关于android:救我于水深火热的热修复

5次阅读

共计 18121 个字符,预计需要花费 46 分钟才能阅读完成。

上周五线上我的项目呈现了紧急缺点,无奈之下周六苦逼加班发补丁????,惟一值得快慰的是因为呈现缺点的性能会在明天通过 ABTest 下发,补丁赶在了大推之前。刚好周日在家闲着,就写一下「救我于生灵涂炭的热修复」。

心愿当你看完这篇文章之后,可能理解到利用 热修复 它并不难,也不须要本人造轮子,业界很多优良的框架如 TinkerRobustSophix 等。

如果我的项目还没有反对这个热更能力,心愿你能尝试折腾缓缓接入,这不仅仅能学习到新常识也能为服务项目提供容错能力。

文章篇幅比拟长,心愿各位看官能急躁看完,把握整体思路并有所播种。

编程是为了业务解决问题,学习编程的外围是把握程序实现的思路,而代码只是一种实现程序的工具。

上面从文章围绕 技术原理 技术选型 实际流程 开展。

技术原理

热修复依照类修复机会可分为 类冷修复 类热更新

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

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

上面针对 类冷修复 类热更新 SO 库修复资源文件修复 进行理解。

类冷修复

一个 Class 文件 若已被 JVM 虚拟机 所加载,只能通过重启伎俩解决来革除虚拟机中已保留的类信息。

咱们以 QZone 插桩计划 和 微信 tinker 计划 计划为剖析,并援用Sophix 计划 的法做比照。

QZone 计划

一个 ClassLoader 可加载多个 DEX 文件,每一个DEX 文件 被加载后在内存中体现为一个 Element 对象,多个 DEX 文件 被加载后则排列成一个有序数组 dexElements。对于 ClassLoader 不相熟的敌人,倡议先看看链接里的储备常识。

如果类已被 ClassLoader 加载,那么查找其对应 class 对象是通过调用 findClass(String name, List<Throwable> 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 来实时拉取配置信息,进而构建配置信息。

无论通过哪种形式来构建配置信息,后续都须要实现以下流程:

调试与日志反对

调试 除了 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 高可用架构设计及实际”,“业务中的疑难杂症及解决方案”等实用内容进行分享。
也会分享作为技术者如何在公司横蛮成长,包含技术提高,职级及支出的晋升。
欢送来撩。

正文完
 0