Flutter混合开发—iOS篇

很多情况下用Flutter来编写整个项目是不太现实的。例如公司已经有了成熟的App产品了,去用Flutter去重写整个项目会有很大的工作量和功能上的风险;有时候公司出于谨慎的原因,不可能去冒失的取采用新的技术,可能更愿意去用一些次要的功能部分去试水,如果效果不错才会继续大面积使用。

我们可以将Flutter打包成模块(module)整合进入原生的iOSAndroid项目中实现上述需求。最开始Flutter只支持单个页面,最近已经开始支持多个Flutter页面,但是正如官方所说的其还是不太稳定,有各种莫名其妙的问题。如果不幸采坑,可以试着弯弯绕绕去解决哦,否则只能躺平了。

Note: Support for adding multiple instances of Flutter became available as of Flutter 2.0.0. Use at your own risk since stability or performance issues, and API changes are still possible.

项目介绍

本项目的例子是一个影音App,基于Native项目搭建,包含三个Tab,每个Tab的内容是一个Flutter模块:

  • 首页模块
首页模块
  • 频道模块
频道模块
  • 我的模块
我的模块

说明:上面三个Flutter模块都是独立的,但是首页和频道模块能进入影音详情页面,播放的时候记录播放的历史记录,能够点赞,这些播放历史和点赞的数据在我的模块中显示,会涉及到独立的Flutter模块之间的数据共享。此外,看不到播放效果是因为播放器不支持iOS模拟器,真机上是可以播放的。

混合开发的实现过程

ios项目搭建

新建项目的具体过程就不介绍了。

我们基于CocoaPodStoryBoard搭建了一个首页是UITabbarController的项目。然后新建了三个UIViewController---MainViewController, ChannelViewControllerMineViewController,他们将会分别嵌入对应的Flutter模块。

  • 项目的结构和Podfile内容
项目结构
  • Storyboard预览
在这里插入图片描述
  • UIViewController中都没有代码
class MainViewController: UIViewController {}
class ChannelViewController: UIViewController {}
class MineViewController: UIViewController {}
  • 最后的效果
效果

Flutter模块的编写

  • 建立一个Flutter模块---flutter_movie_player
cd /directory
flutter create --template module flutter_movie_player

注意:Flutter项目和iOS项目最好是放在一个目录中,并且层级相同。原因是iOS项目需要引用Flutter项目中的文件和库。

目录
  • 编写Flutter代码

由于本文只是为了介绍混合开发的实现逻辑,所以不会去详细介绍每个Flutter页面是如何实现的,你自己练习时可以不修改任何代码,就用默认的那个Flutter计数器也是可以的。

我们接下来会介绍一些重要的入口相关的类:

  1. main.dart
<!-- 首页模块入口 -->
@pragma('vm:entry-point')
void main() => runApp(MainApp());

<!-- 频道模块入口 -->
@pragma('vm:entry-point')
void channel() => runApp(ChannelApp());

<!-- 我的模块入口 -->
@pragma('vm:entry-point')
void mine() => runApp(MineApp());

  1. 我们定义了三个函数main,channelmine, 他们分别加载了MainApp(),ChannelApp()
    MineApp(),也可以直接理解为三个模块,他们是相互独立的。
  2. @pragma('vm:entry-point')这个注解是为了避免Dart的摇树优化(tree-shaking)将这里定义的函数认定为无用代码给优化掉了。main函数可以不加这个注解,统一加上也无妨。
  1. main_page.dart

其实上述3个App()的入口代码是类似的,我们只以首页模块的入口main_page.dart为例做说明。

class MainApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    
    return MaterialApp(
      title: "FBMovie首页模块",
      theme: FBTheme.normalTheme,
      routes: FBRouter.routes,
      initialRoute: FBRouter.homePageInitialRoute,
      onGenerateRoute: FBRouter.generateRoute,
      debugShowCheckedModeBanner: false,
    );
  }
}

class FBMainPage extends StatefulWidget {
  // 路由的路径
  static final String routeName = "/main";

  @override
  _FBMainPageState createState() => _FBMainPageState();
}

class _FBMainPageState extends State<FBMainPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: buildAppBar(),
      body: FBHomePage(),
    );
  }

  /// AppBar
  AppBar buildAppBar() {
    return AppBar(
      backgroundColor: Colors.white,
      brightness: Brightness.light,
      leadingWidth: 154.rpx,
      shadowColor: Colors.transparent,
      leading: null,
      actions: buildActions(),
      title: null,
    );
  }

  /// AppBar的actions
  List<Widget> buildActions() {
    return [
      GestureDetector(
        child: Padding(
          padding: EdgeInsets.symmetric(horizontal: 36.rpx),
          child: Icon(
            Icons.search,
            color: FBTheme.redColor,
            size: 46.rpx,
          ),
        ),
        onTap: searchTapped,
      )
    ];
  }

  /// 搜索按钮的点击跳转
  void searchTapped() {
    Navigator.of(context).pushNamed(FBSearchPage.routerName);
  }
  
}

这个逻辑也很简单,和普通的Flutter project 的代码没有任何差别。MaterialApp -> Scaffold -> appBar + body(FBHomePage) -> 轮播图+列表 -> ....

iOS项目引入Flutter模块

  • 修改podfile文件
// 1. 找到flutter module 的目录
flutter_application_path = '../../flutter_movie_player'
// 2. 找到flutter module 的目录中的/.ios/Flutter/podhelper.rb文件
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

target 'FBMoviePlayer' do
  use_frameworks!
  // 3. 执行podhelper.rb中的install_all_flutter_pods方法
  install_all_flutter_pods(flutter_application_path)

end

加的每行代码的逻辑意义在注释中有说明。注意一点是flutter_application_path这个路径别整错了,否则就没法继续了。

  • 执行pod install

执行这个命令能将Flutter SDKFlutter 代码 引入到iOS项目中。

  • Appdelegate中定义一个FlutterEngineGroup对象
var engineGroup = FlutterEngineGroup(name: "fb-movie-player", project: nil)

如果项目中有多个Flutter模块就需要使用FlutterEngineGroup, 它能管理多个FlutterEngine, 让他们共享资源等功能。

继续接下来的工作之前,我先介绍下实现思路:

我们这里的设计思路是将三个加载不同Flutter APPFlutterViewControllerView放在MainViewController, ChannelViewControllerMineViewControllerView上。

这里有的小伙伴可能会有疑问:为什么不将MainViewController, ChannelViewControllerMineViewController直接定义为FlutterViewController的子类。

这里我解释下:UITabbarController的子ViewController几乎是同时初始化的,如果他们都是FlutterViewController那么会造成对FlutterEngineGroup共享资源的争夺,这样显示会出现异常。这也是目前使用多个Flutter module会出现的一个问题。所以需要改变下思路,在需要使用的时候再进行FlutterViewController的初始化。其实这也有点问题,就是进行预加载比较难控制,每个Flutter module第一次加载的时候会有点慢。

介绍完实现方法后,继续敲代码啦。

  • 定义一个FlutterViewController子类
class FBFlutterViewController: FlutterViewController {
    
    init(withEntrypoint entryPoint: String?) {
        let appDelegate: AppDelegate = UIApplication.shared.delegate as! AppDelegate
        // 1. 用Appdelegate中的FlutterEngineGroup生成一个FlutterEngine,引擎加载入口是main.dart的entrypoint函数
        let newEngine = appDelegate.engineGroup.makeEngine(withEntrypoint: entryPoint, libraryURI: nil)
        // 2. 用这个FlutterEngine初始化FlutterViewController
        super.init(engine: newEngine, nibName: nil, bundle: nil)
    }
    
    required convenience init(coder aDecoder: NSCoder) {
        self.init(withEntrypoint: nil)
    }
    
}

自定义了一个FlutterViewController子类,这个子类会根据传过来的entryPoint初始化一个FlutterEngine, 这个FlutterEngine的加载入口是main.dart文件中的entrypoint函数,然后FlutterViewController子类持有这个FlutterEngine;

  • UIViewController加载FBFlutterViewController
class MainViewController: UIViewController {
    // 1. 懒加载 main.dart 中的main入口函数对应的Flutter App
    private lazy var subFlutterVC: FBFlutterViewController = FBFlutterViewController(withEntrypoint: nil)
    
    override func viewDidLoad() {
        // 2. 添加FlutterViewController
        addChild(subFlutterVC)
        let safeFrame = self.view.safeAreaLayoutGuide.layoutFrame
        subFlutterVC.view.frame = safeFrame
        self.view.addSubview(subFlutterVC.view)
        subFlutterVC.didMove(toParent: self)
    }
    
}
  1. MainViewController加载main.dart 中的main入口函数对应的Flutter App, 对应的void main() => runApp(MainApp());的内容;
  2. 懒加载也是为了解决资源竞争的问题。
class ChannelViewController: UIViewController {
    // 懒加载 main.dart 中的channel入口函数对应的Flutter App
    private lazy var subFlutterVC: FBFlutterViewController = FBFlutterViewController(withEntrypoint: "channel")
    
    override func viewDidLoad() {
        addChild(subFlutterVC)
        let safeFrame = self.view.safeAreaLayoutGuide.layoutFrame
        subFlutterVC.view.frame = safeFrame
        self.view.addSubview(subFlutterVC.view)
        subFlutterVC.didMove(toParent: self)
    }
    
}

对应的void channel() => runApp(ChannelApp());的内容

class MineViewController: UIViewController {
    // 懒加载 main.dart 中的mine入口函数对应的Flutter App
    private lazy var subFlutterVC: FBFlutterViewController = FBFlutterViewController(withEntrypoint: "mine")
    
    override func viewDidLoad() {
        addChild(subFlutterVC)
        let safeFrame = self.view.safeAreaLayoutGuide.layoutFrame
        subFlutterVC.view.frame = safeFrame
        self.view.addSubview(subFlutterVC.view)
        subFlutterVC.didMove(toParent: self)
    }
}

对应的void mine() => runApp(MineApp()); 的内容

目前位置,三个Flutter module已经被集成到了我们的iOS项目中了,每个模块基本上能正常显示。但是这个项目还有两个问题需要我们来解决。

注册插件

我前面提到过,首页模块频道模块 中的播放历史和点赞记录是需要在 我的模块 中展示的。但是现在他们是独立的,这就涉及到模块数据同步的问题。

这个同步的逻辑有一些通用的方式:

  1. 通过服务器网络请求的方式;
  2. App进行内存存储;
  3. APP进行文件存储;
  4. App进行数据库存储;

我们这里用的是数据库的存储方式,但是Flutter的数据库存储是通过插件来实现的,我们上面是没有实现插件的注册,所以需要进行这方面的工作。

dependencies:
  flutter:
    sdk: flutter
  ...
  fijkplayer: ^0.8.7
  shared_preferences: 0.5.12+4
  sqflite: ^1.3.0
  url_launcher: ^5.7.10

其实我们的Flutter项目中用到了这些插件,都需要统一注册Flutter Engine中。

  • 问题之一
问题

未注册插件,看不到观看历史和点赞

  • 解决方案:
// 1. 引入库
import FlutterPluginRegistrant

class FBFlutterViewController: FlutterViewController {
    
    /// ...

    override func viewDidLoad() {
        super.viewDidLoad()
        // 2. 注册插件到FlutterEngine中
        GeneratedPluginRegistrant.register(with: self.pluginRegistry())
    }
    
}
  • 实现效果
效果

注册插件,能看到观看历史和点赞

编写插件

当集成到项目中后肯定会遇到各种问题,这时候编写插件就是很常见的需求了。我们来看一下下面这个图:

二级界面

我们看到从首页进入到二级页面,底下的TabBar没有隐藏,这是不符合一般的设计逻辑的。但是最开始Flutter开发者可能并不了解这个问题,这时候就需要进行改代码了。

我们需要实现的逻辑就是当二级甚至更深层级的界面的时候需要隐藏TabBar,只有一级界面显示TabBar

Flutter端修改

  • 封装一个TabBar功能相关的插件类TabBarController
class TabBarController {
  // 定义一个MethodChannel
  static final channel = const MethodChannel("fbmovie.com/tab_switch");

  /// 显示tabbar
  static Future<int> showTab() async {
    final result = await channel.invokeMethod("showTab");
    return result ?? 0;
  }

  /// 隐藏tabbar
  static Future<int> hideTab() async {
    final result = await channel.invokeMethod("hideTab");
    return result ?? 0;
  }

}

定义一个MethodChannel,然后定义了一个showTab和一个hideTab方法去调用原生代码。

  • 初始化一个路由监听器
// 路由监听器
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
  • 监听路由的变化
class MainApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {

    return MaterialApp(
      // ...省略
      // 1. MaterialApp 加上路由监听器
      navigatorObservers: [routeObserver],
    );
    
  }
}

class FBMainPage extends StatefulWidget {
  
  @override
  _FBMainPageState createState() => _FBMainPageState();
}

// 2. 混入 RouteAware
class _FBMainPageState extends State<FBMainPage> with RouteAware {
  // ...省略
  
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // 3. 订阅路由监听器
    routeObserver.subscribe(this, ModalRoute.of(context));
  }

  @override
  void dispose() {
    // 4. 取消订阅路由监听器
    routeObserver.unsubscribe(this);
    super.dispose();
  }

  void didPopNext() {
    // 5. 返回到当前页面
    TabBarController.showTab();
  }

  void didPushNext() {
    // 6. 跳转到下一个页面
    TabBarController.hideTab();
  }
  
}

当订阅路由监听器后,FBMainPage跳转到其他页面时会调用didPushNext,此时通知Native代码隐藏TabBar,当其他页面跳转回FBMainPage时,此时通知Native代码显示TabBar

iOS端修改

class FBFlutterViewController: FlutterViewController {

    private var channel: FlutterMethodChannel?
    
    override func viewDidLoad() {
        // ...省略
        // 1. 生成FlutterMethodChannel
        channel = FlutterMethodChannel(
            name: "fbmovie.com/tab_switch", binaryMessenger: self.engine!.binaryMessenger)
        // 2. 注册回调方法    
        channel!.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
          if call.method == "showTab" {
            // 3. 显示TabBar
            self?.showTab()
          } else if call.method == "hideTab" {
          // 3. 隐藏TabBar
            self?.hideTab()
          } else {
            result(FlutterMethodNotImplemented)
          }
        }
    }
    
    /// 显示TabBar 
    func showTab() {
        self.parent?.tabBarController?.tabBar.isHidden = false
    }
    /// 隐藏TabBar
    func hideTab() {
        self.parent?.tabBarController?.tabBar.isHidden = true
    }
    
}

iOS 端主要就是在FlutterViewController中初始化FlutterMethodChannel,监听Flutter端的调用,然后去控制UITabbarController

效果如下:

效果图

总结

Flutter多模块集成还有一些待完善的地方,但是整体上来说Flutter混入原生还是很不错的一个方式。由于自己的渲染闭环效率,做出来的效果还是不错的。

本文介绍了Flutter混合iOS项目的实现方式,下节我们将继续来介绍Flutter混入Android项目。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,898评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,401评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,058评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,539评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,382评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,319评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,706评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,370评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,664评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,715评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,476评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,326评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,730评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,003评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,275评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,683评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,877评论 2 335

推荐阅读更多精彩内容