作者:路锦(小蘭)
Android 应用数据采集背景
在移动应用开发领域,对应用性能(APM)和用户体验的实时监控至关重要。传统的监控方案通常要求开发者在代码中手动添加和初始化 SDK,并在需要监控的业务逻辑处(如网络请求、页面跳转、用户点击等)手动调用埋点代码。
这种方式存在诸多痛点:
- 侵入性强:监控代码与业务代码高度耦合,增加了代码的复杂度和维护成本。
- 工作量大:对于庞大的应用,手动埋点耗时耗力,且容易遗漏关键的监控点。
- 难以维护:业务逻辑的频繁变更可能导致埋点代码失效或需要同步修改,增加了出错的风险。
- 接入成本高:新项目或新团队成员需要花费时间学习和理解埋点规范。
为了解决以上问题,实现监控能力的自动化、全面化和降低接入成本,无侵入式的插桩方案应运而生。其核心目标是在不修改应用源码的情况下,通过在编译打包过程中自动注入监控探针,实现对应用行为的全面监控,将开发者从繁琐的埋点工作中解放出来。
核心挑战与关注点
在设计和实现一套稳定、高效的无侵入插桩方案时,我们必须面对并解决以下核心挑战:
1)Android 生态的碎片化挑战
Android 系统的开放性导致其生态存在严重的碎片化问题,这在构建工具层面尤为突出。Android Gradle 插件(AGP)版本迭代迅速,核心编译 API 频繁变更(例如从 Transform API 到 Instrumentation API 的迁移)。插桩方案必须能够动态适配不同的 AGP 版本,否则将无法在开发者的多样化环境中正常工作。
2)第三方插件的兼容性与冲突风险
市面上的 APM 或功能增强插件(如其他监控工具、热修复框架等)大多采用类似的字节码插桩技术。如果我们的插桩方案与其他插件在同一位置修改了同一段代码,极易引发构建错误或运行时冲突。因此,必须设计一套机制来避免“重复插桩”,并尽可能地与其他插件和平共存。
3)插桩代码的健壮性与独立性
通过插件注入到用户代码中的探针必须具备极高的健壮性和独立性。一个常见且致命的问题是:如果用户在项目中应用了插桩插件,但忘记在代码中初始化主 SDK,那么注入的探针代码在调用 SDK 功能时可能会因为依赖未就绪而导致空指针(NullPointerException)等严重崩溃。插桩方案必须保证即使在主 SDK 未启动的情况下,应用也不会崩溃。
Android 无侵入式采集方案探讨
业界主流的无侵入插桩方案主要围绕在编译期对代码进行修改,采集原理均基于 AOP 思想,AOP 的思想主张将“横切关注点”从业务逻辑中抽离出来,独立地封装到一个被称为“切面”(Aspect)的模块中,然后通过声明的方式,告诉程序应该在“什么时机”、“什么地方”去执行这些切面中的逻辑,而不需要去修改业务逻辑的源码。
应用数据采集场景分析
Android 端的无侵入采集种类繁多,但核心思想都是通过自动化手段在不修改业务代码的前提下,捕获应用运行时的各种事件和数据。按照 Android 应用常见的采集场景分类,我们分别探讨每种场景对应的方案选型。
1. 用户行为与页面采集
这类采集的目标是了解用户如何与 App 交互,以及页面的生命周期。
- 页面(Activity/Fragment)生命周期采集
- 技术方案:
- Activity:Application.registerActivityLifecycleCallbacks。
- Fragment:AndroidX 可用生命周期回调;老版 android.app.Fragment 常用字节码插桩在 onResume/onPause/onViewCreated 等方法前后注入。
- 采集数据:页面浏览路径、页面加载时长、PV/UV 统计。
- 用户交互事件(点击、滑动等)
- 技术方案:主流方案是字节码插桩,例如通过 ASM 操作字节码。
- 代理监听器:插桩修改 setOnClickListener 等设置监听器的方法,将其中的监听器替换为一个代理监听器。代理类在执行原始逻辑前后加入采集代码。这种方式可以精确采集到控件信息。
- Hook 方法:通过字节码插桩技术,在编译期直接向处理点击事件的方法中注入采集代码来实现。
- 采集数据:控件点击事件(Action)、控件的标识(ID、文本)、关联页面。
2. 网络请求监控
目标是采集 App 发出的所有 HTTP/HTTPS 请求的性能和成功率。
- 技术方案:同样以字节码插桩为主,针对不同的网络库进行 Hook。
- OkHttp:这是目前最主流的网络库。可以通过插桩 OkHttpClient.Builder.build() 方法,在其中添加一个自定义的拦截器来获取请求的全部信息,并计算请求性能。
- HttpURLConnection:这是 Android 原生的网络请求方式。通常插桩 URL.openConnection() 方法,将其返回的 HttpURLConnection 对象替换为一个代理对象,从而在代理类中监控回调方法,实现数据采集。
- 其他网络库:如 Retrofit 等,它们的底层逻辑通常也是基于OkHttp 或HttpURLConnection。
- 采集数据:URL、请求方法、HTTP 状态码、请求耗时(DNS、TCP、SSL、总耗时等)、请求和响应体大小、TraceID(用于分布式链路追踪)。
3. 应用性能监控
- 应用启动耗时
- 技术方案:
- 冷启动、热启动:通常通过 Android API 采集。
- UI 卡顿与长任务
- 技术方案:
- Looper 监控:通过 Looper.getMainLooper().setMessageLogging() 设置一个自定义的 Printer,可以监控到主线程 Looper 处理每个 Message 的开始和结束。如果处理单个 Message 耗时过长,即可判定为一次卡顿或长任务,并抓取主线程堆栈。
- ANR (Application Not Responding)
- 技术方案:通用做法是启动一个独立的“看门狗”线程,该线程定期向主线程的 Looper 发送一个任务。如果在规定时间(如 4-5 秒)内该任务没有被执行,就认为主线程被阻塞,此时“看门狗”线程会抓取主线程的堆栈信息,作为 ANR 日志上报。
4. 崩溃监控
- Java/Kotlin 崩溃
- 技术方案:使用 Thread.setDefaultUncaughtExceptionHandler() 设置一个全局的未捕获异常处理器。当应用崩溃时,这个处理器会被调用,SDK 可以在这里捕获异常信息、堆栈、线程状态等,保存后上报。
- Native (C/C++) 崩溃
- 技术方案:通过 JNI 实现。使用 Linux 的 Signal 信号处理机制,注册对 SIGSEGV, SIGABRT, SIGILL 等致命信号的监听。当 Native 代码崩溃触发这些信号时,信号处理器被回调。在处理器中,可以记录下崩溃现场保存为文件,待下次 App 启动时上报。
5. WebView监控
- 技术方案:核心是 JS 探针注入。
- 通过字节码插桩 Hook WebView 的相关方法,插入 JS 采集探针实现采集。
小结:根据采集场景分析,我们发现在 Android 无侵入式采集中,最需要关注的技术是字节码插桩。
字节码插桩技术
- 技术介绍:这是目前 Android 领域最主流和最强大的无侵入技术。它利用 Android Gradle 插件(AGP)在编译过程中提供的 API(如 Transform API 或新的 Instrumentation API),在
.class文件被编译成.dex文件之前,对其字节码进行扫描和修改。其中,ASM 是一个高性能、轻量级的 Java 字节码操作和分析框架。它提供了丰富的 API,可以像操作对象一样对类的结构、字段、方法和指令进行精细化的增删改查。 - 原理:
- 通过插件注册一个在编译构建阶段执行的任务。
- 该任务遍历项目源码和所有依赖库(jar/aar)中的
.class文件。 - 使用 ASM 的
ClassReader读取每个类的字节码。 - 通过自定义的
ClassVisitor和MethodVisitor访问类和方法的结构。 - 在
MethodVisitor中找到需要注入代码的目标位置(如方法入口、方法出口、或者某个特定指令前后),并插入新的字节码指令。 - 使用
ClassWriter将修改后的字节码写回,替换原文件。
- 优点:控制粒度最细,功能最为强大,几乎可以实现任意逻辑的注入。性能开销极低,是实现高性能监控 SDK 的首选方案。
- 构建 API 演进与兼容
- Transform API:AGP 8 起移除。
- Instrumentation API:AGP 7 引入,AGP 8 强烈推荐且基本强制使用。
- 插件需动态选择:优先使用 Instrumentation API;旧环境降级到 Transform。
无侵入式插桩方案实践
基于上述Android无侵入式采集方案分析,我们这里使用字节码插桩技术,以点击行为采集场景为例,进行一次完整的无侵入式采集方案实践。
核心思想
章节一中我们提到了无侵入式采集需要面临的挑战,这里我们整理了以下三个核心思想,来解决上述问题。
AGP 版本动态适配策略
为了适应 Android Gradle 插件的快速迭代,在插件开发中需要对新旧版本的 AGP 做兼容处理,以不同版本的AGP兼容性特点为例:
- 老版本AGP (Legacy):插件会使用 AGP 旧版的
TransformAPI 来处理字节码的转换逻辑。 - AGP 7+:插件会采用 Google 官方推荐的
InstrumentationAPI 来负责实现新版 API 的对接,这种方式更高效、更稳定。
在插件入口时可以在运行时动态检测 AGP 版本,并选择相应的实现,对开发者完全透明。这里我们提供一个基于不兼容 API 的适配策略。
MyApmPluginpublic class MyApmPlugin implements Plugin<Project> { @Override public void apply(Project project) { boolean hasAsmFactory = classExists("com.android.build.api.instrumentation.AsmClassVisitorFactory"); boolean hasTransform = classExists("com.android.build.api.transform.Transform"); if (hasAsmFactory) { // AGP 7+,使用 Instrumentation API(推荐) new Agp7PlusImpl(project).init(); } else if (hasTransform) { // 老版本,使用 Transform API new LegacyTransformImpl(project).init(); } else { project.getLogger().warn("No supported AGP API found. Plugin disabled."); } } }
兼容性设计,避免插件冲突
为了最大限度地兼容第三方插件并防止冲突,我们提供了以下方案:
- 黑名单:跳过系统包、常见 APM/热修复/加固框架、自身 SDK。
- 白名单:可选,只处理应用/业务相关包,最大程度降低误伤与冲突。
- 幂等插桩:避免重复注入,例如 tag 标记、instanceOf 判断。
- 注解式控制:可选,支持 @NoTrack、@TrackIgnore 注解,编译期间扫描后跳过特定类/方法,给业务兜底控制权。
- 插桩失败回退:单类插桩失败时,记录日志并回退为原始字节码,构建继续。
这里是一个黑名单过滤样例代码:
public class ClassInstrumentChecker { private static final List<String> BLACKLISTED_PREFIXES = Arrays.asList( // 常见系统库、协程等应该避免插桩 "java/", "javax/", "kotlin/", "kotlinx/", "android/", "androidx/", "com/my/apm/sdk/" // 自身 SDK,避免递归处理 // 其他 APM 或性能监控产品等 "com/networkbench/", "com/sensorsdata/", "com/tencent/qapmsdk/", // 常见热修复或加固框架 "com/tencent/tinker/", "com/taobao/sophix/", // 自身 SDK,避免重复处理 "com/my/apm/sdk/" ); /** * 检查一个类是否应该被插桩。 * @param className 类的名称 (e.g., "com/example/myapp/MyClass") * @return 如果应该被插桩,返回 true;否则返回 false。 */ public static boolean shouldInstrument(String className) { // 排除 R 文件和 BuildConfig if (className.contains("/R$") || className.endsWith("/R") || className.endsWith("/BuildConfig")) { return false; } for (String prefix : BLACKLISTED_PREFIXES) { if (className.startsWith(prefix)) { return false; // 命中黑名单,跳过 } } return true; // 未命中,可以插桩 } }
安全插桩,确保代码独立与稳定
这是保障方案健壮性的核心。我们秉持“最少侵入”和“绝对安全”的原则,确保注入的代码稳定且无副作用。
- 不替换原生逻辑:我们的插桩始终是在原生方法逻辑的“之前”或“之后”进行补充,而不是替换。例如,在监听网络请求时,我们会先调用 SDK 的追踪方法,然后通过
super.visitMethodInsn()继续执行原生的网络调用指令,保证应用原有功能不受任何影响。 - 不引入第三方依赖:注入的字节码指令极其精简,仅包含对我们自身 SDK 中特定静态方法的调用(如
TrackInstrument.trackViewOnClick(...)),不引入任何新的外部库依赖,保持了插桩点的纯净性。 - 探针代码独立运行与异常隔离:这是解决“SDK 未初始化”问题的关键。所有被注入的探针最终调用的 SDK 工具类(如
TrackInstrument)内部都遵循了严格的防御性编程。在该工具类的入口处,会首先检查主 SDK 是否已成功初始化。插桩部分发生异常时不影响原始业务逻辑,未初始化时所有插桩代码会立即静默返回,不执行任何实质性操作。确保了即使在极端情况下注入的探针也不会引发任何崩溃。 - Kotlin 与 Jetpack Compose 插桩补充:
- 内联函数 (
inline):inline函数体在编译期被直接复制到调用处,可能改变最终方法布局与调用栈,插桩目标应是其内部调用的非内联方法,而非inline函数本身。 - Jetpack Compose 点击事件 (
Modifier.clickable):通常通过 Modifier.clickable 实现,需另行在 Compose Runtime 层或特定包装函数处插桩(或在 UI Toolkit 层提供可选的轻量扩展,而非硬插桩)。 - Lambda 表达式的实现差异:Lambda 的字节码实现方式不唯一。为兼容低版本安卓,编译器可能将其“脱糖” (Desugar) 为匿名内部类,而非现代的
invokedynamic实现。两种模式生成的方法签名(类名、方法名、是否静态)完全不同,插桩方案必须兼容这两种情况,以防因签名不匹配而失效。
这里提供一个插桩方法样例,给 OnClick 事件插入我们需要的日志采集代码。
public class OnClickMethodVisitor extends AdviceAdapter { public OnClickMethodVisitor(MethodVisitor mv, int access, String name, String desc) { super(Opcodes.ASM9, mv, access, name, desc); } @Override protected void onMethodEnter() { super.onMethodEnter(); // 在 onClick(Landroid/view/View;)V 方法的入口处插入代码 mv.visitVarInsn(Opcodes.ALOAD, 1); // 加载第一个参数 (View 对象) 到操作数栈 // 调用我们自己的静态工具方法来处理点击事件 mv.visitMethodInsn( Opcodes.INVOKESTATIC, // 静态方法调用 "com/my/apm/sdk/TrackInstrument", // 包含追踪方法的类 "trackViewOnClick", // 方法名 "(Landroid/view/View;)V", // 方法描述符 false // 非接口方法 ); } }
public class TrackInstrument { public static void trackViewOnClick(View view) { try { // 核心安全设计:检查 SDK 是否已初始化 if (!MyApmAgent.isInitialized() || view == null) { return; // 如果未初始化,则静默返回,不执行任何操作 } // 如果已初始化,则执行正常的事件采集逻辑 String viewId = getViewId(view); MyApmAgent.get().logUserAction("click", viewId); } catch (Throwable ignored) { // 完全隔离异常,避免影响业务 } } }
插桩实践
结合上述核心思想,我们完整地串联起一个 onClick 点击数据的采集方案。方案的核心是一个自定义的 Gradle 插件,当 Android 应用集成此插件后,它会自动注入到应用的构建流程中,在编译期完成代码的自动化注入。
无论使用哪种 AGP API,核心的字节码修改逻辑都由 ASM 库驱动,整体流程如下:
1. 遍历 Class 文件
插件在执行时会获取到项目中的所有 .class 文件,包括源码编译的类和第三方库中的类。
// 代码样例: 在 Gradle Transform 中遍历输入文件 @Override public void transform(TransformInvocation transformInvocation) { Collection<TransformInput> inputs = transformInvocation.getInputs(); TransformOutputProvider outputProvider = transformInvocation.getOutputProvider(); for (TransformInput input : inputs) { // 遍历 Jar 包 for (JarInput jarInput : input.getJarInputs()) { File srcJar = jarInput.getFile(); File destJar = outputProvider.getContentLocation(...); processJar(srcJar, destJar); } // 遍历目录 for (DirectoryInput directoryInput : input.getDirectoryInputs()) { File srcDir = directoryInput.getFile(); File destDir = outputProvider.getContentLocation(...); processDirectory(srcDir, destDir); } } }
2. ASM 分析与修改
- 每个类文件都会被
ClassAdapter访问。该类是一个ClassVisitor,它会首先进行过滤,通过isClassShouldInstrument方法中的黑名单,跳过对系统库、其他 APM 产品、SDK 自身以及一些已知会产生冲突的第三方库的插桩,以保证稳定性和兼容性。 - 对于需要处理的类,核心插桩任务交由
MyMethodAdapter(一个AdviceAdapter的子类)完成。它会遍历类中的每一个方法。
// 代码样例: ClassVisitor 和 MethodVisitor 的责任链 public class MyClassAdapter extends ClassVisitor { private String className; public MyClassAdapter(ClassVisitor classVisitor) { super(Opcodes.ASM9, classVisitor); } @Override public void visit(int version, int access, String name, ...) { super.visit(version, access, name, ...); this.className = name; } @Override public MethodVisitor visitMethod(int access, String name, String descriptor, ...) { MethodVisitor mv = super.visitMethod(access, name, descriptor, ...); // 检查此类是否在黑名单中 if (!ClassInstrumentChecker.shouldInstrument(className)) { return mv; // 在黑名单中,返回原始 MethodVisitor,不处理 } // 如果需要处理,则返回我们自定义的 MethodVisitor return new MyMethodAdapter(mv, access, name, descriptor); } }
3. 采集探针注入 (Hook)
MyMethodAdapter的onMethodEnter方法会在方法体的最开始处插入代码。MyHookConfig.java文件中预定义了所有需要 Hook 的目标方法,例如点击行为的onClick方法等。- 当
MyMethodAdapter访问到这些目标方法时,就会在方法开头插入对TrackInstrument中对应追踪方法的调用(如trackViewOnClick),从而实现对页面生命周期、用户点击、菜单选择等事件的自动采集。 - Lambda 表达式处理:通过
visitInvokeDynamicInsn指令对 Java 8 的 Lambda 表达式进行了特殊处理,能够准确识别出作为监听器实现的 Lambda 表达式(如view.setOnClickListener(v -> ...)),并对其进行正确的插桩。
public class OnClickAdviceAdapter extends AdviceAdapter { protected OnClickAdviceAdapter(MethodVisitor mv, int access, String name, String desc) { super(Opcodes.ASM9, mv, access, name, desc); } @Override protected void onMethodEnter() { // 加载参数 View(index=1) visitVarInsn(ALOAD, 1); // 调用静态方法 TrackInstrument.trackViewOnClick(View) visitMethodInsn(INVOKESTATIC, "com/my/apm/sdk/TrackInstrument", "trackViewOnClick", "(Landroid/view/View;)V", false); } }
// 代码样例: MethodVisitor 的实现, 包含 Lambda 处理 public class MyMethodAdapter extends AdviceAdapter { private final String methodNameDesc; private final String className; public MyMethodAdapter(MethodVisitor mv, int access, String name, String desc, String className) { super(Opcodes.ASM9, mv, access, name, desc); this.methodNameDesc = name + desc; this.className = className; } @Override protected void onMethodEnter() { super.onMethodEnter(); // 检查当前方法是否为普通方法或已被标记的 Lambda 方法 MyHookConfig.HookCell hookCell = MyHookConfig.HOOK_METHODS.get(methodNameDesc); if (hookCell == null) { hookCell = MyHookConfig.LAMBDA_METHODS_TO_HOOK.get(methodNameDesc); } if (hookCell != null) { // 注入探针代码... (此部分逻辑见核心思想中的样例) } } @Override public void visitInvokeDynamicInsn(String name, String descriptor, Handle bsm, Object... bsmArgs) { super.visitInvokeDynamicInsn(name, descriptor, bsm, bsmArgs); try { // 检查是否为我们关心的 Lambda 表达式, 例如 OnClickListener String samMethodDesc = ((Type) bsmArgs[0]).getDescriptor(); if ("(Landroid/view/View;)V".equals(samMethodDesc)) { // 获取 Lambda 的方法体实现信息 Handle implMethodHandle = (Handle) bsmArgs[1]; String lambdaBodySignature = implMethodHandle.getName() + implMethodHandle.getDesc(); // 将该 Lambda 的方法体标记为需要插桩,值为 onClick 的 HookCell MyHookConfig.LAMBDA_METHODS_TO_HOOK.put(lambdaBodySignature, MyHookConfig.HOOK_METHODS.get("onClick(Landroid/view/View;)V")); } } catch (Exception e) { // ignore } } }
// 代码样例: Hook 配置类 public class MyHookConfig { public static final Map<String, HookCell> HOOK_METHODS = new HashMap<>(); // 用于存储被识别出的、需要被插桩的 Lambda 方法体 public static final Map<String, HookCell> LAMBDA_METHODS_TO_HOOK = new ConcurrentHashMap<>(); static { HOOK_METHODS.put("onClick(Landroid/view/View;)V", new HookCell("trackViewOnClick", "(Landroid/view/View;)V")); } // ... 其他配置 }
4. 生成新类
所有修改完成后,ClassWriter 会生成新的字节码,替换原有的 .class 文件。这些被注入了监控探针的类文件最终会被打包进 APK 中,在应用运行时自动执行监控逻辑。
// 代码样例: ASM 生成新字节码的核心逻辑 public byte[] processClass(InputStream classInputStream) throws IOException { ClassReader classReader = new ClassReader(classInputStream); // ClassWriter 会在责任链的末端,负责将所有修改写入字节码 ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS); // 启动访问者链,MyClassAdapter 是我们自定义的访问器 classReader.accept(new MyClassAdapter(classWriter), ClassReader.EXPAND_FRAMES); // 返回包含所有修改的新字节码 return classWriter.toByteArray(); }
总结
本文通过探讨 Gradle 插件 + AGP API + ASM 字节码插桩的插桩方案,实现了一套对业务代码零侵入、易于集成、可安全运行的自动化监控采集方案。阿里云 RUM 针对 Android 端实现了对应用性能、稳定性、和用户行为的无侵入式采集 SDK。可以参考接入文档[1]体验使用。相关问题可以加入“RUM 用户体验监控支持群”(钉钉群号:67370002064)进行咨询。
相关链接:
[1] 接入文档
https://help.aliyun.com/zh/arms/user-experience-monitoring/access-to-android-applications