Stories

Detail Return Return

告別手動埋點!Android 無侵入式數據採集方案深度解析 - Stories Detail

作者:路錦(小蘭)

Android 應用數據採集背景

在移動應用開發領域,對應用性能(APM)和用户體驗的實時監控至關重要。傳統的監控方案通常要求開發者在代碼中手動添加和初始化 SDK,並在需要監控的業務邏輯處(如網絡請求、頁面跳轉、用户點擊等)手動調用埋點代碼。

這種方式存在諸多痛點:

  • 侵入性強:監控代碼與業務代碼高度耦合,增加了代碼的複雜度和維護成本。
  • 工作量大:對於龐大的應用,手動埋點耗時耗力,且容易遺漏關鍵的監控點。
  • 難以維護:業務邏輯的頻繁變更可能導致埋點代碼失效或需要同步修改,增加了出錯的風險。
  • 接入成本高:新項目或新團隊成員需要花費時間學習和理解埋點規範。

image

為了解決以上問題,實現監控能力的自動化、全面化和降低接入成本,無侵入式的插樁方案應運而生。其核心目標是在不修改應用源碼的情況下,通過在編譯打包過程中自動注入監控探針,實現對應用行為的全面監控,將開發者從繁瑣的埋點工作中解放出來。

核心挑戰與關注點

在設計和實現一套穩定、高效的無侵入插樁方案時,我們必須面對並解決以下核心挑戰:

image

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,可以像操作對象一樣對類的結構、字段、方法和指令進行精細化的增刪改查。
  • 原理:

image

1.  通過插件註冊一個在編譯構建階段執行的任務。
2.  該任務遍歷項目源碼和所有依賴庫(jar/aar)中的 `.class` 文件。
3.  使用 ASM 的 `ClassReader` 讀取每個類的字節碼。
4.  通過自定義的 `ClassVisitor` 和 `MethodVisitor` 訪問類和方法的結構。
5.  在 `MethodVisitor` 中找到需要注入代碼的目標位置(如方法入口、方法出口、或者某個特定指令前後),並插入新的字節碼指令。
6.  使用 `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 官方推薦的 Instrumentation API 來負責實現新版 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/ac...

user avatar ting_61d6d9790dee8 Avatar definecloud Avatar u_17569005 Avatar pannideniupai Avatar weidejianpan Avatar liudamao Avatar dependon Avatar dtstack Avatar guhejiahongdoumianbao Avatar
Favorites 9 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.