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篇)://www.greatytc.com/p/48a9083ebe89

作者简介

就职于甜橙金融(翼支付)信息技术部,负责 iOS 客户端开发。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容