在 18 年 Flutter 发布正式版 1.0 版本以来,有道 Luna 团队保持持续的关注,在不少业务上进行大量的尝试,Flutter 本身统一 Skia 引擎带来的跨平台特性和一致的体验,AOT 下高性能,JIT 下热重载带来提高开发效率等特性,都让人们保持极大的热情和持续的投入,其生态社区也在快速增长;
从实际表现上来看,整个技术栈设计很好。上层 Flutter Framework 引入 Widget/LayerTree 等概念自己实现了界面描述框架,下层 Flutter Engine 把 LayerTree 用 OpenGL 渲染成用户界面。
长期来看,用 Flutter 来替代 Native,实现双端代码统一,节约人力开发,也是我们持续探索的方向。
ydtech
我们使用 Flutter 在有道词典去年的3月份、7月份分别上线了单词本和听力模考业务,现在是 Flutter 1.12 版本。以下是业务展示:
单词本
听力模考
我们在较为独立的新业务上进行大胆尝试,新技术难免会有问题,但是还是要勇于尝试。
ydtech
Dart 和 JavaScript 都是单线程模型,运行机制很相似,Dart 官方提供了一张运行原理图:
Dart 在单线程中是以消息循环机制来运行的,其中包含两个任务队列,一个是“微任务队列” microtask queue,另一个叫做“事件队列” event queue。从图中可以发现,微任务队列的执行优先级高于事件队列。
其中 event queue:负责处理 I/O 事件、绘制事件、手势事件、接收其他 isolate 消息等外部事件;microtask queue:可以自己向isolate内部添加事件,事件的优先级比 event queue 高。
事件队列模型过程:
在事件循环中,当某个任务发生异常并没有被捕获时,程序并不会退出,而直接导致的结果是当前任务的后续代码就不会被执行了,也就是说一个任务中的异常是不会影响其它任务执行的。
异常捕获上传至统计崩溃平台也是应用这个模型,后面会讲到。
介绍完 Dart,我们再看下 Flutter Widget。在 Flutter 中一切皆为 Widget,通过使用 Widget 可以实现页面整体布局、文本展示、图片展示、手势操作、事件响应等。
2.2.1 StatelessWidget
StatelessWidget 是一个没有状态的 widget ——没有要管理的内部状态。它通过构建一系列其他小部件来更加具体地描述用户界面,从而描述用户界面的一部分。当我们的页面不依赖Widget对象本身中的配置信息以及 BuildContext 时,就可以用到无状态组件。
例如当我们只需要显示一段文字时。实际上 Icon、Divider、Dialog、Text 等都是 StatelessWidget 的子类。
2.2.2 StatefulWidget
StatefulWidget 是可变状态的 widget 。使用 setState 方法管理 StatefulWidget 的状态的改变。调用 setState 通知 Flutter 框架某个状态发生了变化,Flutter 会重新运行 build 方法,应用程序变可以显示最新的状态。
状态是在构建 widget 的时候,widget 可以同步读取的信息,而这些状态会发生变化。要确保在状态改变的时候即使通知 widget 进行动态更改,就需要用到 StatefulWidget。例如一个计数器,我们点击按钮就要让数字加一。在 Flutter 中,Checkbox、FadeImage 等都是有状态组件。
StatefulWidget的生命周期大致可分为三个阶段:
具体的声明周期调用过程如下:
2.2.3 两者的实用场景
在 Flutter 中,组件和页面数据变化是通过 State 驱动的,对于有交互的页面或组件可以继承 StatefulWidget,静态组件或页面可以继承 StatelessWidget。
StatelessWidget 没有内部状态,Icon、 IconButton 和 Text 都是无状态 widget, 他们都是 StatelessWidget 的子类。
StatefulWidget 是动态的。用户可以和其交互或者可以随时间改变 (也许是数据改变导致的 UI 更新)。Checkbox、 Radio、Slider, InkWell、Form、TextField 都是 StatefulWidget, 他们都是 StatefulWidget 的子类。
使用 StatefulWidget 还是 StatelessWidget 的判断依据:
ydtech
开发之初我们考虑两个问题:
起初我们希望生成多个产物进行嵌入,通过 Flutter 的线下会议探讨发现这个思路是比较后期的事情,但是也得到了另一个思路将我们的业务进行“**下沉”**,下沉到同一个工程里面进行业务区分,引入组件化的概念进行实践。
Flutter 工程中,通常有以下4种工程类型,下面分别简单概述下:
Flutter 工程之间的依赖管理是通过 Pub 来管理的,依赖的产物是直接源码依赖,这种依赖方式和 IOS 中的 Pod 有点像,都可以进行依赖库版本号的区间限定与 Git 远程依赖 path 等。其中具体声明依赖是在 pubspec.yaml 文件中,其中的依赖编写是基于 YAML 语法,YAML 是一个专门用来编写文件配置的语言,下面是依赖示例:
vibration:
git:
url: https://github.com/YoudaoMobile/flutter_vibration.git
ref: 'task/youdao'
flutter_jsbridge_builder:
path: ../../Common/flutter_jsbridge_builder
所以,通过 Flutter Plugin / Flutter Package + Pub 达到解耦的目的。
以 Flutter Plugin / Flutter Package 为模块开发,原则上我们将工程分为壳工程、业务组件、基础组件;依赖关系为壳工程->业务组件->基础组件,不能依赖倒置,同层之间不能相互引用。
通过以组件化形式进行开发,通过各个团队业务的不断迭代,逐步沉淀出一套 CommonUI 的基础 Widget 组件,方便其他业务和团队扩展使用。
button工具:YDLabelButton,YDRaisedButton
字体工具:YDFontWeight,YDText,YDHtmlText
遮罩工具:YDMaskView
loading工具:YDDefaultLoadingView,YDLoadingView
圆角工具:CornerDecoration
模态弹出工具:YDCupertinoModalPopupRoute
点击弹窗工具:YDCoordinateTap
帧序列动画工具:YDSimpleFrameAniImage
YDRaisedButton,YDLabelButton是我们统一遵守有道UI准则自定义的一套button内容。
YDText 集成了英文屏幕取词,以及解决中日韩同时展示在界面字体展示异常的问题;
YDHtmlText 我们集成了基于html标签展示进行深层定制,来实现富文本的效果;
YDSimpleFrameAniImage 帧动画播放组件,解决了单纯的图片第一次循环播放会闪烁等问题的播放动画组件;
YDCupertinoModalPopupRoute 仿照新的ios模态弹出的效果,支持随手滑动消失的交互方式。
我们在开发过程发现我们的 bridge 的内容大多数是相同的,只不过是形,函数名不同罢了,所以我们打算引入 source_gen,来生成 bridge 层的代码。这样也带来两个好处,一是防止手误,带来的不必要的 bug;二是将代码统一。source_gen主要提处理dart源码,可以通过注解生成代码。
大致的流程是通过 source_gen 一个 _Builder,_Builder 需要生成器 Generator,之后通过 Generator 去生成代码。
总结一下,在 Flutter 中应用注解以及生成代码仅需以下几个步骤:
1 . 依赖 source_gen
dev_dependencies:
source_gen: ^0.9.
2 . 创建注解
class JSBridgeModule {
final String moduleName;
final List<String> enumTypeName;
const JSBridgeModule({this.moduleName : "app", this.enumTypeName : const []});
}
3 . 创建生成器
class JSBridgeImplGenerator
extends GeneratorForAnnotation<JSBridgeModule> {
JSBridgeImplGenerator() {}
@override
Iterable<String> generateForAnnotatedElement (
Element element, ConstantReader annotation, BuildStep buildStep) {
if (element is! ClassElement) {
final name = element.name;
throw InvalidGenerationSourceError('Generator cannot target `$name`.',
return _generate(classElement, moduleName, enumTypeName: checkEnumTypeName);
}
}
4 . 创建 Builder
Builder getJSBridgeImpGeneratorBuilder(BuilderOptions options) {
return SharedPartBuilder();}
5 . 编写配置文件
在项目根目录创建 build.yaml 文件,配置各项参数
builders:
JSBridgeImpGeneratorBuilder:
import: "package:flutter_jsbridge_builder/builder.dart"
builder_factories: ["getJSBridgeImpGeneratorBuilder"]
build_extensions: {".dart": ["flutter_jsbridge_builder.g.part"]}
auto_apply: dependents
build_to: cache
applies_builders: ["source_gen|combining_builder"]
这样就为我们输出一份模板代码提供了实现的可能。
众所周知,flutter 图片等资源管理方面,还是处在手动管理阶段,费时费力,所以推荐一款网易严选团队开发的 flr 插件,flr 配合通过 AndroidStudio 插件,将用于帮助 Flutter 开发者在修改项目资源后,可以自动为资源添加声明到 pubspec.yaml 以及生成集中在一起的资源路径文件,Flutter 开发者可以在代码中通过资源 ID 函数的方式应用资源。
通过建立起一个自动化的服务来监听和管理资源变化,之后将变化的资源同步到 pubspec.yaml 和对应的资源文件当中,也支持文本,字体资源,后续我们也和 flr 的团队支持黑暗模式的计划。
地址:https://github.com/Fly-Mix/flr-as-plugin/tree/d56e4b989c1de4b493d8e27b3f8ce4100af1f6df
在 flutter 简介里面我们介绍了 dart 的线程模型:
事件队列模型过程:
在事件循环中,当某个任务发生异常并没有被捕获时,程序并不会退出,而直接导致的结果是当前任务的后续代码就不会被执行了,也就是说一个任务中的异常是不会影响其它任务执行的。
Flutter 框架为我们在很多关键的方法进行了异常捕获。在发生异常时,错误是通过 FlutterError.reportError 方法上报的,其中 onError 是 FlutterError 的一个静态属性,我们重写 onError 就可以捕获异常了;但是还有一些异步异常是需要我们通过 Zone 方法来捕获的,整理代码如下:
ydtech
1.12是个分水岭,在这之前安卓打包方式有所不同,并且 iOS官方也提供一些命令也来支持打不同的包。
通过 flutter build ios --release 来得到产物,然后 flutter-plugins里面记录各种 plugin 的位置 copy 过来,放在 .symlinks 文件夹下。
app.framework : 代码数据段+图片
flutter.framework : engine+channel+...
FlutterPluginRegistrant : 源码,一些 flutter 自身的 bridge
podhelper.rd:通过flutter-plugins里的bridge列表循环的将bridge填到pod中,在宿主工程通过pod引入
android 产物组成 flutter1.0
进入 flutter 工程的 .android 文件夹执行 ./gradlew assembleRelease 就会打出一个 flutter-release.aar 的包,但是还有 path_provider,share_preference,audioplayer 等官方插件我们也需要 copy出来,这里我们发现 flutter 工程目录下面有 .flutter-plugins 这个文件,这个文件记录着你当前 flutter 所使用的官方插件的文件位置,我们通过 shell 读取文件位置,找到对应的 aar 集中到一起。
flutter 1.12 打包执行 flutter build aar --no-debug –-no-profile 来得到。
在 flutter1.0 版本进入 android 文件夹执行 ./gradlew assembleRelease 会得到 aar 产物,但是此时的 aar 嵌入进去 run 起来会报错,错误信息是缺少一个 .dat 文件,根据官方的 issue 和对源码的思考,讨论结果是里 assets下面少一个 flutter_assets文件内容,事实上在 io/flutter.jar 可以看到,但是 flutter 还是会去 assets 文件夹下去找,导致嵌入 Android 失败。
解决办法:从apk里copy一份flutter_asset放到aar里。
在 flutter 1.12 版本官方提供 aar 产物命令,但是工程中引入官方库(shared_preferences)的时候会执行命令失败,原因是第三方会带上 macos 和 web 的 package,但是这个 package 不带 android 文件的内容。
解决办法:通过修改官方 sdk 对其 android 文件夹进行兼容。
在打包机配置完 flutter 环境,需要在 Jenkins 的节点配置将 flutter path 添加到 PATH 当中,否则 flutter 命令执行失败;以及 ios 打包 flutter build ios --release 会因为 code sign 没有权限的问题失败,尽量用 flutter build ios --release --no-codesign 来得到环境。
ydtech
5.1.1 混合栈boost出现的问题
首先感谢咸鱼团队,提供了混合栈的一种方案,我们从 flutter1.9 升级到 1.12过程中,遇到不少的问题和麻烦。
在 1.9 的版本中,ContainerLifeCycle.Appear 方法会回调两次,导致依赖生命周期操作重复,在 ios 这边是在 viewdidappear 的时候会发通过 channel 发 didShowPageContainer 的消息,调用 nativeContainerDidShow,然后在 TransitionBuilder 的方法再去调用一次。
onPageStart 然后再去调用 nativeContainerDidShow,就会导致两次触发,android 也是在 onAppear 的方法上重复上述的操作。
解决办法:去掉其中一个。
这个问题版本有很多,得考虑业务场景。我们这里是先模态出一个 NavigationViewController,然后在这个 NavigationViewController 基础上进行 push 和 pop 操作,然后我们在全局提供一个回到模态之前 ViewController 的操作。在全局回退的过程中,我们清掉了 native 的栈,然后在 native 的任意 vc,切前后台后crash。
但是在 1.9 版本时并没有发现此类问题。crash 的原因是在 1.12的版本中 FlutterEngine 自身加了 surfaceUpdated的操作,当你整个退出后没有正确的处理,导致 FlutterEngine 认为你的页面上还存在着 Flutter 页面,进行刷新创建工作,就 crash 了。当然这个是我们这个业务场景总结出的 crash 的原因,据说还有其他版本crash问题,欢迎其他朋友补充。
解决办法:在全局回退的过程中循环调用close方法将栈里的vc退出。
5.1.2 多语言显示异常
当界面同时显示在韩语/日语与中文时,界面展示异常。官方issue:https://github.com/flutter/flutter/issues/36527。解决方式有三种:
1 . 增加字体 ttf ,全局指定改字体显示。
2 . TextStyle 属性指定:可以封装成一个 widget
fontFamilyFallback: ["PingFang SC", "Heiti SC"]
3 . 修改主题下所有 TextTheme 的 fontFamilyFallback
getThemeData() {
var themeData = ThemeData(
primarySwatch: primarySwatch
);
var result = themeData.copyWith(
textTheme: confirmTextTheme(themeData.textTheme),
accentTextTheme: confirmTextTheme(themeData.accentTextTheme),
primaryTextTheme: confirmTextTheme(themeData.primaryTextTheme),
);
return result;
}
/// 处理 ios 上,同页面出现韩文和简体中文,导致的显示字体异常
confirmTextTheme(TextTheme textTheme) {
getCopyTextStyle(TextStyle textStyle) {
return textStyle.copyWith(fontFamilyFallback: ["PingFang SC", "Heiti SC"]);
}
return textTheme.copyWith(
display4: getCopyTextStyle(textTheme.display4),
display3: getCopyTextStyle(textTheme.display3),
display2: getCopyTextStyle(textTheme.display2),
display1: getCopyTextStyle(textTheme.display1),
headline: getCopyTextStyle(textTheme.headline),
title: getCopyTextStyle(textTheme.title),
subhead: getCopyTextStyle(textTheme.subhead),
body2: getCopyTextStyle(textTheme.body2),
body1: getCopyTextStyle(textTheme.body1),
caption: getCopyTextStyle(textTheme.caption),
button: getCopyTextStyle(textTheme.button),
subtitle: getCopyTextStyle(textTheme.subtitle),
overline: getCopyTextStyle(textTheme.overline),
);
}
flutter pub get 失败。Flutter 项目在引用第三库时,在 pub 会选择使用 git 引用,如:
flutter_boost:
git:
url: 'https://github.com/YoudaoMobile/flutter_boost.git'
ref: 'youdao_0.0.8'
会报 pub get fail 的问题。
在下载包的过程中出现问题,下次再拉包的时候,在 .pub_cache 内的 git 可能是空目录,导致 flutter packages get 的时候异常。
所以你需要清除掉 .pub_cache 内的 git 的异常目录或者执行 flutter cache repair ,之后重新执行 flutter packages get 。
Flutter定义了三种不同类型的Channel,它们分别是
其中 channel 有个很重要的变量 codec;Codec 官方定义了两种 Codec:MessageCodec和 MethodCodec。
其中 MessageCodec 有4种不同的种类:
BinaryCodec;StringCodec;JSONMessageCodec;StandardMessageCodec
起初我们使用 MethodChannel 来建立通信,但是使用过程中遇到大内存的传递耗时很长的问题,我们通过一系列的实验和官方文档的指引,当需要传递大内存数据块时,使用 BasicMessageChannel 以及 BinaryCodec可以解决问题。
以下是实验内容:
实验机型:iphone 7,ios 13.7系统
实验数据:开发者可以自行模拟1M-2M左右的数据进行测试,由于涉及到真实数据,这里就不放出来了。
实验结果:
实验次数 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
传输时间(s) | 0.112 | 0.003 | 0.006 | 0.005 | 0.004 |
实验次数 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
传输时间(s) | 0.162 | 0.121 | 0.149 | 0.162 | 0.163 |
实验次数 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
传输时间(s) | 0.021 | 0.064 | 0.035 | 0.033 | 0.037 |
实验次数 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
传输时间(s) | 0.075 | 0.034 | 0.052 | 0.061 | 0.053 |
从实验结果上看,传输效率最优的是 BinaryCodec,当然选择什么样的 code 根据项目的需求来定才是最合理的。
BinaryCodec>StringCodec>StandardMessageCodec>JSONMessageCodec
5.4.1 列表内容项长短不一
当我们实现类似上面的页面,item 高度不一的思路肯定是类似以下的代码,但是当我们达到一定的数量级的情况下,发现内存占用的十分严重,导致有些需求就实现不了,比如说支持滚动条快速定位;究其因 CustomScrollView 初始化就加载了很多的 widget。
List<Widget> list = [];
for (int i = 0; i < 10000; i ++) {
list.add(SliverToBoxAdapter(child: Container(height:30,color: Colors.red,),));
list.add(SliverFixedExtentList(delegate: SliverChildBuilderDelegate((context,index){
return Container(child: Text(index.toString()),);
},childCount: 50), itemExtent: 50)
);
}
CustomScrollView(slivers: list,);
但是假如都是同样的 itemExtent,滚动效率,内存表现都是良好的;因为事先告诉好高度,而不是依赖 widget 自身的 layout 计算效率就高了很多, 比如说以下的代码:
List<Widget> list = [];
list.add(SliverFixedExtentList(delegate: SliverChildBuilderDelegate((context,index){
return Container(child: Text(index.toString()),);
},childCount: 20000), itemExtent: 50)
);
CustomScrollView(slivers: list,);
如何两者兼得呢?
我们决定自定义 SliverFixedExtentList, SliverFixedExtentList 返回的 RenderObject 是 RenderSliverFixedExtentBoxAdaptor,我们将 RenderSliverFixedExtentBoxAdaptor 重新设计下。
RenderSliverFixedExtentBoxAdaptor 原先设计的思路是通过 scrolloffset 除以 itemExtent 计算出当前的 index(itemExtent 是写死的所以直接除),SliverConstraints 可以拿到他的 remainingCacheExtent 也就是 cacheExtent 加上滚动可见区域,也就可以拿到 lastIndex,在滚动的过程中不断的释放和创建。
我们改写的思路如下:
大致的思路就是这样,接口层面我们设计成这个样子,以下是调用示例:
YDSliverFixedExtentList(
delegate: SliverChildBuilderDelegate((context, int index) {
if (index > wordList.length - 1){
return Container(color: Colors.transparent,);
}
YDWBListBaseModel model = wordList[index];
if (model is YDWBListHeaderModel) {
return buildSusWidget(model.title);
} else if (model is YDWBListItemModel){
return buildListSingleItem(index, wordList[index], onMoveTap, onDeleteTap);
} else{
return Container();
}
},
childCount: wordList.length + 1,
),
itemHeightDelegate: (index){
if (index > wordList.length - 1){
return 60;
}
var model = wordList[index];
if (model is YDWBListHeaderModel) {
return kItemHeaderHeight;
} else if (model is YDWBListItemModel) {
return kItemHeight;
} else {
return 60;
}
},
itemIndexDelegate: (startIndex, endIndex){
firstIndex = startIndex;
lastIndex = endIndex;
},
5.4.3 如何获取当前展示列表索引
ios 开发都知道,我们的 TableView 是有代理来知道我们当前页面展示的 Cell 的索引,但是在 Flutter 里我们怎么办呢?
代码如下:
double y=model.key.currentContext.findRenderObject().getTransformTo(null).getTranslation().y;
double height=model.key.currentContext.findRenderObject().paintBounds.size.height;
然后找到对应绑定的model。
不过,接下来介绍的是另一种办法,改写 SliverChildBuilderDelegate,在 SliverChildBuilderDelegate 里面的 didFinishLayout 里会返回它的 firstIndex和 lastIndex,但是要注意此时返回的是加了 cacheExtent 的 firstIndex,所以可能比实际展示的要小,所以可以结合思路一进行精确定位。
class MySliverChildBuilderDelegate extends SliverChildBuilderDelegate {
final int markIndex;
MySliverChildBuilderDelegate(
this.markIndex,
Widget Function(BuildContext, int) builder, {
int childCount,
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
}) : super(builder,
childCount: childCount,
addAutomaticKeepAlives: addAutomaticKeepAlives,
addRepaintBoundaries: addRepaintBoundaries,
);
@override
void didFinishLayout(int firstIndex, int lastIndex) {
debugPrint('pre' + 'didFinishLayout firstIndex: $firstIndex, lastIndex: $lastIndex' + "markIndex" + markIndex.toString());
if (firstIndex == 0 && lastIndex == 0) {
return;
}
YDBaseListEvent.notifyIndexChange({"firstIndex": markIndex + firstIndex, "lastIndex": markIndex + lastIndex});
debugPrint(mark + 'didFinishLayout firstIndex: $firstIndex, lastIndex: $lastIndex' + "markIndex" + markIndex.toString());
}
}
ydtech
我们后续计划升级到flutter2.0,但是目前来看2.0还存在问题,如果2.0真的彻底的解决多引擎复用的问题,我们也会尝试去除boost的管理机制,根据https://flutter.cn/posts/flutter-engage-china-event-recap,在多个引擎复用的视频章节,于潇分析了多引擎复用的内存增长的问题,主要在:
这五部分,每起一个 engine 之后就会起 3 个新的操作系统的线程,每个线程都是有成本的尤其是在 ios 上,在 2.0 版本上都合并在一起了;另一部分GPU资源,skia Context 就包含了 opengl 的 context,metal context,metal buffer,shader program,skia program,为了提高使用启动将 GPU 资源和 Skia Context 的内容做共享;字形的大小都会有缓冲的,假如不加以利用的话也造成一定的浪费;dart Isolate 事实上每次创建 engine 都会重新创建一个,2.0 版本也做了一个共享。
结果也是比较客观,优化的效果比较明显,10 次的启动不升反降,40M 变成了 35M。有兴趣的可以试下,https://github.com/flutter/samples/tree/master/add_to_app,但是对于 flutter 团队来说 2.0 版本只是解决了内存问题,还存在其他的问题,主要是以下几方面:
ydtech
我们在单词本和听力等模块进行 flutter 落地的探索,在前期实践过程中,碰到了很多问题,但总体来说还处于可控的状态;前期把各种困难都解决后,后面业务再此基础上进行开发会顺畅很多,效率会提升很多,这个也是 flutter 期望带给我们的一次开发,多端运行。但是希望开发者们在落地过程中,期望更为慎重些,多多实践,提前发现提前解决,毕竟存在处理不好的情况,还需要推动官方或者生态提供更好的解决办法。
未来期望 flutter 以及社区在平台一致性以及混合栈,内存,键盘,音视频等具体问题上持续发力,我们也会进一步的探索 flutter 在业务上更多实现的可能。感谢观看。
以上内容仅代表个人观点,如果内容或者实验数据存在疑问和问题,欢迎大家批评指正,一起学习,一起成长。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8