Tinker是微信官方的Android热补丁解决方案,它支持动态下发代码、So库以及资源,让应用能够在不需要重新安装的情况下实现更新。当然,你也可以使用Tinker来更新你的插件。
它主要包括以下几个部分:
tinker-patch-gradle-plugin
tinker-android-lib
tinker-patch-cli.jar
当前市面的热补丁方案有很多,其中比较出名的有阿里的AndFix、美团的Robust以及QZone的超级补丁方案。但它们都存在无法解决的问题,这也是正是我们推出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体积 | 较大 | 较小 | 较小 | 较小 |
成功率 | 较高 | 较高 | 一般 | 最高 |
总的来说:
特别是在Android N之后,由于混合编译的inline策略修改,对于市面上的各种方案都不太容易解决。而Tinker热补丁方案不仅支持类、So以及资源的替换,它还是2.X-8.X(1.9.0以上支持8.X)的全平台支持。利用Tinker我们不仅可以用做bugfix,甚至可以替代功能的发布。Tinker已运行在微信的数亿Android设备上,那么为什么你不使用Tinker呢?
在项目的build.gradle添加tinker-patch-gradle-plugin
的依赖:
classpath "com.tencent.tinker:tinker-patch-gradle-plugin:${TINKER_VERSION}"
在项目目录下新建tinkerconfig.gradle文件,用来存放tinker相关的配置:
apply plugin: 'com.tencent.tinker.patch'
def gitSha() {
return "1.0.0"
}
def bakPath = file("${buildDir}/bakApk/")
def bakPatchName = "App-ali-release-0319-15-36-44"
/**
* you can use assembleRelease to build you base apk
* use tinkerPatchRelease -POLD_APK= -PAPPLY_MAPPING= -PAPPLY_RESOURCE= to build patch
* add apk from the build/bakApk
*/
ext {
//for some reason, you may want to ignore tinkerBuild, such as instant run debug build?
tinkerEnabled = true
//old apk file to build patch apk
tinkerOldApkPath = "${bakPath}/${bakPatchName}.apk"
//proguard mapping file to build patch apk
tinkerApplyMappingPath = "${bakPath}/${bakPatchName}-mapping.txt"
//resource R.txt to build patch apk, must input if there is resource changed
tinkerApplyResourcePath = "${bakPath}/${bakPatchName}-R.txt"
//only use for build all flavor, if not, just ignore this field
// tinkerBuildFlavorDirectory = "${bakPath}/App-ali-release-0315-18-06-53"
}
def getOldApkPath() {
return hasProperty("OLD_APK") ? OLD_APK : ext.tinkerOldApkPath
}
def getApplyMappingPath() {
return hasProperty("APPLY_MAPPING") ? APPLY_MAPPING : ext.tinkerApplyMappingPath
}
def getApplyResourceMappingPath() {
return hasProperty("APPLY_RESOURCE") ? APPLY_RESOURCE : ext.tinkerApplyResourcePath
}
def getTinkerIdValue() {
return hasProperty("TINKER_ID") ? TINKER_ID : gitSha()
}
def buildWithTinker() {
return hasProperty("TINKER_ENABLE") ? Boolean.parseBoolean(TINKER_ENABLE) : ext.tinkerEnabled
}
def getTinkerBuildFlavorDirectory() {
return ext.tinkerBuildFlavorDirectory
}
if (buildWithTinker()) {
apply plugin: 'com.tencent.tinker.patch'
tinkerPatch {
oldApk = getOldApkPath()
ignoreWarning = false
useSign = true
tinkerEnable = buildWithTinker()
buildConfig {
applyMapping = getApplyMappingPath()
applyResourceMapping = getApplyResourceMappingPath()
tinkerId = getTinkerIdValue()
keepDexApply = false
isProtectedApp = false
supportHotplugComponent = true
}
dex {
dexMode = "jar"
pattern = ["classes*.dex",
"assets/secondary-dex-?.jar"]
/**
* necessary,default '[]'
* Warning, it is very very important, loader classes can't change with patch.
* thus, they will be removed from patch dexes.
* you must put the following class into main dex.
* Simply, you should add your own application {@code tinker.sample.android.SampleApplication}
* own tinkerLoader, and the classes you use in them
*
*/
loader = [
//use sample, let BaseBuildInfo unchangeable with tinker
"tinker.sample.android.app.BaseBuildInfo"
]
}
lib {
pattern = ["lib/*/*.so"]
}
res {
pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
/**
* optional,default '[]'
* the resource file exclude patterns, ignore add, delete or modify resource change
* it support * or ? pattern.
* Warning, we can only use for files no relative with resources.arsc
*/
ignoreChange = ["assets/sample_meta.txt"]
/**
* default 100kb
* for modify resource, if it is larger than 'largeModSize'
* we would like to use bsdiff algorithm to reduce patch file size
*/
largeModSize = 100
}
packageConfig {
configField("patchMessage", "tinker is sample to use")
configField("platform", "all")
configField("patchVersion", "1.0")
}
//or you can add config filed outside, or get meta value from old apk
//project.tinkerPatch.packageConfig.configField("test1", project.tinkerPatch.packageConfig.getMetaDataFromOldApk("Test"))
//project.tinkerPatch.packageConfig.configField("test2", "sample")
/**
* if you don't use zipArtifact or path, we just use 7za to try
*/
sevenZip {
/**
* optional,default '7za'
* the 7zip artifact path, it will use the right 7za with your platform
*/
zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
/**
* optional,default '7za'
* you can specify the 7za path yourself, it will overwrite the zipArtifact value
*/
// path = "D:\\soft\\7z1900-x64.exe"
}
}
List 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
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"
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"
}
}
}
}
}
}
task sortPublicTxt() {
doLast {
File originalFile = project.file("public.txt")
File sortedFile = project.file("public_sort.txt")
List sortedLines = new ArrayList<>()
originalFile.eachLine {
sortedLines.add(it)
}
Collections.sort(sortedLines)
sortedFile.delete()
sortedLines.each {
sortedFile.append("${it}\n")
}
}
}
以上是参考tinker官方demo进行修改过的。
修改的地方有:
1) gitSha方法返回值的修改:改成你对应的tinkerId,也就是我们的基线版本的唯一id,一般我们用版本号来确定唯一性,如V1.0.0等等。
2)ext{}中存放的是跟打差分包相关的参数,只有在需要打差分包的时候才需要修改:
// 打开tinker开关
tinkerEnabled = true :
//基线版本的apk包的名称
tinkerOldApkPath = "${bakPath}/app-release-1229-16-38-39.apk"
//生成基线版本的apk包的时候一起生成的mapping文件
tinkerApplyMappingPath = "${bakPath}/app-release-1229-16-38-39-mapping.txt"
//生成基线版本的apk包的时候一起生成的R文件
tinkerApplyResourcePath = "${bakPath}/app-release-1229-16-38-39-R.txt"
这是demo中的内容,为了方便,我将三个文件的名称统一设置了一下:
def bakPath = file("${buildDir}/bakApk/")
def bakPatchName = "App-ali-release-0319-15-36-44"
/**
* you can use assembleRelease to build you base apk
* use tinkerPatchRelease -POLD_APK= -PAPPLY_MAPPING= -PAPPLY_RESOURCE= to build patch
* add apk from the build/bakApk
*/
ext {
//for some reason, you may want to ignore tinkerBuild, such as instant run debug build?
tinkerEnabled = true
//old apk file to build patch apk
tinkerOldApkPath = "${bakPath}/${bakPatchName}.apk"
//proguard mapping file to build patch apk
tinkerApplyMappingPath = "${bakPath}/${bakPatchName}-mapping.txt"
//resource R.txt to build patch apk, must input if there is resource changed
tinkerApplyResourcePath = "${bakPath}/${bakPatchName}-R.txt"
//only use for build all flavor, if not, just ignore this field
// tinkerBuildFlavorDirectory = "${bakPath}/App-ali-release-0315-18-06-53"
}
以上三个文件目录在使用命令:assembleRelease后,都会在本地生成:
每次调用assembleRelease或assembleDebug时,都会在bakApk目录下生成一个新的apk文件,时间可以进行区分。
注意:因为clean的时候清除掉基线APK,所以每次打基本版本的时候,一定记得备份这三个文件!
3)修改:supportHotplugComponent = true
下面是它给的注释:
/** | |
* 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. | |
* | |
* Notice that currently this feature is incubating and only support NON-EXPORTED Activity | |
*/ |
翻译:修补程序是否应该支持组件热插拔(动态添加新组件)。如果该属性为真,则添加到新apk中的组件将在之后可用补丁加载成功。否则,在生成补丁时将宣布错误在编译时。
4)SevenZip报错
如果SevenZip报错,修改:path = "D:\\software\\SevenZip-1.1.10-windows-x86_64.exe"
看来下注释,这行代码会优先:zipArtifact = "com.tencent.mm:SevenZip:1.1.10"配置。
因为在我的电脑报错了:
大概是找不到这个工具,而且还给了个链接,然后点击链接进行下载配置好路径就可以了!没有的话可以留言邮箱。
首先导入我们第二步中新建的文件:
1)apply from: '../tinkerconfig.gradle',必须放在app主工程中,不然报错。
2)添加android参数配置,来个完整的,
def javaVersion = JavaVersion.VERSION_1_7
android {
compileSdkVersion 28
defaultConfig {
applicationId "com.victor.tinkerdemo"
minSdkVersion 19
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
multiDexEnabled true
buildConfigField "String", "MESSAGE", "\"I am the base apk\""
// buildConfigField "String", "MESSAGE", "\"I am the patch apk\""
/**
* client version would update with patch
* so we can get the newly git version easily!
*/
buildConfigField "String", "TINKER_ID", "\"9d1a1432426d7316\""
buildConfigField "String", "PLATFORM", "\"all\""
}
compileOptions {
sourceCompatibility javaVersion
targetCompatibility javaVersion
}
//recommend
dexOptions {
jumboMode = true
}
signingConfigs {
release {
try {
storeFile file("./keystore/release.keystore")
storePassword "testres"
keyAlias "testres"
keyPassword "testres"
} catch (ex) {
}
}
debug {
storeFile file("./keystore/debug.keystore")
}
}
buildTypes {
release {
minifyEnabled true
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android.txt'), project.file('proguard-rules.pro')
}
debug {
debuggable true
minifyEnabled false
signingConfig signingConfigs.debug
}
}
sourceSets {
main {
jniLibs.srcDirs = ['libs']
}
}
}
主要就是配置配置一下tinkerId的参数和打版本时的参数,都好理解。
3)导入tinker依赖包
implementation("com.tencent.tinker:tinker-android-lib:${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 }
我们将用到两组任务:
如果是debug版本,则用assembleDebug和tinkerPatchDebug;如果是release版本则用assembleRelease和tinkerPatchRelease。
当我们使用assembleDebug或assembleRelease命令生成了apk后,会在本地bakApk目录下生成对应的三个文件:
然后将以下三个参数按照名称进行修改:
ext {
//for some reason, you may want to ignore tinkerBuild, such as instant run debug build?
tinkerEnabled = true
//old apk file to build patch apk
tinkerOldApkPath = "${bakPath}/app-release-1229-16-38-39.apk"
//proguard mapping file to build patch apk
tinkerApplyMappingPath = "${bakPath}/app-release-1229-16-38-39-mapping.txt"
//resource R.txt to build patch apk, must input if there is resource changed
tinkerApplyResourcePath = "${bakPath}/app-release-1229-16-38-39-R.txt"
//only use for build all flavor, if not, just ignore this field
// tinkerBuildFlavorDirectory = "${bakPath}/app-release-1229-14-15-29"
}
最后在使用tinkerPatchDebug或assembleRelease命令生成patch包。
最后的patch_signed_7zip.apk就是我们需要的差分包了。
3.1 差分包下发
1)可以使用tinker平台的方式来下发管理
2)从后台获取
不管怎样,都是下载到SD卡或手机,从本地进行加载。
3.2 patch生效
TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), Environment.getExternalStorageDirectory().getAbsolutePath() + "/patch_signed_7zip.apk");
这个方法主要就是把加载路径告诉它即可。
注意:调用完加载方法后,需要重启APP才能生效。
另外在demo中我们还看到它还可以加载library。
4.1 各个module使用的各种库的版本不一致,将导致打包失败,有时候提示还不好找
4.2 只支持到java7,跟要求使用java8的库冲突。其中我遇到的就是butterknife9.0.0要求使用java8,所以结果就是不使用butterknife了。
但是,后来在使用时,新建一个tinker模块,把tinker模块配置使用:
def javaVersion = JavaVersion.VERSION_1_7
并没有影响其他模块使用lamda表达式。
4.3 如果打开了混淆配置,请注意混淆配置
4.控制差分包生效时机
SampleResultService类负责监听热更新是否成功,tinker官方demo是一旦合并成功,就直接杀死进程退出。
if (result.isSuccess) {
TinkerLoadResult tinkerLoadResult = Tinker.with(this).getTinkerLoadResultIfPresent();
Log.e(TAG, "合并成功, current version : " + tinkerLoadResult.currentVersion + ", result version : " + result.patchVersion);
deleteRawPatchFile(new File(result.rawPatchFilePath));
//not like TinkerResultService, I want to restart just when I am at background!
//if you have not install tinker this moment, you can use TinkerApplicationHelper api
if (checkIfNeedKill(result)) {
if (Utils.isBackground()) {
TinkerLog.i(TAG, "it is in background, just restart process");
restartProcess();
} else {
//we can wait process at background, such as onAppBackground
//or we can restart when the screen off
TinkerLog.i(TAG, "tinker wait screen to restart process");
new Utils.ScreenState(getApplicationContext(), new Utils.ScreenState.IOnScreenOff() {
@Override
public void onScreenOff() {
TinkerLog.i(TAG, "screen off, start kill app");
restartProcess();
}
});
}
} else {
TinkerLog.i(TAG, "I have already install the newly patch version!");
}
}
参考链接:
https://github.com/Tencent/tinker/wiki/Tinker-%E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%97
https://github.com/Tencent/tinker/wiki