热修复原理学习(6)资源热修复技术

1. 普遍的实现方式

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

目前市面上很多资源热修复方案基本上都参考了Instant Run的实现。关于Instant Run如何进行资源替换的,请看这篇热修复原理学习 第3.2节。

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

  1. 构造一个新的AssetManager,并通过反射调用 addAssetPath(),把这个完整的新资源包加入到AssetManager中。这样就得到了一个含有所有新资源的AssetManager
  2. 找到之前引用原有AssetManager的地方,通过反射,将AssetManager类型的mAssets字段引用全部替换为新创建的 AssetManager。

这其中的重点,自然是 AssetManager.addAssetPath()这个函数,Java层的AssetManager只是个包装,所以这个方法真正的实现,是位于Native层中。
执行addAssetPath就是解析这个资源格式,然后构造出底层数据的过程。整个解析资源的调用链是:

  • public final int addAssetPath(String path)
  • 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;
   
    Vector<Header*> mHeaders; //表示所有resources.arsc原始数据,这就等同于所有通过addAssetPath加载进来的路径的资源ID信息
    Vector<PackageGroup*> mPackageGroups  //资源包的实体,包含所有加载进来的package id所对应的资源
    uint8_t mPackageMap[256]; //索引表,表示0~255的package id,每个元组分别存放该ID所属PackageGroup在mPackageGroups中的index

    uint8_t mNextPackageId; 
}

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

2. 资源文件的格式

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

struct ResChunk_header
{
    uint16_t type;
    uint16_t headerSize;
    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结构体,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的id就是1,drawable就是2,以此类推。
  • 对于entry id,每个entry表示一个资源项,资源项是按照排列的先后顺序自动被标记编号的。也就是说,一个type里按位置出现的第一个资源项其entry id为0,依次类推。因此我们因此我们是无法直接指定entry id的,只能够根据排布顺序决定。资源项之间是紧密排布的,没有空隙,但是可以指定资源项为 ResTable_type::NO_ENTRY来填入一个空资源。

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

spc resource 0x7f040019 com.rikkathworld.hotfix:layout/activity_main flags=0x00000000

表示 activity_main这个资源的编号是 0x7f040019,其中package id是 0x7f,资源类型ID是0x04(即layout类型),而0x04类型的第0x0019个资源项就是activity_main这个资源。

3. 运行时资源的解析

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

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

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

// ResourcesManager.java
    protected @Nullable AssetManager createAssetManager(@NonNull final ResourcesKey key) {
        AssetManager assets = new AssetManager();
        
        if (key.mResDir != null) {   // 1
            if (assets.addAssetPath(key.mResDir) == 0) {  // 2
                Log.e(TAG, "failed to add asset path " + key.mResDir);
                return null;
            }
        }
        ....
        return assets;
    }

注释1:if语句 mResDir,指的就是安装包APK。
注释2:用新建的AssetManager调用 addAssetPath()去解析这个APK下的资源

因此这个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中。

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

而在Android4.4及以下的版本,addAssetPath只是把补丁包的路径添加到了 mAssetPath中,而真正解析的资源包的逻辑是在App第一次执行AssetManager::getResTbale()的时候

//Android4.4_r1 frameworks/base/libs/androidfw/AssetManager.cpp
const ResTable* AssetManager::getResTable(bool required) const
{
    // mResources已经存在,直接返回不再往下走
    //如果已经执行过一次完整的该方法,以后都会直接return,所以补丁包来了也解析不了。
    ResTable* rt = mResources;  
    if (rt) {
        return rt;
    }
    ....
    const size_t N = mAssetPaths.size();
    for (size_t i=0; i<N; i++) {
        //真正解析package的地方
        ....
    }
    return rt;
}

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

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

4. 另辟蹊径的资源修复方案

一个好的资源修复方案是怎样的呢?
首先,补丁包要足够的小,像直接下发完整的补丁包肯定是不行的,很占用空间

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

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

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

真的这么简单?

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

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

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

用下图来说明补丁包的情况:
热修复原理学习(6)资源热修复技术_第1张图片
图中绿线表示新增资源,红线表示内容发生修改的资源,黑线表示内容没有变化,但是ID发生改变的资源,×表示删除的资源。

4.1 新增的资源及其导致的ID偏移

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

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

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

imageView.setImageResource(R.drawable.holo_light);

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

imageView.setImageResource(0x7f020002);

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

imageView.setImageResource(0x7f020003);

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

imageView.setImageResource(0x7f020002);

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

4.2 内容发生改变的资源

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

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

setContentView(R.layout.activity_main);
//实际上就是下面的
setContentView(0x7f030000);

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

setContentView(0x66020000);

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

4.3 删除了的资源

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

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

4.4 对于type的影响

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

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

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

5. 更优雅地替换AssetManager

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

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

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

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

// AssetManager.java
.....
    private native final void destroy();
.....

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

static void android_content_AssetManager_destroy(JNIEnv* env, jobject clazz)
{
    AssetManager* am = (AssetManager*) (env->GetIntField(clazz, gAssetManagerOffsets.mObject));

    if (am != null) {
       delete am;
       env->SetIntField(clazz, gAssetManagerOffsets, 0);
    }
}

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

AssetManager::~AssetManager(void)
{
    int count = android_atomic_dec(&gCount);
    
    delete mConfig;
    delete mResources;
   
    delete[] mLocal;
    delete[] mVendor;
}

native层的AssetManager析构函数会析构它的所有成员,这样就会释放之前的加载了的资源。
而现在,Java层的AssetManager已经成为了空客,我们就可以调用它的init方法,对它重新进行初始化了!

public final class AssetManager {
   ...
    private native final void init(boolean isSystem);
}

它同样是一个native方法:

static void android_content_AssetManager_init(JNIEnv* env, jobject clazz)
{
    AssetManager* am = new AssetManager();
    if(am == NULL) {
       ...
    }
    
    am->addDefaultAssets();

    env->SetIntField(clazz, gAssetManagerOffsets.mObject, (jint)am);
}

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

//Android4.4 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;
}

所以我们要想办法在加载资源前,调用当前进程的AssetManager进行destroy,然后添加补丁包路径后,再调用AssetManager的init方法,这个方案的实现代码如下:

    ....
Method initMeth = assetManagerMethod("init");  //通过反射拿到 init方法
Method destroyMeth = assetManagerMethod("destroy");  //通过反射拿到destroy方法
Method addAssetPathMeth = assetManagerMethod("addAssetPath", String.class);  //通过反射拿到addAssetPath
destroyMeth.invoke(am);   // 析构 AssetManager
initMeth.invoke(am);    // 重新构造 AssetManager
assetManagerField("mStringBlocks").set(am, null);  // 通过反射拿到mStringBlocks字段并置空
for (String path : loadedPaths) {     // 重新添加原有 AssetManager 中加载过的资源路径
    addAssetPathMeth.invoke(am, path);
}
addAssetPathMeth.invoke(am, patchPath);  // 添加 patch 资源路径
assetManagerMethod("ensureStringBlocks").invoke(am);  // 重新对 mStringBlocks 赋值

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

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

顺带一提,类似Instant Run的完整替换资源的方案,在替换AssetMIanager的这一步,也可以采用Sophix的方式进行替换。

你可能感兴趣的:(Android热修复)