Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

Realank Liu發表於2018-08-20

執行一個原生的Flutter工程(也就是純Flutter)非常簡便,不過現在Flutter屬於試水階段,要是想在商業app中使用Flutter,目前基本上是將Flutter的頁面嵌入到目前先有的iOS或者安卓工程,目前講混合開發的文章有很多:

Flutter新銳專家之路:混合開發篇

Flutter混合工程改造實踐

Flutter混合工程開發探究

Now直播iOS Flutter混合工程實踐

不過這些文章大多講的是安卓和flutter混合開發的,沒有iOS和Flutter混合開發的比較詳細的步驟實操,上週試了一下iOS和Flutter混合,有一些坑,總結給大家

1.目的

既然用Flutter混合開發,那肯定是希望寫一套程式碼,安卓iOS都能無負擔執行,所以在開發的時候,需要滿足如下需求:

  • Flutter、iOS、安卓工程的目錄在同一級,互相之前平級、無巢狀
  • 開發iOS的時候,不用操心Flutter部分,只用xcode點選執行就可以(即修改編譯iOS專案時,使用編譯好的Flutter產物)
  • 開發Flutter的時候,不用操心iOS部分,只用android studio點選執行就可以
  • 支援模擬器和真機

混合開發最權威的指南當然是flutter自己的wiki,但是缺陷是iOS部分,自動執行指令碼的內容不夠詳細,專案結構也不利於混合開發,本文以其為基礎,又對目錄結構和指令碼做了一些修改,使其便於維護

2.專案搭建

2.1 檔案目錄搭建

HybridFlutter
    |-iOS
    |-Android
    |-Flutter
    |-build
複製程式碼

2.2 iOS專案搭建

建立完了上圖檔案目錄,新增iOS工程(安卓工程暫時忽略)

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

並且在第一頁VC上增加一個Next按鈕,整合好Flutter以後,點選Next可以進入Flutter頁面

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

因為我們要推入flutter頁面,所以需要有navigation controller:

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

目前Flutter混合開發還不支援bit code,所以在iOS工程裡關閉

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

2.3 Flutter Module搭建

這裡有一個坑,按照flutter官方文件,下載的flutter工具對應其beta分支,是不支援生成Flutter module的,而混合開發的wiki裡說,需要建立這麼個module,通過諮詢大牛,需要切換到master分支,而flutter有個channel命令,可以切換工具分支:

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

如果你不在master分支,請執行flutter channel master

之後在Flutter目錄下執行flutter create -t module flutter_module

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

這樣就建立好了flutter module

目前為止的目錄結構

2.4 新增膠水檔案

混合開發最關鍵的是將兩個專案銜接起來,所以需要一些配置

2.4.1 xcconfig檔案

首先是xcode工程配置的銜接,開啟ios工程,在xcode中點選File->New->File新增Configuration Settings File檔案,命名為FlutterConfig.xcconfig,

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文
注意新增的路徑是HybridFlutter/Flutter/flutter_module

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文
此時可能xcode會在ios工程裡新增了一個FlutterConfig.xcconfig檔案的引用,為了專案乾淨,可以刪除這個引用(但是不要刪除檔案)

在FlutterConfig.xcconfig裡新增 #include "./.ios/Flutter/Generated.xcconfig" 引用flutter_module下的ios外掛裡的Generated.xcconfig檔案

上面是給flutter新增xcconfig檔案,下載新增ios工程裡的xccofig檔案Debug.xcconfig,並引用FlutterConfig.xcconfig(如果iOS工程裡已經有了xcconfig檔案,那麼直接在已有的xcconfig裡新增)

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文
新增內容#include "../../../Flutter/flutter_module/FlutterConfig.xcconfig"

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

然後,將Debug.xcconfig新增到iOS專案的Info-Configuration裡:

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

2.4.2 AppFrameworkInfo.plist

這個檔案在最新的flutter工具裡已經自動建立好了 剛才我們看的檔案目錄,不包含隱藏檔案,其實flutter_module裡還有對應的ios和android外掛工程,都是隱藏檔案,從隱藏檔案裡可以看到AppFrameworkInfo.plist

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

2.4.3 引入xcode-backend.sh

在ios工程裡新增執行指令碼"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build,並且確保Run Script這一行在 "Target dependencies" 或者 "Check Pods Manifest.lock"後面

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

此時點選xcode的執行,會執行到xcode-backend.sh指令碼,所以不僅會編譯安裝iOS app到模擬器(暫時執行物件是模擬器),而且在iOS工程目錄,也會生成一個Flutter資料夾,裡面是Flutter工程的產物

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

把這些產物放到iOS工程裡,就能獲取到flutter的資源了。

2.4.4 新增flutter編譯產物

,將iOS工程目錄下的Flutter資料夾新增到工程,然後確保資料夾下的兩個framework新增到Embeded Binaries裡

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文
確保flutter_aseets新增到Build Phases裡的Copy Bundle Resources裡

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

新增完,在工程目錄裡,會多出一個flutter _aseets引用(注意只是引用,如果是拷貝可能會有問題),其實是引用的Flutter/flutter _aseets,試了半天沒有去掉,就先這樣吧

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

目前,所有的膠水檔案都已經新增完了,下一步就是在iOS工程裡,顯示flutter頁面

3. 引用Flutter頁面

3.1 AppDelegate改造

改變AppDelegate.h,使其父類指向FlutterAppDelegate:

#import <Flutter/Flutter.h>

@interface AppDelegate : FlutterAppDelegate <UIApplicationDelegate, FlutterAppLifeCycleProvider>
@end
複製程式碼

改造AppDelegate.m

//
//  AppDelegate.m
//  HybridIOS
//
//  Created by Realank on 2018/8/20.
//  Copyright © 2018年 Realank. All rights reserved.
//

#import "AppDelegate.h"

@interface AppDelegate ()

@end

@implementation AppDelegate

{
    FlutterPluginAppLifeCycleDelegate *_lifeCycleDelegate;
}
- (instancetype)init {
    if (self = [super init]) {
        _lifeCycleDelegate = [[FlutterPluginAppLifeCycleDelegate alloc] init];
    }
    return self;
}

- (BOOL)application:(UIApplication*)application
didFinishLaunchingWithOptions:(NSDictionary*)launchOptions {
    return [_lifeCycleDelegate application:application didFinishLaunchingWithOptions:launchOptions];
}

// Returns the key window's rootViewController, if it's a FlutterViewController.
// Otherwise, returns nil.
- (FlutterViewController*)rootFlutterViewController {
    UIViewController* viewController = [UIApplication sharedApplication].keyWindow.rootViewController;
    if ([viewController isKindOfClass:[FlutterViewController class]]) {
        return (FlutterViewController*)viewController;
    }
    return nil;
}

- (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event {
    [super touchesBegan:touches withEvent:event];
    
    // Pass status bar taps to key window Flutter rootViewController.
    if (self.rootFlutterViewController != nil) {
        [self.rootFlutterViewController handleStatusBarTouches:event];
    }
}

- (void)applicationDidEnterBackground:(UIApplication*)application {
    [_lifeCycleDelegate applicationDidEnterBackground:application];
}

- (void)applicationWillEnterForeground:(UIApplication*)application {
    [_lifeCycleDelegate applicationWillEnterForeground:application];
}

- (void)applicationWillResignActive:(UIApplication*)application {
    [_lifeCycleDelegate applicationWillResignActive:application];
}

- (void)applicationDidBecomeActive:(UIApplication*)application {
    [_lifeCycleDelegate applicationDidBecomeActive:application];
}

- (void)applicationWillTerminate:(UIApplication*)application {
    [_lifeCycleDelegate applicationWillTerminate:application];
}

- (void)application:(UIApplication*)application
didRegisterUserNotificationSettings:(UIUserNotificationSettings*)notificationSettings {
    [_lifeCycleDelegate application:application
didRegisterUserNotificationSettings:notificationSettings];
}

- (void)application:(UIApplication*)application
didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken {
    [_lifeCycleDelegate application:application
didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}

- (void)application:(UIApplication*)application
didReceiveRemoteNotification:(NSDictionary*)userInfo
fetchCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler {
    [_lifeCycleDelegate application:application
       didReceiveRemoteNotification:userInfo
             fetchCompletionHandler:completionHandler];
}

- (BOOL)application:(UIApplication*)application
            openURL:(NSURL*)url
            options:(NSDictionary<UIApplicationOpenURLOptionsKey, id>*)options {
    return [_lifeCycleDelegate application:application openURL:url options:options];
}

- (BOOL)application:(UIApplication*)application handleOpenURL:(NSURL*)url {
    return [_lifeCycleDelegate application:application handleOpenURL:url];
}

- (BOOL)application:(UIApplication*)application
            openURL:(NSURL*)url
  sourceApplication:(NSString*)sourceApplication
         annotation:(id)annotation {
    return [_lifeCycleDelegate application:application
                                   openURL:url
                         sourceApplication:sourceApplication
                                annotation:annotation];
}

- (void)application:(UIApplication*)application
performActionForShortcutItem:(UIApplicationShortcutItem*)shortcutItem
  completionHandler:(void (^)(BOOL succeeded))completionHandler NS_AVAILABLE_IOS(9_0) {
    [_lifeCycleDelegate application:application
       performActionForShortcutItem:shortcutItem
                  completionHandler:completionHandler];
}

- (void)application:(UIApplication*)application
handleEventsForBackgroundURLSession:(nonnull NSString*)identifier
  completionHandler:(nonnull void (^)(void))completionHandler {
    [_lifeCycleDelegate application:application
handleEventsForBackgroundURLSession:identifier
                  completionHandler:completionHandler];
}

- (void)application:(UIApplication*)application
performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler {
    [_lifeCycleDelegate application:application performFetchWithCompletionHandler:completionHandler];
}

- (void)addApplicationLifeCycleDelegate:(NSObject<FlutterPlugin>*)delegate {
    [_lifeCycleDelegate addDelegate:delegate];
}

@end


複製程式碼

這部分改造的原理還沒有深究,而且有一些方法的實現iOS已經提示棄用了,大家在加入已有工程的時候,需要酌情考慮,我相信後續flutter官方也會更新相關的方法

3.2 推入flutter頁面

在首頁VC中新增如下程式碼

//
//  ViewController.m
//  HybridIOS
//
//  Created by Realank on 2018/8/20.
//  Copyright © 2018年 Realank. All rights reserved.
//

#import "ViewController.h"
#import <Flutter/Flutter.h>
@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
}

- (IBAction)goNext:(id)sender {
    FlutterViewController* flutterViewController = [[FlutterViewController alloc] initWithProject:nil nibName:nil bundle:nil];
    FlutterBasicMessageChannel* messageChannel = [FlutterBasicMessageChannel messageChannelWithName:@"channel"
                                                        binaryMessenger:flutterViewController
                                                                  codec:[FlutterStandardMessageCodec sharedInstance]];//訊息傳送程式碼,本文不做解釋
    __weak __typeof(self) weakSelf = self;
    [messageChannel setMessageHandler:^(id message, FlutterReply reply) {
        // Any message on this channel pops the Flutter view.
        [[weakSelf navigationController] popViewControllerAnimated:YES];
        reply(@"");
    }];
    NSAssert([self navigationController], @"Must have a NaviationController");
    [[self navigationController]  pushViewController:flutterViewController animated:YES];
}

@end

複製程式碼

如果你的首頁不在navigation controller裡,那麼pushflutter頁面肯定會報錯,這和flutter沒關係,如果確實沒有navigation controller,可以present flutterViewController

執行程式碼,點選next,就可以看到flutter頁面了:

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

因為我們的導航欄使用了iOS原生的,所以flutter的導航欄有點多餘了,我們去掉flutter導航欄:

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文
再次執行:

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

證明改動可以同步到app

3.3 flutter頁面管理

你可能發現了,上面的程式碼執行的時候,在flutter頁面點選右下角的加號可以增加中間的數字,但是當退出當前頁面,再進入flutter頁面以後,中間的數字又重置為0了,這是因為每次點選Next,都會重新分配和初始化所有flutter資源,這造成了flutter頁面啟動慢,狀態無法儲存(這個頁面的數字狀態沒必要儲存,但是別的場景下一定有需要儲存的內容)

所以Flutter新銳專家之路:混合開發篇對混合開發中flutter部分做了很好的管理,它將flutter部分做成單例,使其基礎資源在app執行期間只執行一次,再將flutter根頁面設定成一個空白container,需要flutter推入什麼頁面,就發訊息給flutter,flutter在空白container基礎上推入對應頁面,這樣當從flutter的某個頁面回退到iOS原生頁面的時候,flutter也會釋放掉剛剛顯示的頁面,回退到空白頁面。

4. 配置自動執行指令碼

針對怎麼寫程式碼,不是這篇文章的範疇,下面說說混合開發最後的一個痛點

現在的工程,flutter部分有改動,可以直接通過繫結的xcode-backend.sh來編譯,並生成framework和資原始檔,所以無論是iOS端,還是flutter端有改動,在xcode上點選run都可以執行到模擬器和真機,而且iOS和flutter專案程式碼彼此獨立,只有flutter的編譯產物留在了iOS資料夾裡 但是現在還有一個問題,就是當開發flutter部分的時候,我們並不想碰xcode,最好能關掉xcode,只開啟android studio做開發,然後點選AS上的run按鈕執行。

4.1 實現原理

  • xcode命令列工具,可以編譯iOS專案(就像xcode裡點選run一樣),並且還能指定生成.app檔案的目錄
  • flutter執行的時候,可以指定--use-application-binary,flutter編譯產物,以hot-load的方式注入到指定app中(這個原理是我自己猜的,實際情況待仔細確認)

通過上述兩步,就可以在android studio裡,直接往iOS系統裡安裝混合app了

4.2 模擬器實現

用android studio開啟flutter_module資料夾

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

可以看到右上角已經是可以run的狀態了,但是點選的話,會有如下錯誤提示:

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

原因很簡單,這個flutter_module不是一個獨立的工程,需要依賴一個app,所以我們需要先編譯出iOS app,並放到好找的位置:

點選下圖的Edit Configurations

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

然後新增一個執行前編譯app的命令,點選下圖的Run External tool

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文
新增下面的一條:

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

Program裡填/usr/bin/env,Arguments裡填xcrun xcodebuild build -configuration Debug VERBOSE_SCRIPT_LOGGING=YES -project ../../iOS/HybridIOS/HybridIOS.xcodeproj -scheme HybridIOS BUILD_DIR=../build/ios -sdk iphonesimulator -arch x86_64,這裡面指定了編譯的引數

新增後如圖:

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

接著新增flutter編譯的引數,指定剛剛編譯出來的app作為hotload的宿主app: --use-application-binary /Users/realank/Documents/GitHub/HybridFlutter/iOS/build/ios/Debug-iphonesimulator/HybridIOS.app 這裡需要注意,我一開始使用相對路徑,怎麼也執行不起來,說找不到對應的app,所以我使用了絕對路徑,你要換成自己的HybridFlutter/iOS/build/ios/Debug-iphonesimulator/HybridIOS.app的絕對路徑

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

大功告成,這時候點選run執行,就會先編譯ipa,在執行flutter

4.3 真機

真機是一樣的原理,就是命令引數不一樣:

執行flutter前編譯app的命令:xcrun xcodebuild build -configuration Debug VERBOSE_SCRIPT_LOGGING=YES -project ../../iOS/HybridIOS/HybridIOS.xcodeproj -scheme HybridIOS BUILD_DIR=../build/ios -sdk iphoneos -arch arm64

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

真機的app和模擬機app的產物路徑不一樣,所以flutter引數也得變: --use-application-binary /Users/realank/Documents/GitHub/HybridFlutter/iOS/build/ios/Debug-iphoneos/HybridIOS.app

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

Flutter與已有iOS工程混合開發與指令碼配置 | 掘金技術徵文

這樣,我們就可以選擇想要執行的是真機還是模擬器,然後點選run執行

5 總結

flutter混合開發,需要手動設定的地方很多,但是一旦設定好,就不需要再改動,至於最後的flutter執行引數,需要指定絕對路徑,不知道什麼原因,好在影響不大,有空再仔細研究。希望本文會對你有幫助

專案GitHub

從 0 到 1:我的 Flutter 技術實踐 | 掘金技術徵文,徵文活動正在進行中

相關文章