众安银行的Flutter 热修复实践之路
前言 Flutter是Google开源的构建用户界面(UI)工具包,它帮助开发者通过一套代码库高效构建多平台精美应用,支持移动、Web、桌面和嵌入式平台。目前ZABank已在基金股票等业务成功接入Flutter,在保证用户体验的同时,显著提升了开发效率,节约了研发成本。但官方不支持Flutter 热修
前言
Flutter是Google开源的构建用户界面(UI)工具包,它帮助开发者通过一套代码库高效构建多平台精美应用,支持移动、Web、桌面和嵌入式平台。目前ZABank已在基金股票等业务成功接入Flutter,在保证用户体验的同时,显著提升了开发效率,节约了研发成本。但官方不支持Flutter 热修复功能,这又让我们在业务端无法毫无顾忌地使用此技术进行开发,尤其是金融类的APP,缺乏热修复功能,严重时还会造成巨大的经济损失。为解决此项目难点,我们结合公司业务与项目框架,开发并应用了适合ZA Bank体系的Flutter热修复技术。本文将深入探讨这一实践之路,包括实施过程、挑战与解决方案,以及从中获得的经验教训。希望通过分享这一实践案例,能对大家有所帮助或启发。
一、方案选型
1.1目标
不管是任何一个热修复的框架,总要考虑以下几点:
1.核心目的是确保热修复的基本功能不影响现有的上层业务开发,能够无缝衔接,且无需修改或变动上层业务;
2.需要以极小的接入成本实现,无需对项目进行大规模改动,也不需要完全颠覆现有的代码逻辑和架构;
3.要支持业务的动态扩展和线上热修复的能力,同时能够支持Flutter每个版本的迭代更新,无需复杂的适配工作;
4.必须方便业务升级和扩展,同时支持动态热更新的能力。
1.2 现状
ZABank众安银行从19年上线至今已经历了将近4年的阶段。由于历史原因,ZABank App目前的客户端主要采用了两种不同的跨平台技术方案。首页和基金股票业务采用Flutter技术架构,其他业务采用React Native技术架构。APP端团队人员的技术栈主要为Android/iOS。按照目前人员技术栈,实现Flutter热修复的方案主要有以下几种:
1.2.1 业务改造JS
采用改造存量业务为JS,逻辑通过V8/JSCore解释运行,属于对存量业务的重写,耗时耗力。如果仅将数据逻辑改造为JS,将面临热修复覆盖面不全的问题。另外,将Dart改为JS,会导致现有的Flutter开发工具无法直接使用,与低成本的诉求背道而驰。
1.2.2 AOT 搭载 JIT
Flutter在Release模式下构建的是AOT编译产物,其中iOS使用AOT Assembly,Android默认使用AOT Blob。同时,Flutter也支持JIT Release模式,能够动态加载Kernel snapshot或App-JIT snapshot。然而,在AOT上支持JIT,就能实现动态化的能力。但问题在于AOT所依赖的Dart VM和JIT并不相同。AOT需要一个编译后的“Dart VM”,而JIT依赖的是Dart VM(一个虚拟机,提供语言执行环境)。此外,JIT Release并不支持iOS设备,构建的应用也不能在AppStore上发布。
要实现这一方案,需要将Dart VM抽离出来并进行独立编译,然后以动态库的形式引入项目。初步测试发现,这样做会增大包体积20MB+,不符合我们对适用性的要求。
1.2.3 静态DSL
利用Dart-lang官方提供的Analyzer分析库构建DSL。该库提供了一组API,能对Dart源代码进行分析,并按照文件粒度生成AST对象。AST对象使用整齐的数据结构包含了Dart文件的所有信息,利用这些信息可以便捷地生成所需的DSL。整个分析和转换过程都在离线状态下进行。随后,DSL-JSON以Zip的形式下发,Flutter的AOT侧以此为数据源,完成整个Flutter项目的渲染与交互。
这种方案有一些优势,例如可以保持Flutter/Dart的开发体验,没有平台差异,逻辑动态化依赖静态映射和基础逻辑支持,而非JSCore,有效地避免了性能上的开销。然而,这种方法的实现成本巨大,与我们的人力技术栈不匹配。而且,后期Flutter版本升级可能会影响其实现。因此,我们不考虑采用此方案。
1.2.4 FlutterWeb
2018年,Google首次公开发布了Flutter Web Beta版,旨在进一步实现一份代码、多端运行的愿景。目前,Flutter Web已发布稳定版,经过无数工程师的努力,Flutter Web已能够提供与Flutter Native较为统一的交互行为和视觉体验。
我们了解到Flutter是跨平台的,支持WEB APP、PC等。因此,我们可以尝试通过dart2js的方式,以较低的成本将现有项目转换成标准WEB项目,然后在APP端的H5页面上运行。这种方案可以保持Flutter原本的开发体验,由于最终构建选择了Web方式,因此即使后期Flutter升级,也不太会对整体方案实现产生太大影响。
1.2.5 Shorebird
Shorebird 的主要功能是支持Flutter的热更新。然而,由于Shorebird将补丁存储在谷歌存储上,对于国内用户来说,可能无法正常下载,导致热更新的流程在最后一步遇到阻塞。因此,在中国区域使用Shorebird时,需要考虑这个问题。
此外,关于Shorebird的实现原理,它主要通过Dart服务器拦截和代理Flutter artifact请求来实现热更新。具体的实现细节可能涉及到一些复杂的技术实现,可能需要对Flutter和原生开发有一定的了解。
1.3 思考与选择
在技术选型的时候主要是基于人力成本,技术成本,时间成本三个方面考虑,选择最适合我们的方案。
-
人力成本:人力成本最小化,只需2~4人团队即可维护整个项目的建设。后期更新迭代也无需担心大量兼容性问题。
-
技术成本:本身我们已经是多平台的混合栈,底层已实现了跨平台通信逻辑和最基础的通信层。因此,我们只需维护一套Flutter Web的通信机制,使其更方便和快捷。
-
时间成本:项目立项初期上线紧急,由于银行项目代码不可随意部署到未知平台,我们需借助现有能力完成此任务。
综上考虑,我们采用Flutter Web技术来实现Flutter热修复。
二、整体方案
2.1 基于页面降级的热修复
不管是任何一个热修复的框架,总要考虑以下几点:
使用FlutterWeb进行修复的流程:
当页面遇到了Bug后,我们在有问题的页面上进行修复,然后通过Dart2JS 将项目转换成FlutterWeb,最后在路由中通过将原本跳转的有问题Flutter PageB 切换至 FlutterWeb PageB 的方案达到修复Bug的目的。
2.1.1 页面降级的原理
首先由于我们的路由是基于协议的模式进行跳转的,跳转的每一个页面都在注册表里面存了一份数据,跳转中心就负责根据被注册的路由的优先级来确定跳转的页面是H5,是原生还是Flutter或者是FlutterWeb,基础就在于通过设定不同路由表的页面等级来控制是否跳转到降级页面这个是基础的核心部分。因此我们可以根据下发的动态路由表来确定下一个页面是原生或者H5或者RN,这样就能保存在进入下一个页面的时候能正常加载和修复。
另外业务逻辑同样是可以降级的我们对于每一段同样有个统一的注册中心和逻辑的中心,可以动态的根据注册表的业务逻辑进行动态逻辑,对于一些支持action的业务逻辑也是可以支持原生进行降级的,也就是支持原生部分业务逻辑的修复,不需要将页面进行页面降级,基础的原理就是将方法中的数据当做一种数据流的管道在出现问题进行热修复的时候相当于把管道嫁接到了Flutterweb的管道来支持动态修改业务逻辑的目的,也就是上面讲到的需要磨平端和端的差异性,让端数据无缝衔接。
2.1.2 部分原生逻辑的降级
目前众安银行为了实现真正的业务跨端,定义了一个action的方法类似是这样子的:
在test1方法出错的时候可以动态去修复这个action产生的bug,这部分是可以修复原生代码的,而且是无感知的。目前存在的困难是因为毕竟修复的是H5平台的action会存在说和原生之间内存不共享,因此我们尽量保持action的"干净",这样就可以动态修复原生,当然后续考虑是是否能扩展到任意方法,这部分待研究,因为flutter限制了使用dart:mirror,有个弱项的reflectable可以做到部分的方法映射,但是也有部分不支持比如所私有的api不支持,并且通过代码生成的方式无疑会增加包的体积。
本质上来讲就是说将旧方法的逻辑的调用转发到新方法的调用上来,那么整个链条上的数据就会同样会全部转为H5,隐藏如果能保持整个链条的“干净”是可以修复任何方法的、另外作为热修复的web肯定优先级是高于原先的注册模块的。因此当有新同名的方法action出现的时候优先是执行web版本的。
2.1.3 核心功能消息通道
消息通道是能否降级的成功标志着方案是否能真正的逻辑,我们的做法是如下图所示,这部分相当于是比如对于MethodChannel这种消息的通道如何通过web承接方然后在转移到原生功能是重中之重,flutterweb相当于是flutter业务侧MethodChannel的承接方,将业务方的业务逻辑丢大原生即可实现数据一样可以从原生获取。如何做到无缝衔接呢,就是如何将本身原生MethodChannel的调用转嫁到web层呢。
完成FlutterWeb的承接方,原理就是将FlutterWeb的消息InvolveMethodChannel的方法另外新增一个方法,比如将Flutter原生端通信的管道嫁接到FlutterWeb这端来,这样子就能实现在业务层调用的代码可以通过FlutterWeb的方式来实现,同时将FlutterWeb调用又转接回原生的消息通道,通过原生通道的JS回调会给FlutterWeb端,然后FlutterWeb端再将消息转接回业务调用方,通过这种方式实现了业务侧和web侧的通信机制。
2.2 方案设计
整套热修复的逻辑和设计如下图所示:
后台支撑:
-
设计并搭建一个可靠的后台系统,用于管理热修复的补丁。
-
实现不同平台和版本的发布和管理功能。
-
提供动态调整包子母包的能力,以保障线上修复的成功率。
-
设计和实现一个看板,用于查看安装包管理成功率和热修复的数据情况。
Native支持:
-
利用现有混合栈和协议的开发,整合热修复技术栈。
-
最小化改动,确保整个技术栈与混合栈相吻合。
-
处理混合栈内部通信、路由拦截、登录检验等方面的支持。
-
平滑端和端之间的差异性,实现无平台化的数据传递。
CI JOB支持:
-
集成CI/CD支持,确保打包的记录能够持久化存储。
-
利用Jenkins等构建平台,提前校验发包的正确性。
-
实现智能和动态化的发包流程。
FlutterWeb:
-
设计无侵入、扩展性强、支持动态修复而无需App重启的整体架构。
-
解决Web容器、跨域、字体、文件压缩、国际化等问题。
-
实现真正的无平台化,无缝传输的数据传递。
-
解决动态修复时的容器、跨域等问题。
UI可视化发包工具:
-
自研发包工具,确保本地热修复的简单模板可以方便地随时发包更新。
-
提供快速验证业务的能力,不需要等待很久。
-
设计用户友好的界面,简化发包流程。
-
控制整体打包时间在1分30秒内,确保高效的开发和验证。
2.2.1 修复流程
App下发包时需要特别考虑,而且App包的修复率和安装包的成功率一直是我们线上关注的焦点。整体资源复制分为三个部分:main.dart.js和app原生资源的复制,以及字体HTML文件的复制。
我们团队在App热修复方面提出了一套自研的修复概念,其中之一是母包+Diff包。母包是一个完整的跟随版本的包,包含每个版本不同的资源和数据。母包的作用是与Diff包进行参考,Diff包则是修复代码与当前版本之前每个文件的比对生成的。主要目的是在下发Diff包时进行文件基本的hash校验,以实现修复的目标。
整体包的修复流程分为三个步骤:
1. 母包资源的复制和生成
母包的资源来自两个主要部分:资源图片和代码文件。考虑到FlutterWeb的整体资源分为不变动和动态两部分,每次需要动的部分是main.dart.js,即主代码部分。其余部分是静态资源,基本上不需要修改。整体流程如下图所示,同时增加了三次的容错机制,以防止母包生成失败,确保每个生成的母包与上传给app store审核的母包的hash值一致。
2. Diff包的复制和生成
Diff包是根据线上版本的母包通过diff算法生成的,需要对每个文件进行diff程度的考虑。按照文件路径作为key,文件内容作为value进行diff。如果发现存在差异,就生成diff.path部分,并在下发时将其整体打成一个zip压缩包,与母包合并。然后根据下发的配置表进行patch计算,确定母包哪个文件发生了变动。生成的完整包就是Diff包,整体大小大约为1左右,这完全取决于修复的代码而变动。
3. 全量包的复制和生成
全量包是在Diff包hash校验出错或其他异常情况下的容错机制,旨在最大程度上保证包的修复率。这是一个完整的修复后的数据包,无需根据母包进行diff。它是一个独立运行的包,只需校验最终结果的hash值即可使用。
2.2.2 母包和Diff
1.母包的生成
母包的生成相对简单。在apk打包过程中,将dart代码直接转换为相应的js文件和相关的图片资源文件,打包在一起即可生成一个母包。难点在于如何进行母包的识别,以实现在PC、iOS、Android等多端上的一致性校验。我们设计了一个简单的算法,对母包进行计算生成一个唯一的字符串作为签名,从而标记和识别每个版本的母包。我们以下图所示场景为例,解释一下如何生成母包签名。
1)使用深度优先遍历算法遍历目录Flutter-JS目录,对所有文件计算hash值。每个文件生成一条记录信息,规则为:文件相对路径+连接符+文件hash值。
2)以数组形式整理第一步生成的记录,然后进行字典序排序(以实现多端一致性)。
3)将第二步的结果转换为一个字符串,再进行hash算法处理,生成母包对应的签名串,用以标记母包。
2.补丁的生成
修复代码bug后,重新编译dart文件将生成一个热修复包。此时将其与对应版本的母包进行比对,遵循以下规则:
1)文件在母包中存在但在热修复包中不存在,标记为del(删除)类型;
2)文件在母包中不存在但在热修复包中存在,标记为add(新增)类型;
3)文件在母包中存在且在热修复包中也存在,但hash值不一致,标记为diff(修改)类型;
4)文件在母包和热修复包中存在,且hash值一致,不做任何处理。
按照以上规则,将add类型的文件拷贝到补丁包中,对于diff类型的文件,使用diff工具对齐生成差异文件,并将差异文件拷贝到补丁包中。记录文件类型标记信息到config.ini文件,也放入补丁包中。
3.补丁的合并生效
前面两步为铺垫,客户端收到补丁后,进行合并生效的逻辑非常简单。只需反向执行生成补丁包的操作即可。具体步骤如下:
1)从apk中获取母包,拷贝到目标位置。接着获取补丁包,解压缩补丁包,得到其中的config.ini指引文件。对于del类型的文件,直接在目标位置删除相应文件。对于add类型文件,从补丁包中拷贝到目标位置。对于diff类型的文件稍微复杂一些。首先从补丁包中获取差异文件,然后从母包中找到相应的基准文件,将基准文件和差异文件合并生成新文件。最后将合成的新文件拷贝到目标位置。
2)对目标位置的文件进行签名算法生成操作,将本地生成的签名与服务器下发的预期签名进行比对。如果一致,说明文件没有差异,视为修复成功。如果签名不一致,说明有文件破损,热更新失败。
4.管理与发布
了解母包和diff包概念后,我们还需要在后台设定每个版本的线上母包,以确保在客户端请求diff时存在一个锚点,以确保本地的diff包是否正常下载下来。
整体分为两个主要部分:
1. 设置每个版本的母包,确保每个版本的锚点
这一部分的关键是在后台系统中为每个应用版本设定相应的母包。确保每个版本的母包都经过验证和正式发布。这样,客户端在请求diff时可以使用这个母包作为参考点,确保从正确的基准开始进行差异比对。
2. 设置diff包,确保最新的diff包的数据,或者来确定这个包是否能正常对外完全开放
在后台系统中,需要管理和维护最新的diff包数据。确保系统可以动态生成和更新diff包,并提供给客户端。这包括在系统中设置适当的规则和流程,以确保diff包的准确性和及时性。同时,要确定何时将最新的diff包完全开放,以使客户端能够顺利应用修复和更新。
通过以上两个部分的设置,我们可以确保客户端在请求diff包时获得正确的母包锚点,并且能够获取最新的、可正常对外开放的diff包数据。这是保障热修复系统正常运作的关键环节。
三、遇到的挑战
我们完成整套系统的并发过程一帆风顺。虽然中途遇到了一些棘手的问题,但我们成功解决了这些挑战。以下是其中几个较为困难的问题和我们解决它们的方式。
3.1 字体加载乱码或者方块的问题
问题如下图所示字体成“乱码”方框展示:
图3.2
图3.1是我们首先遇到的问题。当使用中文时,无论是中文字体还是繁体,都会显示为方框。原因是基于Canvaskit,没有内置的字体,所有字体都需要重新引入。而且在Canvaskit模式下,字体是通过Canvas绘图绘制的,绘制时默认会寻找谷歌Roboto字体。如果没有找到,就会从谷歌的字体库中加载,导致长时间的方框和乱码显示。整体大小超过7.4M,如果下载时间过长,会极大影响用户体验。图3.2是在下载完字体后重新绘制的效果。
为了解决这个问题,我们采取了内置字体和离线处理的方法。通过设置TextStyle的FontFamily和fontFamilyFallback,当遇到中文时会加载这份内置字体,从而成功解决了字体乱码的问题。
3.2 首屏加载过慢
App首屏加载时间主要集中在Canvaskit.js和Canvaskit.wasm上,同时也受到字体库下载时间的影响。这可能导致用户在页面首帧上看到很长的加载时间,显著影响用户体验和接受度。通过我们提供的最简单的Demo,可以直观地看到整体加载时长。
图3.3 未优化前
图3.4 优化后
通过优化后可以看到整体加载速度提升了至少10秒,实现了页面整体秒开的效果。
解决上述问题的方法如下:
-
对离线文件Canvaskit.wasm和Canvaskit.js进行离线存储和压缩,将总大小从6.8M减小到约2.8M,整体减小了很多。
-
对字体库进行处理。简体中文字体库大约为10M,压缩并不会生效,因为字体库已经是矢量,不能通过zip进一步压缩。解决方法是裁剪字体,通常我们使用的字体库约为6000,将字体库大小从10M压缩到约800K,极大地缩小了字体库。
-
即使是离线加载存储,仍然可能存在首屏加载过慢的问题。一般情况下,我们测试发现首屏最简单的界面加载耗时约为500ms。因此,我们采取的方法是预热一个后台常驻的webview模块的"引擎",在需要修复的页面每次进入时都进行热重载,使首屏加载时间快到可以忽略不计的程度。
3.3 包体积的问题
Canvaskit.wasm的版本为3.7.7,大小约为6.8M,Canvaskit.js大约为127KB。整体业务库加上各种资源使得App的体积达到30多MB,这很大程度上存在体积过大的问题。因此,在整体设计上,必须将这部分体积压缩到极致,并减少到用户可接受的程度。为了实现这一目标,需要对包体积进行压缩、简化等优化策略。
即使通过上述整体压缩,一个完整的包仍然需要大约12M。对于修复bug而言,下载如此庞大的体积仍然是不可接受的。因此,我们自行研发了一个哈希算法,整体策略如下:
图3.5
大致分为两个阶段:
第一个是本地阶段:我们修改代码后进行打包,通过使用线上版本的基准包作为原始包,然后将修改后的代码作为修复包,计算所有文件的差异,生成最终的差分包。结果是整体差分包可以小到只有不到1KB大小,大大提高了修复的成功率,同时显著减小了流量,一般不超过20K左右,除非涉及到特别大的资源。如果遇到这种情况,我们可以完全使用CDN链接替代。同时,我们支持任意文件的增删改,使整个过程完全无感知。
第二个阶段是下发阶段:用户手机每次都会存储一个基准包,结合我们的差分包进行patch合包操作,从而得到一个完整的修复包。整个修复流程就此完成。
3.4 数据内存共享的问题
因为采用了web降级的策略,所以在数据共享方面,我们追求实现真正的跨平台数据框架调用。这意味着无论是从H5、原生、RN还是Flutter业务调用,我们希望找到的函数都能实现尽可能的“无副作用”,即任何方的调用都能做到幂等操作,消除平台差异问题。每个页面都需要对于核心数据进行完全独立处理,这本质上是两个不同客户端通信的问题。我们设计的action同行方式旨在解决这种操作,如下图所示:
图3.6
3.5 其他的问题(诸如跨域、资源加载等问题)
在安卓端遇到了跨域问题,因为安卓在不同的浏览器内部,fetch不支持跨域。解决这个问题需要修改引擎代码,将其改为支持跨域的原生JS请求,以替代fetch。在刚开始的阶段,我们就遇到了这个问题。
另一个问题是安全区域的处理。需要从原生将安全区域的位置传递给Web端。由于Web端无法获取手机本身的安全区域,因此我们需要设置paddingTop和paddingBottom来解决安全区域的问题。在图片中可以看到,上下都完全贴边了,这是因为我们需要通过设定padding来适应手机的安全区域。
图3.7
四、成果展示
在我们的线上抽样测试中,整体修复率达到了99.27%。其他修复失败的主要原因主要是由于CDN问题和用户空间不足导致的。我们没有遇到非功能性的问题,且在生产环境中没有产生新的线上bug。目前已经成功在生产环境中推广,整体的修复率非常可观。
图4.1
图4.2
下面两个视频是Flutter 原生与Flutter热修复在页面打开和列表曲线的性能直观体验对比:
视频链接:https://www.ixigua.com/7359527731325435942
修复前
修复后
性能体验小结:
-
页面的流畅度:实际效果显示,部分页面基本能达到接近60FPS以上,并且对于复杂的曲线图依然能够兼容得十分完美。不惧复杂页面,这在最终的效果视频中有所展示。
-
页面秒开:首帧几乎在0秒内完成加载。
-
FPS效果展示:
- 列表展示几乎达到60FPS。
- 复杂曲线图依然呈现十分优秀的效果。
- 与原生Flutter UI几乎无缝感知,保持一致性。 -
内存:整体内存增量约为3M左右,这与业务本身相关。目前,一个空的Web页面的内存增量大致在这个范围内。在热修复的情况下,我们可以在当次修复中直接进行,而无需用户重新启动App。
总结与展望
我们实现了动态下发与解释的逻辑页面一体化的 Flutter 动态化方案,同时也有搭配热修复各种CI/JOB各种配套的工具完善整个热修复和发包的工具链条,支持任何资源和代码的修复以及Flutter能支持的所有特性。增强和补齐了Flutter线上修复的能力,弥补了跨平台技术栈热修复的空白。
未来我们会将将热修复的技术推广到更多的团队,并完善好热修复整体的流程和平台支持。
其他问题
目前也有不足的地方,也就是目前无法修复的问题。
1、跨页面之间的内存是无法共享的因此没有办法处理A和B页面的数据共享,暂时还未想到好的解决方案,我们的想法和建议是尽量通过类似我们的action的方式解决跨页面层级的数据共享问题,这样风险就完全是可控的。
2、需要和我们本身的混合栈的框架进行同步的绑定,也就是说逻辑需要和我们的混合栈保持一致。否则可能修复的效果就会大打折扣,比如说只能修复单个页面等。
3、低端机上面的表现性能可能依然不太友好,可能原生的Flutter一样存在类似的问题,这个期待官方来解决web端目前本身滑动卡顿的问题。
本文作者:
- 袁杰龙 来自于众安保险-技术研发中心-大前端技术部
- 胡伟 来自于众安保险-技术研发中心-大前端技术部
- 谢鹏飞 众安ZATI/ZA/ZA Engineering
— END —
更多推荐
所有评论(0)