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.17
TINKER_ID=1063
#是否开启tinker
TINKER_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 开启jumboMode
dexOptions {
jumboMode = true
}
创建ApplicationLike代理类
@DefaultLifeCycle(application = "tinker.sample.android.app.SampleApplication",//自动帮助生成SampleApplication类这个类就是Application
flags = 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)
@Override
public 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 installed
TinkerManager.setUpgradeRetryEnable(true);
//optional set logIml, or you can use default debug log
TinkerInstaller.setLogIml(new MyLogImp());
//installTinker after load multiDex
//or you can put com.tencent.tinker.** to main dex
TinkerManager.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
<application
android: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() > 0
def 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.name
tasks.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}") : bakPath
if (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 destPath
rename { String fileName ->
fileName.replace("${fileNamePrefix}.apk", "${newFileNamePrefix}.apk")
}
from "${buildDir}/outputs/mapping/${variant.dirName}/mapping.txt"
into destPath
rename { 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 destPath
rename { String fileName ->
fileName.replace("R.txt", "${newFileNamePrefix}-R.txt")
}
}
}
}
}
}
project.afterEvaluate {
//sample use for build all flavor for one time
if (hasFlavors) {
task(tinkerPatchAllFlavorRelease) {
group = 'tinker'
def originOldPath = getTinkerBuildFlavorDirectory()
for (String flavor : flavors) {
def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Release")
dependsOn tinkerTask
def 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 tinkerTask
def 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。