Tinker热更新

一:什么是 Tinker?

   Tinker  是微信官方的 Android  热补丁解决方案,它支持动态下发代码、So库以及资源,让应用能够在不需要重新安装的情况下实现更新。

官方接入指南:

Tinker官方接入指南

二: 具体集成步骤:

1:添加gradle依赖

在项目的build.gradle中,添加tinker-patch-gradle-plugin的依赖

buildscript {
    dependencies {
         classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.9.1')
    }
}

2:然后在app的gradle文件app/build.gradle,我们需要添加tinker的库依赖以及apply tinker的gradle插件,build.gradle配置如下:

apply plugin: 'com.android.application'

android {
    //签名配置
    signingConfigs {
        release {
            keyAlias 'tinker'
            keyPassword '123456'
            storeFile file('E:/androidProjects/tinker.jks')
            storePassword '123456'
        }
    }

    compileSdkVersion 26
    defaultConfig {
        applicationId "example.glh.tencenthotfix"
        minSdkVersion 15
        targetSdkVersion 26
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"

        //由于报annotation错误才引入这一句,可不用
        javaCompileOptions { annotationProcessorOptions { includeCompileClasspath = true } }

        // tinker 基本配置
        multiDexEnabled true
        buildConfigField "String", "MESSAGE", "\"I am the base apk\""
        buildConfigField "String", "TINKER_ID", "\"${getTinkerIdValue()}\""
        buildConfigField "String", "PLATFORM", "\"all\""
    }
    // Tinker 推荐设置
    dexOptions {
        jumboMode = true
    }

    buildTypes {
        release {
            minifyEnabled true
            signingConfig signingConfigs.release
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
        debug {
            signingConfig signingConfigs.release
        }
    }
    configurations.all {
        resolutionStrategy.force 'com.android.support:support-annotations:27.1.1'
    }
}

dependencies {
    implementation fileTree(include: ['*.jar'], dir: 'libs')
    implementation 'com.android.support:appcompat-v7:26.1.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
    // 多dex 打包的类库
    compile 'com.android.support:multidex:1.0.1'
    //可选,用于生成application类
    provided 'com.tencent.tinker:tinker-android-anno:1.9.1'
    //tinker的核心库
    compile 'com.tencent.tinker:tinker-android-lib:1.9.1'
}

//=======================Tinker 配置=======================================

def gitSha() {
    try {
        // String gitRev = 'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim()
        String gitRev = "1008611"
        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/")

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}/app-release-0426-14-59-42.apk"
    //proguard mapping file to build patch apk
    tinkerApplyMappingPath = "${bakPath}/app-release-0426-14-59-42-mapping.txt"
    //resource R.txt to build patch apk, must input if there is resource changed
    tinkerApplyResourcePath = "${bakPath}/app-release-0426-14-59-42-R.txt"

    //only use for build all flavor, if not, just ignore this field
    tinkerBuildFlavorDirectory = "${bakPath}/app-0421-17-11-26"
}

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 {
        /**
         * 默认为null
         * 将旧的apk和新的apk建立关联
         * 从build / bakApk添加apk
         */
        oldApk = getOldApkPath()
        /**
         * 可选,默认'false'
         *有些情况下我们可能会收到一些警告
         *如果ignoreWarning为true,我们只是断言补丁过程
         * case 1:minSdkVersion低于14,但是你使用dexMode与raw。
         * case 2:在AndroidManifest.xml中新添加Android组件,
         * case 3:装载器类在dex.loader {}不保留在主要的dex,
         * 它必须让tinker不工作。
         * case 4:在dex.loader {}中的loader类改变,
         * 加载器类是加载补丁dex。改变它们是没有用的。
         * 它不会崩溃,但这些更改不会影响。你可以忽略它
         * case 5:resources.arsc已经改变,但是我们不使用applyResourceMapping来构建
         */
        ignoreWarning = false

        /**
         *可选,默认为“true”
         * 是否签名补丁文件
         * 如果没有,你必须自己做。否则在补丁加载过程中无法检查成功
         * 我们将使用sign配置与您的构建类型
         */
        useSign = true

        /**
         可选,默认为“true”
         是否使用tinker构建
         */
        tinkerEnable = buildWithTinker()

        /**
         * 警告,applyMapping会影响正常的android build!
         */
        buildConfig {
            /**
             *可选,默认为'null'
             * 如果我们使用tinkerPatch构建补丁apk,你最好应用旧的
             * apk映射文件如果minifyEnabled是启用!
             * 警告:你必须小心,它会影响正常的组装构建!
             */
            applyMapping = getApplyMappingPath()
            /**
             *可选,默认为'null'
             * 很高兴保持资源ID从R.txt文件,以减少java更改
             */
            applyResourceMapping = getApplyResourceMappingPath()

            /**
             *必需,默认'null'
             * 因为我们不想检查基地apk与md5在运行时(它是慢)
             * tinkerId用于在试图应用补丁时标识唯一的基本apk。
             * 我们可以使用git rev,svn rev或者简单的versionCode。
             * 我们将在您的清单中自动生成tinkerId
             */
            tinkerId = getTinkerIdValue()

            /**
             *如果keepDexApply为true,则表示dex指向旧apk的类。
             * 打开这可以减少dex diff文件大小。
             */
            keepDexApply = false
        }

        dex {
            /**
             *可选,默认'jar'
             * 只能是'raw'或'jar'。对于原始,我们将保持其原始格式
             * 对于jar,我们将使用zip格式重新包装dexes。
             * 如果你想支持下面14,你必须使用jar
             * 或者你想保存rom或检查更快,你也可以使用原始模式
             */
            dexMode = "jar"

            /**
             *必需,默认'[]'
             * apk中的dexes应该处理tinkerPatch
             * 它支持*或?模式。
             */
            pattern = ["classes*.dex",
                       "assets/secondary-dex-?.jar"]
            /**
             *必需,默认'[]'
             * 警告,这是非常非常重要的,加载类不能随补丁改变。
             * 因此,它们将从补丁程序中删除。
             * 你必须把下面的类放到主要的dex。
             * 简单地说,你应该添加自己的应用程序{@code tinker.sample.android.SampleApplication}
             * 自己的tinkerLoader,和你使用的类
             *
             */
            loader = [
                    //use sample, let BaseBuildInfo unchangeable with tinker
                    "tinker.sample.android.app.BaseBuildInfo"
            ]
        }

        lib {
            /**
             可选,默认'[]'
             apk中的图书馆应该处理tinkerPatch
             它支持*或?模式。
             对于资源库,我们只是在补丁目录中恢复它们
             你可以得到他们在TinkerLoadResult与Tinker
             */
            pattern = ["lib/armeabi/*.so"]
        }

        res {
            /**
             *可选,默认'[]'
             * apk中的什么资源应该处理tinkerPatch
             * 它支持*或?模式。
             * 你必须包括你在这里的所有资源,
             * 否则,他们不会重新包装在新的apk资源。
             */
            pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]

            /**
             *可选,默认'[]'
             *资源文件排除模式,忽略添加,删除或修改资源更改
             * *它支持*或?模式。
             * *警告,我们只能使用文件没有relative与resources.arsc
             */
            ignoreChange = ["assets/sample_meta.txt"]

            /**
             *默认100kb
             * *对于修改资源,如果它大于'largeModSize'
             * *我们想使用bsdiff算法来减少补丁文件的大小
             */
            largeModSize = 100
        }

        packageConfig {
            /**
             *可选,默认'TINKER_ID,TINKER_ID_VALUE','NEW_TINKER_ID,NEW_TINKER_ID_VALUE'
             * 包元文件gen。路径是修补程序文件中的assets / package_meta.txt
             * 你可以在您自己的PackageCheck方法中使用securityCheck.getPackageProperties()
             * 或TinkerLoadResult.getPackageConfigByName
             * 我们将从旧的apk清单为您自动获取TINKER_ID,
             * 其他配置文件(如下面的patchMessage)不是必需的
             */
            configField("patchMessage", "tinker is sample to use")
            /**
             *只是一个例子,你可以使用如sdkVersion,品牌,渠道...
             * 你可以在SamplePatchListener中解析它。
             * 然后你可以使用补丁条件!
             */
            configField("platform", "all")
            /**
             * 补丁版本通过packageConfig
             */
            configField("patchVersion", "1.0")
        }
        //或者您可以添加外部的配置文件,或从旧apk获取元值
        //project.tinkerPatch.packageConfig.configField("test1", project.tinkerPatch.packageConfig.getMetaDataFromOldApk("Test"))
        //project.tinkerPatch.packageConfig.configField("test2", "sample")

        /**
         * 如果你不使用zipArtifact或者path,我们只是使用7za来试试
         */
        sevenZip {
            /**
             * 可选,默认'7za'
             * 7zip工件路径,它将使用正确的7za与您的平台
             */
            zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
            /**
             * 可选,默认'7za'
             * 你可以自己指定7za路径,它将覆盖zipArtifact值
             */
//        path = "/usr/local/bin/7za"
        }
    }

    List 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[0].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"
                    }

                }
            }
        }
    }
}

3:修改application,

@SuppressWarnings("unused")
@DefaultLifeCycle(application = "tinker.sample.android.app.SampleApplication",
                  flags = ShareConstants.TINKER_ENABLE_ALL,
                  loadVerifyFlag = false)
public class SampleApplicationLike extends DefaultApplicationLike {
    private static final String TAG = "Tinker.SampleApplicationLike";

   ..............
   ..............
   
    @Override
    public void onCreate() {
        super.onCreate();
        //此处写自己的Application逻辑
    }

4:注册一个加载补丁回调结果的Service

service中所做的操作是在你加载成功热更新插件后,会提示你更新成功,并且这里做了锁屏操作就会加载热更新插件,同时不要忘记在清单文件注册这个service


public class SampleApplicationLike extends DefaultApplicationLike {
    private static final String TAG = "Tinker.SampleApplicationLike";

    public SampleApplicationLike(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag,
                                 long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) {
        super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);
    }

    /**
     * install multiDex before install tinker
     * so we don't need to put the tinker lib classes in the main dex
     *
     * @param base
     */
    @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);
        TinkerInstaller.install(this,new DefaultLoadReporter(getApplication())
                ,new DefaultPatchReporter(getApplication()),new DefaultPatchListener(getApplication()),SampleResultService.class,new UpgradePatch());
        Tinker tinker = Tinker.with(getApplication());

        Toast.makeText(getApplication(),"加载完成", Toast.LENGTH_SHORT).show();
    }

    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    public void registerActivityLifecycleCallbacks(Application.ActivityLifecycleCallbacks callback) {
        getApplication().registerActivityLifecycleCallbacks(callback);
    }

    @Override
    public void onCreate() {
        super.onCreate();
        //此处写自己的Application逻辑
    }
}

5 Utils工具类:

public class Utils {

    /**
     * the error code define by myself
     * should after {@code ShareConstants.ERROR_PATCH_INSERVICE
     */
    public static final int ERROR_PATCH_GOOGLEPLAY_CHANNEL      = -5;
    public static final int ERROR_PATCH_ROM_SPACE               = -6;
    public static final int ERROR_PATCH_MEMORY_LIMIT            = -7;
    public static final int ERROR_PATCH_ALREADY_APPLY           = -8;
    public static final int ERROR_PATCH_CRASH_LIMIT             = -9;
    public static final int ERROR_PATCH_RETRY_COUNT_LIMIT       = -10;
    public static final int ERROR_PATCH_CONDITION_NOT_SATISFIED = -11;

    public static final String PLATFORM = "platform";

    public static final int MIN_MEMORY_HEAP_SIZE = 45;

    private static boolean background = false;

    public static boolean isGooglePlay() {
        return false;
    }

    public static boolean isBackground() {
        return background;
    }

    public static void setBackground(boolean back) {
        background = back;
    }

    public static int checkForPatchRecover(long roomSize, int maxMemory) {
        if (Utils.isGooglePlay()) {
            return Utils.ERROR_PATCH_GOOGLEPLAY_CHANNEL;
        }
        if (maxMemory < MIN_MEMORY_HEAP_SIZE) {
            return Utils.ERROR_PATCH_MEMORY_LIMIT;
        }
        //or you can mention user to clean their rom space!
        if (!checkRomSpaceEnough(roomSize)) {
            return Utils.ERROR_PATCH_ROM_SPACE;
        }

        return ShareConstants.ERROR_PATCH_OK;
    }

    public static boolean isXposedExists(Throwable thr) {
        StackTraceElement[] stackTraces = thr.getStackTrace();
        for (StackTraceElement stackTrace : stackTraces) {
            final String clazzName = stackTrace.getClassName();
            if (clazzName != null && clazzName.contains("de.robv.android.xposed.XposedBridge")) {
                return true;
            }
        }
        return false;
    }

    @Deprecated
    public static boolean checkRomSpaceEnough(long limitSize) {
        long allSize;
        long availableSize = 0;
        try {
            File data = Environment.getDataDirectory();
            StatFs sf = new StatFs(data.getPath());
            availableSize = (long) sf.getAvailableBlocks() * (long) sf.getBlockSize();
            allSize = (long) sf.getBlockCount() * (long) sf.getBlockSize();
        } catch (Exception e) {
            allSize = 0;
        }

        if (allSize != 0 && availableSize > limitSize) {
            return true;
        }
        return false;
    }

    public static String getExceptionCauseString(final Throwable ex) {
        final ByteArrayOutputStream bos = new ByteArrayOutputStream();
        final PrintStream ps = new PrintStream(bos);

        try {
            // print directly
            Throwable t = ex;
            while (t.getCause() != null) {
                t = t.getCause();
            }
            t.printStackTrace(ps);
            return toVisualString(bos.toString());
        } finally {
            try {
                bos.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private static String toVisualString(String src) {
        boolean cutFlg = false;

        if (null == src) {
            return null;
        }

        char[] chr = src.toCharArray();
        if (null == chr) {
            return null;
        }

        int i = 0;
        for (; i < chr.length; i++) {
            if (chr[i] > 127) {
                chr[i] = 0;
                cutFlg = true;
                break;
            }
        }

        if (cutFlg) {
            return new String(chr, 0, i);
        } else {
            return src;
        }
    }
}

6:在MainActivity中添加按钮调用Tinker的api,测试实际效果(包含访问android6.0以上sd权限)

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

    private static final int MY_PERMISSIONS_REQUEST_CALL_PHONE = 2;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        findViewById(R.id.button1).setOnClickListener(this);
        findViewById(R.id.button2).setOnClickListener(this);
    }

    @Override
    protected void onResume() {
        super.onResume();
        //判断存储权限,如果没有就申请,用户拒绝就提示某些功能不可用
        setPermission();
        Utils.setBackground(false);
    }

    @Override
    protected void onPause() {
        super.onPause();
        Utils.setBackground(true);
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()){
            case R.id.button1:
                loadPatch();
                break;
            case R.id.button2:
                killApp();
                break;
        }
    }

    /**
     * 加载热补丁插件
     */
    public void loadPatch() {
        TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(),
                Environment.getExternalStorageDirectory().getAbsolutePath() + "/patch_signed_7zip.apk");
    }

    /**
     * 杀死应用加载补丁
     */
    public void killApp() {
        ShareTinkerInternals.killAllOtherProcess(getApplicationContext());
        android.os.Process.killProcess(android.os.Process.myPid());
    }

    private void setPermission() {
        if (ContextCompat.checkSelfPermission(this,
                Manifest.permission.WRITE_EXTERNAL_STORAGE)
                != PackageManager.PERMISSION_GRANTED) {

            ActivityCompat.requestPermissions(this,
                    new String[]{Manifest.permission.READ_EXTERNAL_STORAGE,
                            Manifest.permission.WRITE_EXTERNAL_STORAGE,
                            Manifest.permission.CAMERA
                    }, MY_PERMISSIONS_REQUEST_CALL_PHONE);
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {

        if (requestCode == MY_PERMISSIONS_REQUEST_CALL_PHONE) {
            if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                //获取到存储权限
            } else {
                // Permission Denied
                //拒绝了权限
                //AppContext.showToastText(getResources().getString(R.string.denied_permission));
                //AppContext.showToast(R.string.denied_permission);
            }
            return;
        }
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    }
}

Tinker的集成就这些了,然后就是打基准包和补丁包以及发补丁包。这里需要自己在后台构建补丁包管理系统,在APP中从后台下载补丁包,当然也可以使用Tinkerpatch平台管理发布补丁包,另外还可以通过集成腾讯bugly的热更新方案,bugly热更新使用的是Tinker热更新,同时拥有一套补丁管理发布系统,功能和tinker+Tinkerpatch的一样,bugly热更新文档很详细,但兼容性效果没那么好,亲测用6款手机,有两款手机收不到从bugly发布下来的补丁包,也不知道是不是自己操作失误造成的。Tinkerpatch平台没有试过,但它是收费的,也许效果会好些。

 

三: Tinker热更新结合加固打多渠道包

 如果想要Tinker热更新需要加固,就在tinkerPatchr中的buildConfig里添加isProtectedAPP=true,支持加固模式,然后可以使用腾讯乐固加固工具进行加固大多渠道包,在以前的乐固版本中,一键加固签名打出来的多渠道包是不支持热更新的,它过程是先加固后给加固包签名,最后才拿签名过后的渠道包进行打多渠道,这样就每个渠道包的dex值不一样,不能热更新,后来乐固升级过后先加固,后打多渠道包,最后才对每一个渠道包进行签名。保证dex值一样。亲测这种方式是可以进行热更新的!

四: Tinker集成过程踩过的坑

1:在tinkerPatchr中配置的tinkerId要保持基准包和下发补丁包一样,并且每次升级版本(也就是更换基准包)时要更改tinkerId,建议  tinkerId可以设置成工程的版本号,这样每次升级版本tinkerId都不同

2:升级版本后要备份基准包,后面打补丁包的时候需要用到它

 

你可能感兴趣的:(Tinker热更新)