阅读 4992

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工程(安卓工程暂时忽略)

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

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

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

2.3 Flutter Module搭建

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

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

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

这样就创建好了flutter module

目前为止的目录结构

2.4 添加胶水文件

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

2.4.1 xcconfig文件

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

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

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

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

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

2.4.2 AppFrameworkInfo.plist

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

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

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

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

2.4.4 添加flutter编译产物

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

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

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

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

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

再次运行:

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

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

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

点击下图的Edit Configurations

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

添加下面的一条:

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编译的参数,指定刚刚编译出来的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的绝对路径

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

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

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

5 总结

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

项目GitHub

从 0 到 1:我的 Flutter 技术实践 | 掘金技术征文,征文活动正在进行中

关注下面的标签,发现更多相似文章
评论