本文使用的是tinker的1.9.6版本,使用gradle方式接入。具體的接入方式可參考官方接入指南。
需要特別注意:
1. Tinker不支持修改AndroidManifest.xml,Tinker不支持新增四大組件(1.9.0開始支持新增非export的Activity);
2. 由於Google Play的開發者條款限制,不建議在GP渠道動態更新代碼(實際上大概率會過不了審);
3. 在Android N上,補丁對應用啓動時間有輕微的影響;
4. 不支持部分三星android-21機型,加載補丁時會主動拋出”TinkerRuntimeException:checkDexInstall failed”;
5. 對於資源替換,不支持修改remoteView,例如transition動畫,notification icon以及桌面圖標;
6. 目前tinker熱更新so包的話,只支持armeabi目錄下的庫文件。
引入tinker
在項目根build.gradle中:
buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.3.1'
// 引入tinker插件
classpath "com.tencent.tinker:tinker-patch-gradle-plugin:1.9.6"
}
}
在項目app的build.gradle裏配置基本引入:
// 用git號做TINKER_ID
def getTinkerIdValue() {
return hasProperty("TINKER_ID") ? TINKER_ID : gitSha()
}
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'")
}
}
android {
...
defaultConfig {
...
// 配置TINKER_ID
buildConfigField "String", "TINKER_ID", "\"${getTinkerIdValue()}\""
// 必須開啓multiDex
multiDexEnabled true
}
// tinker建議開啓此項,否則若Application所直接使用的類沒有被打在main dex中,就會導致patch失敗
dexOptions {
// 忽略方法數限制的檢查
jumboMode = true
}
sourceSets {
main {
...
// 若要熱修復so包,需要指明so包路徑
jniLibs.srcDirs = ['libs']
}
}
}
dependencies {
compile fileTree(include: ['*.*'], dir: 'libs')
...
// 若使用>=3的gradle,tinker_version=1.9.6
implementation "com.android.support:multidex:1.0.1"
implementation("com.tencent.tinker:tinker-android-lib:${rootProject.ext.tinker_version}") {
changing = true
}
// 用於編譯時生成application類
annotationProcessor("com.tencent.tinker:tinker-android-anno:${rootProject.ext.tinker_version}") {
changing = true
}
compileOnly("com.tencent.tinker:tinker-android-anno:${rootProject.ext.tinker_version}") {
changing = true
}
// 若使用<3的gradle
//compile "com.android.support:multidex:1.0.1"
//compile("com.tencent.tinker:tinker-android-lib:${rootProject.ext.tinker_version}") {
// changing = true
//}
//provided("com.tencent.tinker:tinker-android-anno:${rootProject.ext.tinker_version}") {
// changing = true
//}
}
// 其他簽名啊,混淆啊啥的都正常配置就行
代碼引入方面,此處基本可以照搬示例代碼:
在Application接入時,Tinker藉助註解來生成Application:
/**
* 用於生成實際Application的註解類,這樣做是因爲,所有與Application直接使用的類都需要被打在主dex中。
* 同時,儘量避免在Application中做多餘的工作。
*/
@SuppressWarnings("unused")
@DefaultLifeCycle(
// 定義生成的Application類,與AndroidManifest中的application節點name屬性一致,爲避免錯誤,必須是全名
application = "cn.com.bluemoon.delivery.AppContext",
// TINKER_ENABLE_ALL:支持dex、lib(so包)、資源的更新
flags = ShareConstants.TINKER_ENABLE_ALL,
// 在加載時是否校驗dex、lib與res的md5,默認false
loadVerifyFlag = false
// loaderClass: 定義tinker的類加載器,默認爲TinkerLoader)
public class HFApplicationLike extends DefaultApplicationLike {
...
public HFApplicationLike(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag,
long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) {
super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);
}
/**
* 在安裝tinker前安裝multiDex,以避免將tinker放進主dex
*/
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
@Override
public void onBaseContextAttached(Context base) {
super.onBaseContextAttached(base);
// 必須先執行MultiDex的install
MultiDex.install(base);
// 賦值全局使用的application單例以及其context,其實就是生成的application的實例
AppContextHolder.application = getApplication();
AppContextHolder.context = getApplication();
TinkerManager.setTinkerApplicationLike(this);
// 沒設置uncaughtExceptionHandler,就用默認的SampleUncaughtExceptionHandler
TinkerManager.initFastCrashProtect();
// 必須在tinker安裝前設置
TinkerManager.setUpgradeRetryEnable(true);
// 設置log的實現,可以使用默認實現
TinkerInstaller.setLogIml(new MyLogImp());
// 在加載multiDex後執行,否則就需要將com.tencent.tinker.**手動放到主dex,麻煩
// 配置tinker其他項
TinkerManager.installTinker(this);
Tinker tinker = Tinker.with(getApplication());
}
/**
* 可重寫onCreate() 、onLowMemory()、onTrimMemory(int level)、onTerminate()、onConfigurationChanged,對應的調用時機與實際Application中的一致
*/
@Override
public void onCreate() {
super.onCreate();
}
其中TinkerManager是協助Tinker初始化的輔助類,具體可見sample代碼,此處只關注:
public class TinkerManager {
...
public static void installTinker(ApplicationLike appLike) {
if (isInstalled) {
TinkerLog.w(TAG, "install tinker, but has installed, ignore");
return;
}
// Tinker在加載補丁時的一些回調,默認實現爲DefaultLoadReporter
LoadReporter loadReporter = new SampleLoadReporter(appLike.getApplication());
// Tinker在修復或者升級補丁時的一些回調,默認實現爲DefaultPatchReporter
PatchReporter patchReporter = new SamplePatchReporter(appLike.getApplication());
// 用來過濾Tinker收到的補丁包的修復、升級請求,也就是決定是不是真的要喚起:patch進程去嘗試補丁合成,默認實現爲DefaultPatchListener
PatchListener patchListener = new SamplePatchListener(appLike.getApplication());
// 用來升級當前補丁包的處理類,一般來說不需要複寫
AbstractPatch upgradePatchProcessor = new UpgradePatch();
TinkerInstaller.install(appLike,
loadReporter, patchReporter, patchListener,
SampleResultService.class, upgradePatchProcessor);
isInstalled = true;
}
}
詳細的配置說明可見Tinker自定義擴展。
最後,需要在AndroidManifest中做做些微修改:
<!-- 權限添加 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- name的值與ApplicationLike的application值一致 -->
<application
android:name=".AppContext"
...>
<!-- :patch補丁合成進程將合成結果返回給主進程的類,默認實現爲DefaultTinkerResultService -->
<service
android:name=".tinker.service.SampleResultService"
android:exported="false"/>
</application>
配置patch
patch的配置在app的build.gradle中定義,詳細的配置可見官方指南
// 生成備份的apk、混淆mapping文件、資源R文件的目錄,其實就是將每次build出來的相應文件以別名備份
def bakPath = file("${buildDir}/bakApk/")
// bakPath裏的備份包名稱,用於與新包作比較得出補丁包
def curApkName = "月亮天使_20180514_09_測試版_4.9.4(1726)_0514-09-20-59"
/**
* 手動的話,可以先使用assembleRelease編譯出基礎包,
* 再使用"tinkerPatchRelease -POLD_APK=... -PAPPLY_MAPPING=... -PAPPLY_RESOURCE=..."命令生成補丁包
*/
ext {
// 是否執行tinker。開發期間,可以將這個關了來使用instant run。btw,目前instant run與tinker是互斥的。
tinkerEnabled = true
// 舊包(用於打補丁的基礎舊包)apk名
tinkerOldApkPath = "${bakPath}/${curApkName}.apk"
// 舊包mapping文件
tinkerApplyMappingPath = "${bakPath}/${curApkName}-mapping.txt"
// 舊包R文件
tinkerApplyResourcePath = "${bakPath}/${curApkName}-R.txt"
//only use for build all flavor, if not, just ignore this field,暫未用過
tinkerBuildFlavorDirectory = "${bakPath}/app-1018-17-32-47"
}
////////// 以下都是一些輔助方法(start) /////////////
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
}
// 用git號做版本號
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 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
}
////////// 以上都是一些輔助方法(end) /////////////
if (buildWithTinker()) {
// 引入tinker的patch插件
apply plugin: 'com.tencent.tinker.patch'
// tinkerPatch參數配置
tinkerPatch {
/**
* 必填,默認爲null
* 舊包路徑,用作與新包比對後得出差分包
* 一般是build/bakApk下
*/
oldApk = getOldApkPath()
/**
* 選填,默認爲false
* 以下情況會出現warnings,若設置ignoreWarning爲true,只會assert加載補丁進程:
* 1、minSdkVersion < 14, 但設置了dexMode爲raw,此騷操作在加載補丁時必崩;
* 2、在AndroidManifest.xml中添加新的四大組件(1.9.0版本以上非export的Activity除外),同上,必崩;
* 3、在以下dex.loader中的用於加載補丁的類沒有在主dex中,這樣的話,tinker不會起作用;
* 4、dex.loader中的用於加載補丁的類修改,加載器類是用來加載補丁的,在新包修改的話也不會起作用。此操作不會引發崩潰,但也不會起效,可以忽略;
* 5、resources.arsc變更了,但沒有設置applyResourceMapping(applyResourceMapping=null)用於編譯
*/
ignoreWarning = false
/**
* 選填,默認爲true
* 是否需要對補丁簽名
* false時需要手動簽名,否則在加載補丁時無法通過檢查
* 這裏使用的是android.buildTypes.xx.signingConfig的配置
*/
useSign = true
/**
* 選填,默認爲true
* 此處同ext.tinkerEnabled
*/
tinkerEnable = buildWithTinker()
/**
* applyMapping會影響正常的編譯
*/
buildConfig {
/**
* 選填,默認爲null
* 若使用tinkerPatch命令去打補丁包, 並且開啓了minifyEnabled混淆,建議用舊包的mapping文件。
* 在編譯新的apk時候,通過保持舊apk的proguard混淆方式,從而減少補丁包的大小。
* 警告:
* 此操作會影響正常的編譯過程
*/
applyMapping = getApplyMappingPath()
/**
* 選填,默認爲null
* 使用舊apk的R.txt文件保持ResId的分配,這樣不僅可以減少補丁包的大小,同時也避免由於ResId改變導致remote view異常。
*/
applyResourceMapping = getApplyResourceMappingPath()
/**
* 必填,默認爲null
* 由於不希望在運行時檢測基礎apk(舊包)的md5(慢),
* 這裏在打補丁包的時候,使用了tinkerId來標識基礎apk的版本。
* 此處使用的是gitSha(),同時,thinkerId會自動的被寫到AndroidManifest中
*/
tinkerId = getTinkerIdValue()
/**
* 若爲true,多dex時會按照基準包的類分佈來編譯,可以減少dex差分包大小。低版本的tinker此選項開啓後有bug。
*/
keepDexApply = false
/**
* 選填,默認爲false
* 是否使用加固模式,僅僅將變更的類合成補丁。注意,這種模式僅僅可以用於加固應用中。
*/
isProtectedApp = false
/**
* 選填,默認爲false
* 是否支持新增非export的Activity,只有在再次啓動加載補丁後,此Activity才起作用
*/
supportHotplugComponent = true
}
// dex相關的配置項
dex {
/**
* 選填,默認爲jar
* 取值爲raw或jar,
* raw模式會保持輸入dex的格式,
* jar模式,會把輸入dex重新壓縮封裝到jar,如果minSdkVersion<14,必須選擇jar模式,而且它更省存儲空間,但是驗證md5時比raw模式耗時。默認並不會去校驗md5,一般情況下選擇jar模式即可。
dexMode = "jar"
/**
* 必填,默認爲[]
* 需要處理dex路徑,支持*、?通配符,必須使用'/'分割。
* 路徑是相對安裝包的,例如assets/...
*/
pattern = ["classes*.dex",
"assets/secondary-dex-?.jar"]
/**
* 必填,默認爲[]
* 此項非常重要,它定義了哪些類在加載補丁包的時候會用到。這些類是通過tinker無法修改的類,也是一定要放在main dex的類。
* 必須把以下的類放進這裏:
* 1、自定義的Application類(爲避免版本問題,此處最好把ApplicationLike生成的Application也加進來);
* 2、Tinker庫中用於加載補丁包的部分類,即com.tencent.tinker.loader.*;
* 3、若自定義了TinkerLoader,需要將它以及它引用的所有類也加入loader中;
* 4、其他一些不希望被更改的類,例如Sample中的BaseBuildInfo類。這裏需要注意的是,這些類的直接引用類也需要加入到loader中,或者需要將這個類變成非preverify。
* 5、使用1.7.6版本之後的gradle版本,參數1、2會自動填寫。若使用newApk或者命令行版本編譯,1、2依然需要手動填寫
*/
loader = [
"cn.com.bluemoon.delivery.AppContext",
//use sample, let BaseBuildInfo unchangeable with tinker
"cn.com.bluemoon.delivery.tinker.app.BaseBuildInfo"
]
}
// lib相關的配置項
lib {
/**
* 選填,默認爲[]
* 需要處理的lib路徑,支持*、?通配符,必須使用'/'分割。與dex.pattern一致, 路徑是相對安裝包的。
* 對於在assets中的lib,tinker只在補丁包中回覆它,可以在TinkerLoadResult中拿到
*/
pattern = ["lib/*/*.so"]
}
// res相關的配置項
res {
/**
* 選填,默認爲[]
* 需要處理的res路徑,支持*、?通配符,必須使用'/'分割。與dex.pattern一致, 路徑是相對安裝包的。
* 所有的資源文件都必須包含進來,否則否則不會再新包中被重新打包
*/
pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
/**
* 選填,默認爲[]
* 支持*、?通配符,必須使用'/'分割。若滿足ignoreChange的pattern,在編譯時會忽略該文件的新增、刪除與修改。
* 只能用於不與resources.arsc相關聯的文件
*/
ignoreChange = ["assets/sample_meta.txt"]
/**
* 默認100kb
* 對於修改的資源,如果大於largeModSize,將使用bsdiff算法。這可以降低補丁包的大小,但是會增加合成時的複雜度。
*/
largeModSize = 100
}
// 用於生成補丁包中的'package_meta.txt'文件
packageConfig {
/**
* 選填
*
* we will get the TINKER_ID from the old apk manifest for you automatic,
* other config files (such as patchMessage below)is not necessary
* 默認自動從基準安裝包與新安裝包的Manifest中讀取tinkerId,並自動寫入configField。在這裏,可以定義其他的信息,在運行時可以通過自定義的ownPackageCheck方法裏的securityCheck.getPackageProperties()或TinkerLoadResult.getPackageConfigByName得到相應的數值。但是建議直接通過修改代碼來實現,例如BuildConfig。
* 以下都是例子
*/
configField("patchMessage", "tinker is sample to use")
configField("platform", "all")
configField("patchVersion", "1.0")
}
/**
* 7zip路徑配置項,執行前提是useSign爲true
* 若不使用zipArtifact或者path, 會自動試用7za。
*/
sevenZip {
/**
* 選填,默認'7za'
* 例如"com.tencent.mm:SevenZip:1.1.10",將自動根據機器屬性獲得對應的7za運行文件,推薦使用。
*/
zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
/**
* 選填,默認'7za'
* 系統中的7za路徑,例如"/usr/local/bin/7za"。path設置會覆蓋zipArtifact,若都不設置,將直接使用7za去嘗試。
*/
// path = "/usr/local/bin/7za"
}
}
}
// 多flavors
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")
/**
* 備份任務
*/
android.applicationVariants.all { variant ->
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}"
// 將生成的apk包備份到bak文件夾下,以newFileNamePrefix.apk的名字
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")
}
// 同理,備份mapping.txt
from "${buildDir}/outputs/mapping/${variant.dirName}/mapping.txt"
into destPath
rename { String fileName ->
fileName.replace("mapping.txt", "${newFileNamePrefix}-mapping.txt")
}
// 同理,備份R.txt
from "${buildDir}/intermediates/symbols/${variant.dirName}/R.txt"
into destPath
rename { String fileName ->
fileName.replace("R.txt", "${newFileNamePrefix}-R.txt")
}
}
}
}
}
}
// 以下爲多flavors打包用到的,本文未涉及
project.afterEvaluate {
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"
}
}
}
}
}
加上使用補丁包代碼
使用的時候,通常用的是生成的7zip包,在得到補丁包後,調用:
TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(),
// 補丁包絕對路徑
Environment.getExternalStorageDirectory().getAbsolutePath() + "/Download/patch_signed_7zip.apk");
若要手動清除補丁,可調用:
// 清除補丁
Tinker.with(getApplicationContext()).cleanPatch();
打出補丁包
- 接入、配置好tinker後,使用 assemblexxx(如assembleDeug)編譯出基礎包(舊包);
- 修改代碼,並將app的build.gradle中的curApkName改爲bak文件夾裏的備份基礎包名(不含後綴);
- 使用tinkerPatchxxx(如tinkerPathDebug)命令,即可得出補丁文件:
生成文件中主要關注的是:
文件名 | 描述 |
---|---|
patch_unsigned.apk | 沒有簽名的補丁包 |
patch_signed.apk | 簽名後的補丁包 |
patch_signed_7zip.apk | 簽名後並使用7zip壓縮的補丁包,也是我們通常使用的補丁包。但正式發佈的時候,最好不要以.apk結尾,防止被運營商挾持 |
log.txt | 在編譯補丁包過程的控制檯日誌 |
dex_log.txt | 在編譯補丁包過程關於dex的日誌 |
so_log.txt | 在編譯補丁包過程關於lib的日誌 |
tinker_result | 最終在補丁包的內容,包括diff的dex、lib以及assets下面的meta文件 |
resources_out.zip | 最終在手機上合成的全量資源apk,你可以在這裏查看是否有文件遺漏 |
tempPatchedDexes | 在Dalvik與Art平臺,最終在手機上合成的完整Dex,我們可以在這裏查看dex合成的產物 |
檢驗成果
將補丁文件保存到設備,如上上節中的Environment.getExternalStorageDirectory().getAbsolutePath() + “/Download/patch_signed_7zip.apk”,調用TinkerInstaller.onReceiveUpgradePatch即可加載補丁,加載完成的回調中一般需要將補丁包刪除。等待patch進程完成後,重新啓動應用(必須完全kill掉進程重新啓動),即可完成補丁加載。
參考與深度閱讀
你必須知道的APT、annotationProcessor、android-apt、Provided、自定義註解
類加載機制系列2——深入理解Android中的類加載器
Android熱修復技術選型——三大流派解析
Tinker 接入指南
Android 熱修復 Tinker接入及源碼淺析