Flutter與已有iOS工程混合開發與指令碼配置

LeeJoey77發表於2018-12-14
作者:Realank Liu
連結:https://juejin.im/post/5b7a1bfbe51d4538a93d2339
來源:掘金
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

本文轉自⬆️, 並結合自己的實踐對其中一些地方做修改, 使用 Swift 語言.

修改的地方用"註釋*"標出.


執行一個原生的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的資源了。

註釋*: 本人在嘗試以上下劃線部分執行 xcode 後, 並沒有在本地生成 Flutter 資料夾.解決辦法是, 到 Flutter 專案目錄下, "shift + commond + >" 讓隱藏的 .iOS 資料夾顯示出來, 複製如下三個資料夾

Flutter與已有iOS工程混合開發與指令碼配置

然後到 iOS 專案下, 在一級 HybridiOS 目錄下建立 Flutter 資料夾(和專案資料夾 Hybrid iOS 並列), 然後將剛才複製的三個檔案貼上到 Flutter 資料夾中.


Flutter與已有iOS工程混合開發與指令碼配置

進入 iOS 專案右鍵 Add files to......, 將剛才的 Flutter 資料夾新增到專案中

Flutter與已有iOS工程混合開發與指令碼配置



2.4.4 新增flutter編譯產物

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

註釋*: 由於 Flutter 資料夾並不在 HybridiOS 中, 此處選擇Add other 新增, Copy Bundle Resources 的新增同樣選擇 Add other

Flutter與已有iOS工程混合開發與指令碼配置



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改造

 FlutterPluginAppLifeCycleDelegate 只有對於使用外掛的才需要寫, 沒有使用外掛不需要. 3.1 部分可直接忽略看下邊註釋* 

註釋*: 此處本人使用的是 Swift 語言, 直接貼出 AppDelegate 的程式碼.

修改部分:

1. import Flutter 

2.AppDelegate: UIResponder, UIApplicationDelegate改為AppDelegate: FlutterAppDelegate 

3.didFinishLaunch 的返回值 return true 改為  return super.application(application, didFinishLaunchingWithOptions: launchOptions); 4.AppDelegate 所有代理函式前加 override

import UIKit
import Flutter


@UIApplicationMain
class AppDelegate: FlutterAppDelegate {


    override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        return super.application(application, didFinishLaunchingWithOptions: launchOptions);
    }


    override func applicationWillResignActive(_ application: UIApplication) {
        // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
        // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
    }


    override func applicationDidEnterBackground(_ application: UIApplication) {
        // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
        // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
    }


    override func applicationWillEnterForeground(_ application: UIApplication) {
        // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
    }


    override func applicationDidBecomeActive(_ application: UIApplication) {
        // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
    }


    override func applicationWillTerminate(_ application: UIApplication) {
        // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
    }
}


複製程式碼


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執行引數,需要指定絕對路徑,不知道什麼原因,好在影響不大,有空再仔細研究。希望本文會對你有幫助Flutter與已有iOS工程混合開發與指令碼配置


Flutter與已有iOS工程混合開發與指令碼配置


Flutter與已有iOS工程混合開發與指令碼配置


Flutter與已有iOS工程混合開發與指令碼配置


Flutter與已有iOS工程混合開發與指令碼配置


Flutter與已有iOS工程混合開發與指令碼配置


Flutter與已有iOS工程混合開發與指令碼配置


Flutter與已有iOS工程混合開發與指令碼配置


Flutter與已有iOS工程混合開發與指令碼配置




相關文章