tinker熱修復——資源補丁載入過程

鋸齒流沙發表於2017-12-26

通過上一篇文章《tinker熱修復——dex補丁載入過程》,基本上我們已經熟悉了tinker載入dex的過程,tinker除了能夠新增類,還可以新增資源和so庫等,那麼tinker是如何做到熱更資源的呢?資源補丁的載入過程又是怎麼樣的呢?

我們都知道,tinker打補丁包的時候,只會打diff的補丁包,也就說補丁包中包括資源的diff,而生成資源的diff的時候,會把變化的索引寫在補丁包的assets/res_meta.txt中,當補丁下發到app後,會將所有的資源整合起來生成一個resources.apk(該資源包,包含新增的資源和原來的資源),在載入資源補丁時候就是載入這個包含所有資源的包,然後將LoadApk中的資源路徑替換成補丁的路徑,並將Resources容器中的屬性都替換成新的載入補丁的AssertManager。那麼接下來我們看看tinker是如何一步步載入資源補丁的。

《tinker熱修復——dex補丁載入過程》文章中知道,最終呼叫的載入過程都是在TinkerLoader的tryLoadPatchFilesInternal方法中的。那麼我們重點關注該方法,我們注意到經過對補丁包的一層層的安全校驗,檢查資源補丁的時候,會呼叫TinkerResourceLoader的checkComplete方法。

 /**
     * resource file exist?
     * fast check, only check whether exist
     *
     * @param directory
     * @return boolean
     */
    public static boolean checkComplete(Context context, String directory, ShareSecurityCheck securityCheck, Intent intentResult) {
        //拿到補丁包中res_meta.txt的資訊
        String meta = securityCheck.getMetaContentMap().get(RESOURCE_META_FILE);
        //not found resource
        if (meta == null) {
            return true;
        }
        /**
         * only parse first line for faster
         * 將meta中第一行的資料讀取到resPatchInfo中用來快速校驗.
         */
        ShareResPatchInfo.parseResPatchInfoFirstLine(meta, resPatchInfo);

        if (resPatchInfo.resArscMd5 == null) {
            return true;
        }
        //checkResPatchInfo:檢查asrc檔案的MD5本身是否為空或者長度是否合法
        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 (!SharePatchFileUtil.isLegalFile(resourceFile)) {
            ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_RESOURCE_FILE_NOT_EXIST);
            return false;
        }
        try {
            TinkerResourcePatcher.isResourceCanPatch(context);
        } catch (Throwable e) {
            Log.e(TAG, "resource hook check failed.", e);
            intentResult.putExtra(ShareIntentUtil.INTENT_PATCH_EXCEPTION, e);
            ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_RESOURCE_LOAD_EXCEPTION);
            return false;
        }
        return true;
    }
複製程式碼

該方法首先會讀取res_meta.txt的資訊,並把meta中的第一行的資料讀取到resPatchInfo中,做一個快速檢驗,然後檢查檔案的md5是否有效,以及檢查資源補丁的檔案是否有效,最後呼叫TinkerResourcePatcher的isResourceCanPatch方法。

/**
     * 獲取資源更新時需要的method和Field
     * 並且儲存起來,方便補丁載入時使用
     * @param context
     * @throws Throwable
     */
    public static void isResourceCanPatch(Context context) throws Throwable {
        //   - Replace mResDir to point to the external resource file instead of the .apk. This is
        //     used as the asset path for new Resources objects.
        //   - Set Application#mLoadedApk to the found LoadedApk instance

        /**
         *
         * Find the ActivityThread instance for the current thread
         * 獲取當前執行緒的ActivityThread物件
         */
        Class<?> activityThread = Class.forName("android.app.ActivityThread");
        currentActivityThread = ShareReflectUtil.getActivityThread(context, activityThread);

        /**
         *
         * API version 8 has PackageInfo, 10 has LoadedApk. 9, I don't know.
         * LoadedApk是從Android 2.3開始才有的
         * 該類是對之前系統ActivityThread的內部類PackageInfo重新封裝而成的.所以這裡要分開處理.
         */
        Class<?> loadedApkClass;
        try {
            loadedApkClass = Class.forName("android.app.LoadedApk");
        } catch (ClassNotFoundException e) {
            loadedApkClass = Class.forName("android.app.ActivityThread$PackageInfo");
        }

        //獲取loadedApkClass的mResDir屬性,設定成可訪問
        resDir = loadedApkClass.getDeclaredField("mResDir");
        resDir.setAccessible(true);
        //獲取activityThread的mPackages屬性,設定成可訪問
        packagesFiled = activityThread.getDeclaredField("mPackages");
        packagesFiled.setAccessible(true);
        //獲取activityThread的mResourcePackages屬性,設定可訪問
        resourcePackagesFiled = activityThread.getDeclaredField("mResourcePackages");
        resourcePackagesFiled.setAccessible(true);

        /**
         *
         * Create a new AssetManager instance and point it to the resources
         * 建立一個AssetManager例項
         * 一些ROM修改了類名,如Baidu的ROM改成了android.content.res.BaiduAssetManager
         * 所以要做相容
         */
        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();
        }

        //獲取AssetManager的addAssetPath方法,設定成可訪問
        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.
         * 在Kitkat需要呼叫AssetManager的ensureStringBlocks方法
         * 在Lollipop則不需要呼叫
         * 但是不用區分系統,照常呼叫,不會造成任何問題
         */
        ensureStringBlocksMethod = AssetManager.class.getDeclaredMethod("ensureStringBlocks");
        ensureStringBlocksMethod.setAccessible(true);
        /**
         * 載入補丁要替換的Resources物件在KITKAT以下是以HashMap的型別作為ActivityThread類的屬性.
         * 其餘的系統版本都是以ArrayMap被ResourcesManager持有的.
         * 需要做系統區分
         */
        // Iterate over all known Resources objects
        //獲取所有的Resources物件的引用
        if (SDK_INT >= KITKAT) {
            //pre-N
            // Find the singleton instance of ResourcesManager
            Class<?> resourcesManagerClass = Class.forName("android.app.ResourcesManager");
            Method mGetInstance = resourcesManagerClass.getDeclaredMethod("getInstance");
            mGetInstance.setAccessible(true);
            //呼叫getInstance方法,拿到ResourcesManager物件
            Object resourcesManager = mGetInstance.invoke(null);
            try {
                //獲取ResourcesManager的mActiveResources物件,設定可訪問
                Field fMActiveResources = resourcesManagerClass.getDeclaredField("mActiveResources");
                fMActiveResources.setAccessible(true);
                //獲取得到所有Resources物件的引用
                ArrayMap<?, WeakReference<Resources>> activeResources19 =
                    (ArrayMap<?, WeakReference<Resources>>) fMActiveResources.get(resourcesManager);
                references = activeResources19.values();
            } catch (NoSuchFieldException ignore) {
                // N moved the resources to mResourceReferences
                Field mResourceReferences = resourcesManagerClass.getDeclaredField("mResourceReferences");
                mResourceReferences.setAccessible(true);
                references = (Collection<WeakReference<Resources>>) mResourceReferences.get(resourcesManager);
            }
        } else {
            Field fMActiveResources = activityThread.getDeclaredField("mActiveResources");
            fMActiveResources.setAccessible(true);
            HashMap<?, WeakReference<Resources>> activeResources7 =
                (HashMap<?, WeakReference<Resources>>) fMActiveResources.get(currentActivityThread);
            references = activeResources7.values();
        }
        // check resource,校驗是否為null
        if (references == null) {
            throw new IllegalStateException("resource references is null");
        }
        // fix jianGuo pro has private field 'mAssets' with Resource
        // try use mResourcesImpl first
        //將Resources物件中持有AssetManager物件引用的屬性mAssets,hook出來
        //mAssets是載入補丁時進行替換的
        if (SDK_INT >= 24) {
            //Android N的路徑變成了Resources -> ResourcesImpl -> AssetManager
            try {
                // N moved the mAssets inside an mResourcesImpl field
                resourcesImplFiled = Resources.class.getDeclaredField("mResourcesImpl");
                resourcesImplFiled.setAccessible(true);
            } catch (Throwable ignore) {
                // for safety
                assetsFiled = Resources.class.getDeclaredField("mAssets");
                assetsFiled.setAccessible(true);
            }
        } else {
            assetsFiled = Resources.class.getDeclaredField("mAssets");
            assetsFiled.setAccessible(true);
        }
//        final Resources resources = context.getResources();
//        isMiuiSystem = resources != null && MIUI_RESOURCE_CLASSNAME.equals(resources.getClass().getName());

        try {
            publicSourceDirField = ShareReflectUtil.findField(ApplicationInfo.class, "publicSourceDir");
        } catch (NoSuchFieldException ignore) {
        }
    }
複製程式碼

該方法就是為資源補丁的替換做準備的工作,並且把這些準備的工作儲存起來,等到替換的時候用。首先獲取ActivityThread和LoadedApk,通過反射獲取LoadedApk的mResDir屬性,該屬性需要設定補丁包的路徑。通過ActivityThread獲取mPackages和mResourcePackages屬性,然後建立新的AssetManager同時通過反射獲取AssetManager的addAssetPath方法(通過addAssetPath可以將補丁載入到新的AssetManager中)。

Class<?> resourcesManagerClass = Class.forName("android.app.ResourcesManager");
            Method mGetInstance = resourcesManagerClass.getDeclaredMethod("getInstance");
            mGetInstance.setAccessible(true);
            //呼叫getInstance方法,拿到ResourcesManager物件
            Object resourcesManager = mGetInstance.invoke(null);
複製程式碼

通過反射呼叫getInstance方法,拿到ResourcesManager物件,因為該物件持有Resources容器物件,因此通過反射就可以拿到所有的Resources物件,拿到Resources物件就可以將其屬性替換為新建立的AssetManager。

tinker.png

這裡就是通過反射拿到Resources物件中持有AssetManager物件引用的屬性mAssets,而mAssets是載入補丁時需要進行替換的。

publicSourceDirField = ShareReflectUtil.findField(ApplicationInfo.class, "publicSourceDir");
複製程式碼

最後獲取ApplicationInfo的屬性publicSourceDir是瞭解決Android的webview引起的問題,在Android N上,如果一個活動包含一個webview,當螢幕旋轉時,資源補丁可能會失去效果。

當全部校驗通過,並且為資源補丁更新做好準備之後,呼叫TinkerResourceLoader的loadTinkerResources方法進行資源補丁更新。

tinker.png

loadTinkerResources:

/**
     * Load tinker resources
     */
    public static boolean loadTinkerResources(TinkerApplication application, String directory, Intent intentResult) {
        //判斷是否有資源更新
        if (resPatchInfo == null || resPatchInfo.resArscMd5 == null) {
            return true;
        }
        //補丁檔案
        String resourceString = directory + "/" + RESOURCE_PATH +  "/" + RESOURCE_FILE;
        File resourceFile = new File(resourceString);
        long start = System.currentTimeMillis();

        if (application.isTinkerLoadVerifyFlag()) {
            //校驗檔案的md5
            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));
        }
        try {
            //載入補丁
            TinkerResourcePatcher.monkeyPatchExistingResources(application, resourceString);
            Log.i(TAG, "monkeyPatchExistingResources resource file:" + resourceString + ", use time: " + (System.currentTimeMillis() - start));
        } catch (Throwable e) {
            Log.e(TAG, "install resources failed");
            //remove patch dex if resource is installed failed
            try {
                SystemClassLoaderAdder.uninstallPatchDex(application.getClassLoader());
            } catch (Throwable throwable) {
                Log.e(TAG, "uninstallPatchDex failed", e);
            }
            intentResult.putExtra(ShareIntentUtil.INTENT_PATCH_EXCEPTION, e);
            ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_RESOURCE_LOAD_EXCEPTION);
            return false;
        }

        return true;
    }
複製程式碼

該方法首先檢查資原始檔是否有更新,然後找到資原始檔路徑,並檢查md5是否校驗通過,再進一步呼叫TinkerResourcePatcher的monkeyPatchExistingResources方法來載入資源。

 /**
     * @param context
     * @param externalResourceFile
     * @throws Throwable
     */
    public static void monkeyPatchExistingResources(Context context, String externalResourceFile) throws Throwable {
        if (externalResourceFile == null) {
            return;
        }

        for (Field field : new Field[]{packagesFiled, resourcePackagesFiled}) {
            //獲取當前執行緒的ActivityThread物件
            Object value = field.get(currentActivityThread);

            for (Map.Entry<String, WeakReference<?>> entry
                : ((Map<String, WeakReference<?>>) value).entrySet()) {
                //獲取LoadedApk容器的物件
                Object loadedApk = entry.getValue().get();
                if (loadedApk == null) {
                    continue;
                }
                if (externalResourceFile != null) {
                    //將LoadedApk物件的mResDir屬性的值替換成資源補丁包的路徑
                    resDir.set(loadedApk, externalResourceFile);
                }
            }
        }
        /**
         *
         * Create a new AssetManager instance and point it to the resources installed under
         * 通過addAssetPath方法,將補丁載入到新的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);

        //遍歷ResourcesManager持有的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 {
                    //替換Resources的屬性assetsFiled為新的AssetManager
                    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);
                }

                clearPreloadTypedArrayIssue(resources);

                //根據原屬性重新更新Resources物件的配置.
                resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
            }
        }

        // Handle issues caused by WebView on Android N.
        // Issue: On Android N, if an activity contains a webview, when screen rotates
        // our resource patch may lost effects.
        // for 5.x/6.x, we found Couldn't expand RemoteView for StatusBarNotification Exception
        if (Build.VERSION.SDK_INT >= 24) {
            try {
                if (publicSourceDirField != null) {
                    publicSourceDirField.set(context.getApplicationInfo(), externalResourceFile);
                }
            } catch (Throwable ignore) {
            }
        }

        if (!checkResUpdate(context)) {
            throw new TinkerRuntimeException(ShareConstants.CHECK_RES_INSTALL_FAIL);
        }
    }
複製程式碼

1、獲取當前執行緒的ActivityThread物件和該物件持有的LoadedApk容器;

2、遍歷容器中的LoadedApk物件,替換LoadedApk的mResDir屬性為補丁物理路徑;

3、通過addAssetPath方法,將補丁載入到新建立的AssetManager中;

4、遍歷ResourcesManager持有的Resources容器物件;

5、替換Resources的屬性assetsFiled為新的AssetManager;

6、根據原屬性重新更新Resources物件的配置;

7、呼叫checkResUpdate方法,補丁生效校驗及解除安裝;

8、更加詳細說明請看文章的程式碼的註釋。

以上就是tinker的資源補丁載入的全過程,本人也是剛剛涉及熱修復的,如果哪裡講的不夠明白,也可以參考《Android 熱修復方案Tinker(四) 資源補丁載入》,該文章講得更加詳細,思路也非常清晰。

參考文章:《Android 熱修復方案Tinker(四) 資源補丁載入》

相關文章