背景
在竞争激烈的移动时代,各大互联网公司都在争相抢夺市场,如何提高研发效率,快速迭代产品成为非常重要的因素。
跨平台方案能够节约一定开发、测试、运维成本。Flutter是由谷歌开源的跨平台框架,可以快速在 iOS 和 Android 上构建高质量的原生用户界面。
一、 为什么选择Flutter
携程在已经引入了 React Native 的情况下,为什么还会选择 Flutter?更多是对性能的考虑。开发效率与性能体验就像天平两端,需要找到一个平衡点。RN 能够满足我们绝大部分的业务,并且热更、版本控制都很灵活。但是在复杂页面上,特别是在长列表的渲染上,还是存在一定的问题,促使我们去尝试一些新的解决方案。Flutter官宣自绘UI引擎,采用原生方式做渲染,媲美原生体验。
Native 、React Native、Flutter 对比如下:
1.1 研发效率
Flutter具有跨平台性,可以在多端上运行。同时Dart语言作为开发语言,本身的优势就在于它既支持JIT,又支持AOT,在 JIT(Just In Time)即时编译功能下,能提供 Hot Reload 功能。在开发过程中,实时地看到界面改动。生产包AOT编译,将代码编译成 ARM 二进制,从而既可以享受运行时又具有原生语言相近的运行效率。
1.2 扩展性好
Flutter提供了多种不同的Channel,用于 Dart 和平台之间相互通信。通过这些桥方法,使Flutter具有很好地与 Native 和 React Native 进行混合编程的能力。赋予 Flutter 一些 Native 的能力,同时也能很好地让我们在现有 Native 项目混合Flutter开发。
二、 Provider对MVVM架构的实践
在Flutter的开发过程中,特别是一些业务复杂的页面,为了代码结构清晰,模块逻辑解耦,我们一般采用的是模块化的编程思想。随之而来的问题就是,组件之间怎么相互通讯,比如变更了登录态,如何通知其他模块刷新?
推荐使用Provider来管理各个组件的状态,我们实践下来 ,主体布局采用MVVM模式是比较方便做模块化编程的。
2.1 为什么需要使用Provider
如果状态是该组件私有的,则应该由组件自己管理;但是如果状态要跨组件共享,则该状态应该由各个组件共同的父元素来管理。对于组件私有的状态很好理解,当需要刷新当前widget的时候,只需要通过setState()的方法来实现组件重绘的效果;对于跨组件共享的状态,可以使用EventBus来实现。
可是当事件多了的时候,难以正确管理,其次订阅者必须要显式注册状态改变回调,也必须在组件销毁的时候手动解绑以避免内存泄漏。而Provider就可以通过自身的原理,简单地去实现状态共享,不需要麻烦的操作。且Provider是官方推荐的状态管理方式,具有良好的生态环境及维护团队。
2.2 Provider的实现原理
1) InheritedWidget简单介绍
Provider是基于InheritedWidget的再次封装,InheritedWidget提供了一种数据在Widget树中自上而下传递,共享的方式。我们在根Widget继承了InheritedWidget,然后在该组件中存放一个数据data,那么可以在任意子Widget中来获取该组件的数据并使用。当在任一组件中改变了共享数据data,InheritedWidget组件会自上而下通知所有使用过共享数据的组件并刷新组件,同时会回调didChangeDependencies() 方法。
2) Provider的原理和流程
共享数据的Model变化后,会自动通知ChangeNotifierProvider,ChangeNotifierProvider内部会重新构建InheritedWidget,而依赖该InheritedWidget的子Widget就会更新。
2.3 Provider的使用方式
架构模式图如下:
1)创建业务ViewModel,在ViewModel内部存放需要共享的数据。ViewModel 继承Flutter SDK中提供的ChangeNotifier类,它继承Listenable,也实现了一个Flutter风格的订阅者模式,其内部实现了addListener(),removeListener()等方法,实现对订阅者的处理。同时最好复写dispose()和notifyListeners()方法,防止用户在调用数据时销毁界面,而等到数据获取到以后通知界面刷新导致Crash。
2)注册状态管理类,使用ChangeNotifierProvider或者MutiProvider将需要共享数据的Widget包起来,单个NotifierProvider时使用ChangeNotifierProvider,多个NotifierProvider时使用MutiProvider包装,如下:
///多个NotifierProvider的时候
return MultiProvider(providers: [
ChangeNotifierProvider(create: (context) => dataViewModel(mCommonAdvancedFilterRoot,query)),
ChangeNotifierProvider(create: (context) => UserPreferentialViewModel(query)),
ChangeNotifierProvider(create: (context) => UserPromotionViewModel())
///需要调用共享数据的子Widget
], child: ListResearchPageful(query));
3)在被包起来的Widget中的任一子组件中获取共享数据ViewModel,可以在StatefulWidget中的builder()方法中获取,也可以使用Builder组件进行获取,如下:
///在StatefulWidget中的build()方法中获取ViewModel
class ListResearchPageState extends TripState<ListResearchPageful> {
@override
Widget build(BuildContext context) {
///使用Provider包装以后,可以在widget的任一一个子widget获取共享数据并操作数据,在这里就是可以在HotelListView方法下的唯一位置获取ViewModel
var listViewModel = Provider.of<ListDataViewModel>(context);
var userPromotionViewModel = Provider.of<UserPromotionViewModel>(context);
return MediaQuery(
child: QueryListPage(widget.query,
ListDataViewModel, userPromotionViewModel));
}
}
///借用Builder组件进行获取ViewModel
@override
Widget build(BuildContext context) {
///使用Provider包装以后,可以在widget的任一一个子widget获取共享数据并操作数据,在这里就是可以在ListView方法下的唯一位置获取ListDataViewModel
var userPromotionViewModel = Provider.of<UserPromotionViewModel>(context);
return MediaQuery(
child: Builder(builder: (context) {
var listDataViewModel = Provider.of<ListDataViewModel>(context);
return queryListPage(
widget.query, listDataViewModel, userPromotionViewModel);
},));
}
4)获取到ViewModel后,可以在子组件中直接使用viewmodel中的共享数据,如下:
//领券监听
///此处可以直接使用viewModel调用viewmodel中的方法
Event.addEventListener(
"UPDATE_QUERY_RESULT_LIST",(eventName, eventData) {
if (isOnPause) {
listViewModel.isNeedRefresh = true;
listViewModel.refreshListData(listViewModel.query);
} else {
listViewModel.refreshListData(listViewModel.query);
}
});
2.4 Provider的优势
1)我们的业务代码更专注数据,只要更新Model,UI就会自动更新,不用在状态改变后再去手动调用setState()来显示更新页面。
2)数据改变的消息传递被屏蔽时,我们无需手动去处理状态改变事件的发布和订阅,provider自行处理。
3)在大型复杂应用中,尤其是需要全局共享的状态非常多时,使用Provider将会大大简化代码逻辑,降低出错的概率,提高开发效率。
三、Flutter 性能调优
一个新技术改造完成,我们最关注的当然是性能体验有没有达到预期。那Flutter页面性能评判标准是什么,如何去度量,有没有可视化工具,帮我们去做一些性能调优。
3.1 Flutter渲染原理简介
在做性能优化之前,先让我们了解一下渲染的原理。Flutter的一切皆为Widget。为了性能又区分了 [StatefulWidget]。StatefulWidget 能通过[setState()]来实现刷新。这样的设计方便我们去控制局部刷新,从而提高性能。
Flutter 中的控件会历 Widget -> Element -> RenderObject -> Layer 这样的变化过程,而其中 Layer 的组成由 RenderObject 中的 isRepaintBoundary 标志位决定。
当调用 setState() 时,RenderObject 就会往上的父节点去查找,根据 isRepaintBoundary是否为 true,会决定是否从这里开始往下去触发重绘,来确定要更新哪些区域。
3.2 构建运行Profile模式
Flutter 支持三种模式编译 app,Debug模式、Release模式和Profile模式。Debug 模式 采用JIT编译,支持HotReload,所以在Debug模式下会放大性能问题。性能分析需要确保使用真机并在profile模式下运行,这样拿到的数据是最接近真实性能的。
1)Debug 模式对应 Dart 的 JIT 模式,可以在真机和模拟器上运行。该模式会打开所有的断言,以及所有的调试信息、服务扩展和调试辅助。此外,该模式支持有状态的 Hot reload。
2)Release 模式对应 Dart 的 AOT 模式,只能在真机上运行,不能在模拟器上运行,其编译目标为最终的线上发布。该模式会关闭所有的断言,以及尽可能多的调试信息、服务扩展和调试辅助。此外,该模式优化了应用快速启动、代码快速执行,以及二级制包大小。
3)Profile 模式,基本与 Release 模式一致,只是多了对 Profile 模式的服务扩展的支持,包括支持跟踪,以及一些为了最低限度支持所需要的依赖。该模式用于分析真实设备实际运行性能。
flutter run —profile 命令是使用 Profile 模式来编译的。IDE 也是支持这个模式的,例如 Android Studio 提供了 Run > Profile… 菜单选项。
a. 打包Flutter工程Profile产物
// 进入flutter项目,执行build-release,并指定输出目录 tripflutter
build-release -o /projects/ctrip_flutter/release -i info
b. 配置Native项目
打包好flutter产物之后,需要导入到native项目并打包。修改Native项目根目录的gradle.properties文件。
### 开启Profile模式
TRIP_FLUTTER_PROFILE=true
### 设置profile模式下js使用的产物目录(过程1构建的 ./profile 目录)
TRIP_FLUTTER_LOCAL_OUTPUTS_PATH=/projects/ctrip_flutter/profile
c. 构建Native工程
直接通过IDE运行到手机上。
3.3 性能分析工具及方法
1)performance overlay
平时常用的性能分析工具有performance overlay,通过它可以直观看到当前帧的耗时。在Profile模式下,通过Android Studio 看页面的FPS,注意需要在HotReload 连接的情况下查看。
选中 View > Tool Windows > Flutter Performance。
点击上面图中的箭头所指的按钮,就会在手机或模拟器中打开(如下图所示)。FPS是一个动态过程,页面滑动这个值是一直变化的,最右边的是当前帧。出现红色则表示耗时超过16.6ms,也就是发生丢帧现象,也是我们常说的页面闪动问题。performance overlay的主要功能如下:
2)Dart DevTool
另一个工具是Dart DevTool ,在Android studio右侧,还可以从Flutter inspector里面的more action,以及Flutter Performance底部的入口进入。
目前DevTools支持的功能有如下一些:
3.4 实战性能技巧
1)懒加载ListView
推荐使用ListView.builder()构建List,这样当Item滚入屏幕时才创建Item,而不是ListView-children,这样会立刻创建所有的Item。
///Bad code 不推荐使用children 构建List
ListView(children: getItems(mList))
List<Widget> getItems(List<FilterNode> mList){
List<Widget> items=new List<Widget>();
if(null!=mList){
for(Node node in mList){
items.add(Text("不推荐写法"));
}
}
return items;
}
///推荐写法
ListView.builder(
// physics: NeverScrollableScrollPhysics(),
//shrinkWrap: true,
itemCount:mList.length,
itemBuilder: (BuildContext context, int index) {
return Text("推荐使用ListView.builder()");
})
)
注意,无论是ListView还是GridView,只要是设置了shrinkWrap: true属性,都没有了懒加载的效果了。
2)控制刷新范围与次数
如上图所示,需要滑动的过程中,显示、隐藏标题栏,并且是一个渐变的过程,遇到这种情况,一定要尽量的控制刷新的范围和频次。控制在只在头图可见的情况下面触发setStat(),避免不必要的页面滑动触发刷新。
scrollController.addListener(() {
if (scrollController.offset > scrollHeight && titleAlpha != 255) {
setState(() {
titleAlpha = 255;
});
}
if (scrollController.offset <= 0 && titleAlpha != 0) {
setState(() {
titleAlpha = 0;
});
}
if (scrollController.offset > 0 && scrollController.offset < scrollHeight) {
setState(() {
titleAlpha = scrollController.offset * 255 ~/ scrollHeight;
});
}
});
如上图所示在列表中 Item 中存在大量的倒计时。一定要控制刷新倒计时只影响控件本身,并且只有可视的区域视图是在刷新的,不可见的情况下及时销毁计时器。一直刷整个列表,性能开销是恐怖的。
Widget build(BuildContext context) {
return Text(timeRemaining,
style: TextStyle(
color: HotelColors.hotel_list_reduction_sale_color,
fontSize: 10,
fontWeight: FontUtil.mediumWeight));
}
3)避免组件重复创建
能复用的组件尽量复用,特别是在组件化编程,页面级的情况下面,每次刷新页面把所有的子组件都重新渲染一遍,性能开销也是很大的。尽量复用,避免不必要的视图创建。
///存放界面所有的widgets,用以缓存
List<Widget> widgets = new List<Widget>();
///因为头部布局是静态的不刷新,使用变量控制是否复用以前的widgets
var refreshPage = true;
///获取界面布局所有的widgets
List<Widget> getPageWidgets(ScriptDataEntity data) {
if(widgets.isNotEmpty && !refreshPage) {
return widgets;
}
}
四、Flutter 布局技巧
4.1 Flutter 不可见组件预加载
Flutter 一些组件基本都是有懒加载的,不可见的组件是没有渲染视图的,这样滑动过去,有用到网络图片的地方,经常会先白一下。针对这种情况我们对将要加载的图片进行预加载处理,比如列表页在分页请求数据回来的时候做图片预加载。还有,下一个页面的图片,需要一进去就有图片直接显示,就可以在当前页面做图片预加载。
预加载
未预加载
代码如下所示:
///对每一页加载的数据进行做图片预加载
(hotelListViewModel.currentPageHotels ?? []).forEach((element) {
var logo = element?.logo ?? "";
if (StringUtil.isNotEmpty(logo)) {
precacheImage(NetworkImage(logo), context);
}
});
当数据出来后使用PreChcheImage()预加载处理图片链接,以保证当用户滑动图片以后不会看到图片加载白屏这种问题。
4.2 Flutter 数据预加载
为了缩短用户的加载等待时长,我们经常需要一些预加载方法。比如在前一个页面预加载下一个页面的数据,或者在长列表的分页请求时候,可以做分页预加载。比如当你滑动到第五个可见的时候,就提前把下一页的数据加载好。
列表页通过桥方法获取上一个页面预加载的数据,这样就能有一个直出体验,这里要考虑数据已经加载好、加载中、加载失败的情况。同时还要考虑,缓存数据的时效性,什么情况下需要删除缓存。
///请求列表数据数据
void loadListData(HotelQuery query) {
///在首页提前获取列表页的数据并缓存到本地,当用户进入列表页时可以直接展示数据
if (resultModel != null) {
///判断是否需要再次请求数据
_dealWithResult(resultModel);
return;
} else if (isPreloading) {
///通过桥方法获取首页已经缓存的数据 HotelBridge.getListCache<Map>({'queryModel':query.toJson()})
.then((resp) {
final newResultModel =
QueryResultModel.fromJson(resp);
///有缓存数据直接处理使用
_dealWithResult(newResultModel);
}).catchError((error) {
///没有数据采取请求列表页的数据
getHotelList();
});
}
}
4.3 布局自适应高度
如果需要根据内容填充的高度来自适应左边图片的高度,目前Flutter并不支持该功能,我们可以借助IntrinsicHeight组件来完美地解决该问题。InstrinsicHeight可以让同一行的子widget都是相同的高度。
五、Flutter 中常见问题分析及解决方案
5.1 设置State引起的问题
1)错误展示信息:
NoSuchMethodError: The method markNeedsBuild was called on null。
2)错误分析
这个错误一般情况下出现在异步任务,比如一些界面请求网络数据,异步获取本地数据等,需要根据数据的状态来改变刷新Widget State。异步任务结束在页面被销毁之后,没有检查State是否还是mounted状态,继续setState()就会出现这个错误。错误代码如下所示:
///从服务器端获取当前活动终止时间,当服务器返回以后,会通知刷新这里
///如果用户在数据返回之前销毁该界面,等数据回来以后刷新界面就会报错
final endTime = roomDetailItemEntity?.tonightEndTime ?? '';
int endTimeOfNum = 0;
if (endTime.isNotEmpty) {
try {
endTimeOfNum = int.parse(endTime) ?? 0;
if(endTimeOfNum - Util.currentTimeMillis() > 0) {
this.setState(() {
_showCountDown = true;
});
}
} catch (e) {}
}
3)处理办法
在调用setState()方法之前检查是否mounted,mounted是一个标示当前Widget树是否已经被渲染的状态值。所以mounted检查很重要,只要涉及到异步还有各种回调的时候,都不能忘记检查该值。如下:
final endTime = roomDetailItemEntity?.tonightEndTime ?? '';
int endTimeOfNum = 0;
if (endTime.isNotEmpty) {
try {
endTimeOfNum = int.parse(endTime) ?? 0;
if(endTimeOfNum - Util.currentTimeMillis() > 0) {
if(mounted) {
this.setState(() {
_showCountDown = true;
});}}} catch (e) {}
}
5.2 使用MediaQuery.of()动态获取屏幕属性的问题
1)错误展示信息
BoxConstraints has a negative minimum width;
2)错误分析
这种情况一般出现在需要获取屏幕宽度,根据屏幕宽度减去另外一个组件的宽度,用来设置另外一个组件的宽度导致,在一些计算速度比较低的手机,可能获取到的屏幕宽度为0,这样就会导致你的组件的宽度为负数,报出错误异常。如下所示:
Widget hotelListDesContent(BuildContext context) {
return Container(
///此处想实现左边是图片,右边是相关信息的布局,如果MediaQuery.on(context).size.width获取为0时,就会报出异常
width: MediaQuery.of(context).size.width - Dimens.image_width80,
///右边内容
child: Stack(children: [
Container(child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
hotelListDesName(),
englishName(),
hotelListRemarkContent(),],),),
///左边图片
Positioned(child: fullRoomItem()),
],
));
}
3)处理方式
尽量使用Expand,Flexible,Flex,Wrap,Stack等组件配合Column,Row进行动态布局设置组件的宽高等。如下所示:
Widget hotelListDesContent(BuildContext context) {
return Expanded(
flex: 1,child: Stack(
children: [Container(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
hotelListDesName(),
englishName(),
hotelListRemarkContent(),],),),
Positioned(child: fullRoomItem()),
],
));
}
5.3 使用Provider时,未判断界面状态通知界面刷新的问题
1)错误信息展示
Null check operator used on a null value;
2)错误分析
一般情况下出现这种问题是由于界面销毁后,继续调用notifyListeners()方法通知界面刷新引起的bug。当用户打开一个界面,我们发送了API请求,此时用户销毁了界面,我们并未监听,等到数据返回以后,强行通知界面刷新,导致Crash。如下所示:
HotelServices.getTyHotelRoomPrice(params, ApiCallBack(onSuccess: (Object obj) {
this.roomPriceEntity = HotelRoomPriceEntity.fromJson(obj);
this.resultCode = 1;
///如果在数据返回是,用户已经关闭当前界面,此处通知刷新界面会导致crash
notifyListeners();
}, onError: (int code, String message) {}
notifyListeners()
}));
3)处理方式
正常情况下,我们会写一个基类继承ChangeNotifier,在内部重新复写dispose()方法,同时重新封装方法通知刷新界面,在每次需要通知刷新界面的时候判断当前界面是否已经被销毁。如下所示:
import 'package:flutter/cupertino.dart';
/// ViewModel基类
class HotelViewModel extends ChangeNotifier{
bool _disposed = false;
@override
void dispose() {
_disposed = true;
super.dispose();
}
void hotelNotifyListeners() {
if(!_disposed){
notifyListeners();
}
}
}
5.4 使用Text.rich时导致的问题
1)错误信息展示:UnimplementedError
2)错误分析
出现这个问题的原因在于使用Text.rich来展示多个Span组件时,如果设置了最大行数,当组件超过最大行数,有别的组件未成功展示时,再次点击当前widget,使它接受时间,就会导致crash,用户的感知为操作无响应,其实已经crash。如下所示:
///母房型名称, 当前我们Text最大显示两行,当大于两行是,出现...,可是此时第二个组件无处显示,当用户点击就会crash
Row(children: <Widget>[
Expanded(child: Text.rich(TextSpan(
children: [TextSpan(
text: itemRoomEntity.baseName ??""),
WidgetSpan(
child: Container(
padding: EdgeInsets.only(bottom: Dimens.gap_dp3),
child: Icon(HotelIcons.show_more),
),
),
]), maxLines: 2, overflow: TextOverflow.ellipsis),
),
], crossAxisAlignment: CrossAxisAlignment.center,),
3)解决办法
使用Flexible代替Expanded,直接使用Text即可,区别在于Flexible不会自动填充整个剩余宽度,如下所示:
///母房型名称
Row( mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Flexible(child: Text((childCount > 1)?itemRoomEntity.baseName ?? "":"",
maxLines: 2,
overflow: TextOverflow.ellipsis,),),
Container(child: Icon(childCount ==1?HotelIcons.show_more:null),
margin: EdgeInsets.only(top: Dimens.gap_dp2),),
], crossAxisAlignment: CrossAxisAlignment.center,)
六、总结与展望
总结一下,本文我们介绍了选择Flutter的初衷,Provider 状态管理的实际使用,建议Flutter主体的构架采用MVVM模式,还介绍了一些Flutter性能检测、量化工具和一些性能优化点供大家参考。收集了Flutter开发过程中常见并且大量发生的问题,并提供了相应的解决方案。
在复杂业务和长列表上面体验,确实 Flutter 优于 React Native。但是React Native 也有它的优势,比如灵活的版本迭代。没有最好的跨平台方案,只有最合适业务的。目前来说,Flutter还处于早期阶段,随着Flutter2.0的重大升级,其跨平台能力、性能、生态系统将会蓬勃发展,还是很值得尝试的。后续我们也将有更多的业务接入Flutter。
Copyright© 2013-2019