阅读 1973

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,基于iOS项目搭建,包含三个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项目。

文章分类
iOS
文章标签