Android 框架解析:热修复框架 Tinker 从使用到 patch 加载、生成、合成原理分析
大家好,我是拭心,今天我来简单讲一下微信开源的热修复框架 Tinker。
目录
讲的内容主要分为以下几节:
第一节: 特点介绍,简单介绍一下 Tinker 和其他热门框架对比的优点。
第二节:通过官方提供的 sample 了解 Tinker 的使用和基础 API
知道怎么用以后,我们再一起探究一下背后的原理
第三节:了解下运行时 Tinker 是如何加载补丁的,分为 dex,资源和 so 库
第四节:了解一下 patch 的格式和如何做 diff,以及运行时如何合成
时间够的话简单讲下 gradle plugin
第五节:总结
这次分析基于的是目前最新的
1.9.14.3
版本
ok 我们首先来了解一下 Tinker。
Tinker 介绍
Tinker 是微信开源的热修复框架,Github 主页在 https://github.com/Tencent/tinker/wiki。
我们看下 Tinker 和其他热门框架的对比图:
可以看到,Tinker 的特点是:
支持类替换、So 替换,资源替换是采用类似 instant-run 的方案
补丁包较小,自研 diff 方案,下发的是差量包,包括的是变更的内容
支持 gradle,提供了 gradle-plugin,允许我们配置很多内容
采用全量 Dex 更新,不需要额外处理
CLASS_ISPREVERIFIED
问题
CLASS_ISPREVERIFIED
问题大家可能也知道,如果 A 类引用的类都在一个 dex 里,就会被打上preverified
,后面如果有引用其他 dex 的类,就会报错。(这句可以不说:这种情况只发生在 Dalvik 虚拟机上)QZone 采用的方案通过字节码插桩让可能被修复的类都不打上这个标志,会导致有性能影响。Tinker 在合并 dex 时,会创建一个新的几乎完整的 dex,从而规避了这个问题。
具体细节等下讲原理的时候说。
Tinker 还有一个优点就是一直在维护中,迭代更新还比较快。
缺点主要就是不支持即时生效。因为是在 Java 层做修复,而不是 native 层。
ok,简单了解了 Tinker 的特点后,我们来看下 Tinker 的使用。
使用
gradle 配置
核心类介绍
TinkerApplicaition
ApplicationLike
TinkerLoader
PatchListener
TinkerResultService
Reporter
这里使用的是官方提供的 sample,我们来看下接入 Tinker 需要做哪些。
(打开 tinker-sample-android)
首先打开根目录的 `build.gradle,可以看到,这里依赖了
tinker-patch-gradle-plugin``:
这个插件主要做的是提供了五个核心 Task,在编译期间对资源和代码做一些额外处理
接着打开 app
目录下的 build.gradle
文件,可以看到对 tinker 的依赖有三个:
tinker-android-lib
,这个主要是提供对外暴露的 API,等下使用到的 Tinker API 基本都在这个工程下tinker-android-loader
,这个工程主要是完成 patch 的加载,稍后讲解 patch 加载原理时主要讲的就是这个工程tinker-android-anno
,这个工程很简单,就是一个注解处理器,作用就是帮助我们生成一个Applicaition
,可以看下它的代码(读取注解的信息,根据模板信息生成一个类)
添加了依赖后,还需要添加一些配置信息,我们继续看 build.gradle
。
首先看到 ext 拓展属性里定义了几个属性,
tinkerEnabled
和名字一样表示使用启用 tinkertinkerOldApkPath
表示基准包的位置,这里的bakPath
就是app/build/bakApk
目录tinkerApplyMappingPath
表示基准包使用混淆时的 mapping 文件所在路径,在做差量包时需要使用这个 mappingtinkerApplyResourcePath
表示资源 R.txt 的路径,这个文件在 build 阶段处理资源processDebugResources
时,会生成资源索引等信息输出到这个文件
翻到下面我们可以看到,这里有些一个 task 在编译生成 apk 后会拷贝 apk mapping 和 R.txt 文件到这个 bak 目录下
ok 接下来看一下最关键的 tinker-gradle 配置。
https://github.com/Tencent/tinker/wiki/Tinker-%E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%97
tinkerPatch
是 tinker 的拓展属性,允许我们对 build 过程做一些自定义。
(简单介绍两个比较重要的配置)
buildConfig
里的是编译相关的配置keepDexApply
是指开启补丁包根据基准包的类分部进行编译,避免补丁修改很多,导致类所在的 dex 和基准包不一样isProtectedApp
是否使用加固模式,这种情况下只将变更的类合成补丁dex
是对 dex 里的配置dexMode
,输入的 dex 格式,jar 或者 rawpattern
需要处理的 dex 路径loader
是配置一些不会打入 patch 的类,默认放加载插件相关的类
其他的配置还有很多,这里就不 一一介绍了。
ok 了解了 gradle 文件的配置内容后,我们来看下项目代码。
首先看下 AndroidManifest.xml
文件:
可以看到这个 sample 比较简单,先看下这个 Applicaition
,这个类在 build 目录,就是我们前面提到的,通过 注解处理器生成的类。
这个 SampleApplication
继承了 TinkerApplicaitiopn
,我们看下代码。
TinkerApplicaition
TinkerApplicaitiopn
需要讲解的点:
构造函数参数的意义
tinkerFlags
表示要加载的类型,包括 dex , library 还是全部支持delegateClassName
表示 Applicaition 代理类的 className,也就是 ApplicaitionLike第三个参数
loaderClassName
表示 Tinker 加载类的 className,默认是TinkerLoader
,我们也可以继承做些修改最后一个参数
tinkerLoadVerifyFlag
表示是否需要在加载时检查文件的 md5,默认是 false,因为在合成阶段就做了校验,所以这里一般不需要再校验
做了什么,先看
attachBaseContext()
反射创建
TinkerLoader
,调用TinkerLoader#tryLoad
方法加载补丁,具体细节稍后讲解创建桥接类,回调桥接类各个生命周期方法
这里我们可以看到,是通过反射创建一个桥接类,在桥接类里又通过反射创建了代理类,原因我们等下讲
SampleApplicaitionLike
ok,我们接下来看一下 sample 里的 Application 代理类 SampleApplicaitionLike
,它使用了 @DefaultLifeCycle
注解,参数就是要生成的 Applicaition 全路径,支持类型是全部,加载验证是 false。
SampleApplicaitionLike
继承了 DefaultApplicaitionLike
,提供了一些类似 Application 的 API。里面也没有做什么额外处理,只是为了让我们把在 Applicaition 里的代码转移到这里。
为什么要通过这种方式呢?
主要有两个原因:
让 Tinker 可以对 Applicaition 初始化时使用到的类进行修复
应用 7.0 混合编译对热修复的影响
官方文档里的介绍是这样说的:
程序启动时会加载默认的 Application 类,这导致我们补丁包是无法对它做修改了。如何规避?在这里我们并没有使用类似 InstantRun hook Application 的方式,而是通过代码框架的方式来避免,这也是为了尽量少的去反射,提升框架的兼容性。这里我们要实现的是完全将原来的 Application 类隔离起来,即其他任何类都不能再引用我们自己的 Application。我们需要做的其实是以下几个工作:
将我们自己 Application 类以及它的继承类的所有代码拷贝到自己的 ApplicationLike 继承类中,例如 SampleApplicationLike。你也可以直接将自己的 Application 改为继承 ApplicationLike;
Application 的 attachBaseContext 方法实现要单独移动到 onBaseContextAttached 中;
对 ApplicationLike 中,引用 application 的地方改成 getApplication();
对其他引用 Application 或者它的静态对象与方法的地方,改成引用 ApplicationLike 的静态对象与方法;
也就是说,通过反射,将 Tinker 组建和 App 隔离开,并且先后顺序是先 Tinker 后 App,这样可以防止 App 中的代码提前加载,确保 App 中所有的代码都可以具有被热修复的能力包括 ApplicationLike。
ok 回到 SampleApplicationLike
中,可以看到在 onBaseContextAttached()
方法中,调用了 TinkerManager#installTinker
进行初始化,然后调用 Tinker#with
初始化 Tinker 实例。
TinkerManager#installTinker
里有创建了一些自定义的监听器,包括 patch 加载监听、patch 验证监听 、收到 patch 的监听等
TinkerInstaller
TinkerInstaller#install
里创建了 Tinker 类,调用了 install 方法。
TinkerInstaller#onReceiveUpgradePatch
方法,在接收到新的 patch 后我们调用这个方法,传入路径,然后会进行 patch 的合成,我们稍后介绍。
还有其他监听类我们就不一一介绍了。
PatchListener
TinkerResultService
Reporter
OK,那我们接下来把项目运行起来看看效果。
有提供脚本 push 到设备
总结
Tinker 流程图
ok,了解了 Tinker 的基本使用后,我们来看下背后的原理。
tinker.png
这张图来自 Tinker Github。
Tinker 将 old.apk 和 new.apk 做了 diff,生成一个 patch.dex,然后下发到手机,将 patch.dex 和本机 apk 中的 classes.dex 做了合并,生成新的 classes.dex,然后加载。
首先看下 Tinker 加载补丁的代码。因相交于生成,这部分更简单些。
运行时 Tinker 是如何加载补丁
前面我们在介绍生成的 Application
时就提到,TinkerApplication#attachBaseContext
中辗转会调用到 loadTinker()
方法,在该方法内部,反射调用了 TinkerLoader#tryLoad
方法加载 patch。
TinkerLoader
相关的代码在 tinker-android-loader
:
看下加载相关的类图。TinkerLoader
是加载对外暴露的 API,它内部调用了 TinkerDexLoader
, TinkerResourceLoader
和 TinkerSoLoader
分别用于加载 dex 资源和 so。
我们看下代码, TinkerLoader#tryLoad
,它调用了 TinkerLoader#trylLoadPatchFilesInternal
,这个方法内容很多,主要做了两件事:
会判断补丁是否存在,检查补丁信息中的数据是否有效,校验补丁签名以及 tinkerId 与基准包是否一致。在校验签名时,为了加速校验速度,Tinker 只校验 *_meta.txt 文件,然后再根据 meta 文件中的 md5 校验其他文件
根据开发者配置的 Tinker 可补丁类型判断是否可以加载 dex,res,so。然后分别分发给 TinkerDexLoader、TinkerSoLoader、TinkerResourceLoader 分别进行校验是否符合加载条件进而进行加载
很多校验我们就不细看了。主要看下对 TinkerDexLoader
, TinkerResourceLoader
和 TinkerSoLoader
的调用。
TinkerLoader
里搜TinkerDexLoader
首先看下 TinkerDexLoader 是如何校验、加载的 dex。
加载 dex
TinkerDexLoader 的两个方法:
TinkerDexLoader#checkComplete
检查 dex 补丁文件和优化过的 odex 文件是否可以加载TinkerDexLoader#loadTinkerJar
把补丁 dex 插入到 ClassLoader 里
我们重点看下 TinkerDexLoader#loadTinkerJar
。
选中
classloader
变量
前面都是做一些校验和 OTA 的处理,直接看方法的最后,调用了 SystemClassLoaderAdder#installDexes
这个核心方法。
点进去看一下,可以看到,它区分不同版本做了不同的处理。
我们重点看下 19 和 24 的处理。
V19
找到 classLoader.pathList
然后调用
makeDexElements
创建新的 Element[] 数组然后调用
ShareReflectUtil#expandFieldArray
插入,重点看下这个方法创建一个新的数组,把 patch 相关的 dex 放在前面,然后将合并后的数组设置给 pathList
V23
和 19 的区别在于
makeDexElements
->makePathElements
,方法的名称、参数做了调整V24 (7.0)的特殊点在于
为每个 patch 创建了一个新的
AndroidNClassLoader
这是由于安卓 7.0 支持混合编译 ,混合编译对热修复的影响
简单了解下混合编译。
混合编译与热修复
为什么有混合编译
混合编译对热修复的影响
如何解决
我们知道:
Dalvik 虚拟机,运行时通过 JIT 把字节码编译成机器码再执行,运行时速度比较慢
ART 虚拟机上,改为 AOT 提前编译 ,即在安装应用或者 OTA 系统升级的时候把字节码翻译 成 机器码,这样运行时就可以直接执行了
但 ART 的缺点是,可能会导致安装时间过长,而且全量编译占用的 ROM 空间更大
所以在 Android N 上提出了混合编译,AOT 编译, JIT 编译和解释执行配合使用。
在应用运行时分析运行过的代码以及“热代码”,并将配置存储下来。在设备空闲与充电时,ART 仅仅编译这份配置中的“热代码”
简单来说,就是在应用首次安装、运行时不做 AOT 编译,然后把运行中 JIT 解释执行的代码记录下来,设备空闲时通过 dex2oat
编译生成名为 app_image
的 base.art
文件,这个文件主要为了 加快应用对“热代码”的加载和缓存。
在 apk 启动时,会加载应用的 oat 文件和可能存在的 app_image 文件,如果存在 app_image 文件,则把这个文件里的 class 插入到 ClassTable,在类加载时,会先从 ClassTable 中查找,找不到才会去走 defineClass
app image 的作用是记录已经编译好的“热代码”,并且在启动时一次性把它们加载到缓存。预先加载代替用时查找以提升应用的性能,到这里我们终于明白为什么 base.art 会影响热补丁的机制。
无论是使用插入 pathlist 还是 parent classloader 的方式,若补丁修改的 class 已经存在于 app image,它们都是无法通过热补丁更新的。它们在启动 app 时已经加入到 PathClassloader 的 ClassTable 中,系统在查找类时会直接使用 base.apk 中的 class。
从刚才的代码我们也看到了,Tinker 的解决方案是,新建一个 ClassLoader,也就是不使用之前的 cache。
可以看到,加载 dex 其实和 QZone 的方案差不多,都是通过反射将 dex 文件放置到加载的 dexElements 数组的前面。
微信 Tinker 原理图
区别在于:
QZone 是将 patch.dex 插到数组的前面,也就是说没修改的类还是在之前的 dex 里,这就可能导致那个
CLASS_ISPREVERIFIED
问题,QZone 通过插桩解决这个问题,这里就不多说了Tinker 则是将合并后的全量 dex 插在数组前,这样就避免了这个问题的出现
加载资源
Tinker 的资源更新采用的
InstantRun
的资源补丁方式,全量替换资源
首先回顾一下,应用加载资源是通过 Context.getResources()
返回的 Resources 对象, Resources 内部包装了 ResourcesImpl
, 间接持有了 AssetManager
对象,最终由 AssetManager 从 apk 文件中加载资源。
要加载资源,需要做 2 步:
新建一个 AssetManager ,通过
addAssetPath()
方法把补丁资源目录传递进去替换所有 Resources 对象中的 AssetManager
看下代码,资源加载部分主要在 TinkerResourceLoadr
中,两个方法:
TinkerResourceLoader#checkComplete
检查资源补丁是否存在,存在的话,调用TinkerResourcePatcher#isResourceCanPatch
区分版本拿到Resources
对象的集合,同时创建新 AssetsManager看一下代码
拿到
addAssetPathMethod
方法留着后面调用4.4 以上通过
ResourcesManager
获取mActiveResources
变量,它是 ArrayMap 类型;在 7.0 上这个变量名称为mResourceReferences
4.4 以下通过
ActivityThread
获取mActiveResources
变量,是一个 HashMap保存这些集合后
TinkerResourceLoader#loadTinkerResource
调用TinkerResourcePatcher#monkeyPatchExistingResources
(这个方法的名字跟 InstantRun 的资源补丁方法名是一样的)
反射调用新建的
AssetManager#addAssetPath
将路径穿进去循环遍历持有 Resources 对象的 references 集合,依次替换其中的 AssetManager 为新建的 AssetManager
最后调用 Resources.updateConfiguration 将 Resources 对象的配置信息更新到最新状态,完成整个资源替换的过程
加载补丁要替换的 Resources 对象在 KITKAT 之下是以 HashMap 的类型作为 ActivityThread 类的属性.其余的系统版本都是以 ArrayMap 被 ResourcesManager 持有的.所以要按照系统区分开.
市面上大多数的热补丁框架都采用 instant-run 的这套资源更新方案
加载 so
Tinker 加载 SO 补丁提供了两个入口,
TinkerLoadLibraryloadArmLibrary
TinkerApplicationHelper#loadLibraryFromTinker
(看下它的代码)
比较简单,最终都是调用 System#load
dex diff
dex 格式
diff 简单思路
代码
Tinker 的亮点之一就是它的 diff 算法,补丁里只包含改变的信息,非常小。这一节我们来了解下如何实现的 dex diff。
开始之前,先简单介绍下 dex 的格式。
dex 格式
先 javac 生成 class 文件,再通过 dx 工具生成 dex 文件。
如图所示,dex 文件主要包括三个区域:
文件头记录了包含了一些校验相关的字段,和整个 dex 文件大致区块的分布
结合 010Editor 打开的 HelloWorld.dex 文件介绍下内容。
header 定长 112。
magic
是用于表示 dex 文件和版本。checksum
是文件检验和其余的根据名字就可以看出来意义
成对出现的 size 和 off,大多代表各区块的包含的特定数据结构的数量和偏移量。例如:string_ids_off 为 112,指的是偏移量 112 开始为 string_ids 区域;string_ids_size 为 14,代表 string_id_item 的数量为 14 个
紧接着 header 后面的是索引区,描述了 dex 文件中 各种格式的数据和 id。
string_id_list,描述了 dex 文件中所有的字符串,格式很简单只有一个偏移量,偏移量指向了 string_data 区段的一个字符串
type_id_list,描述 dex 中所有的文件类型,内容也很简单,只有一个 string_id 里的序号
proto_id_list,描述了方法原型,内容包括 method 原型名称、返回值和参数,参数 0 表示没有
field_id_list,描述了 dex 文件引用的所有 field,内容包括 field 的所在的 class,field 的类型和 field 的名称
method_id_list,描述了 dex 文件里所有的 method,内容包括方法所属的 class,类型和名称
最后是数据区,010 Editor 中没有展示 data 的数据。
class_def,描述了 dex 文件中的 class 信息
map_list(对应上图中的 link_data ),大部分 item 跟 header 中的相应描述相同,都是介绍了各个区的偏移和大小,但是 map_list 中描述的更加全面,包括了 HEADER_ITEM 、TYPE_LIST、STRING_DATA_ITEM、DEBUG_INFO_ITEM 等信息。
类型
个数
偏移量
**通过
map_list
,可以将一个完整的 dex 文件划分成固定的区域(本例为 13),且知道每个区域的开始,以及该区域对应的数据格式的个数
了解了 dex 格式后,看下 tinker 中讲 dex 文件读取到内存中的类 TableOfContents
,可以看到,使用 Section 描述不同类型的区域。
tinker patch 格式了解
tinker dex format
tinker patch 里主要包括两部分内容 :
header,magic 和 version ,记录各个 Section 的偏移量
其余部分是不同类型的变更情况,item 包括操作类型、索引和变更内容
使用
可以看到,patch 主要记录的是对不同数据类型的数据进行的新增、删除或者修改操作,和修改的内容。
对应 tinker 里的 PatchOperation
类:
diff 和合并简单思路
了解 tinker-patch 的内容后,就基本可以了解到 tinker dex-diff 的思路了。
逐个对比新旧 dex 每个 Section 的变更情况,然后再 patch 里把每个区域变更的类型和索引、内容写到 patch 里。
运行时拿到 patch,根据变更 Section 里的数据,去修改对应的索引的数据,生成最终 dex。
看看代码是不是这样。
源码分析
前面的例子我们知道,在执行完 tinker 的 tinkerPatchDebug
task 后 ,就生成了 patch。
顺着代码看下,
TinkerPatchSchemaTask
Runner#tinkerPatch
ApkDecoder#patch
DexDiffDecoder#onAllPatchesEnd
DexDiffDecoder#generatePatchInfoFile
DexDiffDecoder#diffDexPairAndFillRelatedInfo
DexPatchGenerator#executeAndSaveTo
最后发现,真正生成 patch 是在 DexPatchGenerator
这个类中。
DexPatchGenerator
dex 读取到内存,
Dex#loadFrom
,TableOfContents#readFrom
,将 dex 文件内容,按照 map-list 分到不同的 Section 中DexPatchGenerator
构造函数,初始化了 15 个对不同区域的算法,目的就是前面说的,计算出每个区域的变更情况DexPatchGenerator#executeAndSaveTo
,调用 15 个算法的execute()
和simulatePatchOperation()
以 stringDataSectionDiffAlg
为例,看下做了什么。
DexSectionDiffAlgorithm
看下它的 execute()
和 simulatePatchOperation()
方法 。
execute()
做的工作:
拿到旧、新 dex 的 Section Item 列表,排序
遍历,对比每个 item
做一些排序处理,合并相同 index 的 ADD 和 DEL 为 REPLACE
把不同类型的操作,保存到三个 map
经历完成 execute
之后,我们主要的产物就是 3 个 Map,
indexToDelOperationMap,indexToAddOperationMap,indexToReplaceOperationMap
分别记录了:oldDex 中哪些 index 需要删除;newDex 中新增了哪些 item;哪些 item 需要替换为新 item。
simulatePatchOperation()
做的工作:根据前面的 3 个 map,计算变更数据 index 和 offset,计算下一个 Section 需要依赖前面的 offset。
经过这两个方法 ,得到了这个 Section 的 patchOperationList
和 patchSectionSize
。
执行完所有算法,就可以得到整个 patch 所有 Section 的变更操作和对应的偏移量,
写 patch 文件
执行完所有算后,进入 DexPatchGenerator#writeResultToStream
生成 patch 文件。
DexPatchGenerator#writePatchOperations
中,主要完成三步(看代码):
可以看到,和我们前面看的那个图对应的数据一样。
patch 的合成
前面提到,TinkerInstaller#onReceiveUpgradePatch
方法,在接收到新的 patch 后我们调用这个方法,传入路径,然后会进行 patch 的合成。
补丁合成在单独的 patch 进程工作,包括 dex,so 还有资源,主要完成补丁包的合成以及升级。
patch 里关于变更信息的数据:
1.del 操作的个数,每个 del 的 index2.add 操作的个数,每个 add 的 index3.replace 操作的个数,每个需要 replace 的 index4.最后依次写入 newItemList.
DexPatchApplier
Dex File ->
DexPatchFile
检查 patch 是否能作用到当前的 oldDex(通过比较 oldDex 签名和 patch 里记录的 oldDex 签名,签名使用
SHA-1
算法)把 patch 里记录的合并后的各个 Section 的值复制给合并后 dex 的
TableOfContents
创建 15 个合并算法处理器,处理不同区域的数据合并
最后,写入 header mapList 和 合并后 dex 的签名和校验和
每个 Section 的合并算法类似,继承自 DexSectionPatchAlgorithm
:
读取保存 del add replace 操作 index 的数组
计算出合并后的 itemCount:
newItemCount
=oldItemCount
-deleteItemCount
+addItemCount
往合并后的 dex 对应的 xxData 区域写最终内容(包括没变的、新增的和替换的)
从 0 开始,按顺序写合并后的内容规则:
首看先这个位置是否有新增的
然后看这个位置是否需要替换为 newItem
如果这个位置包含在 del/replace 数组里,那就跳过这个位置 oldItem
否则说明这个位置的 oldItem 元素没动,写到合并后的 dex 里
版权声明: 本文为 InfoQ 作者【拭心】的原创文章。
原文链接:【http://xie.infoq.cn/article/25f0dd3caaca8a0d95832e330】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论