上周五线上项目出现了紧急缺陷,无奈之下周六苦逼加班发补丁,唯一值得欣慰的是由于出现缺陷的功能会在今天通过 ABTest 下发,补丁赶在了大推之前。刚好周日在家闲着,就写一下「救我于水深火热的热修复」。
希望当你看完这篇文章之后,能够了解到应用热修复它并不难,也不需要自己造轮子,业界很多优秀的框架如Tinker,Robust,Sophix等。
如果项目还没有支持这个热更能力,希望你能尝试折腾慢慢接入,这不仅仅能学习到新知识也能为服务项目提供容错能力。
文章篇幅比较长,希望各位看官能耐心看完,掌握整体思路并有所收获。
编程是为了业务解决问题,学习编程的核心是掌握程序实现的思路,而代码只是一种实现程序的工具。
下面从文章围绕 技术原理-技术选型及实践流程展开。
技术原理
热修复按照类修复时机可分为类冷修复及类热更新。
所谓类冷修复是指应用重启之后通过加载修复后的类文件来修复类已知的问题,而类热更新则是不需要重启应用前提下修复类已知的问题。
另外热更修复的对象还可包括SO库及资源文件。
下面针对类冷修复,类热更新,SO库修复及资源文件修复进行了解。
类冷修复
一个Class文件若已被JVM虚拟机所加载,只能通过重启手段解决来清除虚拟机中已保存的类信息。
我们以QZone插桩方案 和微信tinker方案 方案为分析,并引用Sophix方案的法做对比。
QZone方案
一个ClassLoader可加载多个DEX文件,每一个DEX文件被加载后在内存中表现为一个 Element 对象,多个DEX文件被加载后则排列成一个有序数组 dexElements。 对于 ClassLoader 不熟悉的朋友,建议先看看链接里的储备知识。
如果类已被ClassLoader加载,那么查找其对应 class 对象是通过调用 findClass(String name, List
这个方案涉及到类校验,可能会因为DEX文件被优化而导致异常:当我们第一次安装 APK 时,虚拟机如果检测到有一项 verify 参数被打开,则会对DEX文件执行 dexopt 优化。如果使用上述方案插入一个DEX文件,则会先执行 dexopt,这个过程可能会抛出异常 dvmThrowIllegalAccessError。
通过截取 DexPrepare.cpp#verifyAndOptimizeClass 核心代码并做注释阐述:
static void verifyAndOptimizeClass(DexFile* pDexFile, ClassObject* clazz,
const DexClassDef* pClassDef, bool doVerify, bool doOpt)
{
if (doVerify) {
// 目的在于防止类外部被篡改。
// 会对类的 static 方法,private 方法,构造函数,虚函数(可被继承的函数) 进行校验。
// 如果类的所有方法中直接引用到的第一层类和当前类是在同一个 dex 文件,则会返回 true
if (dvmVerifyClass(clazz)) {
// 如果满足校验规则,则打上 CLASS_ISPREVERIFIED,设置 verified 为 true
((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISPREVERIFIED;
verified = true;
}
}
if (doOpt) {
bool needVerify = (gDvm.dexOptMode == OPTIMIZE_MODE_VERIFIED ||
gDvm.dexOptMode == OPTIMIZE_MODE_FULL);
if (verified || needVerify) {
//把部分指令优化成虚拟机内部指令,为了提升方法的执行速度。
dvmOptimizeClass(clazz, false); //Optimize class
// 再打上 CLASS_ISOPTIMIZED
((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISOPTIMIZED;
}
}
}
所以只需要理解如果一个类满足校验条件,就会被打上 CLASS_ISPREVERIFIED。具体做法是去校验 Class 所有 directMethod 和 virtualMethod,包含了:
- static 方法
- private 方法
- 构造器方法
- 虚函数
- ...
这些方法中第一层级关系引用到的类是在同一个DEX文件,则会被打上校验通过被打上CLASS_ISPREVERIFIED。
那么被打上 CLASS_ISPREVERIFIED 那么为何会有异常呢?
假如原先有个DEX文件中类B引用了类A,旧的类A与类B在同一个DEX文件,则B会被打上CLASS_ISPREVERIFIED,现在修复DEX文件包含了类A,当类B某个方法引用到类A时尝试去解析类A。
通过截取Resolve.cpp#dvmResolveClass 核心代码并做注释阐述:
ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx,bool fromUnverifiedConstant){
if (resClass != NULL) {
//此时 B 类已经被打上 CLASS_ISPREVERIFIED,满足条件
if (!fromUnverifiedConstant &&
IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED))
{
//被引用类 A
ClassObject* resClassCheck = resClass;
//发现类 A 和 类 B 不在同一个 dex
if (referrer->pDvmDex != resClassCheck->pDvmDex &&
resClassCheck->classLoader != NULL)
{
dvmThrowIllegalAccessError(
"Class ref in pre-verified class resolved to unexpected "
"implementation");
return NULL;
}
}
dvmDexSetResolvedClass(pDvmDex, classIdx, resClass);
}
}
为了解决类校验的问题,需要避免类被打上CLASS_ISPREVERIFIED,那么只需要保证 dvmVerifyClass 返回 false 即可。
QZone 的做法是使用字节码修改技术,在所有 class 构造器中引用一个 帮助类,该类单独存放在一个DEX文件中,就可以实现所有类都不会被打上 CLASS_ISPREVERIFIED 标志,进而避免在 dvmResolveClass 解析中出现异常。
上述例子类B由于引用类帮助类进而不会被打上CLASS_ISPREVERIFIED,所以加载修复后的类A也不会有问题。
当然这样的做法也存在的问题与限制:由于类的加载涉及 dvmResolveClass,dvmLinkClass 和 dvmInitClass 三个阶段。
dvmInitClass 会在类解析完并尝试初始化类时执行,如果类没有被打上CLASS_ISPREVERIFIED 或 CLASS_ISOPTIMIZED,校验和优化都会在该阶段进行。
正常情况下类的校验和优化应该在 APK 第一次安装的时候执行 dexopt 操作时执行,但是我们干预了CLASS_ISPREVERIFIED的设置流程导致在同一时间加载大量类且进行校验及优化,容易在应用启动时出现白屏。
手Q方案
为了避免插桩带来的性能问题,手Q则选择在 dvmResolveClass 避开了 CLASS_ISPREVERIFIED 相关逻辑。
参考上面 Resolve.cpp#dvmResolveClass的核心逻辑可知:
ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx,bool fromUnverifiedConstant){
DvmDex* pDvmDex = referrer->pDvmDex;
//从dex缓存中查找类 class,则直接返回
resClass = dvmDexGetResolvedClass(pDvmDex, classIdx);
if (resClass != NULL)
return resClass;
//... resClass赋值工作
if (resClass != NULL) {
//记住 fromUnverifiedConstant 这个变量
if (!fromUnverifiedConstant &&IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED)){
//...类校验流程
}
//已经解析的类放入 dex 缓存
dvmDexSetResolvedClass(pDvmDex, classIdx, resClass);
}
}
- dvmDexGetResolvedClass 方法是尝试从 dex 缓存中查找引用的类,找到了就直接返回;
- dvmDexSetResolvedClass 方法是将已经解析的类存入 dex 缓存中。
所以只需要将补丁类A提前解析并设置 fromUnverifiedConstant 为 true 绕过类校验,然后把A存储 dex 缓存中就可以达到效果。这一步可以通过 jni 主动调用 dalvik#dvmRsolveClass 方法实现。
后续引用到该补丁类 A 的时候就可以直接从 dex 缓存中找到。当类B在校验是否和类A在同一个 dex时是通过以下条件:
referrer->pDvmDex != resClassCheck->pDvmDex
如果不打破这个条件,依然会出现异常。所以对补丁类A进行 dex 缓存时拿到的 pDvmDex 应该指向原来类A所在的 dex 。
那么在 dalvik#dvmRsolveClass 的过程中,referrer 和 classIdx 要怎么确定?
- referrer 为和原类同个 dex 下的一个任意类即可。但是需要调用 dvmFindLoadedClass 来实现,在补丁注入之后,在每个 dex 中找一个已经成功加载的引用类的描述符作为参数来实现。比如主 dex 就用 Application 类描述符。其他 dex,手Q*确保了每一个份 dex* 有一个空类完成初始化,使用的是空类的描述符。
- classIdx 为原类 A 在所 dex 下的类索引 ID,通过dexdump -h指令获取。
这套方案可完美避开插桩所带来的类校验影响,但假如在某个待修复多态类中新增方法,可能会导致修复前类的 vtable 的索引与修复后类的 vtable 索引对不上。因此修复后的类不能新增 public 函数,同样QZone也存在这样的问题。所以只能寻找全量合成新 dex文件的方案。
Tinker方案
tinker方案是全量替换 DEX 文件。
使用自研算法通过计算重新生成新的DEX文件与待修复的DEX文件差异进而得到新的DEX文件,该DEX文件文件被下发到客户端与待修复的DEX文件重新进行合并生成新的全量DEX文件,并把其加载后插到 dexElements 数组的最前面。
与QZone方案不一样的是,由于被修复的类与原类是在同一个DEX文件,所以不存在类校验问题。
由于不同 Android 虚拟机下采用不同的 DEX 加载逻辑,所以在处理全量 DEX 时也有差异。
比如Dalvik虚拟机 调用 Dalvik_dalvik_system_DexFile_openDexFileNative来加载 DEX 文件,如果是一个压缩包则只会加载第一个 DEX 文件。而art虚拟机 则是调用 LoadDexFiles, 加载的是 oat 中多个 DEX 文件。
在Art虚拟机加载的压缩包下,可能存在多个DEX文件,main dex为classes.dex,其他的DEX文件依次命名为 classes(2,3,4...)dex。假如某个classesNdex出现了问题,tinker 会重新合成 classesNdex 。修复流程为:
- 保留原来修复前classesNdexDex 文件
- 获取修复后的classedNdexFixdex 文件
- 使用算法计算得到classesNdexPatch补丁文件
- 下发classesNdexPatch补丁文件在客户端与classesNdexDEX 文件进行合并,得到classedNdexFixDex 文件
- 重启应用,提前加载classedNdexFixDex 文件修复问题。
这种全量合成修复 DEX文件 的做法,确保了复前后的类在同一个DEX文件中,遵循原来虚拟机所有校验方式,避开了QZone方案面临的类校验问题。
Sophix方案
阿里Sophix方案认为
既然 art 能加载压缩文件中的多个 dex 且优先加载 classes.dex,如果把补丁 dex 作为 classes.dex,然后 apk 中原来的 dex 改成 classes(2,3,4...)dex,然后重新打包压缩文件,让 DexFile.loadDex 得到 DexFile 对象,并最终替换掉旧的 dexElements 数组就可以了。
但是这种方案下,Art虚拟机需要重新加载整个压缩文件,针对每一个 dex 执行 dexoat 来得到 odex 的过程是很耗时的。需要把整个过程事务化,在接收到服务端补丁之后再启动一个子线程在后台进行异步处理。如果下次重启之后发现存在处理完的完整 odex 文件集,才进行处理。
同时认为
针对 dalvik 下,全量合成 dex 可参照 multi-dex 方案,在原来 dex 文件中剔除需要修复的类,然后再合并进修复的类。并不需要像 tinker 方案中针对 dex 的所有内容进行比较,粒度非常细也非常复杂,以类作为粒度作为替换是较佳选择。
但是如果 Application 加载了新 dex 的类 Application 刚好被打上 CLASS_ISPREVERIFIED ,那么就会面临前面 QZone 方案的类校验问题,实际上所有全量合成的方案都会面临这个问题。 tinker 使用的是 TinkerApplication 接管应用 Application 并在生命周期回调的时候反射调用原 Application 的对应方案。而 Sophix 也是使用 SohpixStubApplication 做了类似的事情。
小结一波
由于涉及的技术非常多,细致的实现可参考其各框架方案的开源代码,重点了解大致流程。冷启动方案几乎可以修复任何代码场景,但是补丁注入前已经被加载的类,如 Application 等是无法被修复的。综合上面的多种方案可以得到针对不同虚拟机的优先冷启动方案:
- Dalvik 虚拟机下使用类 multi-dex 全量方案避免插桩的方案
- Art 虚拟机下使用补丁类作为 classes.dex 重新打包压缩文件进行加载的方案
类热更新
类热更新指的是在不需要重启应用的前提下修复类的已知问题。
如果一个类已被虚拟机所加载后要修正该类的某些方法,只能通过实现类热更新来实现:在 navite 层替换到对应被虚拟机加载过的类的方法。
以阿里开源项目Andfix及Sophix方案为分析。
- AndFix#replaceMethod(Method src,Method dest)") 为 Java 层替换错误方法的入口,通过 JNI 调用 Navite 层代码
- andifx#replaceMethod 为 Navite 层被上层所调用的代码,对虚拟机内的方法进行 ”替换“
static void replaceMethod(JNIEnv* env, jclass clazz, jobject src,jobject dest) {
if (isArt) {
art_replaceMethod(env, src, dest);
} else {
dalvik_replaceMethod(env, src, dest);
}
}
代码区分了Dalvi虚拟机和Art虚拟机的不同实现。
extern void __attribute__ ((visibility ("hidden"))) art_replaceMethod(JNIEnv* env, jobject src, jobject dest) {
if (apilevel > 23) {
replace_7_0(env, src, dest);
} else if (apilevel > 22) {
replace_6_0(env, src, dest);
} else if (apilevel > 21) {
replace_5_1(env, src, dest);
} else if (apilevel > 19) {
replace_5_0(env, src, dest);
}else{
replace_4_4(env, src, dest);
}
}
但是不同虚拟机版本,由于虚拟机底层数据结构并不相同,所以还进一步针对不同 Android 版本再做区分。
这就头大了啊。这里以 6.0 版本的Art虚拟机的替换流程简单讲一下。
每一个 Java 方法在Art虚拟机内都对应一个 art_method 结构,用于记录 Java 方法的所有信息,包括归属类,访问权限,代码执行地址等。然后对这些信息进行逐一替换,替换完之后再次调用替换方法就可直接走新方法逻辑。
当 Java Code 被编译处理成 Dex Code 之后,Art虚拟机 加载并可通过解释模式或者 AOT 模式执行。要在热更之后调用新方法就得替换方法执行入口。
解释模式下通过获取 art_method.entry_point_from_jni_ 方法获取执行入口,而 AOT 模式模式则调用 art_method.entry_point_from_jni_ 获取。
除了获取执行入口替换外,还需要保证方案使用的 art_method_replace_6_0#replace_6_0 数据结构与安卓源码 art_method 数据结构完全一致才可以。但由于各种厂商存在对 ROM 进行魔改,难以保证能够修复成功。
针对上述兼容问题,Sophix探索出了一种突破底层结构差异的方法。
这种方法把一个art_method 看成了一个整体进行替换而不必针对每个版本 ArtMethod 严格控制内容。换句话说,只要知道当前设备 art_method 的长度,就可以把整个结构体完全替换掉。
由于 ArtMethod 是紧密排列的,所以相邻两个 ArtMethod 的起始地址差值就是 ArtMethod 的大小。通过定义一个简单类 NativeMethodCal 来模拟计算。
public class NativeMethodCal{
final public static void f1(){}
final public static void f2(){}
}
两个方法属于static方法 且该类只有这两个方法,所以必定相邻,Native 层的替换可为
void replacee(JNIEnv* env, jobject src, jobject dest) {
//...
size_t firMid = (size_t) env->GetStaticMethodID(nativeMethodCalClazz,"f1","()V");
size_t secMid = (size_t) env->GetStaticMethodID(nativeMethodCalClazz,"f2","()V");
size_t methodSize = secMid - firMid
memcpy(smeth,dmeth, methodSize);
}
小结一波
了解了两种方案在 Native 层的类热更思路及作用,但这两种方案也存在一些限制与问题:
- 针对反射调用非静态方法产生的问题。这类问题只能通过冷启动修复,原因是反射调用的 invoke 底层回调用到 InvokeMethod,该方法会校验反射的对象和是不是ArtMethod的一个实例,但方案替换了ArtMethod导致校验失败。
- 不适合类发生结构变化的修改。比如增删方法可能引起类及 Dex 方法数变化,进而改变方法索引。同样地,增删字段也会更改方法索引。
资源修复
资源修复是很常见的操作,资源修复方案很多参考InstantRun的实现,InstantRun资源修复核心流程大致如下:
- 构建一个新的AssetManager对象,并调用addAssetPath添加新的资源包;
- 修改所有Activity的 Activity.mAssets(AssetManager实例) 的引用指向新构建的AssetManager对象;
- 修改所有Resource的 Resource.mAssets(AssetManager实例) 的引用指向新构建的AssetManager对象.
对于任意的资源包,被 AssetManager#addAssetPath 添加之后,解析resourecs.asrc并在 Native 层 mResources 侧保存起来。可参考 AssetManager.h 的实现。
实际上 mResources 是一个ResTable结构体,存放resourecs.asrc信息用的。而且一个进程只会有一个ResTable。
ResTable 可加载多个资源包,一个资源包都包含一个resourecs.asrc ,每一个resourecs.asrc 记录了该包的所有资源信息,每一个资源对应一个ResChunk。
每一个ResChunk都有唯一的编号,由该编号由三部分构成,比如0x7f0e0000,可以随便找一个 APK 解包查看 resourecs.asrc 文件。
- 前两位 0x7f 为 package id,用于区分是哪个资源包
- 接着两位 0x0e 为 type id,用于区分是哪类型资源,比如 drawable,string 等
- 最后四位 0x0000 为 entry id,用于表示一个资源项,第一个为 0x0000,第二个为 0x0001 依次递增。
值得注意的是,系统的资源包的 package id 为 0x01,我们的 apk 为 0x7f
在应用启动之后,ResourceManager在构建AssetManager时候就已经加载了 APK 包的资源和系统的资源。
补丁下发的资源 packageId 也会是 0x7f ,我们使用已有的AssetManager进行加载,在Android L版本之后这些内容会继续追加到已经解析资源的后面。
由于相同的 packageId 的原因,有可能在获取某个资源是原 APK 已经存在近而忽略了补丁的新资源。故 类InstantRun方案只有AssetManager被完全替换才有效。
假如完整替换AssetManager ,则需要完整的资源包。补丁包需要通过修复前后的资源包经过差异计算之后下发,客户端接收并合成完整的新资源包,运行时可能会耗费较多的时间和内存。
Sophix给出了一种可以不用重新合成资源包的方案,该方案可被应用到Android L及后续版本。
同样是比较新旧资源包得到补丁资源包,然后通过修改补丁资源包的 packageId 为 0x66 ,并利用已有的AssetManager直接使用。这个补丁资源包要遵循以下规则:补丁包只包含新增的资源,包含纯新增的资源和修改旧包的资源,不包含旧包需要删除的资源。
- 纯新增的资源,代码处直接引用该资源;
- 旧包需要修改的资源,则新增修改后的对应资源,然后把代码处资源引用指向修改后资源;
- 旧包需要删除的资源,则代码处不引用该资源就好。(虽然会占着坑)
使用新资源包进行编译,代码中可能出现资源 ID 偏移,需修正代码处的资源引用。
举个。
比如原来有一个Drawable在代码的引用为 0x7f0002,由于新资源包新增了一个Drawable,导致原Drawable在代码的引用为0x7f0003。
这个时候就需要把代码引用更改回原来的 0x7f0002。因为Sophix 加载的是 packageId 为 0x66 的补丁包而不是重新合成新的资源包。同时,对于使用到补丁包内的资源,其引用也需改成对应补丁资源引用 0x66????(????为可改变)。
但是这种做法会导致构建补丁资源时非常复杂,需要懂得分析新旧资源包的resources.asrc及对系统资源加载流程十分了解才行。
针对 Android KitKat及以下版本,为了避免和InstantRun一样创建新的AssetManager并做大量反射修改工作,对原 AssetManager 对象析构和重构。
具体做法是让 Native 层的AssetManager释放所有已加载的旧资源,然后把 Java 层的AssetManager对其的引用设置为 null。同时 Java 层的AssetManager重新调用 init 方法驱动 Native 创建一个没有加载过资源的 AssetManager。
这样一来,java 层上层代码对AssetManager引用就不需要修改了,然后在对其调用 AddAssetPath 添加所有资源包就可以了。
小结一波
资源修复整体是围绕AssetManager展开,本文也只是记录了大体的思路,学习一下著名框架的设计思路及解决问题方法。中间细节自然存有一些难点兼容点需被攻克,感兴趣可查看文章末端参考资料中的书籍。
SO修复
要理解 so 如何被修复得先了解系统如何加载 so 库。
安卓有两种加载 so 库的方法。
- 调用 System.loadLibrary 方法,接收一个 so 的名称作为参数进行加载。对于 APK 而言,其libs目录下的 so 文件会被复制到应用安装目录并完成加载;
- 调用 System.load 方法 方法,接收一个 so 的完整路径作为参数进行加载。
系统加载完 so 库之后需要进行注册,注册也分静态注册和动态注册
静态注册使用 Java_{类完整路径}_{方法名} 作为 native 的方法名。当 so 已经被加载之后,native 方法在第一次被执行时候就会完成注册。
public class Test{
public static native String test();
}
extern "C" jstring Java_com_effective_android_test(JNIEnv *env,jclass clazz)
动态注册借助 JNI_OnLoad 方法完成绑定。当 so 被加载时会调用 JNI_OnLoad 方法进行注册。
public class Test{
public static native void testJni();
}
void test(JNIEnv *env,jclass clazz){
//native 实现逻辑
}
//申明列表
JNINativeMethod nativeMethods[] = {
{"test","()V",(void *) test}
}
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm,void *reserved){
//实现注册
jclass clz = env->FindClass("com/effective/android/Test");
if(env->RegisterNatives(clz, nativeMethods,sizeOf(nativeMethods)/sizeOf(nativeMethods[0])) != JNI_OK){
return JNI_ERR;
}
//...
}
在修复在上述两种注册场景的 so 会存在局限:
针对动态注册场景
- 对于Art虚拟机需要再次加载补丁 so 来完成方法映射的更新;
- 对Dalvik虚拟机则需要对补丁 so 重命名来完成 Art 下方法映射的更新。
针对静态注册场景
- 解除已经完成静态注册的方法工作难度大;
- so 中哪些静态注册的方法需要更新也很难得知。
由于涉及补丁 so 的二次加载,内存损耗大,可能导致JNI OOM出现。同时如果动态注册 so 场景下中新增了一些方法但是对应的 DEX文件 中没有与之对应的方法,则会出现 NoSuchMethodError 异常。
虽然困难,但是方案也是有的。
假如在在应用加载 so 之前能够先尝试加载补丁 so 再加载应用 so,就可以实现修复。
比如自定义一个方法,替换掉 System.loadLibrary 方法来完成这个逻辑,但是存在一个缺点就是很难修复已经混淆编译的第三方库。
所以最后采取的是类似类修复的注入方案。so 库被加载之后,最终会在 DexPathList.nativeLibararyDirectories/nativeLiraryPathElements 变量所表示的目录下遍历搜索到。前者nativeLibararyDirectories是 SDK<23 时的目录,后者nativeLibararyDirectories是 SDK>=23 时的目录,只需要把补丁 so 的路径插入到他们目录的最前面即可。
但是 so 库文件存在多种 CPU 架构,补丁和 apk 一样都存在需要选择哪个abi的 so 来执行的问题。
Sophix 提供了一种思路, 通过从多个abis目录中选择一个合适的primaryCpuAbi目录插到 nativeLibararyDirectories/nativeLiraryPathElements 数组中。
- SDK>=21时直接反射拿到 ApplicationInfo 对象的primaryCpuAbi。
SDK<21时由于不支持 64 位所以直接把 Build.CPU_ABI, Build.CPU_ABI2 作为primaryCpuAbi。
具体可实现为以下逻辑。
ApplicationInfo mAppInfo = pm.getApplicationInfo(mApp.getPackageName(),0);
if(mAppInfo != null){
// SDK>=21
if(Build.VERSION>SDK_INT >= Build.VERSION_CODES>LOLLIPOP){
File thirdFiled = ApplicationInfo.class.getDeclaredFiled("primaryCpuAbi");
thirdFiled.setAccessable(true);
String cupAbi = (String) thirdFiled.get(mAppInfo);
primaryCpuAbis = new String[](cpuAbi "");
}else{
primaryCpuAbis = new String[](Build.CPU_ABI,Build.CPU_ABI2 "");
}
}
方案选型
两年前在旧的团队预研热修复的时候,我们选择了tinker。现在所在的团队也还是tinker。
对于中小团队而言,我们选择方案一般需要:兼容性强,修复范围广,免费,开源社区活跃。
- 兼容性强,需要兼容 Android 的所有版本,我们也尝试过AndFix,QZone等方案,基本Android N之后就放弃了;
- 修复范围广,除了能修复类场景,资源,so 也需要考虑;
- 免费,一开始AndFix时最简单易用,后面转sophix后收费就放弃了。如果有金主爸爸可以忽略,sophix非常简单易用,上述原理技术也参考了sophix 的技术方案,非常优秀;
- 社区活跃,目前tinker的开源维护还算不错。
故我们最终选择以tinker作为热修复方案技术框架来实现热修功能。
集成与实践流程
Tinker 集成
在我们项目中,tinker相关代码是作为Service层中的一个模块。模块包含以下信息:
- 代码目录,包含tinker提供的所有库及项目封装的代码,涉及下载,加载,调试,日志上报等场景;
- Gradle脚本,配置信息等;
- 基线资源,用于存放未加固包,Mapping文件,R文件等;
- Shell脚本,用于打包补丁的脚本,提供给Jenkins使用,用于读取基线资源联合tinker提供的插件进行补丁生成。
主端项目由于我们使用ApplicationLike进行代理,所以是否开启热修复,都需要 tinker 来代理我们的 Application。主端根据是否打开热修复功能动态 apply Gradle 脚本及对 DefaultLifeCycle.flag 进行开关切换。
实践流程
在生产环境中,我们通过Jenkins平台输出产物,并先把产物输出到内部测试平台。如需要对外发布则同时上传产物到CDN文件服务器。
另外,内部维护的CMS平台可对补丁信息进行分发,客户端通过读取CMS配置信息来获取补丁信息,进而驱动客户端修复行为。
下面梳理了线上涉及补丁业务的所有流程,完全可复用到任何项目场景:
- release分支保留基线资源
- 修复线上紧急缺陷
- 生成补丁上传到服务器
- 分发平台配置补丁信息
- 客户端加载补丁信息
- 调试与日志支持
每个模块都涉及到真实项目的流程。
release 分支保留基线资源
一般的Git开发流程可参考 Git Flow 一文,核心的分支概念主要由以下五类分支:
- master主分支,发布线上应用及版本 Tag;
- develop开发分支,开发总分支;
- feature功能分支,版本功能开发测试分支;
- hotfix补丁分支,紧急 Bug 修复分支;
- release预发分支,功能测试回归预发版分支。
一般一个版本可能需要开发多个功能,可从develop拉取一个该版本的总feature分支,然后该总feature分支再拉取各个子分支给团队内部人员开发。这样可尽可能避免或减少分支的合并冲突。
下面以我们团队日常开发分支实践展开,同时区分常规发版及补丁发版来修复紧急 Bug 来梳理整个版本的开发流程,见下图(强烈建议认真看一下)。
如果同一个版本存在多个补丁,比如 release 1.0.0 出现 Bug 需要修复,则可衍生出 hotfix 1.0.0.1 作为第一个补丁的分支,hotfix 1.0.0.2 作为第二个补丁分支一次类推。
在release测试回归结束后,需要输出发版分支前,Jenkins打开输出基线资源的配置,基线资源就会跟随打包产物一起发布到内部测试平台。
这些资源会通过一个序列号进行关联区分,在命名上体现。我们团队使用的是 Git 提交记录来作为序列号区分。
修复线上紧急缺陷
从原发布版本对应的release分支中拉出hotfix分支,针对紧急缺陷进行修复。
同时从内部测试平台下载基线资源存放到规定的目录后,把该分支推送到remote远端。这里使用的是tinkerPatchRelease 进行补丁合成,所有合成工作逻辑都写在了Shell脚本中连同项目一起推上远端,等待被Jenkins执行处理。
生成补丁上传
Jenkins建立一个Job用于生产补丁。每次构建补丁前,把修复线上紧急缺陷步骤对应的分支名写到Job配置信息中。
该 Job执行时会先从remote远端拉取hotfix分支,然后执行shell脚本对基线资源进行读取并完成 Gradle 脚本的配置,再调用 tinkerPatchRelease 进行补丁合成,最后对补丁产物进行重命名后上传到内部测试平台。
分发平台配置补丁信息
首先明确应用与版本,补丁间的关系:
- 一个应用存在多个版本
- 一个应用版本可存在多个补丁,同个版本的补丁可以互相覆盖
根据这个关系,我们需要设计对应数据结构来承载补丁信息。
定义补丁信息,版本补丁信息,应用补丁信息
public class PatchInfo {
public String appPackageName;
public String appVersionName;
//灰度或者全量,在(0-10000]之间
public int percent = Constants.VERSION_INVALID;
//补丁版本,有效的版本应该是(1-正无穷),0为回滚,如果找到patchData下的补丁version匹配,则修复,否则跳过
public long version = Constants.VERSION_INVALID;
//补丁包大小
public long size;
//补丁描述
public String desc;
//补丁创建时间
public long createTime;
//补丁下载链接
public String downloadUrl;
//补丁文件 md5
public String md5;
}
public class VersionPatchInfo {
//应用包名
public String packageName;
//应用版本
public String versionName;
//目标补丁版本
public long targetPatchVersion;
//某个版本下的多个补丁信息,一个版本可有多个补丁
public List patchList;
}
public class PatchScriptInfo {
//应用报名
public String packageName;
//当前所有补丁列表,按版本区分
public Map versionPatchList;
}
则三者的类关系为:
定义一份配置信息文件,用于声明全平台所有版本的补丁信息。
则我们的分发平台CMS会根据规则通过配置项来构建上述这份配置文件,客户端通过CMS提供的 Api 来请求这份配置信息文件。
客户端加载补丁信息
除了主动拉取CMS配置信息文件外,一般还需要支持被动接收推送信息。
- 被动接收推送,客户端通过接收推动信息来构建配置信息;
- 主动拉取配置,通过CMS提供的 Api 来实时拉取配置信息,进而构建配置信息。
无论通过哪种方式来构建配置信息,后续都需要完成以下流程:
调试与日志支持
调试除了 IDE 的 Debug 之后,还可支持线上应用某些入口支持加载配置信息并可手动调试补丁。比如说在某些业务无相关的页面如 关于页面的某个view在连续快速点击达到一定次数后弹出对话框,在对话框输入内部测试码之后就可进入调试界面。
另外在 分发平台配置补丁信息章节中涉及的配置信息下载或补丁下载 downloadUrl,可自定义协议扩展进行多场景支持。
- cms协议,通过内部的 CMS 文件协议来获取文件或者 Api 接口来请求,如果 URL 是以 cms: 开头的协议则固定从 CMS 文件服务器读取。
- http/https协议,如果 URL 是常规 http:/https: 开头的协议则默认需要下载。
- sdcard协议,以设备的 SDCARD 根目录为起点进行检索,如果 URL 是以 sdcard: 开头的协议则默认读取 SDCARD 本地文件。该协议用于测试使用,比如 /sdcard/patch/config.txt 等。
调试界面在扫描补丁脚本配置时,只需要输入满足上述 3 种协议中一种的 URL 来获取补丁信息。除此之外,整个加载流程都会定义流程码进行标示,可定义枚举类来支持,以下仅供参考。
public enum ReportStep {
/**
* 获取脚本,1开头
*/
STEP_FETCH_SCRIPT(1, "获取热修复配置脚本"),
STEP_FETCH_SCRIPT_REMOTE(10, "获取远端配置脚本"),
STEP_FETCH_SCRIPT_LOCAL(11, "获取本地配置脚本"),
STEP_FETCH_SCRIPT_CMS(12, "获取CMS配置脚本"),
STEP_FETCH_SCRIPT_SUCCESS(100, "获取配置成功", Level.DEBUG),
STEP_FETCH_SCRIPT_FAIL(101, "获取配置失败", Level.ERROR),
/**
* 解析脚本,2开头
*/
STEP_RESOLVING_SCRIPT(2, "解析热修复配置脚本"),
STEP_RESOLVING_SCRIPT_REMOTE(20, "解析远端配置脚本"),
STEP_RESOLVING_SCRIPT_LOCAL(21, "解析本地配置脚本"),
STEP_RESOLVING_SCRIPT_CMS(22, "解析CMS配置脚本"),
STEP_RESOLVING_SCRIPT_LOCAL_SUCCESS(200, "解析成功", Level.DEBUG),
STEP_RESOLVING_SCRIPT_LOCAL_FAIL(201, "解析失败", Level.ERROR),
STEP_RESOLVING_SCRIPT_MISS_CUR_PATCH_VERSION(2000, "当前客户端版本找不到目标补丁", Level.ERROR),
STEP_RESOLVING_SCRIPT_CUR_PATCH_INVALID(2001, "补丁为无效补丁,补丁配置信息配置错误", Level.ERROR),
STEP_RESOLVING_SCRIPT_CUR_PATCH_CANT_HIT(2002, "客户端版本目标补丁未命中灰度", Level.ERROR),
STEP_RESOLVING_SCRIPT_CUR_PATCH_IS_REDUCTION(2003, "目标补丁为回滚补丁", Level.DEBUG),
STEP_RESOLVING_SCRIPT_CUR_PATCH_HAS_PATCHED(2004, "目标补丁已经被加载过,跳过", Level.DEBUG),
STEP_RESOLVING_SCRIPT_HAS_SAME_NAME_FILE_BUT_MD5(2005, "本地补丁目录查询到与目标补丁同名的文件,但md5校验失败", Level.ERROR),
STEP_RESOLVING_SCRIPT_HAS_SAME_NAME_FILE_AND_MATCH_MD5(2006, "本地补丁目录查询到与目标补丁同名的文件,md5校验成功", Level.DEBUG),
/**
* 获取补丁,3开头
*/
STEP_FETCH_PATCH_FILE(3, "获取补丁"),
STEP_FETCH_PATCH_FILE_REMOTE(30, "从远端获取下载补丁文件"),
STEP_FETCH_PATCH_FILE_LOCAL(31, "从本地目录获取补丁文件"),
STEP_FETCH_PATCH_SUCCESS(300, "获取补丁文件成功", Level.DEBUG),
STEP_FETCH_PATCH_FAIL(301, "获取补丁文件失败", Level.ERROR),
STEP_FETCH_PATCH_MATCH_MD5(3000, "校验补丁文件 md5 成功", Level.DEBUG),
STEP_FETCH_PATCH_MISS_MD5(3001, "校验补丁文件 md5 失败", Level.ERROR),
STEP_FETCH_PATCH_WRITE_DISK_SUCCESS(3002, "补丁文件写入补丁目录成功", Level.DEBUG),
STEP_FETCH_PATCH_WRITE_DISK_FAIL(3003, "补丁文件写入补丁目录失败", Level.ERROR),
/**
* 修复补丁,4开头
*/
STEP_PATCH(4, "补丁修复"),
STEP_PATCH_LOAD_SUCCESS(40, "读取补丁文件成功", Level.DEBUG),
STEP_PATCH_LOAD_FAIL(41, "读取补丁文件失败", Level.ERROR),
STEP_PATCH_RESULT_SUCCESS(400, "补丁修复成功", Level.DEBUG),
STEP_PATCH_RESULT_FAIL(4001, "补丁修复失败", Level.ERROR),
/**
* 补丁回滚,4开头
*/
STEP_ROLLBACK(5, "补丁回滚"),
STEP_ROLLBACK_RESULT_SUCCESS(50, "补丁回滚成功", Level.DEBUG),
STEP_ROLLBACK_RESULT_FAIL(51, "补丁回滚失败", Level.ERROR);
public int step;
public String desc;
@Level
public int logLevel;
ReportStep(int step, String desc) {
this(step, desc, Level.INFO);
}
ReportStep(int step, String desc, int logLevel) {
this.step = step;
this.desc = desc;
this.logLevel = logLevel;
}
}
在补丁流程的每一个节点都进行 Log 日志输出,除了输出到 IDE 和调试界面外,还需上传到每个项目的日志服务器以便分析线上补丁流程的具体情况及补丁效果。
到这,从技术原理-技术选型-实践流程整体思路上希望会大家有帮助~。
码字不易,如对你有价值,点赞支持一下吧~
专注 Android 进阶技术分享,记录架构师野蛮成长之路
如果在Android领域有遇到任何问题,包括项目遇到的技术问题,面试及简历描述问题,亦或对未来职业规划有疑惑,可添加我微信 「Ming_Lyan」 或关注公众号 「Android之禅」,会尽自所能和你讨论解决。
后续会针对 “Android 领域的必备进阶技术”,“Android高可用架构设计及实践” ,“业务中的疑难杂症及解决方案” 等实用内容进行分享。
也会分享作为技术者如何在公司野蛮成长,包括技术进步,职级及收入的提升。
欢迎来撩。