Flutter和原生混編-兩種方案結合使混編更輕鬆

語言: CN / TW / HK

最近公司產品想要實踐下和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進行引用。

打包指令碼.webp

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 打包產物

指令碼路徑.webp

由上圖可以看出打出來的為framework,其中App.frameworkDart打包的,其它是使用的外掛的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? 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 routerMap = { '/': (settings, uniqueId) { // 聯調時可設定為自己開發的頁面(可直接執行AS開發) return _buildPage(settings, YTBasePage()); } RoutePath.feedbackContent: (settings, uniqueId) { return _buildPage(settings, FeedbackContentPage()); }, RoutePath.inspectCarRecord: (settings, uniqueId) { Map arguments = settings.arguments as Map; 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頁面的pushpop。 具體實現如下:

``` 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的混編,包括混編方案的選擇及具體實現,希望可以對大家起到一些借鑑作用。