Flutter与已有iOS工程混合开发与脚本配置

运行一个原生的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工程(安卓工程暂时忽略)

image

并且在第一页VC上增加一个Next按钮,集成好Flutter以后,点击Next可以进入Flutter页面

image

因为我们要推入flutter页面,所以需要有navigation controller:

image

目前Flutter混合开发还不支持bit code,所以在iOS工程里关闭

image

2.3 Flutter Module搭建

这里有一个坑,按照flutter官方文档,下载的flutter工具对应其beta分支,是不支持生成Flutter module的,而混合开发的wiki里说,需要建立这么个module,通过咨询大牛,需要切换到master分支,而flutter有个channel命令,可以切换工具分支:

image

如果你不在master分支,请执行flutter channel master

之后在Flutter目录下执行flutter create -t module flutter_module

image

这样就创建好了flutter module

目前为止的目录结构

2.4 添加胶水文件

混合开发最关键的是将两个项目衔接起来,所以需要一些配置

2.4.1 xcconfig文件

首先是xcode工程配置的衔接,打开ios工程,在xcode中点击File->New->File添加Configuration Settings File文件,命名为FlutterConfig.xcconfig,

image

注意添加的路径是HybridFlutter/Flutter/flutter_module

image

此时可能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里添加)

image

添加内容#include "../../../Flutter/flutter_module/FlutterConfig.xcconfig"

image

然后,将Debug.xcconfig添加到iOS项目的Info-Configuration里:

image

2.4.2 AppFrameworkInfo.plist

这个文件在最新的flutter工具里已经自动创建好了
刚才我们看的文件目录,不包含隐藏文件,其实flutter_module里还有对应的ios和android插件工程,都是隐藏文件,从隐藏文件里可以看到AppFrameworkInfo.plist

image

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"后面

image

此时点击xcode的运行,会执行到xcode-backend.sh脚本,所以不仅会编译安装iOS app到模拟器(暂时运行对象是模拟器),而且在iOS工程目录,也会生成一个Flutter文件夹,里面是Flutter工程的产物

image

把这些产物放到iOS工程里,就能获取到flutter的资源了。

2.4.4 添加flutter编译产物

,将iOS工程目录下的Flutter文件夹添加到工程,然后确保文件夹下的两个framework添加到Embeded Binaries里

image

确保flutter_aseets添加到Build Phases里的Copy Bundle Resources里

image

添加完,在工程目录里,会多出一个flutter _aseets引用(注意只是引用,如果是拷贝可能会有问题),其实是引用的Flutter/flutter _aseets,试了半天没有去掉,就先这样吧

image

目前,所有的胶水文件都已经添加完了,下一步就是在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页面了:

image

因为我们的导航栏使用了iOS原生的,所以flutter的导航栏有点多余了,我们去掉flutter导航栏:

image

再次运行:

image

证明改动可以同步到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文件夹

image

可以看到右上角已经是可以run的状态了,但是点击的话,会有如下错误提示:

image

原因很简单,这个flutter_module不是一个独立的工程,需要依赖一个app,所以我们需要先编译出iOS app,并放到好找的位置:

点击下图的Edit Configurations


image

然后添加一个运行前编译app的命令,点击下图的Run External tool


image

添加下面的一条:

image

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,这里面指定了编译的参数

添加后如图:

image

接着添加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的绝对路径

image

大功告成,这时候点击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

image

真机的app和模拟机app的产物路径不一样,所以flutter参数也得变:
--use-application-binary /Users/realank/Documents/GitHub/HybridFlutter/iOS/build/ios/Debug-iphoneos/HybridIOS.app

image
image

这样,我们就可以选择想要运行的是真机还是模拟器,然后点击run运行

5 总结

flutter混合开发,需要手动设置的地方很多,但是一旦设置好,就不需要再改动,至于最后的flutter运行参数,需要指定绝对路径,不知道什么原因,好在影响不大,有空再仔细研究。希望本文会对你有帮助

项目GitHub

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

推荐阅读更多精彩内容