Flutter、iOS混合開發實踐

降維程式設計發表於2020-03-12

一、前言

上一篇筆記介紹了Flutter、Android混編的操作步驟,這篇筆記介紹一下iOSFlutter混編的應用。

閱讀Flutter官方文件我們可以大致瞭解iOSFlutter混編的關鍵步驟,都需要將Flutter相關的檔案編譯成靜態庫framework,再通過CocoaPods進行管理。下面記錄本人根據官方文件及網上優秀作者分享的便捷指令碼使用過程。

二、新建Flutter模組並編譯成framework

閱讀過上一篇《Flutter、Android混合開發實踐》的同學知道,在AndroidFlutter未編譯成aar前就可以進行引用除錯。不同於安卓,iOS只能以framework的形態接入Flutter模組。大致步驟如下:

  • 步驟一:新建iOS工程,使用CocoaPods管理工程
  • 步驟二:新建Flutter Module
  • 步驟三:將Flutter Module編譯成framework,引入iOS工程
  • 步驟四:編寫測試程式碼,檢視結果
老規矩在開始新建工程前先新建一個總檔案(這裡命名為iOS_Flutter_MixBuilder),請確保在安裝了CocoaPods的前提下進行下面操作。


步驟一:新建iOS工程,使用CocoaPods管理工程

iOS_Flutter_MixBuilder目錄下新建Xcode project,開啟Xcode選擇Create a new Xcode project -> 選擇Single View App -> 將工程命名為ios_app -> 選擇剛剛新建的資料夾(iOS_Flutter_MixBuilder),create

Flutter、iOS混合開發實踐

Flutter、iOS混合開發實踐

Flutter、iOS混合開發實踐

Flutter、iOS混合開發實踐

啟動終端在iOS_Flutter_MixBuilder/ios_app目錄下依次執行一下命令:

pod init
pod install
複製程式碼


步驟二:新建Flutter Module

開啟終端切換到iOS_Flutter_MixBuilder目錄下,執行下面命令:

flutter create -t module my_flutter複製程式碼

my_fluttermodule的名字,執行完命令等待即可。

Flutter、iOS混合開發實踐

此時我們觀察一下Flutter Module檔案目錄,首先顯示隱藏檔案(macOS Sierra及以上(Mojave),我們可以使用快捷鍵 ⌘⇧.(Command + Shift + .) 來快速(在 Finder 中)顯示和隱藏隱藏檔案

Flutter、iOS混合開發實踐

隱藏資料夾.ios內檔案不需要更改,每次flutter cleanflutter packages get操作後會重新生成。給Flutter Module新增任意外掛引用後執行

flutter pub get後會生成一個Podfile檔案。

開啟pubspec.yaml檔案,該檔案管理Flutter外掛的依賴。新增資料持久化外掛依賴,儲存檔案。終端切換到my_flutter目錄下,執行下面命令:

cd /Users/admin/Desktop/WJJ_WJJ/iOS_Flutter_MixBuilder/my_flutter複製程式碼

flutter pub get複製程式碼

Flutter、iOS混合開發實踐

Flutter、iOS混合開發實踐

Flutter、iOS混合開發實踐

注意:為了防止沒有科學上網引起flutter pub get操作經常卡死,我們可以更換中國映象https://flutter.io/community/china,更換操作:

vi ~/.bash_profile 複製程式碼

然後新增

export PUB_HOSTED_URL=https://pub.flutter-io.cn複製程式碼

export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn複製程式碼

然後儲存,重啟終端。

有的同學會發現重啟終端執行任何Flutter命令都提示Waiting for another flutter command to release the startup lock...,在你的Flutter包中刪除flutter/bin/cache/lockfile檔案即可


步驟三:將Flutter Module編譯成framework,引入iOS工程

這裡有兩種方式:

  • 方式一:在iOS工程的podfile檔案中新增如下程式碼,my_flutter為Flutter Module名

flutter_application_path = '../my_flutter/'

load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

install_all_flutter_pods(flutter_application_path)
複製程式碼

Flutter、iOS混合開發實踐

然後切到iOS工程目錄ios_app,執行:

pod install複製程式碼

開啟iOS工程可以看到,Flutter相關的檔案已被引入iOS工程中。

Flutter、iOS混合開發實踐

該方法好處是一步到位且是官方推薦的第一種方式,但需要每個引用framework的開發者電腦都有Flutter環境,且需要引用的framework分佈在不同的資料夾裡,檢視繁瑣,很顯然這不友好。

  • 方式二:將Flutter Module編譯產物通過CocoaPods引入工程

先對Flutter Module進行編譯,選擇debug模式或者release模式。注意:應新增--no-codesign防止證書引起的編譯不通過。

終端切到my_flutter資料夾下執行下面命令(根據需要二選一):

flutter build ios --debug --no-codesign //編譯debug產物(選擇不需要證書)複製程式碼
flutter build ios --release --no-codesign //編譯release產物(選擇不需要證書)複製程式碼

命令執行成功後觀察資料夾變化,build/ios/Debug-iphoneos資料夾存放著編譯產物FlutterPluginRegistrant.frameworkshared_preferences.framework

Flutter、iOS混合開發實踐

上面的兩個frameworkFlutter程式碼編譯產物,除此之外我們還需要包含Flutter資源的庫App.framework它在.ios/Flutter/目錄下,以及Flutterengine執行庫Flutter.framework它在.ios/Flutter/engine/目錄下。

因為CocoaPods無法直接管理framework檔案,所以我們需要把上面找到的四個檔案簡單的封成一個Pod庫。

終端切到iOS_Flutter_MixBuilder資料夾下執行下面命令,回答一波靈魂拷問後,建立名為flutter_libPod庫:

pod lib create flutter_lib複製程式碼

Flutter、iOS混合開發實踐

新建Pod庫成功後會自動彈出例子工程,關掉它,觀察檔案目錄。

Flutter、iOS混合開發實踐

簡單介紹一下Pod庫,flutter_lib.podspec檔案是Pod庫的配置檔案,在該檔案裡可以指定Pod庫的版本、以及庫內容檔案的地址等資訊。

Flutter、iOS混合開發實踐

現在我們只需要把先前得到的四個Flutter庫檔案放到/flutter_lib/目錄下的一個新資料夾ios_frameworks中,再在flutter_lib.podspec檔案中指定ios_frameworks的路徑就大功告成了。手動效率太低,這裡借鑑了作者:做人要簡單的指令碼進行自動化處理。

Flutter、iOS混合開發實踐

將指令碼build_file.sh放入my_flutter資料夾

Flutter、iOS混合開發實踐

終端切到my_flutter資料夾下執行下面命令使指令碼工作:

sh build_file.sh複製程式碼

Flutter、iOS混合開發實踐

指令碼執行成功後檢視檔案目錄,我們所需要的framework已經出現在指定位置。

Flutter、iOS混合開發實踐

flutter_lib/ios_frameworks資料夾下的四個frameworkflutter_lib.podspec檔案中需要被申明指定,flutter_lib.podspec中程式碼如下:

Flutter、iOS混合開發實踐

至此,本次所要製作的Flutter framework Pod庫已經完成,下面只需要在iOS工程中將這個Pod庫以本地的形式引入即可。開啟iOS_Flutter_MixBuilder/ios_app/Podfile檔案新增下面程式碼:

pod 'flutter_lib', :path => '../flutter_lib'複製程式碼

Flutter、iOS混合開發實踐

切到iOS_Flutter_MixBuilder/ios_app目錄下執行Pod指令:pod install

Flutter、iOS混合開發實踐

指令成功後開啟iOS工程檢視,此時我們所製作Pod庫已經成功被引入iOS工程,build一下iOS工程,成功!

Flutter、iOS混合開發實踐


步驟四:編寫測試程式碼,檢視結果

我們沿用安卓篇使用的Flutter程式碼,同時嘗試安卓篇列舉的4個經典場景:

  • iOS頁面開啟Flutter頁面並傳值
  • Flutter頁面開啟iOS頁面並傳值
  • iOS頁面退回Flutter頁面並傳值
  • Flutter頁面退回iOS頁面並傳值

iOS中我們需要新建的頁面有原生頁面FirstNativeViewController、SecondNativeViewController和安卓中不同,我們不需要新建套殼原生頁面FirstFlutterActivity。因為iOS中我們可以例項化Flutter控制器物件:FlutterViewController。而安卓中是例項化了Flutter檢視:FlutterView,FlutterView需要一個殼子控制器FirstFlutterActivity去承載它

在開始上面的場景前,Flutter頁面定義如下內容:

  1. 新增一個textView用來顯示其他頁面傳過來的內容
  2. 新增一個button用來開啟下個原生頁面
  3. 新增一個button用來返回到上個原生頁面

iOS中顯示Flutter頁面有兩種思路:

  1. 例項化一個FlutterViewController物件 -> 為FlutterViewController指定路由 -> push/presentFlutterViewController
  2. 例項化一個FlutterEngine -> 為FlutterEngine指定路由 -> FlutterEngine run -> 使用FlutterEngine建立一個FlutterViewController -> push/presentFlutterViewController  

注意:方法2中,為FlutterEngine指定路由必須在FlutterEngine run之前,否則路由無效。


彩蛋:在目前版本中FlutterEngine攜帶的路由在Flutter中統一變為"/",應該算是Flutter一個bug,目前使用FlutterEngine建立的方式無法指定具體路由。


關鍵名詞介紹:

FlutterViewControllerFlutter頁面控制器,我們可以直接push/present到該控制器,或將其作為ChildViewController嵌入到我們的頁面中。

FlutterEngineFlutter負責在iOS端執行Dart程式碼的引擎,將Flutter編寫的UI程式碼渲染到FlutterViewController中。


接下來我們在FirstNativeViewController中開啟Flutter頁面,並完成其和原生之間的通訊。為了方便我們將FirstNativeViewController定義為全域性屬性

@property (nonatomic, strong) FlutterViewController *flutterViewController;複製程式碼
不使用FlutterEngine開啟Flutter頁面程式碼如下:

//初始化FlutterViewController
self.flutterViewController = [[FlutterViewController alloc] init];
//為FlutterViewController指定路由以及路由攜帶的引數
[self.flutterViewController setInitialRoute:@"route1?{\"message\":\"嗨,本文案來自第一個原生頁面,將在Flutter頁面看到我\"}"];
//設定模態跳轉滿屏顯示
self.flutterViewController.modalPresentationStyle = UIModalPresentationFullScreen;
[self presentViewController:self.flutterViewController animated:YES completion:nil];複製程式碼

使用FlutterEngine開啟Flutter頁面程式碼如下:

//初始化FlutterEngine
FlutterEngine *flutterEngine = [[FlutterEngine alloc]initWithName:@"FirstFlutterViewController"];
//指定路由開啟某一頁面,Flutter1.12版本指定路由後在Flutter程式碼裡獲取的路由統一為“/”,為Flutter bug
[[flutterEngine navigationChannel] invokeMethod:@"setInitialRoute" arguments:@"route1?{\"message\":\"嗨,本文案來自第一個原生頁面,將在Flutter頁面看到我\"}"];
//路由的指定需要在FlutterEngine run方法之前,run方法之後指定路由不管用
[flutterEngine run];
//使用FlutterEngine初始化FlutterViewController
self.flutterViewController = [[FlutterViewController alloc] initWithEngine:flutterEngine nibName:nil bundle:nil];
//設定模態跳轉滿屏顯示
self.flutterViewController.modalPresentationStyle = UIModalPresentationFullScreen;
[self presentViewController:self.flutterViewController animated:YES completion:nil];複製程式碼

FlutterDart程式碼如下:

解析路由獲取本次攜帶的資料

void main() => runApp(_widgetForRoute(window.defaultRouteName));
Widget _widgetForRoute(String url) {
  // route名稱
  String route =  url.indexOf('?') == -1 ? url : url.substring(0, url.indexOf('?'));
// 引數Json字串
  String paramsJson =  url.indexOf('?') == -1 ? '{}' : url.substring(url.indexOf('?') + 1);
  Map<String, dynamic> mapJson = json.decode(paramsJson);  String message = mapJson["message"];
// 解析引數
  switch (route) {
    case 'route1':
      return MaterialApp(
        home: Scaffold(
          appBar: AppBar(
            title: Text('Flutter頁面'),
          ),
          body: Center(child: Text('頁面名字: $route',style: TextStyle(color: Colors.red), textDirection: TextDirection.ltr),),
        ),
      );
    default:
      return Center(
        child: Text('Unknown route: $route',style: TextStyle(color: Colors.red), textDirection: TextDirection.ltr),
      );
  }}複製程式碼

完成以上程式碼就可以在FirstNativeViewController中開啟Flutter頁面,下面介紹iOSFlutter是如何互動的:

思路:我們熟悉的傳統的h5頁面和原生互動時,通過中間通訊工具物件,定義好方法或者屬性進行通訊。同理,FlutteriOS原生互動也有專門的通訊物件(Platform Channel),它有三種型別:

  • MethodChannel:用於最常見的方法傳遞,幫助Flutter和原生平臺互相呼叫方法,也是本次我們著重介紹的。
  • BasicMessageChannel:用於資料資訊的傳遞。
  • EventChannel:用於事件監聽傳遞等場景

在上面的介紹中,我們可以在一個iOS頁面中開啟Flutter頁面,那接下來我們只需要通過MethodChannelFlutter傳送命令,以及接收訊息的回撥。那麼我們就可以在iOSFlutter頁面呈現一些對方傳過來的資料。開整!

iOS部分程式碼如下(下面的程式碼依舊在FirstNativeViewController中編寫):

我們在開始使用MethodChannel時,先對其進行唯一性定義。注意:這裡我們定義兩個MethodChannel,一個用於對Flutter的訊息傳送,一個用於Flutter的回撥訊息接收。

//Flutter向Native發訊息static NSString *CHANNEL_NATIVE = @"com.example.flutter/native";
//Native向Flutter發訊息static NSString *CHANNEL_FLUTTER = @"com.example.flutter/flutter";複製程式碼

使用定義好的名字,初始化MethodChannel注意:MethodChannel初始化方法裡有兩個引數。第一個引數BinaryMessenger messenger,我們可以理解為MethodChannelFlutter頁面的繫結項,通過flutterViewController.binaryMessenger或者flutterEngine.binaryMessenger我們都可以可以得到構造MethodChannel的第一個引數。第二個引數需要傳入我們之前定義好的唯一命名。


iOS接收Flutter發來的訊息

接收Flutter訊息得先初始化一個MethodChannel,且用之前定義好的名字CHANNEL_NATIVE。通過下面程式碼我們可以看到MethodChannel回撥引數有:FlutterMethodCall callFlutterResult resultcall可以給我們提供本次Flutter所傳送的方法名(call.method),還可以提供本次Flutter所傳送的方法攜帶的引數(call.arguments)。result是一個block回撥我們在處理完邏輯後可以呼叫這個block告知Flutter我們的結果。

//初始化messageChannel,CHANNEL_NATIVE為iOS和Flutter兩端統一的通訊訊號
FlutterMethodChannel *messageChannel = [FlutterMethodChannel methodChannelWithName:CHANNEL_NATIVE binaryMessenger:self.flutterViewController.binaryMessenger];
//接受Flutter回撥
[messageChannel setMethodCallHandler:^(FlutterMethodCall * _Nonnull call, FlutterResult  _Nonnull result) {
        __strong __typeof(weakSelf) strongSelf = weakSelf;
        if ([call.method isEqualToString:@"openSecondNative"]) {
            //開啟第二個原生頁面
            NSLog(@"開啟第二個原生頁面");
            strongSelf.sMessageFromFlutter = call.arguments[@"message"];
            [strongSelf pushSecondNative];
            //告訴Flutter我們的處理結果
            if (result) {
                result(@"成功開啟第二個原生頁面");
            }
        }
        else if ([call.method isEqualToString:@"backFirstNative"]){
            //返回第一個原生頁面
            NSLog(@"返回第一個原生頁面");
            [strongSelf backFirstNative];
            strongSelf.lblTitle.text = call.arguments[@"message"];
            //告訴Flutter我們的處理結果
            if (result) {
                result(@"成功返回第一個原生頁面");
            }
        }
    }];
複製程式碼

//開啟第二個原生頁面
- (void)pushSecondNative{
    SecondNativeViewController *secondNativeVC = [[SecondNativeViewController alloc]initWithNibName:@"SecondNativeViewController" bundle:nil];
    secondNativeVC.showMessage = self.sMessageFromFlutter;
    __weak __typeof(self) weakSelf = self;
    //第二個原生頁面的block回撥
    secondNativeVC.ReturnStrBlock = ^(NSString *message){
        __strong __typeof(weakSelf) strongSelf = weakSelf;
        //從第二個原生頁面回來後通知Flutter頁面更新文案
        [strongSelf sendMessageToFlutter:message];
    };
    secondNativeVC.modalPresentationStyle = UIModalPresentationFullScreen;
    //進行本操作時,當前螢幕的s控制器為FlutterViewController,所以應該使用self.flutterViewController進行跳轉
    [self.flutterViewController presentViewController:secondNativeVC animated:YES completion:nil];
}
複製程式碼

- (void)backFirstNative{
    //關閉Flutter頁面
    [self.flutterViewController dismissViewControllerAnimated: YES completion: nil];
}複製程式碼

注意:例子中iOS涉及ViewController之間回撥統一使用block來處理(例如:ReturnStrBlock)。


iOS給Flutter發訊息

Flutter發訊息同樣得先初始化一個MethodChannel,且用之前定義好的名字CHANNEL_FLUTTER。使用MethodChannel的方法invokeMethod就可以將本次的訊息傳送到Flutter中去啦!

- (void)sendMessageToFlutter:(NSString *)message{
    //初始化messageChannel,CHANNEL_FLUTTER為iOS和Flutter兩端統一的通訊訊號
    FlutterMethodChannel *messageChannel = [FlutterMethodChannel methodChannelWithName:CHANNEL_FLUTTER binaryMessenger:self.flutterViewController.binaryMessenger];
    [messageChannel invokeMethod:@"onActivityResult" arguments:@{@"message":message}];}複製程式碼

上面介紹了互動時iOS端的程式碼,下面介紹Flutter端的程式碼。如下:

首先我們在原來的main.dart檔案中做一下擴充套件。定義一個Widget用來顯示iOS傳過來的資料,並建立一個按鈕給iOS發訊息。同iOS端,在main.dart檔案中我們也定義了同名MethodChannel。注意:我們在WidgetinitState()方法裡就應該寫上MethodChannel的監聽程式碼。我們可以在FlutterMethodChannel的回撥方法中通過獲取call.method、call.method.arguments來知道,iOS這次想要呼叫我們什麼方法、以及帶來了什麼引數。

class ContentWidget extends StatefulWidget{
  ContentWidget({Key key, this.route,this.message}) : super(key: key);
  String route,message;
  _ContentWidgetState createState() => new _ContentWidgetState();
}
class _ContentWidgetState extends State<ContentWidget>{
  static const nativeChannel = const MethodChannel('com.example.flutter/native');
  static const flutterChannel = const MethodChannel('com.example.flutter/flutter');
  void onDataChange(val) {
    setState(() {
      widget.message = val;
    });
  }
  @override
  void initState(){
    super.initState();
    Future<dynamic> handler(MethodCall call) async{
      switch (call.method){
        case 'onActivityResult':
          onDataChange(call.arguments['message']);
          print('1234'+call.arguments['message']);
          break;
      }
    }
    flutterChannel.setMethodCallHandler(handler);
  }
  Widget build(BuildContext context) {
    // TODO: implement build
    return Center(
      child: Stack(
        children: <Widget>[
          Positioned(
            top: 100,
            left: 0,
            right: 0,
            height: 100,
            child: Text(widget.message,textAlign: TextAlign.center,),
          ),
          Positioned(
            top: 300,
            left: 100,
            right: 100,
            height: 100,
            child: RaisedButton(
                child: Text('開啟上一個原生頁面'),
                onPressed: (){
                  returnLastNativePage(nativeChannel);
                }
            ),
          ),
          Positioned(
            top: 430,
            left: 100,
            right: 100,
            height: 100,
            child: RaisedButton(
                child: Text('開啟下一個原生頁面'),
                onPressed: (){
                  openNextNativePage(nativeChannel);
                }
            ),
          )
        ],
      ),
    );
  }}複製程式碼

上面的程式碼缺少了方法:returnLastNativePageopenNextNativePage。如下:

大家肯定還記得我們之前在iOS頁面接收Flutter的回撥後,還能呼叫result這個block來告訴Flutter頁面我們的處理結果。沒錯,我們在下面兩個方法中,非同步獲取這些回撥的資訊並列印。

Future<Null> returnLastNativePage(MethodChannel channel) async{
  Map<String, dynamic> para = {'message':'嗨,本文案來自Flutter頁面,回到第一個原生頁面將看到我'};
  final String result = await channel.invokeMethod('backFirstNative',para);
  print('這是在flutter中列印的'+ result);
}複製程式碼

Future<Null> openNextNativePage(MethodChannel channel) async{
  Map<String, dynamic> para = {'message':'嗨,本文案來自Flutter頁面,開啟第二個原生頁面將看到我'};
  final String result = await channel.invokeMethod('openSecondNative',para);
  print('這是在flutter中列印的'+ result);
}複製程式碼

至此,iOSFlutter可以互通有無了。如果你在編譯的時候發現main.dartMethodChannel報錯,那麼你一定是沒有正確的引入標頭檔案比如:import 'package:flutter/services.dart'

注意:在你變更Flutter檔案內容後,記得重新執行上面介紹的指令碼檔案build_file.sh,並切到ios_app資料夾目錄下重新pod install一下更新Pod庫哦。

上面的嘗試都是基於Flutter1.12版本實現,若您的Flutter版本 < 1.12,請先更新Flutter版本。


-----------------------------------完整程式碼地址------------------------------------------------

功能程式碼地址:

https://github.com/JJwow/iOS_Flutter_MixBuilder.git複製程式碼

Pod庫地址:

https://github.com/JJwow/flutter_lib.git複製程式碼



相關文章