写在最前面
咱们好我是三雒,宽恕我用了这么略带虚浮的标题,其实我是一个十分务实的正派技能人,变成专家的确有点吹牛的成分,可是你或许能够离专家更近一步,不信? 看完你或许大约或许就信了,就怕你看不完啊..
写在前面
近几年一方面跟着热修正结构自身已发展相对成熟,另一方面关于事务动态更新的诉求咱们致力于经过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的前身诞生了。
以下是附的各种“代码”的问题频率和热修正计划表格:
代码种类 | 对应终产物 | 线上问题频率 | 动态加载计划 |
---|---|---|---|
Java/Kotlin代码 | Dex文件 | 高频 | 类替换 、办法替换(编译时/运转时) |
Native代码 | So文件 | 很低 | So替换 |
资源文件 | xml、png、resource.arsc等 | 很低 | AssetsManager替换、资源id固定, 不替换AssetsManager |
接下来咱们将详细介绍类替换、编译时办法替换和运转时计划替换三种Dex修正的原理以及中心问题,而且也会介绍So修正的原理。
Dex修正
类替换计划
该类型的计划其实也是咱们最熟知的计划,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:
- App不是Debug版别的;
- 被调用的办法地点的类与调用者地点的类坐落同一个Dex;(留意,符合Class N命名规则的多个Dex要看成同一个Dex)
- 被调用的办法的字节码条数不超越dex2oat经过
--inline-max-code-units
指定的值,6.x默以为100,7.x默以为32; - 被调用的办法不含try块;
- 被调用的办法不含非法字节码;
- 关于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中。
咱们能够在手机上履行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
method的输出格局如下:
0x792b639c ArtMethod: void com.tencent.mm.e.a.je.<init>()
OAT CODE: 0x471dae14-0x471daece
SIZE: Dex Instructions=10 StackMaps=0 AccessFlags=0x90001
0x792b63c0 ArtMethod: void com.tencent.mm.e.a.je.<init>(byte)
OAT CODE: 0x471daee4-0x471daf52
SIZE: Dex Instructions=48 StackMaps=0 AccessFlags=0x90002
0x792b63e8 ArtMethod: void com.tencent.mm.e.a.jo.<init>()
OAT CODE: 0x463d5f44-0x463d5f50
SIZE: Dex Instructions=10 StackMaps=0 AccessFlags=0x90001
那么咱们就剩余最后一个问题,AppImage文件是什么时分被加载并怎样进步运用功能的?
在apk发动时咱们需求加载运用的oat文件以及或许存在的AppImage文件,它的大致流程如下:
- 经过OpenDexFilesFromOat加载oat时,若AppImage存在,则经过调用OpenImageSpace函数加载;
- 在加载AppImage文件时,经过UpdateAppImageClassLoadersAndDexCaches函数,将art文件中的dex_cache中dex的一切class刺进到ClassTable,一起将method更新到dex_cache;
- 在类加载时,运用时ClassLinker::LookupClass会先从ClassTable中去查找,找不到时才会走到DefineClass中,
简略来说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;
}
}
accessDispatch办法也相似:
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());
// ...
}
生成的Patch中首要有这三个类:
- 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字段
到这儿整个Patch的逻辑就跑通了。
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<PatchedClassInfo> 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<art::mirror::Class*>(dmeth->declaring_class_)->class_loader_ =
reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->class_loader_; //for plugin classloader
reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->clinit_thread_id_ =
reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->clinit_thread_id_;
reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->status_ = reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->status_-1;
//for reflection invoke
reinterpret_cast<art::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的字段,上面的代码其实真实意义就会变成下面这样,和原始的逻辑不一致了。
smeth->foo = dmeth->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进行替换的:
/* 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办法替换之后,逻辑仍然履行的是之前的指令。
- AOT inline的影响
这儿的inline其实包含对办法的inline和常量折叠(const folding) 。前者意味着假如咱们需求修正的办法在机器码层面被inline进了一个办法的机器码中;后者指一些常量(数值,String intern)的状况下,相关读取常量的字节码指令会被省去,取而代之的是直接将常量结果嵌入在机器码指令中。这两种状况都下意味着办法的修正无法收效。
- JIT的影响
从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修正
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
So 差分
有一些So文件十分大,单个So有几M大,过大的So文件对用户流量、存储空间和补丁下载成功率都有负面影响。处理这个问题能够选用So 差分的计划,在Patch打包进程中对so文件做差分处理,然后在客户端补丁装置时进行整包组成。详细差分能够运用hdiff算法,比较传统的bsdiff算法功率更高。
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和类相同加载成功不会再触发加载。
那这个问题怎样处理呢?
-
与Java层Hook计划相似,想办法将途径也注入到Native层,但这种Hook的办法无疑也会有许多兼容性问题需求处理,完结杂乱
-
运用新的ClassLoader,已然Native层途径只能在ClassLoader创立时分读取,那咱们运用新的ClassLoader就能够处理这个问题,这个计划仍然有兼容的本钱
-
在加载Patch时分提早加载Patch中的so文件,这个机遇要求越早越好。因为依靠的问题,事务上何时怎样运用是不可控的,咱们无妨将自动权掌握在自己手中,只需提早加载Patch中的so文件,就必定能保证Patch成功。一般一个App版别不会呈现修正很多so的状况,在补丁中最多也就只要少量几个so文件,提早加载补丁中的so关于功能上并不会有太大影响。需求留意的是当补丁中含有多个so文件时,这些so文件或许也会含有依靠联系,需求核算补丁中的so加载次序,保证被依靠的so比依靠它的so先加载。比方补丁中一起修正a和b两个so,需求先加载b,再加载a。出于解析依靠的功能考虑,依靠解析能够放到Patch打包进程中,结果存储到Patch包中去。这种计划是相当于纯运用层避免问题,不存在任何的兼容问题。
收成四重
本文到这儿就完毕了,阅读完本文你或许会有下面四重收成:
- 榜首重,了解热修正的常识系统自身,热修正全体能够拆分为对Dex修正,So修正和资源的修正,每种修正的大致计划和原理。
- 第二重,全面了解相关的常识,包含Android类加载机制,怎样查找和加载一个类;字节码插装,怎样完结无兼容性问题的函数替换;虚拟怎样履行字节码办法,包含解说履行、JIT、AOT、ARTMethod、JVMTI等等
- 第三重,从”马后炮“的视点看,其实咱们关于任何问题的拆分仍是要从本质上动身,考虑整个链路上或许有哪些处理计划,先尽或许地罗列或许性,然后再一一进行可行性剖析,这样咱们或许能得到更全的视角和更优的处理计划。
- 第四重,系统考虑固然重要,但更重要的其实仍是咱们关于事物自身的知道,知道的越全面越详细越深入,咱们就越或许有更多答案。更多的时分咱们不是缺一个方向,而缺的是怎样克服这条路上的一个又一个小困难。
当然关于热修正本文还有很多常识没有介绍,感兴趣的同学能够自行探究。比方下面这些话题:
- 资源热修正原理
- Patch打包方面的常识,怎样生成Patch包;Tinker的 DexDiff原理;Robust在Transform阶段生成Path,怎样处理Proguard inline等状况;
- 干流热修正计划的的优劣对比
参考文档
Android N Combines AOT, Interpretation and JIT
安卓App热补丁动态修正技能介绍
Qzone 超级补丁热修正计划原理
微信Android热补丁实践演进之路
Android 热修正 AndFix 原理,看这篇就够了
Android N混合编译与对热补丁影响深度解析
ART下的办法内联战略及其对Android热修正计划的影响剖析