Android 腾讯热修复 Tinker + Flutter

简介: Android 腾讯热修复 Tinker + Flutter

热修复概述


热修复说白了就是”打补丁”,比如你们公司上线一个 App,用户反应有重大 Bug,需要紧急修复。

如果按照通常做法,那就是程序猿加班搞定 Bug,然后测试,重新打包并发布。这样带来的问题就是成本高,效率低。于是,热修复就应运而生。

一般通过事先设定的接口从网上下载无 Bug 的代码来替换有 Bug 的代码。这样就省事多了,用户体验也好。

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


它主要包括以下几个部分:

  • Gradle 编译插件: tinker-patch-gradle-plugin
  • 核心 SDK 库: tinker-android-lib
  • 非 Gradle 编译用户的命令行版本: tinker-patch-cli.jar

Tinker 和阿里 AndFix、美团 Robust 以及 QZone 的比较:


Tinker QZone AndFix Robust
类替换 yes yes no
So替换 yes no no
资源替换 yes yes no
全平台支持 yes yes yes
即时生效 no no yes
性能损耗 较小 较大 较小
补丁包大小 较小 较大 一般
开发透明 yes yes no
复杂度 较低 较低 复杂
gradle支持 yes no no
Rom体积 较大 较小 较小
成功率 较高 较高 一般

总的来说:

  • AndFix 作为 Native 解决方案,首先面临的是稳定性与兼容性问题,更重要的是它无法实现类替换,它是需要大量额外的开发成本的;
  • Robust 兼容性与成功率较高,但是它与 AndFix 一样,无法新增变量与类只能用做的 bugFix 方案;
  • Qzone 方案可以做到发布产品功能,但是它主要问题是插桩带来Dalvik的性能问题,以及为了解决 Art 下内存地址问题而导致补丁包急速增大的。

特别是在 Android N 之后,由于混合编译的 inline 策略修改,对于市面上的各种方案都不太容易解决。而 Tinker 热补丁方案不仅支持类、So 以及资源的替换,它还是 2.X-7.X 的全平台支持。利用 Tinker 我们不仅可以用做bugfix,甚至可以替代功能的发布。Tinker 已运行在微信的数亿 Android 设备上,那么为什么你不使用 Tinker 呢?


Tinker 的已知问题


由于原理与系统限制,Tinker有以下已知问题:

  • Tinker 不支持修改 AndroidManifest.xml,Tinker 不支持新增四大组件;
  • 由于 Google Play 的开发者条款限制,不建议在 GP 渠道动态更新代码;
  • 在 Android N 上,补丁对应用启动时间有轻微的影响;
  • 不支持部分三星android-21机型,加载补丁时会主动抛出"TinkerRuntimeException:checkDexInstall failed";
  • 对于资源替换,不支持修改 remoteView。例如 transition 动画,notification icon 以及桌面图标


Tinker 参数


详见官方文档:Tinker 接入指南

build.gradle 中的具体配置参数与介绍:

我们将原 APK 包称为基准 APK 包,tinkerPatch 直接使用基准 APK 包与新编译出来的 APK 包做差异,得到最终的补丁包。Gradle 配置的参数详细解释如下:

参数 默认值 描述
tinkerPatch 全局信息相关的配置项
tinkerEnable true 是否打开tinker的功能。
oldApk null 基准apk包的路径,必须输入,否则会报错。
newApk null 选填,用于编译补丁apk路径。如果路径合法,即不再编译新的安装包,使用oldApk与newApk直接编译。
outputFolder null 选填,设置编译输出路径。默认在build/outputs/tinkerPatch
ignoreWarning false 如果出现以下的情况,并且ignoreWarning为false,我们将中断编译。因为这些情况可能会导致编译出来的patch包带来风险:


  1. minSdkVersion小于14,但是dexMode的值为”raw”;
  2. 新编译的安装包出现新增的四大组件(Activity, BroadcastReceiver…);
  3. 定义在dex.loader用于加载补丁的类不在main dex中;
  4. 定义在dex.loader用于加载补丁的类出现修改;
  5. resources.arsc改变,但没有使用applyResourceMapping编译。 | | useSign | true | 在运行过程中,我们需要验证基准apk包与补丁包的签名是否一致,我们是否需要为你签名。 | | buildConfig |  | 编译相关的配置项 | | applyMapping | null | 可选参数;在编译新的apk时候,我们希望通过保持旧apk的proguard混淆方式,从而减少补丁包的大小。这个只是推荐设置,不设置applyMapping也不会影响任何的assemble编译。 | | applyResourceMapping | null | 可选参数;在编译新的apk时候,我们希望通过旧apk的R.txt文件保持ResId的分配,这样不仅可以减少补丁包的大小,同时也避免由于ResId改变导致remote view异常。 | | tinkerId | null | 在运行过程中,我们需要验证基准apk包的tinkerId是否等于补丁包的tinkerId。这个是决定补丁包能运行在哪些基准包上面,一般来说我们可以使用git版本号、versionName等等。 | | keepDexApply | false | 如果我们有多个dex,编译补丁时可能会由于类的移动导致变更增多。若打开keepDexApply模式,补丁包将根据基准包的类分布来编译。 | | isProtectedApp | false | 是否使用加固模式,仅仅将变更的类合成补丁。注意,这种模式仅仅可以用于加固应用中。 | | dex |  | dex相关的配置项 | | dexMode | jar | 只能是’raw’或者’jar’。 对于’raw’模式,我们将会保持输入dex的格式。 对于’jar’模式,我们将会把输入dex重新压缩封装到jar。如果你的minSdkVersion小于14,你必须选择‘jar’模式,而且它更省存储空间,但是验证md5时比’raw’模式耗时。默认我们并不会去校验md5,一般情况下选择jar模式即可。 | | pattern | [] | 需要处理dex路径,支持*、?通配符,必须使用’/’分割。路径是相对安装包的,例如assets/… | | loader | [] | 这一项非常重要,它定义了哪些类在加载补丁包的时候会用到。这些类是通过Tinker无法修改的类,也是一定要放在main dex的类。 这里需要定义的类有:
  6. 你自己定义的Application类;
  7. Tinker库中用于加载补丁包的部分类,即com.tencent.tinker.loader.*;
  8. 如果你自定义了TinkerLoader,需要将它以及它引用的所有类也加入loader中;
  9. 其他一些你不希望被更改的类,例如Sample中的BaseBuildInfo类。这里需要注意的是,这些类的直接引用类也需要加入到loader中。或者你需要将这个类变成非preverify。
  10. 使用1.7.6版本之后版本,参数1、2会自动填写。 | | lib |  | lib相关的配置项 | | pattern | [] | 需要处理lib路径,支持*、?通配符,必须使用’/’分割。与dex.pattern一致, 路径是相对安装包的,例如assets/… | | res |  | res相关的配置项 | | pattern | [] | 需要处理res路径,支持*、?通配符,必须使用’/’分割。与dex.pattern一致, 路径是相对安装包的,例如assets/…,务必注意的是,只有满足pattern的资源才会放到合成后的资源包。 | | ignoreChange | [] | 支持*、?通配符,必须使用’/’分割。若满足ignoreChange的pattern,在编译时会忽略该文件的新增、删除与修改。 最极端的情况,ignoreChange与上面的pattern一致,即会完全忽略所有资源的修改。 | | largeModSize | 100 | 对于修改的资源,如果大于largeModSize,我们将使用bsdiff算法。这可以降低补丁包的大小,但是会增加合成时的复杂度。默认大小为100kb | | packageConfig |  | 用于生成补丁包中的’package_meta.txt’文件 | | configField | TINKER_ID, NEW_TINKER_ID | configField(“key”, “value”), 默认我们自动从基准安装包与新安装包的Manifest中读取tinkerId,并自动写入configField。在这里,你可以定义其他的信息,在运行时可以通过TinkerLoadResult.getPackageConfigByName得到相应的数值。但是建议直接通过修改代码来实现,例如BuildConfig。 | | sevenZip |  | 7zip路径配置项,执行前提是useSign为true | | zipArtifact | null | 例如”com.tencent.mm:SevenZip:1.1.10”,将自动根据机器属性获得对应的7za运行文件,推荐使用。 | | path | 7za | 系统中的7za路径,例如”/usr/local/bin/7za”。path设置会覆盖zipArtifact,若都不设置,将直接使用7za去尝试。 |

制作签名文件与 build.gradle 中的相对应:

signingConfigs {
    release {
        try {
            storeFile file("../demojks.jks") // .. 就是项目的根目录  demo.jks与app目录同级
            storePassword '123456'
            keyAlias 'demojks'
            keyPassword '123456'
        } catch (ex) {
            throw new InvalidUserDataException(ex.toString())
        }
    }
    debug {
        storeFile file("../demojks.jks")
        storePassword '123456'
        keyAlias 'demojks'
        keyPassword '123456'
    }
}

Demo 地址:github.com/niezhiyang/…


Flutter 加载热更文件


先要搞明白我们到底需要动态替换一些什么?因此这里需要对 flutter 构建的产物有一定的了解了,怕有些小伙伴不太明白,这里也简单的带一下。


image.png


实际上,我们只需要 copy 一些 aar 文件,so 文件到 native 工程 lib 目录,就可以以 aar 的方式来跑 Flutter 的页面了,这也是典型的已 aar 方式接入 Flutter 的模式。其中,libapp.so,注意在 armeabi 中没有,如果你的 gralde 配置这么写的,abiFilters "armeabi" 那么,copy armeabi-v7a 下面的 so 到 armeabi 中,也是没有任何问题的,关于构建产物提取到原生工程就介绍到这里。线上问题,并不涉及到资源的话,我们只需要替换libapp.so即可实现热更新。

Tinker 好像具备修复 so 的功能吧,可已不可以直接使用 Tinker 呢?

答案是,并不能直接使用 Tinker,因为 Flutter 有自己的一套 so 加载流程,如下图,换句话说,Tinker 使用热修复后的 so 替换之前的 so,Flutter 不感知,因为它自己的环境会依然去读哪个没有修复的 so。



image.png


这里提供两种解决方案:

  • 自己写一个管理端,下发需要替换的 so 即可。但是,这样会涉及到补丁版本的管理,客户端补丁下载管理,而且因为 libapp.so 会比较大,我们目前来看一两个页面,就 8M 多了,因此我们也需要做 differ 拆分,然后下发到客户端之后在合并出功能修复的 so,如果团队实力雄厚有较多的定制型需求可以考虑这种方案。
  • Tinker 虽然具备修复 Android 原生 so 的问题,但是不能直接用来修复 flutter,但是,如果我们利用 Tinker 的热修复,将我们需要修复的 libapp.so 送达客户端,然后,我们想办法找到这个 so,在想办法 hook 以上Flutter 加载 libapp.so 就可以在继续使用 Tinker 热更新的生态的同时,达到 Flutter 热更新的效果。


代码实现


package com.xxx.xxx.common.utils;
import android.content.Context;
import android.util.Log;
import com.xxx.xxx.MyApplication;
import com.tencent.tinker.lib.tinker.Tinker;
import com.tencent.tinker.lib.tinker.TinkerLoadResult;
import com.tencent.tinker.lib.util.TinkerLog;
import com.tencent.tinker.loader.shareutil.ShareConstants;
import com.tencent.tinker.loader.shareutil.SharePatchFileUtil;
import java.io.File;
import java.lang.reflect.Field;
import io.flutter.view.FlutterMain;
/**
 * flutter 热更新
 * create by brzhang
 * date 2019.10.24
 */
public class FlutterPatch {
    private static final String TAG = "FlutterPatch";
    private FlutterPatch() {
    }
    public static void flutterPatchInit() {
        try {
            String libPath = findLibraryFromTinker(MyApplication.getIGameApplicationContext(), "lib/armeabi", "libapp.so");
            Log.e("FlutterPatch", "flutterPatchInit() called   " + libPath);
            Field field = FlutterMain.class.getDeclaredField("sAotSharedLibraryName");
            field.setAccessible(true);
            field.set(null, libPath);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static String findLibraryFromTinker(Context context, String relativePath, String libName) throws UnsatisfiedLinkError {
        final Tinker tinker = Tinker.with(context);
        libName = libName.startsWith("lib") ? libName : "lib" + libName;
        libName = libName.endsWith(".so") ? libName : libName + ".so";
        String relativeLibPath = relativePath + "/" + libName;
        if (tinker.isEnabledForNativeLib() && tinker.isTinkerLoaded()) {
            TinkerLoadResult loadResult = tinker.getTinkerLoadResultIfPresent();
            if (loadResult.libs == null) {
                return libName;
            }
            for (String name : loadResult.libs.keySet()) {
                if (!name.equals(relativeLibPath)) {
                    continue;
                }
                String patchLibraryPath = loadResult.libraryDirectory + "/" + name;
                File library = new File(patchLibraryPath);
                if (!library.exists()) {
                    continue;
                }
                //whether we check md5 when load
                boolean verifyMd5 = tinker.isTinkerLoadVerify();
                if (verifyMd5 && !SharePatchFileUtil.verifyFileMd5(library, loadResult.libs.get(name))) {
                    tinker.getLoadReporter().onLoadFileMd5Mismatch(library, ShareConstants.TYPE_LIBRARY);
                } else {
//                    System.load(patchLibraryPath);
                    TinkerLog.i(TAG, "findLibraryFromTinker success:" + patchLibraryPath);
                    return patchLibraryPath;
                }
            }
        }
        return libName;
    }
}
//然后在你的application 的 onCreate中调用
Flutter.startInitialization(this);
FlutterPatch.flutterPatchInit();

嗯,不到 80 行代码就搞定了 Flutter 热更新了,当然我这里只写了 armeabi 架构的,这是因为我们项目只需要这个架构,如果你的项目有多种,这里需针对性修改一下,最后可以看一下。


image.png


当 Tinker 下发补丁成功之后,我们的应用 data/data 目录会有这个生成这个 libapp.so 的补丁。然后使用上面的代码去偷梁换柱即可实现修复了。而且可以看一下,补丁的大小因为 Tinker 做了拆分包的缘故,会远远小于8M。


image.png


来对比下两种方案:


image.png


Android 端 Flutter 环境目前是:

  • Flutter 1.9.1+hotfix.6 • channel stable

如果 Flutter 升级到 Channel stable, v1.12.13+hotfix.5,热更新模块需要稍微修改一下:

public static void flutterPatchInit() {
    try {
        FlutterLoader flutterLoader = FlutterLoader.getInstance();
        String libPath = findLibraryFromTinker(IGameApplication.getIGameApplicationContext(), "lib/armeabi", "libapp.so");
        Log.e("FlutterPatch", "flutterPatchInit() called   " + libPath);
        Field field = FlutterLoader.class.getDeclaredField("aotSharedLibraryName");
        field.setAccessible(true);
        field.set(flutterLoader, libPath);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

对比之前,sAotSharedLibraryName 由静态私有变量变为了私有变量,而且不再放在 FlutterMain 当中了,放到了 FlutterLoader 当中。


参考



目录
相关文章
|
2月前
|
移动开发 Dart 搜索推荐
打造个性化安卓应用:从零开始的Flutter之旅
【10月更文挑战第20天】本文将引导你开启Flutter开发之旅,通过简单易懂的语言和步骤,让你了解如何从零开始构建一个安卓应用。我们将一起探索Flutter的魅力,实现快速开发,并见证代码示例如何生动地转化为用户界面。无论你是编程新手还是希望扩展技能的开发者,这篇文章都将为你提供价值。
|
2月前
|
开发框架 移动开发 Android开发
安卓与iOS开发中的跨平台解决方案:Flutter入门
【9月更文挑战第30天】在移动应用开发的广阔舞台上,安卓和iOS两大操作系统各自占据半壁江山。开发者们常常面临着选择:是专注于单一平台深耕细作,还是寻找一种能够横跨两大系统的开发方案?Flutter,作为一种新兴的跨平台UI工具包,正以其现代、响应式的特点赢得开发者的青睐。本文将带你一探究竟,从Flutter的基础概念到实战应用,深入浅出地介绍这一技术的魅力所在。
84 7
|
3月前
|
开发框架 搜索推荐 开发工具
打造个性化安卓应用:从零开始的Flutter之旅
【8月更文挑战第51天】本文是一篇面向初学者的Flutter入门教程,旨在通过简单易懂的语言和实际代码示例,引导读者步入跨平台移动应用开发的世界。文章首先介绍了Flutter的基本概念和优势,然后逐步展示了如何搭建开发环境、创建第一个Flutter应用,并实现了一个简单的待办事项列表。最后,文章探讨了Flutter在实现高性能和美观界面方面的潜力,鼓励读者发挥创意,探索更多可能。
87 15
|
3月前
|
Dart 开发工具 Android开发
在 Android 系统上搭建 Flutter 环境的具体步骤是什么?
在 Android 系统上搭建 Flutter 环境的具体步骤是什么?
|
3月前
|
开发框架 Dart 前端开发
Android 跨平台方案对比之Flutter 和 React Native
本文对比了 Flutter 和 React Native 这两个跨平台移动应用开发框架。Flutter 使用 Dart 语言,提供接近原生的性能和丰富的组件库;React Native 则基于 JavaScript,具备庞大的社区支持和灵活性。两者各有优势,选择时需考虑团队技能和项目需求。
380 8
|
3月前
|
安全 Android开发 开发者
探索安卓开发的未来:Kotlin的崛起与Flutter的挑战
在移动开发的广阔天地中,安卓平台始终占据着举足轻重的地位。随着技术的不断进步和开发者需求的多样化,Kotlin和Flutter成为了改变游戏规则的新玩家。本文将深入探讨Kotlin如何以其现代化的特性赢得开发者的青睐,以及Flutter凭借跨平台的能力如何挑战传统的安卓开发模式。通过实际案例分析,我们将揭示这两种技术如何塑造未来的安卓应用开发。
77 6
|
4月前
|
搜索推荐 IDE 开发工具
打造个性化安卓应用:从零开始的Flutter之旅
在数字时代的浪潮中,拥有一款个性化且高效的移动应用已成为许多创业者和企业的梦想。本文将引导你使用Flutter框架,从零基础开始构建一个安卓应用,不仅涉及界面设计、功能实现,还包括性能优化的关键技巧。通过简洁易懂的语言和实用的代码示例,我们将一起探索如何让你的应用在众多竞争者中脱颖而出。 【8月更文挑战第31天】
|
4月前
|
存储 开发工具 Android开发
打造你的专属安卓应用:从零开始的Flutter之旅
【8月更文挑战第31天】在数字时代的浪潮中,拥有一款属于自己的应用不仅是梦想的启航,也是技术实力的展现。本文将引导你使用Flutter框架,轻松步入安卓应用的开发世界。无论你是编程新手还是希望拓展技能边界的开发者,跟随这篇指南,你将学会如何搭建开发环境、设计用户界面,并实现基本功能。让我们一起探索代码的力量,开启一段创造之旅吧!
|
4月前
|
开发框架 Dart 搜索推荐
打造个性化安卓应用:从零开始的Flutter之旅
【8月更文挑战第31天】在数字化浪潮中,拥有一款个性化的移动应用是许多人的梦想。本文将引导你使用Flutter框架,快速入门安卓应用开发。我们会一起探索Flutter的基础概念,并通过一个简单的计数器应用示例,展示如何实现交互式界面。无论你是编程新手还是希望扩展技能边界的开发者,这篇文章都将为你开启一扇新窗,让你看到用代码创造美丽事物的无限可能。
|
4月前
|
存储 搜索推荐 Android开发
打造个性化安卓应用:从零开始的Flutter之旅
【8月更文挑战第31天】 在数字时代的浪潮中,移动应用成为连接用户与服务的桥梁。本文将引导你使用Flutter框架,从无到有构建一个具有独特风格的安卓应用,让你在编程的海洋里扬帆起航,探索个性化应用的秘密花园。我们将一步步揭开Flutter的神秘面纱,通过实例代码带你领略它的魅力所在。准备好了吗?让我们一起开启这段激动人心的旅程!