Android App Trace 演进之路

1,701 阅读7分钟

TraceView

TraceView 是早期 Android SDK 中内置的一个工具,它可以加载 trace 文件,用图形的形式展示代码的执行时间、次数及调用栈,便于我们分析。底层实现是基于ART插桩实现的,只能监控 Java函数调用,并且性能损耗较大,开启后使用起来会比较卡

Debug.startMethodTracing(""); 
Debug.stopMethodTracing(); 

使用代码很简单,当你调用开始代码的时候,系统会生产 trace 文件,并且产生追踪数据,当你调用结束代码时,会将追踪数据写入到 trace 文件中。性能开销大的一个很大原因是ART虚拟机对每个方法执行的时候都需要额外调用下面这种回调处理,这里会包含堆栈获取和匹配和buffer写入的逻辑;故而android 后面也废弃了 TraceView 的方案,取而代之的则是Systrace方案。

 if (UNLIKELY(instrumentation->HasMethodEntryListeners())) {
      instrumentation->MethodEnterEvent(self,
                                        shadow_frame.GetThisObject(accessor.InsSize()),
                                        method,
                                        0);
      if (UNLIKELY(shadow_frame.GetForcePopFrame())) {
        // The caller will retry this invoke. Just return immediately without any value.
        DCHECK(Runtime::Current()->AreNonStandardExitsEnabled());
        DCHECK(PrevFrameWillRetry(self, shadow_frame));
        return JValue();
      }
      if (UNLIKELY(self->IsExceptionPending())) {
        instrumentation->MethodUnwindEvent(self,
                                           shadow_frame.GetThisObject(accessor.InsSize()),
                                           method,
                                           0);
        return JValue();
      }
    }

Systrace & Perfetto

Systrace 是 Android4.1 中新增的性能数据采样和分析工具。它可帮助开发者收集 Android 关键子系统(如 SurfaceFlinger/SystemServer/Kernel/Input/Display 等 Framework 部分关键模块、服务,View系统等)的运行信息,从而帮助开发者更直观的分析系统瓶颈,改进性能。

最新版本的 platform-tools 里面已经移除了 Systrace 工具,Google 推荐使用 Perfetto 来抓 Trace。Perfetto 相比 Systrace 最大的改进是可以支持长时间数据抓取,这是得益于它有一个可在后台运行的服务,通过它实现了对收集上来的数据进行 Protobuf 的编码并存盘。从数据来源来看,核心原理与 Systrace 是一致的,也都是基于 Linux 内核的 Ftrace 机制实现了用户空间与内核空间关键事件的记录(ATRACE、CPU 调度)。

APP层可以通过在函数前后写入下面这些代码来将其加入 TracePoint 中(本质是 Ftrace 信息)。

Trace.beginSection
Trace.endSection

下面是网上找的到Systrace的实现图

Transfrom插桩

通常我们通过 Systrace 排查问题的时候,事先是不知道哪里的代码有问题的,系统也只在一些关键代码点加入了少量的 TracePoint 信息,那么在实际操作过程中,就需要我们手动给可疑代码添加Trace.beginSection("")Trace.endSection() 的形式来让其加入 TracePoint ,这个对人工来说也是一个巨大的体力活。所以很自然的会希望这一部分能够尽量的自动化。

好在 android 对这一块的支持比较全面,可以通过编译时插桩和运行时织入代码的形式都可以实现这个需求。

先介绍下编译时插桩的一种实现,这里是通过Transfrom阶段 ASM 插桩实现 Trace 注入的主要代码

class SourceFileClassVisitor(private val context: Context) : BaseClassVisitor() {
    private var className: String? = null
    private var isNotNeedTraceClass = false
    private val beatClass = context.extension.beatClass

    /**
* 访问类
*
*  @param  version
*  @param  access
*  @param  name
*  @param  signature
*  @param  superName
*  @param  interfaces
*/
override fun visit(version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array<out String>?) {
        super.visit(version, access, name, signature, superName, interfaces)
        if (name == null) {
            isNotNeedTraceClass = true
            return
        }
        this.className = name
        //抽象方法或者接口
        if (access and Opcodes.ACC_ABSTRACT > 0 || access and Opcodes.ACC_INTERFACE > 0) {
            isNotNeedTraceClass = true
            return
        }
    }

    /**
* 访问方法
*
*  @param  access
*  @param  name
*  @param  desc
*  @param  signature
*  @param  exceptions
*  @return
 */
override fun visitMethod(
        access: Int,
        name: String,
        desc: String,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        val mv = super.visitMethod(access, name, desc, signature, exceptions)
        if (isNotNeedTraceClass) {
            return mv
        }
        val traceMethod = TraceMethod(
            accessFlag = access,
            className = className,
            methodName = name,
            desc = desc
        )
        val key = traceMethod.generatorMethodName(null)
        var needIgnore = false
        if (!context.extension.traceConstructor) {
            needIgnore = traceMethod.isConstructor()
        }
        return object : MethodVisitor(Constants.ASM_API, mv) {
            /**
* 访问方法中每一条指令。
*
*  @param  opcode
*  @param  owner
*  @param  name
*  @param  descriptor
*  @param  isInterface
*/
override fun visitMethodInsn(opcode: Int, owner: String, name: String, descriptor: String, isInterface: Boolean) {
                val isConstructor = name == "<init>" || name == "<clinit>"
                // 过滤构造函数本身
                if (isConstructor) {
                    return super.visitMethodInsn(
                        opcode,
                        owner,
                        name,
                        descriptor,
                        isInterface
                    )
                }
                // 过滤kt编译器生成的方法
                if (owner.startsWith("kotlin/jvm")) {
                    return super.visitMethodInsn(
                        opcode,
                        owner,
                        name,
                        descriptor,
                        isInterface
                    )
                }
                // 过滤编译器生成的桥接方法
                if (name.contains("access$")) {
                    return super.visitMethodInsn(
                        opcode,
                        owner,
                        name,
                        descriptor,
                        isInterface
                    )
                }
                // 过滤掉函数体内的方法调用
                if (context.extension.flatMethodInsn) {
                    return super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)
                }
                val inKey = traceMethod.generatorMethodName(name)
                traceStart(mv, inKey)
                super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)
                traceEnd(mv, inKey)
            }

            override fun visitCode() {
                super.visitCode()
                // 方法进入的时候打点
                if (needIgnore) {
                    return
                }
                traceStart(mv, key)
            }

            override fun visitInsn(opcode: Int) {
                if (needIgnore) {
                    return super.visitInsn(opcode)
                }
                if (opcode == Opcodes.RETURN
|| opcode == Opcodes.IRETURN
|| opcode == Opcodes.LRETURN
|| opcode == Opcodes.FRETURN
|| opcode == Opcodes.DRETURN
|| opcode == Opcodes.ARETURN
|| opcode == Opcodes.ATHROW) {
                    // 方法退出的时候打点  与 visitCode 配合起效
                    traceEnd(mv, key)
                }
                super.visitInsn(opcode)
            }
        }
    }

    private fun traceStart(mv: MethodVisitor, key: String) {
        mv.visitLdcInsn(key)
        mv.visitMethodInsn(
            AdviceAdapter.INVOKESTATIC,
            beatClass,
            "start",
            "(Ljava/lang/String;)V",
            false
        )
    }

    private fun traceEnd(mv: MethodVisitor, key: String) {
        mv.visitLdcInsn(key)
        mv.visitMethodInsn(
            AdviceAdapter.INVOKESTATIC,
            beatClass,
            "end",
            "(Ljava/lang/String;)V",
            false
        )
    }
}

有了插件,那么需要改变插桩范围的时候,只需要修改插件配置重新编译即可在生成的 DEX 中自动植入trace 信息,由于 Transfrom 执行在R8 之前,所以对于 release 包的性能分析也可以很好的支持。同时,这种方案也有其局限性

  1. 只能对APK中的代码插桩,无法观测到 framework 中的代码执行情况
  2. 只能对 java 代码插桩,无法感知 so 函数运行情况
  3. 依赖编译,对于编译耗时较长的项目,编译成本也不低

Hook ART解释器插桩

可不可以和TraceView 一样在虚拟机层面进行干预,做到运行时动态插桩呢?

通过 native hook 技术是有办法的。由于 java 方法的执行离不开虚拟机,通过hook hook art 虚拟机解释器,给函数植入 TracePoint 即可。 Art 虚拟机的执行包括解释执行和快速执行两种模式,关于Art的解释执行,在 ART 虚拟机的解释执行 一文有介绍。单对于 Trace 插桩这个能力来说,大部分的使用场景还是在线下查问题和进行性能优化的使用下更为高频一点,那么只需要hook 解释执行其实就能满足这个需求,不需要关注机器码和JIT的复杂处理过程,也使得工具的稳定性更为可靠。

  1. 首先hook系统的 ATrace 实现,拿到 beginSection 和 endSection 的函数地址,并且保存到 内存

  1. Hook switch 解释器的函数实现

  1. 在 hook 的代理函数中调用 beginSection 和 endSection,将自己需要的函数签名信息作为参数传递进去

  1. Hook Mterp 解释器的函数实现

  1. 对于android 13+设备,hook 前置判断函数,禁用掉Nterp解释器,使方法走到switch解释器

  1. 或者 hook Nterp 解释器的方法调用函数,实践下来比降级到switch解释器表现更卡一点,故而默认采样的是降级到switch 函数的实现

优势:

  • 使用简单,可动态开启和关闭,无需编译即可调整插桩范围
  • 几乎可以观测本进程内全部java方法的运行状态,包括framework层的代码

缺点:

  • ART虚拟机每个版本代码改动都挺大,每当有版本发布可能需要进行适配
  • 高版本降级到switch 后或 hook Nterp 函数后,在不过滤任何方法的情况下,执行效率会慢不少
  • 无法覆盖到 so 函数调用

该部分代码已经提交到github上了 传送门

Native so Trace

前面提到的都是对java函数的ftrace能力,对于存在大量so的apk来说,native 层代码也想要有这样一个灵活的trace能力。

针对单个so,目前其实完全可以按照 Hook ART 方式来对so中的函数一个个进行hook,只是这个过程会比较繁琐和痛苦。我还是希望有一个无侵入的,对native代码也能一键全插桩的能力,并且这个能力是app层可用,设备无需root

目前这方面能力还在建设中,目前仅有初步的思路,各位大佬如果有更好的思路和实现,欢迎在评论区共同探讨。

  • Hook ART JNI So linker 阶段函数,开一个线程解析so的符号表,并且将每个符号表进行hook 。
  • Debug 包内置 frida ,借助frida 的能力对 native 方法插 trace。