作者:三雒
链接:
https://juejin.cn/post/7253611796111769637
本文由作者授权发布。
原谅我用了这么略带浮夸的标题,其实我是一个非常务实的正经技术人,变成专家确实有点吹牛的成分,但是你或许可以离专家更近一步,不信?看完你或许大概可能就信了,就怕你看不完啊..
近几年一方面随着热修复框架本身已发展相对成熟,另一方面对于业务动态更新的诉求大家致力于通过RN、Flutter等动态化框架解决,热修复这个话题似乎已经没什么热度了。写这篇文章也是恰好由于我之前从事过一段热修复相关的工作,觉得这个话题相关的技术体系和问题还是非常有趣的。比如随便列出的下面几个问题:- 我们可能都知道通过PathClassLoader的类替换方案可以实现class的热修复,但可能不了解替换之后由于dex2oat的编译导致的一系列不生效或异常崩溃问题。
- Roubst是通过方法插桩来实现逻辑的替换,但是宿主和Patch是怎么桥接的呢。
- AndFix是通过运行时替换ArtMethod来实现的,存在兼容性问题,那大致有哪些问题,未来有没有好的解决方案呢。
本文主要介绍热修复的发展史和目前现存的主流框架,尝试解释各种流派框架诞生的底层逻辑,并且对每种框架的核心原理以及面临的主要问题做一个详细的分析,文章有点长,但如果耐心看完相信一定会对热修技术有更完整和深刻的理解。
时年2014,随着移动互联网行业的迅速发展,Android应用程序线上Bug的快速修复成为一个重要问题。由于传统的应用商店更新方式存在成本高,耗时长,无法及时止损等问题,国内开发人员开始寻求更加高效的解决方案。大约2015年之后,热修复技术迎来了爆发的发展时代。阿里系先后推出了Dexposed、AndFix以及在此之上改良的Sophix,腾讯系比较著名的有QQ空间的超级补丁以及微信的Tinker, 而后美团从Instant Run方案中获得灵感,Robust横空出世。当然其中一些未提及的方案都因为其弊端或知名度昙花一现,但是不能否定他们为热修复技术发展所做的探索和奠基,并且提供了更多的可能性。到目前为止,可用性高、比较成熟的框架其实也就三个,分别是微信Tinker、阿里Sophix、美团Robust。这三家公司无疑对热修复的发展做出了巨大贡献,巧合的是他们也分别代表了热修复的不同流派,从不同的技术视角缔造自己的热修复方案。这三种流派分别是类替换方案(Tinker),运行时方法替换方案(Sophix),编译时方法替换(AndFix)。
有一个问题不知道你有没有想过,为什么会有这三种流派的方案,只是一种偶然的巧合么?这个问题非常有趣,看上去是这样,但是巧合的背后其实也存在一种必然性。在具体解释这个问题之前,我们不妨重新审视一下热修复的定义:热修复是一种快速、低成本的修复软件Bug方式,是指在不更新软件的情况下将补丁投放给用户,动态加载执行补丁代码从而解决线上Bug。
从上述定义可以看出热修的根本目标是解决线上Bug,关键实现路径通过动态加载补丁并执行补丁代码。线上Bug对应于我们的“代码”出现了问题,这里“代码”有点笼统,在Android平台上我们可以将“代码”出问题的情况细分为Java/Kotlin代码、Native代码、资源文件三类,我们先来评估下线上这三种情况出问题的频率,确定热修复方案的重心。根据经验不难确定Java/Kotlin代码修复是这三种的最高频场景,事实上在我们应用内Java补丁的基本上每个版本都有发,但是So和资源的补丁一年内都可能不发一次,即使出问题也有可能通过Java补丁规避。因此针对Java/Kotlin代码的修复方案就显得至关重要,大佬们下功夫也会比较多,这是第我认为的第一层原因,但不是核心原因。核心原因是Java/Kotlin代码的执行路径,或者说是虚拟机的运行机制确实存在这么多的可能性。从微观的角度看程序执行,虚拟机执行的是一条条字节码指令,我们修复bug无非是将错误的指令替换成正确的指令。再往外层宏观看,承载指令的是方法,而承载方法的是类,承载类的是Dex,所以我们能在任意一种粒度上实现替换都能实现程序逻辑的替换,但具体还是要看其可行性。从方法替换的角度看,我们需要对方法由源码被javac/kotlinc编译成class字节码、再由d8转换成dex中smali字节码,最终被虚拟机解释执行,同时也会被JIT/AOT编译成机器码这整个过程有所了解。- 有一天小M忽然灵感大爆发,想到我要是在方法前加一行条件判断,如果当前方法需要被修复,就直接执跳转到Patch逻辑,否则执行原始逻辑,那不就行了么。但是方法那么多,不能逐个添加吧。恰巧他又对dorid的构建过程有了解,直接通过Transform字节码插桩解决方法这个问题,Robust就诞生了。
- 另一边小A了解到了虚拟机会把方法抽象成对应数据结构,运行时主要从对应的数据结构获取方法的指令等信息,那我直接把这个方法对象替换成修复后的对象,不就行了么,于是Sophix的原型就有了。
如果你对Native hook有所了解的话,不难发现上述两种替换和Native hook的inline hook以及plt hook有异曲同工之妙,思想上其实都是有相通之处的。从类替换的角度看,由于无法在类本身上预埋逻逻辑实现类的替换,小W主要从虚拟机类加载过程下手,他对JVM的ClassLoader双亲委托模型有所了解,对应到Android上加载App类的是PathClassLoader,经过分析不难发现在DexPathList中插入Dex、ClassLoader替换、以及修改PathClassLoder的parent ClassLoader都有可能实现类替换,于是Tinker的前身诞生了。以下是附的各种“代码”的问题频率和热修复方案表格:接下来我们将详细介绍类替换、编译时方法替换和运行时方案替换三种Dex修复的原理以及核心问题,并且也会介绍So修复的原理。
类替换方案
该类型的方案其实也是大家最熟知的方案,App启动时会为应用创建一个PathClassLoader(继承自BaseDexClassLoader),并将应用所有的Dex文件路径存放到DexPathList的dexElements数组中,当虚拟加载一个类时会按照数组的顺序从前往后查找,找到之后就加载对应的类,同一个类加载过后不会重复加载。这给了我们可乘之机,只需要将修复好的类编译成单独的Dex插入到dexElements数组的最前面去就可以实现类替换的方案。方案的核心原理非常简单,但由于虚拟机对class字节码的编译优化,要做出来一个同时兼顾稳定性和性能的方案也绝非易事。AOT inline问题
dex2oat方法内联
自从Android 5.0之后主要使用ART虚拟机,App在安装时或者系统空闲时会通过dex2oat将Dex文件编译成目标平台的机器码,从而提高App的运行效率。在这个编译过程中,dex2oat还会对代码做一些优化,其中就包括方法内联优化。由于方法内联干掉了一些方法体,改变了方法的调用流程,对热修复方案势必会带来影响,在讲具体的影响之前,我们先对dex2oat的方法内联有一个更加详细的认识,以便更好理解后文的内容。方法内联条件可以从art/compiler/optimizing/inliner.cc里的HInliner::Run()方法开始分析,当以下条件均满足时被调用的方法将被inline:- 被调用的方法所在的类与调用者所在的类位于同一个Dex;(注意,符合Class N命名规则的多个Dex要看成同一个Dex)
- 被调用的方法的字节码条数不超过dex2oat通过--inline-max-code-units指定的值,6.x默认为100,7.x默认为32;
- 对于7.x版本,被调用方法还不能包含对接口方法的调用;(invoke-interface指令)
- 此外内联可以跨多级方法调用进行,若有这样的调用链:method1->method2->method3 则在三个方法都满足内联条件的情况下,最终内联的结果将是method1包含method2,method3的代码,但这种跨调用链内联会受到调用dex2oat时通过--inline-depth-limit参数指定的值的限制,默认为5,即超过5层的调用就不会再被内联到当前方法了。
带来的影响
关于dex2oat内联的基本情况我们已经了解,那到底会对类替换有什么影响呢?如下图,我们举一个例子来说明,假设有类B的方法b调用类A的方法hello, 且hello方法出了bug,我们尝试通过Patch将class A进行替换。- 最简单情况就是Class B的b方法已经被编译成机器码并且将类A的hello方法内联了,这样即使我们使用了Patch中的Class A,并没有起到替换作用,类B的b方法执行的时候还是执行旧的机器码。
- 实际上还会有新旧Dex对应数据不匹配导致各种意料之外的异常,Dex文件中有专门的typeId,mehthodId作为类或者方法的id,都是从0开始索引的,这些id会在被编译的机器码中被使用,当新的类A被替换成功之后,类B的机器码执行到类A的代码时候会去获取A所在的Patch Dex,但对应的机器码中的methodId确是旧的Dex中的id,这样就可能crash或者发生其他不可预期的异常。
合成全量Dex
- 第一种思路就是既然是内联造成的问题,那能不能阻止内联。这当然可以从第一步中内联的条件入手,对我们来说比较方便的条件就是在每个方法前面插入一个空try块,这样这些方法就不会参与内联了。但是这样势必对App运行时的性能有影响,另外考虑到ART的内联触发条件随时都在更新,存在维护成本和不确定性,Tinker并没有这样做。
- 第二种思路是把修改类的整个调用链(调用修改类的类,与调用[调用修改类的类]的类,一直递归下去)都放到补丁中,需要包括所有可能被影响的类。这个思路的主要问题在于整个完整调用链的类会非常庞大,很有可能与全量差别不大。
- Tinker最终采用的应对方案是去掉ART环境下的合成增量Dex的逻辑,直接合成全量的NewDex,这样除了Loader类,所有方法统一都用了NewDex里的,也就不怕有方法被内联了。
但是Tinker这种方案也存在较多的弊端,使用全量的新Dex完全抛弃了用户使用了一段时间之dex2oat编译机的机器码,这对运行时的性能是巨大的,对启动以及流畅度都有明显的劣化,必须在恰当的时机动手动触发dexoat以尽量减少影响。对于大型应用而言dex2oat的执行需要比较长的时间,单次执行有一定的失败率,另外经过我们的实际测试,即使执行成功也和用户使用一段时间的性能是有一些gap。再者目前看Google对dex2oat的调用也要求越来越严格,将来App是否还能正常调用dex2oat做编译也会成为一个潜在的隐患。AppImage问题
AppImage介绍
Android 7.0之前应该在安装时候会做最大限度的机器码编译,这种编译带来三个主要的问题:实际上用户对于App功能的使用也遵循“二八原则”,即用户只高频使用App 20%的功能,那么全量编译其实不是必要的,可以采取一些策略优化安装时长和资源占用。Android 7.0为了解决这些问题,通过管理解释,JIT与AOT三种模式,达到运行效率与安装时长、存储占用和耗电的平衡。简单来说,不在应用安装时编译,在应用运行时分析运行过的“热代码”,并以profile文件形式存储下。在设备空闲与充电时,ART仅仅编译profile文件中的“热代码”。这种编译的模式叫做speed-profile, Android N上一共提供了12种编译模式,它们可能用于不同的场景,具体的定义在compiler_filter.h中。
https://android.googlesource.com/platform/art/+/android-n-preview-5/runtime/compiler_filter.h#32
我们可以在手机上执行getprop | grep pm查看不同场景下系统使用的编译模式:pm.dexopt.ab-ota: [speed-profile]
pm.dexopt.bg-dexopt: [speed-profile]
pm.dexopt.boot: [verify-profile]
pm.dexopt.core-app: [speed]
pm.dexopt.first-boot: [interpret-only]
pm.dexopt.forced-dexopt: [speed]
pm.dexopt.install: [interpret-only]
pm.dexopt.nsys-library: [speed]
pm.dexopt.shared-apk: [speed]
如上是在 应用安装(install)和首次启动(first-boot)使用的是interpret-only,即只verify,代码解释执行;后台编译(bg-dexopt)与系统升级(ab-ota)使用的speed-profile,即根据“热代码”的profile 来编译,这也是本小节的主角,因为使用这种模式时候会生成AppImage文件。接下我们来具体看一下,speed-profile模式的dex2oat编译命令的核心参数如下:dex2oat --dex-file=./base.apk --oat-file=./base.odex --compiler-filter=speed-profile --app-image-file=./base.art --profile-file=./primary.prof ...
dex2oat 不仅会生成编译后的OatFile(.odex),而且会生成AppImage(.art),该文件作用与系统的boot.art文件类似,主要是加快应用对“热代码"的加载和缓存。可以通过oatdump命令来看到art文件的内容,具体命令如下:oatdump --app-image=base.art --app-oat=base.odex --image=/system/framework/boot.art --instruction-set=arm64
我们可以dump到art文件中的所有信息,这里我只将它的头部信息输出如下:IMAGE LOCATION: base.art
IMAGE BEGIN: 0x77ea1000
IMAGE SIZE: 1597200
IMAGE SECTION SectionObjects: size=2040 range=0-2040
IMAGE SECTION SectionArtFields: size=0 range=2040-2040
IMAGE SECTION SectionArtMethods: size=0 range=2040-2040
IMAGE SECTION SectionRuntimeMethods: size=0 range=2040-2040
IMAGE SECTION SectionIMTConflictTables: size=0 range=2040-2040
IMAGE SECTION SectionDexCacheArrays: size=1591080 range=2040-1593120
IMAGE SECTION SectionInternedStrings: size=4040 range=1593120-1597160
IMAGE SECTION SectionClassTable: size=40 range=1597160-1597200
IMAGE SECTION SectionImageBitmap: size=4096 range=1597440-1601536
base.art文件主要记录已经编译好的类的具体信息以及函数在oat文件的位置,一个class的输出格式如下:0x78c8f768: java.lang.Class "com.tencent.mm.ui.d.a" (StatusInitialized)
shadow$_klass_: 0x6fc76488 Class: java.lang.Class
shadow$_monitor_: 0 (0x0)
accessFlags: 524305 (0x80011)
annotationType: null sun.reflect.annotation.AnnotationType
classFlags: 0 (0x0)
classLoader: 0x787b5140 java.lang.ClassLoader
classSize: 460 (0x1cc)
clinitThreadId: 0 (0x0)
componentType: null java.lang.Class
copiedMethodsOffset: 3 (0x3)
dexCache: 0x782290c8 java.lang.DexCache
dexCacheStrings: 2036372056 (0x79609258)
dexClassDefIndex: 12138 (0x2f6a)
dexTypeIndex: 11797 (0x2e15)
iFields: 2031076964 (0x790fc664)
ifTable: 0x78836500 java.lang.Object[]
methods: 2032787876 (0x7929e1a4)
name: null java.lang.String
numReferenceInstanceFields: 4 (0x4)
numReferenceStaticFields: 0 (0x0)
objectSize: 36 (0x24)
primitiveType: 131072 (0x20000)
referenceInstanceOffsets: 63 (0x3f)
sFields: 0 (0x0)
status: 10 (0xa)
superClass: 0x78bcc968 Class: com.tencent.mm.pluginsdk.ui.b.b
verifyError: null java.lang.Object
virtualMethodsOffset: 1 (0x1)
vtable: null java.lang.Object
0x792b639c ArtMethod: void com.tencent.mm.e.a.je.()
OAT CODE: 0x471dae14-0x471daece
SIZE: Dex Instructions=10 StackMaps=0 AccessFlags=0x90001
0x792b63c0 ArtMethod: void com.tencent.mm.e.a.je.(byte)
OAT CODE: 0x471daee4-0x471daf52
SIZE: Dex Instructions=48 StackMaps=0 AccessFlags=0x90002
0x792b63e8 ArtMethod: void com.tencent.mm.e.a.jo.()
OAT CODE: 0x463d5f44-0x463d5f50
SIZE: Dex Instructions=10 StackMaps=0 AccessFlags=0x90001
那么我们就剩下最后一个问题,AppImage文件是什么时候被加载并如何提升应用性能的?在apk启动时我们需要加载应用的oat文件以及可能存在的AppImage文件,它的大致流程如下:通过OpenDexFilesFromOat加载oat时,若AppImage存在,则通过调用OpenImageSpace函数加载;
https://android.googlesource.com/platform/art/+/android-n-preview-5/runtime/oat_file_manager.cc#541
https://android.googlesource.com/platform/art/+/android-n-preview-5/runtime/oat_file_assistant.cc#989
在加载AppImage文件时,通过UpdateAppImageClassLoadersAndDexCaches函数,将art文件中的dex_cache中dex的所有class插入到ClassTable,同时将method更新到dex_cache;
https://android.googlesource.com/platform/art/+/android-n-preview-5/runtime/class_linker.cc#1227
在类加载时,使用时ClassLinker::LookupClass会先从ClassTable中去查找,找不到时才会走到DefineClass中。
https://android.googlesource.com/platform/art/+/android-n-preview-5/runtime/class_linker.cc#3599
https://android.googlesource.com/platform/art/+/android-n-preview-5/runtime/class_linker.cc#2437
简单来说AppImage的作用是记录已经编译好的“热代码”,并且在启动时一次性把它们加载到缓存,一个很明显的性能提升是在应用启动时类加载的速度有明显的优化,因为有很多类都不用再define。对热修复的影响
无论是使用插入DexPathList还是parent classloader的方式,若补丁修改的class已经存在于AppImage,它们都是无法通过热补丁更新的。它们在启动App时已经加入到PathClassLoader的ClassTable中,系统在查找类时会直接使用base.art中对应的编译好的class。
假设base.art文件在补丁前已经存在,会存在三种情况:- 补丁修改的类都不app image中,这种情况是最理想的,此时补丁机制依然有效。
- 补丁修改的类部分在app image中,这种情况我们只能更新一部分的类,此时是最危险的。一部分类是新的,一部分类是旧的,app可能会出现地址错乱而出现crash。
- 补丁修改的类全部在app image中;这种情况只是造成补丁不生效,app并不会因此造成crash。
运行时替换PathClassLoader
事实上,App image中的class是插入到PathClassloader中的ClassTable中。假设我们完全废弃掉PathClassloader,而采用一个新建Classloader来加载后续的所有类,即可达到将cache无用化的效果。需要注意的问题是我们的Application类是一定会通过PathClassloader加载的,所以我们需要将Application类与我们的逻辑解耦,这里方式有两种:- 采用类似instant run的实现,使用BootstrapApplication在运行时反射替换ActivityThread、LoadApk中的Application对象为真正的用户Application。这种方式的优点在于接入容易,但是这种方式无法保证兼容性,特别在反射失败的情况,是无法回退的。
- 采用直接代理Application实现的方式,即Application的所有实现都会被代理到ApplicationLike类,Application类不会再被使用到。这种方式没有兼容性的问题,但是会带来一定的接入成本。
Tinker采用了方案2,总的来说,这种方式不会影响没有补丁时的性能,但在加载补丁后,由于废弃了App image带来一定的性能损耗。具体数据如下:事实上,在Android N上我们不会出现完整编译一个应用的base.odex与base.art的情况。base.art的作用是加快类与方法的第一次查找速度,所以在启动时这个数据是影响最大的。在这种情况,废弃base.art大约带来15%左右的性能损耗。对启动性能、流畅度等要求高的应用需慎用Tineker。编译时方法替换
如上图所示,编译时方法替换的核心原理非常简单,是一种非常直接的编程思维。只需要在编译时APK时给方法添加一行插桩代码,当判断当前方法被修复时,就走入Patch中修复后的逻辑,这样就完成了一个方法逻辑的替换。那么我们如何判断当前方法是否被修复以及怎么走入Patch逻辑中,由于我们项目中使用这个方案,我对它非常熟悉,会通过具体的代码细节来讲解。插桩代码
Apk打包时Robust Gralde插件为每个类新增了一个类型为 ChangeQuickRedirect (接口) 的静态变量,并在每个方法前插入PatchProxy.proxy逻辑,增加判断该变量是否为空的逻辑,如果不为空并且方法id匹配就走Patch内逻辑,否则走正常逻辑。我们反编译出基础包中的代码如下: public class MainFragment extends AmeBaseFragment implements a, IMainFragment, i, c {
public static ChangeQuickRedirect a;
public void onSearchClick() {
if(PatchProxy.proxy(new Object[0], this, a, false, 163999).isSupported){
return;
}
//省略原有代码
...
}
}
其中proxy的逻辑主要是调用isSupport方法判断是否要热修,如果需要热修的话会走 accessDispatch方法。public static PatchProxyResult proxy(Object[] paramsArray, Object current, ChangeQuickRedirect changeQuickRedirect, boolean isStatic, int methodNumber) {
PatchProxyResult patchProxyResult = new PatchProxyResult();
//判断这个方法是否要热修
if (PatchProxy.isSupport(paramsArray, current, changeQuickRedirect, isStatic, methodNumber, null, null)) {
patchProxyResult.isSupported = true;
patchProxyResult.result = PatchProxy.accessDispatch(paramsArray, current, changeQuickRedirect, isStatic, methodNumber, null, null);
}
return patchProxyResult;
}
isSupport方法最终会调用changeQuickRedirect.isSupport,将方法的参数、this 、类名、方法名、方法id等信息包装传递过去。public static boolean isSupport(Object[] paramsArray, Object current, ChangeQuickRedirect changeQuickRedirect, boolean isStatic, int methodNumber, Class[] paramsClassTypes, Class returnType) {
//如果changeQuickRedirect 为null直接判定没有热修
if (changeQuickRedirect == null) {
return false;
}
// 拼接方法的一些信息 classMethod = className + ":" + methodName + ":" + isStatic + ":" + methodNumber;]
String classMethod = getClassMethod(isStatic, methodNumber);
if (TextUtils.isEmpty(classMethod)) {
return false;
}
// 把参数和this对象放到一个数组里
Object[] objects = getObjects(paramsArray, current, isStatic);
try {
return changeQuickRedirect.isSupport(classMethod, objects);
} catch (Throwable t) {
return false;
}
}
public static Object accessDispatch(Object[] paramsArray, Object current, ChangeQuickRedirect changeQuickRedirect, boolean isStatic, int methodNumber, Class[] paramsClassTypes, Class returnType) {
if (changeQuickRedirect == null) {
return null;
}
String classMethod = getClassMethod(isStatic, methodNumber);
if (TextUtils.isEmpty(classMethod)) {
return null;
}
Object[] objects = getObjects(paramsArray, current, isStatic);
return changeQuickRedirect.accessDispatch(classMethod, objects);
}
由于ChangeQuickRedirect本身是一个接口,我们可以猜测到changeQuickRedirect 的实现类就是在Patch中,ChangeQuickRedirect也就是宿主和Patch的桥接点。Patch代码
接下看Path的代码,以如下的Patch修改代码为例,其中@Modify是Robust提供的注解用于标记改方法是被修改过的。// MainFragment:
@Modify
public void onSearchClick() {
if (isHotSearchGuideShowing() && !isViewValid() && getActivity() == null) {
return;
}
//热修复增加的代码
MobClickHelper.onEventV3("hotfix_test_java_event", EventMapBuilder.newBuilder()
.appendParam("content", "on search click string ")
.builder());
// ...
}
- PatchesInfo(如何确定给哪些类的ChangeQuickRedirect赋值)
该类中主要记录Patch修改的方法原本所在的类和 ChangeQuickRedirect实现的对应关系,Patch加载后根据这个类的信息给对应的类的 changeQuickRedirect变量赋值 。public class PatchesInfoImpl implements PatchesInfo {
public List getPatchedClassesInfo() {
ArrayList arrayList = new ArrayList();
arrayList.add(new PatchedClassInfo("com.ss.android.ugc.aweme.main.MainFragment", "com.bytedance.ies.patch.MainFragmentPatchControl"));
EnhancedRobustUtils.isThrowable = false;
return arrayList;
}
}
- ChangeQuickRedirect(连接宿主和Patch的桥梁)
- isSupport通过方法id判断是不是要执行热修的方法。
- accessDispatch 区分静态和非静态方法构造 XxxPatch 类对象,并真正调用修复后的方法。
public class MainFragmentPatchControl implements ChangeQuickRedirect {
//...
public boolean isSupport(String methodName, Object[] paramArrayOfObject) {
//一个类里有很多个方法,通过方法id确定要修复哪个方法
return ":163999:".contains(new StringBuffer().append(":").append(methodName.split(":")[3]).append(":").toString());
}
public Object accessDispatch(String methodName, Object[] paramArrayOfObject) {
try {
MainFragmentPatch mainFragmentPatch;
//静态方法
if (!methodName.split(":")[2].equals("false")) {
mainFragmentPatch = new MainFragmentPatch(null);
//非静态方法,获取到当前对象this, 传递给MainFragmentPatch ,
} else if (keyToValueRelation.get(paramArrayOfObject[paramArrayOfObject.length - 1]) == null) {
mainFragmentPatch = new MainFragmentPatch(paramArrayOfObject[paramArrayOfObject.length - 1]);
keyToValueRelation.put(paramArrayOfObject[paramArrayOfObject.length - 1], null);
} else {
mainFragmentPatch = (MainFragmentPatch) keyToValueRelation.get(paramArrayOfObject[paramArrayOfObject.length - 1]);
}
if ("163999".equals(methodName.split(":")[3])) {
//调用替换后的onSearchClick方法逻辑
mainFragmentPatch.onSearchClick();
}
} catch (Throwable th) {
th.printStackTrace();
}
return null;
}
- MainFragmentPatch(修复方法的真正实现)
限制于访问权限的问题,Patch的方法的具体逻辑都被翻译成反射实现。- Patch修复的方法中会访问原有类的一些私有成员,在修复后的类是访问不到的,只能通过反射。
- Patch类和原有类不在同一包名下,一些默认权限的方法也是访问不到。
public class MainFragmentPatch {
MainFragment originClass;
public MainFragmentPatch(Object obj) {
this.originClass = (MainFragment) obj;
}
public void onSearchClick() {
// ... 方法代码通过反射实现
}
}
Patch与宿主桥接
- 创建新的DexClassLoader去加载patch的Dex文件,其parent ClassLoader为PathClassLoader。
- 加载PatchesInfo类,获取要宿主中修复的类和其对应的ChangeQuickRedirect类。
- 创建ChangeQuickRedirect对象,并赋值给宿主中要修类的ChangeQuickRedirect字段。
private void loadPatchInternal(@NonNull JavaPatch patch) throws JavaLoaDexception {
String dexOptimizedPath = patch.getDexOptimizedPath();
//in Android 5.0, need optimize path file exist
FileUtils.ensureDirExist(new File(DexOptimizedPath));
//创建单独的DexClassLoader, 父ClassLoader是PathClassLoader
DexClassLoader dexClassLoader = new DexClassLoader(patch.javaPatchFile.getAbSolutePath(),
dexOptimizedPath, null, JavaLoader.class.getClassLoader());
EnhancedRobustUtils.setClassLoader(dexClassLoader);
try {
// 解析Patch, 给对应的类设置ChangeQuickRedirect
parsePatchAndLoad(dexClassLoader, patch);
} catch (Throwable throwable) {
}
// ...
}
private void parsePatchAndLoad(@NonNull DexClassLoader dexClassLoader, @NonNull JavaPatch patch)
throws ClassNotFounDexception, IllegalAccessException, InstantiationException, JavaLoaDexception {
//加载了PatchesInfoImpl 类
Class patchesInfoClass = dexClassLoader.loadClass(patch.getPatchesInfoImplClassFullName());
PatchesInfo patchesInfo = (PatchesInfo) patchesInfoClass.newInstance();
//调用getPatchedClassesInfo获取到要修复的类
List patchedClassInfoList = patchesInfo.getPatchedClassesInfo();
for (PatchedClassInfo patchedClassInfo : patchedClassInfoList) {、
//宿主里要修复的类名
String classNameInHost = patchedClassInfo.patchedClassName.trim();
//Patch中对应的ChangeQuickRedirect
String classNameInPatch = patchedClassInfo.patchClassName.trim();
//加载宿主对应的类
Class classInHost = dexClassLoader.loadClass(classNameInHost);
//找到该类的changeQuickRedirect静态字段并赋值
Field changeQuickRedirectField = findChangeQuickRedirectField(classInHost);
Class classInPatch = dexClassLoader.loadClass(classNameInPatch);
Object patchObject = classInPatch.newInstance();
changeQuickRedirectField.setAccessible(true);
changeQuickRedirectField.set(null, patchObject);
}
patch.setPatchedClasses(patchedClassInfoList);
}
该方案的好处在于没有对系统进行任何hook,字节码插桩的逻辑也不会有任何兼容性问题,稳定性极好。但是方法插桩对于App的运行性能和包体积有损耗,运行性能经过我们的测试大约有1%左右,包体积的影响主要看App本身的代码量,对于大型应用的影响较大,我们也可以通过过滤一些Sdk或不容易出bug的方法尽可能减少对运行性能和包体的影响,总体而言Robust是一个款常优秀的方案。运行时方法替换
运行时方法替换的核心原理就是将修复前的方法结构在虚拟运行时候动态地替换为修复后的方法结构。学习虚拟接的内存区域划分时候,我们了解过方法区会存储加载的类和方法的信息,这个方法的信息在Native层就对应ArtMethod,简单来说我们通过获取到对应的方法的ArtMethod结构,对其进行替换即可。因为Sophix并没有开源,我这里也是从AndFix的代码介绍并结合我自己的一些理解。ArtMethod替换
每一个Java方法在ART虚拟机中都对应着一个 ArtMethod , ArtMethod 记录了这个 Java 方法的所有信息,包括所属类、 访问权限、代码执行地址等。通过 env->FromReflectedMethod,可以由 java.lang.reflect.Method对象得到这个方法所应的ArtMethod的真正起始地址,然后就可以把它强制转化为ArtMethod指针,从而对真包含的所有成员进行修改,这样全部修改完成就完成了方法的替换。如下以Android 6.0的代码替换为例: void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
art::mirror::ArtMethod* smeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(src);
art::mirror::ArtMethod* dmeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(dest);
reinterpret_cast<:mirror::class>(dmeth->declaring_class_)->class_loader_ =
reinterpret_cast<:mirror::class>(smeth->declaring_class_)->class_loader_; //for plugin classloader
reinterpret_cast<:mirror::class>(dmeth->declaring_class_)->clinit_thread_id_ =
reinterpret_cast<:mirror::class>(smeth->declaring_class_)->clinit_thread_id_;
reinterpret_cast<:mirror::class>(dmeth->declaring_class_)->status_ = reinterpret_cast<:mirror::class>(smeth->declaring_class_)->status_-1;
//for reflection invoke
reinterpret_cast<:mirror::class>(dmeth->declaring_class_)->super_class_ = 0;
// 所在类
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
//方法权限修饰
smeth->access_flags_ = dmeth->access_flags_ | 0x0001;
//对应的code_item在Dex文件的offset
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
//对应的method_id在Dex文件的index
smeth->dex_method_index_ = dmeth->dex_method_index_;
smeth->method_index_ = dmeth->method_index_;
//解释执行指令入口
smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
dmeth->ptr_sized_fields_.entry_point_from_interpreter_;
smeth->ptr_sized_fields_.entry_point_from_jni_ =
dmeth->ptr_sized_fields_.entry_point_from_jni_;
//机器码指令入口
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;
LOGD("replace_6_0: %d , %d",
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
}
其中关键的字段是entry_point_from_interpreter_和 entry_point_from_quick_compiled_code_,从名字可以看出他们是方法指令的执行入口。Android平台上Java代码最终编译成Dex文件中的smali 指令,虚拟机在执行时候可以对smali指令解释执行,另外虚拟机也会通过dex2oat将指令编译成机器码,entry_point_from_interpreter_和entry_point_from_quick_compiled_code_分别对应这两种模式下的指令入口。那是不是只替换这两个字段就可以了?并不是,主要虚拟机在执行方法的过程中也需要访问ArtMethod,需要保持信息的一致性。核心的替换逻辑就这么简单,但是替换过程中以及之后存在许多问题需要解决。随着Android系统版本的升级ArtMethod本身会有一些调整,另外各个厂商还可能会针ArtMethod做各种定制。前者的话,我们还可以分系统版本去兼容处理,但后者的厂商定制太碎片化,就比较难逐个去兼容了。那是否有一个通用的替换方案呢?在此之前我们先了解一下如果厂商在ArtMethod里增删字段会发生什么? smeth->declaring_class_ = dmeth->declaring_class_;
上述对declaring_class_替换的代码,由于其是ArtMethod的第一个成员,它是和下面这个代码等价的。 *(uint32_t*)(smeth +0) = *(uint32_t*)(dmeth +0)
假设厂商最前面加一个foo的字段,上面的代码其实真正含义就会变成下面这样,和原始的逻辑不一致了。再看上面我们的替换方案其实是替换ArtMethod的所有成员的,那既然如此通过下面一行代码就可以完成整个的替换。 memcpy(smeth, dmeth, sizeof (ArtMethod));
但这里有一个关键点就是如何在运行时动态计算出sizeof (ArtMethod),如果计算有偏差的话会导致,部分没有替换或者替换区域超过边界。ArtMethod结构整体替换了之后,方法对应declaring_class_也是Patch中生成的mirror::Class对象,这会导致虚拟机在做一些方法调用权限校验时候出问题。比如Patch后的方法调用了原始类的一些私有方法或者字段时候会有问题。private 可见性:patch 方法所在类的所有 Field 都更改为 public ,patch 方法所在类的所有 constructor 都更改为 public, patch 方法通过反射调用其他私有方法,patch 打包实现。package 可见性:更改 patch 类的 ClassLoader 为 patch 前的。protected 可见性:将 patch 方法所在类的所有 protected 方法变为 public。在我们替换某个方法时,这个方法可能正在执行,为了方便起见,我们以解释模式举例,解释器正在从ArtMethod中表示该方法字节码所在的地址一条条指令取出来直接执行,解释器用一个pc指针来表示已取到的字节码位置,如果我们此时替换ArtMethod结构,可能会导致解释器取指令错误,从而引起崩溃。我们替换的ArtMethod的过程中,其它虚拟机相关线程依然在运行:各个Java线程在进行新的类加载;JIT线程在对热点方法进行编译;HeapTaskDaemon在进行GC,这些行为都可能导致替换过程出现稳定性问题。只有在确保所有虚拟机相关的线程均没有持有mutator lock下替换才是绝对安全的。JVM TI替换
Android 8.0在 ART 内部实现了标准的 JVMTI ,支持 IDE 和 ART 通信,可以借助JVM TI的能力来完成替换。JVMTI标准头文件中定义了RedefineClasses接口,这个接口即是用来对Class进行替换的:https://docs.oracle.com/javase/8/docs/platform/jvmti/jvmti.html#RedefineClasses /* 87 : Redefine Classes */
jvmtiError (JNICALL *RedefineClasses) (jvmtiEnv* env,
jint class_count,
const jvmtiClassDefinition* class_definitions);
从它的实现发现Class Redefine有以下特点:- 待替换方法所在的Class被整个替换(所有方法),不能修改方法签名,不支持新增成员变量和方法(Android 11开始通过structural redefine支持了新增成员,这里不展开讨论)。
- 输入参数要求每个待替换的独立类,都需要在生成补丁的环节生成单个的dex文件。
在遵循这些预设条件后,能够成功在支持ART TI的设备上完成初步的方法替换了。使用JVM TI替换的好处也比较多,就是完全不用再处理上述的ArtMethod替换的一系列问题。方法去优化
在类替换方案部分我们介绍过AOT编译过程中会对代码做一些inline的优化,另Android N之后JIT也会做一些优化,这些都会导致Patch方法替换之后,逻辑仍然执行的是之前的指令。这里的inline其实包括对方法的inline和常量折叠(const folding) 。前者意味着如果我们需要修复的方法在机器码层面被inline进了一个方法的机器码中;后者指一些常量(数值,String intern)的情况下,相关读取常量的字节码指令会被省去,取而代之的是直接将常量结果嵌入在机器码指令中。这两种情况都下意味着方法的修复无法生效。从Android N开始,Android启用JIT编译,目的是在运行过程中实时地对一些未编译的方法通过提供更多运行时信息进行编译,以提高性能,其中包含对方法的inline,这直接影响了方法的替换能否生效。同时JIT利用OSR在运行时实时对栈上的方法栈帧进行替换,可能导致我们的热修复在运行时不定期的失效,进一步地增加了热修复效果的不确定性,在Android N上通过demo可以很容易模拟出JIT使得热修复失效的场景,因此我们需要在整个热修复的生命周期中克服JIT带来的热修复正确性问题。如果在这种情况下都忽略已经生成的机器码,仍然从字节码执行, 就可以保证热修复的正确性和稳定性。JVM里将强制一个方法运行在解释执行的过程称为DeOptimization(简称deopt),直译为“去优化”。 为了尽量保运行证性能只让被修复方法的调用链上所有的方法去优化就好。以AOT inline的情况举例,A->B->C的调用链路,修复C方法,我们让ABC均以字节码解释执行,就可以达到被修复的目的。在不同的Android版本上实现deopt的方案不同,总的来说均是参考IDE通过JVM标准协议为某个方法设置断点的方法来对单个方法进行deopt。deopt之后,无论是AOT编译还是JIT code cache所得到的机器码的入口地址被无效化,从而使方法通过字节码解释执行。
So热修复方案和Dex类替换的原理基本是一样的,App启动时会获取So library的搜索路径放置到DexPathList的nativeLibraryElements数组中。当我们使用System.loadLibrary 加载So时,就会从nativeLibraryElements数组中依次从前往后遍历,找到目标So文件拼接完整路径,然后交给Native层去加载。同样只需要把修复后的So路径插入到nativeLibraryPathElements这个List的最前面去,这样就会优先找到修复后的So文件,如图中的patch so path的插入。这里需要针对Android的各个版本适配兼容,具体可以参考Tinker Hook的相关代码TinkerLoadLibrary.java。
https://github.com/Tencent/tinker/blob/dev/tinker-android/tinker-android-lib/src/main/java/com/tencent/tinker/lib/library/TinkerLoadLibrary.java
So 差分
有一些So文件非常大,单个So有几M大,过大的So文件对用户流量、存储空间和补丁下载成功率都有负面影响。解决这个问题可以采用So 差分的方案,在Patch打包过程中对so文件做差分处理,然后在客户端补丁安装时进行整包合成。具体差分可以使用hdiff算法,相比传统的bsdiff算法效率更高。
https://github.com/sisong/HDiffPatch
So依赖导致Patch失败
So依赖指的是一个So中引用了另外一个So中的符号,So的依赖关系可以用DAG来表示,如下a依赖b和c,b和c都依赖d。当系统加载a时候,会查找并加载它的依赖,比如要加a,会先触发b,b会触发d,最终加载顺序为d->b->a。 这里需要了解的是So的加载在Java层只是做一些路径查找和拼接的,真正加载的过程是在Native层实现的,Native层也存有一份So PathList,这个数据在PathClassloader创建过程中就由Java层传递给Native层初始化,以后不会再再更新。当发现一个So动态链接了其他So时,会在Native层的路径列表中查找它依赖的So先进行加载,但是由于我们Patch的路径并没有注入到Native层,只能找到未修复的o路径,最终Patch失败。如下当我们加载a时候,我们把a的完整路径在Java层拼接好传递给Native层,解析时发现a依赖b,会从So Path List中查找到原始的b并加载。这时候我们就发现一个问题,当我的Patch中修复的是b时候,假设业务逻辑是在Java层先加载a,那我们Patch中注入的b根本加载不到,只能加载原始的b,即使业务后面再主动加载b也不行,因为so和类一样加载成功不会再触发加载。
本文到这里就结束了,阅读完本文你或许会有下面四重收获:- 第一重,了解热修复的知识体系本身,热修复整体可以拆分为对Dex修复,So修复和资源的修复,每种修复的大致方案和原理。
- 第二重,全面了解相关的知识,包括Android类加载机制,如何查找和加载一个类;字节码插装,如何实现无兼容性问题的函数替换;虚拟如何执行字节码方法,包括解释执行、JIT、AOT、ARTMethod、JVMTI等等。
- 第三重,从”马后炮“的角度看,其实我们对于任何问题的拆分还是要从本质上出发,思考整个链路上可能有哪些解决方案,先尽可能地列举可能性,然后再一一进行可行性分析,这样我们可能能得到更全的视角和更优的解决方案。
- 第四重,系统思考固然重要,但更重要的其实还是我们对于事物本身的认识,认识的越全面越详细越深刻,我们就越可能有更多答案。更多的时候我们不是缺一个方向,而缺的是如何克服这条路上的一个又一个小困难。
当然关于热修复本文还有很多知识没有介绍,感兴趣的同学可以自行探索。比如下面这些话题:- Patch打包方面的知识,如何生成Patch包;Tinker的 DexDiff原理;Robust在Transform阶段生成Path,如何处理Proguard inline等情况。
参考文档
Android N Combines AOT, Interpretation and JIT
https://www.infoq.com/news/2016/03/android-n-aot-jit/
安卓App热补丁动态修复技术介绍
https://zhuanlan.zhihu.com/p/20308548
Qzone 超级补丁热修复方案原理
https://blog.csdn.net/qq_22393017/article/details/81811101
微信Android热补丁实践演进之路
https://github.com/WeMobileDev/article/blob/master/%E5%BE%AE%E4%BF%A1Android%E7%83%AD%E8%A1%A5%E4%B8%81%E5%AE%9E%E8%B7%B5%E6%BC%94%E8%BF%9B%E4%B9%8B%E8%B7%AF.md
Android 热修复 AndFix 原理,看这篇就够了
https://cloud.tencent.com/developer/article/1633531
Android N混合编译与对热补丁影响深度解析
https://github.com/WeMobileDev/article/blob/master/Android_N%E6%B7%B7%E5%90%88%E7%BC%96%E8%AF%91%E4%B8%8E%E5%AF%B9%E7%83%AD%E8%A1%A5%E4%B8%81%E5%BD%B1%E5%93%8D%E8%A7%A3%E6%9E%90.md
ART下的方法内联策略及其对Android热修复方案的影响分析
https://github.com/WeMobileDev/article/blob/master/ART%E4%B8%8B%E7%9A%84%E6%96%B9%E6%B3%95%E5%86%85%E8%81%94%E7%AD%96%E7%95%A5%E5%8F%8A%E5%85%B6%E5%AF%B9Android%E7%83%AD%E4%BF%AE%E5%A4%8D%E6%96%B9%E6%A1%88%E7%9A%84%E5%BD%B1%E5%93%8D%E5%88%86%E6%9E%90.md
最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
扫一扫 关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!