Android 热补丁技术——资源的热修复

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android 热补丁技术——资源的热修复相关的知识,希望对你有一定的参考价值。

前言

今年真是热补丁框架的洪荒之力爆发的一年,短短几个月内,已经出现了好几个热修复的框架了,基本上都是大同小异,这里我就不过多的去评论这些框架。只有自己真正的去经历过,你才会发现其中的

大写的坑

事实上,现在出现的大多数热修复的框架,稳定性和兼容性都还达不到要求,包括阿里的Andfix,据同事说,我们自己的app原本没有多少crash,接入了andfix倒引起了一部分的crash,这不是一个热修复框架所应该具有的“变态功能”。虽然阿里百川现在在大力推广这套框架,我依旧不看好,只是其思路还是有学习价值的。

Dex的热修复总结

Dex的热修复目前来看基本上有四种方案:

此外,微信的方案是多classloader,这种方式可以解决用multidex方式在部分机型上不生效patch的问题,同时还带来一个好处,这种多classloader的方式使用的是instant run的代码,如果存在native library的修复,也会带来极大的方便。

Native Library热修复总结

而native libraray的修复,目前来说,基本上有两种方案。。

  • 类似multidex的dex方式,插入目录到数组最前面,具体文章见Android热更新之so库的热更新,需要处理系统的兼容性问题,系统分隔线是android 6.0
  • 第二种方式需要依赖多classloader,在构造BaseDexClassLoader的时候,获取原classloader的native library,通过环境变量分隔符(冒号),将patch的native library与原目录进行连接,patch目录在前,这样同样可以达到修复的目的,缺点是需要依赖dex的热修复,优点是应用native library时不需要处理兼容性问题,当然从patch中释放出来的时候也需要处理兼容性问题。

第二种方式的实现可以看看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()

资源的热修复

而对于资源的热修复,其实主要还是和插件化的思路是一样的,具体实现可以参考两个

  • Atlas或者携程的插件化框架
  • Instant run的资源处理方式,甚至可以做到运行期立即生效。

本篇文章就来说说资源的热修复的实现思路,在这之前,需要贴两个链接,以下文章的内容基于这两个链接去实现,所以务必先看看,不然会一脸懵逼。一个是instant run的源码,自备梯子,另一个是冯老师写的一个类,这个类在Atlas中出现过,后来被冯老师重写了,同样自备梯子。

重要的事情说三遍
自备梯子
自备梯子
自备梯子

资源的热修复实现,主要由一下几个步骤组成:

  • 提前感知系统兼容性,不兼容则不进行后续操作
  • 服务器端生成patch的资源,客户端应用patch的资源
  • 替换系统AssetManger,加入patch的资源

对于第一步,我们需要先看看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<Resources> 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

坑么,你没遇到,总是说没有,遇到了,坑无数。

  • 主要的分界线是Android 19 和 Android N
  • 首先需要拿到App运行后内存中的Resources对象

    • Android N,通过ResourcesManager中的mResourceReferences去获取Resources对象,是个ArrayList对象
    • Android 19到Android N(不含N),通过ResourcesManager中的mActiveResources去获取Resources对象,是个ArrayMap对象
    • Android 19以下,通过ActivityThread的mActiveResources去获取Resources对象,是个HashMap对象。
  • 接着就是替换Resources中的AssetManager对象
    • Android N,替换的是Resources对象中的mResourcesImpl成员变量中的mAssets成员变量。
    • Android N以前,替换的是Resources对象中的mAssets成员变量。
  • 对于Android 19以下,ActivityThread是通过ActivityThread中的静态函数currentActivityThread获取的的,这里有个坑,如果在主线程获取还好,但是万一在子线程获取,在低版本的Android上可能就是Null,因为在低版本,这个变量是通过ThreadLocal进行存储的,对于这种情况,只要检测当前线程是不是主线程,如果是主线程,则直接获取,如果不是主线程,则阻塞当前线程,然后切换到主线程获取,获取完成后通知阻塞线程。

这里我已经基本实现了反射检测系统支持性相关的代码,主要就是对以上分析的内容做反射检测,一旦发生异常,则不再进行资源的修复,代码如下(HackPlus的源码见上面的Hack.java的源码):

//这个类用于保存hack过程中发生的异常,一旦mAssertionErr不为空,则表示当前系统不支持资源的热修复,直接return,不进行修复
public class AssertionArrayException extends Exception {
    private static final long serialVersionUID = 1;
    private List<AssertionException> mAssertionErr;

    public AssertionArrayException(String str) {
        super(str);
        this.mAssertionErr = new ArrayList();
    }

    public void addException(AssertionException hackAssertionException) {
        this.mAssertionErr.add(hackAssertionException);
    }

    public void addException(List<AssertionException> list) {
        this.mAssertionErr.addAll(list);
    }

    public List<AssertionException> 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<android.content.res.AssetManager> AssetManager;
    public static HackedMethod0<android.content.res.AssetManager, Void, HackPlus.Unchecked, HackPlus.Unchecked, HackPlus.Unchecked> AssetManager_construct;
    public static HackPlus.HackedMethod1<Integer, android.content.res.AssetManager, HackPlus.Unchecked, HackPlus.Unchecked, HackPlus.Unchecked, String> AssetManager_addAssetPath;
    public static HackedMethod0<Void, android.content.res.AssetManager, HackPlus.Unchecked, HackPlus.Unchecked, HackPlus.Unchecked> AssetManager_ensureStringBlocks;


    //>=19
    public static HackedClass<Object> ResourcesManager;
    public static HackedMethod0<Object, Void, HackPlus.Unchecked, HackPlus.Unchecked, HackPlus.Unchecked> ResourcesManager_getInstance;
    public static HackedField<Object, ArrayMap> ResourcesManager_mActiveResources;
    //>=24
    public static HackedField<Object, ArrayList> ResourcesManager_mResourceReferences;

    //<19
    public static HackedClass<Object> ActivityThread;
    public static HackedMethod0<Void, Void, HackPlus.Unchecked, HackPlus.Unchecked, HackPlus.Unchecked> ActivityThread_currentActivityThread;
    public static HackedField<Object, HashMap> ActivityThread_mActiveResources;


    //>=24
    public static HackedField<Resources, Object> Resources_ResourcesImpl;
    public static HackedField<Object, Object> ResourcesImpl_mAssets;
    //<24
    public static HackedField<Resources, Object> Resources_mAssets;


    public static boolean sIsIgnoreFailure;
    public static boolean sIsReflectAvailable;
    public static boolean sIsReflectChecked;


    public static boolean defineAndVerify() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
            return false;
        }
        if (sIsReflectChecked) {
            return sIsReflectAvailable;
        }
        long startHack = System.currentTimeMillis();
        try {
            initAssertion();
            hackResources();
            if (exceptionArray != null) {
                Logger.e(TAG, "Hack error:" + AndroidHack.exceptionArray);
                sIsReflectAvailable = false;
                return sIsReflectAvailable;
            }
            sIsReflectAvailable = true;
            return sIsReflectAvailable;
        } catch (Throwable e) {
            sIsReflectAvailable = false;
            Logger.d(TAG, e);
        } finally {
            sIsReflectChecked = true;
            long stopHack = System.currentTimeMillis();
            Logger.e(TAG, "Hack spend time: " + (stopHack - startHack) + " ms");
        }
        return sIsReflectAvailable;
    }


    private static void initAssertion() {
        HackPlus.setAssertionFailureHandler(new AssertionFailureHandler() {
            @Override
            public void onAssertionFailure(final AssertionException failure) {
                if (!sIsIgnoreFailure) {
                    if (exceptionArray == null) {
                        exceptionArray = new AssertionArrayException("Hack assert failed");
                    }
                    exceptionArray.addException(failure);
                }
            }
        });
    }


    private static void hackResources() {
        //Hack AssetManager
        AssetManager = HackPlus.into(AssetManager.class);
        AssetManager_construct = AssetManager.constructor().withoutParams();
        AssetManager_addAssetPath = AssetManager.method("addAssetPath").returning(int.class).withParam(String.class);
        AssetManager_ensureStringBlocks = AssetManager.method("ensureStringBlocks").withoutParams();

         //大于19时,开始有ResourcesManager这个类,通过这个类去替换内存中的AssetManager对象
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            ResourcesManager = HackPlus.into("android.app.ResourcesManager");
            ResourcesManager_getInstance = ResourcesManager.staticMethod("getInstance").returning(ResourcesManager.getClazz()).withoutParams();
            //Android N的时候,将Resources对象移到了mResourceReferences中
            if (Build.VERSION.SDK_INT >= 24) {
                // N moved the resources to mResourceReferences
                ResourcesManager_mResourceReferences = ResourcesManager.field("mResourceReferences").ofType(ArrayList.class);
            } else {
                 //Android N之前的版本,Resources随心则在mActiveResources对象中
                // Pre-N
                ResourcesManager_mActiveResources = ResourcesManager.field("mActiveResources").ofType(ArrayMap.class);
            }
        } else {
            //在Andorid 19之前,没有ResourcesManager对象,通过ActivityThread去操作,但是通过ActivityThread操作有个坑,在早期的版本中,ActivityThread是保存在ThreadLocal对象中的,如果你要在子线程中去拿,就会出问题,所以这里也需要Hack一下。
            ActivityThread = HackPlus.into("android.app.ActivityThread");
            ActivityThread_currentActivityThread = ActivityThread.staticMethod("currentActivityThread").withoutParams();
            ActivityThread_mActiveResources = ActivityThread.field("mActiveResources").ofType(HashMap.class);
        }
         //在Android N中,AssetManager对象从Resources对象中的mAssets成员变量转移到了mResourcesImpl成员变量中mAssets成员 变量
        if (Build.VERSION.SDK_INT >= 24) {
            // N moved the mAssets inside an mResourcesImpl field
            Resources_ResourcesImpl = HackPlus.into(Resources.class).field("mResourcesImpl");
            ResourcesImpl_mAssets = HackPlus.into(Resources_ResourcesImpl.getType()).field("mAssets");
        } else {
            // Pre-N
            Resources_mAssets = HackPlus.into(Resources.class).field("mAssets");
        }
    }


    private static Object _sActivityThread;

    static class ActivityThreadGetter implements Runnable {
        ActivityThreadGetter() {
        }

        public void run() {
            try {
                _sActivityThread = AndroidHack.ActivityThread_currentActivityThread.invoke().statically();
            } catch (Exception e) {
                e.printStackTrace();
            }
            synchronized (AndroidHack.ActivityThread_currentActivityThread) {
                AndroidHack.ActivityThread_currentActivityThread.notify();
            }
        }
    }
    //获取ActivityThread的Hack方式,通过判断是否是主线程,如果不是主线程,在阻塞当前线程,切换到主线程去拿
    public static Object getActivityThread() throws Exception {
        if (_sActivityThread == null) {
            if (Thread.currentThread().getId() == Looper.getMainLooper().getThread().getId()) {
                _sActivityThread = AndroidHack.ActivityThread_currentActivityThread.invoke().statically();
            } else {
                // In older versions of Android (prior to frameworks/base 66a017b63461a22842)
                // the currentActivityThread was built on thread locals, so we‘ll need to try
                // even harder
                Handler handler = new Handler(Looper.getMainLooper());
                synchronized (AndroidHack.ActivityThread_currentActivityThread) {
                    handler.post(new ActivityThreadGetter());
                    AndroidHack.ActivityThread_currentActivityThread.wait();
                }
            }
        }
        return _sActivityThread;
    }
}

使用的时候,只要在加载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<WeakReference<Resources>> 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<WeakReference<Resources>>) 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<?, WeakReference<Resources>> arrayMap = AndroidHack.ResourcesManager_mActiveResources.on(resourcesManager).get();
                references = arrayMap.values();
            } else {
                //Android 19以下,通过ActivityThread获取得到的是一个HashMap对象,通过其values方法获得对象赋值给references
                Object activityThread = AndroidHack.getActivityThread();
                @SuppressWarnings("unchecked")
                HashMap<?, WeakReference<Resources>> map = (HashMap<?, WeakReference<Resources>>) AndroidHack.ActivityThread_mActiveResources.on(activityThread).get();
                references = map.values();
            }
            //遍历获取到的Ressources对象的弱引用,将其AssetManager对象替换为我们的patch的AssetManager
            for (WeakReference<Resources> 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的资源从哪里来,这里主要讲两种方式。

  • 直接下发整个apk文件,全量的资源,想怎么用就怎么用,当然缺点很明显,文件太大了,下载容易出错,不过也最简单。
  • 下发patch部分的资源,在客户端和没改变的资源合成新的apk,这种方式的优点是文件小,缺点是合成时占内存,需要开启多进程去合成,比较复杂,没有办法校验合成文件的md5值。

无论哪一种方式,都需要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<Element> 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("<?xml version=\"1.0\" encoding=\"utf-8\"?>")
            idsFile.append("\n")
            idsFile.append("<resources>")
            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("    <item type=\"id\" name=\"${attrName}\" />\n")
                    project.logger.error "write id item ${attrName}"
                }
            }
            idsFile.append("</resources>")

        }
    }
}
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<Element> publicIds = valuesRoot.selectNodes("item[@type=‘id‘]")


                if (publicIds != null && publicIds.size() != 0) {
                    Set<String> existIdItems = new HashSet<String>();
                    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<Element> oldElements = oldIdsRoot.elements();

                    if (oldElements != null && oldElements.size() != 0) {
                        File newIdsFile = new File(mergeResourcesTask.outputDir, "values/${IDS_XML}")
                        newIdsFile.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>")
                        newIdsFile.append("\n")
                        newIdsFile.append("<resources>")
                        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("</resources>")
                    }
                }


            } 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文件,相关算法如下:

  • 对比new.apk和old.apk中的所有资源相关的文件。
  • 对于新增资源文件,则直接压入patch.apk中。
  • 对于删除的资源文件,则不处理到patch.apk中。
  • 对于改变的资源文件,如果是assets或者res目录中的资源,则直接压缩到patch.apk中,如果是arsc文件,则使用bsdiff算法计算其差量文件,压入patch.apk,文件名不变。
  • 对于改变和新增的文件,通过一个meta文件去记录其原始文件的adler32和合成后预期文件的adler32值,以及文件名,这是个文本文件,直接压缩到patch.apk中去。
  • 对patch.apk进行签名。

这样做的好处是能将资源patch文件尽可能的减小到最低,实际情况严重下来,res目录下的资源文件大小都非常小,没有必要去进行diff,所以直接使用原文件,而arsc文件则相对比较大,在考虑文件大小和内存的两个因素下,牺牲内存换大小还是ok的,所以在下发前,我们对其进行diff,生成diff文件,在客户端进行合成最终的arsc文件。

客户端下载到patch.apk后需要进行还原,还原的步骤如下:

  • 考虑到客户端jni的兼容性问题,bspatch算法全部使用java实现
  • 首先校验patch.apk的签名
  • 读取压缩包中meta文件,判断哪些文件是新增文件,哪些文件是改变的文件。
  • 遍历patch.apk中的文件,如果是新增文件,则压缩到new.apk文件中去
  • 如果是改变的文件,如果是assets和res文件夹下的资源,则直接压缩到new.apk文件中,如果是arsc文件,则应用bspatch算法合成最终的arsc文件,压缩到new.apk中
  • 如果文件没有改变,则直接复制old.apk中的原始文件到new.apk中
  • 以上任何一个步骤都会去校验合成时旧文件的adler32和合成后的adler32值和meta文件中记录的是否符合
  • 由于无法验证合成后的文件的md5值(没有记录哪些文件被删除了,加上压缩算法等原因),需要使用一种方式在加载前进行验证,这里使用crc32值。
  • 合成成功后计算new.apk文件的crc32值,计算方式进行改进,不计算所有文件内容的crc32,为了快速计算,只计算文件的某一个特定段的crc32值,比如文件从200字节开始到2000字节部分的crc32值,并保存在sharePrefrences中,加载patch前进行校验crc32,校验不通过,则直接删除patch文件,当然这种计算方式有一定概率会把错误的文件当成正确的,毕竟计算的不是完整的文件,当然正确的文件是一定不会当成错误的,这种低概率事件可以接受。

这种方式的兼容性如何?简单自测了下,4.0-7.0的模拟器运行全部通过,当然不排除国产奇葩ROM的兼容性,所以这里我不宣称100%兼容。

无图言屌,没图你说个jb,先上一张没有进行热修复的图:

技术分享

热修复之后的效果图

技术分享

最后送上一句话:

热修复远远没有你想象的那么简单,踩坑之路漫漫,入坑需谨慎。





以上是关于Android 热补丁技术——资源的热修复的主要内容,如果未能解决你的问题,请参考以下文章

Sophix及热修复原理介绍

Android热修复技术专题:来自微信淘宝支付宝QQ空间的热修复方案

Android 热修复方案Tinker 系列文章

热修复干货| AndFix热补丁动态修复框架使用教程

android中的热修复

android中的热更新