Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

AGP 插件与字节码工程

本篇承接 Gradle 基础和构建性能专题, 回答 “如何写构建插件, 如何改 Android 字节码, 为什么插件会拖慢构建, R8 为什么会让线上代码表现不同” 等中高级面试题.

一, Gradle Plugin, Extension 与 Task

三个核心角色

角色负责什么常见错误
Plugin在项目中注册规则, 扩展和任务apply() 中直接执行耗时工作
Extension给构建脚本暴露类型安全配置用普通可变字段绕开 Provider API
Task在执行阶段读取输入并产生输出配置阶段读取文件或访问网络

最小插件模型:

abstract class InterviewExtension {
    abstract val enabled: Property<Boolean>
    abstract val outputDir: DirectoryProperty
}

abstract class GenerateReportTask : DefaultTask() {
    @get:Input
    abstract val enabled: Property<Boolean>

    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty

    @TaskAction
    fun generate() {
        if (!enabled.get()) return
        outputDir.file("report.txt").get().asFile.writeText("generated")
    }
}

class InterviewPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        val extension = project.extensions.create<InterviewExtension>("interview")
        project.tasks.register<GenerateReportTask>("generateInterviewReport") {
            enabled.set(extension.enabled)
            outputDir.set(extension.outputDir)
        }
    }
}

面试重点不是背 DSL, 而是说清:

  • tasks.register 做延迟注册, 避免配置阶段立刻创建所有 Task.
  • Extension 的 Property/Provider 保留惰性依赖关系, 不要过早调用 get().
  • Task 输入和输出必须可声明, Gradle 才能判断是否 up-to-date, 支持增量执行并命中 Build Cache.
  • Task action 中才能做真正的文件处理; 配置阶段不要遍历 APK, 解析 Git 或访问网络.

二, 增量, 缓存与配置缓存

三种 “缓存” 不是一回事

机制跳过什么命中依据
Up-to-date check当前工作区内重复 Task输入, 输出是否变化
Build Cache复用本地或远端 Task 结果可缓存 Task 的输入指纹
Configuration Cache复用配置阶段构建模型构建脚本和配置输入是否兼容且未变化

一个可缓存 Task 需要稳定, 可序列化的输入输出, 不能依赖未声明的环境状态. 时间戳, 随机数, 绝对机器路径, 网络响应和任意全局变量都会破坏可重复性.

增量任务怎么设计

  • 用 @InputFile/@InputFiles/@InputDirectory 声明输入, 并指定合理的路径敏感度.
  • 用 @OutputFile/@OutputDirectory 声明产物.
  • 需要按文件差异处理时使用 Gradle 的增量输入能力, 区分 added, modified, removed.
  • 删除输入时必须同步删除对应输出, 否则增量结果会和 clean build 不一致.
  • 同一输入必须得到等价输出, 才适合远程 Build Cache.

三, Android Variant API

Android 插件会组合 build type, product flavor 等配置生成 Variant. 自定义插件如果要读取或变换 Android 产物, 应在 AGP 提供的 Variant/Artifacts API 上工作.

val androidComponents = project.extensions
    .getByType(AndroidComponentsExtension::class.java)

androidComponents.onVariants { variant ->
    project.logger.lifecycle("configure ${variant.name}")
    // 按 variant 注册任务,连接 artifacts 或配置 instrumentation
}

旧 Transform API 为什么不再推荐

旧 Transform API 允许插件扫描和改写全量 class/jar, 曾被埋点, 路由, 热修复和字节码框架广泛使用. 它的问题是作用域过大, 增量协议复杂, 不同插件容易重复扫描, 并妨碍 AGP 优化流水线.

AGP 7 开始推动迁移, AGP 8 移除旧 Transform API. 现代方案通常是:

  • 用 androidComponents.onVariants 按 Variant 配置行为.
  • 用 Artifacts API 读取或变换 APK, AAB, Manifest, 资源等产物.
  • 用 Instrumentation API/ASM visitor 变换 class.
  • 用 Scoped Artifacts 处理指定范围内的类和 jar, 不要默认扫描整个工程.

回答面试题时应说 “理解旧 Transform 的工作模型, 新代码使用 Variant/Artifacts/Instrumentation API”, 而不是继续给出已移除 API 的实现模板.

四, ASM 字节码插桩

典型使用场景

  • 自动点击埋点和页面耗时监控.
  • Trace section 自动插入.
  • 权限 / API 合规扫描.
  • 禁止调用某些高风险 API.
  • 路由表, 依赖图或调用关系采集.

典型流水线:

class/jar input
  -> ClassReader
  -> ClassVisitor
  -> MethodVisitor / AdviceAdapter
  -> ClassWriter
  -> transformed class output

插桩必须回答的五个问题

  1. 匹配范围: 只处理业务包名还是包含依赖, 如何排除 generated class, Compose, 协程和自身 runtime.
  2. 插入位置: 方法进入 / 退出, 异常路径, 构造方法和 synchronized 方法如何处理.
  3. 栈帧正确性: 局部变量, 操作数栈和 stack map frame 是否由 ASM 正确计算.
  4. 增量与并行: 未变化 class 能否复用, visitor 是否有共享可变状态.
  5. 运行时依赖: 插入的方法调用在宿主中是否一定存在, R8 是否会改名或裁剪.
class TimingMethodVisitor(
    api: Int,
    methodVisitor: MethodVisitor,
    access: Int,
    private val methodName: String,
    descriptor: String,
    private val owner: String,
) : AdviceAdapter(api, methodVisitor, access, methodName, descriptor) {
    override fun onMethodEnter() {
        visitLdcInsn("$owner#$methodName")
        visitMethodInsn(
            INVOKESTATIC,
            "com/example/TraceRuntime",
            "begin",
            "(Ljava/lang/String;)V",
            false,
        )
    }

    override fun onMethodExit(opcode: Int) {
        visitMethodInsn(
            INVOKESTATIC,
            "com/example/TraceRuntime",
            "end",
            "()V",
            false,
        )
    }
}

这段代码只展示访问器结构, 真实项目还要处理异常退出, 构造方法, 重复插桩, runtime 缺失和包名过滤. 插桩失败往往不是 “ASM API 不会用”, 而是边界条件没有定义.

从插件到产物的最小骨架

以下为上下文片段, 展示完整连接顺序, 不保证脱离已配置的 build-logic, AGP 与 ASM 依赖独立编译. 它必须应用在 Android application/library 插件之后, 并固定在已验证的 AGP 版本矩阵中.

abstract class TimingParams : InstrumentationParameters {
    abstract val enabled: Property<Boolean>
}

class TimingPlugin : Plugin<Project> {
    override fun apply(project: Project) = with(project) {
        fun register(pluginId: String) = pluginManager.withPlugin(pluginId) {
            val components = extensions.getByType(AndroidComponentsExtension::class.java)
            components.onVariants { variant ->
                variant.instrumentation.transformClassesWith(
                    TimingVisitorFactory::class.java,
                    InstrumentationScope.PROJECT,
                ) { params -> params.enabled.set(true) }
                variant.instrumentation.setAsmFramesComputationMode(
                    FramesComputationMode.COPY_FRAMES,
                )
            }
        }
        register("com.android.application")
        register("com.android.library")
    }
}

abstract class TimingVisitorFactory : AsmClassVisitorFactory<TimingParams> {
    override fun isInstrumentable(classData: ClassData): Boolean =
        parameters.enabled.get() &&
            classData.className.startsWith("com.example.") &&
            classData.className != "com.example.TraceRuntime"
    override fun createClassVisitor(classContext: ClassContext, next: ClassVisitor): ClassVisitor =
        TimingClassVisitor(next)
}

class TimingClassVisitor(next: ClassVisitor) : ClassVisitor(Opcodes.ASM9, next) {
    private lateinit var owner: String
    override fun visit(
        version: Int, access: Int, name: String, signature: String?, superName: String?, interfaces: Array<out String>?,
    ) {
        owner = name
        super.visit(version, access, name, signature, superName, interfaces)
    }

    override fun visitMethod(
        access: Int, name: String, descriptor: String, signature: String?, exceptions: Array<out String>?,
    ): MethodVisitor {
        val delegate = super.visitMethod(access, name, descriptor, signature, exceptions)
        val isAbstractOrNative = access and (Opcodes.ACC_ABSTRACT or Opcodes.ACC_NATIVE) != 0
        val isSynthetic = access and Opcodes.ACC_SYNTHETIC != 0
        return if (name == "<clinit>" || isAbstractOrNative || isSynthetic) delegate
        else TimingMethodVisitor(Opcodes.ASM9, delegate, access, name, descriptor, owner)
    }
}

TimingClassVisitor.visitMethod 必须把每个被选中的方法连接到 TimingMethodVisitor; 过滤必须同时覆盖 class 包名, runtime 自身, abstract/native/synthetic 方法, 并按业务决定是否跳过 coroutine/Compose 生成类. AdviceAdapter 对构造器的 onMethodEnter 会在合法的 super/this 构造调用之后触发; onMethodExit 可覆盖显式 return 与 ATHROW, 但不能自动覆盖被调用代码抛出的隐式异常. 需要 “所有异常出口都 end” 时, 应在验证过的实现中生成 try/finally 异常处理器, 而不是只复制本片段.

变换类型frame 策略适用和限制
只插入不改变控制流的调用COPY_FRAMES保留输入 frame, 适合本例的线性 enter/exit 调用; 不得新增跳转, handler 或改变局部变量布局.
新增跳转, try/catch/finally 或复杂控制流COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS 或目标 AGP 所支持的更宽计算模式让 AGP/ASM 重算受影响方法的 frame; 构造器, 异常出口和层级解析必须以目标 AGP 的 API/验证结果为准.

注册路径是 Plugin -> AndroidComponents Variant API -> instrumentation -> AsmClassVisitorFactory -> TimingClassVisitor -> TimingMethodVisitor -> variant classes -> DEX/APK/AAB. 本插件分别在 com.android.application 与 com.android.library 应用后注册, 避免假设 library 没有插桩需求. 产物验证至少包括: (1) 对一个明确目标方法执行 release/minified 变体的 instrumentation test 或受控手测;(2) 解包/反编译仅用于确认插入调用存在;(3) 检查插入 runtime 没有被 R8 裁剪;(4) 以对应 mapping retrace 崩溃堆栈. 不要以 debug 成功替代 release 验证.

五, 构建逻辑如何组织

方式优点缺点适用场景
根 build script直接易膨胀, 复用和测试差少量一次性配置
buildSrc自动加入构建 classpath, 上手快改动容易让整个构建逻辑失效重编小型项目
Convention Plugin类型安全, 规则集中, 模块脚本变薄需要设计清晰插件边界中大型多模块项目
Included Build构建逻辑隔离, 可独立缓存和复用初始结构更复杂大型项目或多仓库共享
发布插件版本化, 跨仓库复用发布和兼容成本最高多团队统一工程平台

Convention Plugin 应按职责拆分, 如 android-application, android-library, android-compose, android-feature, 不要再做一个包含所有开关的巨型插件.

六, KAPT, KSP 与编译器插件

机制输入层级适合场景风险
KAPT/APTJava 注解处理模型兼容历史 Java processorKotlin 需要 stub, 构建开销高
KSPKotlin 符号模型Room, 路由, 序列化等代码生成不能任意改写已有函数体
Kotlin Compiler Plugin编译器 IR / 语义阶段深度语言或 Compose 类能力强绑定编译器版本, 维护成本高
ASMJVM class 产物通用字节码扫描和插桩已丢失部分源码语义, 要保证字节码正确

选择原则: 能用普通代码解决就不用生成; 能用 KSP 生成就不进入编译器内部; 只有必须改写已有字节码时才用 ASM/Instrumentation.

七, R8, 混淆与线上边界

R8 的工作不只是改类名:

  • shrink: 移除不可达代码和资源引用.
  • optimize: 内联, 常量传播, 去虚调用等优化.
  • obfuscate: 重命名类, 字段和方法.
  • desugar / 配套编译步骤: 让部分新语言 / API 语法适配目标字节码和设备能力, 具体阶段由工具链版本决定.

必须显式保护或验证的入口:

  • 反射按类名 / 方法名访问.
  • Gson 等按字段名序列化.
  • JNI 静态注册和 native 查找.
  • WebView JSBridge, 路由和插件入口.
  • 由外部配置, Manifest 或服务端下发名称引用的类型.

插桩发生在 R8 之前时, 直接写入 invokestatic com/example/TraceRuntime.begin/end 是 R8 可见的静态调用边, 通常无需因 “插桩” 本身添加 keep. 只有 runtime 通过反射, 外部名称, JNI/Manifest/服务端配置等非静态可见边界进入, 或确实需要保留特定类/成员名称时, 才添加收窄到该边界的 keep. 流水线为 AGP instrumentation -> R8 shrink/optimize/obfuscate -> DEX/APK/AAB, 因此要以 minified 产物验证调用仍存在且行为正确.

以下是上下文片段, 示例仅适用于 runtime 被反射按完整名称调用的边界; 直接 invokestatic 场景不应机械复制该规则.

# Only for a reflection/external-name boundary requiring these names.
-keep class com.example.TraceRuntime { public static void begin(java.lang.String); public static void end(); }

retrace 使用与崩溃 APK/AAB 完全同一 build ID 归档的 mapping.txt, 例如 retrace <mapping.txt> <obfuscated-stacktrace.txt>; 命令可用性随 R8/AGP 发行包变化, 应使用该构建所带工具. R8, retrace 和 release 验证属于本篇; 构建耗时测量请转至 Gradle 构建性能专题, 常规工程 DSL 请转至 Gradle 与工程化.

发布流水线必须归档与 APK/AAB 一一对应的 mapping 和 native symbols. 修复混淆问题后要用 release/minified 变体回归, debug 包通过不能证明 R8 配置正确.

自测 (在已固定兼容矩阵的样例 Android 工程中执行): 为 com.example 下的普通方法, 构造器和抛出异常的方法各写一个受控用例; 构建 application 与 library 的 debug/release 变体. 预期目标普通方法出现一次 TraceRuntime.begin/end 调用, runtime 包和 synthetic 方法没有调用; 线性插桩使用 COPY_FRAMES 可通过字节码校验. 若加入 finally/handler 控制流, 切换到目标 AGP 支持的 compute-frame 模式并验证构造器与异常路径. minified APK 中直接调用的 runtime 仍可达; 仅反射用例需要上述精确 keep, 且 mapping 能 retrace 受控崩溃.

八, 插件性能边界

插件只负责避免自己制造性能问题: 不在 apply() 扫描文件 / 执行网络命令, 声明 Task 输入输出, 收窄 Variant, 模块和包名范围, 避免共享可变状态. 性能测量, profile, Build Scan, configuration cache 报告和优化归因统一见 Gradle 构建性能专题.


九, API 与版本治理

新插件优先使用当前 AGP 的 Variant API 与 Instrumentation API. Transform API 只用于理解存量实现, 不应作为新项目入口. AGP 内部类, task 名和构建目录不是稳定公共 API; 每次升级都要在兼容矩阵中固定 AGP/Gradle/JDK/Kotlin 版本, 对插桩结果做字节码级回归, 并测量 configuration/build cache 命中与增量失效范围.

高频面试题

Q1: Gradle Plugin, Extension 和 Task 分别做什么? Plugin 注册构建规则, Extension 暴露用户配置, Task 在执行阶段消费声明的输入并生成输出. Plugin apply 阶段应保持轻量, 真正工作放在 Task action.

Q2: 为什么推荐 tasks.register 而不是 tasks.create? register 使用配置规避, 只有任务进入执行图或被真正需要时才创建和配置; create 会立即实例化, 多模块项目中会放大配置阶段成本.

Q3: Transform API 为什么被替换? 旧 API 常要求大范围扫描 class/jar, 增量和插件组合成本高. 现代 AGP 用 Variant/Artifacts/Instrumentation API 提供更明确的产物和作用域, 让增量, 缓存和并行优化更可控.

Q4: 写一个 ASM 插桩插件最容易踩什么坑? 重复插桩, 构造/异常路径, stack frame, 全量扫描, 共享状态线程安全, runtime 被 R8 裁剪以及对 Kotlin 协程/Compose 生成类误处理. 必须先定义过滤范围和验证 release 产物.

Q5: KSP 和 ASM 怎么选? KSP 读取源码符号并生成新代码, 类型信息丰富, 适合路由, 序列化和 DI; ASM 处理编译后的 class, 适合修改已有方法或扫描字节码. 能生成新代码时优先 KSP, 必须改原方法才考虑 ASM.

Q6: 如何让自定义 Task 支持 Build Cache? 声明完整, 稳定的输入输出, 保证相同输入得到等价输出, 避免读取未声明环境状态和写入输出目录之外. 再根据任务性质标记可缓存, 用报告确认命中原因.

Q7: 为什么 debug 正常但 release 崩溃? 常见原因是 R8 裁剪或重命名了反射, JNI, 序列化, 路由入口, 也可能是 release 专属资源, 签名或变体配置. 要复现 minified 变体, 用 mapping 还原堆栈并补精确 keep 规则.

易错点 / 追问

  • 不要把 configuration cache, build cache 和 up-to-date 混成一个概念.
  • 不要在插件 apply() 或配置块里执行文件扫描, 网络请求和产物修改.
  • 不要继续把旧 Transform API 当作新项目推荐方案.
  • 不要为了 “自动化” 默认扫描所有依赖和 Variant, 先收窄包名, 模块和构建类型.
  • 插桩和 R8 都会改变最终代码, 必须以 release 产物和 mapping 为准验证.
  • AGP 和 Kotlin 编译器内部 API 兼容性有限, 插件需要版本矩阵, 降级开关和清晰错误信息.