日常修改,3秒生效:腾讯音乐 Android 秒编方案 Jugg 开源

本文首发自公众号:腾讯音乐技术团队

5 年研发和内部使用,累积 80 万次生产工程编译验证,让大型 Android 工程日常编译不再痛苦。

在大型 Android 工程的日常开发中,"等待编译" 往往是吞噬开发体验与效率的最大杀手。多年迭代使得工程日益臃肿,构建速度逐渐劣化 ------ 有时仅改动一两行代码,就要面对几分钟的等待。这种被频繁打断的开发体验,成为越来越多开发者的痛点。

为解决这一痛点,腾讯音乐技术团队经过多年研发和内部使用,正式开源 Jugg ------ 专为大规模 Android 工程打造的秒级增量构建方案。 Jugg 名字来源于 Dota 的剑圣,我们希望它能像剑圣一样砍掉漫长的构建,实现敏捷的秒级响应。

通过绕开已繁重复杂的 Gradle 执行链,Jugg 使用旁路构建、热重载、资源编译定制等技术,将日常增量修改提速到 3 秒内即可看到效果。从几分钟到 "即改即现",Jugg 真实改变了 Android 开发同学的日常开发体验。

Jugg 于 2021 年开始研发,2023 年内部发布,已投入全民 K 歌、QQ 音乐、JOOX、We​Sing、酷狗音乐、酷狗直播、QQ 浏览器、央视频等工程的日常开发。累计编译 80 万+ 次,累计节省编译等待 36,000+ 小时,相当于约 20 人年的研发工时。

以下是 Jugg 在大型工程上增量编译演示:Android 增量编译插件「Jugg」效果展示

Jugg 目前已在 Github 开源。 欢迎大家前往 github.com/tencentmusi... 下载试用、star、反馈 issue、加用户群。所有明确问题都会跟进解决。

0. 快速接入

Jugg 以 Android Studio 插件形式提供。 安装即用、无侵入,不需要修改工程的任何文件。安装后支持 AS、命令行、云编译等方式使用。

安装与使用仅需 3 步:

  1. 前往 github.com/tencentmusi... 下载最新插件。

  2. 安装至 Android Studio。安装后无需任何手动配置,Jugg 会解析项目信息,自动生成配置。

  3. 选择 Jugg Run Configuration 点击运行即可,与正常编译运行一致。

除了安装使用方便,Jugg 还拥有宽泛的工程和能力兼容。 所有接入工程共用一套实现,不含任何业务定制逻辑------你的工程无需专项适配即可使用。Jugg 已在内部多年迭代中完成对主流开发环境覆盖:

未提及版本可能存在少量兼容性问题,如遇到请前往 Github 提 issue 解决。

1. 方案选型:秒级速度背后的技术路径探索

看完演示视频,大家可能会好奇,这近乎 "魔法" 的编译和部署速度是如何实现的?它和目前已有的编译优化方案有什么相似和区别?

业界对比:不同技术路线的速度上限

业界已有不少各种类型的增量编译方案,但都可以总结为在改造成本与性能上限之间做权衡

  • 离 Gradle 越近:改动越小,收益的性价比(ROI)越高,甚至只需要修改极少流程就能获得 30% 甚至特定场景下 50% 的提升,但速度上限受限于 AGP 原有编译流程本身;

  • 离 Gradle 越远:需要付出的研发和兼容成本越高,但能更多的打掉框架带来的固定开销,性能上限也更高。

沿着这个视角,我们可以看到不同的方案有不同的思路和实际收益:

  • 对 AGP 特定 Gradle task 进行执行优化 :基于 Gradle task 的缓存命中机制进行调优,提高命中率;或者针对性对 compileDebugKotlin 等特定的长耗时 task 做有损正确性的缓存命中,都属于这一范畴。这些方案的优点是维护成本低,影响可控,但收益上限低。

  • Instant Run 是 Android 官方较早的一次对 偏离 AGP 原有构建体系 的探索。它会在首次构建时替换 Application、对方法入口插桩、植入与 IDE 通信的 AppServer,并将 APK 拆分;后续只编译变化内容,再根据修改类型选择立即生效、重启 Activity 或更新部分 APK。它带来了接近"修改后立即生效"的体验,但编译产物、运行时和部署流程都被改变,复杂工程中收益有限,且在兼容性上出现了"令人挠头"的问题,最终被官方废弃

  • Apply Changes 是官方吸取了 Instant Run 的经验,选择主动收缩边界的方案,目前依然在 Android Studio 可用。它不再改造编译过程和 APK 构建,而是保持通过 Gradle 生成完整产物,再由 Android Studio 找出变化的 class 和资源,通过 JVMTI 更新到正在运行的 App。方案不再对构建流程做任何侵入,但只优化了部署,前面的耗时大头 - Gradle 编译时间仍然存在。

  • 还有一些内部研发的增量方案 也在尝试将日常修改从标准 AGP 构建流程中剥离。它们先通过一次完整 Gradle 构建建立 APK、classpath 和已有编译产物,后续识别变化文件时,调用 javackotlincD8aapt2 等工具生成增量产物,再通过 热修复split APK 等方式部署到设备。部分方案还会依据预设规则或字节码关系扩大编译范围,将受影响源码重新加入编译。

    这类方案已经大幅减少了实际编译量,但增量流程仍主要依附 Gradle 插件运行,每次运行都需要承担 Gradle 启动和 Configuration 的固定开销。在大型工程中,这部分耗时不会随着修改减少而消失,而成为了增量编译的方案上限,无法满足我们希望的 少量修改在几秒内生效 的目标。

这些方案没有绝对的优劣,只是选择了不同的速度上限和能力边界。但对于 Jugg 希望实现的 "秒级生效",方向已经明确:保留 Gradle 提供的可信构建结果,但让日常修改彻底脱离 Gradle 的固定开销。

Jugg 的解法:基于 Gradle 产物实现独立旁路增量编译

Gradle 的核心目标是保证整个工程构建的绝对正确性。 但在大型工程中,Gradle 启动、Configuration 、以及 Task 编排等固定开销无法避免。这决定了只要还在 Gradle Runtime 内部,就无法将耗时压缩至几秒之内。

要获得理论上最快的速度,可以引用最近比较流行的第一性原理的概念,也就是:我只修改一行代码,完成一次最基础的代码修改与设备生效,最少需要哪几个步骤?

抛开所有框架约束,真正不可省略的步骤只有三个:

  1. 找出本轮变化源码文件;

  2. 把这些文件编译成可部署的增量产物;

  3. 让增量产物在当前设备上生效。

基于这个判断,Jugg 最终选择了这条最短路线:在一次 Gradle 编译完成后,后续的源码修改,脱离 Gradle,走独立的增量编译旁路分支。修改 build.gradle 等影响构建结果的文件后,有条件的回退一次 Gradle 编译。

但把日常增量路径移出 Gradle Runtime,原本由 Gradle 和 AGP 完成的事情,现在则需要 Jugg 自己构建完整的必要环境,包括:

  • 解析维护工程上下文,读取项目模型与编译参数;

  • 分析跨文件的调用、继承、常量内联等编译影响范围;

  • 适配 Compose,KMP,Data​Binding 等非常规源码编译;

  • 针对不同设备与系统版本进行 API 兼容。

Jugg 的速度收益,来自跳过与本轮修改无关的工作。 离开 Gradle 后,变化识别、影响传播、增量编译和设备部署,仍然需要在旁路中重新组成一条完整链路。

2. 速览:一次 Jugg 增量编译 Run 如何完成

一次 Jugg Run,就是这条链路从可信基线到设备生效的一次完整闭环。它只处理本轮变化及其真实影响,并在部署成功后提交新的状态,作为下一次运行的起点。执行流程如图:

01. 复用基线
  • 做什么:Gradle 编译完成后,Jugg 复用最近一次全量 Gradle 构建留下的 APK、class、生成源码等中间产物,作为增量编译的基线。
02. 识别变化
  • 做什么:监听本轮改动的文件。监听有两个来源,IDE 文件变化回调和 Git 变更查询。

  • 分支保护 :如果检测到修改了 build.gradle、增加了大型依赖等影响工程根基的操作,Jugg 会有条件安全降级,切回 Gradle 完成一次构建,回到 01。

Gradle 编译和增量编译都是同一个 Run 按钮,Jugg 自动识别处理要如何编译,用户也会收到对应提示。

03. 增量编译
  • 做什么:绕过 Gradle Task 编排,直接在独立的旁路线程中调用编译器。

  • 产物生成 :将改动的 Kotlin/Java 源码编译为 Class,经 D8 快速转为 Dex;将修改的资源/XML 交给 Jugg 定制的 AAPT2 增量链接,直接生成 .dexresources.arsc 等极小增量产物。

04. 影响扩散 / 连锁编译 ------ 正确性的关键
  • 做什么:不少情况下,只编译改动文件是不够的。Jugg 会在此阶段进行字节码/​语法级的影响分析。

  • 典型扩散场景

    • 编译检查:删除或修改了接口方法,所有调用方需重新编译以抛出或检查兼容性;

    • 签名变更:给 Kotlin 方法增加带默认值的参数,由于编译后的方法签名发生改变,调用方字节码必须重新生成;

    • 常量内联:修改了 const val 或 static final 常量,所有内联了该常量的引用类都需要重新编译。

  • 自循环机制:Jugg 会将这些受影响的源码自动加入下一轮编译队列,触发"03 ➔ 04"的局部循环,直到不再产生新的连锁影响。

05. 增量部署
  • 做什么 :产物生成后,Jugg 会根据改动类型与设备状态(如 Android 版本、JVMTI 支持情况),智能选择 热重载(Apply Changes/JVMTI)热修复(Dex 注入)重新安装 策略,将变更毫秒级推送至设备并生效。
06. 保存增量产物和变更记录
  • 做什么:部署成功后,保存本次部署的产物和已编译的文件 checksum。

  • 状态闭环:部署成功后,提交本次 Checkpoint,本次运行结果即刻成为下一次 Jugg Run 的新基线。

  • 状态恢复:该步骤用于记录部署状态,下轮编译甚至到下次工程重新打开时都可以正确恢复状态。

总结来说,Jugg 的秒级速度来自 按照最小原则只处理必要输入 ,而结果的可信度来自 影响扩散、Gradle 回退、部署状态检测 等机制。

下面沿着这五个步骤,依次拆解源码、资源、影响传播和部署的关键实现。

技术方案原理

本章节具体介绍 Jugg 具体实现方案和技术要点。完整内容可以前往 历史文章Jugg Wiki 查看。

1. 增量编译的环境准备

前面提到,Jugg 采用最简路径,使用 Java/Kotlin Compiler、D8、AAPT2 等工具完成变更文件到可部署产物 的转换。这些工具都有同一个特点,它们都是原子性的编译工具,提供正确的参数就能输出正确的产物。所以关键在 "正确" 二字 。脱离 Gradle Runtime 后,Jugg 需要为这些编译器准备正确的变化文件、编译依赖、以及从工程环境获取准确的编译参数。这一节解答的就是前文速览中 "01 复用基线""02 识别变化" 的底层支撑机制。

1.1. 复用 Gradle 产物,建立增量基线

Jugg 在首次编译/回退 Gradle 编译完成后,开始初始化并收集 Gradle 构建 build 目录里的中间产物。核心产物举例:

  • {module}/build/outputs/apk/debug.apk:由 Gradle 编译命令生成,Jugg 无需关注细节。后续增量编译无需重新生成 apk,而是部署增量产物。

  • {module}/build/intermediates/javac/debug/classes/*.class:Java 文件的编译产物,用于增量编译阶段作为 --classpath 传给 javac,这样就不需要传入全量的源码文件了。

  • {module}/build/tmp/kotlin-classes/debug/*.class:Kotlin 文件的编译产物,同上,传给 kotlinc

  • {module}/build/intermediates/compile_and_runtime_r_class_jar/R.jar:存放所有资源 id 的 R.jar,也作为编译源码时的输入。

以上这些是方案核心的产物目录,还有很多其他目录先暂不展开。可以注意到,这些产物的路径都是会因为工程 AGP 版本,build.gradle 配置不同而不同的,甚至 build 目录也是可以定制的。而得益于大量的工程验证,Jugg 对这些情况都做了正确的处理。

1.2. 读取工程信息

除了收集 Gradle 产物,读取工程结构也是重要的环节。比如一个模块哪些目录是源码目录,哪些是资源目录,模块依赖关系是什么?又比如编译一个 Kotlin 文件,需要传入哪些模块的 classpathjvm-targetlanguage-version 是多少,module-name 参数填什么?这些都需要从工程/​模块信息中读取,如果不正确就会有各种编译错误。

Jugg 开始是从 Android Studio API 获取这些信息,后面又补充了 Gradle 数据源来完善 Android Studio 信息不缺失或者不准确的问题,最终形成 双数据源合并 的方案。

随着功能越来越丰富,目前已经会从工程读取 40 多个参数信息:

Gradle 数据源的获取方式是,预生成一个 readProjectInfo.gradle.kts 文件,在执行 Gradle 命令时通过 -I 参数传入该 init script,来实现无需修改工程文件的读取方案。

1.3. 获取变更文件

产物收集和工程信息收集完成后,下一步就是获取变更文件。 当你在 Android Studio 编辑文件,或者执行 git pull 更新工程时,Jugg 会自动检测到变更的文件。

首先当文件修改时,IDE 文件变化事件会通知到 Jugg,实现 0 耗时文件变化监听。

但 IDE 文件变化事件存在两个短板:

  • IDE 关闭期间的文件修改无法感知。

  • 大量非 IDE 内的文件变更如 git pull 时,会有一定概率丢失部分文件,导致 "漏" 监听。

基于以上两个问题,Jugg 则会使用 Git 进行辅助确认,且通过柔性策略做到绝大部分场景不阻塞编译。

Jugg 拿到变化的文件列表后,会根据工程信息过滤掉真正的源码文件,并识别出具体文件类型。比如只有在源码目录下的 .java 文件,才会被认为是 Java 源码。最终我们就得到了标记好类型的变更文件列表。

2. 源码增量编译

经过"收集 Gradle 产物 ➔ 读取工程信息 ➔ 获取变更文件"后,Jugg 完成编译上下文的管理,精确指导后续编译阶段 javackotlincaapt2d8 应该如何组装参数

2.1 Java 编译

在 Gradle 编译中,AGP 使用的是 compileDebugJavaWithJavac 完成 Java 源码编译(对应实现是 org.gradle.api.tasks.compile.JavaCompile)。这个 task 会接收模块所有的 Java 文件,并将所有 Java 文件编译为 class 文件。

因为需要处理所有的 Java 文件,即使 task 实现有增量编译逻辑,耗时依然较为可观。

在 Jugg 的增量编译中,为了获得更快的编译速度,我们可以利用全量编译生成的的 class 文件作为 classpath 依赖,仅对改动的文件进行编译。

Java 编译实质等同于调用 javac 命令,可通过 JDK 提供的 javax.tools.JavaCompiler 实现。

调用一个命令是流程性,原子性的,只需要提供正确的参数,即可成功编译出二进制 class 文件。javac 常用的参数有:

  • -cp:依赖的 classpath,支持目录和 jar 包,通过File.pathSeparator分割。对应 build.gradle 里的 implementationapi

  • -g:保留调试符号,否则 debug 调试时会无法显示变量名;

  • -​source:源码最高支持版本,对应 build.gradle 里的 sourceCompatibility

  • -target:生成的 class 文件的版本,对应 build.gradle 里的 targetCompatibility

  • -d:指定输出目录。

2.2 Kotlin 编译

在 Gradle 编译中,AGP 使用的是 compileDebugKotlin 完成 Kotlin 源码编译(对应实现是 org.jetbrains.kotlin.gradle.tasks.KotlinCompile)。这个 task 会接收模块所有的 Kotlin 文件,并将所有 Kotlin 文件编译为 class 文件。

同样的,Kotlin 编译实质等同于调用 kotlinc 命令,可通过 maven 包 org.jetbrains.kotlin:kotlin-compiler-embeddable 提供的 K2JVMCompiler 实现来完成。

虽说依然是"只需要提供正确的参数,即可正确实现编译",但 Kotlin 编译器显然要复杂许多。因为 Kotlin 语言在语法设计上比 Java 丰富得多,但最终又在同一个 JVM 上运行,甚至支持和 Java 任意互调用,这些能力本质是通过一系列复杂的机制实现的。下面也简单拓展一下 Kotlin 混编、 top-level 声明、拓展方法、internal 方法的一些技术介绍。

2.3 Java / Kotlin 混编 - 鸡生蛋蛋生鸡

这个场景是一个"哲学"场景:一个互相引用的 Java 和 Kotlin 同时被修改,应该先编译哪个?

答案是先编译 Kotlin ,因为 Kotlin 可以通过 -Xjava-source-roots 同时读取 Java 源文件联合编译。

如果没有正确设置该参数,混编时会出现:class is not abstract and does not implement abstract memberreference not foundtype mismatch, cannot access class 等各种因为没有引入 Java 新源码,而是使用老的 classpath 导致的错误。

2.4 .kotlin_module - 记录 top-level 声明、拓展方法

.kotlin_module 是一个 Kotlin 模块(粒度与 Gradle 模块一致)的描述文件。kotlin_​module 文件记录了 JVM 字节码不支持的顶级声明信息,如扩展方法,全局变量。因为这些信息无法在 class 文件中体现,所以需要额外的信息来帮助 Kotlin 编译器编译。

在全量编译中,Kotlin 编译器总是接收所有的 Kotlin 文件,生成完整的 kotlin_module 文件,没有问题。

而单文件编译中,如果文件新增了顶层声明或扩展函数,会新生成一个 .kotlin_module。新的声明也需要加入到原来的 kotlin_module 声明中,否则会发生编译报错,如 extension unresolved reference

所以我们需要实现 kotlin_module 文件的合并,将新增的声明和已有的声明合并起来。具体做法是,使用 kotlin-metadata-jvm 库,在编译前先把旧的 kotlin_module 读取并缓存,编译成功后再把新生成的 kotlin_module 读取并合并。

2.5 internal 方法支持

Kotlin 的 internal 特性是标记为 internal 的方法和变量只可以被本模块访问。kotlinc 将同一次编译的源文件视为当前模块;也可以通过 friend-paths 参数允许当前编译文件访问 internal 声明。

此外 Kotlin 编译器对 internal 方法字节码处理方式是,将标记为 internal 方法和变量的末尾加上 module_name 。比如 app 的 debug 包的 func1 方法,编译后 class 字节码中的方法名为:func1$app_debug,避免被 Java 意外访问。

$ 符号是专门预留给编译器生成内部类或合成方法(Synthetic Name)使用的。Java/​Kotlin 语言规范均明确建议开发者在源码命名中避免使用 $​,以防止与编译器生成的字节码符号发生冲突。

所以对 internal 方法正确处理的办法是:设置正确的 -module-name 参数,如果没有正确设置,则调用的地方有可能因为名字对不上发生运行时报错 NoSuchMethodError

2.6 Class 到 Dex

javackotlinc 编译出来的是 class 文件,还需要使用 d8 将 class 转换为 dex。

D8 除了将 class 转换为 dex,还有一个重要的功能是脱糖(desugar)。脱糖指的是将使用了高版本 Java 语法的 class 文件修改为等效的低版本 Java class 文件(如 Java 7),让高版本 Java 语法也可以运行在低版本设备上。

D8 支持脱糖的特性包含:

  • Lambda 转内部匿名类

  • 使用 JDK8 才有的方法

  • 方法引用(set​On​Click​Listener(::on​Click))

  • 重复注解

  • 接口默认方法

默认方法脱糖最为特殊:其他脱糖都是在单 class 内完成的,而 默认方法脱糖会将接口里的默认实现提取并生成一个静态辅助类(Companion Class),和生成一个桥接方法,并将调用重定向到该静态方法。 。所以默认方法脱糖需要通过一个额外参数 --classpath 传入它的所有父类和接口来完成脱糖。

除了 D8 常规脱糖,还有 coreLibraryDesugaring API 脱糖,用于低版本设备上,安全地使用高版本 Java API,这个 Jugg 也适配了。

3. 资源与其他输入

3.1 Jugg 定制版 AAPT2 增量链接 - 提速 97%

aapt2 是官方唯一指定资源编译工具。

aapt2 是 aapt 的 v2 版本。aapt 是 一步走,输入所有文件,一次性编译出产物。

aapt2 实现了增量编译,参考 gcc 的方式做成了编译 + 链接两步走:

  1. aapt2 compile 命令将 xml 编译为 xml.flat。这个 flat 文件就是 aapt2 编译的中间产物,由 protobuf 序列化。此时文件引用的 ID 还未确定,还不是最终编译产物;

  2. aapt2 link 命令读取所有 flat 文件和 Android​Manifest.​xml 并输出最终产物。link 会为所有资源分配一个唯一的 ID,然后将 ID 回填到 flat 文件并输出为最终的格式。并同时输出 resource.​arsc,Android​Manifest.​xml 和 R.java。

aapt2 使得 compile 耗时大大下降了,不用每次都全量编了,编译一个文件一般只需要 100-200ms。但 link 耗时还是比较可观,在大型工程(如超过 1 万个资源文件)中,仍需要 10s 以上的 link 耗时。增量了,但没完全增。

而 Jugg 的改造方案,是增量场景的非标准解法。 该方案可以得到极快的编译速度(10-​15s 降低到 0.​2s),但代价是无法删除资源 ID,删除资源后对应的 ID 会一直保留在 resource.​arsc,直到下一次降级构建。这在 debug 开发环境是可以接受的,也没什么副作用,且 Apply Changes 的方案对 class 的处理也是如此,但不适宜用在生产构建环境。

Jugg 定制了 aapt2,增加了 inclink 命令,将 link 再拆分一次,拆成加载和增量链接:

  1. 加载通过 inclink --load 命令,加载 APK 中的 res 和 resource.arsc;

  2. 增量链接通过 inclink 命令,接收改动的 flat 文件,增量处理并增量输出编译产物;

  3. 如果 inclink 检测到没有 ID 新增,则连 R.java 都不会生成。可以省下 R.java 2-3 秒的编译耗时。

改造后使用 inclink 编译一般只需要 100ms,速度非常快。

3.2 Assets、Manifest 与 Native Library

Assets:比较简单。存放在 assets 中的文件不需要二次处理,直接用于部署。

Android​Manifest.​xml:比较复杂。因为 Android​Manifest.​xml 是不支持增量部署的,系统只认 APK 里的 Android​Manifest.​xml。Jugg 是通过把 重新编译后的 Android​Manifest.​xml 更新到 APK,然后重签名,重装 APK,恢复历史部署,来实现这个功能。

流程简述:

  1. diff Android​Manifest.​xml 差异;

  2. 将变化/新增的节点 merge 到最终的 Android​Manifest.​xml;

  3. 用 aapt2 编译新的 Android​Manifest.​xml;

  4. 把新的 Android​Manifest.​xml 更新到 APK 并重签名;

  5. 重装 APK,并重新部署历史增量部署文件。

Native Library:so 文件也是采用更新进 APK 的方式。so 理论上也支持增量部署(反射插入),但目前暂时没有为 so 专门做一套部署逻辑。

4. 影响扩散和连锁编译

之前提到 Java / Kotlin 增量编译都仅只对改动的文件进行编译,以获得最快的编译速度。

那么现在考虑这个场景 A:文件 A 删除了一个方法,调用这个方法的文件 B 没改,只编译了文件 A。这个时候会出现运行时 crash NoSuchMethodError,因为方法被删除了,但没修改的地方还留着调用。

如果是 Gradle 编译,因为会将全量的源码加入到编译列表中,交给编译器,在编译期间就会检出问题并报错。但增量编译的原始方案只编译文件 A,不会做编译检查,而是延迟到运行时 crash。这个问题会对开发体验带来一定的折损。

再考虑另一个场景 B:文件 A 方法增加了一个可选参数,所有调用处没有修改(也不用修改)。但实际上 增加可选参数会导致 JVM 字节码的方法签名发生变化,如果调用处不重新编译,使用老的签名调用也会触发运行时 crash NoSuchMethodError

以上这些场景可简称为 影响扩散 。Jugg 需要检测以上场景,将受影响的源码加入下一轮编译循环,提供正确的字节码,避免运行时 crash。

目前 Jugg 处理了多种场景,包括但不限于:

  1. 方法签名变化/​删除时-​-​编译所有引用类源码。

  2. 变量签名变化/​删除时-​-​编译所有引用类源码。

  3. 抽象类父类新增抽象接口时-​-​编译所有子类源码。

  4. 修改常量时-​-​编译所有引用类源码。

其中前三个场景是通过 Dex 字节码解析获得的引用关系;最后的常量场景,因为常量被内联进了字节码,无法追溯源头,使用的是 AST 语法树解析的方式查询。

5. 增量部署:JVMTI,安卓自己的有限热重载

增量编译完成了快速产出增量 DEX/资源文件 。增量部署接力,负责将这些极小产物推送到设备并让 App 读取生效

传统的全量部署需要经过 "重新打 APK ➔ 安装 ➔ 重启 App"的漫长过程;官方 Apply Changes 通过调用 JVMTI(在 Android ART 上为 ART TI,即虚拟机工具接口) 运行时替换 dex 字节码,实现了部分场景无需重启 App 的热重载(Hot Reload),但因为替换字节码 不支持修改类结构(如增删方法、变量、接口) 的严格限制,触发限制时降级为 APK 安装,这与 Jugg 脱离 Gradle 的独立旁路体系是冲突的。

为了兼顾「秒级生效的极速体验」与「修改类结构时的可用性」,Jugg 实现的是热重载优先,热修复兜底的策略

5.1 部署策略自动判断:热重载 vs 热修复

Jugg 在编译出增量字节码后,会优先进行类结构差异对比(Class Diff)

  • 结构未变更 (仅修改方法体内部逻辑):符合 JVMTI 条件,命中 热重载(Hot Reload) 流程,实现应用不重启的毫秒级生效;

  • 结构已变更 (增删了方法、变量、接口或继承关系):主动避开 JVMTI 的报错限制,自动切入 热修复(Hot Fix) 流程,将增量 DEX 插入 ClassLoader 链表首部,并通过轻量重启 Activity/​进程完成生效。

5.2 复用 Apply Changes 部署通道,实现无侵入式"热修复"与现场恢复

我们深入阅读了 Android Studio 底层 Deploy Agent 的 Socket + Protobuf 通信协议,发现可以直接构造 Apply Changes 所需的部署元数据(包含历史部署 ID、增量 Class 字节码、增量资源字节流等),复用其通道进行高速推送。整套传输与应用过程平均耗时仅约 0.9 秒 ,且 App 侧完全无需集成任何 SDK 和代码

但 Apply Changes 官方原生并不支持热修复。我们在探索其 Agent 源码时发现:当将带有结构变更的 Class 作为"new Class"提交给 Apply Changes 时,Deploy Agent 会跳过 JVMTI 的重载检查,直接将其塞入 Dex 文件列表并持久化存储。而下次 App 启动时,framework 又会自动加载 JVMTI agent,触发 Apply Changes 的恢复部署机制(Restore Flow)。

基于这一底层特性,Jugg 将热修复的类标记为"new Class",部署完成后重启 App,实现了巧妙的热修复。

5.3 经典热修复和把增量打进 APK

Apply Changes 很好,但厂商 "魔改" 过的 framework 会出现兼容性问题。比如目前已发现安卓 HarmonyOS 4.2 部署不生效,因为系统调整了 ClassLoader 的初始化时机,导致增量 Dex 无法按原有顺序生效;以及 Oppo/Vivo Android 11 上 AssetManager 在部署资源后会出现 native crash;在小米设备上还出现过直接关闭 JVMTI 功能的情况。

针对以上兼容性问题,Jugg 针对性引入了 "经典热修复" 方案:关闭 Apply Changes 通道,使用类似热修复补丁的机制,在 Application.attachBaseContext 实际把增量产物反射注入到 context。

而针对 Android RemoteView 和 CI 构建等场景,Jugg 还引入了 将增量变更打进 Apk 的能力。方案还是更新 Android​Manifest.​xml 那个方案:把变更更新到 APK 内部,然后重签名。增量资源直接覆盖,增量 Dex 文件会放入 assets,在 App 启动时读取并反射插入。

工程化/鲁棒性优化

成为开发者每天高频使用的工具,还需要处理大量不属于核心算法、却直接影响日常体验的工程问题。

前面数据提到 Jugg 已经累计编译 80W 次了,这种体量下,确实是什么问题都有可能遇到。这类问题很难靠拍脑袋提前设计出来,基本都是靠海量运行次数逐渐冒出来的。用的越多越稳定,也算是一种技术壁垒了

从技术方案到可日用工具

在插件的使用优化上,Jugg 实际投入了非常多的工作,其工作量甚至多于编译方案的研发 。因为我们希望它不仅是一个技术方案,而更加是一个能用且好用的产品

体验方面,Jugg 和 Android Studio 高度集成。除了对齐原有编译交互,还集成了编译进度提示,后台运行提示,弹窗确认交互,以及提供丰富的自定义设置和快捷按钮,满足大家日常的开发使用:

稳定性方面,Jugg 已经可以正确的处理非常多的边界场景,如:

  • 识别工程关闭期间变更的文件;

  • 多种不同类型文件混编,模块混编;

  • 使用过程切换设备;

  • 卸载 App 后恢复增量部署现场;

  • 多工程开发,多设备调试。

  • 强化重试能力,解决用户日常比较容易卡住的地方,如:清理增量缓存、部署超时渐进恢复、ADB 自动恢复。

这些拿出来都不复杂,但 Jugg 是每天高频运行的工具,出问题时恢复链路能多兜住一步,就少一次"Jugg 有问题"的反馈。使用者的积极反馈,也让 Jugg 越来越完善,支持越来越丰富的场景。

写在最后

对 "改完代码秒级生效" 的追求,推动我们完成了 Jugg 从 0 到 1 的研发,并在过去几年里陪伴我们度过了 80 多万次日常构建。

今天 Jugg 开源,我们非常期待它能走出公司的内部工程,真正帮到更多受编译耗时困扰的 Android 开发者。同时也希望我们的方案设计思路,能为有相关优化需求的团队带来一些启发。

由于篇幅有限,本文只梳理了最核心的架构设计。如果你对更底层的实现逻辑感兴趣,可以访问 github.com/tencentmusi... 查看包含 10W+ 字技术文章和完整的 Jugg Wiki。

欢迎大家下载试用、加交流群、反馈 issue 或点个 Star。只要是明确的问题,我们都会持续跟进解决。

相关推荐
Android-Flutter1 小时前
android PhoneWindow, DecorView 详解
android
宝杰X72 小时前
Android Umeng 16kb对齐
android
2601_966563522 小时前
NativeWasmTv超精简2m安装包 长效免费电视直播软件-支持老电视盒子安卓4.0
android·电视盒子
Knight_AL2 小时前
MySQL 递归 CTE:WITH RECURSIVE 用法详解
android·数据库·mysql
恋猫de小郭2 小时前
聊个比较有意思的 Flutter Web 问题
android·前端·flutter
stevenzqzq2 小时前
Compose 生命周期与副作用的核心奥秘
android·compose
2501_915918412 小时前
Flutter项目配置iOS混淆的详细步骤与工具推荐
android·flutter·ios·小程序·uni-app·cocoa·iphone
天空之城--2 小时前
过去一周Android行业动态:编码技术与综合趋势精选
android·flutter·性能优化·kotlin·android jetpack
脚踏实地,坚持不懈!2 小时前
Android 上层卡顿在内核中的反应(基于 Linux v7.2.2)
android·linux·运维