在上一篇文章《移动端跨平台技术方案研究》中,我们通过简单的例子学习了RN和Flutter两大主流的跨平台技术方案,并对它们做了全面的分析和比较。后续,我们便在公司的实际项目StreamKar中接入了Flutter,搭建了一些基础的代码,开发了一个完整的页面,更进一步的学习和体验了Flutter混合开发。本文,将以iOS平台为基础,记录Flutter开发实践过程中的一些要点,给大家入门Flutter开发提供一些帮助。
1.Dart语言
Flutter开发需要先学习一下Dart语言。好在Dart与很多语言相比真的十分简单易学,实践下来,我觉得它可能比JS还要简单。 第一次接触的朋友,我推荐大家看这篇中文文档:
就一个页面,主要的语言功能都包含了,半天到一天就可以看完。
2.现有iOS App集成
从1.12版本开始,现有原生App集成Flutter终于有了正式的官方文档。
2.1创建Flutter module
Flutter的代码是以Flutter module的形式集成进原生App的,所以,首先我们需要创建一个Flutter module:
flutter create --template module my_flutter
2.2集成
然后,官方提供了两种方式把Flutter module集成进现有App:
-
通过CocoaPods集成:
在项目Podfile的头部添加:
flutter_application_path = '../my_flutter' load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
然后,再在Podfile的target里添加:
install_all_flutter_pods(flutter_application_path)
这里,我们碰到了一个小问题,由于我们Podfile和App工程文件不在同一个路径下,所以执行pod install自动生成出来的Flutter Build Script中flutter_export_environment.sh文件路径不对,导致编译不过,每次都要手动改路径。
-
直接集成module的编译产物:
需要开发和调试Flutter的项目成员,必须通过第一种方式集成,用CocoaPods。但是,这样会依赖Flutter环境。而对于项目里只做纯原生开发的成员来说,他们没必要安装Flutter环境。于是,Flutter提供了直接集成编译产物的方案。
iOS下编译产物是Framework,生成的命令如下:
flutter build ios-framework --output=some/path/MyApp/Flutter/
这种方式集成,也为自动打包持续集成提供了方便。
2.3调用
我们需要在原生项目里添加一些代码来调用Flutter页面。
官方文档推荐在App启动的时候先创建并运行FlutterEngine
,然后再把这个引擎对象传给FlutterViewController
来显示Flutter页面。这样引擎提前完成初始化,Flutter页面的加载比较快,不会黑屏,不会闪。但是,内存会一直占用着,而且还比较大。
由于我们的Flutter页面是二级子页面,所以我们没有在App启动时创建FlutterEngine
,而是直接在调用的地方创建FlutterViewController
,并显示,这样Flutter引擎会隐式创建和销毁。
FlutterViewController *vc = [[FlutterViewController alloc] init];
[vc setInitialRoute:@"/feedback"]; //设置路由
[self.navigationController pushViewController:vc animated:YES];
这样做还有一个原因是,显式创建FlutterEngine
的话,路由设置始终不起作用。没找到原因。
2.4运行调试
- 先通过Xcode运行App。
- 进入Flutter页面。
- 在VS Code(已安装Dart和Flutter插件)中打开Flutter module目录。
- 在Command Palette里选Debug: Attach to Flutter on Device。
Attach以后就可以正常设置断点Debug,Hot Reload,Restart了,并且可以在弹出的DevTools网页里查看布局树,内存,CPU,帧率等信息。
3.开发Flutter
上一篇中我们已经介绍了Flutter的一些基本概念,本文将介绍项目开发实例中,我们涉及到的一些方面。
3.1导航与路由
FlutterViewController
是一个容器,它可以容纳一个Flutter页面堆栈。管理页面导航,我做了一点封装,实现以下功能:
- 生成和原生一致的导航栏Widget。
- 支持Flutter页面的Push和Pop,支持从Flutter页面Push原生页面。
- 支持混合页面栈的侧滑返回操作。
3.1.1导航栏
普通导航栏Widget的实现比较简单,用一个CupertinoNavigationBar
,并配合CupertinoPageScaffold
一起使用就可以了。
但是,我们项目中的页面导航栏下有一个TabBar,TabBar通常设在Scaffold
结构的AppBar的bottom,和导航栏是一体的。代码如下:
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: Size.fromHeight(MediaQuery.of(context).padding.top + 45),
child: AppBar(
title: Text('Feedback'),
leading: CupertinoButton(
child: AssetImage("assets/navigationbar_1_white.png"),
onPressed: () => NavigatorHelper.popPage(context);
),
backgroundColor: Color(0xFF682193),
bottom: PreferredSize(
preferredSize: Size.fromHeight(45),
child: Container(
height: 45,
color: Colors.white,
child: TabBar(
tabs: <Widget>[Tab(text: 'Feedback'), Tab(text: 'My Feedback')],
controller: _tabController,
indicator: KKUnderlineTabIndicator(
fixedWidth: 18,
borderSide: BorderSide(
color: Color(0xFFdf2a8b),
width: 3,
),
),
labelColor: Color(0xFF333333),
labelStyle:
TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
unselectedLabelColor: Color(0xFFA8AAB3),
),
),
),
elevation: 0,
),
),
body: TabBarView(
controller: _tabController,
children: [
SubmitFeedbackWidget(),
MyFeedbackWidget()
],
),
);
}
我们通过PreferredSize
设置导航栏和TabBar的高度。elevation属性设为0,消除导航栏下面的阴影。
3.1.2Push和Pop
通过MethodChannel实现了:
- 支持Push Flutter页面。
- 支持从Flutter页面Push原生页面。
- 支持Pop Flutter页面。
- 支持Pop到原生页面。 需要注意的是,我们可以从Flutter页面Push原生页面,但是尽量不要再从该原生页面栈进入新的Flutter页面栈。因为这样会有多个FlutterEngine实例,导致内存大涨。 具体的代码是比较简单的:
Dart端:
static pushNativePage(String route, dynamic params) {
final args = {'route': route, 'param': params};
platform.invokeMethod('pushNativePage', args);
}
static pushPage(BuildContext context, Widget page) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => page),
);
}
static popPage(BuildContext context) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
platform.invokeMethod('popToNativePage');
}
}
OC端:
__weak typeof(self) weakSelf = self;
self.navigateChannel = [FlutterMethodChannel methodChannelWithName:@"sk.flutter.dev/navigate" binaryMessenger:vc.binaryMessenger];
[self.navigateChannel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if ([call.method isEqualToString:@"pushNativePage"]) {
NSDictionary *dic = call.arguments;
NSString *route = dic[@"route"];
id param = dic[@"param"];
if ([route isEqualToString:@"/feedback/detail"]) {
KKTVFeedbackDetailViewController *vc = [[KKTVFeedbackDetailViewController alloc] init];
vc.model = param;
[appCtx.navController pushViewController:vc animated:YES];
}
} else if ([call.method isEqualToString:@"popToNativePage"]) {
[appCtx.navController popViewControllerAnimated:YES];
} else if ([call.method isEqualToString:@"canPopFlutterPage"]) {
UIViewController *vc = appCtx.navController.topViewController;
if ([vc isKindOfClass:[KKFlutterBaseViewController class]]) {
((KKFlutterBaseViewController *)vc).canPop = [call.arguments boolValue];
}
} else {
result(FlutterMethodNotImplemented);
}
}];
3.1.3侧滑返回
Flutter页面栈本身支持了侧边右滑返回手势,但是在我们的项目里,我发现没有生效,侧滑时整个FlutterViewController
里的所有Flutter页面都一起返回了。这可能是我们使用了FDFullscreenPopGesture的原因。解决办法是,需要侧滑返回Flutter页面前把原生导航控制器的手势禁掉:
self.navigationController.interactivePopGestureRecognizer.enabled = NO;
另外,由于我们支持Flutter页面和原生页面的混合栈,所以,我们监听了Flutter页面的Push和Pop,并通过MethodChannel控制原生端导航控制器的手势的开启。
Dart端:
class KKNavigatorObserver extends NavigatorObserver {
@override
void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
bool canPop = route.navigator.canPop();
platform.invokeMethod('canPopFlutterPage', canPop);
}
@override
void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
if (!route.isFirst) {
platform.invokeMethod('canPopFlutterPage', true);
}
}
}
OC端:
- (BOOL)fd_interactivePopDisabled {
return self.canPop;
}
- (void)setCanPop:(BOOL)canPop {
self.navigationController.interactivePopGestureRecognizer.enabled = !canPop;
_canPop = canPop;
}
3.1.4路由
Dart端通过window.defaultRouteName
接收原生端传过来的路由信息。
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
final navObserver = KKNavigatorObserver();// 监听Flutter页面导航
Widget _widgetForRoute(String route) {
switch (route) {
case '/feedback':
return FeedbackPage();
case '/route1':
return MyHomePage();
case '/route2':
return SecondRoute();
case '/route3':
return ThirdRoute();
default:
return Center(child: Text('Unknown route: $route'));
}
}
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorObservers: [navObserver],
title: "Flutter Module",
theme: ThemeData(scaffoldBackgroundColor: Color(0xFFF3F5F9)),
home: _widgetForRoute(window.defaultRouteName),
);
}
}
3.2UI和布局
从上面的代码我们可以看到,该页面总体结构是Scaffold + AppBar + TabBar + TabBarView。而具体的页面构建用到了:
3.2.1基础组件
Text、RichText、Image、InkWell、CupertinoButton、CupertinoTextField
这些组件都很常规,但有以下需要注意的地方:
-
Text在iOS平台默认的字体比原生页面的默认字体要粗一些,所以,文本样式里我通常会把fontWeight设为FontWeight.normal。另外,还需要知晓的是TextiOS平台默认字体和iOS系统默认字体并不一样,但两者看起来很接近。
-
CupertinoButton按钮有iOS平台按钮按下的highlight状态效果,但是它的大小无法小于44*44。
-
点击CupertinoTextField输入框会触发界面自动随键盘上移,但如何自己控制界面上移的高度和位置还没有研究清楚。
-
键盘弹起时点击空白处取消键盘可以把整个界面的根Widget用一个手势Widget包起来:
return GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { // 触摸空白收起键盘 FocusScope.of(context).requestFocus(FocusNode()); }, child: Container(
3.2.2布局组件
Row、Column、Stack、Positioned、Align、Center
3.2.3容器组件
Container、SizedBox、Padding、Expanded
我们还可以通过MediaQuery.of(context).size获取屏幕宽高,通过MediaQuery.of(context).padding获取顶部和底部安全区的大小。
3.2.4滚动组件
SingleChildScrollView、ListView
这里有个坑,SingleChildScrollView在Column里无法滚动,解决办法是给SingleChildScrollView包一个Expanded组件。
Column(
children: <Widget>[
Expanded(
child: SingleChildScrollView(),
)
],
)
3.2.5功能组件
ClipRRect、GestureDetector
Flutter的Widget繁多,对于入门的同学,我推荐大家看看Flutter中文网上的这个教程,没有官方文档那么庞杂,而且讲得清晰到位。
3.3网络请求
我的方案是通过MethodChannel调原生端已有的网络请求库。因为,在Flutter端实现的话,还是要通过MethodChannel获取用户token,做请求加密;通过EventChannel监听服务器切换。而且,还能少用一个Flutter package,减小Flutter包的大小。
3.4JSON解析
我们用了这个JSON to Dart网站来自动生成JSON数据对应的模型类。它支持模型类嵌套,支持转模型数组。
需要注意的是,网络请求返回的response是<dynamic, dynamic>类型,要用Map.from转<String, dynamic>类型,再拿来解析。
FeedbackResult result = FeedbackResult.fromJson(Map.from(response));
3.5公用视图
自定义对话框,Toast,加载视图这些公共的视图我直接通过MethodChannel调的原生端已有的实现。这样能最大程度的和原生端保持一致。
3.6列表刷新
列表的下拉刷新和上拉加载更多用的是第三方的插件pull_to_refresh。然后实现了自定义的Header和Footer。
RefreshController _refreshController = RefreshController(initialRefresh: true);
RefreshConfiguration(
shouldFooterFollowWhenNotFull: (state) {
return true;
},
hideFooterWhenNotFull: false,
headerTriggerDistance: 60,
footerTriggerDistance: 60, //最后一个cell的高度
child: SmartRefresher(
controller: _refreshController,
enablePullUp: true,
onRefresh: _onRefresh,
onLoading: _onLoad,
header: KKListHeader(),
footer: KKListFooter(),
child: ListView.builder(
itemExtent: 60,
itemCount: _data.length,
itemBuilder: (context, index) {
...
}
),
),
),
Future _onRefresh() async {
_data.clear();
_pageIndex = 0;
_hasMore = false;
await _getListData();
_refreshController.refreshCompleted();
if (_hasMore)
_refreshController.resetNoData();
else
_refreshController.loadNoData();
}
Future _onLoad() async {
await _getListData();
if (_hasMore)
_refreshController.loadComplete();
else
_refreshController.loadNoData();
}
3.7保持页面状态
TabBar每次切换tab,对应的TabBarView会销毁重绘。为了保持保持住页面状态,我们需要用到AutomaticKeepAliveClientMixin
,并把wantKeepAlive
设为true,最后在build方法里添加super.build(context)
。
class MyFeedbackWidget extends StatefulWidget {
@override
_MyFeedbackWidgetState createState() => _MyFeedbackWidgetState();
}
class _MyFeedbackWidgetState extends State<MyFeedbackWidget>
with AutomaticKeepAliveClientMixin {//保持页面状态1
@override
bool get wantKeepAlive => true; //保持页面状态2
@override
Widget build(BuildContext context) {
super.build(context); //保持页面状态3
......
}
}
3.8插件与资源
Flutter中依赖的第三方插件,图片资源,其它资源需要在pubspec.yaml文件里声明。VS Code里编辑好文件保存或点Get Packages按钮都会刷新依赖。
3.8.1声明
dependencies:
flutter:
sdk: flutter
# packages and plugins
cupertino_icons: ^0.1.2
image_picker: ^0.6.2+3
pull_to_refresh: ^1.5.8
flutter:
# To add Flutter specific assets to your application, add an assets section,
assets:
- assets/navigationbar_1_white.png
- assets/select_country_selected.png
- assets/select_country_unselect.png
对于不同分辨率的图片,需按指定的目录结构存放:
assets/my_icon.png
assets/2.0x/my_icon.png
assets/3.0x/my_icon.png
由于,我们只有2倍和3倍的图片,没有单倍的图片,所以每张图片都要在pubspec.yaml文件里声明,不能只声明一个图片目录。
3.8.2资源共享
混合开发的情况下,我们希望能避免资源的重复。即Flutter端可以调用原生端已有的资源,原生端也可以调用Flutter端的资源。这个需求可以通过官方插件ios_platform_images实现。
Dart端调原生端资源:
import 'package:ios_platform_images/ios_platform_images.dart';
Image(image: IosPlatformImages.load("flutter")),
原生端调Flutter端资源:
#import <ios_platform_images/UIImage+ios_platform_images.h>
UIImage* image = [UIImage flutterImageWithName:@"assets/foo.png"];
总结
本次混合开发项目实践我们尽可能多的涉及到了Flutter开发的各个方面,留下了一些经验和基础代码供后续的页面或其它项目复用。
但也还有一些方面没有覆盖到,比如动画,自绘控件,复杂的状态管理等等,这需要我们后续再去探索。
上一篇中我们已经从多个角度对Flutter进行了详细的介绍和比较。这次,我们重点再讨论一下Flutter的开发体验。
优点:
- Dart语言易学易用。
- Hot Reload大大提高了界面的开发效率。
- 第三方包与插件已经比较丰富了,基本能满足需要。
缺点:
- Widget太多了,毕竟RN才30多个控件。
- Widget层层嵌套,不够简洁。
- 官方没有可视化的界面编辑器。
- 一些比较底层的问题,碰到了难以解决。
比如,我们在这次实践中发现不论SingleChildScrollView还是ListView,在iOS平台下点击状态栏都不能滚动到顶部。我们按照官方文档做了很多尝试,最终都不行。感觉无从下手。
总得来说,FLutter其实已经足够成熟和完备了。但它还在高速的发展中,本身存在一些问题,或社区反馈还不够多也很正常。我相信,随着国内外各大厂的深入应用,Flutter的可用性会越来越高。