Tinker介紹
Tinker是微信團隊開源的Android熱修復工具,支持dex, library和resources的熱更新。關于Tinker的基本的接入方法、Api和原理等,在官方wiki中有非常詳細的介紹。我這里重點描述一下基于我們項目的接入流程(客戶端和后臺),使用姿勢和遇到的問題,以及如何在Jenkins上構建補丁包。
Tinker接入
Tinker是目前熱修復方案中穩定性和兼容性最好的,畢竟源于微信團隊嘛!但也正是為了提高穩定性和兼容性,Tinker在接入成本上做了妥協,它不像以往的Andfix那樣可以一鍵接入,必須改造自己的Application,詳細可參考自定義Application類。其實改造的過程也并不復雜,只是多了一點學習成本。
但使用對Tinker進行再次封裝的第三方平臺的SDK還是可以實現一鍵接入的,比如TinkerPatch平臺 和Bugly熱更新功能,但這種方式對Application進行了反射,是有風險的:
TinkerPatch 平臺通過自動反射 Application,可以實現無縫接入。事實上,對于反射失敗的情況,我們會自動回退到代理 Application 生命周期模式,防止因為反射失敗而造成應用無法啟動的問題。
通過線上統計,大約有 1/1W的反射失敗率。我們更加推薦大家使用 Tinker 的方式改造自身的 Application, 使兼容性高。
而且我們需要自己搭建后臺來管理補丁包,所以不會使用第三方SDK,而是自己封裝了一套SDK,其實就是將Tinker的調用API和與后臺接口的通信功能進行了整合而已,封裝方式和后臺搭建也是基于github上的一個開源項目的:https://github.com/baidao/tinker-manager
我們的客戶端SDK已經放在了公司內部的Maven倉庫中:compile 'com.****.tinkerutils:utils:${version}'
我們已經搭建好的補丁管理平臺測試地址是:http://172.22.34.201/hotfix-console/
開始接入
gradle是Tinker推薦的接入方式,如果要使用命令行接入請參考這里。
第一步,引入Tinker插件和依賴
添加tinker-gradle-plugin到工程根目錄下的build.gradle
的dependencies中:
buildscript {
dependencies {
classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.7.7')
}
}
然后在你的主module中的build.gradle
文件里apply插件:
apply plugin: 'com.tencent.tinker.patch'
注意:這里的主module是指應用的啟動Application所在的module,即含有apply plugin: 'com.android.application'
這句話的module,對應我們項目就是MyMoney
,否則Tinker會拋出Exception
然后加入Tinker的lib依賴:
dependencies {
//optional, help to generate the final application
provided('com.tencent.tinker:tinker-android-anno:1.7.7')
//tinker's main Android lib
compile('com.tencent.tinker:tinker-android-lib:1.7.7')
}
但因為我們使用自己的SDK,SDK中已經有了Tinker lib的依賴,所以,我們加入SDK的依賴即可:
dependencies {
//optional, help to generate the final application
provided('com.tencent.tinker:tinker-android-anno:1.7.7')
//our tinker SDK
compile 'com.****.tinkerutils:utils:${version}'
}
第二步,改造項目原有的Application
將我們現有的AppApplication直接繼承Tinker提供的DefaultApplicationLike類,參考自定義Application類,這樣我們的AppApplication就成了真實的Application(RealApplication,自定義或者通過注解自動生成)的代理類,這樣做就是為了將RealApplication隔離起來,防止誤修改,如此一來,在RealApplication中所做的所有初始化工作也就相當于轉移到了代理類中,間接實現了Application可修改進行熱修復的目的。
比如我們原來的AppApplication如下:
public class AppApplication extends Application {
private static final String TAG = "AppApplication";
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
try {
MultiDex.install(base);
context = this;
}catch (Exception e){
DebugUtil.exception(TAG,e);
}
}
@Override
public void onCreate() {
super.onCreate();
// 全局初始化代碼
... ...
}
// 復寫了Application的方法
@Override
public Resources getResources() {
Resources res = super.getResources();
if(res.getConfiguration().fontScale != 1){
Configuration newConfig = res.getConfiguration();
newConfig.fontScale = 1;
res.updateConfiguration(newConfig, res.getDisplayMetrics());
}
return res;
}
@Override
public void startActivities(Intent[] intents) {
// do some option
... ...
super.startActivities(intents);
}
}
那我們改造后應該是這樣:
@DefaultLifeCycle(application = "${yourpackage}.RealApplication",
flags = ShareConstants.TINKER_ENABLE_ALL)
public class AppApplication extends DefaultApplicationLike {
private static final String TAG = "AppApplication";
public AppApplication(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag, long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) {
super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);
}
@Override
public void onBaseContextAttached(Context base) {
super.onBaseContextAttached(base);
try {
//you must install multiDex whatever tinker is installed!
MultiDex.install(base);
//此處通過getApplication()拿到的其實就是RealApplication
context = getApplication();
//install tinker
TinkerUtils.installTinker(getApplication(), this);
}catch (Exception e){
DebugUtil.exception(TAG,e);
}
}
@Override
public void onCreate() {
super.onCreate();
// 設置tinker參數并向后臺請求補丁包
TinkerUtils.setUpTinker(context);
// 全局初始化代碼
... ...
}
@Override
public Resources getResources(Resources res) {
if(res.getConfiguration().fontScale != 1){
Configuration newConfig = res.getConfiguration();
newConfig.fontScale = 1;
res.updateConfiguration(newConfig, res.getDisplayMetrics());
}
return res;
}
public Resources getResources() {
return getApplication().getResources();
}
}
上面是通過注解的方式來自動生成RealApplication,如果使用自定義的方式,則直接新建RealApplication類繼承TinkerApplication并創建對應構造方法即可,不用注解,也不用引入注解依賴。
public class RealApplication extends TinkerApplication {
public RealApplication() {
super(
//tinkerFlags, tinker支持的類型,dex,library,還是全部都支持!
ShareConstants.TINKER_ENABLE_ALL,
//ApplicationLike的實現類,只能傳遞字符串
"tinker.sample.android.app.SampleApplicationLike",
//Tinker的加載器,一般來說用默認的即可
"com.tencent.tinker.loader.TinkerLoader",
//tinkerLoadVerifyFlag, 運行加載時是否校驗dex,lib與res的Md5
false);
}
}
官方提示:除了構造方法之外,你最好不要引入其他的類,這將導致它們無法通過補丁修改。
注意:改造完成后要用RealApplication替換掉AndroidManifest.xml中原來的AppApplication:
<application
android:name=".RealApplication"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme">
... ...
另外因為我們的SDK中有自定義AbstractResultService類,即TinkerResultService,所以也需要在清單文件中加上它,否則補丁合成會出問題
<service
android:name="com.feidee.tinkerutils.TinkerResultService"
android:exported="false"/>
參考TinkerApplication源碼可以知道為什么如此修改Application:
... ...
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
Thread.setDefaultUncaughtExceptionHandler(new TinkerUncaughtHandler(this));
onBaseContextAttached(base);
}
private void onBaseContextAttached(Context base) {
applicationStartElapsedTime = SystemClock.elapsedRealtime();
applicationStartMillisTime = System.currentTimeMillis();
loadTinker();
ensureDelegate();
//此處的applicationLike現在就是我們的AppApplication類
applicationLike.onBaseContextAttached(base);
//reset save mode
if (useSafeMode) {
String processName = ShareTinkerInternals.getProcessName(this);
String preferName = ShareConstants.TINKER_OWN_PREFERENCE_CONFIG + processName;
SharedPreferences sp = getSharedPreferences(preferName, Context.MODE_PRIVATE);
sp.edit().putInt(ShareConstants.TINKER_SAFE_MODE_COUNT, 0).commit();
}
}
... ...
@Override
public void onCreate() {
super.onCreate();
ensureDelegate();
//此處的applicationLike現在就是我們的AppApplication類
applicationLike.onCreate();
}
... ...
@Override
public Resources getResources() {
Resources resources = super.getResources();
if (applicationLike != null) {
//此處的applicationLike現在就是我們的AppApplication類
return applicationLike.getResources(resources);
}
return resources;
}
... ...
改造原來AppApplication復寫的startActivities
方法時,我發現DefaultApplicationLike類中并沒有類似getResources
的代理方法,所以我只有將這個復寫放到了RealApplication中。
第三步,增加Tinker的gradle配置
Tinker的gradle參數配置很靈活,具體的參數設置事例可參考官方sample中的app/build.gradle,為了使gradle文件不至于太混雜,我將Tinker相關的配置單獨抽取出來放在新建的tinker_support.gradle
文件中,然后在MyMoney/build.gradle文件中加入下面一行即可:
// tinker config
apply from: 'tinker_support.gradle'
為了更方便的構建,主要修改了以下配置參數:
//基準apk包的備份路徑,這里僅作備份用,每次構建apk時會將生成的apk文件、mapping和R文件自動拷貝一份到這個目錄下去
def bakPath = file("${buildDir}/bakApk/")
//構建補丁時獲取基準apk包的文件名
def getTinkerBaseApkFileName(def defaultName) {
return hasProperty("TINKER_BASE_APK_NAME") ? TINKER_BASE_APK_NAME : defaultName
}
/**
* 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 = isRelease();
tinkerBaseApkFileName = getTinkerBaseApkFileName("Mymoney_base.apk")// todo 構建時需要在此配置基準包的filename
//proguard mapping file to build patch apk
tinkerMappingFileName = tinkerBaseApkFileName.substring(0, tinkerBaseApkFileName.length() - 4) + "-mapping.txt"
//resource R.txt to build patch apk, must input if there is resource changed
tinkerSymbolFileName = tinkerBaseApkFileName.substring(0, tinkerBaseApkFileName.length() - 4) + "-R.txt"
}
/**
* mapping 文件 路徑取得是 rootDir/tinker/mapping/
* @return
*/
def getMappingFilePath() {
// String baseMappingPath = project.projectDir.toString() + "/document/mapping/"
String baseMappingPath = "${rootDir}/tinker/mapping/"
String tailPath = ext.tinkerMappingFileName
String middlePath = ""
if (hasProperty("channelCode")) {
middlePath = channelCode + "/"
}
return baseMappingPath + middlePath + tailPath
}
/**
* R 文件 路徑取得是 rootDir/tinker/symbol/
*/
def getSymbolFilePath() {
String baseSymbolPath = "${rootDir}/tinker/symbol/"
String tailSymbolPath = ext.tinkerSymbolFileName
String middleSymbolPath = ""
if (hasProperty("channelCode")) {
middleSymbolPath = channelCode + "/"
}
return baseSymbolPath + middleSymbolPath + tailSymbolPath
}
/**
* 基準apk文件 rootDir/tinker/apk/
* @return
*/
def getBaseApkFilePath() {
String baseApkPath = "${rootDir}/tinker/apk/"
String tailApkPath = ext.tinkerBaseApkFileName
String middleApkPath = ""
if (hasProperty("channelCode")) {
middleApkPath = channelCode + "/"
}
return baseApkPath + middleApkPath + tailApkPath
}
// tinkerId是唯一標識,這里默認指定為apk的版本號
def getTinkerIdValue() {
return hasProperty("TINKER_ID") ? TINKER_ID : ext.apkVersionName
... ...
//如果在本地目錄下找不到基準apk,就去我們放基準包的地方去下載
def downloadBaseApkFile(def address, def savePath) {
new File(savePath).withOutputStream { out ->
out << new URL(address).openStream()
}
}
以上更改把基準包的獲取路徑改到了根目錄下的tinker目錄中,把tinkerEnabled的值付給isRelease()函數,這樣在實際應用發布版本時,我們手動在創建一個tinker目錄及其相應文件夾(只需創建一次即可),然后將我們構建好的apk、相應的mapping和R文件(需改名為${tinkerBaseApkFileName}-mapping.txt和${tinkerBaseApkFileName}-R.txt),這樣做的好處時基準包可以放在本地不被clean掉,方便在本地手動構建時進行管理。
第四步,安裝和初始化Tinker
上面改造后的AppApplication中有兩行代碼用于Tinker的安裝和初始化:
//install tinker
TinkerUtils.installTinker(getApplication(), this);
// 設置tinker參數并向后臺請求補丁包
TinkerUtils.setUpTinker(context);
TinkerUtils代碼如下:
public class TinkerUtils {
private static final String TAG = "Tinker";
public static void installTinker(Context context, ApplicationLike applicationLike) {
// 安裝tinker
SampleTinkerManager.initCurrentChannelValue(ChannelUtil.getChannel());
SampleTinkerManager.setTinkerApplicationLike(applicationLike);
SampleTinkerManager.initFastCrashProtect();
//should set before com.dx168.patchsdk.sample.tinker is installed
SampleTinkerManager.setUpgradeRetryEnable(true);
//installTinker after load multiDex
//or you can put com.tencent.com.dx168.patchsdk.sample.tinker.** to main dex
SampleTinkerManager.installTinker(applicationLike);
Tinker.with(context);
//使用Hack的方式,如果補丁中有so庫 那么直接加載補丁中的armeabi下的so庫(將tinker library中的armeabi注冊到系統的library path中。)
TinkerLoadLibrary.installNavitveLibraryABI(context, "armeabi");
}
public static void setUpTinker(Context context) {
if (ChannelUtil.isGoogleVersion()) {
return;
}
//在補丁管理后臺注冊的id和key,參數值配置在gradle文件中
String appId = BuildConfig.TINKER_APP_ID;
String appSecret = BuildConfig.TINKER_APP_SECRET;
String tinkerUrl = BuildConfig.TINKER_PATCH_URL;
PatchManager.getInstance().init(context, tinkerUrl, appId, appSecret, new ActualPatchManager() {
@Override
public void cleanPatch(Context context) {
TinkerInstaller.cleanPatch(context);
DebugUtil.debug(TAG, "local patch sdk >>>>> cleanPatch");
}
@Override
public void applyPatch(Context context, String patchPath) {
TinkerInstaller.onReceiveUpgradePatch(context, patchPath);
DebugUtil.debug(TAG, "local patch sdk >>>>> applyPatch: " + patchPath);
}
});
PatchManager.getInstance().setTag(ChannelUtil.getChannel());//可用于灰度發布
PatchManager.getInstance().setChannel(ChannelUtil.getChannel());
PatchManager.getInstance().queryAndApplyPatch(new PatchListener() {
@Override
public void onQuerySuccess(String response) {
DebugUtil.debug(TAG, "local patch sdk >>>>> onQuerySuccess response={ignore in log}");
}
@Override
public void onQueryFailure(Throwable e) {
DebugUtil.debug(TAG, "local patch sdk >>>>> onQueryFailure e=" + Log.getStackTraceString(e));
}
@Override
public void onDownloadSuccess(String path) {
DebugUtil.debug(TAG, "local patch sdk >>>>> onDownloadSuccess path=" + path);
}
@Override
public void onDownloadFailure(Throwable e) {
DebugUtil.debug(TAG, "local patch sdk >>>>> onDownloadFailure e=" + Log.getStackTraceString(e));
}
@Override
public void onApplySuccess() {
DebugUtil.debug(TAG, "local patch sdk >>>>> onApplySuccess");
}
@Override
public void onApplyFailure(String msg) {
DebugUtil.debug(TAG, "local patch sdk >>>>> onApplyFailure msg=" + msg);
}
@Override
public void onCompleted() {
DebugUtil.debug(TAG, "local patch sdk >>>>> onCompleted");
}
});
}
}
構建補丁包
如上個步驟所說,構建好基準包后,將apk、mapping和R文件改好名字后放在tinker相應目錄中,就可以開始構建補丁包了。打包方式:
直接使用task:tinkerPatchVariantName(例如tinkerPatchDebug、tinkerPatchRelease)即可自動根據Variant選擇相應的編譯類型,同時它還貼心的為我們完成以下幾個操作:
1.將TINKER_ID自動插入AndroidManifest的meta項,輸出路徑為build/intermediates/tinker_intermediates/AndroidManifest.xml;
2.如果minifyEnabled為true,將自動將Tinker的proguard規則添加到proguardFiles中,輸出路徑為build/intermediates/tinker_intermediates/tinker_proguard.pro,這里你不需要將它們拷貝到自己的proguard配置文件中;
3.如果multiDexEnabled為true,將自動生成Tinker需要放在主dex的keep規則。在tinker 1.7.6版本之前,你需要手動將生成規則拷貝到自己的multiDexKeepProguard文件中。例如Sample中的multiDexKeepProguard file("keep_in_main_dex.txt")。在1.7.6版本之后,這里會通過腳本自動處理,無須手動填寫。
4.把dexOptions的jumboMode打開。
我們構建Release包時,直接執行下面命令即可:
./gradlew tinkerPatchRelease
構建很快,輸出目錄為build/outputs/tinkerPatch/release
,會產生兩個帶簽名的apk格式的補丁patch_signed.apk
和patch_signed_7zip.apk
,構建log會提示我們哪個補丁更小并建議我們使用小的。更改代碼和資源文件造成的改動量會影響補丁包的大小,只改一行代碼的情況下,補丁包大約為4k。
測試
測試主要從以下幾個方面進行:
- 集成Tinker后,打包測試apk是否有可能存在的bug
- 測試補丁下發流程及合成(補丁拉取時機是每次app進程重新啟動時,拉取后會自動合成,合成后在鎖屏或者app正好處于后臺的情況下會自動殺掉app進程,補丁在進程重啟后生效。如合成失敗,會自動重試一次)
- 測試包含不同類型修改的補丁(Tinker目前版本不支持清單文件的修改)
- 修改Application(此處即指改造后的繼承DefaultApplicationLike的類)
- 修改其他代碼
- 修改資源文件
- 測試補丁是否對渠道信息有影響
- 測試對同一個基準apk下發多個補丁的情況(目前的策略后臺會根據補丁上傳的時間自動修改補丁的版本號,當高版本的補丁被下發時,已合成的補丁會自動被清除,再嘗試合成新補?。?/li>
- app版本升級(在升級版本時我們也無須手動去清除補丁,框架已經為我們做了這件事情)
在后臺創建好app,拿到對應的key配置到項目中,并創建對應基準apk的版本,上傳對應版本的補丁包(后臺會自動改名,所以下發的補丁包不會包含.apk的后綴名),選擇是否灰度等,即可下發補丁。
Debug打印日志可以看到補丁的拉取和合成過程
Jenkins構建支持
因為構建補丁包時,有三個變量,即基準apk、mapping和R文件,所以我們可以使用Jenkins提供的參數化構建。
構建命令如下,配置TINKER_BASE_APK_NAME為Mymoney_base.apk
修改構建的輸出目錄為:
MyMoney/build/outputs/tinkerPatch/release/patch_signed_7zip.apk,MyMoney/build/outputs/tinkerPatch/release/patch_signed.apk
在開始構建之前,我們需要上傳相應文件來設置我們添加的三個文件參數,因為Jenkins會將我們設置好的文件參數指向我們上傳的文件,而參數名稱已經根據TINKER_BASE_APK_NAME寫死而且符合規范,所以我們每次構建都無需對項目配置和Jenkins配置做任何修改,只需要上傳對應基準文件即可(也無需修改文件名了)。
構建完成后就可以生成相應的補丁包:
遇到的問題
在Debug構建測試Tinker時,會出現不能斷點調試的情況,這是因為我在測試時Debug模式將
minifyEnabled
設置為了true,所以無法在斷點時識別代碼。-
在Release模式時,將tinkerEnabled設置為false,會報找不到Application的錯誤:
原因也是開啟了混淆,不過官方的demo也一樣有這個問題。鑒于在Release的情況下,似乎不會將Tinker關閉,可忽略這個問題。
提示有png被修改,但是其實沒改過。wiki中有提到這個問題,除了將cruncherEnabled關閉外,可能的原因是使用Run的方式構建了apk。
集成Tinker后第一次啟動app崩潰,并且不打印任何錯誤堆棧。原來以為是分包的問題,經過多次測試,發現應該是在Tinker安裝之前進行了多余的操作,另外Tinker的依賴最好放在啟動Application所在的module中,因為Tinker的安裝和構建都依賴Application。如果將其依賴放在其他lib庫所在的module中,可能引起未知crash。
其他可能遇到的問題參考wiki:常見問題
擴展
- Tinker支持靈活的gradle配置,配置參數參考:
Tinker的gradle參數詳解 - Tinker的代碼擴展和Api參考:
Tinker 自定義擴展
Tinker API概覽 - Tinker熱修復的原理可參考下列文章:
微信Android熱補丁實踐演進之路
微信Tinker的一切都在這里,包括源碼(一)
Tinker Dexdiff算法解析