Flutter和原生混編-兩種方案結合使混編更輕鬆
最近公司產品想要實踐下和
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
/// 然後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
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的混編,包括混編方案的選擇及具體實現,希望可以對大家起到一些借鑑作用。