Tinker 热修复的优点和缺点,以及为什么选择Tinker热修复和Tinker的实现原理
热补丁方案的比较
| ** |
Tinker | QZone | AndFix(阿里) | Robust(美团) |
|---|---|---|---|---|
| 类替换 | yes | yes | no | no |
| So替换 | yes | no | no | no |
| 资源替换 | yes | yes | no | no |
| 全平台支持 | yes | yes | yes | yes |
| 即时生效 | no | no | yes | yes |
| 性能损耗 | 较小 | 较大 | 较小 | 较小 |
| 补丁包大小 | 较小 | 较大 | 一般 | 一般 |
| 开发透明 | yes | yes | no | no |
| 复杂度 | 较低 | 较低 | 复杂 | 复杂 |
| gradle支持 | yes | no | no | no |
| Rom体积 | 较大 | 较小 | 较小 | 较小 |
| 成功率 | 较高 | 较高 | 一般 | 最高 |
- AndFix作为native解决方案,首先面临的是稳定性与兼容性问题,更重要的是它无法实现类替换,它是需要大量额外的开发成本的;在native动态替换java层的方法,通过native层hook java层的代码。

- Robust(字节码插装技术)兼容性与成功率较高,但是它与AndFix一样,无法新增变量与类只能用做的bugFix方案;在对每个函数都在编译打包阶段自动的插入了一段代码,类似于代理,将方法的执行的代码重定向到其他方法中。

- Qzone方案可以做到发布产品功能,但是它主要问题是插桩带来Dalvik的性能问题,以及为了解决Art下内存地址问题而导致补丁包急速增大的。Qzone和Tinker的原理类似
ClassLoader的双亲委派机制(比如java.String(系统)com.String(自定义) 会加载java.String这个类,使自定义的优先于系统的这样就只会加载自定义的类 dex -> pathList的顶部 -> App) - Tinker通过计算对比指定的Base Apk中的dex与修改后的Apk的dex的区别,补丁包中的内容即为两者差分,运行时将Base Apk中的dex与补丁包进行合成,重启后加载全新的合成后的dex文件(Tinker 必须要重启App后才能生效)

特别是在Android N之后,由于混合编译的inline策略修改,对于市面上的各种方案都不太容易解决。而Tinker热补丁方案不仅支持类、So以及资源的替换,它还是2.X-8.X(1.9.0以上支持8.X)的全平台支持。利用Tinker我们不仅可以用做bugfix,甚至可以替代功能的发布。
Tinker的限制
由于原理与系统限制,Tinker有以下已知问题:
- Tinker不支持修改
AndroidManifest.xml,Tinker不支持新增四大组件(1.9.0支持新增非export的Activity); - 由于Google Play的开发者条款限制,不建议在GP渠道动态更新代码;
- 在Android N上,补丁对应用启动时间有轻微的影响;
- 不支持部分三星android-21机型,加载补丁时会主动抛出
"TinkerRuntimeException:checkDexInstall failed"; - 对于资源替换,不支持修改
remoteView。例如transition动画,notification ``icon以及桌面图标。 社区人员维护的,由于开源免费,gradle最新版本不支持兼容性存在一定的问题,目前必须4.0以下的gradle才可以。集成项目中需要大量的时间去校验和验证改成错误等。
Tinker的使用
引入插件依赖
dependencies {classpath 'com.android.tools.build:gradle:3.5.3'classpath("com.tencent.tinker:tinker-patch-gradle-plugin:${TINKER_VERSION}") {changing = TINKER_VERSION?.endsWith("-SNAPSHOT")exclude group: 'com.android.tools.build', module: 'gradle'}}configurations.all {it.resolutionStrategy.cacheDynamicVersionsFor(5, 'minutes')it.resolutionStrategy.cacheChangingModulesFor(0, 'seconds')}
在
gradle.properties中设置tinker的版本和ID(当生成dex包时,顺序+1即可)TINKER_VERSION=1.9.14.17TINKER_ID=1063#是否开启tinkerTINKER_ENABLE=true#是否是gradle 3.x的版本GRADLE_3=true
引入tinker核心库
api("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") { changing = true }// Maven local cannot handle transist dependencies.implementation("com.tencent.tinker:tinker-android-loader:${TINKER_VERSION}") { changing = true }annotationProcessor("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }compileOnly("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }compileOnly("com.tencent.tinker:tinker-android-anno-support:${TINKER_VERSION}") { changing = true }//多分包依赖implementation "androidx.multidex:multidex:2.0.1"
defaultConfig 配置
defaultConfig {....../*** you can use multiDex and install it in your ApplicationLifeCycle implement* 开启多分包*/multiDexEnabled true//tinker混淆情况处理multiDexKeepProguard file("tinker_multidexkeep.pro")/*** buildConfig can change during patch!* we can use the newly value when patch* 配置patch信息*/buildConfigField "String", "MESSAGE", "\"I am the base apk\""/*** client version would update with patch* so we can get the newly git version easily!* 配置TINKER_ID*/buildConfigField "String", "TINKER_ID", "\"${TINKER_ID}\""/*** 支持全平台*/buildConfigField "String", "PLATFORM", "\"all\""}//recommend 开启jumboModedexOptions {jumboMode = true}
创建ApplicationLike代理类
@DefaultLifeCycle(application = "tinker.sample.android.app.SampleApplication",//自动帮助生成SampleApplication类这个类就是Applicationflags = ShareConstants.TINKER_ENABLE_ALL,loadVerifyFlag = false)public class SampleApplicationLike extends DefaultApplicationLike {private static final String TAG = "Tinker.SampleApplicationLike";public SampleApplicationLike(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag,long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) {super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);}@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)@Overridepublic void onBaseContextAttached(Context base) {super.onBaseContextAttached(base);//you must install multiDex whatever tinker is installed!MultiDex.install(base);SampleApplicationContext.application = getApplication();SampleApplicationContext.context = getApplication();}@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)public void registerActivityLifecycleCallbacks(Application.ActivityLifecycleCallbacks callback) {getApplication().registerActivityLifecycleCallbacks(callback);}}
创建
Tinker管理类TinkerManager,在Tinker的Demo中提供了这个类,直接拿过来即可 在这里在
ApplicationLike中的onBaseContextAttached执行TinkerManager.installedTinker(this)TinkerManager.setTinkerApplicationLike(this);TinkerManager.initFastCrashProtect();//should set before tinker is installedTinkerManager.setUpgradeRetryEnable(true);//optional set logIml, or you can use default debug logTinkerInstaller.setLogIml(new MyLogImp());//installTinker after load multiDex//or you can put com.tencent.tinker.** to main dexTinkerManager.installTinker(this);Tinker tinker = Tinker.with(getApplication());
配置Application,编译项目会得到一个自动生成的SampleApplication的class ```groovy public class SampleApplication extends TinkerApplication {
public SampleApplication() {
super(15, "tinker.sample.android.app.SampleApplicationLike", "com.tencent.tinker.loader.TinkerLoader", false, false);
}
}
然后设置到manifest中```groovy<applicationandroid:name=".app.SampleApplication"android:icon="@mipmap/ic_launcher"android:label="@string/app_name"android:theme="@style/AppTheme">......
- 生成基础apk包
Tinker 脚本配置 tinker.gradle,一般我们只需要配置ext中的tinkerEnabled、tinkerOldApkPath、tinkerApplyMappingPath、tinkerApplyResourcePath、tinkerBuildFlavorDirectory这几个选项,其他的配置一般都不要改动。然后将Tinker脚本文件引入即可:apply from: 'tinker.gradle'
平时开发不用开启tinkerEnabled 设置为FALSE即可,如果需要热修复就需要开启tinkerEnabled打基准包(平时一定要做好app的版本tag,当app上线后即使打tag,以后根据tag的代码来打基准包)
def bakPath = file("${buildDir}/bakApk/")ext {//for some reason, you may want to ignore tinkerBuild, such as instant run debug build?tinkerEnabled = true//基准apk路径tinkerOldApkPath = "${bakPath}/app-debug-0830-17-11-57.apk"//未开启混淆,则不需要填写tinkerApplyMappingPath = "${bakPath}/"//基准apk中的R文件路径tinkerApplyResourcePath = "${bakPath}/app-debug-0830-17-11-57-R.txt"//如果你修复了res文件,需要指定你bug版本的R.txt文件tinkerBuildFlavorDirectory = "${bakPath}/app-debug-0830-17-11-57-R.txt"}//获取旧版本路径def getOldApkPath() {return hasProperty("OLD_APK") ? OLD_APK : ext.tinkerOldApkPath}//获取mapping文件路径def getApplyMappingPath() {return hasProperty("APPLY_MAPPING") ? APPLY_MAPPING : ext.tinkerApplyMappingPath}//获取资源mapping路径def getApplyResourceMappingPath() {return hasProperty("APPLY_RESOURCE") ? APPLY_RESOURCE : ext.tinkerApplyResourcePath}//Tinker是否可用,是否def buildWithTinker() {return hasProperty("TINKER_ENABLE") ? TINKER_ENABLE : ext.tinkerEnabled}//获取tinker差分包目录def getTinkerBuildFlavorDirectory() {return ext.tinkerBuildFlavorDirectory}if (buildWithTinker()) {apply plugin: 'com.tencent.tinker.patch'tinkerPatch {/*** 默认为null* 将旧的apk和新的apk建立关联* 从build / bakApk添加apk*/oldApk = getOldApkPath()/*** 可选,默认'false'*有些情况下我们可能会收到一些警告*如果ignoreWarning为true,我们只是断言补丁过程* case 1:minSdkVersion低于14,但是你使用dexMode与raw。* case 2:在AndroidManifest.xml中新添加Android组件,* case 3:装载器类在dex.loader {}不保留在主要的dex,* 它必须让tinker不工作。* case 4:在dex.loader {}中的loader类改变,* 加载器类是加载补丁dex。改变它们是没有用的。* 它不会崩溃,但这些更改不会影响。你可以忽略它* case 5:resources.arsc已经改变,但是我们不使用applyResourceMapping来构建*/ignoreWarning = true/*** 可选,默认为“true”* 是否签名补丁文件* 如果没有,你必须自己做。否则在补丁加载过程中无法检查成功* 我们将使用sign配置与您的构建类型*/useSign = true/*** 可选,默认为“true”* 是否使用tinker构建*/tinkerEnable = buildWithTinker()/*** 警告,applyMapping会影响正常的android build!*/buildConfig {/*** 可选,默认为'null'* 如果我们使用tinkerPatch构建补丁apk,你最好应用旧的* apk映射文件如果minifyEnabled是启用!* 警告:你必须小心,它会影响正常的组装构建!*/applyMapping = getApplyMappingPath()/*** 可选,默认为'null'* 最好保留R.txt文件中的资源id,以减少java更改*/applyResourceMapping = getApplyResourceMappingPath()/*** 必需,默认'null'* 因为我们不想检查基地apk与md5在运行时(它是慢)* tinkerId用于在试图应用补丁时标识唯一的基本apk。* 我们可以使用git rev,svn rev或者简单的versionCode。* 我们将在您的清单中自动生成tinkerId*/tinkerId = TINKER_ID.toInteger()/*** 如果keepDexApply为true,则表示dex指向旧apk的类。* 打开这可以减少dex diff文件大小。*/keepDexApply = false/*** optional, default 'false'* Whether tinker should treat the base apk as the one being protected by app* protection tools.* If this attribute is true, the generated patch package will contain a* dex including all changed classes instead of any dexdiff patch-info files.*/isProtectedApp = false/*** optional, default 'false'* Whether tinker should support component hotplug (add new component dynamically).* If this attribute is true, the component added in new apk will be available after* patch is successfully loaded. Otherwise an error would be announced when generating patch* on compile-time.** <b>Notice that currently this feature is incubating and only support NON-EXPORTED Activity</b>*/supportHotplugComponent = false}dex {/*** 可选,默认'jar'* 只能是'raw'或'jar'。对于原始,我们将保持其原始格式* 对于jar,我们将使用zip格式重新包装dexes。* 如果你想支持下面14,你必须使用jar* 或者你想保存rom或检查更快,你也可以使用原始模式*/dexMode = "jar"/*** 必需,默认'[]'* apk中的dexes应该处理tinkerPatch* 它支持*或?模式。*/pattern = ["classes*.dex","assets/secondary-dex-?.jar"]/*** 必需,默认'[]'* 警告,这是非常非常重要的,加载类不能随补丁改变。* 因此,它们将从补丁程序中删除。* 你必须把下面的类放到主要的dex。* 简单地说,你应该添加自己的应用程序{@code tinker.sample.android.SampleApplication}* 自己的tinkerLoader,和你使用的类*/loader = [//use sample, let BaseBuildInfo unchangeable with tinker"tinker.sample.android.app.BaseBuildInfo"]}lib {/*** 可选,默认'[]'* apk中的图书馆应该处理tinkerPatch* 它支持*或?模式。* 对于资源库,我们只是在补丁目录中恢复它们* 你可以得到他们在TinkerLoadResult与Tinker*/pattern = ["lib/*/*.so"]}res {/*** 可选,默认'[]'* apk中的什么资源应该处理tinkerPatch* 它支持*或?模式。* 你必须包括你在这里的所有资源,* 否则,他们不会重新包装在新的apk资源。*/pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]/*** 可选,默认'[]'* 资源文件排除模式,忽略添加,删除或修改资源更改* *它支持*或?模式。* *警告,我们只能使用文件没有relative与resources.arsc*/ignoreChange = ["assets/sample_meta.txt"]/*** 默认100kb* *对于修改资源,如果它大于'largeModSize'* *我们想使用bsdiff算法来减少补丁文件的大小*/largeModSize = 100}packageConfig {/***可选,默认'TINKER_ID,TINKER_ID_VALUE','NEW_TINKER_ID,NEW_TINKER_ID_VALUE'* 包元文件gen。路径是修补程序文件中的assets / package_meta.txt* 你可以在您自己的PackageCheck方法中使用securityCheck.getPackageProperties()* 或TinkerLoadResult.getPackageConfigByName* 我们将从旧的apk清单为您自动获取TINKER_ID,* 其他配置文件(如下面的patchMessage)不是必需的*/configField("patchMessage", "tinker is sample to use")/***只是一个例子,你可以使用如sdkVersion,品牌,渠道...* 你可以在SamplePatchListener中解析它。* 然后你可以使用补丁条件!*/configField("platform", "all")/*** 补丁版本通过packageConfig*/configField("patchVersion", "1.0.2")}//或者您可以添加外部的配置文件,或从旧apk获取元值//project.tinkerPatch.packageConfig.configField("test1", project.tinkerPatch.packageConfig.getMetaDataFromOldApk("Test"))//project.tinkerPatch.packageConfig.configField("test2", "sample")/*** 如果你不使用zipArtifact或者path,我们只是使用7za来试试*/sevenZip {/*** 可选,默认'7za'* 7zip工件路径,它将使用正确的7za与您的平台*/zipArtifact = "com.tencent.mm:SevenZip:1.1.10"/*** 可选,默认'7za'* 你可以自己指定7za路径,它将覆盖zipArtifact值*/// path = "/usr/local/bin/7za"}}List<String> flavors = new ArrayList<>();project.android.productFlavors.each { flavor ->flavors.add(flavor.name)}boolean hasFlavors = flavors.size() > 0def date = new Date().format("MMdd-HH-mm-ss")/*** bak apk and mapping*/android.applicationVariants.all { variant ->/*** task type, you want to bak*/def taskName = variant.nametasks.all {if ("assemble${taskName.capitalize()}".equalsIgnoreCase(it.name)) {it.doLast {copy {def fileNamePrefix = "${project.name}-${variant.baseName}"def newFileNamePrefix = hasFlavors ? "${fileNamePrefix}" : "${fileNamePrefix}-${date}"def destPath = hasFlavors ? file("${bakPath}/${project.name}-${date}/${variant.flavorName}") : bakPathif (variant.metaClass.hasProperty(variant, 'packageApplicationProvider')) {def packageAndroidArtifact = variant.packageApplicationProvider.get()if (packageAndroidArtifact != null) {try {from new File(packageAndroidArtifact.outputDirectory.getAsFile().get(), variant.outputs.first().apkData.outputFileName)} catch (Exception e) {from new File(packageAndroidArtifact.outputDirectory, variant.outputs.first().apkData.outputFileName)}} else {from variant.outputs.first().mainOutputFile.outputFile}} else {from variant.outputs.first().outputFile}into destPathrename { String fileName ->fileName.replace("${fileNamePrefix}.apk", "${newFileNamePrefix}.apk")}from "${buildDir}/outputs/mapping/${variant.dirName}/mapping.txt"into destPathrename { String fileName ->fileName.replace("mapping.txt", "${newFileNamePrefix}-mapping.txt")}from "${buildDir}/intermediates/symbols/${variant.dirName}/R.txt"from "${buildDir}/intermediates/symbol_list/${variant.dirName}/R.txt"from "${buildDir}/intermediates/runtime_symbol_list/${variant.dirName}/R.txt"into destPathrename { String fileName ->fileName.replace("R.txt", "${newFileNamePrefix}-R.txt")}}}}}}project.afterEvaluate {//sample use for build all flavor for one timeif (hasFlavors) {task(tinkerPatchAllFlavorRelease) {group = 'tinker'def originOldPath = getTinkerBuildFlavorDirectory()for (String flavor : flavors) {def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Release")dependsOn tinkerTaskdef preAssembleTask = tasks.getByName("process${flavor.capitalize()}ReleaseManifest")preAssembleTask.doFirst {String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 15)project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release.apk"project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-mapping.txt"project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-R.txt"}}}task(tinkerPatchAllFlavorDebug) {group = 'tinker'def originOldPath = getTinkerBuildFlavorDirectory()for (String flavor : flavors) {def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Debug")dependsOn tinkerTaskdef preAssembleTask = tasks.getByName("process${flavor.capitalize()}DebugManifest")preAssembleTask.doFirst {String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 13)project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug.apk"project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-mapping.txt"project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-R.txt"}}}}}}
一定要通过 gradle的命令打基础包
在bakApk生成三个文件:
- apk 文件就是基准包
- mapping.txt 是开启混淆后,混淆的映射文件
- R.txt 是资源的映射文件

- 生成差异apk文件的Gradle脚本
修改tinker执行脚本
tinkerOldApkPath = "${bakPath}/app-release-0906-17-18-35.apk"tinkerApplyMappingPath = "${bakPath}/app-release-0906-18-18-35-mapping.txt"tinkerApplyResourcePath = "${bakPath}/app-release-0906-18-18-35-R.txt"
执行tinkerPatchRelease/tinkerPatchDebug一个是打debug环境的patch,一个是打release环境的patch
生成如下的patch包:一般都是用patch_signed_7zip.apk,将这个文件上传服务器即可
Tinker的原理
- 服务端:补丁包管理
- 用户端:执行热修复
- 开发端:生成补丁包
热修复要解决的问题:
- 补丁包是什么
- 如何生成补丁包?
- 开启混淆后会有什么影响?
- 手动生成的补丁包对比自动生成补丁包(Gradle自动生成的补丁包)
- 什么时候执行热修复?
- 怎么执行热修复使用补丁包?
- Android版本兼容的问题
ClassLoader的机制
双亲委托机制:某个类加载器在加载类时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务或者没有父类加载器时,才自己加载。
- 避免重复加载,当父加载器已经加载了该类的时候,就没有必要子ClassLoader再加载一次
- 安全性考虑,防止核心库的API被随意篡改

常用的ClassLoader的类图: 
PathClassLoader :加载App应用程序的类。 DexClassLoader: 没有安装到app中的代码,一般通过
DexClassLoader去加载外部的类,加载外部的dex文件或者apk文件。(DexClassLoader如果发现是apk文件就会解压,解压之后就会把dex文件都会加载一遍)
例如:java.lang.String.class 系统的类,假如在插件包dex中进行加载,首先会通过:
检测父类有没有加载而不是检测DexClassLoader有没有加载:
DexClassLoader -> BaseDexClassLoader -> ClassLoader (先看ClassLoader有没有加载,肯定是已经加载的String是系统的类,不会再次重新加载)
ClassLoader加载过了,将结果返回ClassLoader -> BaseDexClassLoader -> DexClassLoader
再比如:MyClass.class 在内存中没有被加载的,丢给DexClassLoader,而后检测BaseDexClassLoader有没有被加载,没有检测ClassLoader有没有加载,如果都没有丢给BootClassLoader进行加载,加载完之后在依次返回给DexClassLoader.
热修复就是基于:pathList:DexPathList 不同版本的源码有 makeXXElements 进行反射,如下dex的加载流程

热修复的流程
- 获取到当前应用的PathClassLoader
- 反射获取到DexPathList属性对象pathList
- 反射修改pathList的dexElements数组
- 把补丁包
patch.dex转化为Element[](patch)- 获得
pathList的dexElements属性(old)patch + dexElements合并,并反射赋值给pathList的dexElements,这样patch.dex就会在dexElements的第一个,首先会先加载Key.class,如果遇到旧的Class2.dex中的Key.class就不会在加载了,这样新的class就替换了旧的class。

