最近公司产品想要实践下和
flutter
的混编
,也就是基于老的原生APP项目,引入flutter进行混编,这样新的功能就可以使用flutter进行开发,可以节省成本。我负责了该项目,对不同的混编方案进行了了解,最后将自己采取的方案在这里介绍一下[注:此方案我们已进行实际开发并发布],如果大家的项目有混编需求,希望对大家有一定的借鉴意义。
一、混编方案
1.1 三端统一方案
这种方案的项目结构为:
-- iOS项目
-- 安卓项目
-- flutter项目
缺点:
- 三端放在同一个目录,对现有的原生开发项目影响较大,
- 所有人都需要装上flutter环境且版本要一致,不利于团队开发,
优点:
- 但在自己开发时候这种可以及时进行
flutter attach
进行联调,这时候显得非常有必要,所以这种模式适合在开发阶段使用。
1.2 三端分离方案
使用三端分离的模式 三端分离,iOS和安卓原生项目保持不变,创建一个flutter项目用于编写flutter端的代码,然后使用脚本将flutter编译,iOS通过
pod
引用flutter编译后的framework,然后将生成物放在私有库中供原生调用,安卓端将flutter项目打成aar
进行引用。
缺点:
- 在开发阶段不利于联调,修改或新写一些代码后,需要打包等一系列操作后才能看到效果,效率低。
优点:
- 这种模式适用于在老项目基础上进行混编引入flutter项目,对老项目侵入性小,
- 适合团队开发
1.3 采用的方案
综合两种方案的优缺点,最终我们决定采用两种方案结合的方案,即在开发阶段采取三端统一的方案,这样开发中方便进行联调,在发布阶段采用三端分离的方案,利于维护和团队开发。
具体的切换也不麻烦,已iOS为例:
1、创建的flutter端和原生项目放在同一个文件夹;
2、切换不同的方案只需要在podfile中间中切换即可,开发阶段引用本地的flutter端,发布阶段引用私有库的flutter打包生成物。
具体代码可参考2.2
中代码。
二、混编实现
这里以iOS端为例详细介绍下混编的具体细节。
2.1 flutter端打包
Flutter项目打包我使用的是脚本,将flutter项目达成framework
,然后将这些framework放到公司的私有库
中,iOS端就可以通过pod进行引用。
2.1.1 打包脚本
通过图可以看到 build_ios_output.sh
即为打包的脚本,打出来的framework放在 build_for_ios
文件夹中。
打包脚本内容:
#前提flutter一定要是app项目: pubspec.yaml里 不要加
#module:
# androidPackage: com.example.myflutter
# iosBundleIdentifier: com.example.myFlutter
echo "Clean old build"
find . -d -name "build" | xargs rm -rf
flutter clean
echo "开始获取 packages 插件资源"
flutter packages get
echo "开始构建 build for ios 默认为release,debug需要到脚本改为debug"
#flutter build ios --debug
# release下放开下一行注释,注释掉上一行代码
flutter build ios --release --no-codesign
echo "构建 release 已完成"
echo "开始 处理framework和资源文件"
rm -rf build_for_ios
mkdir build_for_ios
cp -r build/ios/Release-iphoneos/*/*.framework build_for_ios
cp -r build/ios/Release-iphoneos/App.framework build_for_ios
#cp -r build/ios/Release-iphoneos/Flutter.framework build_for_ios
cp -r .ios/Flutter/engine/Flutter.xcframework build_for_ios
cp -r .ios/Flutter/FlutterPluginRegistrant/Classes/GeneratedPluginRegistrant.* build_for_ios
在打包是,需在终端进入到flutter项目中,然后运行
sh build_ios_output.sh
2.1.2 打包产物
由上图可以看出打出来的为framework,其中
App.framework
为Dart
打包的,其它是使用的插件的framework,运行脚本后将 build_for_ios
文件夹中打包物上传到私有库中即可。
2.2 原生端引用flutter
以iOS为例,原生端调用flutter是在Podfile
文件中进行调用。
调用如下所示,可以在开发阶段和发布阶段切换不同的方案:
# 联调时候使用该模式 (脚本路径为 .ios->Flutter->podhelper.rb)
# [注:]flutter_debug标志是否是debug 若为release需手动修改为false
flutter_application_path = '../xxxFlutter/xxx_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
$flutter_debug = false
target xxx do
# Flutter端混编(debug联调引用本地,release引用pod私有库中framework)
if $flutter_debug
install_all_flutter_pods(flutter_application_path)
else
# 这里在自己联调时可以直接引用打出来的包,测试时命名为 版本号-dev,上线命名规则为 版本号-release
# pod 'XXXFlutterSDK', :path => '../XXXFlutterSDK'
pod 'XXXFlutterSDK', '0.0.1-dev'
end
end
到这里整体的混编框架已经很清晰了,安卓端也是类似的,写个脚本将flutter端生成物放到私有库,然后通过aar调用即可。
三、两端调用
3.1 混合栈
两端混编首先要解决的问题就是混合栈问题,两端调用如原生->flutter->flutter->原生等,这中间涉及到原生的导航栈跳转到flutter的导航栈的处理,以及两个导航栈之间页面的跳转和入栈出栈等操作。这里面还有一个问题就是
FlutterEngine
的问题,如果你只是简单的调用flutterviewcontroller
进行页面的调用,这样多次调用会创建多个引擎,而FlutterEngine
是很消耗内存的,所以混合栈的问题必须要考虑。
混合栈主流的有:
1.Google官方FlutterEngineGroup
(多引擎方案)
即每次使用一个新的FlutterEngine来渲染Widget树。虽然Flutter 2.0之后的创建FlutterEngine的开销大大降低,但是依然没有解决每个FlutterEngine是一个单独isolate,如果需要Flutter①和Flutter②之间交互数据的话,将会非常麻烦,我们同样无法保证他们之间不会进行数据交互
2.大名鼎鼎的闲鱼flutter_boost
(单引擎方案)
优点:
- 应用的项目多,经过了验证,可实现较好的效果
- 最近发布了3.0的bate版本,摒弃了2.0版本对引擎的侵入。
缺点:
- 对项目侵入性较大
3.哈喽单车团队的flutter_thrio
(单引擎方案)
该库的优劣作者已经说得很详细了,这里就不再赘述,感兴趣的朋友可以进传送门亲自查看,flutter_thrio的优缺点。
4.字节跳动团队的Isolate复用方案和腾讯心悦团队的TRouter方案
很可惜,目前这两个方案并没有开源出来,但很可能字节团队的方案的侵入性相当高。
经过一系列对比后,最终选择了较为成熟和稳定的flutter_boost
。
3.2 Flutter端实现
在pubspec.yaml中引入 flutter_boost
# flutter_boost
flutter_boost:
git:
url: 'https://github.com/alibaba/flutter_boost.git'
ref: '3.1.0'
3.2.1 路由
混编主要是原生和flutter端的相互调用,路由的代码如下:
在main.dart
中:
Route<dynamic>? routeFactory(RouteSettings settings, String? uniqueId) {
// settings.name 首次为 /, 实际是代表首页的意思
// BoostRoute.routerMap为Boost的路由表
FlutterBoostRouteFactory? func = BoostRoute.routerMap[settings.name!];
if (func == null) {
return null;
}
return func(settings, uniqueId);
}
/// 然后build
@override
Widget build(BuildContext context) {
return FlutterBoostApp(
routeFactory,
appBuilder: appBuilder,
// initialRoute: RoutePath.storeSignExpress,
);
}
其中BoostRoute
是项目的路由表,这里给独立为一个类,便于维护,具体代码如下:
import 'package:flutter/material.dart';
import 'package:flutter_boost/flutter_boost.dart';
import 'package:get/get.dart';
import 'package:get/get_core/src/get_main.dart';
import 'package:get/get_navigation/src/extension_navigation.dart';
import 'package:self_driving_flutter/app/config/route/route_path.dart';
import 'package:self_driving_flutter/module/ehi_base_page/view.dart';
import 'package:self_driving_flutter/module/inspect_car_record/view.dart';
import 'package:self_driving_flutter/utils/tools_util.dart';
import '../../../module/feedback_content/view.dart';
/// Boost路由表
class BoostRoute {
/// 注册的路由表
static Map<String, FlutterBoostRouteFactory> routerMap = {
'/': (settings, uniqueId) {
// 联调时可设置为自己开发的页面(可直接运行AS开发)
return _buildPage(settings, YTBasePage());
}
RoutePath.feedbackContent: (settings, uniqueId) {
return _buildPage(settings, FeedbackContentPage());
},
RoutePath.inspectCarRecord: (settings, uniqueId) {
Map<dynamic, dynamic> arguments = settings.arguments as Map<dynamic, dynamic>;
return _buildPage(settings, InspectCarRecordPage(
orderId: arguments['orderId'],
userId: arguments['userId'])
);
},
};
static MaterialPageRoute _buildPage(settings, Widget page) {
return MaterialPageRoute(
settings: settings,
builder: (_) {
return page;
});
}
}
3.3 原生端实现
3.3.1 导航跳转类
这个类主要是控制原生和flutter页面的push
和pop
。
具体实现如下:
class YTFlutterBoostDelegate: NSObject, FlutterBoostDelegate {
///您用来push的导航栏
@objc var navigationController:UINavigationController?{
didSet{
navigationController?.delegate = self
}
}
///用来存返回flutter侧返回结果的表
var resultTable:Dictionary<String,([AnyHashable:Any]?)->Void> = [:];
// MARK: 如果框架发现您输入的路由表在flutter里面注册的路由表中找不到,那么就会调用此方法来push一个纯原生页面
func pushNativeRoute(_ pageName: String!, arguments: [AnyHashable : Any]!) {
//可以用参数来控制是push还是pop
let isPresent = arguments["isPresent"] as? Bool ?? false
let isAnimated = arguments["isAnimated"] as? Bool ?? true
//这里根据pageName来判断生成哪个vc
let targetViewController = dealViewController(with: pageName, arguments: arguments)
// 展示导航,到原生页面使用原生的导航
self.navigationController?.setNavigationBarHidden(false, animated: false)
if(isPresent) {
self.navigationController?.present(targetViewController, animated: isAnimated, completion: nil)
}else{
self.navigationController?.pushViewController(targetViewController, animated: isAnimated)
}
}
// MARK: 当框架的withContainer为true的时候,会调用此方法来做原生的push
func pushFlutterRoute(_ options: FlutterBoostRouteOptions!) {
let vc:FBFlutterViewContainer = FBFlutterViewContainer()
vc.setName(options.pageName, uniqueId: options.uniqueId, params: options.arguments,opaque: options.opaque)
//用参数来控制是push还是pop
let isPresent = (options.arguments?["isPresent"] as? Bool) ?? false
let isAnimated = (options.arguments?["isAnimated"] as? Bool) ?? true
//对这个页面设置结果
resultTable[options.pageName] = options.onPageFinished
// 隐藏导航,到Flutter页面使用Flutter的导航并禁止右滑手势
self.navigationController?.setNavigationBarHidden(true, animated: false)
//如果是present模式 ,或者要不透明模式,那么就需要以present模式打开页面
if(isPresent || !options.opaque){
self.navigationController?.present(vc, animated: isAnimated, completion: nil)
}else{
self.navigationController?.pushViewController(vc, animated: isAnimated)
}
}
// MARK: 当pop调用涉及到原生容器的时候,此方法将会被调用
func popRoute(_ options: FlutterBoostRouteOptions!) {
//如果当前被present的vc是container,那么就执行dismiss逻辑
if let vc = self.navigationController?.presentedViewController as? FBFlutterViewContainer,vc.uniqueIDString() == options.uniqueId{
//这里分为两种情况,由于UIModalPresentationOverFullScreen下,生命周期显示会有问题
//所以需要手动调用的场景,从而使下面底部的vc调用viewAppear相关逻辑
if vc.modalPresentationStyle == .overFullScreen {
//这里手动beginAppearanceTransition触发页面生命周期
self.navigationController?.topViewController?.beginAppearanceTransition(true, animated: false)
vc.dismiss(animated: true) {
self.navigationController?.topViewController?.endAppearanceTransition()
}
}else{
//正常场景,直接dismiss
vc.dismiss(animated: true, completion: nil)
}
}else{
self.navigationController?.popViewController(animated: true)
}
// 展示导航,到原生页面使用原生的导航
self.navigationController?.setNavigationBarHidden(false, animated: false)
//否则直接执行pop逻辑
//这里在pop的时候将参数带出,并且从结果表中移除
if let onPageFinshed = resultTable[options.pageName] {
onPageFinshed(options.arguments)
resultTable.removeValue(forKey: options.pageName)
}
}
}
private extension YTFlutterBoostDelegate {
/// 根据pageName来判断生成哪个vc
func dealViewController(with name: String, arguments: [AnyHashable : Any]) -> UIViewController {
switch name {
case storeDetailPage: // 门店详情
let vc = YTNewStoreDetailViewController()
if let storeID = arguments["storeID"] as? Int {
vc.storeID = storeID
}
YTNavigator.push(vc)
return vc
default:
return UIViewController()
}
}
}
extension YTFlutterBoostDelegate : UINavigationControllerDelegate{
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
// 右滑返回
viewController.transitionCoordinator?.notifyWhenInteractionChanges({ (context) in
if context.isCancelled {
return;
}
self.navigationController?.setNavigationBarHidden(false, animated: false)
})
}
}
3.3.2 配置FlutterBoost
然后需要在AppDelegate中配置FlutterBoost
,在配置中也可以添加两端的交互,用户两端事件的交互,如传值等。
声明属性:
@property (nonatomic, strong) YTFlutterBoostDelegate *boostDelegate;
具体代码如下:
#pragma mark - 配置FlutterBoost及交互
- (void)configFlutterBoost:(UIApplication *)application {
self.boostDelegate = [[YTFlutterBoostDelegate alloc] init];
__block FlutterEngine *callEngine;
// 注册FlutterBoost
[[FlutterBoost instance] setup:application delegate: self.boostDelegate callback:^(FlutterEngine *engine) {
callEngine = engine;
}];
// 处理Flutter调用原生事件
self.methodChannel = [FlutterMethodChannel methodChannelWithName:@"xxx" binaryMessenger:callEngine];
[self.methodChannel setMethodCallHandler:^(FlutterMethodCall * _Nonnull call, FlutterResult _Nonnull result) {
[YTMethodChannelManager methodChannelWith:call result:result];
}];
}
3.3.3 原生调用flutter
我这里将调用方法单独成一个类,便于维护和扩展,用于在原生代码中打开Flutter
页面,具体代码如下:
class YTFlutterUtils: NSObject {
// MARK: 打开Flutter页面
// pageRoute: 路由名称
// arguments: 参数
// opaque: 这个页面是否透明(默认为true)
// completion: open方法完成后的回调,仅在原生->flutter页面的时候有用
// onPageFinished: 参数回传的回调闭包,仅在原生->flutter页面的时候有用
@objc class func openFlutterPage(with pageName: String = "",
arguments: Dictionary<String, Any>? = [:],
opaque: Bool = true,
completion: ((Bool) -> ())? = nil,
onPageFinished: (((Dictionary<AnyHashable, Any>)?) -> ())? = nil
) {
let options = FlutterBoostRouteOptions()
options.pageName = pageName
options.arguments = arguments ?? ["animated": true];
options.opaque = opaque
options.completion = completion
options.onPageFinished = onPageFinished
FlutterBoost.instance().open(options)
}
}
到这里已经完整的实现了原生段和flutter的混编,包括混编方案的选择及具体实现,希望可以对大家起到一些借鉴作用。