很多情况下用Flutter来编写整个项目是不太现实的。例如公司已经有了成熟的App产品了,去用Flutter去重写整个项目会有很大的工作量和功能上的风险;有时候公司出于谨慎的原因,不可能去冒失的取采用新的技术,可能更愿意去用一些次要的功能部分去试水,如果效果不错才会继续大面积使用。
我们可以将Flutter打包成模块(module
)整合进入原生的iOS和Android项目中实现上述需求。最开始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模拟器,真机上是可以播放的。
新建项目的具体过程就不介绍了。
我们基于CocoaPod和StoryBoard搭建了一个首页是UITabbarController的项目。然后新建了三个UIViewController—MainViewController
, ChannelViewController
和MineViewController
,他们将会分别嵌入对应的Flutter模块。
class MainViewController: UIViewController {}
class ChannelViewController: UIViewController {}
class MineViewController: UIViewController {}
flutter_movie_player
cd /directory
flutter create --template module flutter_movie_player
注意:Flutter项目和iOS项目最好是放在一个目录中,并且层级相同。原因是iOS项目需要引用Flutter项目中的文件和库。
由于本文只是为了介绍混合开发的实现逻辑,所以不会去详细介绍每个Flutter页面是如何实现的,你自己练习时可以不修改任何代码,就用默认的那个Flutter计数器也是可以的。
我们接下来会介绍一些重要的入口相关的类:
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());
- 我们定义了三个函数
main
,channel
和mine
, 他们分别加载了MainApp()
,ChannelApp()
和MineApp()
,也可以直接理解为三个模块,他们是相互独立的。- 加
@pragma('vm:entry-point')
这个注解是为了避免Dart的摇树优化(tree-shaking
)将这里定义的函数认定为无用代码给优化掉了。main函数可以不加这个注解,统一加上也无妨。
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 {
@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 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)
->轮播图+列表
-> …
// 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 SDK 和 Flutter 代码 引入到iOS项目中。
Appdelegate
中定义一个FlutterEngineGroup
对象var engineGroup = FlutterEngineGroup(name: "fb-movie-player", project: nil)
如果项目中有多个Flutter模块就需要使用
FlutterEngineGroup
, 它能管理多个FlutterEngine
, 让他们共享资源等功能。
继续接下来的工作之前,我先介绍下实现思路:
我们这里的设计思路是将三个加载不同Flutter APP的FlutterViewController
的View放在MainViewController
, ChannelViewController
和MineViewController
的View上。
这里有的小伙伴可能会有疑问:为什么不将MainViewController
, ChannelViewController
和MineViewController
直接定义为FlutterViewController
的子类。
这里我解释下:UITabbarController的子ViewController几乎是同时初始化的,如果他们都是FlutterViewController
那么会造成对FlutterEngineGroup
共享资源的争夺,这样显示会出现异常。这也是目前使用多个Flutter module会出现的一个问题。所以需要改变下思路,在需要使用的时候再进行FlutterViewController
的初始化。其实这也有点问题,就是进行预加载比较难控制,每个Flutter module第一次加载的时候会有点慢。
介绍完实现方法后,继续敲代码啦。
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
;
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)
}
}
MainViewController
加载main.dart
中的main
入口函数对应的Flutter App
, 对应的void main() => runApp(MainApp());
的内容;- 懒加载也是为了解决资源竞争的问题。
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项目中了,每个模块基本上能正常显示。但是这个项目还有两个问题需要我们来解决。
我前面提到过,首页模块和频道模块 中的播放历史和点赞记录是需要在 我的模块 中展示的。但是现在他们是独立的,这就涉及到模块数据同步的问题。
这个同步的逻辑有一些通用的方式:
我们这里用的是数据库的存储方式,但是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。
TabBarController
class TabBarController {
// 定义一个MethodChannel
static final channel = const MethodChannel("fbmovie.com/tab_switch");
/// 显示tabbar
static Future showTab() async {
final result = await channel.invokeMethod("showTab");
return result ?? 0;
}
/// 隐藏tabbar
static Future hideTab() async {
final result = await channel.invokeMethod("hideTab");
return result ?? 0;
}
}
定义一个
MethodChannel
,然后定义了一个showTab
和一个hideTab
方法去调用原生代码。
// 路由监听器
final RouteObserver routeObserver = RouteObserver();
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 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。
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项目。