Android 热修复方案Tinker(四) 资源补丁加载

基于Tinker V1.7.5

想要做资源的更新首先需要了解分析资源加载流程,这样才能找到突破口.一般我们在应用中使用和加载资源都是通过Context对象的getResources方法.这里以Android 6.0的源码分析资源加载的流程.

  • Context中访问资源其实是是通过子类ContextImpl对象来实现的.在getResources和getAssets时是返回跟Resources类的对象mResources有关,而该对象是在ContextImpl构造的时候通过LoadedApk的方法和ActivityThread进行初始化.

    class ContextImpl extends Context {
    
        ...
        private final ResourcesManager mResourcesManager;
        private final Resources mResources;
    
    
        @Override
        public AssetManager getAssets() {
            return getResources().getAssets();
        }
    
        @Override
        public Resources getResources() {
            return mResources;
        }
    
        private ContextImpl(ContextImpl container, ActivityThread mainThread,
                LoadedApk packageInfo, IBinder activityToken, UserHandle user, boolean restricted,
                Display display, Configuration overrideConfiguration, int createDisplayWithId) {
            ...
    
            Resources resources = packageInfo.getResources(mainThread);
            if (resources != null) {
                if (displayId != Display.DEFAULT_DISPLAY
                        || overrideConfiguration != null
                        || (compatInfo != null && compatInfo.applicationScale
                                != resources.getCompatibilityInfo().applicationScale)) {
                    resources = mResourcesManager.getTopLevelResources(packageInfo.getResDir(),
                            packageInfo.getSplitResDirs(), packageInfo.getOverlayDirs(),
                            packageInfo.getApplicationInfo().sharedLibraryFiles, displayId,
                            overrideConfiguration, compatInfo);
                }
            }
            mResources = resources;
    
            ...
        }
    
        ...
    }
    
  • LoadedApk中只是通过外部聚合的ActivityThread来获取Resources.ActivityThead是整个App主线程的管理者,也是整个App的入口.这里的mResDir属性需要注意一下,他是要加载资源文件的路径.后面加载资源补丁的时候要hook属性为补丁的路径.

    public final class LoadedApk {
    
        ...
        private final String mResDir;
    
        Resources mResources;
    
        public AssetManager getAssets(ActivityThread mainThread) {
            return getResources(mainThread).getAssets();
        }
    
        public Resources getResources(ActivityThread mainThread) {
            if (mResources == null) {
                mResources = mainThread.getTopLevelResources(mResDir, mSplitResDirs, mOverlayDirs,
                        mApplicationInfo.sharedLibraryFiles, Display.DEFAULT_DISPLAY, null, this);
            }
            return mResources;
        }
        ...
    }
    
  • ActivityThread中维护有两个ArrayMap,分别是mPackages和mResourcePackages,这两个属性中存储的都是App加载过的LoadedApk对象.上面提到的hook LoadedApk对象的mResDir属性就要从这两个ArrayMap入手.而在ActivityThread中的资源加载只是通过ResourcesManager的方法来获取Resources.ResourcesManager是在ActivityThread初始化的时候通过单例初始化出来的.但是在Android 4.4版本之下是没有用ResourcesManager来管理资源的.做修复时也要区分开两种实现方式,分别做hook.

    public final class ActivityThread {
    
        ...
    
        final ArrayMap<String, WeakReference<LoadedApk>> mPackages
            = new ArrayMap<String, WeakReference<LoadedApk>>();
    	final ArrayMap<String, WeakReference<LoadedApk>> mResourcePackages
    
        /**
         * Creates the top level resources for the given package.
         */
        Resources getTopLevelResources(String resDir, String[] splitResDirs, String[] overlayDirs,
                String[] libDirs, int displayId, Configuration overrideConfiguration,
                LoadedApk pkgInfo) {
            return mResourcesManager.getTopLevelResources(resDir, splitResDirs, overlayDirs, libDirs,
                    displayId, overrideConfiguration, pkgInfo.getCompatibilityInfo());
        }
        ...
    
    }
    
  • 在单例类ResourcesManager中持有着资源Resources引用的ArrayMap**(在Android 4.4之下该对象为ActivityThread中持有的HashMap)**.从上面的时序图可以看出读取资源的核心操作就是在ResourcesManager中,所以我们详细分析一下方法getTopLevelResources的详细实现.

    public class ResourcesManager {
    
        ...
        private final ArrayMap<ResourcesKey, WeakReference<Resources> > mActiveResources =
                new ArrayMap<>();
    
        Resources getTopLevelResources(String resDir, String[] splitResDirs,
                String[] overlayDirs, String[] libDirs, int displayId,
                Configuration overrideConfiguration, CompatibilityInfo compatInfo) {
                ...
                return r;
            }
        }
        ...
    }
    

    由于要确保每个apk的资源访问对应一个Resources对象,这里通过ResourcesKey来绑定唯一标识. ResourcesKey则通过apk的路径,设备的配置和兼容信息构造出来的.当从两个不同的apk文件中加载资源时会产生出两个ResourcesKey对应两个Resources.

    final float scale = compatInfo.applicationScale;
    Configuration overrideConfigCopy = (overrideConfiguration != null)
            ? new Configuration(overrideConfiguration) : null;
    ResourcesKey key = new ResourcesKey(resDir, displayId, overrideConfigCopy, scale);
    

    拿到资源对应的key之后,直接去持有的资源容器mActiveResources中查询Resources是否已经存在,并且要确定Resources中持有的AssetManager是否已经从文件系统中拿到最新的文件状态.如果一切就绪则讲已经持有的Resources直接return出去.由于访问mActiveResources对象时会发生线程并发的问题,所以这部分逻辑是有加锁的.

    Resources r;
    synchronized (this) {
        // Resources is app scale dependent.
        if (DEBUG) Slog.w(TAG, "getTopLevelResources: " + resDir + " / " + scale);
    
        WeakReference<Resources> wr = mActiveResources.get(key);
        r = wr != null ? wr.get() : null;
        //if (r != null) Log.i(TAG, "isUpToDate " + resDir + ": " + r.getAssets().isUpToDate());
        if (r != null && r.getAssets().isUpToDate()) {
            if (DEBUG) Slog.w(TAG, "Returning cached resources " + r + " " + resDir
                    + ": appScale=" + r.getCompatibilityInfo().applicationScale
                    + " key=" + key + " overrideConfig=" + overrideConfiguration);
            return r;
        }
    }
    

    mActiveResources对象中没有当前要加载apk的Resources引用时,则需要重新创建一个Resources对象出来.在这之前要先创建出新的AssetManager对象,并将其初始化完毕.从源码中可以看出AssetManager通常使用addAssetPath将指定的路径或者文件中的资源加载进来.这是一个关键入口,我们要从别处加载资源补丁时就需要hook该方法.同时此处也会利用Android的资源覆盖机制,从/vector/overlay下寻找同名apk文件加载资源.

    ...
    AssetManager assets = new AssetManager();
    
    if (resDir != null) {
        if (assets.addAssetPath(resDir) == 0) {
            return null;
        }
    }
    ...
    

    接下来是继续创建构造Resources对象所需的屏幕信息和相关配置信息,这部分跟资源补丁的加载没有关联,这里就不细说了.拿到这些信息之后再加上上面创建出来的AssetManager对象一起构造出Resources对象.

    DisplayMetrics dm = getDisplayMetricsLocked(displayId);
    Configuration config;
    final boolean isDefaultDisplay = (displayId == Display.DEFAULT_DISPLAY);
    final boolean hasOverrideConfig = key.hasOverrideConfiguration();
    if (!isDefaultDisplay || hasOverrideConfig) {
        config = new Configuration(getConfiguration());
        if (!isDefaultDisplay) {
            applyNonDefaultDisplayMetricsToConfigurationLocked(dm, config);
        }
        if (hasOverrideConfig) {
            config.updateFrom(key.mOverrideConfiguration);
            if (DEBUG) Slog.v(TAG, "Applied overrideConfig=" + key.mOverrideConfiguration);
        }
    } else {
        config = getConfiguration();
    }
    r = new Resources(assets, dm, config, compatInfo);
    

    在构建Resources对象的时候需要注意一下,AssetManager是直接赋值的,以后hook的时候可以直接替换,而updateConfiguration方法和assets.ensureStringBlocks调用在替换过AssetManager之后要再重新反射调用这两个方法,一个方法用法来确保Resources对象数据的更新, 一个方法加锁创建出一个字符串资源池维护资源索引.

    public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config,
            CompatibilityInfo compatInfo) {
        mAssets = assets;
        mMetrics.setToDefaults();
        if (compatInfo != null) {
            mCompatibilityInfo = compatInfo;
        }
        updateConfiguration(config, metrics);
        assets.ensureStringBlocks();
    }
    

    虽然Resources对象已经创建出来了,但是由于整个流程并没有加锁,所以有可能当程序运行到这里的时候,可能其他线程已经创建出过Resources对象并已经将他存入到mActiveResources对象中了,所以在存储Resources对象之前要再加锁并重新验证mActiveResources对象是否已经持有有效的Resources对象了,如果还是没有持有的话就将上面创建出来的Resources对象真正存入ArrayMap中,并return出来.

    synchronized (this) {
        WeakReference<Resources> wr = mActiveResources.get(key);
        Resources existing = wr != null ? wr.get() : null;
        if (existing != null && existing.getAssets().isUpToDate()) {
            // Someone else already created the resources while we were
            // unlocked; go ahead and use theirs.
            r.getAssets().close();
            return existing;
        }
    
        // XXX need to remove entries when weak references go away
        mActiveResources.put(key, new WeakReference<>(r));
        if (DEBUG) Slog.v(TAG, "mActiveResources.size()=" + mActiveResources.size());
        return r;
    }
    

至此资源加载的流程就走了一圈, 结合要做资源更新的需求,我们可以整理一下资源替换的步骤.

  1. 反射拿到ActivityThread对象持有的LoadedApk容器
  2. 遍历容器中LoadedApk对象,反射替换mResDir属性为补丁物理路径.
  3. 创建新的AssetManager, 并根据补丁路径反射调用addAssetPath将补丁加载到新的AssetManager中.
  4. 反射获得ResourcesManager持有的Resources容器对象.
  5. 遍历出容器中的Resources对象, 替换对象的属性为新的AssetManager, 并且根据原属性重新更新Resources对象的配置.

初步校验补丁

回到Tinker, 在Android 热修复方案Tinker(二) 补丁加载流程中的第12个步骤中,加载资源补丁之前需要先快速校验一次.首先从SecurityCheck中拿到补丁包中res_meta.txt的信息,将meta中第一行的数据读取到PatchInfo中用来快速校验.

String meta = securityCheck.getMetaContentMap().get(RESOURCE_META_FILE);
//not found resource
if (meta == null) {
    return true;
}
//only parse first line for faster
ShareResPatchInfo.parseResPatchInfoFirstLine(meta, resPatchInfo);

校验PatchInfo中asrc文件的MD5本身是否为空或者长度是否合法.

if (resPatchInfo.resArscMd5 == null) {
    return true;
}
if (!ShareResPatchInfo.checkResPatchInfo(resPatchInfo)) {
    intentResult.putExtra(ShareIntentUtil.INTENT_PATCH_PACKAGE_PATCH_CHECK, ShareConstants.ERROR_PACKAGE_CHECK_RESOURCE_META_CORRUPTED);
    ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_PACKAGE_CHECK_FAIL);
    return false;
}

校验合成资源补丁的路径和文件是否存在

String resourcePath = directory + "/" + RESOURCE_PATH + "/";

File resourceDir = new File(resourcePath);

if (!resourceDir.exists() || !resourceDir.isDirectory()) {
    ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_RESOURCE_DIRECTORY_NOT_EXIST);
    return false;
}

File resourceFile = new File(resourcePath + RESOURCE_FILE);
if (!resourceFile.exists()) {
    ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_RESOURCE_FILE_NOT_EXIST);
    return false;
}

最后就是校验当前的环境是否可以做资源更新,并且为补丁的加载做预热.如果不能支持目前的修复方案就直接return.在开始的时候已经分析过资源更新的流程,下面就按着分析过的套路做一次校验.

首先拿到ActivityThread类.再拿到LoadedApk类,而LoadedApk是从Android 2.3开始才有的,该类是对之前系统ActivityThread的内部类PackageInfo重新封装而成的.所以这里要分开处理.

Class<?> activityThread = Class.forName("android.app.ActivityThread");
// API version 8 has PackageInfo, 10 has LoadedApk. 9, I don't know.
Class<?> loadedApkClass;
try {
    loadedApkClass = Class.forName("android.app.LoadedApk");
} catch (ClassNotFoundException e) {
    loadedApkClass = Class.forName("android.app.ActivityThread$PackageInfo");
}

根据上文环境拿到ActivityThread和LoadedApk中修复资源相关的三个属性,并且将mResDir,mPackages和mResourcePackages三个属性设置可访问,并存起来供加载补丁时使用.

Field mApplication = loadedApkClass.getDeclaredField("mApplication");
mApplication.setAccessible(true);
resDir = loadedApkClass.getDeclaredField("mResDir");
resDir.setAccessible(true);
packagesFiled = activityThread.getDeclaredField("mPackages");
packagesFiled.setAccessible(true);

resourcePackagesFiled = activityThread.getDeclaredField("mResourcePackages");
resourcePackagesFiled.setAccessible(true);

接下来创建出一个AssetManager的实例.需要注意的是一些ROM修改了类名,像Baidu的ROM改成了BaiduAssetManager,要对这些ROM做一下兼容.再拿到AssetManager的两个关键方法设置访问权限并保存起来.

AssetManager assets = context.getAssets();
// Baidu os
if (assets.getClass().getName().equals("android.content.res.BaiduAssetManager")) {
    Class baiduAssetManager = Class.forName("android.content.res.BaiduAssetManager");
    newAssetManager = (AssetManager) baiduAssetManager.getConstructor().newInstance();
} else {
    newAssetManager = AssetManager.class.getConstructor().newInstance();
}

addAssetPathMethod = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
addAssetPathMethod.setAccessible(true);

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

由于加载补丁要替换的Resources对象在KITKAT之下是以HashMap的类型作为ActivityThread类的属性.其余的系统版本都是以ArrayMap被ResourcesManager持有的.所以要按照系统区分开.

  • Android SDK >= 19

    先将调用单例类ResourcesManagergetInstance方法,拿到ResourcesManager对象.在Android SDK >= 24时ResourcesManager持有的Resources容器属性名是mResourceReferences而Android SDK在(24, 19]之间时属性名是mActiveResources要再区分开两种实现.最终拿到所有Resources对象的引用.

    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);
        ArrayMap<?, WeakReference<Resources>> arrayMap =
            (ArrayMap<?, WeakReference<Resources>>) fMActiveResources.get(resourcesManager);
        references = arrayMap.values();
    } catch (NoSuchFieldException ignore) {
        // N moved the resources to mResourceReferences
        Field mResourceReferences = resourcesManagerClass.getDeclaredField("mResourceReferences");
        mResourceReferences.setAccessible(true);
        //noinspection unchecked
        references = (Collection<WeakReference<Resources>>) mResourceReferences.get(resourcesManager);
    }
    
  • Android SDK < 19

    在该Android SDK区间内Resources对象集合的引用是在ActivityThread对象的mActiveResources对象持有的.反射拿到之后也将所有的Resources引用保存起来.

    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();
    

如果Resources集合为空则直接抛个异常上去,结束整个加载的流程.集合不为空的话则将Resources对象中持有AssetManager对象引用的属性hook出来,以供加载补丁时进行替换.这里还有一个系统差异就是从Android N开始, AssetManager不是被Resources对象直接持有, 而是变成了 Resources -> ResourcesImpl -> AssetManager这样的间接持有, 需要将这两种情况区分出来.

if (references == null || references.isEmpty()) {
    throw new IllegalStateException("resource references is null or empty");
}
try {
    assetsFiled = Resources.class.getDeclaredField("mAssets");
    assetsFiled.setAccessible(true);
} catch (Throwable ignore) {
    // N moved the mAssets inside an mResourcesImpl field
    resourcesImplFiled = Resources.class.getDeclaredField("mResourcesImpl");
    resourcesImplFiled.setAccessible(true);
}

做完上述的操作之后,如果在过程中没有主动或被动抛异常出来就说明当前的系统环境是可以做资源的更新,并且将更新资源要用到的Field和Method保存起来方便加载补丁时的使用.

加载资源补丁

经过上述一系列的校验之后可以确保系统环境是否支持修复资源以及资源加载的预热. 接下来就是加载资源了.在加载之前先过滤当前补丁是否包含资源更新,并拼装资源补丁的路径, 并统计资源加载的耗时.

if (resPatchInfo == null || resPatchInfo.resArscMd5 == null) {
    return true;
}
String resourceString = directory + "/" + RESOURCE_PATH +  "/" + RESOURCE_FILE;
File resourceFile = new File(resourceString);
long start = System.currentTimeMillis();

如果开启了补丁合法性校验, 则校验补丁文件的MD5.同Dex补丁的校验流程, 这里就不细讲了.

if (tinkerLoadVerifyFlag) {
    if (!SharePatchFileUtil.checkResourceArscMd5(resourceFile, resPatchInfo.resArscMd5)) {
        Log.e(TAG, "Failed to load resource file, path: " + resourceFile.getPath() + ", expect md5: " + resPatchInfo.resArscMd5);
        ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_RESOURCE_MD5_MISMATCH);
        return false;
    }
    Log.i(TAG, "verify resource file:" + resourceFile.getPath() + " md5, use time: " + (System.currentTimeMillis() - start));
}

接下来就是真正的资源替换了.首先拿到当前主线程的ActivityThread对象.在根据ActivityThread对象遍历两个持有LoadedApk容器的对象.再遍历容器中的每一个LoadedApk对象 并将mResDir属性的值替换成资源补丁包的路径.

Class<?> activityThread = Class.forName("android.app.ActivityThread");
Object currentActivityThread = getActivityThread(context, activityThread);

for (Field field : new Field[]{packagesFiled, resourcePackagesFiled}) {
    Object value = field.get(currentActivityThread);

    for (Map.Entry<String, WeakReference<?>> entry
        : ((Map<String, WeakReference<?>>) value).entrySet()) {
        Object loadedApk = entry.getValue().get();
        if (loadedApk == null) {
            continue;
        }
        if (externalResourceFile != null) {
            resDir.set(loadedApk, externalResourceFile);
        }
    }
}

接下来将补丁包中的资源通过addAssetPath方法load进在校验步骤中新构建的AssetManager中.上面分析源码资源加载流程中在构造Resources的时候Resources会调用一次ensureStringBlocks确保资源的字符串索引创建出来.所以我们在创建出新的AssetManager之后, 主动调用一次该方法.

if (((Integer) addAssetPathMethod.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.
ensureStringBlocksMethod.invoke(newAssetManager);

最后遍历Resources容器, 将每个Resources对象中引用的AssetManager替换成加载过补丁资源的新的AssetManager对象.注意从Android N开始 Resources和AssetManager之间变成了间接引用Resources -> ResourcesImpl -> AssetManager, 要从ResourcesImpl对象中替换AssetManager对象.替换过AssetManager对象之后再重新反射调用updateConfiguration,将Resources对象的配置信息更新到最新状态.由于前面校验阶段的预热,到这里补丁加载的流程很快就分析结束了.

for (WeakReference<Resources> wr : references) {
    Resources resources = wr.get();
    //pre-N
    if (resources != null) {
        // Set the AssetManager of the Resources instance to our brand new one
        try {
            assetsFiled.set(resources, newAssetManager);
        } catch (Throwable ignore) {
            // N
            Object resourceImpl = resourcesImplFiled.get(resources);
            // for Huawei HwResourcesImpl
            Field implAssets = ShareReflectUtil.findField(resourceImpl, "mAssets");
            implAssets.setAccessible(true);
            implAssets.set(resourceImpl, newAssetManager);
        }

        resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
    }
}

补丁生效校验及卸载

如果在资源补丁加载步骤发生错误或者抛出异常则需要卸载当前补丁,这里的卸载补丁其实是卸载上一篇文章中加载的dex补丁.原因是怕dex补丁中的修改跟资源补丁有关联, 如果资源补丁加载失败之后又不卸载dex补丁就可能会造成找不到关联的资源从而导致App崩溃.卸载的过程在上篇文章已经讲过了这里就不复述了.

如果在资源加载的流程中没有发生问题, 怎么校验补丁是否已经生效呢? 跟Dex补丁的生效校验类似,这里也是在补丁中埋一个测试文件, 加载完资源补丁之后如何打开该测试文件失败则说明补丁加载失败.加载失败之后直接抛个异常出去走Dex补丁卸载.

private static boolean checkResUpdate(Context context) {
    try {
        Log.e(TAG, "checkResUpdate success, found test resource assets file " + TEST_ASSETS_VALUE);
        // eg only_use_to_test_tinker_resource.txt
        context.getAssets().open(TEST_ASSETS_VALUE);
    } catch (Throwable e) {
        Log.e(TAG, "checkResUpdate failed, can't find test resource assets file " + TEST_ASSETS_VALUE + " e:" + e.getMessage());
        return false;
    }
    return true;
}

转载请注明出处:http://blog.csdn.net/l2show/article/details/53454933

评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值