一、什么是热修复
顾名思义,动态的修复或者更新我们的APP的行为,有时候会被称为动态更新
二、热修复的好处
以前APP修复BUG,或者新添一些小功能,只能通过发布一个版本,覆盖安装才能解决问题,过程成本非常高,严重的话可能导致用户流失
现在通过热修复,就能无感修复一些BUG或者添加一些小功能
热修复其实是一种亡羊补牢的手段,热修复和发布的正式版一样,都要经过一些列正规测试
三、流行技术
QQ空间的超级补丁方案
微信的Tinker
阿里的AndFix 、dexposed
美团的Robust 、ele的migo 、百度的hotfix等
技术对比
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 https://github.com/alibaba/AndFix
作为一个Android依赖库使用,支持Android版本2.3到7.0,ARM和x86架构,Dalvik和ART,32bit和64bit
原理:对方法完成一个替换,使得有bug的方法永远不会执行到,从而完成Bug的修复
集成阶段
gradle中添加AndFix依赖,注意是app中的gradle,不是工程的gradle,因为app下的gradle,说白了都是一些方法和类,如果仅仅是Java方法、类,那么就在app的gradle写compile或者implementation,如果包含一些gradle脚本,那就需要在工程的gradle的dependencies写入classpath
dependencies {
compile 'com.alipay.euler:andfix:0.5.0@aar'
}
在代码中完成对AndFix的初始化
patchManager = new PatchManager(context);
patchManager.init(Utils.getVersionName(context));
patchManager.loadPatch();
//官网上建议以上代码在Application的onCreate()方法中执行,加载AndFix模块
安装BUG APK 修复APK阶段
生成release 版本,安装在手机上
//1.新建一个按钮,点击闪退
String error = null;
Log.d(TAG,error)
//2.新建一个按钮,点击修复
patchManager.loadPatch(path);//path是指你存放.apatch文件的路径
//生成release版本
./gradlew assembleRelease
生成apatch文件
修改闪退的按钮的点击事件,例如弹出千年Hello World
官网Github上下载apkpatch-tools
apkpatch -f 生成一个apatch文件
apkpatch -m 合并多个apatch文件
apkpatch -f new.apk -t old.apk -o outputs/ -k android.jks -p android -a android -e android (outputs目录下会有.apatch文件,具体命令含义直接输入apkpatch,会将 -f/-m 参数列出)
修复BUG
将生成的.apatch文件放到loadPatch方法制定的目录(实际上会将这部分组件化,方便复用,其中apatch文件可以通过网络每次比较然后下载到指定的路径下,然后调用loadPatch方法)
点击之前新建的按钮(loadPatch()加载apatch文件),然后点击闪退的那个按钮,会发现弹出Hello World
AndFix源码分析
初始化代码通过Android Studio一步一步点进去,都是一些类的初始化,没什么好讲的
主要代码就在得到patch文件后的loadPatch(String path)方法里,它会调用内部的loadPatch(Patch patch)方法,最后调用的是mAndFixManager.fix(File ,ClassLoader,List
/**
* fix
*
* @param file
* patch file
* @param classLoader
* classloader of class that will be fixed
* @param classes
* classes will be fixed
*/
public synchronized void fix(File file, ClassLoader classLoader,
List classes) {
if (!mSupport) {
return;
}
if (!mSecurityChecker.verifyApk(file)) {// security check fail
return;
}
try {
File optfile = new File(mOptDir, file.getName());
boolean saveFingerprint = true;
if (optfile.exists()) {
// need to verify fingerprint when the optimize file exist,
// prevent someone attack on jailbreak device with
// Vulnerability-Parasyte.
// btw:exaggerated android Vulnerability-Parasyte
// http://secauo.com/Exaggerated-Android-Vulnerability-Parasyte.html
if (mSecurityChecker.verifyOpt(optfile)) {
saveFingerprint = false;
} else if (!optfile.delete()) {
return;
}
}
final DexFile dexFile = DexFile.loadDex(file.getAbsolutePath(),
optfile.getAbsolutePath(), Context.MODE_PRIVATE);
if (saveFingerprint) {
mSecurityChecker.saveOptSig(optfile);
}
ClassLoader patchClassLoader = new ClassLoader(classLoader) {
@Override
protected Class> findClass(String className)
throws ClassNotFoundException {
Class> clazz = dexFile.loadClass(className, this);
if (clazz == null
&& className.startsWith("com.alipay.euler.andfix")) {
return Class.forName(className);// annotation’s class
// not found
}
if (clazz == null) {
throw new ClassNotFoundException(className);
}
return clazz;
}
};
Enumeration entrys = dexFile.entries();
Class> clazz = null;
while (entrys.hasMoreElements()) {
String entry = entrys.nextElement();
if (classes != null && !classes.contains(entry)) {
continue;// skip, not need fix
}
//将名字转换成字节码
clazz = dexFile.loadClass(entry, patchClassLoader);
if (clazz != null) {
fixClass(clazz, classLoader);
}
}
} catch (IOException e) {
Log.e(TAG, "pacth", e);
}
}
最后在fixClass里面会通过native方法getAnnotationNative()寻找字节码中被注解的方法,最后通过native的replaceMethod(Method dest,Method src)方法替换,最后通过native的setFieldFlag()方法init
替换方法的实际操作都在阿里写的本地方法内,java层都是一些操作patch、查找方法等常规操作
原理简单,集成简单,使用简单,即时生效
只能修复方法级别的bug,极大的限制了使用场景
五、Tinker https://github.com/Tencent/tinker
Tinker是微信官方的Android热补丁方案,它支持动态下发代码,SO库以及资源,让应用能够再不需要重新安装的情况下实现更新,也可以使用Tinker更新插件
主要包括以下几部分
gradle编译插件:tinker-patch-gradle-plugin
核心sdk库:tinker-android-lib
非gradle编译用户的命令版本:tinker-patch-cli.jar
存在的已知问题
不支持AndroidManifest.xml修改,不支持新增四大组件(1.9.0支持新增非export的Activity)
由于Google Play的开发着条款限制,不建议在Google Play渠道动态更新代码
在Android N (7.x) 上,补丁对应用启动时间有轻微的影响
不支持部分三星android-21机型,加载补丁时会主动抛出 "TinkerRuntimeException:checkDexInstall failed"
对于资源替换,不支持修改remoteView,例如transition动画,notification icon 以及桌面图标
快速接入Tinker,利用tinker-tools生成patch文件
//1.gradle.properites
TINKER_VERSION=1.9.9
//2.build.gradle
//optional, help to generate the final application
compileOnly "com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}"
//tinker's main Android lib
implementation "com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}"
//3.AndroidManifest.xml
android:name="TINKER_ID"
android:value="1.0" />
//4.照着官网写,如下,然后再rebuild一下,就生成了MyTinkerApplication了,然后在AndroidManifest.xml
//加入Application
@DefaultLifeCycle(application = ".MyTinkerApplication",flags = ShareConstants.TINKER_ENABLE_ALL,loadVerifyFlag = false)
public class MyTinkerApplicationLike extends ApplicationLike {
public MyTinkerApplicationLike(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);
//you must install multiDex whatever tinker is installed!
MultiDex.install(base);
TinkerManager.installTinker(this);//安装tinker
}
}
//TinkerManager类
public static void installTinker(ApplicationLike applicationLike){
mAppLike = applicationLike;
if(isInstalled) {
return;
}
//完成Tinker的初始化
TinkerInstaller.install(mAppLike);
isInstalled = true;
}
//5.当然还需要加载patch的代码,这部分和AndFix类似
//完成patch文件的加载
public static void loadPath(String path){
if(Tinker.isTinkerInstalled()){
TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(),path);
}
}
private static Context getApplicationContext(){
if(mAppLike != null){
return mAppLike.getApplication().getApplicationContext();
}
return null;
}
//6. gradlew assembleRelease 生成一个APK
//7.修改布局文件等,重新生成一个新的APK
//8.官网 wiki 页面有tinker-patch-cli引导使用,下载
//文件如下
//tinker-patch-cli-1.7.7.jar
//tinker_proguard.pro
//tinker_multidexkeep.pro
//tinker_config.xml
//以上最重要的是tinker_config.xml,使用时需要将其中的issue标签内的最后一个loader便签的value设置为
//我们自定义的MyTinkerApplication,将最后一个issue的storepass 等值设置为jks对应的正确值,毕竟生成
//生成patch文件也需要签名
//9.指定命令生成singed.apk
java -jar tinker-patch-cli.jar -old old.apk -new new.apk -config tinker_config.xml -out output_path
//10.大功告成,安装旧版APK,然后将patch放入指定路径,打开应用点击调用加载patch,当应用重启后,就会生效,当然实际上肯定是从服务端获取,对比md5检查,然后再patch
gradle 生成patch文件
首先和上面一样需要配置app 下的build.gradle,在工程的build.gradle的dependencies中需要配置
classpath "com.tencent.tinker:tinker-patch-gradle-plugin:${TINKER_VERSION}"
然后新建一个buildTinker.gradle,配置一些有关tinker的配置,都是官网上的,直接复制过来用,其中包括多渠道打包等,地址 : https://github.com/Tencent/tinker/wiki/Tinker-%E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%97
def bakPath = file("${buildDir}/bakApk/")
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'")
}
}
/**
* 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}/"
//proguard mapping file to build patch apk
tinkerApplyMappingPath = "${bakPath}/"
//resource R.txt to build patch apk, must input if there is resource changed
tinkerApplyResourcePath = "${bakPath}/"
//only use for build all flavor, if not, just ignore this field
tinkerBuildFlavorDirectory = "${bakPath}/"
}
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 {
/**
* 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'
* if we use tinkerPatch to build the patch apk, you'd better to apply the old
* apk mapping file if minifyEnabled is enable!
* Warning:
* you must be careful that it will affect the normal assemble build!
* old APK打包时所使用的混淆文件
*/
applyMapping = getApplyMappingPath()
/**
* optional,default 'null'
* It is nice to keep the resource id from R.txt file to reduce java changes
*指定 old APK 的资源文件
*/
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 = "1.0"//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.
*
* Notice that currently this feature is incubating and only support NON-EXPORTED Activity
*/
supportHotplugComponent = false
}
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"
//加载Patch文件用到的类
"com.jianxiongrao.tinkerdemo.tinker.MyTinkerApplication"
]
}
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 = ["libs/*/*.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
}
packageConfig {
/**
* optional,default 'TINKER_ID, TINKER_ID_VALUE' 'NEW_TINKER_ID, NEW_TINKER_ID_VALUE'
* package meta file gen. path is assets/package_meta.txt in patch file
* you can use securityCheck.getPackageProperties() in your ownPackageCheck method
* or TinkerLoadResult.getPackageConfigByName
* we will get the TINKER_ID from the old apk manifest for you automatic,
* other config files (such as patchMessage below)is not necessary
*/
configField("patchMessage", "tinker is sample to use")
/**
* just a sample case, you can use such as sdkVersion, brand, channel...
* you can parse it in the SamplePatchListener.
* Then you can use patch conditional!
*/
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 = "/usr/local/bin/7za"
// }
}
List 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 ->
/**
* 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"
}
}
}
}
}
}
通过Android Stdio右侧的gradle工具 build -> assembleRelease 或者命令行生成一个release APK,安装到手机上,然后需要将上述gradle文件的ext方法内的tinkerOldApkPath 值添加设置为 "${bakPath}/old_apk_name",然后再gradle工具栏找到tinker -> tinkerPatchRelease ,然后去outputs目录下找singed.apk补丁文件即可。
如果想实现进度条之类的就需要利用TinkerInstaller.install() 五个参数的方法,参数都设置成默认的即可,例如,LoadReporter 可以传递 new DefaultLoadReporter()
至于源码,利用Android Studio查看,核心方法还在于利用单例和建造者模式生成的Tinker类里面,例如操作字节码,设置偏移量,加载补丁后杀死进程等。