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

作者: Realank | 来源:发表于2018-10-23 11:47 被阅读109次

    运行一个原生的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

    相关文章

      网友评论

      • ForestSen:感谢分享,最近在搞这一块
      • GoBg:android studio 怎么连接ios设备?
        Realank:@GoBg flutter插件已经支持了,连上就能用

      本文标题:Flutter与已有iOS工程混合开发与脚本配置

      本文链接:https://www.haomeiwen.com/subject/cygozftx.html