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

2,510 阅读12分钟
作者:Realank Liu
链接:https://juejin.cn/post/6844903661982711816
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

本文转自⬆️, 并结合自己的实践对其中一些地方做修改, 使用 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工程(安卓工程暂时忽略)

并且在第一页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的资源了。

注释*: 本人在尝试以上下划线部分运行 xcode 后, 并没有在本地生成 Flutter 文件夹.解决办法是, 到 Flutter 项目目录下, "shift + commond + >" 让隐藏的 .iOS 文件夹显示出来, 复制如下三个文件夹


然后到 iOS 项目下, 在一级 HybridiOS 目录下创建 Flutter 文件夹(和项目文件夹 Hybrid iOS 并列), 然后将刚才复制的三个文件粘贴到 Flutter 文件夹中.



进入 iOS 项目右键 Add files to......, 将刚才的 Flutter 文件夹添加到项目中




2.4.4 添加flutter编译产物

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

注释*: 由于 Flutter 文件夹并不在 HybridiOS 中, 此处选择Add other 添加, Copy Bundle Resources 的添加同样选择 Add other




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

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

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

因为我们的导航栏使用了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运行参数,需要指定绝对路径,不知道什么原因,好在影响不大,有空再仔细研究。希望本文会对你有帮助