热修复 - Tinker多渠道加固配置

1,201 阅读2分钟

欢迎关注微信公众号:FSA全栈行动 👋

一、问题

腾讯的热修复方案 Tinker 为加固应用提供了支持,需要在 gradle 脚本中,通过 isProtectedApp 配置当前的基准包(base apk)是否为加固 apk ,而这个配置是全局性的,Tinker 没有为多渠道提供单独的配置,这意味着,如果你的 app 工程在各个渠道不是全部统一使用加固或非加固的话,那么在为线上 apk 制作补丁包时,你不得不总要考虑是否需要修改 isProtectedApp 的值。为了提升工作效率,确保产出的补丁准确无误,非常有必要固化各渠道 isProtectedApp 的值。

二、摸索

先来初步认识一下 isProtectedApp ,它的作用是什么?下面是官方 demo tinker-sample-android 中对 isProtectedApp 的注释:

tinkerPatch {
    buildConfig {
        ...
        /**
         * optional, default 'false'
         * Whether tinker should treat the base apk as the one being protected by app
         * protection tools.
         * If this attribute is true, the generated patch package will contain a
         * dex including all changed classes instead of any dexdiff patch-info files.
         */
        isProtectedApp = false // 是否使用加固模式,仅仅将变更的类合成补丁。注意,这种模式仅仅可以用于加固应用中。
    }
    ...
}

代码出自:github.com/Tencent/tin…

翻译过来就是说,isProtectedApp 的值是可选的,默认为 false;作用是让 Tinker 知道是否应该将基准包(base apk)视为被加固工具加固过的受保护的 apk;如果值为 true ,则生成的补丁包将是 一个包含所有变更过的 class 的 dex 文件,而不是那些 dexdiff 补丁信息文件。简而言之,就是生成的补丁包文件会有不同。

接下来是要搞清楚 isProtectedApp 在 Tinker 内部是怎么用的,为了搞清这个问题,我 clone 了一份 Tinker 源码,研究了一下补丁的生成过程,下面是关键流程的分析与结论。

1、TinkerPatchPlugin

我们在生成补丁时,需要在 Gradle 面板中执行 tinkerPatchXXX 任务(比如:tinkerPatchRelease、tinkerPatchXiaomiRelease),然后等待 tinker 帮我们生成对应渠道的补丁包即可。该功能由 Tinker 开发的 Gradle 插件提供,对应的插件类就是 TinkerPatchPlugin ,源码如下:

class TinkerPatchPlugin implements Plugin<Project> {

    @Override
    public void apply(Project project) {
        ...
        mProject.afterEvaluate {
        ...
        android.applicationVariants.all { ApkVariant variant ->
            ...
            // 创建 tinkerPatchXXX 任务
            TinkerPatchSchemaTask tinkerPatchBuildTask = mProject.tasks.create("tinkerPatch${capitalizedVariantName}", TinkerPatchSchemaTask)
            tinkerPatchBuildTask.signConfig = variant.signingConfig

            variant.outputs.each { variantOutput ->
                // setPatchNewApkPath() 内部有这么一段代码,作用是让 tinkerPatchXXX 任务 依赖于 assembleXXX 任务:
                // tinkerPatchBuildTask.dependsOn Compatibilities.getAssembleTask(mProject, variant)
                setPatchNewApkPath(configuration, variantOutput, variant, tinkerPatchBuildTask)
                setPatchOutputFolder(configuration, variantOutput, variant, tinkerPatchBuildTask)
                ...
            }
        }
    }
}

从上面的源码中,可以得知以下几点:

  • tinkerPatchXXX 任务的具体实现在 TinkerPatchSchemaTask 类中。
  • tinkerPatchXXX 任务依赖 assembleXXX 任务,所以每次打补丁时,都会重新 build 一次。

2、TinkerPatchSchemaTask

来看看 tinkerPatchXXX 任务的具体实现类 TinkerPatchSchemaTask

public class TinkerPatchSchemaTask extends DefaultTask {
    @Internal
    TinkerPatchExtension configuration

    TinkerPatchSchemaTask() {
        ...
        configuration = project.tinkerPatch
    }

    @TaskAction
    def tinkerPatch() {
        InputParam.Builder builder = new InputParam.Builder()
        ...
        for (def i = 0; i < newApks.size(); ++i) {
            ...
            builder.setOldApk(oldApk.getAbsolutePath())
                    .setNewApk(newApk.getAbsolutePath())
                    ...
                    .setIsProtectedApp(configuration.buildConfig.isProtectedApp)
            InputParam inputParam = builder.create()
            Runner.gradleRun(inputParam)
            ...
        }
    }
}

在 Gradle 中,一般任务(Task)会有继承自 DefaultTask,被 @TaskAction 修饰的方法就是任务的执行逻辑。TinkerPatchSchemaTask 中被 @TaskAction 修饰的方法是 tinkerPatch() ,它就是 tinkerPatchXXX 任务的具体实现。在该方法中,我们看到了 isProtectedApp 被赋值到 InputParam 实例中,然后传递给 Runner.gradleRun(inputParam)

3、Runner

跟进到 Runner 类中,可以看到 inputParam 最终会被 Runner 实例的 mConfig 持有:

public class Runner {
    protected Configuration mConfig;

    public static void gradleRun(InputParam inputParam) {
        Runner m = new Runner(true);
        m.run(inputParam);
    }

    private void run(InputParam inputParam) {
        loadConfigFromGradle(inputParam);
        tinkerPatch();
    }

    private void loadConfigFromGradle(InputParam inputParam) {
        ...
        mConfig = new Configuration(inputParam);
    }
}

Runner.gradleRun(inputParam) 通过 run() 方法最终会触发到 tinkerPatch() 方法:

public class Runner {
	protected Configuration mConfig;
	...
	protected void tinkerPatch() {
        Logger.d("-----------------------Tinker patch begin-----------------------");

        Logger.d(mConfig.toString());
        try {
            //gen patch
            ApkDecoder decoder = new ApkDecoder(mConfig);
            decoder.onAllPatchesStart();
            decoder.patch(mConfig.mOldApkFile, mConfig.mNewApkFile);
            decoder.onAllPatchesEnd();

            //gen meta file and version file
            PatchInfo info = new PatchInfo(mConfig);
            info.gen();

            //build patch
            PatchBuilder builder = new PatchBuilder(mConfig);
            builder.buildPatch();

        } catch (Throwable e) {
            goToError(e, ERRNO_USAGE);
        }

        Logger.d("Tinker patch done, total time cost: %fs", diffTimeFromBegin());
        Logger.d("Tinker patch done, you can go to file to find the output %s", mConfig.mOutFolder);
        Logger.d("-----------------------Tinker patch end-------------------------");
    }
}

这个 tinkerPatch() 就是 Tinker 生成补丁包的核心方法了,分为 3 大部分:

  • ApkDecoder:管理各 Decoder 协同生成补丁文件(manifestDecoderdexPatchDecodersoPatchDecoderresPatchDecoder
  • PatchInfo.gen():生成 meta 文件和 version 文件
  • PatchBuilder.buildPatch():将上面的 补丁文件 和 信息文件 打包成补丁包、签名

而用到 isProtectedApp 的地方有两处,分别在 ApkDecoderPatchInfo

4、UniqueDexDiffDecoder & DexDiffDecoder

ApkDecoder 管理了各个 Decoder,其中,dexPatchDecoderUniqueDexDiffDecoder 实例:

public class ApkDecoder extends BaseDecoder {
    private final UniqueDexDiffDecoder dexPatchDecoder;

    public ApkDecoder(Configuration config) throws IOException {
        dexPatchDecoder = new UniqueDexDiffDecoder(config, prePath + TypedValue.DEX_META_FILE, TypedValue.DEX_LOG_FILE);
        ...
    }
}

public class UniqueDexDiffDecoder extends DexDiffDecoder {
    ...
}

UniqueDexDiffDecoder 继承自 DexDiffDecoder,dex 补丁生成的核心逻辑在 DexDiffDecoder 中:

public class DexDiffDecoder extends BaseDecoder {
    @Override
    public void onAllPatchesEnd() throws Exception {
        ...
        if (config.mIsProtectedApp) {
            // 对于加固app,则将变更的类以及相关信息写入到 patch dex
            generateChangedClassesDexFile();
        } else {
            // 对于非加固app,则使用 dexdiff 算法生成 patch dex,补丁包更小
            generatePatchInfoFile();
        }
        ...
    }
}

DexDiffDecoderonAllPatchesEnd() 方法中使用到了 isProtectedApp,用于分别对加固和非加固的基准包(base apk)生成 dex 补丁文件,不过,generateChangedClassesDexFile()generatePatchInfoFile() 具体实现细节这里不展开,有兴趣的可以自己研究下,两者的区别看上述代码注释即可,这就是官方对 isProtectedApp 解释对应到的具体代码位置。

5、PatchInfo & PatchInfoGen

最后来看看另一处使用 isProtectedApp 的地方,PatchInfoPatchInfoGen 的包装类,PatchInfo.gen() 方法最终调用的 PatchInfoGen.gen()

public class PatchInfo {

    private final PatchInfoGen infoGen;

    public PatchInfo(Configuration config) {
        infoGen = new PatchInfoGen(config);
    }

    /**
     * gen the meta file txt
     * such as rev, version ...
     * file version, hotpatch version class
     */
    public void gen() throws Exception {
        infoGen.gen();
    }
}

public class PatchInfoGen {
	...
    public void gen() throws Exception {
        addTinkerID();
        addProtectedAppFlag();
        ...
    }

    private void addProtectedAppFlag() {
        // If user happens to specify a value with this key, just override it for logic correctness.
        config.mPackageFields.put(TypedValue.PKGMETA_KEY_IS_PROTECTED_APP, config.mIsProtectedApp ? "1" : "0");
    }
}

PatchInfoGen.gen() 方法中调用了 addProtectedAppFlag() 方法,将 boolean 类型的 isProtectedApp 转换为数字 01,最终会保存到 package_meta.txt 文件中。

三、解决方案

通过上面的源码分析,可以知道在生成补丁时,isProtectedApp 的两个作用:

  • DexDiffDecoder 判断具体的 dex 补丁生成方式
  • PatchInfoGen 确定 is_protected_app 的值,最后记录在 package_meta.txt 文件中

另外,在上述流程中,可以发现各环节使用的 isProtectedApp 的值,归根到底均来源于一处,即 TinkerPatchSchemaTask 类中的 configuration 属性,而 configuration 又是 project.tinkerPatch 的引用:

public class TinkerPatchSchemaTask extends DefaultTask {
    @Internal
    TinkerPatchExtension configuration

    TinkerPatchSchemaTask() {
        ...
        configuration = project.tinkerPatch
    }
}

别忘了,TinkerPatchSchemaTask 对应的是 tinkerPatchXXX 任务,如果我们能在 tinkerPatchXXX 任务执行前,篡改掉 project.tinkerPatchisProtectedApp 的值,那么后续各环节中所使用的就是被篡改后的 isProtectedApp 了。为了实现该功能,需要用到 Gradle 提供的 Task 相关的几个方法:

  • tasks.findByName():通过任务名字精确获取到对应的 task。
  • task.doFirst{}:在 doFirst{} 闭包中编写的代码逻辑,会被放到 task 的执行阶段的最前面。
  • afterEvaluate{}:配置阶段完成后的监听回调。

结合上述几个 api,最终的 Gradle 代码如下:

apply from: 'configure/script/tinkerconfig.gradle' // Tinker 的 gradle 配置,同官方Demo

// 注意:以下代码必须放在 Tinker 配置之后,否则 tasks.findByName 找不到 tinkerPatchXXX 任务
afterEvaluate {
    android.applicationVariants.all { variant ->
        // println "tinkerPatchTask ----> ${tasks.findByName("tinkerPatch${variant.name.capitalize()}")}"
        tasks.findByName("tinkerPatch${variant.name.capitalize()}").doFirst {

            // 打印原本的 project.tinkerPatch.buildConfig 信息
            println "original project.tinkerPatch --> ${project.tinkerPatch.buildConfig}"

            // 渠道区分,xiaomi渠道的 base apk 会加固,其他渠道不加固
            def isProtectedApp
            if (variant.name.startsWith("xiaomi")) {
                isProtectedApp = true
            } else {
                isProtectedApp = false
            }

            println "change ${variant.name}'s `isProtectedApp` --> ${isProtectedApp}"
            project.tinkerPatch.buildConfig.isProtectedApp = isProtectedApp
        }
    }
}

四、方案优化

上述代码中,isProtectedApp 的赋值部分属于硬编码,还是有优化的空间的,你可以使用函数,结合闭包组合的方式,将 isProtectedApp 的赋值提前到 productFlavors 配置阶段,比如:

// build.gradle
apply from: 'configure/script/basic.gradle'
android {
    productFlavors {
        xiaomi profileCommon(
            isProtectedApp: true
        ) >> {
            buildConfigField "String", "USER_ID", '"GitLqr"'
        }

        googlePlay profileCommon(
            isProtectedApp: false
        ) >> {
            buildConfigField "String", "USER_ID", '"CharyLin"'
        }
    }
}

这样效果是不是更加直观了呢?下面是 basic.gradle 脚本中的代码:

// basic.gradle
project.ext.variantIsProtectedAppMap = [:]
project.ext.profileCommon = { profiles = [:] ->
    return {
        def flavorName = delegate.name
        android.buildTypes.forEach {
            def variant = flavorName + it.name.capitalize() // xiaomiRelease
            project.ext.variantIsProtectedAppMap[variant] = profiles.getOrDefault('isProtectedApp', false)
        }
    }
}

apply from: 'configure/script/tinkerconfig.gradle'
afterEvaluate {
    android.applicationVariants.all { variant ->
        // println "tinkerPatchTask ----> ${tasks.findByName("tinkerPatch${variant.name.capitalize()}")}"
        tasks.findByName("tinkerPatch${variant.name.capitalize()}").doFirst {
            println "original project.tinkerPatch --> ${project.tinkerPatch.buildConfig}"
            def isProtectedApp = variantIsProtectedAppMap[variant.name]
            println "change ${variant.name}'s `isProtectedApp` --> ${isProtectedApp}"
            project.tinkerPatch.buildConfig.isProtectedApp = isProtectedApp
        }
    }
}

至此,Tinker 的多渠道加固配置问题就解决了。如果你对 Gradle 的语法、插件、任务等概念不熟悉,可以阅读下列文章来学习 Gradle:

如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有Android技术, 还有iOS, Python等文章, 可能有你想要了解的技能知识点哦~