今年真是热补丁框架的洪荒之力爆发的一年,短短几个月内,已经出现了好几个热修复的框架了,基本上都是大同小异,这里我就不过多的去评论这些框架。只有自己真正的去经历过,你才会发现其中的
大写的坑
事实上,现在出现的大多数热修复的框架,稳定性和兼容性都还达不到要求,包括阿里的Andfix,据同事说,我们自己的app原本没有多少crash,接入了andfix倒引起了一部分的crash,这不是一个热修复框架所应该具有的“变态功能”。虽然阿里百川现在在大力推广这套框架,我依旧不看好,只是其思路还是有学习价值的。
Dex的热修复目前来看基本上有四种方案:
此外,微信的方案是多classloader,这种方式可以解决用multidex方式在部分机型上不生效patch的问题,同时还带来一个好处,这种多classloader的方式使用的是instant run的代码,如果存在native library的修复,也会带来极大的方便。
而native libraray的修复,目前来说,基本上有两种方案。。
第二种方式的实现可以看看BaseDexClassLoader的构造函数
BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent)
只需要在修复dex的同时,如果有native library,则获取原来的路径与patch的路径进行连接,伪代码如下:
nativeLibraryPath = 获取与原始路径;
nativeLibraryPath = patchNativeLibraryPath + File.pathSeparator + nativeLibraryPath;
IncrementalClassLoader inject = IncrementalClassLoader.inject(
classLoader,
nativeLibraryPath,
optDir.getAbsolutePath(),
dexList);
而这种方式需要强依赖dex的修复,如果没有dex,就无能为例了,实际情况基本上是两种方式交叉使用,在没有dex的情况下,使用另外一种方式。
而native library还有一个坑,就是从patch中释放so的过程,这个过程需要处理兼容性,在android 21以下,通过下面这个函数去释放
com.android.internal.content.NativeLibraryHelper.copyNativeBinariesIfNeededLI
而在andrdod 21及以上,则通过下面的这几个函数去释放
com.android.internal.content.NativeLibraryHelper$Handle.create()
com.android.internal.content.NativeLibraryHelper.findSupportedAbi()
com.android.internal.content.NativeLibraryHelper.copyNativeBinaries()
而对于资源的热修复,其实主要还是和插件化的思路是一样的,具体实现可以参考两个
本篇文章就来说说资源的热修复的实现思路,在这之前,需要贴两个链接,以下文章的内容基于这两个链接去实现,所以务必先看看,不然会一脸懵逼。一个是instant run的源码,自备梯子,另一个是冯老师写的一个类,这个类在Atlas中出现过,后来被冯老师重写了,同样自备梯子。
重要的事情说三遍
自备梯子
自备梯子
自备梯子
资源的热修复实现,主要由一下几个步骤组成:
对于第一步,我们需要先看看instant run对于资源部分的实现,其伪代码如下
AssetManager newAssetManager = new AssetManager();
newAssetManager.addAssetPath(externalResourceFile)
// Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm
// in L, so we do it unconditionally.
newAssetManager.ensureStringBlocks();
// Find the singleton instance of ResourcesManager
ResourcesManager resourcesManager = ResourcesManager.getInstance();
// Iterate over all known Resources objects
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
for (WeakReference wr : resourcesManager.mActiveResources.values()) {
Resources resources = wr.get();
// Set the AssetManager of the Resources instance to our brand new one
resources.mAssets = newAssetManager;
resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
}
}
代码很简单,通过调用addAssetPath将patch的资源加到新建的AssetManager对象中,然后将内存中所有Resources对象中的AssetManager对象替换为新建的AssetManager对象。当然还需要处理兼容性问题,对于兼容性问题,则需要用到冯老师的Hack类(这里我为了与原来冯老师没有重写前的Hack类做区分,将其重命名了HackPlus,意思你懂的),具体Hack过程请参考Atlas或者携程的插件化框架的实现,然后基于instant run进行实现,当然这种方式有一部分资源是修复不了了,比如notification。
坑么,你没遇到,总是说没有,遇到了,坑无数。
首先需要拿到App运行后内存中的Resources对象
这里我已经基本实现了反射检测系统支持性相关的代码,主要就是对以上分析的内容做反射检测,一旦发生异常,则不再进行资源的修复,代码如下(HackPlus的源码见上面的Hack.java的源码):
//这个类用于保存hack过程中发生的异常,一旦mAssertionErr不为空,则表示当前系统不支持资源的热修复,直接return,不进行修复
public class AssertionArrayException extends Exception {
private static final long serialVersionUID = 1;
private List mAssertionErr;
public AssertionArrayException(String str) {
super(str);
this.mAssertionErr = new ArrayList();
}
public void addException(AssertionException hackAssertionException) {
this.mAssertionErr.add(hackAssertionException);
}
public void addException(List list) {
this.mAssertionErr.addAll(list);
}
public List getExceptions() {
return this.mAssertionErr;
}
public static AssertionArrayException mergeException(AssertionArrayException assertionArrayException, AssertionArrayException assertionArrayException2) {
if (assertionArrayException == null) {
return assertionArrayException2;
}
if (assertionArrayException2 == null) {
return assertionArrayException;
}
AssertionArrayException assertionArrayException3 = new AssertionArrayException(assertionArrayException.getMessage() + ";" + assertionArrayException2.getMessage());
assertionArrayException3.addException(assertionArrayException.getExceptions());
assertionArrayException3.addException(assertionArrayException2.getExceptions());
return assertionArrayException3;
}
public String toString() {
StringBuilder stringBuilder = new StringBuilder();
for (AssertionException hackAssertionException : this.mAssertionErr) {
stringBuilder.append(hackAssertionException.toString()).append(";");
try {
if (hackAssertionException.getCause() instanceof NoSuchFieldException) {
Field[] declaredFields = hackAssertionException.getHackedClass().getDeclaredFields();
stringBuilder.append(hackAssertionException.getHackedClass().getName()).append(".").append(hackAssertionException.getHackedFieldName()).append(";");
for (Field field : declaredFields) {
stringBuilder.append(field.getName()).append(File.separator);
}
} else if (hackAssertionException.getCause() instanceof NoSuchMethodException) {
Method[] declaredMethods = hackAssertionException.getHackedClass().getDeclaredMethods();
stringBuilder.append(hackAssertionException.getHackedClass().getName()).append("->").append(hackAssertionException.getHackedMethodName()).append(";");
for (int i = 0; i < declaredMethods.length; i++) {
if (hackAssertionException.getHackedMethodName().equals(declaredMethods[i].getName())) {
stringBuilder.append(declaredMethods[i].toGenericString()).append(File.separator);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
stringBuilder.append("@@@@");
}
return stringBuilder.toString();
}
}
//具体Hack类,主要Hack AssetManager相关类,
public class AndroidHack {
private static final String TAG = "AndroidHack";
//exception
public static AssertionArrayException exceptionArray;
//resources
public static HackPlus.HackedClass AssetManager;
public static HackedMethod0 AssetManager_construct;
public static HackPlus.HackedMethod1 AssetManager_addAssetPath;
public static HackedMethod0 AssetManager_ensureStringBlocks;
//>=19
public static HackedClass
使用的时候,只要在加载patch资源前,调用如下方法进行检测
if(!AndroidHack.defineAndVerify()){
//不加载patch资源
return;
}
//加载patch资源逻辑
patch资源的生成比较麻烦,我们放在最后面说明,现在假设我们有一个包含整个apk的资源的文件,需要运行时替换,现在来实现上面的加载patch资源的逻辑,具体逻辑上面反射的时候已经说明了,这时候只需要调用上面反射获取的包装类,进行替换即可,直接看代码中的注释:
public class ResourceLoader {
private static final String TAG = "ResourceLoader";
public static boolean patchResources(Context context, File patchResource) {
try {
if (context == null || patchResource == null){
return false;
}
if (!patchResource.exists()) {
return false;
}
//通过构造函数new一个AssetManager对象
AssetManager newAssetManager = AndroidHack.AssetManager_construct.invoke().statically();
//调用AssetManager对象的addAssetPath方法添加patch资源
int cookie = AndroidHack.AssetManager_addAssetPath.invokeWithParam(patchResource.getAbsolutePath()).on(newAssetManager);
//添加成功时cookie必然大于0
if (cookie == 0) {
Logger.e(TAG, "Could not create new AssetManager");
return false;
}
// 在Android 19以前需要调用这个方法,但是Android L后不需要,实际情况Andorid L上调用也不会有问题,因此这里不区分版本
// Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm
// in L, so we do it unconditionally.
AndroidHack.AssetManager_ensureStringBlocks.invoke().on(newAssetManager);
//获取内存中的Resource对象的弱引用
Collection> references;
if (Build.VERSION.SDK_INT >= 24) {
// Android N,获取的是一个ArrayList,直接赋值给references对象
// Find the singleton instance of ResourcesManager
Object resourcesManager = AndroidHack.ResourcesManager_getInstance.invoke().statically();
//noinspection unchecked
references = (Collection>) AndroidHack.ResourcesManager_mResourceReferences.on(resourcesManager).get();
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
//Android 19以上 获得的是一个ArrayMap,调用其values方法后赋值给references
// Find the singleton instance of ResourcesManager
Object resourcesManager = AndroidHack.ResourcesManager_getInstance.invoke().statically();
@SuppressWarnings("unchecked")
ArrayMap> arrayMap = AndroidHack.ResourcesManager_mActiveResources.on(resourcesManager).get();
references = arrayMap.values();
} else {
//Android 19以下,通过ActivityThread获取得到的是一个HashMap对象,通过其values方法获得对象赋值给references
Object activityThread = AndroidHack.getActivityThread();
@SuppressWarnings("unchecked")
HashMap> map = (HashMap>) AndroidHack.ActivityThread_mActiveResources.on(activityThread).get();
references = map.values();
}
//遍历获取到的Ressources对象的弱引用,将其AssetManager对象替换为我们的patch的AssetManager
for (WeakReference wr : references) {
Resources resources = wr.get();
// Set the AssetManager of the Resources instance to our brand new one
if (resources != null) {
if (Build.VERSION.SDK_INT >= 24) {
Object resourceImpl = AndroidHack.Resources_ResourcesImpl.get(resources);
AndroidHack.ResourcesImpl_mAssets.set(resourceImpl, newAssetManager);
} else {
AndroidHack.Resources_mAssets.set(resources, newAssetManager);
}
resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
}
}
return true;
} catch (Throwable throwable) {
Logger.e(TAG, throwable);
throwable.printStackTrace();
}
return false;
}
}
这样一来,就在Appliction启动的时候完成了资源的热修复,当然我们也可以像instant run那样,把activity也处理,不过我们简单起见,让其重启生效,所以activity就不处理了。
于是,我们Appliction的onCreate()中的代码就变成了下面这个样子
if (hasResourcePatch){
if (!AndroidHack.defineAndVerify()) {
//不加载patch资源
return;
}
//加载patch资源逻辑
File file = new File("/path/to/patchResource.apk");
ResourceLoader.patchResources(this, file);
}
这里有一个坑。
patch应用成功后,如果要删除patch,patch文件的删除一定要谨慎,最好先通过配置文件标记patch不可用,下次启动时检测该标记,然后再删除,运行期删除正在使用的patch文件会导致所有进程的重启,Application中的所有逻辑会被初始化一次。
还差最后一步,patch的资源从哪里来,这里主要讲两种方式。
无论哪一种方式,都需要public.xml去固定资源id。
这里讨论的是第二种方式,所以给出精简版的实现思路:
首先需要生成public.xml,public.xml的生成通过aapt编译时添加-P参数生成。相关代码通过gradle插件去hook Task无缝加入该参数,有一点需要注意,通过appt生成的public.xml并不是可以直接用的,该文件中存在id类型的资源,生成patch时应用进去编译的时候会报resource is not defined,解决方法是将id类型的资源单独记录到ids.xml文件中,相当于一个声明过程,编译的时候和public.xml一样,将ids.xml也参与编译即可。
/**
* 添加aapt addition -P选项
*/
String processResourcesTaskName = variant.variantData.getScope().getGenerateRClassTask().name
ProcessAndroidResources processResourcesTask = (ProcessAndroidResources) project.tasks.getByName(processResourcesTaskName)
Closure generatePubicXmlClosure = {
if (processResourcesTask) {
//添加-P 参数,生成public.xml
AaptOptions aaptOptions = processResourcesTask.aaptOptions
File outPublicXml = new File(outputDir, PUBLIC_XML)
aaptOptions.additionalParameters("-P", outPublicXml.getAbsolutePath())
processResourcesTask.setAaptOptions(aaptOptions)
}
}
/**
* public.xml中对一些选项进行剔除,目前处理id类型资源,不然应用的时候编译不过,会报resource is not defined,主要是生成一个ids.xml,相当于对这部分资源进行声明
*/
Closure handlePubicXmlClosure = {
if (processResourcesTask) {
File outPublicXml = new File(outputDir, PUBLIC_XML)
if (outPublicXml.exists()) {
SAXReader reader = new SAXReader();
Document document = reader.read(outPublicXml);
Element root = document.getRootElement();
List childElements = root.elements();
File idsFile = new File(outPublicXml.getParentFile(), IDS_XML)
if (idsFile.exists()) {
idsFile.delete()
}
if (!idsFile.exists()) {
idsFile.getParentFile().mkdirs()
idsFile.createNewFile()
}
idsFile.append("")
idsFile.append("\n")
idsFile.append("" )
idsFile.append("\n")
for (Element child : childElements) {
String attrName = child.attribute("name").value
String attrType = child.attribute("type").value
if ("id".equalsIgnoreCase(attrType)) {
String value = child.asXML()
idsFile.append(" \n")
project.logger.error "write id item ${attrName}"
}
}
idsFile.append("")
}
}
}
if (processResourcesTask) {
processResourcesTask.doFirst(generatePubicXmlClosure);
processResourcesTask.doLast(handlePubicXmlClosure)
}
在编译资源之前,将public.xml和ids.xml文件拷贝到资源目录values下,并检测values.xml文件中是否有已经定义的id类型的资源,如果有,则从ids.xml文件中将其删除,否则会报resource is already defined的异常,也会编译不过去。
/**
* 应用public.xml
*/
String mergeResourcesTaskName = variant.variantData.getScope().getMergeResourcesTask().name
MergeResources mergeResourcesTask = (MergeResources) project.tasks.getByName(mergeResourcesTaskName)
Closure applyPubicXmlClosure = {
if (mergeResourcesTask != null) {
if (oldTinkerDir != null && needApplyPublicXml) {
File publicXmlFile = new File(oldTinkerDir, "${dirName}/${PUBLIC_XML}")
if (publicXmlFile.exists()) {
File toDir = new File(mergeResourcesTask.outputDir, "values")
project.copy {
project.logger.error "\n$variant.name:copy a ${PUBLIC_XML} from ${publicXmlFile.getAbsolutePath()} to ${toDir}/${PUBLIC_XML}"
from(publicXmlFile.getParentFile()) {
include PUBLIC_XML
rename PUBLIC_XML, "${PUBLIC_XML}"
}
into(toDir)
}
} else {
logger.error("${publicXmlFile.absolutePath} does not exist")
}
File valuesFile = new File(mergeResourcesTask.outputDir, "values/values.xml")
File oldIdsFile = new File(oldTinkerDir, "${dirName}/${IDS_XML}")
if (valuesFile.exists() && oldIdsFile.exists()) {
SAXReader valuesReader = new SAXReader();
Document valuesDocument = valuesReader.read(valuesFile);
Element valuesRoot = valuesDocument.getRootElement()
List publicIds = valuesRoot.selectNodes("item[@type='id']")
if (publicIds != null && publicIds.size() != 0) {
Set existIdItems = new HashSet();
for (Element element : publicIds) {
existIdItems.add(element.attribute("name").value)
}
logger.error "existIdItems:${existIdItems}"
SAXReader oldIdsReader = new SAXReader();
Document oldIdsDocument = oldIdsReader.read(oldIdsFile);
Element oldIdsRoot = oldIdsDocument.getRootElement();
List oldElements = oldIdsRoot.elements();
if (oldElements != null && oldElements.size() != 0) {
File newIdsFile = new File(mergeResourcesTask.outputDir, "values/${IDS_XML}")
newIdsFile.append("")
newIdsFile.append("\n")
newIdsFile.append("" )
newIdsFile.append("\n")
for (Element element : oldElements) {
String itemName = element.attribute("name").value
if (!existIdItems.contains(itemName)) {
newIdsFile.append(" ${element.asXML()}\n")
} else {
logger.error "already exist id item ${itemName}"
}
}
newIdsFile.append("")
}
}
} else {
logger.error("${valuesFile.absolutePath} does not exist")
}
} else {
logger.error "res not changed.not to apply public.xml"
}
}
}
if (mergeResourcesTask) {
mergeResourcesTask.doLast(applyPubicXmlClosure);
}
这样一来,按照正常流程去编译,生成的apk安装包就可以获得了,然后将这个new.apk和有问题的old.apk进行差量算法,这里只考虑资源相关文件,即assets目录,res目录,arsc文件,AndroidManifest.xml文件,相关算法如下:
这样做的好处是能将资源patch文件尽可能的减小到最低,实际情况严重下来,res目录下的资源文件大小都非常小,没有必要去进行diff,所以直接使用原文件,而arsc文件则相对比较大,在考虑文件大小和内存的两个因素下,牺牲内存换大小还是ok的,所以在下发前,我们对其进行diff,生成diff文件,在客户端进行合成最终的arsc文件。
客户端下载到patch.apk后需要进行还原,还原的步骤如下:
这种方式的兼容性如何?简单自测了下,4.0-7.0的模拟器运行全部通过,当然不排除国产奇葩ROM的兼容性,所以这里我不宣称100%兼容。
无图言屌,没图你说个jb,先上一张没有进行热修复的图:
热修复之后的效果图
最后送上一句话: