首页
/ Flutter iOS Add-to-App UIScene 生命周期集成测试:devicelab 如何验证 App 与插件的迁移组合

Flutter iOS Add-to-App UIScene 生命周期集成测试:devicelab 如何验证 App 与插件的迁移组合

2026-09-06 14:23:43作者:冯爽妲Honey

本文围绕 Flutter 仓库中 dev/integration_tests/ios_add2app_uiscene 目录的官方集成测试展开:它通过「原生 App 是否迁移到 UIScene」×「Flutter 插件是否支持 Scene 生命周期」的全部组合,系统性地验证 Flutter add-to-app 场景下生命周期事件(Scene 事件 vs Application 事件)的下发是否正确。读完后,你将理解这套测试的目录组织、运行方式、场景注入机制(FileReplacements),以及如何为它新增一个测试场景。

1. 测试要解决的问题

当 Flutter 以 Module 形式嵌入原生 iOS 应用(add-to-app)时,原生宿主 App 的 AppDelegate / SceneDelegate 负责向 Flutter 引擎与插件分发生命周期回调。iOS 的 UIScene 机制引入后,一个真实工程里可能出现「宿主 App 已迁移到 UIScene 但插件还没迁移」这类版本错位的情况,此时插件到底能收到哪些事件(scene:willConnectToSession: 还是 applicationDidFinishLaunchingWithOptions:),直接影响插件的启动逻辑正确性。

该目录中的资产与模板正是为一个 devicelab 集成测试服务的:测试任务定义 会自动创建 Flutter Module(my_module)、Flutter 插件(my_plugin)和原生 iOS 宿主 App,嵌入插件后逐一运行各个场景,并在测试结束后删除生成的工程。

官方说明 列出的四个核心验证场景是:

  • 原生 App 已迁移到 UIScene,但 Flutter 插件未迁移;
  • 原生 App 与 Flutter 插件均已迁移;
  • 原生 App 未迁移,但 Flutter 插件已迁移;
  • 原生 App 与 Flutter 插件均未迁移。

每个场景都会检查在该组合下,插件收到的是 scene-based 还是 application-based 的生命周期事件。

2. 目录结构与各目录职责

dev/integration_tests/ios_add2app_uiscene/ 下的四个子目录分别承担「宿主模板」「场景覆盖文件」「Flutter 应用模板」「Flutter 插件模板」四种角色:

目录 职责 典型内容
xcode_uikit_swift(README 中的称谓) UIKit + Swift 原生宿主 App 模板,作为 Flutter Module 的宿主 实际对应仓库中的 NativeUIKitSwiftExperiment 工程,含 AppDelegate.swiftSceneDelegate.swiftViewController.swiftInfo.plist 与 UI 测试文件
native/ 用于按场景替换 Xcode 工程内文件的 Swift 覆盖模板 不同实现的 AppDelegate-*.swiftSceneDelegate-*.swiftViewController-*.swift、各版本 Info-*.plistUITests-*.swift
flutterapp/ Flutter Module(my_module)的 main.dartpubspec.yaml 模板 main-LifeCycleTestpubspec-LifeCycleTest.yaml
flutterplugin/ Flutter 插件(my_plugin)模板,含迁移/未迁移两版 iOS 实现 LifecyclePlugin-migrated.swiftLifecyclePlugin-unmigrated.swift 及 Dart 侧三个模板文件

README 特别提醒:Dart 模板文件不带 .dart 后缀,目的是让分析器忽略它们——这些模板单独拿出来无法编译,只有被复制进 Module、App 或插件后才能成为有效 Dart 代码。

此外仓库中还有一个 NativeSwiftUIExperiment SwiftUI 宿主工程,用于覆盖「SwiftUI 宿主 + UIScene」的组合场景(见下文场景定义)。

3. 运行测试:devicelab 命令与参数

该测试设计为作为 Flutter devicelab 测试运行。按照 README,在 flutter/dev/devicelab 目录下执行:

../../bin/cache/dart-sdk/bin/dart bin/test_runner.dart test -t module_uiscene_test_ios

也可以配合本地引擎构建运行:

../../bin/cache/dart-sdk/bin/dart bin/test_runner.dart test -t module_uiscene_test_ios --local-engine <your_local_engine> --local-engine-host host_debug

默认情况下,测试生成的 Flutter Module、插件与原生 iOS App 会在测试结束时被删除。如果想把生成的工程保留到指定目录以便排查,可以传入 --task-args

../../bin/cache/dart-sdk/bin/dart bin/test_runner.dart test -t module_uiscene_test_ios --task-args destination=[/path/to/copy/destination]

任务源码 可以看到这些参数的实际解析逻辑:

  • destination:指定保留目录时,生成的工程会复制到 <destination>/flutter_uiscene_test_generated_project,且该目录不会被清理(destinationOverride 为真时跳过 rmTree);
  • name:只运行指定名称的单个场景(scenarioName != testName 时跳过);
  • type:选择 Xcode 工程类型,swiftuiuikit-swift,不传则两种都测。

4. 测试执行流程:从创建工程到 xcodebuild

main() 的整体流程可以归纳为以下步骤(对应 module_uiscene_test_ios.dart):

  1. 解析参数并准备输出目录:未指定 destination 时在系统临时目录创建 flutter_module_test. 开头的临时目录。
  2. 创建 Flutter Module:执行 flutter create --org dev.flutter.devicelab --template=module my_module
  3. 创建 Flutter 插件:执行 flutter create --org dev.flutter.devicelab --template=plugin --platform=ios my_plugin
  4. 在 iPad 模拟器上循环测试testWithNewIOSSimulator 创建一台 iPad Pro(11 英寸,第 3 代)模拟器,对每种 XcodeProjectTypeUIKitSwiftSwiftUI):
    • 从模板目录整体复制 Xcode 工程到目标目录(recursiveCopy);
    • 遍历 Scenarios 类给出的场景 Map,对每个场景执行 FileReplacements 的文件替换、flutter build ios --config-onlypod install、再用 xcodebuild test 运行 UI 测试;
    • 场景之间重置被替换的文件:如果不是按 name 定向运行单个场景,则逐个调用 replacement.reset() 还原原始内容,保证下一个场景从干净的模板状态开始。
  5. 汇总结果:任一场景失败即返回 TaskResult.failure,并提示在日志中搜索 ** TEST FAILED **;失败时 xcodebuild.xcresult 会被压缩上传到 devicelab 的 dump 目录(见 _uploadTestResults)。

其中 pod install 通过环境变量 COCOAPODS_DISABLE_STATS=true 关闭 CocoaPods 统计上报以降低延迟——源码注释注明了这一点对应一个历史 issue。

5. 场景注入机制:FileReplacements 与模板变量

整个测试可复用的关键在于 FileReplacements 类:

  • fromScenario 把场景 Map(模板路径 -> 目标路径)解析为替换列表,支持四个占位变量:$TEMPLATE_DIR(本目录)、$XCODE_PROJ_DIR(生成的 Xcode 工程)、$PLUGIN_DIR$APP_DIR
  • replace() 在覆盖目标文件前保存原始内容(若目标文件不存在则记录为 null),随后把模板文件复制过去;
  • reset() 用保存的原始内容还原;若目标原本是新建文件,则直接删除,从而把工程恢复为初始模板状态。

每个场景本质就是一个「模板文件 -> 工程内文件」的映射。例如 basicLifecycleScenarios 中最典型的组合(摘自 场景定义):

// 宿主已迁移到 scenes,插件未迁移时,期望以 application 事件作为回退。
'AppMigrated-FlutterSceneDelegate-PluginNotMigrated': <String, String>{
  ...sharedLifecycleFiles,
  r'$TEMPLATE_DIR/native/SceneDelegate-FlutterSceneDelegate.swift':
      r'$XCODE_PROJ_DIR/NativeUIKitSwiftExperiment/SceneDelegate.swift',
  r'$TEMPLATE_DIR/flutterplugin/ios/LifecyclePlugin-unmigrated.swift':
      r'$PLUGIN_DIR/ios/my_plugin/Sources/my_plugin/MyPlugin.swift',
  r'$TEMPLATE_DIR/native/UITests-ApplicationEvents-AppMigrated.swift':
      r'$XCODE_PROJ_DIR/NativeUIKitSwiftExperimentUITests/NativeUIKitSwiftExperimentUITests.swift',
},

场景 Map 由几个共享片段拼装而成:

  • sharedAppLifecycleFiles:替换 my_modulemain.dartpubspec.yaml 为生命周期测试版本;
  • sharedPluginLifecycleFiles:替换插件 Dart 侧的 my_plugin.dartmy_plugin_method_channel.dartmy_plugin_platform_interface.dart
  • sharedLifecycleFiles:在前两者基础上再固定 UIKit 宿主的 AppDelegate(继承 FlutterAppDelegate 并持有显式 FlutterEngine)与 ViewController(引擎从 AppDelegate 获取);
  • sharedStateRestorationFiles:状态恢复场景专用的文件组合。

按宿主类型区分的场景族包括:

  • basicLifecycleScenarios(UIKit):上表中的六种「AppMigrated/AppNotMigrated × FlutterSceneDelegate/FlutterSceneLifeCycleProvider × PluginMigrated/PluginNotMigrated」基础组合;
  • multiSceneScenarios:开启多 Scene(UIApplicationSupportsMultipleScenes)后,rootViewController 为 FlutterViewController 或手动注册引擎(Storyboard / 无 Storyboard)三种布局;
  • implicitEngineDelegateScenarios:使用 Storyboard 隐式创建的 FlutterEngine 的若干变体,包括注册到 FlutterLaunchEngine 的差异场景;
  • stateRestorationScenarios:状态恢复在迁移与未迁移两种宿主下都应工作;
  • swiftUIScenarios(SwiftUI 宿主):SwiftUI-FlutterSceneDelegateSwiftUI-FlutterSceneLifeCycleProvider 两个场景。

6. 「迁移」与「未迁移」在代码层面的区别

6.1 插件侧:注册方式不同

迁移版插件 的类声明同时遵循 FlutterPluginFlutterSceneLifeCycleDelegate,并在注册时多了一行 registrar.addSceneDelegate(instance)

public class MyPlugin: NSObject, FlutterPlugin, FlutterSceneLifeCycleDelegate {
  public static func register(with registrar: FlutterPluginRegistrar) {
    ...
    registrar.addApplicationDelegate(instance)
    registrar.addSceneDelegate(instance)   // 关键差异:同时接收 Scene 事件
  }
}

未迁移版插件 只遵循 FlutterPlugin,仅调用 registrar.addApplicationDelegate(instance)。两个版本都实现了完整的事件记录方法(Application 一组、Scene 一组),并把事件名追加到 events 数组,Dart 侧通过 my_plugin 方法通道的 getLifecycleEvents 一次性取回、以换行符拼接返回。

6.2 宿主侧:SceneDelegate 的两种写法

  • SceneDelegate-FlutterSceneDelegate.swift:直接继承 FlutterSceneDelegate,类体为空——由 Flutter 引擎自带的基类完成 Scene 生命周期向插件的分发;
  • SceneDelegate-FlutterSceneLifeCycleProvider.swift:继承 UIResponder 并遵循 UIWindowSceneDelegateFlutterSceneLifeCycleProvider,内部持有 FlutterPluginSceneLifeCycleDelegate,手动把 scene(_:willConnectTo:options:)sceneDidDisconnectsceneWillEnterForeground 等回调转发给 sceneLifeCycleDelegate——对应「不继承 Flutter 基类、只接入生命周期提供器」的集成方式。

6.3 宿主 App 侧:Info.plist 决定 Scene 是否生效

「App 是否迁移到 UIScene」由 Info.plist 中的 UIApplicationSceneManifest 决定。对比两个模板:

  • Info-unmigrated.plist<dict/> 为空,完全没有 Scene Manifest,系统按传统单 Window 生命周期运行;
  • Info-migrated-no-config.plist:声明了 UIApplicationSceneManifestUIApplicationSupportsMultipleScenesfalseUISceneConfigurations 为空字典。

多 Scene 场景则使用 Info-MultiSceneEnabled-Storyboard.plist / Info-MultiSceneEnabled-NoStoryboard.plist

7. 断言逻辑:UI 测试如何校验事件序列

各场景的 UITests-*.swift 模板会替换宿主工程的 UI 测试文件,其验证手法统一:

  1. Flutter 侧界面(见 main-LifeCycleTest)只有一个「Get Lifecycle Events」按钮和一个展示事件列表的 Text,点击按钮即调用插件的 getLifecycleEvents()
  2. UI 测试启动 App、点击按钮,然后用 NSPredicate 断言界面上的静态文本与预期事件序列(换行拼接)完全相等,再模拟 Home 键压后台、重新激活 App、再次读取断言。

以全迁移场景的 UITests-SceneEvents.swift 为例,预期事件为:

applicationDidFinishLaunchingWithOptions
sceneWillConnect
sceneWillEnterForeground
sceneDidBecomeActive

压后台并回到前台后,序列追加:

sceneWillResignActive
sceneDidEnterBackground
sceneWillEnterForeground
sceneDidBecomeActive

这说明:即便宿主 App 已迁移到 Scene,applicationDidFinishLaunchingWithOptions 仍会先触发一次,而前后台切换走的是 Scene 事件链——这正是该测试要锁定的行为契约。其他断言模板(如 UITests-ApplicationEvents-AppNotMigrated.swiftUITests-SceneEvents-ApplicationLaunchEvents.swift)则对应各自场景下预期收到的事件序列。

8. 如何新增一个测试场景

README 给出的扩展步骤,结合源码可落地为以下操作:

  1. Scenarios 类 对应的场景 Map(uiKitSwiftScenariosswiftUIScenarios)中新增一条记录,key 为场景名;
  2. value 是 Map<String, String>:左侧为 $TEMPLATE_DIR 前缀的模板路径,右侧为 $XCODE_PROJ_DIR / $PLUGIN_DIR / $APP_DIR 前缀的目标路径,FileReplacements.fromScenario 会自动解析;
  3. 若需要新的文件内容,把模板文件放进对应子目录(native/flutterapp/flutterplugin/)——注意 Dart 模板不要加 .dart 后缀,以免被分析器当作独立代码检查;
  4. 在场景 Map 中引用这些新模板文件,并确保为场景挑选正确的 UI 测试断言模板(UITests-*.swift),因为断言序列本身就是场景预期的一部分。

场景名同时用于日志分区(section('Test Scenario $scenarioName'))和失败时的 .xcresult 压缩包命名,建议采用现有命名风格:宿主状态-集成方式-插件状态,例如 AppMigrated-FlutterSceneDelegate-PluginMigrated

9. 小结

dev/integration_tests/ios_add2app_uiscenedev/devicelab/bin/tasks/module_uiscene_test_ios.dart 共同构成了一套「模板 + 文件替换 + 模拟器 UI 测试」的集成验证框架:用同一套 Flutter Module、插件和宿主工程,通过替换 AppDelegateSceneDelegateInfo.plist、插件注册方式与 UI 测试断言,穷举 UIScene 迁移状态下的组合,并把每个组合下插件实际收到的生命周期事件序列固化为可回归的断言。对于在做 iOS add-to-app 集成、排查 Scene/Application 事件分发问题的开发者,这套场景矩阵和断言序列本身就是一份可靠的行为参照。

登录后查看全文
热门项目推荐
相关项目推荐