Android Tinker v1.9.1熱補丁接入教程實戰以及專案注意點
前言:
公司專案開發中可能會經常遇到需求變更,或者剛釋出到線上,第二天結果就出現了嚴重性的bug,導致APP crash,內心是**的,怎麼之前沒測出來??我不管,測試的鍋~ ,測試:開發的鍋,於是展開了一場鍋王爭霸賽~
開個玩笑~
經過debug之後,發現原來是這裡出了問題~ 產品經理走過來問,你解釋道,這個只能重新發包......

em...不慌,上面的這種情況只要用Tinker都能迎刃而解~
Tinker是什麼?
Tinker是微信官方的Android熱補丁解決方案,它支援動態下發程式碼、So庫以及資源,讓應用能夠在不需要重新安裝的情況下實現更新。當然,你也可以使用Tinker來更新你的外掛。

如何接入?
官方建議我們採用 gradle 的形式接入,好處是在gradle外掛tinker-patch-gradle-plugin中官方幫我們完成proguard、multiDex以及Manifest處理等工作。
新增gradle依賴
為了 Tinker版本的可維護性 ,建議把版本號抽離到專案裡的gradle.properties檔案中
TINKER_VERSION=1.9.1
在專案的 ofollow,noindex">build.gradle 中,新增tinker-patch-gradle-plugin的依賴
buildscript { dependencies { classpath "com.tencent.tinker:tinker-patch-gradle-plugin:${TINKER_VERSION}" } }
然後在app的gradle檔案 app/build.gradle ,我們需要新增tinker的庫依賴以及apply tinker的gradle外掛.
//apply tinker外掛 apply plugin: 'com.tencent.tinker.patch' dependencies { //可選,用於生成application類 provided("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") //tinker的核心庫 compile("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") }
然後sync一下...
你以為這樣就完事了?

真正到使用上,還要配置一些Tinker的相關引數
完整的 app/build.gradle 如下
android{ <---省略了一些你自己的專案的配置---> signingConfigs { debug { try { storeFile file("keys/***.jks") keyAlias *** keyPassword *** storePassword *** } catch (ex) { throw new InvalidUserDataException(ex.toString()) } } release { try { storeFile file("keys/***.jks") keyAlias *** keyPassword *** storePassword *** } catch (ex) { throw new InvalidUserDataException(ex.toString()) } } } //recommend dexOptions { jumboMode = true } } dependencies { //可選,用於生成application類 provided("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") //tinker的核心庫 compile("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") } def gitSha() { try { String gitRev = 'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim() if (gitRev == null) { throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'") } return gitRev } catch (Exception e) { throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'") } } 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 //for normal build //old apk file to build patch apk tinkerOldApkPath = "${bakPath}/app-debug-0912-17-33-26.apk" //resource R.txt to build patch apk, must input if there is resource changed tinkerApplyResourcePath = "${bakPath}/app-debug-0912-17-33-26-R.txt" } def getOldApkPath() { return hasProperty("OLD_APK") ? OLD_APK : ext.tinkerOldApkPath } 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 } if (buildWithTinker()) { apply plugin: 'com.tencent.tinker.patch' tinkerPatch { /** * necessary,default 'null' * the old apk path, use to diff with the new apk to build * add apk from the build/bakApk */ oldApk = getOldApkPath() /** * optional,default 'false' * there are some cases we may get some warnings * if ignoreWarning is true, we would just assert the patch process * case 1: minSdkVersion is below 14, but you are using dexMode with raw. *it must be crash when load. * case 2: newly added Android Component in AndroidManifest.xml, *it must be crash when load. * case 3: loader classes in dex.loader{} are not keep in the main dex, *it must be let tinker not work. * case 4: loader classes in dex.loader{} changes, *loader classes is ues to load patch dex. it is useless to change them. *it won't crash, but these changes can't effect. you may ignore it * case 5: resources.arsc has changed, but we don't use applyResourceMapping to build */ ignoreWarning = false /** * optional,default 'true' * whether sign the patch file * if not, you must do yourself. otherwise it can't check success during the patch loading * we will use the sign config with your build type */ useSign = true /** * optional,default 'true' * whether use tinker to build */ tinkerEnable = buildWithTinker() /** * Warning, applyMapping will affect the normal android build! */ buildConfig { /** * optional,default 'null' * It is nice to keep the resource id from R.txt file to reduce java changes */ applyResourceMapping = getApplyResourceMappingPath() /** * necessary,default 'null' * because we don't want to check the base apk with md5 in the runtime(it is slow) * tinkerId is use to identify the unique base apk when the patch is tried to apply. * we can use git rev, svn rev or simply versionCode. * we will gen the tinkerId in your manifest automatic */ tinkerId = getTinkerIdValue() /** * if keepDexApply is true, class in which dex refer to the old apk. * open this can reduce the dex diff file size. */ 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 = true } dex { /** * optional,default 'jar' * only can be 'raw' or 'jar'. for raw, we would keep its original format * for jar, we would repack dexes with zip format. * if you want to support below 14, you must use jar * or you want to save rom or check quicker, you can use raw mode also */ dexMode = "jar" /** * necessary,default '[]' * what dexes in apk are expected to deal with tinkerPatch * it support * or ? pattern. */ 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 { /** * optional,default '[]' * what library in apk are expected to deal with tinkerPatch * it support * or ? pattern. * for library in assets, we would just recover them in the patch directory * you can get them in TinkerLoadResult with Tinker */ pattern = ["lib/*/*.so"] } res { /** * optional,default '[]' * what resource in apk are expected to deal with tinkerPatch * it support * or ? pattern. * you must include all your resources in apk here, * otherwise, they won't repack in the new apk resources. */ 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 } /** * 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 = "/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 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") } } } } } } }
關於相關引數的中文解釋,這裡可以查閱到 Tinker gradle引數詳解
什麼??你還覺得多?拜託,我已經從官方demo裡面剔除到一些不常用的了。
這裡我們需要搞清楚一個概念
基準apk包:原apk包稱為基準apk包,tinkerPatch直接使用基準apk包與新編譯出來的apk包做差異,得到最終的補丁包。
通俗一點講就是,每次你釋出新版本的那個安裝包就是基準包,你當前這個版本如果要生成補丁的話,Tinker是用新編譯出來的apk包和你的基準包做對比,來產生補丁包,所以每次釋出新版本的時候,你都要儲存好你自己的安裝包。
若配置無誤,sync成功後,每次編譯執行,Tinker都會在build/bakApk/目錄下幫我們生成 安裝包 與 R.txt ,其實這個就相當於你的基準包,以及基準包對應的R.txt

所以我們回顧下我們上面的gradle配置,可以看到一處是我們需要手動管理的。
/** * 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 //for normal build //old apk file to build patch apk tinkerOldApkPath = "${bakPath}/app-debug-0912-17-33-26.apk" //resource R.txt to build patch apk, must input if there is resource changed tinkerApplyResourcePath = "${bakPath}/app-debug-0912-17-33-26-R.txt" }
這個 tinkerOldApkPath 以及 tinkerApplyResourcePath 其實就是我們每次要生成某個版本的補丁包的時候,需要手動填入的基準包的路徑以及基準包的R.txt,這樣講應該好理解吧。。。

自定義Application類
好,到了這一步,我們的專案可以跑起來了,但是程式啟動時會載入預設的Application類,這導致我們補丁包是無法對它做修改了。如何規避?在這裡我們並沒有使用類似InstantRun hook Application
的方式,而是通過程式碼框架的方式來避免,這也是為了儘量少的去反射,提升框架的相容性。
這裡我們要實現的是完全將原來的Application類隔離起來,即其他任何類都不能再引用我們自己的Application。我們需要做的其實是以下幾個工作:
- 將我們自己Application類以及它的繼承類的所有程式碼拷貝到自己的ApplicationLike繼承類中,例如SampleApplicationLike。你也可以直接將自己的Application改為繼承ApplicationLike;
- Application的
attachBaseContext
方法實現要單獨移動到onBaseContextAttached
中; - 對ApplicationLike中,引用application的地方改成
getApplication()
; - 對其他引用Application或者它的靜態物件與方法的地方,改成引用ApplicationLike的靜態物件與方法;
更詳細的事例,大家可以參考下面的一些例子以及 SampleApplicationLike 的做法。
如果你不願意改造自己的應用,可以嘗試TinkerPatch的一鍵傻瓜式接入,具體的可參考文件 TinkerPatch 平臺介紹 。
以上是Tinker官方的術語,PS:我當然願意改造了

這裡要詳細講下,不然有人可能會搞混淆,做法如下:
- 新建一個class A extends DefaultApplicationLike;
- 把你原Application裡attachBaseContext的方法移動到新的A class裡的onBaseContextAttached 中
- 在你的A class中,引用application的地方改成getApplication()
- 外部引用Application它的靜態物件與方法的地方,改成引用ApplicationLike的靜態物件與方法;
- 刪除你自己的Application,然後再A class裡用Tinker的註解生成自己的Application;
- AndroidManifest.xml裡面宣告Applicaiton路徑千萬不要填錯了, 不是A classs!!! 這裡是 註解生成Application的路徑 ,第一次報紅沒關係,編譯過後就好了。
import android.annotation.TargetApi; import android.app.Application; import android.content.Context; import android.content.Intent; import android.os.Build; import android.support.multidex.MultiDex; import com.tencent.tinker.anno.DefaultLifeCycle; import com.tencent.tinker.lib.tinker.Tinker; import com.tencent.tinker.lib.tinker.TinkerInstaller; import com.tencent.tinker.loader.app.DefaultApplicationLike; import com.tencent.tinker.loader.shareutil.ShareConstants; /** * because you can not use any other class in your application, we need to * move your implement of Application to {@link com.tencent.tinker.loader.app.ApplicationLifeCycle} * As Application, all its direct reference class should be in the main dex. * <p> * We use tinker-android-anno to make sure all your classes can be patched. * <p> * application: if it is start with '.', we will add SampleApplicationLifeCycle's package name * <p> * flags: * TINKER_ENABLE_ALL: support dex, lib and resource * TINKER_DEX_MASK: just support dex * TINKER_NATIVE_LIBRARY_MASK: just support lib * TINKER_RESOURCE_MASK: just support resource * <p> * loaderClass: define the tinker loader class, we can just use the default TinkerLoader * <p> * loadVerifyFlag: whether check files' md5 on the load time, defualt it is false. */ @SuppressWarnings("unused") @DefaultLifeCycle( application = ".MyApplication",//application類名 loaderClass = "com.tencent.tinker.loader.TinkerLoader",//loaderClassName, 我們這裡使用預設即可! flags = ShareConstants.TINKER_ENABLE_ALL, loadVerifyFlag = false) public class MyApplicationLike extends DefaultApplicationLike { public static Application application; public MyApplicationLike(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag, long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) { super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent); } /** * install multiDex before install tinker * so we don't need to put the tinker lib classes in the main dex * * @param base */ @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); MyApplicationLike.application = getApplication(); TinkerInstaller.install(this); } @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) public void registerActivityLifecycleCallbacks(Application.ActivityLifecycleCallbacks callback) { getApplication().registerActivityLifecycleCallbacks(callback); } }
到此為止,Tinker初步的接入已真正的完成,你已經可以愉快的使用Tinker來實現補丁功能了。
使用示例
先看下Tinker安裝補丁的語法
TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), yourFilePath);
注意:因為補丁是需要從伺服器上下載到本地,所以這裡涉及到SD檔案的讀取,所以請自行處理APP許可權的事情。
假設我們現在走一個正常的開發流程,這個是我的APP,長這樣。

上面我們提到過,每次編譯執行,Tinker都會幫我們生成基準包和基準包對應的R.txt;所以看一下我們build目錄

image.png
這個apk就是我當前手機安裝的apk,也就是基準包。
我現在演示如何通過打補丁的形式;
新增檔案中....修改程式碼中.... 這裡我新增了一個Activity,然後把上圖Button click事件改成跳轉到新的頁面;
如何生成補丁?
找到我們的gradle配置,把我們上面的基準包及基準包R.txt填寫進去。
ext { //for some reason, you may want to ignore tinkerBuild, such as instant run debug build? tinkerEnabled = true //for normal build //old apk file to build patch apk tinkerOldApkPath = "${bakPath}/app-debug-0913-15-49-36.apk" //resource R.txt to build patch apk, must input if there is resource changed tinkerApplyResourcePath = "${bakPath}/app-debug-0913-15-49-36-R.txt" }
然後sync一下後,點選Gradle視窗

image.png
我這裡使用測試環境打包,正式的話就選擇Release,然後Tinker就會讀取你在app/build.gradle中signingConfigs中配置key資訊。
等待片刻,檢視Run視窗log

wow~ successful~

補丁檔案在該目錄

在tinkerPatch輸出目錄build/outputs/tinkerPatch中,我們關心的檔案有:
patch_unsigned.apk 沒有簽名的補丁包
patch_signed.apk 簽名後的補丁包
patch_signed_7zip.apk 簽名後並使用7zip壓縮的補丁包,也是我們通常使用的補丁包。但正式釋出的時候,最好不要以.apk結尾,防止被運營商挾持。
輸出檔案更多解析請參閱:
輸出檔案詳解這時候把patch_signed_7zip.apk 複製到記憶體卡,模擬使用者在伺服器上下載了補丁。
開始安裝補丁~

點選B menu進行安裝
經過一段時間等待,檢視log

現在重啟APP,見證奇蹟~

至此整個流程都介紹的查不多了,更多資料請查閱wiki
Tinker Wiki關於合成結果回撥請查閱:
自定義AbstractResultService類最後
