一. 背景
企业微信的跨平台之路
企业微信作为跨android、ios、mac、pc、web五个端,超千万行代码的超大型工程,每一个需求迭代周期,都需要5端同步开发、发版,不管是对于开发,还是产品、设计、测试来说,都是一个巨大的挑战。
企业微信初期架构设计上就将底层网络、db以及大部分业务逻辑都抽离到c 实现,以供多平台复用。但是UI还是各平台独自处理,从开发的角度来看,移动端的android、ios,电脑端的mac、pc,同样的界面布局,却需要写两套逻辑代码,因此,ui的跨平台诉求是我们的一大痛点。企业微信内对UI跨平台的方案做了一些尝试比方说h5 和 小程序,但是这两种方案因为性能或者体验的原因都不能覆盖大部分的业务场景,因此我们一直在寻找一个高性能的跨平台框架。
直到google推出了flutter,我们做了一些dem验证,不但体验效果比拟原生,而且底层采用skia自绘引擎渲染,能满足高复杂度的需求场景,同时丰富的pub社区支持,也加速了框架的成熟。
因此,在时机成熟的时候,我们决定将flutter接入我们的项目工程中来。
二. 企业微信Flutter工程架构
flutter 多模块架构
flutter为我们提供了四种不同的工程模块
Appcalition(独立app) | Module(add2app) |
---|---|
plugin(包含android/ios dart代码) | |
package(dart) |
在四种模式中,由于我们是已有的项目工程,因此使用Flutter Module的形式依赖flutter的工程,另外对于flutter module里面的模块划分,吸取native组件化的经验,如果我们要利用Flutter来开发新的业务,我们希望各个业务之间是相互独立的,方便进行管理。
对于不同的业务模块,避免不了需要native进行交互,flutter plugin 可以提供原生的代码,但plugin 也会有一些特点:
1: 每个plugin 在项目下都会被打成一个aar,开发的过程又会源码依赖plugin的代码,现有的工程上管理会变得更加难以维护。
2: 在不修改编译代码的情况下,ios上每个plugin都会被打成的framework,framework的数量在一定程度上影响ios的启动速度。
最终我们的业务代码通过package 纯dart来实现,通过channel 生成的双端代码,由native各自的模块维护,如果是第三方的sdk或者插件则由plugin的方式引入,减少aar或frameowk的产生。
另外在基础库上我们下层了一些ui控件库,基础工具,和路由相关的组件。通过channel pigeon 的方式实现了我们的线上crash监控,我们最终的组件化架构可以设计为:
对于pacage 组件中各个模块之间的相互调用,可以设计dart api文件对应要暴露出去的接口,文件主要在存放在lib 目录下,组件提供一个统一个对外暴露的Dart文件,内部的细粒度的Dart实现通过export导入,这种设计思想正是Flutter官方Api的设计。
library lib_flutter;
export 'src/cupertino/dialog.dart';export 'src/cupertino/nav_bar.dart';export 'src/cupertino/route.dart';
三. 混合栈开发
什么是混合栈?简单来说,就是app中同时存在原生和flutter页面,并且互相跳转。
除了部分新的app,现在市面上大多数app引入flutter,都是以混合栈的形式引入。
FlutterBoost | 企业微信 | |
---|---|---|
导航效率 | 中 | 高 |
实现/维护成本 | 高 | 低 |
性能开销 | 中 | 低 |
应用场景 | 全场景 | 2.0以前局部场景,2.0以后全场景 |
接入成本 | 高 | 低 |
成熟度 | 高 | 中 |
MAC/PC扩展支持能力 | 低 | 高 |
对比FlutterBoost、FlutterThrio的混合栈方案,FlutterBoost入侵了原生Flutter navigator栈,将栈统一由原生或者flutter内部管理的方式,而FlutterThrio则是直接使用flutter导航栈。
相比之下:
FlutterBoost方案栈管理更清晰,但开发、维护成本更高。
FlutterThrio直接使用flutter导航栈的方案,开发、维护成本更低,且比较好切换到Mac和PC的支持上,但文档较少
FlutterBoost在企业微信的接入flutter 初期,一直停留在flutter低版本,并且对于flutter 的sdk 有一定的入侵性,经过考量,企业微信实现一套flutter内部导航栈的方案,并且遵循官方的路由设计,设计AppContainer作为基础容器,在engine初始化的时候,先预热这个AppContainer容器,并进行基础配置、主题设置等操作,具体页面打开时候,通过channel 来push 一个MaterialApp 的OverlayEntry 来做具体的路由栈,flutter内部的跳转由flutter 内部实现。
这样做的好处是收拢了基础逻辑,业务开发时只需要关注业务逻辑,并且方便进行全局层面的配置,提供了统一的导航栈插桩能力,对于flutter 的导航也没有入侵性,都是通过Navigator 来控制路由。
除了栈管理之外,混合栈还有一个需要关注的问题,是engine的使用管理。
混合栈的页面栈形式,栈中往往会出现多个flutter页面,flutter的页面和engine之间存在绑定关系,而flutter engine开销很大,为每个flutter页面绑定一个engine,不现实。
针对引擎的使用方案,企业微信从引入flutter至今,可以大致分为两个阶段:
1. 单引擎阶段:
在flutter 2.0以前,我们使用单引擎模式,engine初始化后将被缓存下来,每个flutter页面打开时,都和这个engine绑定,这样app中就只会有一个engine的开销。
然而,混合栈的页面栈形式,往往会出现 原生页面->flutter页面->flutter页面 ,在flutter1.20版本的的前期,我们的这种路由设计无法支撑而多个flutter页面共存于栈中,所以我们限制了flutter->flutter的场景不允许进行容器之间跳转, 但是后面有一些浮窗的业务场景让我们不得不打破这个限制,为了解决这种业务场景我们使用了独立的flutter engine。
2. 多引擎阶段:
解决这个问题最好的方式,就是支持多引擎模式,并解决由此带来的内存开销问题。此时业内的解决方案,多是修改engine源码,复用多个engine的内存空间。但这样带来的问题也很多,修改的engine方式始终落后官方engine版本,适配成本高,且往往会出现很多难以预测的问题。
恰逢此时,flutter发布了2.0版本,官方提供了FlutterEngineGroup,以支持engine内存空间的复用,彻底解决了多engine的内存开销问题。我们对多引擎的效果进行了分析,新增一个engine的内存开销大概在4MB左右。
不过,虽然内存开销问题得到了解决,但engine初始化的耗时,仍是一个不可忽视的问题,为了优化体验,我们并没有直接使用多引擎与flutter页面进行1对1的绑定。而是采用了主引擎 临时引擎的多引擎复用模式。
对于flutter页面打开时,栈中不会存在其他flutter页面的情况,使用主引擎;
对于flutter页面打开时,栈中可能存在其他flutter页面的情况,使用临时引擎,同时,页面自定义一个引擎名称,临时引擎初始化后也将被缓存,这个页面再次打开时将继续使用这个临时引擎,以优化页面启动速度。
整体来看:
原生侧,我们通过引擎复用来减少性能的消耗,通过引擎预加载来减少首次启动的时间。
在flutter侧,通过一个统一的AppContainer容器来作为页面载体,在引擎初始化的时候,即预热该容器。在实际页面打开的时候,根据不同的路由,使用AppContainer来切换不同的子页面。这样相比于官方每次打开flutter页面,都进入一个新的页面的做法,统一了flutter页面入口,减少了大量原生与flutter交互的成本。
四. Flutter通信建设
flutter与native的通信
1. 为什么需要pigeon
在flutter开发中,我们需要通过channel 的方式与native进行通信,在多端的实践过程中,我们发现channel存在一些问题:
1. 多端接口定义的一致性、可维护性差
2. 无法保证类型的安全,通信容错率低
3. 复杂的对象转换需要手动解码与反解码
因此官方推荐使用pigeon来维护我们的channel代码,pigeon 将 我们定义的接口,通过dart的反射将class转换成map的数据结构,并生成各端接口。简化了我们平时手写channel 和对接协议所带来的成本。
2. pigeon的问题
企业微信是亿万级的项目,业务场景也十分复杂,在实际接入使用pigeon 的过程中,受到了非常大的业务挑战,在使用中发现pigeon还是存在着不少的问题。
比如:数据类型的支持较弱,不支持list和map
为什么不支持List和map呢?其实跟pigeon 传输的数据结构有关。
channel 支持基础的数据类型,其中就包含了map,pigeon在解析dart class的时候实际是将class转换成map,再传输给native,native再以map的结构反解成class,在正常的数据下似乎是没什么问题,但是遇到List和map,由于没有json那样的反序列化工具,toMap和fromMap 的代码的复杂度就会急剧上升,我们曾经为了支持list的结构,改造pigeon的部分源码,直接映射List 的数据结构,对于一些基础类型,并没有什么很大的改造成本,但是遇到object 就需要继续对object 进行toMap 的操作:
如果List和map相互嵌套,对框架的生成来说代码逻辑十分复杂,而且生成的代码也会特别臃肿。
3. pigeon 的传输数据结构优化
List在我们实际的开发中使用的地方非常多,因此我们对pigeon 源码进行了改动目的是为了:
1. 性能上更好,避免重复嵌套带来的复杂计算的和性能问题。
2. 支持更多的数据结构,支持List/Map
3. 能够复用已有的pb
由于protobuf在企业微信有大量地在使用,因此我们考虑能否将pigeon 的data class转换成proto的数据结构,不仅能够解决List/Map等数据的问题,对与已有的一些pb结构也能起到很好的复用作用,因此我们沿着这个思路,优化了pigeon 在生成代码上的思路,具体流程如下:
在pigeon 生成class 的阶段,我们hook 生成map的过程,改为生成proto,再编译proto到各自的平台上,由于proto 支持list和map,而且序列化和反序列化都有现成的工具,对于现有的工具链来说几乎是零成本,而且我们还能复用已有的proto,避免了重复的数据转换。
4. pigeon channel 注册
pigeon生成的server,需要在activity中注册后,flutter页面才能通过channel调用native的实现。然而,业务产品功能的变更,往往会让两个一开始设计的两个独立页面,需要相互跳转。flutter页面的跳转,在dart侧通过flutter的navigator即可完成跳转,此时承载flutter页面的activity容器还是原来的界面,这个activity容器并没有注册新的flutter页面的channel server。如:
Activity A 包含 Flutter页面A
Activity B 包含 Flutter页面B
此时打开Activity A,将注册Flutter页面A的channel server。
再从Flutter页面A跳转至Flutter页面B,此时activity栈仍在Activity A中。
Flutter页面B的channel server没有得到注册,如果此时调用Flutter页面B的channel,将因为找不到实现类而抛异常。
设计方案:
问题的难点,在于Anroid的channel server实现类,分散在不同的module中,跨module手动注册其他flutter页面的channel server实现类,繁琐且不够优雅,而且不同的flutter页面,往往是由不同的开发同事完成,互相的调用往往并不清楚哪些需要注册channel server,一旦遗漏,就会产生异常,且这种异常,由于业务路径的特殊性,开发和测试都难以检测出来,风险性更大。
因此,设计了channel server的自动化注册流程:
整体流程原理如下:
1. 通过pigeon自动生成胶水代码
2. flutter调用业务channel的时候,如果发现channel server未注册,自动调用专门用来注册的channel,通知native去注册该channel server
3. native收到请求到,从manifest中获取channel server的全路径名(这个全路径名会在编译期自动生成),然后通过反射,将实现类注册到activity中,并通知flutter注册成功
4. flutter收到注册成功消息后,再次调用业务channel
五. dart与c 调用演进
1. 企业微信客户端的架构
企业微信底层chroumin service 业务层级的跨平台开发模式架构已经非常成熟和稳定,而且拥有比较完善的工具链,如图所示,Android和IOS主要负责UI绘制与联调,将与网络请求,数据处理等复杂的逻辑都交给c 层来处理。
在接入flutter 之后,重新在flutter上实现一套service和network无疑是巨大的成本,我们的首要目标就是要复用底层跨平台的逻辑,为了复用我们已有的工具链, 不可避免地需要解决dart与c 的相互调用问题。
2. flutter调用cpp
dartvm 提供了Dart_SetNativeResolver 的方法来加载dart上标记了native的方法, dart 与engine的通信方式也是基于这种方式来进行的,在flutter engine 中我们能找到大量的RegisterNatives 方法,其中参数
DartLibraryNatives 里面就存储着我们的方法签名,最后再通过Dart_SetNativeResolver 来加载dart 上标记了native的方法。
Dart_Handle result_code = Dart_SetNativeResolver(parent_library, ResolveName, NULL);
因此我们可以通过修改engine 的方式,将flutter engine 内部
RegisterNatives 以及Dart_SetNativeResolver 方法暴露出来并在合适的时机装载自己的c 模块,但是这种模式需要维护engine,而且对我们后面的升级和维护带来很大的不便。
3. dart::ffi 调用
dart 在2.5 之后实现了dart::ffi 来调用c 的接口,并且在flutter上也得到了支持,但是dart::ffi在实践的过程中依然有一些限制条件:
1. dart调用c 操作步骤繁琐, 接口维护和约束困难
2. c 调用dart方法只支持静态或者顶级函数
3. dart上开放了指针的分配和释放,调用c 之后内存管理混乱,容易造成内存泄漏
4. 如果出现接口绑定不匹配的情况或者so 忘记更新,会导致全局的异常,影响正常开发流程
第一个问题,看下如果dart调用c 的同步接口,首先要在dart上绑定c 的方法,绑定过程包括范形和参数这些。
final loggerFunction = _dl.lookupFunction< Void Function(Pointer<Uint8>, Int32,Int64), void Function(Pointer<Uint8>, int,int)>("Logger");
c 的对应实现如下
WE_DART_EXPORT void Logger(uint8_t * string, int32_t type,int64_t length)
可以看到其中理解需要一定的成本,而且在编写代码的过程一定要对齐参数。
第二个问题,如果c 的方法是一个异步接口,c 回调dart,异步回调的核心思路是在dart isolate 启动一个listenPort的监听函数,在c 中,我们可以通过Dart_PostCObject 的方法来将某个function 的指针传给dart,dart再通过ffi在flutter的ui线程上执行这个function,其中的关系和逻辑相对复杂。
第三,如果dart与c 相互调用传递的数据是bytes,string这种,都是通过指针来传递,dart上提供了Pointer类,和malloc/free函数,如果bytes的数据要传递到c ,则需要先在dart上分配堆上的uint8指针内存,数据回调回来也类似,先将c 的pb数据转换为 uint8 指针之后再回调给dart,内存在c 分配之后,回调给dart,c 底层接口无法知道dart 上数据内存什么时候用完,只能交给dart来处理,而且dart的开发者和c 的函数都要时刻保持着指针操作的风险。
4. ffi::gen
ffi::gen是官方后来推出的自动生成ffi接口的工具,ffi::gen我们依然没有采用的主要原因是,没办法解决c 层代码维护困难,胶水代码,以及线程安全等问题。
5. ffi接口自动生成与管理
企业微信在2020年下开始使用flutter作为大型独立应用开发,通过dart::ffi 的方式复用了原有底层的service 架构,在一定程度上提高了开发效率,但是在实际开发过程中,每一次的业务需求都伴随着大量dart::ffi 的胶水代码,并且dart::ffi的方式类似于jni 的开发方式,一方面需要在dart/c 写一套中转的胶水代码,另一方面由于dart::ffi 的调用 方式需要进行线程的切换,并且dart 提供了指针的分配与释放,内存的管理似乎变得不太安全。
综合以上我们希望对dart调用c ,做一些业务调用上的改进,主要目的是为了:
1. 减少手写胶水代码,降低dart::ffi的复杂度
2. 内存可控,由框架层管理,开发者不需要关心指针的问题
3. 线程安全,开发者不需要关心flutter 线程与native 主线程的关系
为了解决以上这些问题,我们希望能够更加方便地调用c 的方法,因此参考grpc/trpc 实现了一套dart::ffi的简单的rpc。在引入这套rpc工具后,对开发效率有显著的提升。在proto上定义dart调用c 的接口,数据结构统一为proto,c 层引入rpc的部分能力,dart层也引入相应的stub,我们去掉rpc的通信机制,改为dart::ffi来进行client和server的通信,只在c 层引入至关重要的服务发现与服务调用。整体的架构如下:
接下来我们需要调用c 的方法的过程为:
1. 在proto上定义rpc方法
2. 通过proro生成dart client service, c 的service 接口
3. 底层实现生成的接口,并将service 注册到LanguageCallServer中
4. dart通过proto生成的RpcServiceApi调用c 的方法
final GovernRpcServiceApi api = GovernRpcServiceApi(WeRpcClient());final RpcResult<GetGovernMyReportListResp> result = await api.getGovernMyReportListFromServer(GetGovernMyReportListReq()..limit = 10);
dart调用c 的方法,就跟调用本地的异步方法一样。
调用过程如下 :
从整体的流程看,除了虚函数的实现需要业务逻辑方自己处理之外,其他的能力几乎是全自动生成,后台和客户端也可以共用一份rpc的proto。
六.flutter性能优化
1. flutter着色器卡顿
flutter着色器卡顿问题
在实际的flutter 体验中,我们注意到一些首次进入复杂的页面会存在卡顿以及首次进入flutter白屏的问题。根据官方的资料
https://flutter.dev/docs/perf/rendering/shader 通过trace-skia 跟踪了主要的耗时点:
在启动的过程中我们发现skia的GPURasterizer::Draw 有持续的耗时,有些耗时甚至达到了 597.016 ms,存在严重的卡顿问题。
这属于skia 着色器卡顿的一部分,但是在2.3 之前,ios skia 的持久缓存会失效,直到2.3 beta之后skia 支持了ios metal 渲染。
针对add2app的方式优化
但是着色器的卡顿处于初级阶段,针对于add2app的方式,很多命令行都不适用,我们跟踪了flutter的编译源码,最终发现在ios上可以通过 launchArguments添加一些flutter 的启动变量,例如
flutter run --profile --cache-sksl --purge-persistent-cache
在add2app 的方式下在实现为:
生成相应的着色器之后,我们只需要将io.flutter.shaders.json 放在项目的根目录,并且加到asset 中
flutter: assets: - io.flutter.shaders.json
2. 图片缓存框架建设
flutter本身没有磁盘缓存能力,pub社区提供了很多解决方案,一般主流的 cached_image_network 缓存使用了 flutter_cache_manager 库来实现
cached_image_network虽然提供了硬盘缓存能力,但flutter在项目中以混合栈形式集成,原生本身也已经有缓存框架。如果使用cached_image_network,原生与flutter加载同一张图片,仍然需要加载并存储两次,且原生的图片下载,还有复杂的下载策略,cached_image_network框架无法支持定制化。
因此,我们自己实现了一套缓存框架,打通了flutter与native的图片缓存,流程如下:
在无内存缓存的情况下,通过channel通道,调起原生侧的图片缓存逻辑,加载硬盘缓存,如果硬盘缓存也没有,再通过原生的网络通道去加载图片缓存
3. svg与iconFont转换
flutter目前还没有直接使用native图片资源的办法,所以大部分情况我们需要维护一套新的图标库,但是经过实践发现,flutter在渲染图片的时候并不是特别完美:如果是在底部tab,点击之后切换图片这种情况,低端机型上,第一次点击切换图片的时候会稍微闪一下,而且png占的资源比较大,flutter上我们希望找一套稳定好用的矢量图标。
svg不被官方所支持,依赖第三方的package, 在flutter里面运用最多的就是字体图标(Icons),字体图标具备矢量,颜色可修改,并且渲染性能好等特点,被flutter官方运用于自身的MaterialIcons和CuptinoIcons中,我们因此也想实现一套属于自己的Icon图标库。
png | svg | iconfont | |
---|---|---|---|
官方支持 | - | x | - |
应用场景 | 丰富 | 部分 | 纯色 |
渲染性能 | 低 | 低 | 高 |
包大小 | 大 | 中 | 小 |
具体的资源构建主要是针对svg来的,我们在蓝盾上部署nodejs环境以及安装gulp,蓝盾通过监听项目svg资源的变化自动生成IconFont.dart的索引、ttf文件、以及相应的静态html。
在使用Iconfont图标之后,我们的图片体积有所下降,只剩下多色图的png资源,并且开发中通过字体图标定制颜色和大小都非常方便。
七:flutter 生态建设
1. 多语言框架建设
flutter本身没有多语言框架支持,普通的做法是通过flutter_intl框架来管理多语言资源,但仍需要手动筛选需要翻译的资源,待翻译后再手动填入项目。
文字资源集中管理 | 多语言切换 | 增量提取待翻译资源 | 翻译脚本 | 翻译后资源增量写入 | |
---|---|---|---|---|---|
flutter_intl | Y | Y | N | N | N |
为了让多语言框架实现闭环,最大程度地减少开发阶段的工作,我们需要用脚本建设来补足框架缺失的能力。
针对英文、繁体翻译,我们需要开发两套插件。其中英文翻译需要人工翻译,繁体翻译可以依赖api自动翻译。
同时,为了更好地提高开发阶段的代码书写效率,我们也期望允许开发阶段将文本hardcode写到代码中,并通过脚本工具来自动提取hardcode的文本资源。
总结来说,我们需要建设的脚本如下:
- hardcode文本提取工具
- 英文翻译脚本(人工翻译)
- 繁体翻译脚本(Api翻译)
string_extractor 文本提取工具
通常来说,开发者在文字资源编写的时候,为了节省开发时间,不中断开发时的思路,往往会先将文字资源hardcode编写到代码中。待功能开发完之后,再将hardcode的文字资源统一提取到统一资源管理类中。这样后期的提取工作费时费力,且容易遗漏。
框架提供了string_extractor自动化hardcode文本资源提取的IDE工具,只需要安装到IDE中,使用快捷键option e即可自动识别页面中的hardcode文本,并提取到.arb文件中。
如图为string_extractor插件界面,支持自定义索引id前缀:
增量翻译脚本
1. rescan_flutter: 基于java实现的脚本工具,用来实现中译英翻译,主要提供了两个命令:
- findNeedTranslateRes: 自动比对项目.arb文件中的中文和英文文字资源,针对未翻译的中文文本,先从缓存中查找是否已有翻译过的英文缓存,如果有则直接填入,没有则提取出未翻译的增量中文文本,写入excel中。
- merge2res: 将已翻译的英文文字资源填入.arb文件中,并记录到缓存中。
流程如图:
2. conversion2_flutter:基于python实现的脚本工具,用来实现中译繁翻译,运行后,将直接基于开源api,将项目中.arb文件中的中文文字资源翻译为繁体文字资源,并自动写入.arb文件中。
2. flutter仿原生动画与ui组件
跨平台的首要命题:体验
能否达到原生的体验,是跨平台的首要目标,目前flutter的应用还是有比较明显的特点,这几个特点主要集中表现在:
1. 页面切换效果不佳,设计经常提走查
2. 点击态效果弱,要么没有要么就是Android的效果
3. 导航栏动画跟原生差距较大
flutter体验上的一些优化
在flutter上我们实现了一套自己的ui控件库,实现了一些仿原生ui和动画:
3. 暗黑模式适配
企业微信Flutter暗黑模式的落地
系统主题Theme
Flutter 应用的统一入口是MaterialApp, MaterialApp 提供了theme和darktheme来适配浅色模式和黑暗模式,Flutter提供的组件,比如Appbar,Button,页面的默认文字大小,如果用户在没有指定参数的情况下,会默认从系统的主题里面读取,与native不同的是,native大部分组件都是自己自定义的,flutter控件是通过组装模式来生成新的控件的,其实就是说我们的组件大部分不过就是在官方的组件上套了一层。但是官方的组件又只会默认读取自己系统的主题,因此,我们只能通过修改官方主题的形式来达到尽可能地简化组件的参数和适配黑暗模式目的。
以后在使用官方组件/实现与官方类似的控件的时候,如果是通用组件,优先考虑在主题上设置通用参数,然后才是自定义参数设置。
//主题定义dividerTheme: const DividerThemeData( color: WWKLightColor.color_7, space: 0.33, thickness: 0.33,));dividerTheme: const DividerThemeData( color: WWKDarkColor.color_7, space: 0.33, thickness: 0.33,),//❌错误写法// const Divider(height:0.33,color: Darkcolors.color_7,)//使用方法const Divider();
自定义的颜色
CupertinoDynamicColor 提供了颜色的动态切换,因此我们可以将我们的颜色定义成一个CupertinoDynamicColor,并且通过extension 的方式 添加在context里面。
Color get color73 => CupertinoDynamicColor.resolve(const CupertinoDynamicColor.withBrightness( color: Color(0xff32c757), darkColor: Color(0xff38c95c)), _context);
企业微信落地
八. 企业微信Flutter调试工具 UiInsight-Flutter
随着企业微信在更多业务场景下使用Flutter技术,拥有一款和原生的UiInsight相似的效率工具成了研发、测试、设计的迫切需求。
功能对比
功能 | UiInsight-Flutter | DoKit Flutter | UME |
---|---|---|---|
widget信息查看 | ✔️ | ✔️ | ✔️ |
MethodChannel调用 | ✔️ | ✔️ | ❌ |
内存信息及泄露检查 | ✔️ | ✔️ | ✔️ |
颜色拾取 | ✔️ | ✔️ | ✔️ |
页面启动耗时 | ✔️ | ✔️ | ❌ |
控件位置测量 | ✔️ | ❌ | ✔️ |
控件间距离测量 | ✔️ | ❌ | ✔️ |
全方法执行耗时 | ✔️ | ❌ | ❌ |
图片检查/大图告警 | ✔️ | ❌ | ❌ |
支持扩展 | ✔️ | ❌ | ✔️ |
FlutterInsight 分为三个功能块,除内部集成了效率工具和性能工具之外,也可根据各业务定制扩展功能。
入口
接入FlutterInsight后,将在界面上悬浮展示fps和dart虚拟机的堆内存大小,单击后可展示更多信息,双击将弹出dialog,dialog中可开启各工具。
效率工具
用于提升flutter开发效率、帮助还原设计稿
当前页面信息
可查看当前页面中Scaffold元素对应的widget名和文件名及代码行数。
由于所有页面基本存在Scaffold作为一个页面的主体,Scaffold元素的信息在大部分情况下也可反映当前页面的信息。以Scaffold的信息代表当前页面的信息,可避免对各业务页面的侵入。
控件信息拾取
支持选中某widget获取对应widget的详细信息,如类名、所在文件、所在行数、x/y定位信
位置拾取
拖拽选中环可得到选中环中心点的x/y位置信息。
控件间距离测量
这是一种全新的交互方式,主要用于测量控件A某边和控件B某边之间的距离。
如图1,选中控件A的某条边后长按,可弹出对话框,点击确定后,将确定控件A的该边作为开始边,拖拽选中环,可实时得到选中环对应选中边和开始边的距离,若两条边的相互平行,可得到相对距离,若垂直,则得不到相应距离。
图片检查
用于测量图片源数据的宽高与控件本身的宽高,以确定是否加载了过大的图片
颜色吸管
通过拖拽选中环选中屏幕内某像素点并得到对应的色值信息
性能工具
帮助发现flutter应用的性能问题
fps树状图展示
为方便更直观地查看fps的变化,支持以树状图的形式查看fps
开启大图检测
对Image组件配置了frameBuilder后,可在打开界面时候查看该Image是否出现加载的图片远大于Image组件本身大小的情况:
Image.network( "https://img95.699pic.com/photo/40070/2524.jpg_wh860.jpg", frameBuilder: FlutterInsight.instance.checkImage, width: 100, height: 50,)
可在图片宽高远大于控件宽高的Image组件中看到大图警告的图标:
内存详情及泄露
如图,开启1后,FlutterInsight将监控页面的Scaffold元素是否泄露,若发生泄露,将在左上角展示相关信息。
点击查看泄露情况:
MethodChannel调用
如图开启methodchannel调用后,接下来发生的methodchannel调用均可查看:
页面层级及加载耗时
在本工具的弹出框可开启页面层级及加载耗时监听,如1,开启后,每进入一个新页面都将展示对应页面的加载耗时和widget数量深度信息。
基于aop的方法耗时排行
FlutterInsight 提供了特有的功能,统计flutter的方法耗时:
flutter在编译时,首先由frontend_server将dart代码转换为中间文件app.dill,然后在debug打包下,转换为kernel_blob.bin,release打包下,转换为so或framwork。
flutter的Aop就是对app.dill进行修改实现的。AspectD是闲鱼针对Flutter实现的AOP开源库,可实现对项目中的方法进行插桩。全方法的插桩是我们基于AspectD进行修改实现的:
方案一:在aop_impl.dart中,通过添加Execute注解对所有方法进行插桩。在对类似build这种覆写方法插桩时,拿不到该方法对应的library,将产生nonenull报错,如:
https://github.com/XianyuTech/aspectd/issues/124。
方案二:在aop_impl.dart中,通过添加Call注解对所有方法进行插桩。这个方案可以得到工程中的所有方法被调用时的耗时,但由于没有调用点,故无法得到如xxWidget的build方法的耗时,也无法满足我们的需求。
最终方案:
1. 首先app.dill将读取为Component变量。
2. 通过遍历该component中的library、class、procedure,可得到工程中写的aop_map_help.dart文件,并保存其markStart和markEnd函数为procedure。以便后续添加markStart调用和markEnd调用时使用。
3. 考虑到一个方法的开始和结束存在以下几种情况:
- 带返回的函数,需要在这个函数主体的开始添加markStart调用,需要在这个函数的return语句前添加markEnd调用。
@overrideWidget build(BuildContext context) { int aa = 0; if(aa == 1)return Text("test"); return Container(); //结束时}
- 不带返回的函数,需要在这个函数主体的开始添加markStart调用,需要在这个函数主体的结束添加markEnd调用,在这个的return语句前添加markEnd调用。
void test1() { int add =0; if(add == 0)return;}
4. 我们可以通过RecursiveVisitor 提供的api访问app.dill中library、class、blockreturnElement,并实现上述的插桩行为。
5. 插桩后解开app.dill可以看到:
method test() → void { aop::MethodTrace::markStart("test", "Test", "file:///Users/shuushigeru/Documents/flutter/aspect_demo/lib/my_test_now.dart"); core::int* add = 0; if(add.{core::num::==}(0)) { aop::MethodTrace::markEnd("test", "Test", "file:///Users/shuushigeru/Documents/flutter/aspect_demo/lib/my_test_now.dart"); return; } aop::MethodTrace::markEnd("test", "Test", "file:///Users/shuushigeru/Documents/flutter/aspect_demo/lib/my_test_now.dart"); }
method build(fra::BuildContext* context) → fra::Widget* { aop::MethodTrace::markStart("build", "Test", "file:///Users/shuushigeru/Documents/flutter/aspect_demo/lib/my_test_now.dart"); core::int* aa = 0; if(aa.{core::num::==}(1)) { dynamic value_build_16; value_build_16 = new text::Text::•("test", $creationLocationd_0dea112b090073317d4: #C4290); aop::MethodTrace::markEnd("build", "Test", "file:///Users/shuushigeru/Documents/flutter/aspect_demo/lib/my_test_now.dart"); return value_build_16; } dynamic value_build_17; value_build_17 = new con2::Container::•($creationLocationd_0dea112b090073317d4: #C4291); aop::MethodTrace::markEnd("build", "Test", "file:///Users/shuushigeru/Documents/flutter/aspect_demo/lib/my_test_now.dart"); return value_build_17; }
在test的函数开始、return处或结束处均插入了对应统计代码。
6. 考虑到一般来说,我们更关注未被async修饰函数的耗时,可以在3、4操作前通过读取 procedure.function.dartAsyncMarker.index 先判断当前function是否为async函数,具体判断方式参考官网AsyncMarker enum,若为async函数,则不执行插桩。
扩展工具:
FlutterInsight支持各业务方根据自己的业务/技术特点增加入口,支持跳转、展示、开关三种类型,如企业微信是通过底层native来访问网络和数据库服务,故而专为企业微信扩展了native调用(方法名及耗时)页面的跳转入口。
FlutterInsight.instance.addDialogItem(UiItemWidget( title: "native调用", showMore: true, onMorePressed: (context) { // 跳转 }, )); FlutterInsight.instance.addDialogItem(UiItemWidget( title: "打开测试模式", checked: true, onCheckBoxPressed: (isChecked){ }, ));
九:企业微信Flutter动态化探索
虽然自 2018 年 Flutter 正式发布以来,以其良好的多端渲染一致性和优异的渲染性能俘获了很多开发者的心,但是也有不少人对 Flutter 望而却步,其中一个重要的原因是,Flutter 不具备其他跨平台方案(比如:React Native、Hippy 等)拥有的动态化能力,因为动态化代表着更短的上线路径,更快的线上问题修复速度,同时,无形中也优化了应用安装包体积。在企业微信中,也一直在探索和实践 Flutter 的动态化能力。
1. 基于 Flutter 的动态化方案
根据 DSL 的不同,基于 Flutter 的动态化方案可以分为两大类:面向前端的解决方案和面向终端的解决方案。面向前端的解决方案主要基于 JS 或 TS 语言进行开发,对于前端同学更加友好,面向终端的解决方案主要使用 Dart 语言进行开发,使用 Android Studio、VSCode 等 IDE 进行开发,对终端同学更加友好,对于前端同学来讲,有一定的学习成本。
面向前端的解决方案代表框架有 LiteApp 和 Kraken,LiteApp 由微信自研出品,Kraken 是阿里前段时间开源的;面向终端的解决方案代表框架是美团出品的 MTFlutter(Flap),由于 MTFlutter 还未开源,短期内也用不上,这里就不做过多介绍了,感兴趣的同学可以自行查找资料学习。
下图从开发语言/框架、通信效率、渲染效率、等四个角度,对 LiteApp 和 Kraken 进行了调研和对比:
1. 在上层业务开发时,LiteApp和Kraken都提供了兼容W3C规范的DOM API,并将其暴露给 JS Engine,LiteApp 目前支持 Vue.js 的开发,而Kraken支持HTML/CSS/React/Vue进行开发。
2. 在跨端通信方面,Kraken 对官方的 dart:ffi 进行了一定的改造,支持了 dart 和 c 的双向调用;而 LiteApp 是对 Flutter Engine 进行改造,增加了 dart2cpp 模块,暴露出部分 C 接口,使得外部的动态库可以基于这些接口通过 DartVM 调用到 dart 的接口。在 Dart 的运行环境中 C 和 Dart 之间就可以像调用自身的接口一样调用彼此的接口。
3. 在渲染效率方面,Kraken 不依赖 Flutter Widget,而是直接依赖 Render Object,这样具备更短的渲染管线;LiteApp 是将解析生成的 Virtual DOM Tree 映射为 Flutter Widget Tree。从技术原理的角度看,Kraken 比 LiteApp 具备更优秀的渲染效率。
4. 在兼容 W3C 规范方面,Kraken 对 CSS 的支持比较弱,用于开发线上需求还不够;相比之下,LiteApp 在这方面做的更好,比如:对 CSS 的支持更加全面,并且可以写在单独的 CSS 文件中,支持富文本,支持 Store 等。
2. 企业微信 Flutter 动态化方案 - LiteApp
如下图所示是企业微信 Flutter 整体架构示意图,可以分为两部分,底层是宿主企业微信主工程,上层包括两块,分别是基于 Flutter 的动态化框架 LiteApp 和 Flutter 的原生开发。上层部分是从左至右的执行顺序,总共可以分为三个阶段:
1. 前端同学使用 Vue.js 进行业务开发(生成的 zip 包可以下发到终端),经常 JSEngine(封装后的 JavaScriptCor 和 V8)解析运行,在内置的 JS 基础库的支撑下生成 Virtual Dom Tree
2. 在 LuggageView 层映射为 LuggageView 树,并进行 CSS 属性解析和布局,最后通过 dart2Cpp 模块将 LƒuggageView 树传输到 Flutter 层
3. Flutter 层解析生成对应的 Element Tree → Component Tree → Widget Tree,这样便可以在终端通过 Flutter Engine 渲染了
在企业微信中,目前已有小黑板、家校应用、学习园地、设备巡检四个业务使用 LiteApp 开发并上线(3.1.12 版本),目前也还有一些问题(比如:运行内存较高)正在优化解决,期望后续会开源出来方便更多的开发者和业务。
回顾&展望
企业微信在开始大规模地使用flutter作为跨平台开发后,承受住了各种业务需求的考验,而且flutter页面的占比也逐渐提高,以下是各版本flutter 使用占比率:
流程与效率提升:
实际项目迭代过程中,得益于flutter跨平台的能力,各角色协同效率明显提升
1. 研发侧:基于flutter各平台技术栈统一,需求开发人力投入减少50%
2. 设计侧:基于flutter ui的一致性,设计侧可以把主要精力放到ios平台,ui走查效率提升40%
3. 测试侧:对于flutter内部闭环页面单平台人力就可以做到跨平台覆盖
对外影响力:
Google IO 大会介绍
企业微信客户端团队,包括 iOS、Andrroid、Windows、Mac、Web 五大平台。我们重视跨平台技术框架的研发,各类原创技术专利,截止去年,仅数十人的技术团队在近3年内提交技术专利百余项。团队招聘优秀技术人才,岗位分布在成都、广州、深圳。欢迎在官网投递简历。
可在 hr.tencent.com 搜索企业微信相关岗位,或者扫码联系 HR