在HotFix SDK中接入Tinker

1,142 阅读10分钟

title: HotFix&&Tinker融合调研文档 tags: hotfix,tinker,热修复 grammar_cjkRuby: true

两年前的调研,准备离职了不想白费以前的汗水所以发出来

在HotFix SDK Library中接入Tinker

1. 指定Tinker SDK版本

gradle.propertites中指定tinker接入版本,例如:

 TINKER_VERSION=1.7.7

2. 添加Tinker gradle依赖

  • 项目build.gradle中添加tinker-patch-gradle-plugin的依赖
dependencies {
        classpath "com.tencent.tinker:tinker-patch-gradle-plugin:${TINKER_VERSION}"
    }
  • HotFix SDK library模块下的build.gradle中添加tinker的库依赖
    compile("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") { changing = true }

3. 在HotFix SDK 代理Application中初始化Tinker

  • 将HotFixProxyApplication 继承自TinkerApplication 继承继承自TinkerApplication需要复写其构造方法,方法实现直接调用父类的方法。
 super(tinker_flag);

这里可以考虑多写一个代理Application直接继承Application(即原HotFixProxyApplication),如果不打算接入Tinker,则可以直接在app模块的AndroidManifest.xml使用该代理Application

  • 在runOriginalApplication方法中的反射调用完原Application的attach方法后进行Tinker的初始化工作
  private void installTinker(Application application) {
        if (isInstalled) {
            TinkerLog.w(TAG, "install tinker, but has installed, ignore");
            return;
        }
        Intent tinkerResultIntent = new Intent();
        try {
            //reflect tinker loader, because loaderClass may be define by user!
            Class<?> tinkerLoadClass = Class.forName(TinkerLoader.class.getName(), false, getClassLoader());

            Method loadMethod = tinkerLoadClass.getMethod(TINKER_LOADER_METHOD, TinkerApplication.class, int.class, boolean.class);
            Constructor<?> constructor = tinkerLoadClass.getConstructor();
            tinkerResultIntent = (Intent) loadMethod.invoke(constructor.newInstance(), this, TINKER_FLAG, tinkerLoadVerifyFlag);
        } catch (Throwable e) {
            //has exception, put exception error code
            ShareIntentUtil.setIntentReturnCode(tinkerResultIntent, ShareConstants.ERROR_LOAD_PATCH_UNKNOWN_EXCEPTION);
            tinkerResultIntent.putExtra(ShareIntentUtil.INTENT_PATCH_EXCEPTION, e);
        }
        Tinker tinker = new Tinker.Builder(application).build();
        Tinker.create(tinker);
        tinker.install(tinkerResultIntent);
        isInstalled=true;
    }

至此,在HotFix SDK中已经完成了Tinker SDK的最初步的接入了。

4. 向服务器发起查询补丁请求,如果返回的补丁是Tinker补丁类型,下载完成校验通过则用Tinker SDK进行补丁安装。

目前查询补丁的请求参数可以不变,返回参数中加一个补丁是否是TInker补丁说明字段即可。

当补丁下载成功后我们就可以调用以下方法尝试安装Tinker补丁了

TinkerInstaller.onReceiveUpgradePatch(Context context, String patchLocation)

Tinker补丁生效实际上有3个过程,一是补丁检验,二是补丁合成,三是补丁加载。补丁加载过程是在应用重启后进行的。

  • 补丁检验 Tinker自带了补丁前置检验功能,当然是最基础的校验,因为在补丁的合成和加载的时候还会进行其他一些校验,补丁前置检验包括是否开启了Tinker补丁功能,补丁文件是否合法,当前进程是否是Tinker补丁服务进程等等。而我们HotFix的XCHK校验就需要通过拓展Tinker补丁检验回调方法来实现,方法自定义一个PatchListener继承Tinker的DefaultPatchListenerpatchCheck方法,在其中进行XCHK校验方法。 此外,还可以在patchCheck方法中加入是否是谷歌Play渠道或者360渠道判断(因此谷歌渠道不允许代码下发,360渠道必须要经过360加固但是Tinker1.7.6以后不支持加固),应用获得的最大内存空间是否满足指定的最小内存空间要求等等。
  • 补丁合成 补丁合成结果会回调DefaultPatchReporter中的方法,而自定义PatchReporter继承DefaultPatchReporter,复写其中的回调方法。具体回调方法见下表
回调方法 描述
onPatchResult 这个是无论补丁合成失败或者成功都会回调的接口,它返回了本次合成的类型,时间以及结果等。默认我们只是简单的输出这个信息,你可以在这里加上监控上报逻辑。
onPatchServiceStart 这个是Patch进程启动时的回调,我们可以在这里进行一个统计的工作。
onPatchPackageCheckFail 补丁合成过程对输入补丁包的检查失败,这里可以通过错误码区分,例如签名校验失败、tinkerId不一致等原因。默认我们会删除临时文件。
onPatchVersionCheckFail 对patch.info的校验版本合法性校验。若校验失败,默认我们会删除临时文件。
onPatchTypeExtractFail 从补丁包与原始安装包中合成某种类型的文件出现错误,默认我们会删除临时文件。
onPatchDexOptFail 对合成的dex文件提前进行dexopt时出现异常,默认我们会删除临时文件。
onPatchInfoCorrupted patch.info是用来管理补丁包版本的文件,这是在更新info文件时发生损坏的回调。默认我们会卸载补丁包,因为此时我们已经无法恢复了。
onPatchException 在补丁合成过程捕捉到异常,十分希望你可以把错误信息反馈给我们。默认我们会删除临时文件,并且将tinkerFlag设为不可用。

如果补丁合成失败,则可以通过回调方法上报HotFix服务器的report接口。现有的HotFix补丁错误码可以讨论继续增加。

需要注意的是,PatchReporter中有个onPatchResult方法,这个方法是在补丁合成进程中进行的补丁合成结果回调方法,还有一个TinkerResultService中也有一个onPatchResult方法,TinkerResultService是补丁合成进程将合成结果返回给主进程的服务,Tinker默认的DefaultTinkerResultService是会杀掉:patch进程,假设当前是补丁升级并且成功了,Tinker会杀掉当前进程,让补丁包更快的生效,若是修复类型的补丁包并且失败了,Tinker会卸载补丁包。

Tinker默认的ResultService不是很符合我们当前HotFix的业务逻辑,因此ResultService必须重写。可以参考例子的HotFixTinkerResultService,在当前应用在退入后台或手机锁屏时这两个时机杀掉当前进程去应用补丁。当前也可以提供接口让接入者选择重启时机。

  • 补丁加载 LoadReporter类定义了Tinker在加载补丁时的一些回调,Tinker为我们提供了默认实现DefaultLoadReporter类,不过这肯定不满足我们的需求,因为在这里我们需要上报补丁加载的结果。;例子中的TinkerLoadReporter是继承DefaultLoadReporter的类,在onLoadResult中可以进行补丁安装结果的上报,以及补丁加载失败也可以在这个方法中选择重试安装补丁。 补丁加载也有回调方法,见下表:
回调方法 描述
onLoadResult 这个是无论加载失败或者成功都会回调的接口,它返回了本次加载所用的时间、返回码等信息。默认我们只是简单的输出这个信息,你可以在这里加上监控上报逻辑。
onLoadPatchListenerReceiveFail 所有的补丁合成请求都需要先通过PatchListener的检查过滤。这次检查不通过的回调,它运行在发起请求的进程。默认我们只是打印日志
onLoadPatchVersionChanged 补丁包版本升级的回调,只会在主进程调用。默认我们会杀掉其他所有的进程(保证所有进程代码的一致性),并且删掉旧版本的补丁文件。
onLoadFileNotFound 在加载过程中,发现部分文件丢失的回调。默认若是dex,dex优化文件或者lib文件丢失,我们将尝试从补丁包去修复这些丢失的文件。若补丁包或者版本文件丢失,将卸载补丁包。
onLoadFileMd5Mismatch 部分文件的md5与meta中定义的不一致。默认我们为了安全考虑,依然会清空补丁。
onLoadPatchInfoCorrupted patch.info是用来管理补丁包版本的文件,这是info文件损坏的回调。默认我们会卸载补丁包,因为此时我们已经无法恢复了。
onLoadPackageCheckFail 加载过程补丁包的检查失败,这里可以通过错误码区分,例如签名校验失败、tinkerId不一致等原因。默认我们将会卸载补丁包
onLoadException 在加载过程捕捉到异常。默认我们会直接卸载补丁包

根据HotFix业务需求,需要重写LoadReporteronLoadPatchVersionChanged方法,默认的onLoadPatchVersionChanged方法会杀掉其他所有的进程(保证所有进程代码的一致性),并且删掉旧版本的补丁文件。这就导致了HotFix监控进程也被杀掉,因此需要在这里排除掉HotFix监控进程,使其不被杀死。

Tinker补丁包生成

在需要集成HotFix SDK 的工程的build.gradle中加入Tinker补丁包生成gradle任务 这一部分可以参考Tinker官方接入指南示例,打包参数含义也有详尽的解释。

需要注意的是:在dex节点下的loader节点中加入需要排除的类,包括自定义的Application和HotFix SDK的类。

下面是个人的使用例子:

def bakPath = file("${buildDir}/bakApk/")
ext {
    tinkerEnabled = true

    //for normal build
    //old apk file to build patch apk
    tinkerOldApkPath = "${bakPath}/app-release-0228-15-14-21.apk"
    //proguard mapping file to build patch apk
    tinkerApplyMappingPath = "${bakPath}/app-release-0228-15-14-21-mapping.txt"
    //resource R.txt to build patch apk, must input if there is resource changed
    tinkerApplyResourcePath = "${bakPath}/app-release-0228-15-14-21-R.txt"

    //only use for build all flavor, if not, just ignore this field
//    tinkerBuildFlavorDirectory = "${bakPath}/app-0217-16-54-37"
}

def getOldApkPath() {
    return hasProperty("OLD_APK") ? OLD_APK : ext.tinkerOldApkPath
}

def getApplyMappingPath() {
    return hasProperty("APPLY_MAPPING") ? APPLY_MAPPING : ext.tinkerApplyMappingPath
}

def getApplyResourceMappingPath() {
    return hasProperty("APPLY_RESOURCE") ? APPLY_RESOURCE : ext.tinkerApplyResourcePath
}

def getTinkerIdValue() {
    //versionCode作为TinkerId,这样就不需要git和commit一次
    return android.defaultConfig.versionCode + ""
}

def buildWithTinker() {
    return hasProperty("TINKER_ENABLE") ? TINKER_ENABLE : ext.tinkerEnabled
}

def getTinkerBuildFlavorDirectory() {
    return ext.tinkerBuildFlavorDirectory
}

if (buildWithTinker()) {
    apply plugin: 'com.tencent.tinker.patch'
    tinkerPatch {
        oldApk = getOldApkPath()
        ignoreWarning = true
        useSign = true
        tinkerEnable = buildWithTinker();

        buildConfig {
            applyMapping = getApplyMappingPath()
            applyResourceMapping = getApplyResourceMappingPath()
            tinkerId = getTinkerIdValue()
            keepDexApply = false
        }

        dex {
            dexMode = "jar"
            pattern = ["classes*.dex",
                       "assets/secondary-dex-?.jar"]
            loader = [
                    //use sample, let BaseBuildInfo unchangeable with tinker
                    "com.tencent.tinker.loader.*",
                    "com.cn21.HotTinker.MyApp",
                    "com.cn21.hotfix.*"
            ]
        }

        lib {
            pattern = ["lib/*/*.so"]
        }

        res {
            pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
            ignoreChange = ["assets/sample_meta.txt"]
            largeModSize = 100
        }

        packageConfig {
            configField("patchMessage", "tinker is sample to use")
            configField("platform", "all")
            configField("patchVersion", "1.0")
        }

        sevenZip {
            zipArtifact = "com.tencent.mm:SevenZip:1.1.10"

        }
    }

    List<String> flavors = new ArrayList<>();
    project.android.productFlavors.each { flavor ->
        flavors.add(flavor.name)
    }
    boolean hasFlavors = flavors.size() > 0
/**
 * bak apk and mapping
 */
    android.applicationVariants.all { variant ->
        /**
         * task type, you want to bak
         */
        def taskName = variant.name
        def date = new Date().format("MMdd-HH-mm-ss")

        tasks.all {
            if ("assemble${taskName.capitalize()}".equalsIgnoreCase(it.name)) {

                it.doLast {
                    copy {
                        def fileNamePrefix = "${project.name}-${variant.baseName}"
                        def newFileNamePrefix = hasFlavors ? "${fileNamePrefix}" : "${fileNamePrefix}-${date}"

                        def destPath = hasFlavors ? file("${bakPath}/${project.name}-${date}/${variant.flavorName}") : bakPath
                        from variant.outputs.outputFile
                        into destPath
                        rename { String fileName ->
                            fileName.replace("${fileNamePrefix}.apk", "${newFileNamePrefix}.apk")
                        }

                        from "${buildDir}/outputs/mapping/${variant.dirName}/mapping.txt"
                        into destPath
                        rename { String fileName ->
                            fileName.replace("mapping.txt", "${newFileNamePrefix}-mapping.txt")
                        }

                        from "${buildDir}/intermediates/symbols/${variant.dirName}/R.txt"
                        into destPath
                        rename { String fileName ->
                            fileName.replace("R.txt", "${newFileNamePrefix}-R.txt")
                        }
                    }
                }
            }
        }
    }
    project.afterEvaluate {
        //sample use for build all flavor for one time
        if (hasFlavors) {
            task(tinkerPatchAllFlavorRelease) {
                group = 'tinker'
                def originOldPath = getTinkerBuildFlavorDirectory()
                for (String flavor : flavors) {
                    def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Release")
                    dependsOn tinkerTask
                    def preAssembleTask = tasks.getByName("process${flavor.capitalize()}ReleaseManifest")
                    preAssembleTask.doFirst {
                        String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 15)
                        project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release.apk"
                        project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-mapping.txt"
                        project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-R.txt"

                    }

                }
            }

            task(tinkerPatchAllFlavorDebug) {
                group = 'tinker'
                def originOldPath = getTinkerBuildFlavorDirectory()
                for (String flavor : flavors) {
                    def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Debug")
                    dependsOn tinkerTask
                    def preAssembleTask = tasks.getByName("process${flavor.capitalize()}DebugManifest")
                    preAssembleTask.doFirst {
                        String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 13)
                        project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug.apk"
                        project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-mapping.txt"
                        project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-R.txt"
                    }

                }
            }
        }
    }
} else {
//HotFix插件配置
    apply plugin: "cn.jiajixin.nuwa"
    nuwa {
        isCloseHotfixPatch = false
        oldBuildHotfixDir = "${project.getProjectDir()}/old"
        gradleBuildVersion = "1.5.0"
        excludeClass = ["com.cn21.HotTinker.MyApp",
                        "android/support/multidex/MultiDex.class",
                        "com/cn21/hotfix/HotFixManager.class"]
        excludePackage = ["com/tencent/tinker/lib",
                          "com/tencent/tinker/loader",
                          "com/tencent/tinker/commons"]
    }
}

示例工程HotTinker改动说明

示例工程HotTinker是在HotFix SDK工程的基础上做出以下改动:

  • 修改HotFixProxyApplication
    • 使其继承TinkerApplication,添加构造函数并直接调用super(TINKER_FLAG);
    • 添加 private void installTinker(Application application) 方法,并在runOriginalApplication中调用,进行Tinker的初始化
    • 这里考虑多写一个直接继承Application的HotFix应用代理,当接入者不考虑接入Tinker时可以避免不必要的消耗。
  • 新增com.cn21.hotfix.tinkerReporter包,在这个包下新增了:
    • HotFixTinkerReport类:在这个类中定义了真正的上报接口Reporter不过并没有真正实现,而TinkerLoadReporter和TinkerPatchReporter都是调用了HotFixTinkerReport中上报方法,具体的上报需要Reporter实现类去完成。这里需要完成的是根据HotFix的API协议修改HotFixTinkerReport类中上报结果码,以及实现Reporter接口完成真正的打补丁结果上报HotFix服务器。
      • TinkerLoadReporter类:Tinker补丁加载结果回调类,这里需要补充的是补丁加载回调结果上报以及重写onLoadPatchVersionChanged方法,onLoadPatchVersionChanged方法默认会杀掉主进程外的其他所有的进程(保证所有进程代码的一致性),不过Tinker补丁基本不会对HotFix SDK的监控服务进程进行修改,因此需要在这个方法中排除掉HotFix SDK的监控服务进程。
      • TinkerPatchReporter类:Tinker补丁合成结果回调类,这里需要补充的是如果合成失败需要进行上报失败原因给HotFix服务器,如果合成失败可以选择重试(可选)。可参考Tinker官方示例工程tinker-sample-androidUpgradePatchRetry
      • TinkerPatchListener类:Tinker补丁前置检查,在这里进行了应用是否为谷歌play渠道检查,内存空间大小检查以及补丁文件是否存在检查等等,需要补充的是HotFix补丁xchk检查以及是否是360渠道检查(不支持360渠道)
  • utils包下新增TinkerUtils类
  • service包下新增HotFixTinkerResultService类:补丁合成进程将合成结果返回给主进程的类,需要修改onPatchResult方法,因为默认的实现是补丁合成成功后就立即杀死当前应用进程,而这种方式肯定不行的,HotFixTinkerResultService的做法是在用户锁屏的时候重启应用,当然也可以在其他合适的时机重启应用,还可以让接入者在Manifest进行配置选择重启时机。
  • 将app模块中的AndroidManifest.xmlHotFixService移到了library模块的AndroidManifest.xml,并添加了自定义的HotFixTinkerResultService service节点。
 <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.cn21.hotfix">

    <application>
        <!-- HotFix 服务-->
        <service
            android:name="com.cn21.hotfix.service.HotFixService"
            android:process=":hotfix"/>
        <service
            android:name="com.cn21.hotfix.service.HotFixTinkerResultService"
            android:exported="false"/>
    </application>
</manifest>

附:HotFix与Tinker兼容示例工程:HotTinker