Flutter 開發之 Native 集成 Flutter 混合開發

本文先介紹一下現有工程如何集成 Flutter 實現混合開發,以及混合項目如何打包,再探索下如何降低原生和 Flutter 之間的依賴,使 Flutter 開發對原生開發的影響盡量降低,以及一些我在嘗試中遇到的問題及解決。

介紹 Flutter

Flutter 是 Google 發布的一個用于創建跨平臺、高性能移動應用的框架。Flutter 和 QT mobile 一樣,都沒有使用原生控件,相反都實現了一個自繪引擎,使用自身的布局、繪制系統。開發者可以通過 Dart 語言開發 App,一套代碼同時運行在 iOS 和 Android平臺。Flutter 提供了豐富的組件、接口,開發者可以很快地為 Flutter 添加 Native 擴展。

前提工作

開發者需要安裝好 Flutter 的環境,執行flutter doctor -v驗證。

flutter_doctor_v

驗證通過后即可開始集成 Flutter。

現有原生工程集成 Flutter

最官方的教程應該是Add Flutter to existing apps了,按照教程如下一步步操作:

1.創建 flutter module

使用flutter create xxx指令創建的 Flutter 項目包括用于 Flutter/Dart 代碼的非常簡單的工程。你可以修改 main.dart 的內容,以滿足你的需要,并在此基礎上進行構建。

假設你有一個已經存在 iOS 工程(以 flutterHybridDemo 為例)在some/path/flutterHybridDemo,那么你新建的 flutter_module 和 iOS 工程應該在同一目錄下(即都在 path 下)。

$ cd some/path/
$ flutter create -t module flutter_module
flutter_module目錄結構

通過shift+command+.顯示/隱藏隱藏文件夾

  • lib/main.dart:存放的是 Dart 語言編寫的代碼,這里是核心代碼;
  • pubspec.yaml:配置依賴項的文件,比如配置遠程 pub 倉庫的依賴庫,或者指定本地資源(圖片、字體、音頻、視頻等);
  • .ios/:iOS 部分代碼;
  • .android/:Android 部分代碼;
  • build/:存儲 iOS 和 Android 構建文件;
  • test/:測試代碼。

2.將 flutter module 作為依賴添加到工程

假設文件夾結構如下:

some/path/
  flutter_module/
    lib/main.dart
    .ios/
    ...
  flutterHybridDemo/
    flutterHybridDemo.xcodeproj
    flutterHybridDemo/
        AppDelegate.h
        AppDelegate.m
        ...

集成 Flutter 框架需要使用CocoaPods,這是因為 Flutter 框架還需要對 flutter_module 中可能包含的任何 Flutter 插件可用。

- 如果需要,請參考cocoapods.org了解如何在您的電腦上安裝 CocoaPods。

創建 Podfile:

$ cd some/path/flutterHybridDemo
$ pod init

此時工程中會出現一個 Podfile 文件,添加項目依賴的第三方庫就在這個文件中配置,編輯 Podfile 文件添加最后兩行代碼:

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'TestOne' do
  # Uncomment the next line if you're using Swift or would like to use dynamic frameworks
  # use_frameworks!

  # Pods for TestOne

  target 'TestOneTests' do
    inherit! :search_paths
    # Pods for testing
  end

  target 'TestOneUITests' do
    inherit! :search_paths
    # Pods for testing
  end

end

#新添加的代碼
flutter_application_path = '../flutter_module'
eval(File.read(File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')), binding)
- 如果你的工程(flutterHybridDemo)已經在使用 Cocoapods ,你只需要做以下幾件事來整合你的 flutter_module 應用程序:

(1)添加如下內容到 Podfile:

flutter_application_path = '../flutter_module'
eval(File.read(File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')), binding)

(2)執行pod install

當你在some/path/flutter_module/pubspec.yaml中修改 Flutter 插件依賴時,需要先執行flutter packages get通過 podhelper.rb 腳本來刷新插件列表,然后再從some/path/flutterHybridDemo執行一次pod install

podhelper.rb 腳本將確保你的插件和 Flutter 框架被添加到你的工程中,以及 bitcode 被禁用。

(3)禁用 bitcode

因為 Flutter 現在不支持 bitcode。需要設置 Build Settings->Build Options->Enable Bitcode 為 NO。


bitcode 禁用

3.為編譯 Dart 代碼配置 build phase

打開 iOS 工程,選中項目的 Build Phases 選項,點擊左上角+號按鈕,選擇 New Run Script Phase。


配置 build phase

將下面的 shell 腳本添加到輸入框中:

"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build
"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" embed

最后,確保 Run Script 這一行在 "Target dependencies" 或者 "Check Pods Manifest.lock" 后面。


配置 build phase

至此,你可以編譯一下工程確保無誤:?B

4.在 iOS 工程中使用 FlutterViewController

首先聲明你的 AppDelegate 是 FlutterAppDelegate 的子類。然后定義一個 FlutterEngine 屬性,它可以幫助你注冊一個沒有 FlutterViewController 實例的插件。

在 AppDelegate.h:

#import <UIKit/UIKit.h>
#import <Flutter/Flutter.h>

@interface AppDelegate : FlutterAppDelegate
@property (nonatomic,strong) FlutterEngine *flutterEngine;
@end

在AppDelegate.m,修改didFinishLaunchingWithOptions方法如下:

#import <FlutterPluginRegistrant/GeneratedPluginRegistrant.h> // Only if you have Flutter Plugins
#include "AppDelegate.h"

@implementation AppDelegate

// This override can be omitted if you do not have any Flutter Plugins.
- (BOOL)application:(UIApplication *)application
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  self.flutterEngine = [[FlutterEngine alloc] initWithName:@"io.flutter" project:nil];
  [self.flutterEngine runWithEntrypoint:nil];
  [GeneratedPluginRegistrant registerWithRegistry:self.flutterEngine];
  return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

@end

如果 AppDelegate 已經繼承于別的類的時候,可以通過讓你的 delegate 實現FlutterAppLifeCycleProvider協議:

#import <Flutter/Flutter.h>
#import <UIKit/UIKit.h>
#import <FlutterPluginRegistrant/GeneratedPluginRegistrant.h> // Only if you have Flutter Plugins

@interface AppDelegate : UIResponder <UIApplicationDelegate, FlutterAppLifeCycleProvider>
@property (strong, nonatomic) UIWindow *window;
@end

然后生命周期方法應該由 FlutterPluginAppLifeCycleDelegate 來代理:

@implementation AppDelegate
{
    FlutterPluginAppLifeCycleDelegate *_lifeCycleDelegate;
}

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

- (BOOL)application:(UIApplication*)application
didFinishLaunchingWithOptions:(NSDictionary*)launchOptions {
    [GeneratedPluginRegistrant registerWithRegistry:self]; // Only if you are using Flutter plugins.
    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

在 ViewController 中添加跳轉到 FlutterViewController 的測試代碼即可:

#import "ViewController.h"
#import <Flutter/Flutter.h>
#import "AppDelegate.h"

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
    [button addTarget:self
               action:@selector(handleButtonAction)
     forControlEvents:UIControlEventTouchUpInside];
    [button setTitle:@"Jump to flutterViewController" forState:UIControlStateNormal];
    [button setBackgroundColor:[UIColor grayColor]];
    button.frame = CGRectMake(80.0, 210.0, 300.0, 40.0);
    button.center = self.view.center;
    [self.view addSubview:button];
}

- (void)handleButtonAction {
    AppDelegate *delegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    FlutterEngine *flutterEngine = delegate.flutterEngine;
    
    FlutterViewController *flutterVC = [[FlutterViewController alloc]initWithEngine:flutterEngine nibName:nil bundle:nil];
    [self presentViewController:flutterVC animated:YES completion:nil];
}
@end

5.使用熱重載的方式調試 Dart 代碼

熱重載指的是不用重新啟動就看到修改后的效果,類似 web 網頁開發時保存就看到效果的方式。
進入 flutter module,在終端執行命令:

$ cd some/path/flutter_module
$ flutter run
flutter run

并且你能在控制臺中看下如下內容:

??  To hot reload changes while running, press "r". To hot restart (and rebuild state), press "R".
An Observatory debugger and profiler on iPhone X is available at: http://127.0.0.1:54741/
For a more detailed help message, press "h". To quit, press "q".

你可以在 flutter_module 中編輯 Dart code,然后在終端輸入 r 來使用熱重載。你也可以在瀏覽器中輸入上面的 URL 來查看斷點、分析內存和其他的調試任務。

集成 Flutter 后工程打包

1. flutter build ios

執行flutter build ios以創建 release 版本(flutter build 默認為--release,如需創建 debug 版本執行flutter build ios —debug)。

2.成功后修改 Xcode 為 release 模式配置

3.最后選擇 Product > Archive 以生成構建版本即可

archive 成功

混合工程改造優化

Flutter 的工程結構比較特殊,由 Flutter 目錄、Native 工程的目錄(即 iOS 和 Android 兩個目錄)組成。默認情況下,引入了 Flutter 的 Native 工程無法脫離父目錄進行獨立構建和運行,因為它會反向依賴于 Flutter 相關的庫和資源。

實際上,在真實的開發情況下,開發者很少會創建一個完全 Flutter 的工程重寫項目,更多的情況是原生工程集成 Flutter。

1.問題

這樣就帶來了一系列問題:

(1)構建打包問題:引入 Flutter 后,Native 工程因對其有了依賴和耦合,從而無法獨立編譯構建。在 Flutter 環境下,工程的構建是從 Flutter 的構建命令開始,執行過程中包含了 Native 工程的構建,開發者要配置完整的 Flutter 運行環境才能走通整個流程

(2)混合編譯帶來的開發效率的降低:在轉型 Flutter 的過程中必然有許多業務仍使用 Native 進行開發,工程結構的改動會使開發無法在純 Native 環境下進行,而適配到 Flutter 工程結構對純 Native 開發來說又會造成不必要的構建步驟,造成開發效率的降低。

2.目標

希望能將 Flutter 依賴抽取出來,作為一個 Flutter 依賴庫,供純 Native 工程引用,無需配置完整的 Flutter 環境。

3.Flutter 產物

iOS 工程對 Flutter 有如下依賴:

  • Flutter.framework:Flutter 庫和引擎

  • App.framework:dart 業務源碼相關文件

  • flutter_assets:Flutter依賴的靜態資源,如字體,圖片等

  • Flutter Plugin:編譯出來的各種 plugin 的 framework

把以上依賴的編譯結果抽取出來,即是 Flutter 相關代碼的最終產物。

那么我們只需要將這些打包成一個 SDK 依賴的形式提供給 Native 工程,就可以解除 Native 工程對 Flutter 工程的直接依賴。

產物的產生:

對 flutter 工程執行 flutter build 命令后,生成在.ios/Flutter目錄下,直接手動拷貝 framework 到主工程即可。

注意事項:

framework 選擇 Create groups 加入文件夾,flutter_assets 選擇 Create folder references 加入文件夾。

add_in_project

加入完成后的結構:

thirdFramework

framework 加入后,記住一定要確認 framework 已在 TARGETS -> General -> Embedded Binaries 中添加完成。

embedded_binaires

最后改造 APPDelegate 即可:

#import <UIKit/UIKit.h>
#import <Flutter/Flutter.h>

@interface AppDelegate : FlutterAppDelegate <UIApplicationDelegate>

@property (strong, nonatomic) FlutterEngine *flutterEngine;

@end
#import "AppDelegate.h"

@interface AppDelegate ()
@end

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    self.flutterEngine = [[FlutterEngine alloc]initWithName:@"io.flutter" project:nil];
    [self.flutterEngine runWithEntrypoint:nil];
    return YES;
}

4. 優化

為了更方便管理 framework,可以將這些文件上傳到遠程倉庫,通過 CocoaPods 導入,Native 項目只需及時更新 pod 依賴即可。

我遇到過的一些問題及解決

1.在 Android Studio 上跑設備

More than one device connected; please specify a device with the '-d <deviceId>' flag, or use '-d all' to act on all devices.

選擇模擬器

提示你當前有兩個模擬器設備,跑設備的時候要選擇運行在哪個設備上,flutter run后面拼接上“-d <deviceId>”,deviceId 是第二列的內容。

flutter run -d emulator-5554
flutter run -d C517D2D4-EAFA-42CA-B260-A18FA0ABFF60

電腦連著真機也同理,改成真機的 deviceId 即可。

2.flutter build ios 報錯

build 時可能遇到的錯誤:

It appears that your application still contains the default signing identifier.Try replacing 'com.example' with your signing id in Xcode:

open ios/Runner.xcworkspace

build 時可能遇到的錯誤

解決方法:

修改some/flutter_module/.ios/下 Runner 工程的 Bundle Identifier 和原生工程的一致,再次運行flutter build ios即可。

3.開發時打包產物編譯失敗

當你用flutter build ios的產物添加到原生工程中,跳轉到 Flutter 界面會黑屏并報出如下錯誤:

flutter_build_questions

Failed to find snapshot: …/Library/Developer/CoreSimulator/Devices/…/data/Containers/Bundle/Application/…/FlutterMixDemo.app/Frameworks/App.framework/flutter_assets/kernel_blob.bin

如何解決:

調試模式下用flutter build ios —debug的產物,再次拖入工程即可。

原因:

首先我們對比下,執行flutter build ios和執行flutter build ios --debug.ios/Flutter/App.framework/flutter_assets的文件內容:

flutter_build_ios.png

flutter_build_ios_debug.png

可以發現,差別是在于三個文件:isolate_snapshot_data、kernel_blob.bin、vm_snapshot_data。

這里涉及 Flutter 的編譯模式知識,具體可以參閱Flutter 的兩種編譯模式

Flutter 開發階段的編譯模式:使用了 Kernel Snapshot 模式編譯,打包產物中,可以發現幾樣東西:

  • isolate_snapshot_data:用于加速 isolate 啟動,業務無關代碼,固定,僅和 flutter engine 版本有關;

  • platform.dill:和 Dart VM 相關的 kernel 代碼,僅和 Dart 版本以及 engine 編譯版本有關。固定,業務無關代碼;

  • vm_snapshot_data:用于加速 Dart VM 啟動的產物,業務無關代碼,僅和 flutter engine 版本有關;

  • kernel_blob.bin:業務代碼產物 。

Flutter 生產階段的編譯模式:選擇了 AOT 打包。

4.集成后 Native 工程報錯

Shell Script Invocation Error

line 2:/packages/flutter_tools/bin/xcode_backend.sh: No such file or directory

集成后 Native 工程報錯

解決方法:

修改 TARGETS -> Build Setting -> FLUTTER_ROOT 為電腦安裝的 Flutter 環境的路徑即可。


集成后 Native 工程報錯

5.如何在 iOS 工程 Debug 模式下使用 release 模式的 flutter

只需要將 Generated.xcconfig 中的 FLUTTER_BUILD_MODE 修改為 release,FLUTTER_FRAMEWORK_DIR 修改為 release 對應的路徑即可。

其他

1.說明:

本文僅供用于學習參考,請勿用于商業用途。如需轉載,請標明出處,謝謝合作。

本文系參考網絡公開 Flutter 學習資料以及個人學習體會總結所得,部分內容為網絡公開學習資料,如有侵權請聯系作者刪除。

2.參考資料:

Flutter 中文網:https://flutterchina.club

咸魚技術-flutter:https://www.yuque.com/xytech/flutter

iOS Native混編Flutter交互實踐:https://juejin.im/post/5bb033515188255c5e66f500#heading-3

Flutter混編之路——開發集成(iOS篇):http://www.lxweimin.com/p/48a9083ebe89

作者簡介

就職于甜橙金融(翼支付)信息技術部,負責 iOS 客戶端開發。

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,488評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,034評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,327評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,554評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,337評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,883評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,975評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,114評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,625評論 1 332
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,555評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,737評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,244評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,973評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,615評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,343評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,699評論 2 370

推薦閱讀更多精彩內容