Flutter - 混编项目集成Shorebird热更新🐦(iOS篇)

欢迎关注微信公众号:FSA全栈行动 👋

一、概述

关于 Shorebird 的初始化内容可以在上一篇《Flutter - 混编项目集成Shorebird热更新🐦(安卓篇)》中查看,这里就不再赘述了。

Shorebird 官方文档上对于 iOS 混编方案集成热更新的介绍不算详细,只能说点明了要点,指明了方向。

本文将根据实际的项目应用情况做出集成调整,并补充说明正确的补丁验证方案。

二、踩坑

Shorebird 文档里指出需要我们使用类似 Flutter 官方文档里 Option B - Embed frameworks in Xcode 的方式去集成 Flutter 模块。

关于 Flutter 官方文档指出的各种集成方式可以查看: docs.flutter.dev/add-to-app/...

具体的步骤就是:

  1. 先注释掉原来的 Option A 集成方式的相关配置代码
  2. 执行 Shorebird Release 去构建对应的所有 xcframework 文件。(xcframework 文件包括了 Flutter.xcframeworkApp.xcframework,以及插件依赖的原生第三方库对应的 xcframework
  3. 将所有构建完成的 xcframework 拖到 Build Phases 中的 Embed Frameworks 内。
  4. xcframework 的所在目录路径配置到 Framework Search Paths
  5. 配置 xcframeworkEmbed 模式,静态库必须选 Do Not Embed,动态库必须选 Embed & Sign

因为 Option A - Embed with CocoaPods and the Flutter SDK 的方式只需要简单的配置 Podfile 就可以集成 Flutter 模块,所以相信大家在一般情况下都是会选择 Option A 的方式。很明显,要改成 Option B 需要我们大改特改。

改成 Option B 这种方式有以下几点问题:

1、vendored_frameworks 缺失

如果你依赖的 Flutter 插件依赖了原生第三方的二进制包,如 realm,在它的 podspec 文件是这样声明的 s.vendored_frameworks = 'realm_dart.xcframework',那你会发现在最终构建完成的 xcframework 的目录里会缺少这些 vendored_frameworks

相关的 issue: github.com/flutter/flu...

因为 Option B 是二进制依赖,所以在编译的时候并不会报任何错误,等你 App 运行起来进入一些相关场景,使用到了对应的第三方功能时就会直接来个找不到符号的错误,如:

shell 复制代码
Failed to lookup symbol 'native_method_signature': dlsym(0xa47e7c10, native_method_signature): symbol not found

接着就是闪退,可想而知这得多吓人!

2、重复编译

vendored_frameworks 缺失的问题我通过脚本解决了,但是还有另一个问题,这些 xcframework 中也有可能出现涵盖你原来的原生工程里依赖的第三方包,比如,Flutter 的插件用到了 FMDB,生成的 xcframework 中就会包含 FMDB.xcframework,而你的原生工程本来就有依赖 FMDB,这个时候编译,Xcode 就会告诉你重复了,编译不通过,报错内容如下:

shell 复制代码
Showing Recent Messages
Multiple commands produce '/Users/lxf/Library/Developer/Xcode/DerivedData/xxx.app/Frameworks/FMDB.framework'

如果是你,你选择留下哪个呢?

  • 如果你选择了 Flutter 帮你生成的 FMDB.xcframework,你就得去处理其它原生第三方依赖的 pod 'FMDB',假如此时原生工程里的一些第三方库或私有库也依赖 FMDB,那你要处理这些库可就太麻烦了。
  • 如果你选择使用 pod 'FMDB' 的方式,那你只需要去判断原生工程里是否有对应的依赖,有的话就不再声明依赖,这种还好。

3、静态库与动态库

生成的 xcframework 中,有些是静态库,有些是动态库

如图所示,静态库必须选 Do Not Embed,动态库必须选 Embed & Sign

如果你全选了 Embed & Sign,那么你就无法启动 App 了,如下图所示

该问题的相关 issue: github.com/flutter/flu...

所以为了避免这种情况,我们就必须得选对 Embed 选项,可以使用 file 命令去判断 xcframework 是静态库还是动态库

shell 复制代码
file FlutterPluginRegistrant.xcframework/ios-arm64/FlutterPluginRegistrant.framework/FlutterPluginRegistrant
FlutterPluginRegistrant.xcframework/ios-arm64/FlutterPluginRegistrant.framework/FlutterPluginRegistrant: 
current ar archive random library // 静态库

file url_launcher_ios.xcframework/ios-arm64/url_launcher_ios.framework/url_launcher_ios
url_launcher_ios.xcframework/ios-arm64/url_launcher_ios.framework/url_launcher_ios: 
Mach-O 64-bit dynamically linked shared library arm64 // 动态库

这部分判断逻辑只能交给脚本处理了,因为当数量起来后你就会体验到什么叫崩溃,别问我是怎么知道的 😭

4、直接崩溃

后面我直接用脚本判断 Flutter 插件依赖了哪些原生第三方,将它们统一在原生工程内声明依赖,在一些情况下这也是很危险的,如 connectivity_plus 这个 Flutter 插件依赖了 ReachabilitySwift,你必须得使用 Reachability.xcframework 二进制嵌入的方式,否则运行就崩~

shell 复制代码
dyld[31764]: Symbol not found: _$s12ReachabilityAAC10ConnectionO4wifiyA2DmFWC
  Referenced from: <8142F86E-4C9C-3513-AD29-D3522FC6677F> /Users/lxf/Library/Developer/Xcode/DerivedData/xxx/connectivity_plus.framework/connectivity_plus
  Expected in:     <DA318000-9A97-35AD-87EA-7C5B635DE010> /Users/lxf/Library/Developer/xxx.app/Frameworks/Reachability.framework/Reachability

三、分析

后来仔细想想,Shorebird 的热更新是针对 Dart 代码,跟原生无关,能不能按原来的 Cocoapods 方式去集成 Flutter.xcframeworkApp.xcframework 以及插件依赖的原生第三方库呢?

答案是可以的,来看看 install_all_flutter_pods 方法

ruby 复制代码
def install_all_flutter_pods(flutter_application_path = nil)
  ...
  flutter_application_path ||= File.join('..', '..')
  # 生成 .ios/Flutter/Flutter.podspec
  install_flutter_engine_pod(flutter_application_path)
  # 集成 插件依赖的原生库 Pods
  install_flutter_plugin_pods(flutter_application_path)
  # 编译并集成 Flutter.xcframework 和 App.xcframework
  install_flutter_application_pod(flutter_application_path)
end

1、install_flutter_engine_pod

install_flutter_engine_pod 生成的 Flutter.podspec 是假的podspec,里面没啥实质内容,仅代表 Flutter.xcframework,为什么要这么做呢?因为一些 Flutter 插件声明需要依赖 Flutter,如:

ruby 复制代码
Pod::Spec.new do |s|
  s.name             = 'sqflite'
  ...
  s.dependency 'Flutter'
  s.dependency 'FMDB', '>= 2.7.5'
  ...
end

如果没有这个 Flutter.podspec,那么执行 pod install 就会从 CocoaPods trunk 下载 Flutter 了。

2、install_flutter_application_pod

install_flutter_application_pod 会去编译 Flutter.xcframeworkApp.xcframework,并将它们并集到我们的原生工程内。不过这两玩意我们用 Shorebird Release 去生成了,所以这个方法我们用不上。

我们可以结合上述的 Flutter.podspec 的作用,修改它内部的依赖声明,从而实现通过 Cocoapods 的方式来集成 Flutter.xcframeworkApp.xcframework

diff 复制代码
Pod::Spec.new do |s|
s.name             = 'Flutter'
s.version          = '1.0.0'
s.summary          = 'A UI toolkit for beautiful and fast apps.'
s.homepage         = 'https://flutter.dev'
s.license          = { :type => 'BSD' }
s.author           = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' }
s.source           = { :git => 'https://github.com/flutter/engine', :tag => s.version.to_s }
s.ios.deployment_target = '11.0'
# Framework linking is handled by Flutter tooling, not CocoaPods.
# Add a placeholder to satisfy `s.dependency 'Flutter'` plugin podspecs.
#
# 以上到这句都是原来的,将这句注释掉
+ # s.vendored_frameworks = 'path/to/nothing'
# 新增下面这句,声明依赖当前目录下的 Flutter.xcframework 和 App.xcframework
+ s.vendored_frameworks = 'Flutter.xcframework', 'App.xcframework'
end

生成的所有 xcframework 所在路径为: xxx/flutter_module/build/ios/framework/Release, 我们自己创建的 Flutter.podspec 中的依赖是相对路径,所以该 podspec 也是跟 xcframework 放到一起,当然也可以根据你自己的习惯进行调整。

3、install_flutter_plugin_pods

install_flutter_plugin_pods 会将 Flutter 插件依赖的原生库集成到我们的原生工程,这正是我们需要的。

不过如果你直接将 Podfile 中的 install_flutter_application_pod 给替换成 install_flutter_plugin_pods ,执行 pod install 时是会报如下错误的:

shell 复制代码
pod install

[!] Invalid `Podfile` file: undefined method `flutter_relative_path_from_podfile' for #<Pod::Podfile:0x000000010e74c520 @defined_in_file=#<Pathname:/Users/lxf/xxx/Podfile>, @internal_hash={}, @root_target_definitions=[#<Pod::Podfile::TargetDefinition label=Pods>], @current_target_definition=#<Pod::Podfile::TargetDefinition label=Pods>>

  relative = flutter_relative_path_from_podfile(export_script_directory)

也就是找不到 flutter_relative_path_from_podfile 方法,因为该方法在并不在你的 Flutter 模块的 podhelper.rb 中,而是在 packages/flutter_tools/bin/podhelper.rb

至于为什么原来的 install_all_flutter_pods 方法不会报错,是因为在该方法内先引用了 flutter_tools/bin/podhelper.rb

关键代码如下:

ruby 复制代码
def install_all_flutter_pods(flutter_application_path = nil)
  ...
  # 就是这句
  require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

  flutter_application_path ||= File.join('..', '..')
  install_flutter_engine_pod(flutter_application_path)
  install_flutter_plugin_pods(flutter_application_path)
  install_flutter_application_pod(flutter_application_path)
end

所以我们可以如法炮制,在 install_flutter_plugin_pods 方法中加入 require 这一行代码,以解决上述错误。

diff 复制代码
def install_flutter_plugin_pods(flutter_application_path)
+  require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
  flutter_application_path ||= File.join('..', '..')
  ...
end

老是这么改也不是个办法,所以我提了个 PR: github.com/flutter/flu...

PR 现已合并,应该会在 3.16.9 及之后的版本中生效。

经过验证,该方案是可行的,下面我们来看看如何调整原生工程和 ShorebirdiOS 混编下如何使用吧。

四、原生工程调整

Podfile 文件中,将 Flutter 壳工程的源码依赖方式调整为二进制依赖

diff 复制代码
- install_all_flutter_pods(flutter_application_path)
+ # 源码集成
+ # install_all_flutter_pods(flutter_application_path)

+ # 二进制集成
+ pod 'Flutter', path: 'xxx/flutter_modules/build/ios/framework/Release'
+ install_flutter_plugin_pods(flutter_application_path)
  1. 声明 Flutter 依赖,用于集成 Flutter.xcframeworkApp.xcframework
  2. Option A 方式所需要的代码统统保留,只需要将 install_all_flutter_pods 替换为 install_flutter_plugin_pods,用于集成 Flutter 插件所依赖的原生第三方库

五、创建 Shorebird Release

打发布包的时候操作,在 Flutter 工程目录下执行

shell 复制代码
cd xx/xx/flutter_modules

# 7.0.0+2: 版本号+build版本号
shorebird release ios-framework-alpha --release-version 7.0.0+2

该命令内部会去执行 flutter build ios-framework --no-debug --no-profile ...,并且使用的是 Shorebird 魔改的 Flutter 引擎!

版本号可以在如下图所示进行查看

ShoreBird 的内部逻辑会去以这个版本号组合,向服务器请求判断是否存在相应版本的相关补丁!

执行完成后,在 Shorebird 控制台上可以看到相应的项

在命令执行前,请确保不存在 7.0.0+2Release,如果有的话,请先删除

六、创建 Shorebird Patch

紧急修复线上包的bug时操作,在 Flutter 工程目录下执行

shell 复制代码
shorebird patch ios-framework-alpha --release-version 7.0.0+2

注:版本号与上述的 release 命令中使用的要保持一致!

执行完成后,在 Shorebird 控制台上点击对应的 Release 项,进去后可以看到相应的补丁

看看这个补丁大小,我们再来看看安卓的补丁大小

一样的修改,安卓的补丁大小不到 2 MBiOS 的补丁大小高达 54.83 MB 😂

七、热更新验证

官方文档上就只是说重启 App 查看补丁是否生效,并没有说明失败了该如果排查问题~

1、在执行完 shorebird release 命令并完成上述原生工程的调整后,将原生工程的编译模式调整为 Release 进行编译。

此时会依赖的 flutter_modules/build/ios/framework/Release 下的 xcframework,备份为 Release_release

2、关闭 App,打 patch,注意,此时 flutter_modules/build/ios/framework/Release 下的内容会被清空并重新创建。

3、打 patch 后,将 Release_release 改回 ReleaseXcode 重新运行 App,一切正常的话即可看到变化。

无论成功还是失败,Xcode 的控制台都会有相应的输出

成功

shell 复制代码
2024-01-03 18:37:55.838328+0800 xxx[623:70498] [VERBOSE0:shorebird.cc(151)] Shorebird updater: no active patch.
2024-01-03 18:37:55.838424+0800 xxx[623:70498] [VERBOSE0:shorebird.cc(155)] Starting Shorebird update
[00:00:00.002] (1701cb000) INFO   Sending patch check request: PatchCheckRequest { app_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", channel: "stable", release_version: "7.0.0+2", patch_number: None, platform: "ios", arch: "aarch64" }

[00:00:30.871] (1701cb000) INFO  Patch 1 successfully installed.
[00:00:30.871] (1701cb000) INFO   Update result: Update installed

失败

可以搜索关键字 PatchCheckRequest 定位

shell 复制代码
2024-01-03 18:37:55.838328+0800 xxx[623:70498] [VERBOSE0:shorebird.cc(151)] Shorebird updater: no active patch.
2024-01-03 18:37:55.838424+0800 xxx[623:70498] [VERBOSE0:shorebird.cc(155)] Starting Shorebird update
[00:00:00.002] (1701cb000) INFO   Sending patch check request: PatchCheckRequest { app_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", channel: "stable", release_version: "7.0.0+2", patch_number: None, platform: "ios", arch: "aarch64" }

[00:00:30.871] (1701cb000) ERROR  Update failed: error decoding response body: operation timed out
Caused by:
    operation timed out

[00:00:30.871] (1701cb000) INFO   Update thread finished with status: Update had error

该失败是因为国行机特有的网络权限导致的,开启 Shorebird 的自动检查更新的话,会在网络权限被赋予前去请求,结果就是失败,所以需要关闭自动检查更新,使用 shorebird_code_push 去延迟检查。

八、脚本

由于我们日常研发还是使用的是源码依赖的方式,只会在打最终测试包时才需要去做上述的调整操作,所以这里用我比较熟悉的 Python 去制作了简易的脚本,并结合 Jenkins 来辅助完成这种万年不变的无聊步骤

脚本已上传至 Github: github.com/LinXunFeng/...

看官可自取修改~

switch_flutter_integrate.py

切换 Flutter 项目的集成方式

shell 复制代码
# 二进制依赖
python switch_flutter_integrate.py -p '原生工程路径' -m 'binary' -f 'ios'

# 源码依赖
python switch_flutter_integrate.py -p '原生工程路径' -m 'source' -f 'ios' 

shorebird.py

自动获取版本号,并执行 Shorebird 相关命令

shell 复制代码
# release
python shorebird.py -p '原生工程路径' -s 'Flutter工程路径' -m release -f ios

# patch
python shorebird.py -p '原生工程路径' -s 'Flutter工程路径' -m patch -f ios

需要注意的是,xcodeprojtarget 的名字被我固定写成 OCProject,如下代码中高亮的那两行,大家请先将其修改为自己的工程名再使用 shorebird.py

diff 复制代码
def handle_ios():
    """
    处理iOS项目
    """
    # 1. 读取主版本号
    # 请将 OCProject 修改为你们自己的工程名
+    xcodeproj_path = os.path.join(project_path, 'OCProject.xcodeproj')
    version = ReleaseVersionTool.fetch_project_version(
        xcodeproj_path=xcodeproj_path,
+        target_name='OCProject',
    )

由于我比较懒,就不改成通用的了 😏

九、最后

虽然 iOS 的热更新能用,但也仅仅只是能用,应用于很简单的应用程序,运行起来没有太明显的卡顿感知,但是稍微大点就可以感知到了,卡到怀疑人生那种,相比安卓端的没有任何性能损耗,iOS端的还需要再等等,毕竟现在 iOS 还是 Alpha 版本,相信不久将来 Shorebird 团队会解决该问题。

具体关于安卓和 iOS 两端之间的实现区别可以在这个 issue 中查看 github.com/shorebirdte...

本篇到此结束,感谢大家的支持,我们下次再见! 👋

如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有 iOS 技术,还有 AndroidFlutterPython 等文章, 可能有你想要了解的技能知识点哦~

相关推荐
一只大侠的侠10 小时前
Flutter开源鸿蒙跨平台训练营 Day 10特惠推荐数据的获取与渲染
flutter·开源·harmonyos
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅13 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
未来侦察班14 小时前
一晃13年过去了,苹果的Airdrop依然很坚挺。
macos·ios·苹果vision pro
renke336414 小时前
Flutter for OpenHarmony:色彩捕手——基于HSL色轮与感知色差的交互式色觉训练系统
flutter
崔庆才丨静觅14 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment14 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端