Android热修复工具Tinker集成
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下发多个补丁的情况(目前的策略后台会根据补丁上传的时间自动修改补丁的版本号,当高版本的补丁被下发时,已合成的补丁会自动被清除,再尝试合成新补丁)
- 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算法解析