Android热修复升级探索——资源更新之新思路

前言

Android资源的热修复,就是在app不重新安装的情况下,利用下发的补丁包直接更新本app中的资源

我们在开发阿里云移动热修复(Sophix)的过程中,对Android资源的加载原理做了深入的探究,最终在资源修复方法上取得了突破性进展!新的资源修复方法不论是在使用便捷性、补丁包大小以及运行时效率方面,相比其他实现都有巨大的优势。

普遍的实现方式

目前市面上的很多资源热修复方案基本上都是参考了Instant Run的实现。

首先,我们简单来看一下Instant Run是怎么做到资源热修复的。

Instant Run资源热修复的核心代码就是这个monkeyPatchExistingResources方法:

@com/android/tools/fd/runtime/MonkeyPatcher.java

public static void monkeyPatchExistingResources(@Nullable Context context,
                                                    @Nullable String externalResourceFile,
                                                    @Nullable Collection<Activity> activities) {

    if (externalResourceFile == null) {
        return;
    }

    try {
        // %% Part 1. 创建一个新的AssetManager,并通过反射调用addAssetPath添加/sdcard上的新资源包.
        //         这样就构造出了一个带新资源的AssetManager
        // Create a new AssetManager instance and point it to the resources installed under
        // /sdcard
        AssetManager newAssetManager = AssetManager.class.getConstructor().newInstance();
        Method mAddAssetPath = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
        mAddAssetPath.setAccessible(true);
        if (((Integer) mAddAssetPath.invoke(newAssetManager, externalResourceFile)) == 0) {
            throw new IllegalStateException("Could not create new AssetManager");
        }

        // Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm
        // in L, so we do it unconditionally.
        Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod("ensureStringBlocks");
        mEnsureStringBlocks.setAccessible(true);
        mEnsureStringBlocks.invoke(newAssetManager);

        // %% Part 2. 反射得到Activity中AssetManager的引用处,全部换成刚才新构建的newAssetManager
        if (activities != null) {
            for (Activity activity : activities) {
                Resources resources = activity.getResources();

                try {
                    Field mAssets = Resources.class.getDeclaredField("mAssets");
                    mAssets.setAccessible(true);
                    mAssets.set(resources, newAssetManager);
                } catch (Throwable ignore) {
                    Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
                    mResourcesImpl.setAccessible(true);
                    Object resourceImpl = mResourcesImpl.get(resources);
                    Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
                    implAssets.setAccessible(true);
                    implAssets.set(resourceImpl, newAssetManager);
                }
                    ... ...

                pruneResourceCaches(resources);
            }
        }

        // %% Part 3. 得到Resources的弱引用集合,把他们的AssetManager成员替换成newAssetManager
        // Iterate over all known Resources objects
        Collection<WeakReference<Resources>> references;
        if (SDK_INT >= KITKAT) {
            // Find the singleton instance of ResourcesManager
            Class<?> resourcesManagerClass = Class.forName("android.app.ResourcesManager");
            Method mGetInstance = resourcesManagerClass.getDeclaredMethod("getInstance");
            mGetInstance.setAccessible(true);
            Object resourcesManager = mGetInstance.invoke(null);
            try {
                Field fMActiveResources = resourcesManagerClass.getDeclaredField("mActiveResources");
                fMActiveResources.setAccessible(true);
                @SuppressWarnings("unchecked")
                ArrayMap<?, WeakReference<Resources>> arrayMap =
                        (ArrayMap<?, WeakReference<Resources>>) fMActiveResources.get(resourcesManager);
                references = arrayMap.values();
            } catch (NoSuchFieldException ignore) {
                Field mResourceReferences = resourcesManagerClass.getDeclaredField("mResourceReferences");
                mResourceReferences.setAccessible(true);
                //noinspection unchecked
                references = (Collection<WeakReference<Resources>>) mResourceReferences.get(resourcesManager);
            }
        } else {
            Class<?> activityThread = Class.forName("android.app.ActivityThread");
            Field fMActiveResources = activityThread.getDeclaredField("mActiveResources");
            fMActiveResources.setAccessible(true);
            Object thread = getActivityThread(context, activityThread);
            @SuppressWarnings("unchecked")
            HashMap<?, WeakReference<Resources>> map =
                    (HashMap<?, WeakReference<Resources>>) fMActiveResources.get(thread);
            references = map.values();
        }
        for (WeakReference<Resources> wr : references) {
            Resources resources = wr.get();
            if (resources != null) {
                // Set the AssetManager of the Resources instance to our brand new one
                try {
                    Field mAssets = Resources.class.getDeclaredField("mAssets");
                    mAssets.setAccessible(true);
                    mAssets.set(resources, newAssetManager);
                } catch (Throwable ignore) {
                    Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
                    mResourcesImpl.setAccessible(true);
                    Object resourceImpl = mResourcesImpl.get(resources);
                    Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
                    implAssets.setAccessible(true);
                    implAssets.set(resourceImpl, newAssetManager);
                }

                resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
            }
        }
    } catch (Throwable e) {
        throw new IllegalStateException(e);
    }}

简要说来,Instant Run中的资源热修复分为两步,

1、构造一个新的AssetManager,并通过反射调用addAssetPath,把这个完整的新资源包加入到AssetManager中。这样就得到了一个含有所有新资源的AssetManager。

2、找到所有之前引用到原有AssetManager的地方,通过反射,把引用处替换为AssetManager。


其实仔细看可以发现,大量代码都是在处理兼容性问题和找到所有AssetManager的引用处。真正的实现逻辑其实很简单。

这其中的重点,自然是addAssetPath这个函数。现在我们来看一下它的底层实现逻辑。

以Android6.0为例,addAssetPath最终调用到了native方法。

@frameworks/base/core/java/android/content/res/AssetManager.java

     /**
      * Add an additional set of assets to the asset manager.  This can be
      * either a directory or ZIP file.  Not for use by applications.  Returns
      * the cookie of the added asset, or 0 on failure.
      * {@hide}
      */
     public final int addAssetPath(String path) {
        synchronized (this) {
            int res = addAssetPathNative(path);
            makeStringBlocks(mStringBlocks);
            return res;
        }
     }

     ... ...
    
    private native final int addAssetPathNative(String path);

Java层的AssetManager只是个包装,真正关于资源处理的所有逻辑,其实都位于native层由C++实现的AssetManager。

执行addAssetPath就是解析这个格式,然后构造出底层数据结构的过程。整个解析资源的调用链是:

public final int addAssetPath(String path)

=jni=> android_content_AssetManager_addAssetPath

=> AssetManager::addAssetPath => AssetManager::appendPathToResTable => ResTable::add => ResTable::addInternal => ResTable::parsePackage

解析的细节比较繁琐,就不细细说明了,有兴趣的可以一层层追下去。

大致过程就是,通过传入的资源包路径,先得到其中的resources.arsc,然后解析它的格式,存放在底层的AssetManager的mResources成员中。

@frameworks/base/include/androidfw/AssetManager.h
class AssetManager : public AAssetManager {

    ... ...

    mutable ResTable* mResources;
    
    ... ...

AssetManager的mResources成员是一个ResTable结构体:

class ResTable
{
    mutable Mutex               mLock;
    // 互斥锁,用于多进程间互斥操作。

    status_t                    mError;

    ResTable_config             mParams;

    // Array of all resource tables.
    Vector<Header*>             mHeaders; 
    // 表示所有resources.arsc原始数据,这就等同于所有通过addAssetPath加载进来的路径的资源id信息。

    // Array of packages in all resource tables.
    Vector<PackageGroup*>       mPackageGroups;
    // 资源包的实体,包含所有加载进来的package id所对应的资源。

    // Mapping from resource package IDs to indices into the internal
    // package array.
    uint8_t                     mPackageMap[256]; 
    // 索引表,表示0~255的package id,每个元组分别存放 该id所属PackageGroup 在mPackageGroups中的index

    uint8_t                     mNextPackageId;
};

一个Android进程只包含一个ResTable,ResTable的成员变量mPackageGroups就是所有解析过的资源包的集合。任何一个资源包中都含有resources.arsc,它记录了所有资源的id分配情况以及资源中的所有字符串。这些信息是以二进制方式存储的。底层的AssetManager做的事就是解析这个文件,然后把相关信息存储到mPackageGroups里面。

资源文件的格式

整个resources.arsc文件,实际上是由一个个ResChunk(以下简称chunk)拼接起来的。从文件头开始,每个chunk的头部都是一个ResChunk_header结构,它指示了这个chunk的大小和数据类型。

/**
 * Header that appears at the front of every data chunk in a resource.
 */
struct ResChunk_header
{
    // Type identifier for this chunk.  The meaning of this value depends
    // on the containing chunk.
    uint16_t type;

    // Size of the chunk header (in bytes).  Adding this value to
    // the address of the chunk allows you to find its associated data
    // (if any).
    uint16_t headerSize;

    // Total size of this chunk (in bytes).  This is the chunkSize plus
    // the size of any data associated with the chunk.  Adding this value
    // to the chunk allows you to completely skip its contents (including
    // any child chunks).  If this value is the same as chunkSize, there is
    // no data associated with the chunk.
    uint32_t size;
};

通过ResChunk_header中的type成员,可以知道这个chunk是什么类型,从而就可以知道应该如何解析这个chunk。

解析完一个chunk后,从这个chunk + size的位置开始,就可以得到下一个chunk起始位置,这样就可以依次读取完整个文件的数据内容。

一般来说,一个resources.arsc里面包含若干个package,不过默认情况下,由打包工具aapt打出来的包只有一个package。这个package里包含了app中的所有资源信息。

资源信息主要是指每个资源的名称以及它对应的编号。我们知道,Android中的每个资源,都有它唯一的编号。

编号是一个32位数字,用十六进制来表示就是0xPPTTEEEE。PP为package id,TT为type id,EEEE为entry id。

它们代表什么?在resources.arsc里是以怎样的方式记录的呢?

  • 对于package id,每个package对应的是类型为RES_TABLE_PACKAGE_TYPE的ResTable_package结构体,ResTable_package结构体的id成员变量就表示它的package id。
  • 对于type id,每个type对应的是类型为RES_TABLE_TYPE_SPEC_TYPE的ResTable_typeSpec结构体。它的id成员变量就是type id。但是,该type id具体对应什么类型,是需要到package chunk里的Type String Pool中去解析得到的。比如Type String Pool中依次有attr、drawable、mipmap、layout字符串。就表示attr类型的type id为1, drawable类型的type id为2,mipmap类型的type id为3,layout类型的type id为4。所以,每个type id对应了Type String Pool里的字符顺序所指定的类型。
  • 对于entry id,每个entry表示一个资源项,资源项是按照排列的先后顺序自动被标机编号的。也就是说,一个type里按位置出现的第一个资源项,其entry id为0x0000,第二个为0x0001,以此类推。因此我们是无法直接指定entry id的,只能够根据排布顺序决定。资源项之间是紧密排布的,没有空隙,但是可以指定资源项为ResTable_type::NO_ENTRY来填入一个空资源。

举个例子,我们随便找个带资源的apk,用aapt解析一下,看到其中的一行是:

$ aapt d resources app-debug.apk

... ...
      spec resource 0x7f040019 com.taobao.patch.demo:layout/activity_main: flags=0x00000000
... ...

这就表示,activity_main.xml这个资源的编号是0x7f040019。它的package id是0x7f,资源类型的id为0x04,Type String Pool里的第四个字符串正是layout类型,而0x04类型的第0x0019个资源项就是activity_main这个资源。

运行时资源的解析

默认由Android SDK编出来的apk,是由aapt工具进行打包的,其资源包的package id就是0x7f。

系统的资源包,也就是framework-res.jar,package id为0x01。

在走到app的第一行代码之前,系统就已经帮我们构造好一个已经添加了安装包资源的AssetManager了。

@frameworks/base/core/java/android/app/ResourcesManager.java

    Resources getTopLevelResources(String resDir, String[] splitResDirs,
            String[] overlayDirs, String[] libDirs, int displayId,
            Configuration overrideConfiguration, CompatibilityInfo compatInfo) {
        ... ...

        AssetManager assets = new AssetManager();
        // resDir就是安装包apk
        if (resDir != null) {        
            if (assets.addAssetPath(resDir) == 0) {
                return null;
            }
        }

        ... ...

因此,这个AssetManager里就已经包含了系统资源包以及app的安装包,就是package id为0x01的framework-res.jar中的资源和package id为0x7f的app安装包资源。

如果此时直接在原有AssetManager上继续addAssetPath的完整补丁包的话,由于补丁包里面的package id也是0x7f,就会使得同一个package id的包被加载两次。这会有怎样的问题呢?

在Android L之后,这是没问题的,他会默默地把后来的包添加到之前的包的同一个PackageGroup下面。

而在解析的时候,会与之前的包比较同一个type id所对应的类型,如果该类型下的资源项数目和之前添加过的不一致,会打出一条warning log,但是仍旧加入到该类型的TypeList中。


status_t ResTable::parsePackage(const ResTable_package* const pkg,
                                const Header* const header)
    ... ...
                TypeList& typeList = group->types.editItemAt(typeIndex);
                if (!typeList.isEmpty()) {
                    const Type* existingType = typeList[0];
                    if (existingType->entryCount != newEntryCount && idmapIndex < 0) {
                        ALOGW("ResTable_typeSpec entry count inconsistent: given %d, previously %d",
                                (int) newEntryCount, (int) existingType->entryCount);
                        // We should normally abort here, but some legacy apps declare
                        // resources in the 'android' package (old bug in AAPT).
                    }
                }
                
                Type* t = new Type(header, package, newEntryCount);
                t->typeSpec = typeSpec;
                t->typeSpecFlags = (const uint32_t*)(
                        ((const uint8_t*)typeSpec) + dtohs(typeSpec->header.headerSize));
                if (idmapIndex >= 0) {
                    t->idmapEntries = idmapEntries[idmapIndex];
                }
                typeList.add(t);
   ... ...

但是在get这个资源的时候呢?

status_t ResTable::getEntry(
        const PackageGroup* packageGroup, int typeIndex, int entryIndex,
        const ResTable_config* config,
        Entry* outEntry) const
{
    const TypeList& typeList = packageGroup->types[typeIndex];
    ... ...

    // %% 从第一个type开始遍历,也就是说会先取得安装包的资源,然后才是补丁包的。
    // Iterate over the Types of each package.
    const size_t typeCount = typeList.size();
    for (size_t i = 0; i < typeCount; i++) {
        const Type* const typeSpec = typeList[i];

        int realEntryIndex = entryIndex;
        int realTypeIndex = typeIndex;
        bool currentTypeIsOverlay = false;

        if (static_cast<size_t>(realEntryIndex) >= typeSpec->entryCount) {
            ALOGW("For resource 0x%08x, entry index(%d) is beyond type entryCount(%d)",
                    Res_MAKEID(packageGroup->id - 1, typeIndex, entryIndex),
                    entryIndex, static_cast<int>(typeSpec->entryCount));
            // We should normally abort here, but some legacy apps declare
            // resources in the 'android' package (old bug in AAPT).
            continue;
        }

        const size_t numConfigs = typeSpec->configs.size();
        for (size_t c = 0; c < numConfigs; c++) {
            ... ...

            if (bestType != NULL) {
                // Check if this one is less specific than the last found.  If so,
                // we will skip it.  We check starting with things we most care
                // about to those we least care about.
                if (!thisConfig.isBetterThan(bestConfig, config)) {
                    if (!currentTypeIsOverlay || thisConfig.compare(bestConfig) != 0) {
                        continue;
                    }
                }
            }

            bestType = thisType;
            bestOffset = thisOffset;
            bestConfig = thisConfig;
            bestPackage = typeSpec->package;
            actualTypeIndex = realTypeIndex;

            // If no config was specified, any type will do, so skip
            if (config == NULL) {
                break;
            }
        }
    }
}

在获取某个Type的资源时,会从前往后遍历,也就是说先得到原有安装包里的资源,除非后面的资源的config比前面的更详细才会发生覆盖。而对于同一个config而言,补丁中的资源就永远无法生效了。所以在Android L以上的版本,在原有AssetManager上加入补丁包,是没有任何作用的,补丁中的资源无法生效。

而在Android KK及以下版本,addAssetPath只是把补丁包的路径添加到了mAssetPath中,而真正解析的资源包的逻辑是在app第一次执行AssetManager::getResTable的时候。

@android-4.4.4_r2/frameworks/base/libs/androidfw/AssetManager.cpp

const ResTable* AssetManager::getResTable(bool required) const
{
    // %% mResources已存在,直接返回,不再往下走。
    ResTable* rt = mResources;
    if (rt) {
        return rt;
    }

    // Iterate through all asset packages, collecting resources from each.

    AutoMutex _l(mLock);

    if (mResources != NULL) {
        return mResources;
    }

    if (required) {
        LOG_FATAL_IF(mAssetPaths.size() == 0, "No assets added to AssetManager");
    }

    if (mCacheMode != CACHE_OFF && !mCacheValid)
        const_cast<AssetManager*>(this)->loadFileNameCacheLocked();

    const size_t N = mAssetPaths.size();
    for (size_t i=0; i<N; i++) {
        // ... %% 真正解析package的地方 ...
    }

    if (required && !rt) ALOGW("Unable to find resources file resources.arsc");
    if (!rt) {
        mResources = rt = new ResTable();
    }
    return rt;
}

而在执行到加载补丁代码的时候,getResTable已经执行过了无数次了。这是因为就算我们之前没做过任何资源相关操作,Android framework里的代码也会多次调用到那里。所以,以后即使是addAssetPath,也只是添加到了mAssetPath,并不会发生解析。所以补丁包里面的资源是完全不生效的!

所以,像Instant Run这种方案,一定需要一个全新的AssetManager时,然后再加入完整的新资源包,替换掉原有的AssetManager。

另辟蹊径

而一个好的资源热修复方案是怎样的呢?

首先,补丁包要足够小,像直接下发完整的补丁包肯定是不行的,很占用空间。

而像有些方案,是先进行bsdiff,对资源包做差量,然后下发差量包,在运行时合成完整包再加载。这样确实减小了包的体积,但是却在运行时多了合成的操作,耗费了运行时间和内存。合成后的包也是完整的包,仍旧会占用磁盘空间。

而如果不采用类似Instant Run的方案,市面上许多实现,是自己修改aapt,在打包时将补丁包资源进行重新编号。这样就会涉及到修改Android SDK工具包,即不利于集成也无法很好地对将来的aapt版本进行升级。

针对以上几个问题,一个好的资源热修复方案,既要保证补丁包足够小,不在运行时占用很多资源,又要不侵入打包流程。我们提出了一个目前市面上未曾实现的方案。


简单来说,我们构造了一个package id为0x66的资源包,这个包里只包含改变了的资源项,然后直接在原有AssetManager中addAssetPath这个包。然后,就可以了。

真的这么简单?

没错!由于补丁包的package id为0x66,不与目前已经加载的0x7f冲突,因此直接加入到已有的AssetManager中就可以直接使用了。补丁包里面的资源,只包含原有包里面没有而新的包里面的新增资源,以及原有内容发生了改变的资源。

而资源的改变包含增加、减少、修改这三种情况,我们分别是如何处理的呢?

  1. 对于新增资源,直接加入补丁包,然后新代码里直接引用就可以了,没什么好说的。
  2. 对于减少资源,我们只要不使用它就行了,因此不用考虑这种情况,它也不影响补丁包。
  3. 对于修改资源,比如替换了一张图片之类的情况。我们把它视为新增资源,在打入补丁的时候,代码在引用处也会做相应修改,也就是直接把原来使用旧资源id的地方变为新id。

用一张图来说明补丁包的情况,是这样的:

Android热修复升级探索——资源更新之新思路

图中绿线表示新增资源。红线表示内容发生修改的资源。黑线表示内容没有变化,但是id发生改变的资源。×表示删除了的资源。

新增的资源及其导致id偏移

可以看到,新的资源包与旧资源包相比,新增了holo_grey和dropdn_item2资源,新增的资源被加入到patch中。并分配了0x66开头的资源id。

而新增的两个资源导致了在它们所属的type中跟在它们之后的资源id发生了位移。比如holo_light,id由0x7f020002变为0x7f020003,而abc_dialog由0x7f030004变为0x7f030003。新资源插入的位置是随机的,这与每次aapt打包时解析xml的顺序有关。发生位移的资源不会加入patch,但是在patch的代码中会调整id的引用处。

比如说在代码里,我们是这么写的

    imageView.setImageResource(R.drawable.holo_light);

这个R.drawable.holo_light是一个int值,它的值是aapt指定的,对于开发者透明,即使点进去,也会直接跳到对应res/drawable/holo_light.png,无法查看。不过可以用反编译工具,看到它的真实值是0x7f020002。所以这行代码其实等价于:

    imageView.setImageResource(0x7f020002);

而当打出了一个新包后,对开发者而言,holo_light的图片内容没变,代码引用处也没变。但是新包里面,同样是这句话,由于新资源的插入导致的id改变,对于R.drawable.holo_light的引用已经变成了:

    imageView.setImageResource(0x7f020003);

但实际上这种情况并不属于资源改变,更不属于代码的改变,所以我们在对比新旧代码之前,会把新包里面的这行代码修正回原来的id。

    imageView.setImageResource(0x7f020002);

然后再进行后续代码的对比。这样后续代码对比时就不会检测到发生了改变。

内容发生改变的资源

而对于内容发生改变的资源(类型为layout的activity_main,这可能是我们修改了activity_main.xml的文件内容。还有类型为string的no,可能是我们修改了这个字符串的值),它们都会被加入到patch中,并重新编号为新id。

而相应的代码,也会发生改变,比如,

    setContentView(R.layout.activity_main);

实际上也就是

    setContentView(0x7f030000);

在生成对比新旧代码之前,我们会把新包里面的这行代码变为

    setContentView(0x66020000);

这样,在进行代码对比时,会使得这行代码所在函数被检测到发生了改变。于是相应的代码修复会在运行时发生,这样就引用到了正确的新内容资源。

删除了的资源

对于删除的资源,不会影响补丁包。

这很好理解,既然资源被删除了,就说明新的代码中也不会用到它,那资源放在那里没人用,就相当于不存在了。

对于type的影响

可以看到,由于type0x01的所有资源项都没有变化,所以整个type0x01资源都没有加入到patch中。这也使得后面的type的id都往前移了一位。因此Type String Pool中的字符串也要进行修正,这样才能使得0x01的type指向drawable,而不是原来的attr。


所以我们可以看到,所谓简单,指的是运行时应用patch变的简单了。

而真正复杂的地方在于构造patch。我们需要把新旧两个资源包解开,分别解析其中的resources.arsc文件,对比新旧的不同,并将它们重新打成带有新package id的新资源包。这里补丁包指定的package id只要不是0x7f和0x01就行,可以是任意0x7f以下的数字,我们默认把它指定为0x66。

构造这样的补丁资源包,需要对整个resources.arsc的结构十分了解,要对二进制形式的一个一个chunk进行解析分类,然后再把补丁信息一个一个重新组装成二进制的chunk。这里面很多工作与aapt做的类似,实际上开发打包工具的时候也是参考了很多aapt和系统加载资源的代码。

更优雅的替换方式

对于Android L以后的版本,直接在原有AssetManager上应用patch就行了。并且由于用的是原来的AssetManager,所以原先大量的反射修改替换操作就完全不需要了,大大提高了加载补丁的效率。

但之前提到过,在Android KK和以下版本,addAssetPath是不会加载资源的,必须重新构造一个新的AssetManager并加入patch,再换掉原来的。那么我们不就又要和Instant Run一样,做一大堆兼容版本和反射替换的工作了吗?

对于这种情况,我们也找到了更优雅的方式,不需要再如此地大费周章。

在AssetManager的源码里面,有一个有趣的东西。

@frameworks/base/core/java/android/content/res/AssetManager.java
public final class AssetManager {
    ... ...

    private native final void destroy();
    
    ... ...

明显,这个是用来销毁AssetManager并释放资源的函数,我们来看看它具体做了什么吧。

static void android_content_AssetManager_destroy(JNIEnv* env, jobject clazz)
{
    AssetManager* am = (AssetManager*)
        (env->GetIntField(clazz, gAssetManagerOffsets.mObject));
    ALOGV("Destroying AssetManager %p for Java object %p\n", am, clazz);
    if (am != NULL) {
        delete am;
        env->SetIntField(clazz, gAssetManagerOffsets.mObject, 0);
    }
}

可以看到,首先,它析构了native层的AssetManager,然后把java层的AssetManager对native层的AssetManager的引用设为空。

AssetManager::~AssetManager(void)
{
    int count = android_atomic_dec(&gCount);
    //ALOGI("Destroying AssetManager in %p #%d\n", this, count);

    delete mConfig;
    delete mResources;

    // don't have a String class yet, so make sure we clean up
    delete[] mLocale;
    delete[] mVendor;
}

native层的AssetManager析构函数会析构它的所有成员,这样就会释放之前加载了的资源。

而现在,java层的AssetManager已经成为了空壳。我们就可以调用它的init方法,对它重新进行初始化了!

@frameworks/base/core/java/android/content/res/AssetManager.java
public final class AssetManager {
    ... ...

    private native final void init();
    
    ... ...

这同样是个native方法,

static void android_content_AssetManager_init(JNIEnv* env, jobject clazz)
{
    AssetManager* am = new AssetManager();
    if (am == NULL) {
        jniThrowException(env, "java/lang/OutOfMemoryError", "");
        return;
    }

    am->addDefaultAssets();

    ALOGV("Created AssetManager %p for Java object %p\n", am, clazz);
    env->SetIntField(clazz, gAssetManagerOffsets.mObject, (jint)am);
}

这样,在执行init的时候,会在native层创建一个没有添加过资源,并且mResources没有初始化的的AssetManager。然后我们再对它进行addAssetPath,之后由于mResource没有初始化过,就可以正常走到解析mResources的逻辑,加载所有此时add进去的资源了!

@android-4.4.4_r2/frameworks/base/libs/androidfw/AssetManager.cpp

const ResTable* AssetManager::getResTable(bool required) const
{
    ResTable* rt = mResources;
    // %% mResources没有初始化过,为空,因此不会return。
    if (rt) {
        return rt;
    }

     ... ...

    // %% 这时就会走到这里,进行所有add进去的path的加载。
    const size_t N = mAssetPaths.size();
    for (size_t i=0; i<N; i++) {
        // ... 解析package ...
    }

     ... ...

    return rt;
}

这个方案的实现代码如下:

        ... ...
    
        Method initMeth = assetManagerMethod("init");
        Method destroyMeth = assetManagerMethod("destroy");
        Method addAssetPathMeth = assetManagerMethod("addAssetPath", String.class);

        // %% 析构AssetManager
        destroyMeth.invoke(am);
        
        // %% 重新构造AssetManager
        initMeth.invoke(am);
        
        // %% 置空mStringBlocks
        assetManagerField("mStringBlocks").set(am, null);

        // %% 重新添加原有AssetManager中加载过的资源路径
        for (String path : loadedPaths) {
            LogTool.d(TAG, "pexyResources" + path);
            addAssetPathMeth.invoke(am, path);
        }
        
        // %% 添加patch资源路径
        addAssetPathMeth.invoke(am, patchPath);
        
        // %% 重新对mStringBlocks赋值
        assetManagerMethod("ensureStringBlocks").invoke(am);

    }
        
    private Method assetManagerMethod(String name, Class<?>... parameterTypes) {
       try {
           Method meth = Class.forName("android.content.res.AssetManager")
                               .getDeclaredMethod(name, parameterTypes);
           meth.setAccessible(true);
           return meth;
       } catch (Exception e) {
           LogTool.e(TAG, "assetManagerMethod", e);
           return null;
       }
    }
    
    private Field assetManagerField(String name) {
        try {
            Field field = mAssetManagerClass.getDeclaredField(name);
            field.setAccessible(true);
            return field;
        } catch (Exception e) {
            LogTool.e(TAG, "assetManagerField", e);
            return null;
        }
    }

这里需要注意的地方是mStringBlocks。它记录了之前加载过的所有资源包的String Pool,因此很多时候访问字符串是通过它来找到的。如果不进行重新构造,在后面使用到它时就会导致崩溃。

由于我们是直接对原有的AssetManager进行析构和重构,所有原先对AssetManager对象的引用是没有发生改变的,这样,就不需要像Instant Run那样进行繁琐的修改了。

顺带一提,类似Instant Run的完整替换资源的方案,在替换AssetManager这一步,也可以采用我们这种方式进行替换,省时省力又省心。

总结

总结一下,相比于目前市面上的资源修复方式,我们提出的资源修复的优势在于:

  1. 不侵入打包,直接对比新旧资源即可产生补丁资源包。(对比修改aapt方式的实现)
  2. 不必下发完整包,补丁包中只包含有变动的资源。(对比Instanat Run、Amigo等方式的实现)
  3. 不需要在运行时合成完整包。不占用运行时计算和内存资源。(对比Tinker的实现)

唯一有个需要注意的地方就是,因为对新的资源的引用是在新代码中,所有资源修复是需要代码修复的支持的。也因此所有资源修复方案必然是附带代码修复的。而之前提到过,本方案在进行代码修复前,会对资源引用处进行修正。而修正就是需要找到旧的资源id,换成新的id。查找旧id时是直接对int值进行替换,所以会找到0x7f??????这样的需要替换id。但是,如果有开发者使用到了0x7f??????这样的数字,而它并非资源id,可是却和需要替换的id数值相同,这就会导致这个数字被错误地替换。

但这种情况是极为罕见的,因为很少会有人用到这样特殊的数字,并且还需要碰巧这数字和资源id相等才行。即使出现,开发者也可以用拼接的方式绕过这类数字的产生。所以基本可以不用担心这种情况,只是需要注意它的存在。


这套资源修复方案目前已经完全集成进阿里云移动热修复(Sophix),值得一提的是,结合Sophix提供的代码热替换机制,资源也可以做到补丁下发即时生效,无需重启APP!如果对代码热替换的技术的实现细节有兴趣,可以看这篇文章,其中实现了兼容性极好的Java方法的Native热替换。

另外,不同于阿里Hotfix1.X版本笨拙的命令行操作,新的补丁工具实现了图形界面,使用起来更加方便快捷。

最后,展示一下这个工具的界面。轻松一键,即可完美生成补丁。

Android热修复升级探索——资源更新之新思路

原创文章,转载请注明出处。手淘公众号文章链接:https://mp.weixin.qq.com/s/7f81xxRjqHu3Nu9xDrqShw

上一篇:PHP文件上传源码分析(RFC1867)


下一篇:SolarCity不再只是租赁 发布针对电力公司和系统运营商的新软件