一、什么是Android热更新
Android热更新(Hot Update/Hot Fix),本质是应用在不重新下载安装完整APK包、无需用户手动触发安装流程的前提下,通过网络获取更新补丁,动态替换或补充应用内部的代码、资源文件(如图片、布局)或配置信息,从而实现功能迭代、bug修复或资源更新的技术方案。其核心目标是“无感更新”,最大化降低更新对用户体验的干扰,同时缩短开发者的迭代与问题修复周期。
从更新生效时机来看,Google曾结合Android Studio的Instant Run功能,将热更新相关的部署方式分为三类:热部署(仅修改方法内部代码,无需重启应用和Activity)、暖部署(需重启Activity但无需重启应用,适用于资源修改)、冷部署(需重启应用,适用于类继承关系变更、方法签名修改等场景)。需注意区分热更新与增量更新:增量更新是下载新旧APK的差异包,合并后重新安装,仍属于传统更新范畴;而热更新无需重新安装,补丁包体积更小(通常100KB-1MB),可后台静默完成更新。 可以结合深入理解Instant Run——原理篇进行理解。
热更新的实现依赖Android的类加载机制(Dalvik/ART运行时的ClassLoader),核心逻辑是“替换”——无论是替换类、方法还是资源,本质都是通过钩子(Hook)或动态加载机制,让应用优先执行新的代码或加载新的资源,属于一种侵入性操作。
二、为什么需要Android热更新
热更新的诞生与普及,核心是解决传统应用更新模式的痛点,同时兼顾开发者效率、用户体验与商业价值,其必要性可从开发者、用户、商业场景三个维度展开:
(一)开发者层面:高效迭代与风险管控
传统应用更新需经过重新打包、全面测试、提交各应用市场审核、等待用户下载安装等流程,周期通常长达1-3天,即便仅修改一行代码修复紧急bug,也需完整走一遍流程。热更新可绕过应用商店审核,实现小时级甚至分钟级的紧急bug修复,尤其适用于支付崩溃、闪退等影响核心业务的严重问题,避免故障持续扩散造成损失。同时,对于频繁迭代的业务需求或A/B测试场景,热更新可快速下发不同业务逻辑,验证产品方案,无需频繁发布全量版本,大幅降低开发与发布成本。此外,面对Android碎片化问题(不同厂商、系统版本的兼容性差异),热更新可定向下发补丁,快速修复特定设备的兼容问题,无需为单一问题发布全量版本。
(二)用户层面:降低更新成本,提升体验
传统全量更新需用户下载体积较大的APK包(通常10MB-100MB),消耗较多流量与存储空间,且安装过程会中断应用使用,容易引发用户抵触,导致更新渗透率低,大量用户仍使用旧版本(存在bug或安全隐患)。热更新的补丁包体积仅为全量包的1%-10%,可后台静默下载安装,多数场景下用户无感知,无需手动操作,既节省流量与存储,又避免应用使用被中断,显著提升用户满意度,减少因强制更新导致的用户流失。
(三)商业层面:保障业务连续性与灵活性
对于电商、出行、金融等核心业务高度依赖APP的行业,应用故障会直接造成经济损失(如支付崩溃导致交易失败),热更新可快速止损,保障业务连续性。同时,热更新支持动态扩展业务功能(如上线临时营销活动、更新活动规则),无需用户更新应用即可触达全部用户,提升运营灵活性;还可动态替换低效算法、修复内存泄漏,持续优化应用性能,无需发布新版本。
三、Android线上热更新技术的大框架演变
Android线上热更新技术的演变,核心是围绕“兼容性、稳定性、合规性”三大目标,适配Android系统从Dalvik到ART的运行时迭代,以及应用商店政策收紧,聚焦线上全场景(UI+非UI)更新需求,从早期的民间探索、框架爆发,逐步走向官方规范与分层治理,整体可分为三个核心阶段:早期探索阶段、成熟框架爆发阶段、官方规范与多元化演进阶段。
第一阶段:早期探索阶段(2014年前)—— 民间尝试,适配性有限(侧重非UI简单修复)
此阶段Android系统以Dalvik虚拟机为主,应用规模与复杂度较低,线上热更新需求集中于简单bug修复(多为非UI层面,如工具类逻辑错误、接口适配问题),技术方案以民间自发探索为主,无成熟框架,核心依赖Android类加载机制的基础特性,适配性与稳定性较差,未形成规模化线上应用。
核心技术逻辑:基于Android的DexClassLoader动态加载机制,将修复后的类(多为非UI类)打包成单独的Dex文件(补丁包),通过反射修改系统类加载器的dexElements数组,将补丁Dex插入到数组最前方,让虚拟机优先加载补丁中的新类,替代原有错误类,实现修复效果,本质是简单的线上类替换方案。
此阶段的核心局限的是:仅适配Dalvik虚拟机,无法兼容后续推出的ART虚拟机;反射修改系统API属于非标准操作,易受系统版本影响,在部分机型上出现加载失败、应用闪退等问题;无统一的补丁管理、校验机制,存在补丁篡改、加载顺序错乱的风险;仅支持简单的类替换,无法实现资源更新、复杂非UI代码逻辑迭代,适用场景极窄,未覆盖UI线上更新需求。代表性尝试包括早期的Dex注入工具、第三方开发者自制的简单补丁工具,多应用于小型应用的非UI紧急bug临时修复。
第二阶段:成熟框架爆发阶段(2014-2017年)—— 生态完善,适配ART,覆盖全场景线上更新
2014年Android 5.0推出,ART虚拟机逐步替代Dalvik成为默认运行时,原有基于Dalvik的线上热更新方案彻底失效,同时移动互联网进入爆发期,APP规模扩大、业务迭代加速,紧急bug修复、快速功能上线的需求激增,催生了一批成熟的线上热更新框架,行业进入规范化探索期。此阶段的核心突破是适配ART虚拟机,完善代码与资源(含更新能力,解决兼容性与稳定性问题,实现全场景线上更新覆盖。
核心技术路径分化为三类,按技术思路进行分类依次介绍,依次为:Native层方法替换方案(以阿里AndFix为代表)、编译期插桩方案(百度Robust、美团Robust为代表,解决Hook兼容性问题)、Dex动态加载方案(腾讯Tinker、饿了么Amigo为代表,实现全量更新能力)。
(一)Native层方法替换方案:阿里AndFix(最早落地,热部署首选)
AndFix是国内最早实现ART/Dalvik双架构适配的线上热更新框架,核心定位是“紧急bug快速修复”,采用Native层方法指针替换技术,无需重启应用即可生效(纯热部署),主打轻量、高效,适配Android 2.3-7.0版本。
1. 核心技术原理
AndFix的核心逻辑是“绕开Dex加载限制,直接在Native层修改Java方法的执行入口”,无需处理Dex合并、类加载器替换等复杂逻辑,本质是通过JNI调用底层系统API,修改方法的指针指向,让虚拟机执行修复后的方法而非原错误方法,核心分为补丁制作、补丁加载、方法替换三大环节。
(1)补丁制作环节:通过官方提供的apkpatch工具,对比新旧APK文件,提取被修改的Java类,对修改后的类添加“_CF”后缀(如原类为MainActivity,修改后为MainActivity_CF),同时为每个被修改的方法添加@MethodReplace注解,注解中指定原方法的全类名、方法名及参数列表,用于客户端匹配目标错误方法;最终将修改后的类、注解信息及签名校验文件打包为.apatch格式的补丁包(基于zip压缩,内置MANIFEST.MF记录补丁信息)。
(2)补丁加载环节:客户端通过网络获取.apatch补丁包后,先进行双重安全校验(签名校验:校验补丁签名与APP签名一致,防止篡改;MD5校验:校验补丁文件完整性),校验通过后解压补丁,通过反射解析@MethodReplace注解,获取原方法与修复方法的映射关系,生成方法映射表;同时通过System.loadLibrary()加载AndFix的Native层so库(适配arm、x86等不同架构),为后续方法替换做准备。
(3)方法替换环节(核心,分架构适配):
① Dalvik架构(Android 2.3-4.4):Dalvik虚拟机中,Java方法的结构由Method结构体描述,结构体中包含methodPtr指针(指向方法的执行代码),AndFix通过JNI调用dalvik_system_DexFile.cpp中的底层接口,将原方法的methodPtr指针改为Native类型,同时将指针指向AndFix自定义的dalvik_dispatcher方法;当应用调用原错误方法时,会触发dalvik_dispatcher回调,回调中根据方法映射表,执行修复方法的逻辑,完成替换。
② ART架构(Android 5.0+):ART虚拟机将Dex编译为OAT文件(机器码),Java方法的结构由ArtMethod结构体描述,结构体中包含entry_point_from_quick_compiled_code指针(快速编译代码的入口指针),AndFix无需修改方法类型,直接通过JNI调用art/runtime/art_method.h中的接口,修改该入口指针,将其指向修复方法的OAT机器码地址,实现方法替换,比Dalvik架构更高效、稳定。
关键优势:无需重启应用,热部署实时生效,补丁体积小(仅包含修改的类,通常几十KB),加载速度快;无Dex合并逻辑,避开ART虚拟机的Dex优化限制,兼容性较好(适配低版本系统)。
核心局限:仅支持方法级修复,无法修改类结构(如新增/删除字段、修改继承关系)、新增类或方法;不支持资源更新、so库更新;高版本Android(7.0+)中,系统对ArtMethod结构体的访问权限收紧,导致替换失败,适配性下降;Native层操作存在崩溃风险,若方法替换过程中异常,会导致APP闪退。
2. 核心代码实现
(1)Java层:补丁加载与注解解析核心代码
// 1. 初始化AndFix
AndFixManager andFixManager = new AndFixManager(context);
// 2. 加载补丁(参数为补丁文件路径)
andFixManager.fix("/sdcard/patch.apatch");
// AndFixManager核心逻辑
public class AndFixManager {
private Context mContext;
// 方法映射表:key=原方法标识,value=修复方法
private Map<String, Method> fixMethodMap = new HashMap<>();
public AndFixManager(Context context) {
this.mContext = context;
// 加载Native层so库
System.loadLibrary("andfix");
}
public void fix(String patchPath) throws Exception {
// 1. 校验补丁
verifyPatch(patchPath);
// 2. 解压补丁
File unzipDir = unzipPatch(patchPath);
// 3. 解析补丁中的修复类,获取注解信息
parseFixClasses(unzipDir);
// 4. 调用Native层方法,完成方法替换
for (Map.Entry<String, Method> entry : fixMethodMap.entrySet()) {
String targetMethodId = entry.getKey();
Method fixMethod = entry.getValue();
// 解析原方法的全类名、方法名、参数
String[] methodInfo = targetMethodId.split("#");
String className = methodInfo[0];
String methodName = methodInfo[1];
String paramTypes = methodInfo[2];
// 反射获取原方法
Class<?> targetClass = Class.forName(className);
Method targetMethod = getTargetMethod(targetClass, methodName, paramTypes);
// 调用Native层替换方法
replaceMethod(targetMethod, fixMethod);
}
}
// 校验补丁签名与完整性(简化版)
private void verifyPatch(String patchPath) {
// 省略签名校验、MD5校验逻辑
}
// 解压补丁(简化版)
private File unzipPatch(String patchPath) {
// 省略zip解压逻辑,返回解压后的目录
return new File("/sdcard/andfix_patch");
}
// 解析修复类,处理@MethodReplace注解
private void parseFixClasses(File unzipDir) throws Exception {
File[] files = unzipDir.listFiles();
for (File file : files) {
if (file.getName().endsWith(".class")) {
// 加载修复类
DexClassLoader dexClassLoader = new DexClassLoader(
file.getAbsolutePath(),
mContext.getCacheDir().getAbsolutePath(),
null,
mContext.getClassLoader()
);
Class<?> fixClass = dexClassLoader.loadClass(file.getName().replace(".class", ""));
// 遍历类中的方法,获取@MethodReplace注解
Method[] methods = fixClass.getDeclaredMethods();
for (Method method : methods) {
if (method.isAnnotationPresent(MethodReplace.class)) {
MethodReplace annotation = method.getAnnotation(MethodReplace.class);
// 获取原方法标识(全类名#方法名#参数类型)
String targetClass = annotation.clazz();
String targetMethod = annotation.method();
String paramTypes = getParamTypes(method);
String methodId = targetClass + "#" + targetMethod + "#" + paramTypes;
fixMethodMap.put(methodId, method);
}
}
}
}
}
// 生成方法参数类型字符串(用于匹配原方法)
private String getParamTypes(Method method) {
Class<?>[] paramClasses = method.getParameterTypes();
StringBuilder sb = new StringBuilder();
for (Class<?> cls : paramClasses) {
sb.append(cls.getName()).append(",");
}
return sb.length() > 0 ? sb.substring(0, sb.length() - 1) : "";
}
// 反射获取原方法
private Method getTargetMethod(Class<?> targetClass, String methodName, String paramTypes) throws NoSuchMethodException {
if (TextUtils.isEmpty(paramTypes)) {
return targetClass.getDeclaredMethod(methodName);
}
String[] paramTypeNames = paramTypes.split(",");
Class<?>[] paramClasses = new Class[paramTypeNames.length];
for (int i = 0; i < paramTypeNames.length; i++) {
try {
paramClasses[i] = Class.forName(paramTypeNames[i]);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
return targetClass.getDeclaredMethod(methodName, paramClasses);
}
// Native层方法替换接口(JNI调用)
private native void replaceMethod(Method targetMethod, Method fixMethod);
}
// @MethodReplace注解定义
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MethodReplace {
// 原方法的全类名
String clazz();
// 原方法的方法名
String method();
}
(2)Native层(C++):方法替换核心代码(ART架构为例)
// andfix.cpp
#include <jni.h>
#include <art/runtime/art_method.h>
#include <art/runtime/thread.h>
using namespace art;
extern "C" JNIEXPORT void JNICALL
Java_com_alibaba_andfix_AndFixManager_replaceMethod(JNIEnv *env, jobject thiz, jobject target_method, jobject fix_method) {
// 1. 获取ArtMethod指针(ART虚拟机中,Java Method对应底层ArtMethod)
ArtMethod* targetArtMethod = reinterpret_cast<ArtMethod*>(env->FromReflectedMethod(target_method));
ArtMethod* fixArtMethod = reinterpret_cast<ArtMethod*>(env->FromReflectedMethod(fix_method));
// 2. 修改原方法的入口指针,指向修复方法的快速编译入口
targetArtMethod->SetEntryPointFromQuickCompiledCode(fixArtMethod->GetEntryPointFromQuickCompiledCode());
}
3. 方法插桩原理
需要理解当我们写了一个Java类及方法,是怎么被Android执行的呢?
public class AndFixDemo {
public int add(int a, int b) {
return a + b;
}
}
其实方法的本质就是arm指令,在Android当中,dalvik或者art虚拟机的执行引擎会执行arm指令。
add方法是java代码,java代码编译成class文件,还需要一步转换为dex文件,才能被Android虚拟机执行,dex文件包含了app的所有代码,因此方法也是存在dex文件中,那么通过dx命令,可以查看方法被编译成的字节码指令。
先将对应的.java文件转为.class文件。
javac --release 8 -d /Users/xxx/Documents/UGit/NumForge/androidapp/src/main/java/ /Users/xxx/Documents/UGit/NumForge/androidapp/src/main/java/com/hrm/num/demo/AndFixDemo.java
再将.class文件利用dx输出对应的arm指令集
/Users/xxx/Library/Android/sdk/build-tools/30.0.3/dx --dex --verbose --dump-to=dex_method.txt --dump-method=AndFixDemo.add --verbose-dump AndFixDemo.class
对应的dex_method.txt信息
AndFixDemo.add:(II)I:
regs: 0004; ins: 0003; outs: 0000 //regs表示总寄存器数量为4,ins表示传入参数占用的寄存器数量(3 个),默认第一个参数是`this`(对应 v1,隐藏参数),后续是显式参数(两个 int,对应 v2、v3)
0000: code-address // 0000,0002,0003 均表示指令地址
0000: local-snapshot
0000: code-address
0000: code-address
0000: local-snapshot
0000: code-address
0000: code-address
0000: local-snapshot
0000: add-int v0, v2, v3 // 核心指令:计算v2(第一个int参数)与v3(第二个int参数)的和,存入v0
0002: code-address
0002: code-address
0002: local-snapshot
0002: return v0 // 核心指令:返回计算结果v0
0003: code-address
debug info
line_start: 3
parameters_size: 0002 // 方法参数个数:2个int类型
parameter <unnamed> v2 // 第一个参数(int)对应寄存器v2
parameter <unnamed> v3 // 第二个参数(int)对应寄存器v3
0000: prologue end // 方法序幕结束,进入核心逻辑
0000: line 3 // 对应Java源码第3行
end sequence
source file: "AndFixDemo.java" // 对应源码文件
前面已经认识了对应java文件的字节码,在看在art里是如何保存对应的函数调用点。 art_method.h
# Android 10.0/art/runtime/art_method.h
protected:
GcRoot<mirror::Class> declaring_class_;
std::atomic<std::uint32_t> access_flags_;
uint32_t dex_code_item_offset_;
uint32_t dex_method_index_;
uint16_t method_index_;
union {
uint16_t hotness_count_;
uint16_t imt_index_;
};
// Fake padding field gets inserted here.
// Must be the last fields in the method.
struct PtrSizedFields {
// Depending on the method type, the data is
// - native method: pointer to the JNI function registered to this method
// or a function to resolve the JNI function,
// - conflict method: ImtConflictTable,
// - abstract/interface method: the single-implementation if any,
// - proxy method: the original interface method or constructor,
// - other methods: the profiling data.
void* data_;
// Method dispatch from quick compiled code invokes this pointer which may cause bridging into
// the interpreter.
void* entry_point_from_quick_compiled_code_;
} ptr_sized_fields_;
在ArtMethod中,有一个结构体PtrSizedFields,其中一个成员变量为entry_point_from_quick_compiled_code_,这个指针指向的就是在方法区中该方法本地机器码的内存地址,也就是说,如果想要实现热修复,那么就将entry_point_from_quick_compiled_code_指向正确的方法机器码指令地址即可。
参考文档:Android进阶宝典 -- AndFix热修复原理
(二)编译期插桩方案:美团Robust(无Hook,高兼容)
随着Android碎片化加剧,AndFix等Native层Hook方案的兼容性问题日益凸显(高版本系统限制、机型适配成本高),编译期插桩方案应运而生,核心定位是“无侵入式、高兼容的全场景修复”,通过在编译阶段修改字节码,插入分支判断逻辑,实现方法级替换,无需Hook系统API、无需操作Native层,适配Android2.3-10,支持热部署,同时解决了AndFix无法新增类/方法的局限,成为中大型APP的主流选择。
1. 核心技术原理
编译期插桩方案的核心逻辑是“提前埋点、动态切换”,在APP编译打包阶段,通过字节码插桩工具,为每个Java方法插入分支判断逻辑,同时生成方法映射表与插桩配置;线上需修复时,下发包含修复逻辑的补丁包,客户端加载补丁后,修改分支开关的状态,让应用执行修复后的方法而非原错误方法,核心分为编译插桩、补丁制作、补丁加载与逻辑切换三大环节。
(1)编译插桩环节(核心,APP打包时执行):
① 接入插桩插件:在APP的build.gradle中引入Robust插件,配置插桩规则(如指定需要插桩的包名、排除无需插桩的类/方法,如系统类、第三方库类);
② 字节码修改:当执行gradle assemble任务打包时,Robust插件会介入编译流程,通过ASM工具遍历所有需要插桩的Java类,为每个方法插入分支判断逻辑——插入一个IF条件判断,判断全局的ChangeQuickRedirect对象是否为空,若为空则执行原方法逻辑,若不为空则调用ChangeQuickRedirect的redirectMethod方法,执行自定义逻辑(用于后续加载修复方法);
③ 生成辅助文件:插桩完成后,生成方法映射表(记录每个方法的全类名、方法名、参数列表、插桩标识)、插桩配置文件(记录插桩的类数量、方法数量),并将这些文件打包到APK中,用于客户端匹配目标方法。
示例:插桩前的原方法与插桩后的方法对比
原方法:
public void showToast(Context context, String msg) { Toast.makeText(context, msg, Toast.LENGTH\_SHORT).show(); }
插桩后方法:
public void showToast(Context context, String msg) {
if (changeQuickRedirect != null) { // 插桩新增的分支判断
changeQuickRedirect.redirectMethod(this, "showToast", new Object\[]{context, msg});
return;
}
// 原方法逻辑
Toast.makeText(context, msg, Toast.LENGTH\_SHORT).show();
}
(2)补丁制作环节:通过官方提供的补丁生成工具,对比新旧APK文件(旧APK为已插桩的线上版本,新APK为修复后的版本),提取被修改的方法及新增的类/方法,生成包含修复逻辑的补丁包(通常为.jar格式),补丁包中包含:修复后的方法逻辑、新增的类、方法映射表(用于匹配线上APK的插桩方法)、签名校验文件。
(3)补丁加载与逻辑切换环节:
① 客户端获取补丁包后,先进行安全校验(签名校验、MD5校验),校验通过后,通过DexClassLoader动态加载补丁包中的修复类与修复方法;
② 通过反射初始化ChangeQuickRedirect对象,实现redirectMethod方法,在该方法中,根据方法映射表,匹配目标错误方法,执行修复后的方法逻辑;
③ 将初始化后的ChangeQuickRedirect对象赋值给全局变量,此时应用调用原错误方法时,会触发插桩新增的分支判断,执行修复后的方法逻辑,完成替换(无需重启应用,热部署生效);
④ 支持新增类/方法:对于新增的类,通过DexClassLoader动态加载后,可直接通过反射调用;对于新增的方法,可在补丁中添加对应的插桩分支逻辑,实现动态调用。
关键优势:无Hook系统API、无Native层操作,兼容性极强;支持方法级修复、新增类/方法;热部署生效,无需重启应用;稳定性高,避免了Native层操作带来的崩溃风险;兼容ProGuard、R8混淆。
核心局限:编译期插桩会增加APK体积(每个方法插入分支逻辑,通常增加5%-10%体积);插桩会带来轻微的性能损耗(方法调用时多一次分支判断);补丁制作依赖新旧APK对比,若APP体积较大,补丁生成速度较慢。
2. 核心代码实现(以美团Robust为例)
客户端补丁加载核心代码
// 美团Robust补丁加载管理器
public class RobustPatchManager {
private Context mContext;
// 全局ChangeQuickRedirect对象,用于控制方法切换
private ChangeQuickRedirect mChangeQuickRedirect;
public RobustPatchManager(Context context) {
this.mContext = context;
}
public void loadPatch(String patchPath) throws Exception {
// 1. 安全校验(签名+MD5)
verifyPatch(patchPath);
// 2. 动态加载补丁包(.jar格式)
DexClassLoader dexClassLoader = new DexClassLoader(
patchPath,
mContext.getCacheDir().getAbsolutePath(),
null,
mContext.getClassLoader()
);
// 3. 反射获取补丁中的修复类(PatchImp为补丁中定义的修复实现类)
Class<?> patchClass = dexClassLoader.loadClass("com.example.patch.PatchImp");
// 4. 初始化ChangeQuickRedirect,实现方法重定向
mChangeQuickRedirect = (ChangeQuickRedirect) patchClass.newInstance();
// 5. 将ChangeQuickRedirect设置为全局对象(让所有插桩方法可访问)
setGlobalChangeQuickRedirect(mChangeQuickRedirect);
}
// 安全校验(简化版)
private void verifyPatch(String patchPath) {
// 省略签名校验、MD5校验逻辑
}
// 设置全局ChangeQuickRedirect(通过反射修改全局变量)
private void setGlobalChangeQuickRedirect(ChangeQuickRedirect redirect) throws Exception {
Class<?> globalClass = Class.forName("com.example.myapp.GlobalConfig");
Field field = globalClass.getDeclaredField("changeQuickRedirect");
field.setAccessible(true);
field.set(null, redirect);
}
// 自定义ChangeQuickRedirect实现(补丁中定义)
public static class PatchImp implements ChangeQuickRedirect {
@Override
public boolean redirectMethod(Object target, String methodName, Object[] params) {
// 根据方法名匹配目标方法,执行修复逻辑
if ("showToast".equals(methodName)) {
Context context = (Context) params[0];
String msg = (String) params[1];
// 修复后的逻辑(例如修改Toast显示时长)
Toast.makeText(context, msg, Toast.LENGTH_LONG).show();
return true; // 返回true,表示已执行修复逻辑,无需执行原方法
}
return false; // 返回false,执行原方法逻辑
}
}
}
插桩后方法的字节码示例(ASM修改后)
// 插桩后的showToast方法(字节码反编译结果)
public void showToast(Context context, String msg) {
// 从全局配置中获取ChangeQuickRedirect对象
ChangeQuickRedirect changeQuickRedirect = GlobalConfig.changeQuickRedirect;
if (changeQuickRedirect != null && changeQuickRedirect.redirectMethod(this, "showToast", new Object[]{context, msg})) {
return;
}
// 原方法逻辑
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
}
(三)Dex动态加载方案:腾讯Tinker、饿了么Amigo(全量更新,适配复杂场景)
编译期插桩方案虽解决了兼容性问题,但存在APK体积增大、性能损耗的局限,且无法实现so库更新、大规模资源更新,针对这些痛点,Dex动态加载方案应运而生,核心定位是“全场景、大规模更新”,基于Android类加载机制,通过Dex差分合并、自定义类加载器,实现类替换、资源更新、so库更新的全量能力,需重启应用生效(冷部署),适配中大型APP的复杂迭代场景(如电商APP的营销活动迭代、游戏APP的资源更新)。
腾讯Tinker是该方案的代表,市场占有率最高,饿了么Amigo在Tinker的基础上,优化了多补丁管理能力,适配高频迭代场景,两者核心技术原理一致,以下以Tinker为核心讲解,补充Amigo的差异点。
1. 核心技术原理
Dex动态加载方案的核心逻辑是“类加载器优先级替换”,基于Android的DexClassLoader动态加载能力,通过对比新旧APK生成Dex差异补丁,客户端加载补丁后,将补丁Dex与本地原有Dex合并,生成新的Dex集合,再通过自定义类加载器替代系统类加载器,让虚拟机优先加载合并后的Dex中的类,实现类替换;同时通过Hook AssetManager实现资源更新,通过动态加载so库实现so更新,核心分为补丁制作、补丁加载、Dex合并、类替换四大环节,同时支持资源、so库更新。
(1)核心前置知识:Android类加载机制
Android中的类加载器分为PathClassLoader(系统默认,加载APK中的classes.dex)和DexClassLoader(动态加载外部Dex文件),两者均继承自BaseDexClassLoader,BaseDexClassLoader中包含一个dexElements数组,用于存储Dex文件的路径,虚拟机加载类时,会遍历dexElements数组,从第一个Dex中查找类,找到后立即加载,不再继续遍历。Dex动态加载方案的核心就是修改dexElements数组,将补丁Dex插入到数组最前方,让虚拟机优先加载补丁中的类。
(2)补丁制作环节(核心,TinkerPatch工具):
① 差分对比:通过Tinker官方提供的TinkerPatch工具,对比新旧APK文件,提取Dex、资源、so库的差异内容——对于Dex文件,采用BSDiff二进制差分算法,生成dexdiff差异补丁(体积极小,仅包含修改的字节码);对于资源文件,生成resdiff差异补丁(仅包含修改的图片、布局等);对于so库,生成sodiff差异补丁;
② 补丁打包:将dexdiff、resdiff、sodiff补丁及签名校验文件、补丁配置文件(记录补丁版本、适配APK版本)打包为.tinker格式的补丁包,同时生成补丁的MD5校验值,用于客户端校验补丁完整性。
(3)补丁加载环节:
① 安全校验:客户端获取.tinker补丁包后,先校验补丁的MD5值(确保文件完整)、签名(确保与APP签名一致,防止篡改)、补丁版本(确保适配当前线上APK版本),校验通过后解压补丁;
② 环境准备:初始化Tinker的加载层(LoadReporter、PatchReporter),用于监听补丁加载过程中的日志(如加载成功、加载失败),同时检查当前设备的系统版本、机型,判断是否支持补丁加载;
③ 分别加载三类补丁:Dex补丁、资源补丁、so补丁,其中Dex补丁的加载是核心。
(4)Dex合并与类替换(核心环节):
① 加载补丁Dex:通过DexClassLoader动态加载解压后的补丁Dex文件,获取补丁Dex对应的dexElements数组;
② 获取原有Dex:通过反射获取系统PathClassLoader中的dexElements数组(原有APK的Dex集合);
③ 合并Dex:创建新的dexElements数组,将补丁Dex的dexElements插入到新数组的最前方,再将原有Dex的dexElements拼接在后面,形成“补丁Dex优先”的Dex集合;
④ 替换类加载器:通过反射修改系统PathClassLoader的dexElements数组,将其替换为合并后的dexElements数组;同时创建自定义类加载器(TinkerClassLoader),替代系统类加载器,负责加载合并后的Dex;
⑤ 生效时机:由于类加载器的替换需要重新初始化,因此Dex补丁加载完成后,需重启应用才能生效(冷部署),重启后,虚拟机遍历dexElements数组时,会优先加载补丁Dex中的类,完成类替换(支持UI类、非UI核心类的替换)。
(5)资源更新与so库更新:
① 资源更新:通过Hook AssetManager的addAssetPath()方法,将补丁中的资源文件路径添加到AssetManager中,让AssetManager优先加载补丁中的资源(如图片、布局),需重启Activity生效(暖部署);
② so库更新:通过System.load()或System.loadLibrary()动态加载补丁中的so库,同时通过反射修改系统加载so库的路径,让应用优先加载补丁中的so库,需重启应用生效。
关键优势:支持类替换、资源更新、so库更新,覆盖全场景线上更新需求(非UI核心逻辑迭代、UI资源更新、底层so库修复);补丁体积小(基于二进制差分);无Native层Hook(仅反射修改类加载器,兼容性优于AndFix);支持补丁回滚(删除补丁,重启应用即可恢复原版本);适配绝大多数Android版本与机型,稳定性强。
核心局限:需重启应用生效(冷部署),无法实现紧急bug的实时修复;Dex合并过程中若出现异常,会导致应用闪退;反射修改类加载器,在部分定制化机型(如华为、小米部分机型)上可能被系统限制;不支持新增类(需通过插件化方案补充)。
3. 核心代码实现
(1)Dex合并与类加载器替换核心代码(简化版)
// Tinker的Dex合并工具类(简化版)
public class DexMergeUtil {
// 合并补丁Dex与原有Dex,修改类加载器的dexElements
public static void mergeDex(Context context, String patchDexPath) throws Exception {
// 1. 获取系统PathClassLoader
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
// 2. 获取原有Dex的dexElements数组
Field dexElementsField = BaseDexClassLoader.class.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
Object[] oldDexElements = (Object[]) dexElementsField.get(pathClassLoader);
// 3. 动态加载补丁Dex,获取补丁的dexElements数组
File patchDexFile = new File(patchDexPath);
File optimizedDirectory = context.getCacheDir(); // Dex优化目录
DexClassLoader patchClassLoader = new DexClassLoader(
patchDexFile.getAbsolutePath(),
optimizedDirectory.getAbsolutePath(),
null,
pathClassLoader
);
Object[] patchDexElements = (Object[]) dexElementsField.get(patchClassLoader);
// 4. 合并dexElements数组(补丁Dex在前,原有Dex在后)
Object[] newDexElements = new Object[oldDexElements.length + patchDexElements.length];
System.arraycopy(patchDexElements, 0, newDexElements, 0, patchDexElements.length);
System.arraycopy(oldDexElements, 0, newDexElements, patchDexElements.length, oldDexElements.length);
// 5. 替换系统类加载器的dexElements数组
dexElementsField.set(pathClassLoader, newDexElements);
Log.d("DexMerge", "Dex合并完成,共" + newDexElements.length + "个Dex");
}
}
(2)资源更新核心代码(Hook AssetManager)
// Tinker资源更新工具类(简化版)
public class ResourceUpdateUtil {
// Hook AssetManager,添加补丁资源路径
public static void updateResource(Context context, String patchResPath) throws Exception {
// 1. 获取AssetManager实例
AssetManager assetManager = AssetManager.class.newInstance();
// 2. 反射调用addAssetPath方法,添加补丁资源路径
Method addAssetPathMethod = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
addAssetPathMethod.setAccessible(true);
addAssetPathMethod.invoke(assetManager, patchResPath);
// 3. 替换Context中的AssetManager
Field assetManagerField = ContextImpl.class.getDeclaredField("mAssets");
assetManagerField.setAccessible(true);
assetManagerField.set(context, assetManager);
// 4. 替换Resources中的AssetManager,让资源生效
Resources resources = context.getResources();
Field resourcesAssetField = Resources.class.getDeclaredField("mAssets");
resourcesAssetField.setAccessible(true);
resourcesAssetField.set(resources, assetManager);
Log.d("ResourceUpdate", "资源更新完成,补丁路径:" + patchResPath);
}
}
三类方案核心对比总结
| 方案类型 | 代表框架 | 生效方式 | 核心能力 | 兼容性 | 适用场景 |
|---|---|---|---|---|---|
| Native层方法替换 | 阿里AndFix | 热部署(实时生效) | 仅方法级修复,无资源/so更新 | 中(低版本兼容,高版本受限) | 紧急bug快速修复(非UI/UI方法) |
| 编译期插桩 | 美团Robust | 热部署(实时生效) | 方法级修复、新增类/方法 | 高(全版本适配) | 全场景迭代,高兼容需求 |
| Dex动态加载 | 腾讯Tinker、Amigo | 冷部署(需重启) | 类替换、资源更新、so更新 | 高(多数机型适配) | 复杂场景更新,资源/so更新需求 |
第三阶段:官方规范与多元化演进阶段(2017年后)—— 合规优先,分层治理,全场景精细化适配
2017年后,Android系统持续收紧对动态加载、Hook行为的限制(如Android 7.0引入签名校验强化、Android 9.0限制非公开API调用),同时应用商店(华为、小米、应用宝等)出台明确政策,规范线上热更新行为,禁止未经审核的代码更新,避免恶意应用通过线上热更新植入病毒、篡改功能(含非UI核心业务逻辑)。此阶段的核心转变是“合规性优先”,官方推出标准化方案,民间框架迭代适配政策,线上热更新技术走向分层治理,结合UI与非UI场景实现差异化、精细化应用。
官方标准化方案的推出是此阶段的关键节点,Google针对线上热更新相关需求,推出了多个官方解决方案,逐步替代部分民间框架的核心场景,覆盖UI与非UI线上更新:
App Bundle与动态功能模块(Dynamic Feature Modules):2018年Google推出App Bundle打包格式,支持将应用拆分为基础包与多个动态功能模块,用户可按需下载动态模块,实现“动态功能更新”,兼顾UI与非UI场景。其核心逻辑是通过Google Play的后台下发,将新增或修改的功能模块(含UI组件模块、非UI业务模块)推送至用户设备,无需下载全量APK,本质是官方认可的合规化线上热更新方案,适用于功能扩展、场景化资源更新(UI)、非UI业务模块迭代(如算法模块、接口适配模块)。但该方案依赖Google Play,国内市场需适配各厂商的自有应用商店动态分发机制。
四、热更新的延伸:APK全量自分发与版本更新落地
前文所述的Android线上热更新,核心聚焦“局部修复/迭代”(代码片段、资源、单个类/方法),无需全量APK下发;而将热更新能力延伸为APK全量自分发的版本更新,本质是打通“局部热更”与“全量版本迭代”的协同体系,通过自定义分发通道实现完整APK的下发、校验、安装,替代传统应用商店分发,兼顾迭代灵活性与全量更新需求,适用于应用商店审核周期长、需定向推送新版本、线下场景全量更新等场景(如企业级APP、小众工具、定向运营的应用)。
构建“局部热更应急修复+全量APK自分发版本迭代”的双层体系:小bug用热更新快速止损,重大功能迭代、架构变更则通过全量APK自分发完成版本更新,既保留热更新的高效性,又解决热更新无法覆盖的全量迭代场景,实现从“临时修复”到“标准化版本更新”的能力升级。
4.1 核心定位:APK全量自分发与传统热更新、应用商店分发的差异
要实现热更新到APK全量自分发的延伸,需先明确其核心定位,区分与现有分发、更新方式的边界,避免功能混淆:
| 对比维度 | 传统热更新 | APK全量自分发(版本更新) | 应用商店分发 |
|---|---|---|---|
| 更新范围 | 局部(类、方法、资源) | 全量(完整APK,覆盖所有代码/资源) | 全量(完整APK) |
| 生效方式 | 热部署/暖部署(多无需重启) | 冷部署(需安装后重启应用) | 冷部署(需安装后重启应用) |
| 分发通道 | 自定义服务器(后台静默下发) | 自定义服务器(主动推送/用户触发) | 应用商店官方通道 |
| 合规性 | 需适配应用商店政策(禁止未经审核的代码更新) | 看厂商是否同意 | 官方审核,合规性最高 |
| 适用场景 | 紧急bug修复、小功能迭代 | 重大功能迭代、架构变更、定向版本推送 | 面向全量用户的标准化版本更新 |
4.2 技术实现
dex包体热更,参照Tinker的方案,使用ClassLoader加载
private fun installDexOnN(context: Context, dexFiles: List<File>, dexDir: File) {
Logger.i(TAG, "Install DEX for Android N+")
val classLoader = context.classLoader
// 获取 pathList 字段
val pathListField = ReflectUtil.getClassLoaderField(classLoader, "pathList")
val pathList = pathListField.get(classLoader)
// 创建临时的 DexClassLoader 加载新的 DEX
val optimizedDir = File(dexDir, "oat")
optimizedDir.mkdirs()
val dexPath = dexFiles.joinToString(File.pathSeparator) { it.absolutePath }
val dexClassLoader = DexClassLoader(
dexPath,
optimizedDir.absolutePath,
null,
classLoader
)
// 获取新的 dexElements
val newPathListField = ReflectUtil.getClassLoaderField(dexClassLoader, "pathList")
val newPathList = newPathListField.get(dexClassLoader)
val newDexElementsField = newPathList.javaClass.getDeclaredField("dexElements")
newDexElementsField.isAccessible = true
val newDexElements = newDexElementsField.get(newPathList)
// 合并 dexElements
val dexElementsField = pathList.javaClass.getDeclaredField("dexElements")
dexElementsField.isAccessible = true
val oldDexElements = dexElementsField.get(pathList)
val mergedDexElements = combineArray(newDexElements, oldDexElements)
dexElementsField.set(pathList, mergedDexElements)
Logger.i(TAG, "DEX elements merged successfully")
// 验证加载
verifyDexLoaded(dexFiles)
}
Resource加载利用instantRun的相同方案addAssetPath实现
fun loadResources(context: Context, apkPath: String) {
Logger.i(TAG, "Start load resources from: $apkPath")
val startTime = System.currentTimeMillis()
try {
// 创建新的 AssetManager
val assetManager = AssetManager::class.java.newInstance()
// 添加资源路径
val addAssetPathMethod = AssetManager::class.java.getDeclaredMethod(
"addAssetPath",
String::class.java
)
addAssetPathMethod.isAccessible = true
val cookie = addAssetPathMethod.invoke(assetManager, apkPath) as? Int
if (cookie == null || cookie == 0) {
throw RuntimeException("addAssetPath failed, cookie is 0")
}
Logger.d(TAG, "addAssetPath success, cookie: $cookie")
// 创建新的 Resources 对象
val resources = context.resources
newResources = Resources(
assetManager,
resources.displayMetrics,
resources.configuration
)
// 替换 ContextImpl 中的 Resources
replaceContextResources(context, newResources!!)
// 替换 LoadedApk 中的 Resources
replaceLoadedApkResources(context, newResources!!)
val elapsed = System.currentTimeMillis() - startTime
Logger.i(TAG, "Load resources success, elapsed: ${elapsed}ms")
} catch (e: Exception) {
Logger.e(TAG, "Load resources failed", e)
throw e
}
}
so 路径hook
private fun installSoOnN(context: Context, soFiles: List<File>) {
Logger.i(TAG, "Install SO for Android N+")
val classLoader = context.classLoader
// 获取 pathList 字段
val pathListField = ReflectUtil.getClassLoaderField(classLoader, "pathList")
val pathList = pathListField.get(classLoader)
// 获取 nativeLibraryDirectories 列表
val nativeLibraryDirectoriesField = pathList.javaClass.getDeclaredField("nativeLibraryDirectories")
nativeLibraryDirectoriesField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val nativeLibraryDirectories = nativeLibraryDirectoriesField.get(pathList) as MutableList<File>
// 添加新的 SO 目录
val soDir = soFiles.firstOrNull()?.parentFile
if (soDir != null && !nativeLibraryDirectories.contains(soDir)) {
nativeLibraryDirectories.add(0, soDir)
Logger.d(TAG, "Added SO directory: ${soDir.absolutePath}")
}
// 更新 nativeLibraryPathElements(Android 7.0+)
try {
val nativeLibraryPathElementsField = pathList.javaClass.getDeclaredField("nativeLibraryPathElements")
nativeLibraryPathElementsField.isAccessible = true
// 使用反射调用 makePathElements 来创建新的 path elements
val makePathElementsMethod = pathList.javaClass.getDeclaredMethod(
"makePathElements",
List::class.java
)
makePathElementsMethod.isAccessible = true
val newElements = makePathElementsMethod.invoke(pathList, nativeLibraryDirectories)
nativeLibraryPathElementsField.set(pathList, newElements)
Logger.d(TAG, "Updated nativeLibraryPathElements")
} catch (e: NoSuchFieldException) {
Logger.w(TAG, "nativeLibraryPathElements field not found, might not be critical", e)
} catch (e: NoSuchMethodException) {
Logger.w(TAG, "makePathElements method not found, might not be critical", e)
}
Logger.i(TAG, "SO installation on N+ completed")
}
四大组件的hook与插件化一致,利用占坑的组件来躲避系统的校验。
Activity hook逻辑:
/**
* Hook Activity 启动
* 注意:此方法不能被混淆
*
* 占坑模式核心逻辑:
* 1. 检查目标 Activity 是否已在 AndroidManifest 中注册
* 2. 如果未注册,检查是否在热更新 APK 中存在
* 3. 如果存在,替换为占坑 Activity
* 4. 如果不存在,抛出异常阻止启动
* 5. 保存真实 Activity 信息,在 newActivity 时恢复
*/
@Throws(Exception::class)
fun execStartActivity(
who: Context?,
contextThread: IBinder?,
token: IBinder?,
target: Activity?,
intent: Intent?,
requestCode: Int,
options: Bundle?
): ActivityResult? { // 返回值可能为 null
Logger.d(TAG, "execStartActivity called")
// 处理 Intent
if (intent != null && intent.component != null) {
val targetClass = intent.component?.className
Logger.d(TAG, "Target activity: $targetClass")
// 检查目标 Activity 是否已注册
if (targetClass != null && who != null) {
val isRegisteredInMain = ComponentManager.isActivityRegisteredInMain(targetClass)
if (!isRegisteredInMain) {
Logger.i(TAG, "⚠️ Activity not registered in main APK: $targetClass")
// 检查是否在热更新 APK 中存在
val isInHotUpdate = ComponentManager.isActivityInHotUpdate(targetClass)
if (isInHotUpdate) {
Logger.i(TAG, "✅ Activity found in hot update APK")
Logger.i(TAG, "🔄 Using stub activity for replacement")
// 保存真实 Activity 信息到 Intent
intent.putExtra(KEY_REAL_ACTIVITY, targetClass)
// 根据启动模式选择对应的占坑 Activity
val stubActivity =
ComponentManager.getStubActivityForRealActivity(targetClass)
val stubComponent = ComponentName(who.packageName, stubActivity)
intent.component = stubComponent
Logger.i(TAG, "✅ Replaced with stub activity: $stubActivity")
} else {
// Activity 既不在主 APK 中,也不在热更新 APK 中
Logger.e(TAG, "❌ Activity not found: $targetClass")
Logger.e(TAG, " - Not in main APK")
Logger.e(TAG, " - Not in hot update APK")
throw ClassNotFoundException("Activity not found in main APK or hot update APK: $targetClass")
}
} else {
Logger.d(TAG, "✅ Activity registered in main APK")
}
}
}
// 使用反射调用原始的 execStartActivity 方法
val result =
normalStartActivity(who, contextThread, token, target, intent, requestCode, options)
Logger.d(TAG, "execStartActivity completed successfully")
return result
}
/**
* 使用反射调用原始 Instrumentation 的 execStartActivity 方法
*/
@SuppressLint("PrivateApi")
@Throws(Exception::class)
private fun normalStartActivity(
who: Context?,
contextThread: IBinder?,
token: IBinder?,
target: Activity?,
intent: Intent?,
requestCode: Int,
options: Bundle?
): ActivityResult? { // 返回值可能为 null
val execMethod = Instrumentation::class.java.getDeclaredMethod(
"execStartActivity",
Context::class.java,
IBinder::class.java,
IBinder::class.java,
Activity::class.java,
Intent::class.java,
Int::class.javaPrimitiveType,
Bundle::class.java
)
return try {
execMethod.invoke(
base,
who,
contextThread,
token,
target,
intent,
requestCode,
options
) as? ActivityResult // 使用安全转换,允许 null
} catch (e: java.lang.reflect.InvocationTargetException) {
// 反射调用的异常会被包装成 InvocationTargetException
// 取出真正的异常并抛出
throw e.targetException ?: e
}
}
/**
* Hook Activity 创建
* 注意:此方法不能被混淆
*
* 占坑模式核心逻辑:
* 1. 检查 Intent 中是否包含真实 Activity 信息
* 2. 如果是占坑 Activity,替换为真实 Activity
* 3. 使用热更新的 ClassLoader 加载真实 Activity
*/
@Throws(
InstantiationException::class,
IllegalAccessException::class,
ClassNotFoundException::class
)
override fun newActivity(cl: ClassLoader, className: String, intent: Intent): Activity {
Logger.d(TAG, "newActivity: className=$className")
// 设置 Intent 的 ClassLoader(关键!)
intent.setExtrasClassLoader(cl)
// 检查是否是占坑 Activity
val realActivityClass = intent.getStringExtra(KEY_REAL_ACTIVITY)
val activity: Activity = if (!realActivityClass.isNullOrEmpty()) {
// 这是一个占坑 Activity,需要替换为真实 Activity
Logger.i(TAG, "🔄 Stub activity detected")
Logger.i(TAG, "Stub: $className")
Logger.i(TAG, "Real: $realActivityClass")
// 使用原始 Instrumentation 和热更新的 ClassLoader 加载真实 Activity
base.newActivity(cl, realActivityClass, intent).also {
Logger.i(TAG, "✅ Real activity created successfully: $realActivityClass")
}
} else {
// 正常 Activity,直接创建
base.newActivity(cl, className, intent).also {
Logger.d(TAG, "✅ Activity created successfully: $className")
}
}
return activity
}
其余组件hook细节不做描述,可以直接参考github.com/huarangmeng… 的源码
当业务本身需要频繁的发布版本,商店不一定能够更新时,可以使用此套思路完成版本发布。