Tinker初次体验

1.code

code repo:

git clone https://github.com/ShineLiu/tinker.git

你也可以clone Tinker官网的代码:https://github.com/Tencent/tinker.git

2.build

1). Android Studio打开工程tinker-sample-android。

解决几个issue(Issue.md中列出来遇到的问题)。

Issue 1
WARNING: The specified Android SDK Build Tools version (26.0.2) is ignored, as it is below the minimum supported version (28.0.3) for Android Gradle Plugin 3.2.1.
Android SDK Build Tools 28.0.3 will be used.
To suppress this warning, remove "buildToolsVersion '26.0.2'" from your build.gradle file, as each version of the Android Gradle Plugin now has a default version of the build tools.
Remove Build Tools version and sync project
Affected Modules: app

将targetCompatibility改成28.0.3即可

Issue 2

在AS自带命令行中输入 gradlew tinkerPatchDebug,如果其中报错

Warning:ignoreWarning is false, but resources.arsc is changed, you should use applyResourceMapping mode to build the new apk, otherwise, it may be crash at some times
com.tencent.tinker.build.util.TinkerPatchException: ignoreWarning is false, but resources.arsc is changed, you should use applyResourceMapping mode to build the new apk, otherwise, it may be crash at some times
FAILURE: Build failed with an exception.

可在Gradle中尝试打开警告忽略
ignoreWarning = false -> ignoreWarning = true.

Issue 3

AndroidManifest文件中不应该定义minSdkVersion和targetSdkVersion
Fixed: 删除AndroidManifest文件中的这行代码

<uses-sdk android:minSdkVersion=“15” android:targetSdkVersion=“22”/>

Issue 4

在app/build.gradle中,需要指定git版本号:

String gitRev = 'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim()

上面的代码修改为:

String gitRev = GIT_VERSION

其中GIT_VERSION在gradle.properties中定义:GIT_VERSION=2.14.1
奇怪的是,build成功之后,再使用修改前的代码,没有问题。

###2).调整patch的加载目录
在load Patch Button的点击响应中有一行代码就是在加载patch:

TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), getCacheDir() + "/patch_signed_7zip.apk"); //Tinker官方用的并不是这个地址,你也可以自己设置路径。

其中onReceiveUpgradePatch()方法的第二个参数就是patch的路径。

下面是各种地址的说明:

context.getCacheDir() ; // /data/data/包名/cache
context.getFilesDir();  // /data/data/包名/files

context.getExternalFilesDir(); // /sdcard/Android/data/包名/files
context.getExternalCacheDir(); // /sdcard/Android/data/包名/cache

Environment.getExternalStorageDirectory(); // /storage/emulated/0
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM); // /storage/emulated/0/DCIM, 另外还有MOVIE/MUSIC等很多种标准路径

值得注意的是Tinker官方用的并不是getCacheDir()这个地址,而是Environment.getExternalStorageDirectory().getAbsolutePath()。该路径跟网上很多文档中说的sdcard根目录根本就不一样。而是/storage/emulated/0/ 。你也可以自己设置路径。

这里我们使用getCacheDir()目录。

3.打包

Tinker的原理简单来说就是在修改代码之后,重修build生成apk;然后比较新包和旧包的不同地方,根据这个不同生成补丁patch;然后下发patch,在用户手机上生成新包。

Tinker热修复,也就是在已经安装了apk的基础上,再安装补丁,以修复或解决某些问题。所以我们要先build一个基础包。所谓基础包就是已经安装在手机上的apk。一般而言就是已经上架的apk文件。
然后就是打补丁patch了,并提供下载。安装完成之后,重新启动app即可。
i. 基础包
ii. 修改代码,build补丁patch
iii.下载patch,安装补丁

简单的公式说明:
打补丁下发:patch = diff(old_apk, new_apk)
安装补丁: new_apk = old_apk + patch

看起来是不是像git的工作原理?Tinker为减少下发的patch包大小,而使用一套diff算法来生成一个较小的包,这就是补丁。

1)基础包

a.修改tinker-sample-android\app的build.gradle文件:

tinkerOldApkPath = "${bakPath}/app-debug-1018-17-32-47.apk"

修改为

tinkerOldApkPath = "${bakPath}/old-app.apk"

待会会使用到old-app.apk,也就是在生成patch的时候,作为基础包使用。

b.执行gradle task:assembleDebug。右击Android Studio右上角Gradle -> :app -> Tasks -> build -> assembleDebug
或者Android Studio Terminal中执行:gradlew assembleDebug

c.执行完成后,会在 tinker-sample-android\app\build\bakApk中生成app-debug-0329-14-49-41.apk,其命名中包含了时间,所以每次build都会有新的apk生成。

d.安装生成的apk

e.打开apk,点击"SHOW INFO"按钮,查看弹窗上的文字(我们接下来会修改该文字来生成补丁包)

2)修改代码,build补丁patch

a.先将基础包(前面那个app-debug-0329-14-49-41.apk文件)重命名为old-app.apk

b.修改代码,将上面弹窗的文字改掉。修改方法showInfo()内容, 在文字内容编辑前添加:

sb.append(String.format("[New apk:] \n"));

c.执行gradle task:tinkerPatchDebug。右击Android Studio右上角Gradle -> :app -> Tasks -> tinker -> tinkerPatchDebug
或者Android Studio Terminal中执行:gradlew tinkerPatchDebug

d.执行完成后,会在 tinker-sample-android\app\build\outputs\apk\tinkerPatch中生成多个patch apk,其中patch_signed_7zip.apk就是我们需要的。

3)下载patch,安装补丁

a.下载patch
这里我们就不配置下载了。我们直接把patch包拷贝到手机指定目录。该目录我们可以从代码中找到。
上面已经提到了加载patch的路径是getCacheDir():/data/data/包名/cache。
具体到sample实例,patch的保存路径是/data/data/tinker.sample.android/cache。
将patch_signed_7zip.apk拷贝到该目录。右击Android Studio右下角的Device File Explorer, 找到目录后,右击上传即可:

b.安装补丁
点击"LOAD PATCH"按钮加载patch包. 重启app。点击"SHOW INFO"按钮,查看弹窗上的文字多了一行内容"[New apk:]".

好了,这里就成功地完成了一次Tinker热修复。

4.集成到自己的工程

上面讲解了如何build Tinker,以及配置其环境。下面就来实战。

1). 添加gradle依赖

官方建议我们采用gradle的形式接入,好处是在gradle插件tinker-patch-gradle-plugin中官方帮我们完成proguard、multiDex以及Manifest处理等工作。
在项目的build.gradle文件中添加如下代码:

buildscript {
    dependencies {
        classpath("com.tencent.tinker:tinker-patch-gradle-plugin:${TINKER_VERSION}")
    }
}

其中TINKER_VERSION定义在项目的gradle.properties中

TINKER_VERSION=1.9.11

2).添加tinker的库依赖

在app的gradle文件(app/build.gradle)中dependencies{}添加如下代码

    if (is_gradle_3()) {
        implementation fileTree(dir: 'libs', include: ['*.jar'])
        testImplementation 'junit:junit:4.12'
        //implementation "com.android.support:appcompat-v7:23.1.1"
        implementation 'com.android.support:appcompat-v7:28.0.0'
        api("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") { changing = true }

        // Maven local cannot handle transist dependencies.
        implementation("com.tencent.tinker:tinker-android-loader:${TINKER_VERSION}") { changing = true }

        annotationProcessor("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }
        compileOnly("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }

        implementation "com.android.support:multidex:1.0.1"
        //use to test multiDex
        // implementation group: 'com.google.guava', name: 'guava', version: '19.0'
        // implementation "org.scala-lang:scala-library:2.11.7"
    } else {
        compile fileTree(dir: 'libs', include: ['*.jar'])
        testCompile 'junit:junit:4.12'
        compile "com.android.support:appcompat-v7:28.0.0"
        compile("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") { changing = true }
        provided("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }

        compile "com.android.support:multidex:1.0.1"

        //use to test multiDex
        // compile group: 'com.google.guava', name: 'guava', version: '19.0'
        // compile "org.scala-lang:scala-library:2.11.7"
    }

这段差不多是从Tinker-Sample中复制而来。其中有一个判断,是在判断当前Gradle的版本是否大于3. 其中is_gradle_3()定义在项目的build.gradle中。

3).开启multiDex

multidex的解决方案主要是针对AndroidStudio和Gradle编译环境的,将一个dex文件拆成两个或多个dex文件, 以解决方法数超过了65536的问题。在Android 5.0以前使用multidex需要手动引入Google提供的android-support-multidex.jar这个jar包。而从Android 5.0开始,Andorid默认支持了multidex。
方法数控制在65536以内,没有越界的话,是不会分包的,解压apk,你会发现apk里只有一个classes.dex。
app的gradle文件中添加:

defaultConfig {
    ......
    multiDexEnabled true
}

4).开启支持大工程模式

Tinker文档中推荐将jumboMode设置为true。
app的gradle文件中添加:

    android {
        ......
        //Tinker文档中推荐将jumboMode设置为true,表示支持大工程模式
        dexOptions {
            jumboMode = true
        }
    }

5).配置Task

gradle中的工作还没有完,下面开始配置build的task。将下面的代码copy到app的gradle文件中:

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}/old-app.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}/app-debug-1018-17-32-47-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") ? Boolean.parseBoolean(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

            /**
            * optional, default 'false'
            * Whether tinker should treat the base apk as the one being protected by com.tinker.my.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

            /**
            * optional, default 'false'
            * Whether tinker should support component hotplug (add new component dynamically).
            * If this attribute is true, the component added in new apk will be available after
            * patch is successfully loaded. Otherwise an error would be announced when generating patch
            * on compile-time.
            *
            * <b>Notice that currently this feature is incubating and only support NON-EXPORTED Activity</b>
            */
            supportHotplugComponent = 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 com.tinker.my.app.SampleApplication}
            * own tinkerLoader, and the classes you use in them
            *
            */
            loader = [
                    //use sample, let BaseBuildInfo unchangeable with tinker
                    "com.tinker.my.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
    def date = new Date().format("MMdd-HH-mm-ss")

    /**
    * bak apk and mapping
    */
    android.applicationVariants.all { variant ->
        /**
        * task type, you want to bak
        */
        def taskName = variant.name

        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.first().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"
                    }

                }
            }
        }
    }
}

仔细看一下里面的注释,实际上这段gradle是在配置tinker task。

6). copy Tinker-Sample中的Java代码

将Tinker-Sample中的代码copy到自己的工程中,如下图。解决掉其中的引用冲突。

7). 修改UI

将MainActivity.java中的代码也copy过来,注意保持layout文件不要变。
另外,修改patch的保存目录:

    loadPatchButton.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), Environment.getExternalStorageDirectory().getAbsolutePath() + "/patch_signed_7zip.apk");
        }
    });

8). 修改layout文件

layout比较简单,文件不一定要copy。想自己写的也可以自己动手。这里我也是将Tinker-Sample中layout copy过来。显示出来的效果:

9). build

按照上面的方式build和打包即可。

Logo

瓜分20万奖金 获得内推名额 丰厚实物奖励 易参与易上手

更多推荐