前言
Tinker是什麼
Tinker是微信官方的Android熱補丁解決方案,它支持動態下發代碼、So庫以及資源,讓應用能夠在不需要重新安裝的情況下實現更新。當然,你也可以使用Tinker來更新你的插件。
它主要包括以下幾個部分:
- gradle編譯插件:
tinker-patch-gradle-plugin
- 核心sdk庫:
tinker-android-lib
- 非gradle編譯用戶的命令行版本:
tinker-patch-cli.jar
爲什麼使用Tinker
當前市面的熱補丁方案有很多,其中比較出名的有阿里的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體積 | 較大 | 較小 | 較小 | 較小 |
成功率 | 較高 | 較高 | 一般 | 最高 |
總的來說:
- AndFix作爲native解決方案,首先面臨的是穩定性與兼容性問題,更重要的是它無法實現類替換,它是需要大量額外的開發成本的;
- Robust兼容性與成功率較高,但是它與AndFix一樣,無法新增變量與類只能用做的bugFix方案;
- Qzone方案可以做到發佈產品功能,但是它主要問題是插樁帶來Dalvik的性能問題,以及爲了解決Art下內存地址問題而導致補丁包急速增大的。
特別是在Android N之後,由於混合編譯的inline策略修改,對於市面上的各種方案都不太容易解決。而Tinker熱補丁方案不僅支持類、So以及資源的替換,它還是2.X-8.X(1.9.0以上支持8.X)的全平臺支持。利用Tinker我們不僅可以用做bugfix,甚至可以替代功能的發佈。Tinker已運行在微信的數億Android設備上,那麼爲什麼你不使用Tinker呢?
1.添加gradle依賴
1.1 配置項目build.gradle
在項目的build.gradle添加tinker-patch-gradle-plugin
的依賴:
classpath "com.tencent.tinker:tinker-patch-gradle-plugin:${TINKER_VERSION}"
1.2 tinker相關配置
在項目目錄下新建tinkerconfig.gradle文件,用來存放tinker相關的配置:
apply plugin: 'com.tencent.tinker.patch'
def gitSha() {
return "XXXXXXXXXXXX"
}
def bakPath = file("${buildDir}/bakApk/")
/**
* 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}/app-release-1229-16-14-42.apk"
//proguard mapping file to build patch apk
tinkerApplyMappingPath = "${bakPath}/app-release-1229-16-14-42-mapping.txt"
//resource R.txt to build patch apk, must input if there is resource changed
tinkerApplyResourcePath = "${bakPath}/app-release-1229-16-14-42-R.txt"
//only use for build all flavor, if not, just ignore this field
// tinkerBuildFlavorDirectory = "${bakPath}/app-release-1229-14-15-29"
}
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")
/**
* patch version via packageConfig
*/
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:\\software\\SevenZip-1.1.10-windows-x86_64.exe"
}
}
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
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<String> sortedLines = new ArrayList<>()
originalFile.eachLine {
sortedLines.add(it)
}
Collections.sort(sortedLines)
sortedFile.delete()
sortedLines.each {
sortedFile.append("${it}\n")
}
}
}
以上是參考tinker官方demo進行修改過的。
修改的地方有:
1) gitSha方法返回值的修改:改成你對應的tinkerId,可以去tinker平臺生成一個。
鏈接:http://www.tinkerpatch.com/Apps/detail/id/9721
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"
以上三個文件目錄在使用命令: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. | |
* | |
* <b>Notice that currently this feature is incubating and only support NON-EXPORTED Activity</b> | |
*/ |
翻譯:修補程序是否應該支持組件熱插拔(動態添加新組件)。如果該屬性爲真,則添加到新apk中的組件將在之後可用補丁加載成功。否則,在生成補丁時將宣佈錯誤在編譯時。
4)修改:path = "D:\\software\\SevenZip-1.1.10-windows-x86_64.exe"
看來下注釋,這行代碼會優先:zipArtifact = "com.tencent.mm:SevenZip:1.1.10"配置。
因爲在我的電腦報錯了:
大概是找不到這個工具,而且還給了個鏈接,然後點擊鏈接進行下載配置好路徑就可以了!沒有的話可以留言郵箱。
1.3 app/build.gradle配置
首先導入我們第二步中新建的文件:
1)apply from: '../tinkerconfig.gradle'
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 }
2.編譯和產生patch包
我們將用到兩組任務:
如果是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.使差分包生效
3.1 差分包下發
1)可以使用tinker平臺的方式來下發管理
2)從後臺獲取
不管怎樣,都是下載到SD卡或手機,從本地進行加載。
3.2 patch生效
TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), Environment.getExternalStorageDirectory().getAbsolutePath() + "/patch_signed_7zip.apk");
這個方法主要就是把加載路徑告訴它即可。
注意:調用完加載方法後,需要重啓APP才能生效。
另外在demo中我們還看到它還可以加載library。
但是我有發現在部分手機上需要點擊多次load才能加載成功。
參考鏈接:
https://github.com/Tencent/tinker/wiki/Tinker-%E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%97