Android插件化与热修复(七)-微信Tinker资源加载、gradle插件分析

tinker修复的过程包含两个过程,一方面服务端产生补丁包的过程;另一方面用户端获得补丁包之后的修复工程,简单的流程可以用如下的图描述:


Tinker热修复过程示意图

服务端补丁产生过程主要是tinker 定义的gradle插件来完成。我们在文章的后面会详细介绍gradle插件的细节。
我们首先来分析用户端的逻辑。一个完整的apk解压之后基本上包括dex文件,资源文件和so文件。前面的文章中已经详细介绍了dex文件的修复过程,本章主要介绍资源文件和so文件的修复。

资源文件的修复

资源文件的修复过程发生在客户端。包含两个主要的过程,首先是客户端获取到patch.apk之后,和本地的base.apk的合并过程,这个过程根据BSD算法将patch的资源文件和base的资源文件合并,最后打包到fix.apk中;另一个是资源文件的加载过程,主要的功能是引导base.apk使用fix.apk中的资源(这里有个关键点:虽然我们在用户端有了fix.apk,但是我们的fix.apk并没有安装,启动应用的还是base.apk,只是引导base.apk使用fix.apk里面的dex文件,资源文件,so文件)。我们主要介绍资源文件的加载。资源文件的合并主要是算法,我们这里不做介绍,感兴趣的读者可以在tinker源码中自行学习BSPatch.java。

 public static boolean loadTinkerResources(Context context, boolean tinkerLoadVerifyFlag, 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 (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));
        }
        try {   TinkerResourcePatcher.monkeyPatchExistingResources(context, 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(context.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;
    }

resourceString 局部变量代表的是fix.apk的资源文件。首先检查文件的的md5是否正确。然后再TinkerResourcePatcher中完成资源加载。

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

        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);
                }
            }
        }
        // Create a new AssetManager instance and point it to the resources installed under
        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);

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

                clearPreloadTypedArrayIssue(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.
//        publicSourceDirField.set(context.getApplicationInfo(), externalResourceFile);

        if (!checkResUpdate(context)) {
            throw new TinkerRuntimeException(ShareConstants.CHECK_RES_INSTALL_FAIL);
        }
    }

主要的功能在两个for循环中。外层的for循环是两个Field对象packagesFiled和resourcePackagesFiled,他们分别代表ActivityThread中的成员mPackages和mResourcePackages。里层的for循环分别修改这两个Field对象中的成员resDir,也就是LoadedApk的成员mResDir。这里主要通过反射修改系统文件的属性,实现资源的加载。后面的流程主要对android系统版本兼容处理。

为什么修改了ActivityThread的属性mPackages,mResourcePackages和LoadedApk类的mResDir属性就可以实现Base.apk加载Fix.apk的资源?我们从Android源码的角度分析一下。

通常我们获取某一资源的代码如下:context.getResource().getxxxx,这里的context我们知道是ContextImpl类。ContextImpl的方法getResource()得到的是它的属性mResources,mResources代表了一个资源包,也就是说如果这里的mResources代表的是Fix.apk的资源包,我们就完成了资源的加载。mResources是如何初始化的呢?

 final void init(LoadedApk packageInfo,
                IBinder activityToken, ActivityThread mainThread,
                Resources container, String basePackageName) {
        mPackageInfo = packageInfo;
        mBasePackageName = basePackageName != null ? basePackageName : packageInfo.mPackageName;
        mResources = mPackageInfo.getResources(mainThread);

        if (mResources != null && container != null
                && container.getCompatibilityInfo().applicationScale !=
                        mResources.getCompatibilityInfo().applicationScale) {
            if (DEBUG) {
                Log.d(TAG, "loaded context has different scaling. Using container's" +
                        " compatiblity info:" + container.getDisplayMetrics());
            }
            mResources = mainThread.getTopLevelResources(
                    mPackageInfo.getResDir(), container.getCompatibilityInfo());
        }
        mMainThread = mainThread;
        mContentResolver = new ApplicationContentResolver(this, mainThread);

        setActivityToken(activityToken);
    }

在ContextImpl初始化的时候对mResources 赋值。ActivityThread的方法getTopLevelResources和LoadedApk的方法getResources都可以对mResources ,最终都是调用的

Resources getTopLevelResources(String resDir, CompatibilityInfo compInfo) {
        ResourcesKey key = new ResourcesKey(resDir, compInfo.applicationScale);
        Resources r;
        synchronized (mPackages) {
            // Resources is app scale dependent.
            if (false) {
                Slog.w(TAG, "getTopLevelResources: " + resDir + " / "
                        + compInfo.applicationScale);
            }
            WeakReference<Resources> wr = mActiveResources.get(key);
            r = wr != null ? wr.get() : null;
            //if (r != null) Slog.i(TAG, "isUpToDate " + resDir + ": " + r.getAssets().isUpToDate());
            if (r != null && r.getAssets().isUpToDate()) {
                if (false) {
                    Slog.w(TAG, "Returning cached resources " + r + " " + resDir
                            + ": appScale=" + r.getCompatibilityInfo().applicationScale);
                }
                return r;
            }
        }

        //if (r != null) {
        //    Slog.w(TAG, "Throwing away out-of-date resources!!!! "
        //            + r + " " + resDir);
        //}

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

        //Slog.i(TAG, "Resource: key=" + key + ", display metrics=" + metrics);
        DisplayMetrics metrics = getDisplayMetricsLocked(null, false);
        r = new Resources(assets, metrics, getConfiguration(), compInfo);
        if (false) {
            Slog.i(TAG, "Created app resources " + resDir + " " + r + ": "
                    + r.getConfiguration() + " appScale="
                    + r.getCompatibilityInfo().applicationScale);
        }
        
        synchronized (mPackages) {
            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<Resources>(r));
            return r;
        }
    }

这里逻辑很清晰了。利用resDir设置AssetManager的属性,并创建Resource对象,resource对象和resDir一一对应。这里resDir参数是LoadedApk的属性mResourecDir。因此整个逻辑实现就是如果我们修改LoadedApk的属性mResourceDir是Fix.apk的资源,ContextImpl的属性mResource就代表了Fix.apk的资源。如下的图说明了他们之间的关系:


Android系统资源文件相关类

So文件的加载

so的修复过程也是在用户端完成的。首先是根据BSD算法修复so,然后在使用so文件的地方加载。

so文件加载机制

SO文件加载的时机和Dex、资源的加载有些不一样,Dex和资源的加载都是系统在特定的时机自动去加载,而SO加载的时机则是让开发者自己控制.开发者可以通过System类对外暴露出来的两个静态方法load和loadLibarary加载SO.这两个方法都拿ClassLoader再通过Runtime实现的。

  • Sytem.loadLibrary 方法是加载app安装过之后自动从apk包中释放到/data/data/packagename/lib下对应的SO文件。如果要使用这样的方式加载fix.apk中的so文件,需要利用反射修改DexPathList的属性nativeLibraryDirectorie,具体的方法可以参考前面文章中Dex文件的加载过程。
  • System.load 方法可以根据开发者指定的路径加载SO文件,例如/data/data/packagename/tinker/patch-xxx/lib/libtest.so,这是Tinker使用的方式,开发者不用修改任何代码,只要指定路径就可以加载Fix.apk的so文件。

Gradle插件

为什么要用gradle插件

  • 根据上面的分析,用户端要先得到patch.apk,才完成后面的修复和加载的过程,patch.apk是客户端修复的关键。tinker中的Gradle插件就是完成patch.apk的。
  • 另外,dex文件的差分和合并对比的是字节码,BSD算法的差分和合并对比的是二进制文件,那么这里隐含了一条规则,服务端的fix.apk的Dex文件的混淆,以及资源文件的R文件的属性需要和base.apk的一样。以及对于代码超出65536的方法,需要分包的应用,分包方法也需要和base.apk一样。这些也是gradle插件需要处理的问题。

Tinker中gradle插件的流程

Tinker中gradle插件工作流程图

简单的流程如下:在fix.apk编译之前按照需要修复的base.apk的资源id映射生成map文件;按照base.apk的混淆规则映射fix.apk的混淆规则等,以及按照base.apk的multidex的分包方式编译出fix.apk,最后产生差分的补丁patch.apk。

Tinker 中gradle插件的设计

tinker定义了5个task来完成patch.apk的产生过程。如下图是tinker插件的类图,


图片引自别处

TinkerManifestTask 主要是在AndroidManifest.xml中插入 meta-data TINKER_ID ,它是用来在客户端合并补丁的时候验证old.apk中的TINKER_ID是否一致。

@(工作-待办)TaskAction
    def updateManifest() {
            String tinkerValue = project.extensions.tinkerPatch.buildConfig.tinkerId
        if (tinkerValue == null || tinkerValue.isEmpty()) {
            throw new GradleException('tinkerId is not set!!!')
        }//校验gradle配置的tinkerId的值

        tinkerValue = TINKER_ID_PREFIX + tinkerValue

        writeManifestMeta(manifestPath, TINKER_ID, tinkerValue)//像AndriodManifest.xml插入Tinkerid
        addApplicationToLoaderPattern()
        File manifestFile = new File(manifestPath)
        if (manifestFile.exists()) {//修改后的AndriodManifest.xml拷贝到app\build\intermediates\tinker_intermediates下
            FileOperation.copyFileUsingStream(manifestFile, project.file(MANIFEST_XML))
            project.logger.error("tinker gen AndroidManifest.xml in ${MANIFEST_XML}")
        }

    }

TinkerManifestTask首先根据读取gradle配置的tinkerid,校验是否合法。然后往AndroidManifest.xm插入tinkerid,并拷贝到app\build\intermediates\tinker_intermediates。

TinkerResourceIdTask根据gradle配置的applyResourceMapping文件,产生public.xml和idx.xml,使得new.apk在编译资源文件的时候,按照public.xml指定的id分配给资源

 def applyResourceId() {
        String resourceMappingFile = project.extensions.tinkerPatch.buildConfig.applyResourceMapping
        if (!FileOperation.isLegalFile(resourceMappingFile)) {
            project.logger.error("apply resource mapping file ${resourceMappingFile} is illegal, just ignore")
            return
        }

       project.extensions.tinkerPatch.buildConfig.usingResourceMapping = true
       
        PatchUtil.generatePublicResourceXml(aaptResourceCollector, idsXml, publicXml)

            FileOperation.copyFileUsingStream(publicFile, project.file(RESOURCE_PUBLIC_XML))

            FileOperation.copyFileUsingStream(idxFile, project.file(RESOURCE_IDX_XML))
       }
    }

TinkerMultidexConfigTask 如果开启了multiDex 会在编译中根据gradle的配置和默认配置生成出要keep在main dex中的proguard信息文件,然后copy出这个文件,方便开发者使用multiDexKeepProguard进行配置.首先打开文件并写入默认配置.文件路径也在tinker_intermediates下

 @TaskAction
    def updateTinkerProguardConfig() {
        File file = project.file(MULTIDEX_CONFIG_PATH)
        print(file.getAbsolutePath())
        project.logger.error("try update tinker multidex keep proguard file with ${file}")

        // Create the directory if it doesn't exist already
        file.getParentFile().mkdirs()

        StringBuffer lines = new StringBuffer()
        lines.append("\n")
             .append("#tinker multidex keep patterns:\n")
             .append(MULTIDEX_CONFIG_SETTINGS)
             .append("\n")
             .append("#your dex.loader patterns here\n")

        Iterable<String> loader = project.extensions.tinkerPatch.dex.loader
        for (String pattern : loader) {
            if (pattern.endsWith("*")) {
                if (!pattern.endsWith("**")) {
                    pattern += "*"
                }
            }
            lines.append("-keep class " + pattern + " {\n" +
                    "    *;\n" +
                    "}\n")
                    .append("\n")
        }

        // Write our recommended proguard settings to this file
        FileWriter fr = new FileWriter(file.path)
        try {
            for (String line : lines) {
                fr.write(line)
            }
        } finally {
            fr.close()
        }

        File multiDexKeepProguard = null
        try {
            multiDexKeepProguard = applicationVariant.getVariantData().getScope().getManifestKeepListProguardFile()
        } catch (Throwable ignore) {
            try {
                multiDexKeepProguard = applicationVariant.getVariantData().getScope().getManifestKeepListFile()
            } catch (Throwable e) {
                project.logger.error("can't find getManifestKeepListFile method, exception:${e}")
            }
        }
        if (multiDexKeepProguard == null) {
            project.logger.error("auto add multidex keep pattern fail, you can only copy ${file} to your own multiDex keep proguard file yourself.")
            return
        }
        FileWriter manifestWriter = new FileWriter(multiDexKeepProguard, true)
        try {
            for (String line : lines) {
                manifestWriter.write(line)
            }
        } finally {
            manifestWriter.close()
        }
    }

TinkerProguardConfigTask如果开启了混淆,就会在gradle插件中构建出该任务,主要的作用是将tinker中默认的混淆信息和基准包的mapping信息加入混淆列表,这样就可以通过gradle配置自动帮开发者做一些类的混淆设置,并且可以通过applymapping的基准包的mapping文件达到在混淆上补丁包和基准包一致的目的.首先打开在编译路径下的混淆文件,为后面写入默认的keep规则做准备.文件的路径同样在tinker_intermediates下

  @TaskAction
    def updateTinkerProguardConfig() {
        def file = project.file(PROGUARD_CONFIG_PATH)
        project.logger.error("try update tinker proguard file with ${file}")

        // Create the directory if it doesnt exist already
        file.getParentFile().mkdirs()

        // Write our recommended proguard settings to this file
        FileWriter fr = new FileWriter(file.path)

        String applyMappingFile = project.extensions.tinkerPatch.buildConfig.applyMapping

        //write applymapping
        if (shouldApplyMapping && FileOperation.isLegalFile(applyMappingFile)) {
            project.logger.error("try add applymapping ${applyMappingFile} to build the package")
            fr.write("-applymapping " + applyMappingFile)
            fr.write("\n")
        } else {
            project.logger.error("applymapping file ${applyMappingFile} is illegal, just ignore")
        }

        fr.write(PROGUARD_CONFIG_SETTINGS)

        fr.write("#your dex.loader patterns here\n")
        //they will removed when apply
        Iterable<String> loader = project.extensions.tinkerPatch.dex.loader
        for (String pattern : loader) {
            if (pattern.endsWith("*") && !pattern.endsWith("**")) {
                pattern += "*"
            }
            fr.write("-keep class " + pattern)
            fr.write("\n")
        }
        fr.close()
        // Add this proguard settings file to the list
        applicationVariant.getBuildType().buildType.proguardFiles(file)
        def files = applicationVariant.getBuildType().buildType.getProguardFiles()

        project.logger.error("now proguard files is ${files}")
    }

TinkerPatchSchemaTask 负责校验Extensions的参数和环境是否合法和补丁生成

 @TaskAction
    def tinkerPatch() {
        configuration.checkParameter()
        configuration.buildConfig.checkParameter()
        configuration.res.checkParameter()
        configuration.dex.checkDexMode()
        configuration.sevenZip.resolveZipFinalPath()

        InputParam.Builder builder = new InputParam.Builder()
        if (configuration.useSign) {
            if (signConfig == null) {
                throw new GradleException("can't the get signConfig for this build")
            }
            builder.setSignFile(signConfig.storeFile)
                    .setKeypass(signConfig.keyPassword)
                    .setStorealias(signConfig.keyAlias)
                    .setStorepass(signConfig.storePassword)

        }

        builder.setOldApk(configuration.oldApk)
               .setNewApk(buildApkPath)
               .setOutBuilder(outputFolder)
               .setIgnoreWarning(configuration.ignoreWarning)
               .setDexFilePattern(new ArrayList<String>(configuration.dex.pattern))
               .setDexLoaderPattern(new ArrayList<String>(configuration.dex.loader))
               .setDexMode(configuration.dex.dexMode)
               .setSoFilePattern(new ArrayList<String>(configuration.lib.pattern))
               .setResourceFilePattern(new ArrayList<String>(configuration.res.pattern))
               .setResourceIgnoreChangePattern(new ArrayList<String>(configuration.res.ignoreChange))
               .setResourceLargeModSize(configuration.res.largeModSize)
               .setUseApplyResource(configuration.buildConfig.usingResourceMapping)
               .setConfigFields(new HashMap<String, String>(configuration.packageConfig.getFields()))
               .setSevenZipPath(configuration.sevenZip.path)
               .setUseSign(configuration.useSign)

        InputParam inputParam = builder.create()
        Runner.gradleRun(inputParam);
    }

总结

  • Tinker 和其他的类加载方式的修复方案相比,利用差分算法,最大限度的减小的补丁包的大小,这一点对于移动应用来说非常重要。
  • Tinker的修复包中不能引入新的四大组件,不能修改androidManifest文件。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容