1. 程式人生 > >Android熱修復技術Tinker VS AndFix

Android熱修復技術Tinker VS AndFix

流行的熱修復技術有哪些

時下比較流行的熱修復技術有Tinker、QZone、AndFix、Robust。 前面兩個是騰訊開發的,AndFix源於阿里,Robust是美國一家公司開發的。那麼我們應該選用哪一款呢?網上有個它們之間的對比

Tinker QZone AndFix Robust
類替換 yes yes no no
So替換 yes no no no
資源替換 yes yes no no
全平臺支援 yes yes yes yes
即時生效 no no yes yes
效能損耗 較小 較大 較小 較小
補丁包大小 較小 較大 一般 一般
開發透明 yes yes no no
複雜度 較低 較低 複雜 複雜
gradle支援 yes no no no
Rom體積 較大 較小 較小 較小
成功率 較高 較高 一般 最高



可以看出,幾種產品都有自己的側重點,大家可根據自己的需求自由選擇。本篇文章主要介紹一下Tinker和AndFix

強大的Tinker

tinker的功能非常強大,基本除了AndroidManifest.xml檔案和tinker本身少數幾個類之外,其他內容都能替換,包括佈局、資源。不足之處在於其首次配置稍有點複雜,上手難度較AndFix稍高一些。廢話不多說,上教程

接入步驟

  1. 在專案build.gradle檔案中加入tinker外掛配置

    buildscript {
        dependencies {
            classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.7.7')
        }
    }
  2. 在具體moudle的build.gradle檔案中新增tinker相關依賴

    dependencies {
           //optional, help to generate the final application 
           provided('com.tencent.tinker:tinker-android-anno:1.7.7'
    ) //tinker's main Android lib compile('com.tencent.tinker:tinker-android-lib:1.7.7') compile "com.android.support:multidex:1.0.1" }
  3. 將以下內容拷貝到你moudle下面的build.gradle檔案末尾

    def gitSha() {
        try {
            String gitRev = 'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim()
            if (gitRev == null) {
                throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
            }
            return gitRev
        } catch (Exception e) {
            throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
        }
    }
    
    def bakPath = file("${buildDir}/bakApk/")
    
    /**
     * you can use assembleRelease to build you base apk
     * use tinkerPatchRelease -POLD_APK=  -PAPPLY_MAPPING=  -PAPPLY_RESOURCE= to build patch
     * add apk from the build/bakApk
     */
    ext {
        //for some reason, you may want to ignore tinkerBuild, such as instant run debug build?
        tinkerEnabled = true
    
        //for normal build
        //old apk file to build patch apk
        tinkerOldApkPath = "${bakPath}/tinkerdemo-debug-0420-17-03-27.apk"
        //proguard mapping file to build patch apk
        tinkerApplyMappingPath = "${bakPath}/app-debug-1018-17-32-47-mapping.txt"
        //resource R.txt to build patch apk, must input if there is resource changed
        tinkerApplyResourcePath = "${bakPath}/tinkerdemo-debug-0420-17-03-27-R.txt"
    
        //only use for build all flavor, if not, just ignore this field
        tinkerBuildFlavorDirectory = "${bakPath}/app-1018-17-32-47"
    }
    
    
    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() {
        return hasProperty("TINKER_ID") ? TINKER_ID : gitSha()
    }
    
    def buildWithTinker() {
        return hasProperty("TINKER_ENABLE") ? TINKER_ENABLE : ext.tinkerEnabled
    }
    
    def getTinkerBuildFlavorDirectory() {
        return ext.tinkerBuildFlavorDirectory
    }
    
    if (buildWithTinker()) {
        apply plugin: 'com.tencent.tinker.patch'
    
        tinkerPatch {
            /**
             * necessary,default 'null'
             * the old apk path, use to diff with the new apk to build
             * add apk from the build/bakApk
             */
            oldApk = getOldApkPath()
            /**
             * optional,default 'false'
             * there are some cases we may get some warnings
             * if ignoreWarning is true, we would just assert the patch process
             * case 1: minSdkVersion is below 14, but you are using dexMode with raw.
             *         it must be crash when load.
             * case 2: newly added Android Component in AndroidManifest.xml,
             *         it must be crash when load.
             * case 3: loader classes in dex.loader{} are not keep in the main dex,
             *         it must be let tinker not work.
             * case 4: loader classes in dex.loader{} changes,
             *         loader classes is ues to load patch dex. it is useless to change them.
             *         it won't crash, but these changes can't effect. you may ignore it
             * case 5: resources.arsc has changed, but we don't use applyResourceMapping to build
             */
            ignoreWarning = false
    
            /**
             * optional,default 'true'
             * whether sign the patch file
             * if not, you must do yourself. otherwise it can't check success during the patch loading
             * we will use the sign config with your build type
             */
            useSign = true
    
            /**
             * optional,default 'true'
             * whether use tinker to build
             */
            tinkerEnable = buildWithTinker()
    
            /**
             * Warning, applyMapping will affect the normal android build!
             */
            buildConfig {
                /**
                 * optional,default 'null'
                 * if we use tinkerPatch to build the patch apk, you'd better to apply the old
                 * apk mapping file if minifyEnabled is enable!
                 * Warning:
                 * you must be careful that it will affect the normal assemble build!
                 */
                applyMapping = getApplyMappingPath()
                /**
                 * optional,default 'null'
                 * It is nice to keep the resource id from R.txt file to reduce java changes
                 */
                applyResourceMapping = getApplyResourceMappingPath()
    
                /**
                 * necessary,default 'null'
                 * because we don't want to check the base apk with md5 in the runtime(it is slow)
                 * tinkerId is use to identify the unique base apk when the patch is tried to apply.
                 * we can use git rev, svn rev or simply versionCode.
                 * we will gen the tinkerId in your manifest automatic
                 */
                tinkerId = getTinkerIdValue()
    
                /**
                 * if keepDexApply is true, class in which dex refer to the old apk.
                 * open this can reduce the dex diff file size.
                 */
                keepDexApply = false
            }
    
            dex {
                /**
                 * optional,default 'jar'
                 * only can be 'raw' or 'jar'. for raw, we would keep its original format
                 * for jar, we would repack dexes with zip format.
                 * if you want to support below 14, you must use jar
                 * or you want to save rom or check quicker, you can use raw mode also
                 */
                dexMode = "jar"
    
                /**
                 * necessary,default '[]'
                 * what dexes in apk are expected to deal with tinkerPatch
                 * it support * or ? pattern.
                 */
                pattern = ["classes*.dex",
                           "assets/secondary-dex-?.jar"]
                /**
                 * necessary,default '[]'
                 * Warning, it is very very important, loader classes can't change with patch.
                 * thus, they will be removed from patch dexes.
                 * you must put the following class into main dex.
                 * Simply, you should add your own application {@code tinker.sample.android.SampleApplication}
                 * own tinkerLoader, and the classes you use in them
                 *
                 */
                loader = [
                        //use sample, let BaseBuildInfo unchangeable with tinker
                        "tinker.sample.android.app.BaseBuildInfo"
                ]
            }
    
            lib {
                /**
                 * optional,default '[]'
                 * what library in apk are expected to deal with tinkerPatch
                 * it support * or ? pattern.
                 * for library in assets, we would just recover them in the patch directory
                 * you can get them in TinkerLoadResult with Tinker
                 */
                pattern = ["lib/*/*.so"]
            }
    
            res {
                /**
                 * optional,default '[]'
                 * what resource in apk are expected to deal with tinkerPatch
                 * it support * or ? pattern.
                 * you must include all your resources in apk here,
                 * otherwise, they won't repack in the new apk resources.
                 */
                pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
    
                /**
                 * optional,default '[]'
                 * the resource file exclude patterns, ignore add, delete or modify resource change
                 * it support * or ? pattern.
                 * Warning, we can only use for files no relative with resources.arsc
                 */
                ignoreChange = ["assets/sample_meta.txt"]
    
                /**
                 * default 100kb
                 * for modify resource, if it is larger than 'largeModSize'
                 * we would like to use bsdiff algorithm to reduce patch file size
                 */
                largeModSize = 100
            }
    
            packageConfig {
                /**
                 * optional,default 'TINKER_ID, TINKER_ID_VALUE' 'NEW_TINKER_ID, NEW_TINKER_ID_VALUE'
                 * package meta file gen. path is assets/package_meta.txt in patch file
                 * you can use securityCheck.getPackageProperties() in your ownPackageCheck method
                 * or TinkerLoadResult.getPackageConfigByName
                 * we will get the TINKER_ID from the old apk manifest for you automatic,
                 * other config files (such as patchMessage below)is not necessary
                 */
                configField("patchMessage", "tinker is sample to use")
                /**
                 * just a sample case, you can use such as sdkVersion, brand, channel...
                 * you can parse it in the SamplePatchListener.
                 * Then you can use patch conditional!
                 */
                configField("platform", "all")
                /**
                 * patch version via packageConfig
                 */
                configField("patchVersion", "1.0")
            }
            //or you can add config filed outside, or get meta value from old apk
            //project.tinkerPatch.packageConfig.configField("test1", project.tinkerPatch.packageConfig.getMetaDataFromOldApk("Test"))
            //project.tinkerPatch.packageConfig.configField("test2", "sample")
    
            /**
             * if you don't use zipArtifact or path, we just use 7za to try
             */
            sevenZip {
                /**
                 * optional,default '7za'
                 * the 7zip artifact path, it will use the right 7za with your platform
                 */
                zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
                /**
                 * optional,default '7za'
                 * you can specify the 7za path yourself, it will overwrite the zipArtifact value
                 */
    //        path = "/usr/local/bin/7za"
            }
        }
    
        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"
                        }
    
                    }
                }
            }
        }
    }
  4. 改造專案的Application,這裡有兩種方法

    1. 新建一個類繼承DefaultApplicationLike,並通過Tinker提供的註解@DefaultLifeCycle自動生成我們專案的Application類

      @DefaultLifeCycle(
                 application = "tinker.sample.android.app.SampleApplication",             //生成Application類的包及類名,在AndroidManifest中配置需要填寫
                 flags = ShareConstants.TINKER_ENABLE_ALL)                                //要啟用的功能,一般是all
              public class SampleApplicationLike extends DefaultApplicationLike 
    2. 新建一個類繼承DefaultApplicationLike,不新增任何註解。然後自己新建一個Application類繼承TinkerApplication

    ps: 官方建議第一種做法

    注: Application的各個生命週期方法都在這個類中按自己的需要重寫, Tinker會自動呼叫。

  5. 安裝Tinker
    通常最佳安裝時機在ApplicationLike的onBaseContextAttached回撥中

    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
        @Override
        public void onBaseContextAttached(Context base) {
            super.onBaseContextAttached(base);
            //you must install multiDex whatever tinker is installed!
            MultiDex.install(base);
    
            //TinkerManager.initFastCrashProtect();
            //should set before tinker is installed
            //TinkerManager.setUpgradeRetryEnable(true);
    
            //optional set logIml, or you can use default debug log
            //TinkerInstaller.setLogIml(new MyLogImp());
    
            //installTinker after load multiDex
            //or you can put com.tencent.tinker.** to main dex
            //TinkerManager.installTinker(this); 
            TinkerInstaller.install(this); //用預設的patchservice、reporter等
            Tinker tinker = Tinker.with(getApplication());
        }

    這裡我直接用TinkerInstall.install來安裝, 監聽、回撥都用預設的,實際專案中我們一般都是需要自定義的,以實現一些自己的業務邏輯,其實自定義很簡單,具體可參見官方sample。
    ps: 通過TinkerInstaller.onReceiveUpgradePatch方法可以觸發安裝指定補丁檔案。

  6. 生成base版本
    展開AS右側Gradle projects面板, 展開當前模組, 雙擊build下面的assembleDebug/assembleRelease就可生成base版程式, tinker會自動幫我們拷貝到build/bakApk目錄中(路徑可通過配置修改)
  7. 生成補丁包
    1. 修改程式碼後並提交(如果不提交tinkerId會各以前補丁的tinkerId一樣, tinker可能會載入舊版本補丁)
    2. 開啟當前moudle下的build.gradle, 修改ext領域中的tinkerOldApkPath等相關引數指向的路徑。
    3. 在Gradle projects面板中,展開當前模組,雙擊tinker中的tinkerPatchDebug/tinkerPatchRelease即可tinker生成的補丁檔案在build/outputs/tinkerPatch目錄中

至此Tinker已成功集中至你的工程中,具體在何時檢測、載入補丁檔案就是我們自己需要實現的了,通常需要服務端配合實際補丁版本管理和下發。

踩過的坑

  1. tinkerId is not set: 這種情況新手基本都會遇到。 生成的原因有兩個。
    1. git 命令沒有新增到系統環境變數中,因為TinkerId預設基本git提交版本生成(參見gitSha函式)
    2. 工程沒有加入git版本庫管理,使用Tinker必須加入git版本庫管理並提交過。(注:不建議將官方gitSha函式修改為返回固定值)
  2. 不小心點在gradle面板中點了下clean,結果base版本沒了,搞的我打不出來的補丁無法正常安裝到線上了。 這裡要說明下Tinker補丁在安裝前有一個check過程,如果補丁基於的base版本和當前執行的版本不一樣的話,是不會安裝的。所以大家一定要記得備份好每一版的base版本相關的Tinker檔案(包括apk, R.txt,mapping相關)

  3. 寫Demo時由於程式小,覺得用不上分包,就沒引入multidex, 結果一執行報我的ApplicationLike類不能在base.dex中找到。這裡要注意,在安裝Tinker之前,一定要先安裝MultiDex。
    //you must install multiDex whatever tinker is installed!
    MultiDex.install(base);

AndFix

AndFix出自阿里, 和Tinker不同, AndFix上手非常容易, 只需寥寥數行程式碼即可。由於AndFix實現原理是底層通過c++直接操作指標實現,它可以做到載入補丁後實時生效,但它目前只支援替換方法實現內容,不能增加類也不能增加、修改欄位。。。
同樣,廢話不多說,上教程

接入步驟

  1. 在module的build.gradle中新增依賴

    dependencies {
        compile 'com.alipay.euler:andfix:0.5.0@aar'
    }
  2. 初始化PatchManager

    //建議在Application的onCreate中
    patchManager = new PatchManager(context);
    patchManager.init(appversion);//current version
  3. 載入補丁

    //這裡的路徑必須是一個具體的補丁檔案, 不能是目錄,可以多次呼叫載入多個補丁
    patchManager.loadPatch();

生成補丁包

usage: apkpatch -f <new> -t <old> -o <output> -k <keystore> -p <***> -a <alias> -e <***>
 -a,--alias <alias>     keystore entry alias.
 -e,--epassword <***>   keystore entry password.
 -f,--from <loc>        new Apk file path.
 -k,--keystore <loc>    keystore path.
 -n,--name <name>       patch name.
 -o,--out <dir>         output dir.
 -p,--kpassword <***>   keystore password.
 -t,--to <loc>          old Apk file path.

合併多個補丁包

 usage: apkpatch -m <apatch_path...> -o <output> -k <keystore> -p <***> -a <alias> -e <***>
 -a,--alias <alias>     keystore entry alias.
 -e,--epassword <***>   keystore entry password.
 -k,--keystore <loc>    keystore path.
 -m,--merge <loc...>    path of .apatch files.
 -n,--name <name>       patch name.
 -o,--out <dir>         output dir.
 -p,--kpassword <***>   keystore password.