救我于水深火热的「热修复」

上周五线上项目出现了紧急缺陷,无奈之下周六苦逼加班发补丁😭,惟一值得欣慰的是因为出现缺陷的功能会在今天经过 ABTest 下发,补丁赶在了大推以前。恰好周日在家闲着,就写一下「救我于水深火热的热修复」。java

但愿当你看完这篇文章以后,可以了解到应用热修复它并不难,也不须要本身造轮子,业界不少优秀的框架如TinkerRobustSophix等。android

若是项目尚未支持这个热更能力,但愿你能尝试折腾慢慢接入,这不只仅能学习到新知识也能为服务项目提供容错能力。git

文章篇幅比较长,但愿各位看官能耐心看完,掌握总体思路并有所收获。github

编程是为了业务解决问题,学习编程的核心是掌握程序实现的思路,而代码只是一种实现程序的工具。算法

下面从文章围绕 技术原理-技术选型实践流程展开。shell

技术原理

热修复按照类修复时机可分为类冷修复类热更新编程

所谓类冷修复是指应用重启以后经过加载修复后的类文件来修复类已知的问题,而类热更新则是不须要重启应用前提下修复类已知的问题。api

另外热更修复的对象还可包括SO库资源文件数组

下面针对类冷修复类热更新SO库修复资源文件修复进行了解。缓存

类冷修复

一个Class文件若已被JVM虚拟机所加载,只能经过重启手段解决来清除虚拟机中已保存的类信息。

咱们以QZone插桩方案微信tinker方案 方案为分析,并引用Sophix方案的法作对比。

QZone方案

一个ClassLoader可加载多个DEX文件,每个DEX文件被加载后在内存中表现为一个 Element 对象,多个DEX文件被加载后则排列成一个有序数组 dexElements。 对于 ClassLoader 不熟悉的朋友,建议先看看连接里的储备知识。

若是类已被ClassLoader加载,那么查找其对应 class 对象是经过调用 findClass(String name, List suppressed) 方法实现。整个过程当中若是存在已查找的 class 对象 ,则直接返回该 class 对象。因此QZone方案是把修复过的类打包成新的DEX文件,把该文件优先加载后插到 dexElements 中且排在了待修复类所在 Element 对象前面。

这个方案涉及到类校验,可能会由于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 全部 directMethodvirtualMethod,包含了:

  • 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也不会有问题。

固然这样的作法也存在的问题与限制:因为类的加载涉及 dvmResolveClassdvmLinkClassdvmInitClass 三个阶段。

dvmInitClass 会在类解析完并尝试初始化类时执行,若是类没有被打上CLASS_ISPREVERIFIEDCLASS_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 的过程当中,referrerclassIdx 要怎么肯定?

  • 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 。修复流程为:

  1. 保留原来修复前classesNdexDex 文件
  2. 获取修复后的classedNdexFixdex 文件
  3. 使用算法计算获得classesNdexPatch补丁文件
  4. 下发classesNdexPatch补丁文件在客户端与classesNdexDEX 文件进行合并,获得classedNdexFixDex 文件
  5. 重启应用,提早加载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 层替换到对应被虚拟机加载过的类的方法。

以阿里开源项目AndfixSophix方案为分析。

  1. AndFix#replaceMethod(Method src,Method dest) 为 Java 层替换错误方法的入口,经过 JNI 调用 Navite 层代码
  2. 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 层的类热更思路及做用,但这两种方案也存在一些限制与问题:

  1. 针对反射调用非静态方法产生的问题。这类问题只能经过冷启动修复,缘由是反射调用的 invoke 底层回调用到 InvokeMethod,该方法会校验反射的对象和是否是ArtMethod的一个实例,但方案替换了ArtMethod致使校验失败。
  2. 不适合类发生结构变化的修改。好比增删方法可能引发类及 Dex 方法数变化,进而改变方法索引。一样地,增删字段也会更改方法索引。

资源修复

资源修复是很常见的操做,资源修复方案不少参考InstantRun的实现,InstantRun资源修复核心流程大体以下:

  1. 构建一个新的AssetManager对象,并调用addAssetPath添加新的资源包;
  2. 修改全部ActivityActivity.mAssets(AssetManager实例) 的引用指向新构建的AssetManager对象;
  3. 修改全部ResourceResource.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及后续版本。

一样是比较新旧资源包获得补丁资源包,而后经过修改补丁资源包的 packageId0x66 ,并利用已有的AssetManager直接使用。这个补丁资源包要遵循如下规则:补丁包只包含新增的资源,包含纯新增的资源和修改旧包的资源,不包含旧包须要删除的资源

  • 纯新增的资源,代码处直接引用该资源;
  • 旧包须要修改的资源,则新增修改后的对应资源,而后把代码处资源引用指向修改后资源;
  • 旧包须要删除的资源,则代码处不引用该资源就好。(虽然会占着坑)

使用新资源包进行编译,代码中可能出现资源 ID 偏移,需修正代码处的资源引用。

举个🌰。

好比原来有一个Drawable在代码的引用为 0x7f0002,因为新资源包新增了一个Drawable,致使原Drawable在代码的引用为0x7f0003

这个时候就须要把代码引用更改回原来的 0x7f0002。由于Sophix 加载的是 packageId0x66 的补丁包而不是从新合成新的资源包。同时,对于使用到补丁包内的资源,其引用也需改为对应补丁资源引用 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 库的方法。

  1. 调用 System.loadLibrary 方法,接收一个 so 的名称做为参数进行加载。对于 APK 而言,其libs目录下的 so 文件会被复制到应用安装目录并完成加载;
  2. 调用 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 变量所表示的目录下遍历搜索到。前者nativeLibararyDirectoriesSDK<23 时的目录,后者nativeLibararyDirectoriesSDK>=23 时的目录,只须要把补丁 so 的路径插入到他们目录的最前面便可。

可是 so 库文件存在多种 CPU 架构,补丁和 apk 同样都存在须要选择哪一个abi的 so 来执行的问题。

Sophix 提供了一种思路, 经过从多个abis目录中选择一个合适的primaryCpuAbi目录插到 nativeLibararyDirectories/nativeLiraryPathElements 数组中。

  1. SDK>=21时直接反射拿到 ApplicationInfo 对象的primaryCpuAbi

  2. 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 的全部版本,咱们也尝试过AndFixQZone等方案,基本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配置信息来获取补丁信息,进而驱动客户端修复行为。

下面梳理了线上涉及补丁业务的全部流程,彻底可复用到任何项目场景:

  1. release分支保留基线资源
  2. 修复线上紧急缺陷
  3. 生成补丁上传到服务器
  4. 分发平台配置补丁信息
  5. 客户端加载补丁信息
  6. 调试与日志支持

每一个模块都涉及到真实项目的流程。

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<PatchInfo> patchList;
}  
复制代码
public class PatchScriptInfo {
    //应用报名
    public String packageName;              
    //当前全部补丁列表,按版本区分
    public Map<String, VersionPatchInfo> versionPatchList;  
}                             
复制代码

则三者的类关系为:

定义一份配置信息文件,用于声明全平台全部版本的补丁信息。

则咱们的分发平台CMS会根据规则经过配置项来构建上述这份配置文件,客户端经过CMS提供的 Api 来请求这份配置信息文件。

客户端加载补丁信息

除了主动拉取CMS配置信息文件外,通常还须要支持被动接收推送信息。

  • 被动接收推送,客户端经过接收推进信息来构建配置信息;
  • 主动拉取配置,经过CMS提供的 Api 来实时拉取配置信息,进而构建配置信息。

不管经过哪一种方式来构建配置信息,后续都须要完成如下流程:

file

调试与日志支持

调试除了 IDE 的 Debug 以后,还可支持线上应用某些入口支持加载配置信息并可手动调试补丁。好比说在某些业务无相关的页面如 关于页面的某个view在连续快速点击达到必定次数后弹出对话框,在对话框输入内部测试码以后就可进入调试界面

file

另外在 分发平台配置补丁信息章节中涉及的配置信息下载或补丁下载 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之禅」公众号,和你分享有价值有思考的技术文章。 可添加微信 「Ming_Lyan」备注 “进群” 加入技术交流群,讨论技术问题严禁一切广告灌水。 若有 Android 领域有遇到技术难题亦或对将来职业规划有疑惑,一块儿讨论交流。 欢迎来扰。

相关文章
相关标签/搜索