Flutter混合开发:在已有iOS项目中引入Flutter

1,944 阅读11分钟

Offer 驾到,掘友接招!我正在参与2022春招打卡活动,点击查看活动详情

前言

在android项目中添加flutter模块比较简单,因为毕竟都是google的,但是在ios中添加flutter模块有些麻烦了,我们首先参考的是官方文档 flutter.cn/docs/develo…

但是在实际过程中会遇到各种问题(当然我本身对ios开发不熟悉也造成了不小的困扰),这里结合官方的步骤和我的经验来说说整个接入过程和遇到的坑。

我的环境是Android Studio 4.0.1 + Xcode12.4 + flutter2.0.5 (mac是M1芯片,后面会提到它的影响)

创建flutter module

首先我们创建一个flutter module的项目,可以用命令创建

flutter create --template module 项目名称

也可以直接用idea创建(注意创建的时候一定要选择flutter module)。创建完成build一下,命令如下:

flutter build ios

这里就会出现第一个问题:签名问题。执行上面命令后会报错:

No valid code signing certificates were found

You can connect to your Apple Developer account by signing in with your Apple ID

in Xcode and create an iOS Development Certificate as well as a Provisioning

Profile for your project by:

1- Open the Flutter project's Xcode target with

   open ios/Runner.xcworkspace

2- Select the 'Runner' project in the navigator then the 'Runner' target

 in the project settings

3- Make sure a 'Development Team' is selected. - For Xcode 10, look under General > Signing > Team. - For Xcode 11 and newer, look under Signing & Capabilities > Team.

 You may need to:

     - Log in with your Apple ID in Xcode first
     - Ensure you have a valid unique Bundle ID
     - Register your device with your Apple Developer Account
     - Let Xcode automatically provision a profile for your app

4- Build or run your project again

5- Trust your newly created Development Certificate on your iOS device

 via Settings > General > Device Management > [your new certificate] > Trust

For more information, please visit: developer.apple.com/library/con… AppDistributionGuide/MaintainingCertificates/MaintainingCertificates.html

Or run on an iOS simulator without code signing

这里可以在build的时候选择不签名,命令如下:

flutter build ios --no-codesign

这样就可以build成功。

创建ios项目

我们本身要在已有项目中接入,但是可以先用一个新的项目来进行调研。

这里要注意,创建ios项目的时候我的设置是:

Interfase: StoryBoard

LifeCycle: UIKit App Delegate

Language:Swift

这是因为在官方后续的示例代码中,使用AppDelegate。

这里因为我对ios开发属于小白,所以一开始选择的是SwiftUI

Interfase: SwiftUI

LifeCycle: SwiftUI App

Language:Swift

但是在后续的开发中发现SwiftUI框架好像没法启动flutter页面,于是重新创建了一个项目。

注意:我们将ios项目和之前的flutter module并列放在同一目录下,后面的操作都是基于这一相对路径的。如果你放在了不同目录,后续引入的时候就需要注意路径问题。

ios接入flutter module

官方给出了三种接入方案,这三种方案各有优缺点,我们先简单看看这三种方案:

  • 使用 CocoaPods 和 Flutter SDK 集成:ios项目中用CocoaPods直接接入管理flutter module。这种方案需要所有开发人员都配置flutter环境,且安装CocoaPods;优点是通过CocoaPods自动集成,配置简单。

  • 在 Xcode 中集成 frameworks:将flutter module先build成FrameWork文件,然后在ios项目中引入文件。这种方案的优点是ios开发人员不需要flutter环境,且项目不需要安装CocoaPods;缺点是每次修改都需要重新build,重新导入。

  • 通过CocoaPods打包Framework:与2类似,只不过在build时加入--cocoapods参数:flutter build ios-framework --cocoapods --xcframework --no-universal --output=some/path/MyApp/Flutter/。打包出来的是Flutter.podspec 文件,ios项目中通过CocoaPods管理集成。这个方案的与2方案差不多,缺点也是每次改动需要重新build,优点是ios开发人员不需要flutter环境。

所以要根据自身的情况来选择符合自己的方案。官方推荐第一种方案,我也先尝试了第一个方案。

使用 CocoaPods 和 Flutter SDK 集成

首先我们需要安装CocoaPods:

$ sudo gem install cocoapods

$ pod setup

注意: 这里网上很多文章在安装前会先让你更新ruby,配置淘宝镜像:

sudo gem update –system

gem sources –remove rubygems.org/

gem sources -a ruby.taobao.org/

gem sources -1 (验证你的ruby是淘宝)

更新这一步是没问题的,但是淘宝这个镜像就出问题了,目前这个链接已经无法访问了。所以这些老旧的信息就不要再相信了,我这边测试直接安装就可以。

然后可以根据官网guides.cocoapods.org/using/using… 来为ios项目添加CocoaPods。

首先在ios项目中执行命令:

$ pod init

这样会在ios项目中生成一个名为Podfile的文件。

然后我们修改该文件,先在开头加入:

flutter_application_path = '../flutter模块项目'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

这里的flutter_application_path就是最开始创建flutter module路径,注意这个相对路径。

然后在每个target中添加:

target 'xxx' do
  install_all_flutter_pods(flutter_application_path)
end

最后执行:

$ pod install

CocoaPods会自动将flutter module编辑出的产物集成到ios项目中。如果没有问题,执行⌘+B 编译项目就会成功。

这里有几个问题;

M1 CPU架构导致pod install执行失败

在Finder的Application中找到Terminal(终端),右键选择info(显示简介),在info里将Open with Rosetta(使用Rosetta打开)选中。

然后重新打开终端,执行:

sudo gem install ffi

然后再执行pod install就可以了。

文件缺失

执行pod install后可以看到如下消息:

Installing Flutter (1.0.0)

Installing FlutterPluginRegistrant (0.0.1)

Installing flutter_module项目 (0.0.1)

如果缺失说明编译出了问题,我们在文章一开始创建完flutter module后就执行了flutter build进行编译,然后会在build/ios/目录下生成framework文件,CocoaPods正是将这些文件集成到ios项目中的。所以如果没有进行build,或者build失败就会导致文件缺失。

另外因为涉及到debug和release,所以我执行了两次build:

flutter build ios --no-codesign

flutter build ios --debug --no-codesign

因为第一句命令之生成了release产物。

没有FlutterPluginRegistrant

集成后编译ios不通过,报错:framework not found FlutterPluginRegistrant。但是我们并没有使用任何flutter plugin,所以不存在这个文件,但是CocoaPods不知道为什么一定要这个文件,所以导致一直编译失败。

时间有限,加上与我们的情况不符合,所以我放弃了这种集成方案。

在 Xcode 中集成 frameworks

因为官方推荐的第一种方案未测试通过,且根据我们的情况,第二种方案更加贴合一些,所以我没有在第一种方案上继续纠结研究,转而使用第二种方案。

第二种方案不需要CocoaPods,首先编译打包flutter module:

flutter build ios-framework --xcframework --no-universal --output=./Flutter/

会在flutter module目录下生成一个Flutter目录,里面产出编译后的framework,如下:

flutter module/
└── Flutter/
    ├── Debug/
    │   ├── Flutter.xcframework
    │   ├── App.xcframework
    │   ├── FlutterPluginRegistrant.xcframework (only if you have plugins with iOS platform code)
    │   └── example_plugin.xcframework (each plugin is a separate framework)
    ├── Profile/
    │   ├── Flutter.xcframework
    │   ├── App.xcframework
    │   ├── FlutterPluginRegistrant.xcframework (only if you have plugins with iOS platform code)
    │   └── example_plugin.xcframework (each plugin is a separate framework)
    └── Release/
        ├── Flutter.xcframework
        ├── App.xcframework
        ├── FlutterPluginRegistrant.xcframework (only if you have plugins with iOS platform code)
        └── example_plugin.xcframework (each plugin is a separate framework)

我们可以将这个Flutter目录拷贝到ios项目下,然后在ios项目的Build Phases下的Link Binary With Libraries下添加framework,直接将Flutter.xcframework和App.xcframework等文件(注意:这里官方上使用的是release目录下的,但是我先使用的是Debug目录下的文件,后续会解释这里,先记录一下)拖拽进去即可,如下:

1620804262572.jpg

注意:这一步官网上还在Build Settings -> Framework Search Paths (FRAMEWORK_SEARCH_PATHS) 中增加 $(PROJECT_DIR)/Flutter/Release/。但是这个应该是与上面添加framework文件效果是一样的。我只做了上面添加文件,没有设置这个运行是没有问题的。不知道如果同时设置会不会出现什么问题。

然后需要将framework内嵌(embed)到项目,在项目的General下的FrameWorks, Libraries, and Embedded Content下,将刚才加入的framework改成Embed & Sign,如下:

1620804589618.jpg

然后⌘+B 编译项目即可。 这个过程还算顺利,没有出现什么问题。

ios中启动flutter页面

参考官方教程:flutter.cn/docs/develo…

先是修改AppDelegate文件,修改成:

import UIKit
import Flutter

@UIApplicationMain
class AppDelegate: FlutterAppDelegate {
    lazy var flutterEngine = FlutterEngine(name: "flutter engine")

    override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        flutterEngine.run()
        return true
    }

    // MARK: UISceneSession Lifecycle
    override func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create the new scene with.
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    override func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
        // Called when the user discards a scene session.
        // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
        // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
    }
}

然后修改ViewController文件:

import UIKit
import Flutter
class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        let button = UIButton(type:UIButton.ButtonType.custom)
        button.addTarget(self, action: #selector(showFlutter), for: .touchUpInside)
        button.setTitle("Show Flutter!", for: UIControl.State.normal)
        button.frame = CGRect(x: 80.0, y: 210.0, width: 160.0, height: 40.0)
        button.backgroundColor = UIColor.blue
        self.view.addSubview(button)
    }

    @objc func showFlutter() {
        let flutterEngine = (UIApplication.shared.delegate as! AppDelegate).flutterEngine
        let flutterViewController =
            FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)
        present(flutterViewController, animated: true, completion: nil)
      }
}

然后运行即可。

就这样?显然不可能,下面说说我遇到的几个问题:

编译失败 building for iOS Simulator-arm64 but attempting to link with file built for iOS Simulator-x86_64

报错如下:

ld: warning: ignoring file xxx/Build/Products/Debug-iphonesimulator/App.framework/App, building for iOS Simulator-arm64 but attempting to link with file built for iOS Simulator-x86_64

ld: warning: ignoring file xxx/Build/Products/Debug-iphonesimulator/Flutter.framework/Flutter, building for iOS Simulator-arm64 but attempting to link with file built for iOS Simulator-x86_64

Undefined symbols for architecture arm64:

"OBJC_CLASS$_FlutterAppDelegate", referenced from:

  type metadata for iostest2.AppDelegate in AppDelegate.o

"OBJC_CLASS$_FlutterEngine", referenced from:

  objc-class-ref in AppDelegate.o

"OBJC_METACLASS$_FlutterAppDelegate", referenced from:

  _OBJC_METACLASS_$__TtC8iostest211AppDelegate in AppDelegate.o

"OBJC_CLASS$_FlutterViewController", referenced from:

  objc-class-ref in ViewController.o

ld: symbol(s) not found for architecture arm64

clang: error: linker command failed with exit code 1 (use -v to see invocation)

很明显是cpu架构的问题,但是为什么会出现这样的问题?我们看之前生成的flutter framework文件,拿Debug目录下的App.xcframework为例,这个目录下的文件如下:

1620807185799.jpg

可以看到在simulator(模拟器)上是x86_64的,而在真机上则是arm64_armv7的。从上面报错日志上看,程序是想找arm64下的文件,但是我们是打算运行到模拟器上的,所以找不到了文件。

这个问题官网上flutter.cn/docs/develo… 的最后也提到了,解决方法是在项目的Build Settings -> Archiectures -> Excluded Archiectures下将simulator都设置arm64即可,如下:

1620807694239.jpg

鼠标移到Debug上,后面会出现+号,点击就会在下面添加一条。

然后在新添加的左侧选择Any iOS Simulator SDK,双击右侧就会弹窗,在弹窗中添加一条arm64即可。

同样在Release下也操作一下,最后完成效果如上图。

这样设置后在模拟器上编译运行时就会排除arm64。再进行编译即可通过。

运行后提示Engine run configuration was invalid. Could not launch engine with configuration.

运行后,在日志区域显示如下日志:

Engine run configuration was invalid.

Could not launch engine with configuration.

点击按钮无法正常显示flutter页面。

根据网上一个大神的解释,这是因为物料出问题了(如果你上面按照我的提示做的就不会出现这个问题)。

原因是运行的是debug,但是flutter framework的物料是release的。

上面接入的时候提到过,这里官网上是引入Release目录下的文件,但是我先引用的是Debug目录下的,就是因为这个问题。但是如果已经按照官网引入release物料,就会出现上面的问题,这时候先清理一下项目

Product -> Clean Build Folder

然后在General下的FrameWorks, Libraries, and Embedded Content下将之前引入的文件移除掉,再重新引入Debug目录下的文件即可。再运行就可以正常展示flutter了。

当然,如果要运行release,则需要再执行上面的操作替换一下文件。这也是这种方案的最大弊端。

启动不同的flutter页面

上面我们只是启动flutter默认主页,可以看到在app启动时就将flutter engine启动起来,这样当我们点击按钮启动页面的时候,flutter页面很快就打开了。

当时如果启动不同的flutter页面怎么办?比如有两个按钮,分别启动flutter的主页面和second页面。参考官方文档,可以使用隐式flutter engine来启动,将ViewController的代码修改如下:

import UIKit
import Flutter
class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        let button1 = UIButton(type:UIButton.ButtonType.custom)
        button1.addTarget(self, action: #selector(showMain), for: .touchUpInside)
        button1.setTitle("show main!", for: UIControl.State.normal)
        button1.frame = CGRect(x: 80.0, y: 210.0, width: 160.0, height: 40.0)
        button1.backgroundColor = UIColor.blue
        self.view.addSubview(button1)
        
        let button2 = UIButton(type:UIButton.ButtonType.custom)
        button2.addTarget(self, action: #selector(showSecond), for: .touchUpInside)
        button2.setTitle("show second!", for: UIControl.State.normal)
        button2.frame = CGRect(x: 80.0, y: 310.0, width: 160.0, height: 40.0)
        button2.backgroundColor = UIColor.blue
        self.view.addSubview(button2)
    }

    @objc func showMain() {
        let flutterViewController = FlutterViewController(project: nil, initialRoute: "/", nibName: nil, bundle: nil);
        present(flutterViewController, animated: true, completion: nil)
      }
    
    @objc func showSecond() {
        let flutterViewController = FlutterViewController(project: nil, initialRoute: "second", nibName: nil, bundle: nil);
        present(flutterViewController, animated: true, completion: nil)
      }
}

这样就可以启动不同的页面,但是可以发现我们没有用到之前在AppDelegate创建的flutterEngine,因为创建FlutterViewController时都会隐式的创建新的flutterEngine,这也导致了一个问题,每次启动页面都需要等待一段时间。

我们可以预先创建两个flutterEngine,AppDelegate代码修改如下:

import UIKit
import Flutter

@UIApplicationMain
class AppDelegate: FlutterAppDelegate {
    lazy var flutterEngine1 = FlutterEngine(name: "main")
    lazy var flutterEngine2 = FlutterEngine(name: "second")

    override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        flutterEngine1.run(withEntrypoint: "main", initialRoute: "/")
        flutterEngine2.run(withEntrypoint: "main", initialRoute: "second")
        return true
    }

    // MARK: UISceneSession Lifecycle
    override func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create the new scene with.
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    override func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
        // Called when the user discards a scene session.
        // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
        // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
    }
}

然后修改ViewController的代码如下:

import UIKit
import Flutter
class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        let button1 = UIButton(type:UIButton.ButtonType.custom)
        button1.addTarget(self, action: #selector(showMain), for: .touchUpInside)
        button1.setTitle("show main!", for: UIControl.State.normal)
        button1.frame = CGRect(x: 80.0, y: 210.0, width: 160.0, height: 40.0)
        button1.backgroundColor = UIColor.blue
        self.view.addSubview(button1)
        
        let button2 = UIButton(type:UIButton.ButtonType.custom)
        button2.addTarget(self, action: #selector(showSecond), for: .touchUpInside)
        button2.setTitle("show second!", for: UIControl.State.normal)
        button2.frame = CGRect(x: 80.0, y: 310.0, width: 160.0, height: 40.0)
        button2.backgroundColor = UIColor.blue
        self.view.addSubview(button2)
    }

    @objc func showMain() {
        let flutterEngine1 = (UIApplication.shared.delegate as! AppDelegate).flutterEngine1
        let flutterViewController =
            FlutterViewController(engine: flutterEngine1, nibName: nil, bundle: nil)
        present(flutterViewController, animated: true, completion: nil)
      }
    
    @objc func showSecond() {
        let flutterEngine2 = (UIApplication.shared.delegate as! AppDelegate).flutterEngine2
        let flutterViewController =
            FlutterViewController(engine: flutterEngine2, nibName: nil, bundle: nil)
        present(flutterViewController, animated: true, completion: nil)
      }
}

这样再启动页面就会瞬间打开了,因为flutterEngine已经提前启动起来了。

#####Undefined symbol: _FlutterDefaultDartEntrypoint

过程中出现过一个问题,一开始启动flutterEngine的代码是根据官网上的写法如下:

flutterEngine.run(withEntrypoint: FlutterDefaultDartEntrypoint, initialRoute: FlutterDefaultInitialRoute)

这样可以启动flutter的默认页面。但是编译报错:

Undefined symbol: _FlutterDefaultDartEntrypoint

Undefined symbol: _FlutterDefaultInitialRoute

在FlutterEngine.h源码下可以看到对应的变量,但是通过在Debug/Flutter.xcframework/ios-x86_64-simulator/Flutter.framework下的Flutter文件(C文件生成的二进制文件)中搜索发现并没有这两个字段,说明在C文件中并没有定义这两个字段。

目前还不确定是不是flutter编译导致的问题。但是我们可以解决这个问题,首先FlutterDefaultInitialRoute就是默认路径,其实就是"/"。而FlutterDefaultDartEntrypoint就是默认入口,就是flutter中的main函数,所以就是"main"。所以在上面代码中我直接使用了这两个字符串来代替这两个字段。

总结

所以我们现在面临着与Android同样的困境,需要解决两个问题:

1、不支持传参数

2、每一个页面都需要一个flutterEngine,所以每加一个flutter页面就需要在ios代码中新增一个flutterEngine

所以我们一样需要用一个类似闲鱼flutter-boost原理的框架来管理flutter页面,下一步我会开发一个简单的快速启动框架。