原生App接入Flutter混合开发实践

3,781 阅读11分钟

在上一篇文章《移动端跨平台技术方案研究》中,我们通过简单的例子学习了RN和Flutter两大主流的跨平台技术方案,并对它们做了全面的分析和比较。后续,我们便在公司的实际项目StreamKar中接入了Flutter,搭建了一些基础的代码,开发了一个完整的页面,更进一步的学习和体验了Flutter混合开发。本文,将以iOS平台为基础,记录Flutter开发实践过程中的一些要点,给大家入门Flutter开发提供一些帮助。

1.Dart语言

Flutter开发需要先学习一下Dart语言。好在Dart与很多语言相比真的十分简单易学,实践下来,我觉得它可能比JS还要简单。 第一次接触的朋友,我推荐大家看这篇中文文档:

Dart开发语言概览

就一个页面,主要的语言功能都包含了,半天到一天就可以看完。

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运行调试

  1. 先通过Xcode运行App。
  2. 进入Flutter页面。
  3. 在VS Code(已安装Dart和Flutter插件)中打开Flutter module目录。
  4. 在Command Palette里选Debug: Attach to Flutter on Device。

Attach以后就可以正常设置断点Debug,Hot Reload,Restart了,并且可以在弹出的DevTools网页里查看布局树,内存,CPU,帧率等信息。

3.开发Flutter

上一篇中我们已经介绍了Flutter的一些基本概念,本文将介绍项目开发实例中,我们涉及到的一些方面。

3.1导航与路由

FlutterViewController是一个容器,它可以容纳一个Flutter页面堆栈。管理页面导航,我做了一点封装,实现以下功能:

  1. 生成和原生一致的导航栏Widget。
  2. 支持Flutter页面的Push和Pop,支持从Flutter页面Push原生页面。
  3. 支持混合页面栈的侧滑返回操作。

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实现了:

  1. 支持Push Flutter页面。
  2. 支持从Flutter页面Push原生页面。
  3. 支持Pop Flutter页面。
  4. 支持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

这些组件都很常规,但有以下需要注意的地方:

  1. Text在iOS平台默认的字体比原生页面的默认字体要粗一些,所以,文本样式里我通常会把fontWeight设为FontWeight.normal。另外,还需要知晓的是TextiOS平台默认字体和iOS系统默认字体并不一样,但两者看起来很接近。

  2. CupertinoButton按钮有iOS平台按钮按下的highlight状态效果,但是它的大小无法小于44*44。

  3. 点击CupertinoTextField输入框会触发界面自动随键盘上移,但如何自己控制界面上移的高度和位置还没有研究清楚。

  4. 键盘弹起时点击空白处取消键盘可以把整个界面的根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的开发体验。

优点:

  1. Dart语言易学易用。
  2. Hot Reload大大提高了界面的开发效率。
  3. 第三方包与插件已经比较丰富了,基本能满足需要。

缺点:

  1. Widget太多了,毕竟RN才30多个控件。
  2. Widget层层嵌套,不够简洁。
  3. 官方没有可视化的界面编辑器。
  4. 一些比较底层的问题,碰到了难以解决。

比如,我们在这次实践中发现不论SingleChildScrollView还是ListView,在iOS平台下点击状态栏都不能滚动到顶部。我们按照官方文档做了很多尝试,最终都不行。感觉无从下手。

总得来说,FLutter其实已经足够成熟和完备了。但它还在高速的发展中,本身存在一些问题,或社区反馈还不够多也很正常。我相信,随着国内外各大厂的深入应用,Flutter的可用性会越来越高。