哔哩哔哩的漫画业务是在18年末正式官宣成立的,之后便有了收(jie)购(pan)网易漫画的事。虽说是“新业务”,其实内部立项之早或可追溯到2017年,APP 的代码饱含着历史沧桑感,流淌来自B站内部多个部门程序员的心血,杂糅了多种技术栈和代码风格,令现维护者头疼到头秃。

2019年初适逢 Flutter 声名鹊起,经过一段时间技术调研和在网易漫画 APP 上试验后,便开始在哔哩哔哩漫画 APP (后文简称B漫)上紧锣密鼓一步一个脚印跟着版本迭代与用户见面。我给 Flutter 在B漫的落地计划起了个名叫“胡辣汤计划”


胡辣汤

一晃时间到了19年底,不过区区 6 人的小组将“胡辣汤计划”已经从试验阶段实施到成熟阶段,开始直接对接新页面和业务功能。iOS 和 Android 双端 APP 已由 Flutter 覆盖了多个主路径页面,重写的页面和原来页面相比用户几乎没有感知到差别,日均超七成活跃用户使用过 Flutter 所呈现的页面功能,并且维持着这些页面功能 0.01% 的低错误率、崩溃率,这不能说不是得益于 Flutter 的优秀设计。


网易漫画和哔哩哔哩漫画

本文将B漫 Flutter 混合开发的踩坑经验分享出来。但我技术能力有限理解可能不到位,或有缺漏或有误导,若屏幕前的你有更好的技术理解和想法欢迎留言与我交流。

为什么要实践 Flutter

为什么选择 Flutter?为什么实践 Flutter?可能你也想问。

我一直秉持的理念是,技术一定是解决业务问题而生的,不能解决业务上问题的技术就是屠龙技,无论它做得多好吹得多响。而且一旦引入到团队中来,还必须符合当前团队的技术水平,毕竟适合团队的才是真的好技术。

所以我先探讨一下,我所看到的理解的B漫业务上、技术上的问题。

B 漫业务特点和技术上的痛点

B漫之所以成立的原因,无外乎是 bilibili 想给网站用户提供视频之外的更多的内容选择。这点就不细说了,我毕竟不是专业的。你只需知道漫画业务是b站发展的历史必然就行了ˊ_>ˋ。

我所肤浅的理解,B漫的给用户提供的功能本质是为了看漫画:为了更方便的看漫画,为了看更高清的漫画,为了看好内容的漫画。

引申开来,技术要解决的业务问题就是:

  1. 如何让用户更方便的看漫画
  • 交互逻辑上保持多端一致
  • UI及动效上不出现明显卡顿
  • APP 稳定不出错不崩溃
  1. 如何让用户更看更高清的漫画
  • 流畅的解析、阅览高清图
  • 经受住高分辨率图片带来的内存压力
  1. 如何让用户看好内容的漫画
  • 配合产品运营,快速试错、小步迭代上线
  • 配合漫画个性化推荐,APP 侧数据埋点准确不丢失


b漫上的热门作品

那么,以2019年中B漫移动端的技术水平能不能很好的解决这些业务问题?我的回答是:能解决但不够好。

以 Android 端为例:

  1. 技术栈没有统一,各种代码风格习惯并存
  2. 代码质量参差不齐,把 Kotlin 当 Java 写的代码比比皆是
  3. 线上长期存在高清图带来的内存溢出问题
  4. 阅读器代码历史悠久、包袱重,新需求难以落地
  5. 提测质量不如人意,几乎看不到单元测试代码
  6. 常在交互设计上妥协,双端不一致

iOS 端也或多或少存在类似的问题。

所以在维持B漫版本迭代的同时,这些问题如芒在背、如鲠在喉,然而测试产品运营他们所处视野不同,很难理解我们技术上的痛处,实在不利于业务的稳健发展。

我在19年上半年接手收购的网易漫画 APP 的过程中,因为维护人员少、迁移改造需求众多、时间紧迫,我提出了初步的“胡辣汤计划”,尝试使用 Flutter 将网易漫画 Android、iPhone 和 iPad 各端 APP 的进行一把唆重写。一边踩坑一边上线的过程中,Flutter 也在发展,日渐趋于成熟稳定,社区热度也蒸蒸日上。

基于这个背景,我断定 Flutter 也适合在B漫上实践:“初尝Flutter,觉味极香,不敢独享,决定让B漫也尝尝”,“让B漫也尝尝胡辣汤之计划”在19年中正式提上日程。


胡辣汤计划

Flutter 的优势

上面提到了B漫的种种背景,那你可能还是不理解 Flutter 究竟有什么优势,这么令人着迷,非它不可???

我有在内部做过一次科普性质的分享,讲关于《移动端跨平台开发的前世今生》,里面总结到:移动端开发者朴素的追求 “技术成本”、“UI 性能”、“开发效率”三者之间的平衡,以达到“专注业务”开发的终极目标。


移动端开发者朴素的追求

我认为 Flutter 是2019年当前众多跨平台开发框架中真正做到了这三者平衡且突出的可行方案:

  1. 上手快,学习曲线平滑
  2. 高性能,媲美原生性能
  3. 开发快,工具链完整,编译快捷方便,支持 Hot Reload

同时,Flutter 非常适合小团队,专注解决业务问题。

可以说,业务形态决定了技术的演进方向,B漫选择 Flutter 是偶然,实践 Flutter 是必然

混合开发实践

虽说选择了 Flutter,但B漫这个功能齐全的罗马城不可能是一天建成的,未来一定是且会持续很久的“原生+Flutter”混合开发的模样。

如何引入 Flutter 到现有项目

在我们开始实践 Flutter 之时,官方的 “add to app” 还是个草案,只在 master 分支上给开发者预览。当时,我们对Flutter 的打包脚本做了一些定制化的包装和打补丁,将编译后的产物—— Android 为 aar,iOS 为 framework ——提供给原生项目集成。

这与许多实践Flutter的团队做的事情是类似的,不过是在最终产物的管理上不尽相同而已。


Android 集成 Flutter产物

为了减少影响,原生项目开发同学无需安装 Flutter 环境,以及方便集成、开发、调试,我采用了 git lfs 直接将产物收集到原生项目库中。Flutter 开发同学如果需要调试,在 debug 编译模式下,执行 flutter attach 命令连上即可正常使用调试、Hot Reload 和 Hot Restart。

1
2
3
4
5
6
$ flutter attach --debug-uri "http://127.0.0.1:44397/A60lIavSU54=/"
Syncing files to device VOG AL10...

🔥 To hot reload changes while running, press "r". To hot restart (and rebuild state), press "R".
An Observatory debugger and profiler on VOG AL10 is available at: http://127.0.0.1:53325/A60lIavSU54=/
For a more detailed help message, press "h". To detach, press "d"; to quit, press "q".

其实在 Flutter 版本 1.12.13 的现在,官方的方案已经很成熟,可以直接用:https://flutter.dev/docs/development/add-to-app,不用再额外做补丁工作了:

  • iOS: flutter build ios-framework
  • Android: flutter build aar

只需针对自己团队的开发流程作好产物管理即可,推荐直接用 maven 私有仓库,此处就不赘述了。

Flutter Engine 管理和单例化

在初步集成完 Flutter 后,我们站在闲鱼的肩膀上,使用他们开源的 flutter_boost 引擎集成方案,顺利引入到了网易漫画 APP 中,但经过一段时间实践,发现其对引擎改动太多,这意味着很难跟上官方稳定版本的更新,这对我们这个小团队来说是负作用。于是根据实际需求,自研了一套方案 phoenix

  1. 避免引擎层大改动,紧跟官方稳定版本,快速适配及早发现问题
  2. 紧密贴合b站内部的路由框架,管理好混合页面栈
  3. 定制化的引擎实例管理

phoenix 也解决了一些官方没有预料到的坑,当然也可能是理解不到位使用上的问题:

一个最典型的坑就是 Engine 的单例化及生命周期管理。直到现在,官方支持的仍然是 Flutter 页面必须独立出现在页面栈中,如果你的 APP 里会出现多个 Flutter 页面在栈里,只能开启多个引擎实例分别渲染,但是这会引发极大的内存压力:

所以必须将Flutter 引擎单例化处理,使得一般情况下多个 FlutterView 可以复用同一个引擎:

再一个坑是 iOS 上滑动返回的手势处理。在 Flutter 页面中showDialog时,需要将手势禁用,否则用户可能误触发手势关闭。另外 FlutterViewController 被手势关闭后,如果是单例引擎需要维持 Flutter 的页面栈仍然是正确的。

还有一个坑是引擎的预热(pre-warm),在1.12.13之前的版本,引擎是不支持预热的,渲染过程中会出现各种RenderObject找不到、大小为0.0的问题,需要介入渲染流程,阻止渲染直到首个 FlutterView 连接到 Surface 上。我引入了一个EngineLifecycleState 的机制,来确保在 main() 里如果引擎没有连接到 Surface 不直接 runApp(),而是另外选择时机 WidgetsBinding.instance.attachRootWidget() :

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import 'package:flutter/material.dart';
import 'package:phoenix/phoenix.dart';

void main() {
WidgetsFlutterBinding.ensureInitialized();
if (PhoenixLifecycle.instance.currentState == EngineLifecycleState.attached) {
attachApp();
}
PhoenixLifecycle.instance.setLifecycleStateHandler((state) {
switch (state) {
case EngineLifecycleState.attached:
if (!WidgetsBinding.instance.isRootWidgetAttached) attachApp();
break;
case EngineLifecycleState.detached:
cleanup();
break;
}
return Future.value();
});
}

void attachApp() {
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
WidgetsBinding.instance.attachRootWidget(MyApp());
} else {
runApp(MyApp());
}
}

另外一个坑是部分 Android 手机上有些手势会导致 Flutter 框架处理触摸事件出错,进而引发崩溃,我对 ui.window中的onPointerDataPacket 加了一层保护性的勾子。

还有一个坑是 Android 在顶部状态栏变化时会引发整个 Widget Tree 重新 build,这会导致页面滑动中发生卡顿,同样的,我对 ui.window中的onMetricsChanged 加了一层 WindowMetrics 变化判定的勾子,只有在window大小发生确切变化才通知重绘。

hooks.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class Hooks {
final ui.PointerDataPacketCallback originOnPointerDataPacket;
final VoidCallback originOnMetricsChanged;

Hooks()
: originOnPointerDataPacket = ui.window.onPointerDataPacket,
originOnMetricsChanged = ui.window.onMetricsChanged;

void install() {
assert(
originOnPointerDataPacket != null && originOnMetricsChanged != null,
"Should install hooks after `WidgetsFlutterBinding.ensureInitialized()`",
);
ui.window.onMetricsChanged = _hookOnMetricsChanged;
ui.window.onPointerDataPacket = _hookOnPointerDataPacket;
}

void _hookOnPointerDataPacket(ui.PointerDataPacket packet) {
try {
originOnPointerDataPacket(packet);
} catch (e, stack) {
FlutterError.reportError(
FlutterErrorDetails(
exception: e,
stack: stack,
silent: true,
library: 'gesture library',
context: ErrorDescription('while handling pointer data packet'),
),
);
}
}

_WindowMetrics lastMetrics;

/// Avoid calling [RendererBinding.scheduleForcedFrame] when metrics is not really updated.
void _hookOnMetricsChanged() {
var newMetrics = _WindowMetrics.fromWindow(ui.window);

if (lastMetrics != newMetrics) {
lastMetrics = newMetrics;
if (newMetrics.size != Size.zero) {

var binding = WidgetsBinding.instance;
if (binding.lifecycleState == AppLifecycleState.resumed) {
// in resumed state, update the configuration and scheduleForcedFrame
originOnMetricsChanged();
} else {
// in paused state, update the configuration but not scheduleForcedFrame
binding.renderView?.configuration = binding.createViewConfiguration();
}
}
}
}
}

林林总总还有其它一些坑就不一一细说了,不然本文就成 《Flutter 十宗罪》了╮(╯_╰)╭ 。

灰度和降级

为了进一步降低初期试验 Flutter 带来的未知坑和负面影响,我制定了一个灰度&降级的方案,以保用户在任何情况下都能正常使用 APP:

  • 在用户命中灰度策略之后,他才可以打开由 Flutter 重写的对应页面。
  • 打开 Flutter 页面后,当抓到错误时,当前版本当前页面错误数 +1。同时,Native 容器将 FlutterView 隐藏并提示用户关闭页面再重试。
  • 再次打开该页面前,如果当前版本错误数超出阈值,则打开原 Native 页面。

Flutter Engine 的崩溃处理与之相似,但引擎降级后,则该版本该用户将无法使用到所有的 Flutter 的页面功能。

可以与大家分享B漫的数字:日均降级人数双端加起来大约为 1~3人。侧面证明 Flutter 还是很稳的。

ps. 自本文发布后,升级到最新稳定版 (1.12.13+hotfix.5) flutter engine 崩溃导致降级人数飙升(打脸来的如此之快),建议再观望一阵子再升级。

b漫 flutter 页面截图
b漫-漫画详情页截图

总之呢,虽然混合开发的坑比较多比较出人意料,但都是可以解决的。

Flutter 开发实践

与混合开发到处踩坑相比,Flutter 侧的开发实践则称得上一帆风顺。我想多半归功于 Flutter “一切从简”的设计理念。

架构实践

首先来看一个典型的移动端 APP 架构的样子:

典型的移动端 APP 架构

一个 Flutter APP 也不外如是,但其还多了一层 Platform Channel 与 Native 通信:

Flutter APP 架构

UIService间,其实还有一层就是常说的状态管理,在这里我们实践的是 BLoC

BLoc

Service 则就是一个个跟业务逻辑息息相关的具体数据层了,一些通过纯Dart实现,一些则通过平台Channel实现:

BLoc

譬如Api的服务,因为接口层需要处理签名、鉴权等复杂逻辑,所以通过 Channel 仍交由原生实现。又如数据埋点的服务,也由原生层提供实现。

题外话,在这里我还专门开发了一个包 method_channel_ex,用于解决 Channel 传输大量数据解析较耗时霸占 UI 线程的问题。

基于这个架构设计,我们严格遵循层级关系,从上到下,各司其职,不允许跨层依赖访问更不允许反过来访问。

原生开发同学则可以更快融入,他们可以先从最熟悉的原生平台层、Channel 层往上一步一步开发,直到熟悉 Flutter 代码后,就可以顺势介入 UI开发,成为一名合格的 Flutter 开发者。

Mock 开发

开发 UI 的同学可以通过 mock Service,在数据、API还未准备就绪的情况下,着手布局。
当然 Service 也可以通过 mock Channel 等先行开发。最后再合并联调即可。

并且,Flutter 项目支持以 Mock 的形式在真机上跑起来,可以尽早拿与产品、设计验收:

lib/mock_main.dart
1
2
3
4
5
6
7
8
9
import 'package:flutter/widgets.dart' show WidgetsFlutterBinding;
import 'channels/mock_channels.dart';
import 'main.dart' as master;
void main() {
// ensureInitialized before access the [defaultBinaryMessenger]
WidgetsFlutterBinding.ensureInitialized();
mockChannels();
master.main();
}
1
flutter run --target=lib/mock_main.dart

你可能还不知道如何 mock Flutter 的 Method Channel,其实很简单:

1
2
3
4
MethodChannel('mockable_channel').setMockMethodCallHandler((call) {
// 这里实现你的mock 逻辑
return Future.value(null);
});

崩溃和错误统计

关于这一点我之前有专门撰文,就直接引用了:捕获 flutter app的崩溃日志并上报

  1. Flutter App错误:注册 Zone 的 ErrorHandler
  2. Flutter Framework:注册 FlutterError.onError 回调
  3. Flutter Engine 崩溃:交给 Native (bugly)

事件统计

关于事件统计,其实只有两个难点:页面PV和曝光的统计。

事件时间线

页面PV

其实给 Navigator 注册一个全局 NavigatorObserver 即可统计每个页面的变化:

事件时间线

这里需要特别注意的是,一般情况下,对话框的push和pop不能影响 PV 的,判断是否 opaque 即可:

1
2
3
bool isOpaque(dynamic route) {
return route is TransitionRoute && route.opaque;
}

另外,Flutter 的生命周期变化(前后台切换)也算一次 PV,也需要注册 WidgetsBindingObserver 监听两个状态的变化:AppLifecycleState.resumed、AppLifecycleState.paused。

生命周期

曝光统计

而曝光,也许每个团队的数据产品对于页面元素曝光的理解不一样, 但一般都要求是页面元素露出,换句话说,在 Flutter 里,就是该 Widget 的 RenderObject 被 paint。

其实原理也很简单,给需要统计的 Widget 创建一个 GlobalKey,交给 KeyedSubtree 包一下,通过该 key 即可获取对应 RenderObject,再通过 RenderObject所在位置大小来判断是否被曝光。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Rect getRectFromKey(GlobalKey key, [GlobalKey ancestor]) {
RenderObject object = key.currentContext?.findRenderObject();
var isValid = _isValid(object);
if (!isValid) return null;
Rect bounds = object.semanticBounds;
if (bounds == null) return null;
RenderObject ancestorObject = ancestor?.currentContext?.findRenderObject();
return MatrixUtils.transformRect(object.getTransformTo(ancestorObject), bounds);
}

bool _isValid(RenderObject object) {
if (object == null) return false;
if (object is RenderBox) {
if (!object.hasSize) return false;
}
if (object is RenderSliver) {
if (object.geometry == null || object.constraints == null) {
return false;
}
}
return object.paintBounds != null;
}

此外,推荐用Google开发的 visibility_detector,其通过比 RenderObject 更底层的 Layer 来获取较为准确的曝光。

代码质量

在 Flutter 项目根目录创建 analysis_options.yaml 文件,按团队需要配置即可,也可以直接引用pedantic

1
include: package:pedantic/analysis_options.yaml

具体可以参考文档:https://dart.dev/guides/language/analysis-options#the-analysis-options-file

CI 中配置一个 job 用于检查和收集结果,flutter analyze --write=$output ,每个提交需验证通过后才可合入主干

单元测试

单元测试目前我们一般只针对独立性较高和需求不怎么变动的组件。另外由于 CI 限制,也没有做 Flutter 集成测试。

CI 中配置一个 job ,每个提交需单元测试通过后才可合入主干,flutter test

总结

B漫这半年来实(cai)践(keng)Flutter,越发觉得真香。若你或你的团队还在犹豫,希望本文有打消你的疑虑,赶快拥抱 Flutter。 当然,不要忘了拿起手机下载哔哩哔哩漫画APP,真实体验一下 Flutter,看你能不能找出来哪些页面是 Flutter 渲染出来的。

实践经验其实本文所述的还有许多细节,或实在是细枝末节、或一时未想起、或涉及业务代码不能公开,若有某处你很是感兴趣但我没提到或没有展开,可以直接留言与我交流。


广告环节

哔哩哔哩漫画招 Flutter 开发,欢迎投简历到邮箱 wangyongrong@bilibili.com