Flutter 显示刘海(Display Cutout)与屏幕旋转集成测试:display_cutout_rotation 深度解析
本文围绕 Flutter 仓库中的 display_cutout_rotation 集成测试展开:它验证 Android 设备的显示刘海(cutout)在竖屏/横屏旋转时,能否通过 MediaQuery.of(context).displayFeatures 被 Dart 应用代码正确感知。读完本文,你将掌握该测试的本地运行方式、devicelab 自动化调度原理、测试用例的断言逻辑,以及从引擎侧 DisplayFeature 类型到框架侧 API 的完整数据链路。
测试目标:验证刘海在旋转时位置的实时更新
Android 自 API 28 起在开发者选项中提供"模拟刘海"(display cutout emulation)能力,API 30 起该行为稳定可用。display_cutout_rotation 测试的核心场景是:当设备开启模拟刘海后,旋转屏幕时,原本位于顶部的刘海(竖屏)应报告为位于左侧(landscapeLeft),应用侧的 DisplayFeature 数据必须随之正确更新。
测试假设设备上已启用开发者选项 com.android.internal.display.cutout.emulation.tall,并在以下三种场景中断言:
- 竖屏(
portraitUp)时,存在唯一一个 cutout 类型特征,且其bounds.top == 0(刘海贴顶); - 横屏向左(
landscapeLeft)时,同样只有一个 cutout,且bounds.left == 0(刘海贴左侧); - 在同一 App 实例内从竖屏旋转到横屏,特征查询结果能从"顶部"切换为"左侧",验证旋转过程中数据的动态更新。
项目结构与运行方式
该测试工程位于 dev/integration_tests/display_cutout_rotation/,是一个标准的 Flutter 集成测试应用,由三部分组成:
- lib/main.dart:被测的极简 App,负责把当前刘海状态以文本形式渲染出来;
- integration_test/display_cutout_test.dart:基于
integration_test包的端到端测试用例; - pubspec.yaml:依赖
flutter、flutter_test、flutter_driver与integration_test(均来自 SDK),并设置了resolution: workspace参与仓库的 workspace 解析,publish_to: 'none'表明其为私有工程。
按照 README 的说明,本地运行有两种方式:
# 方式一:在工程目录内直接运行集成测试
cd dev/integration_tests/display_cutout_rotation
flutter test integration_test/display_cutout_test.dart
# 方式二:通过 devicelab 测试运行器执行
cd dev/devicelab
dart bin/test_runner.dart test -t android_display_cutout
方式二走的是 devicelab 任务体系,android_display_cutout 任务的真实定义在 dev/devicelab/lib/tasks/integration_tests.dart 中的 createDisplayCutoutTest()。源码注释明确给出了适用前提:
- 设备必须启用开发者设置,且为 Android API 30 及以上(运行时通过
getprop ro.build.version.sdk校验,SDK 小于 30 直接判失败); - 仅支持 Android 设备,非
AndroidDevice直接抛TaskResult.failure; - 任务
setup阶段执行cmd overlay enable com.android.internal.display.cutout.emulation.tall在设备上注入"合成刘海"(Synthetic notch),注释特别提示:该命令会导致正在运行的 Android Activity 被重新创建; tearDown阶段执行cmd overlay disable移除合成刘海,避免污染后续测试。
也就是说,README 中"假设设备已启用 com.android.internal.display.cutout.emulation.tall"这一前提,在 CI 路径下由 devicelab 任务自动完成注入与清理,本地手动运行则需自行开启。
被测应用:把刘海状态可视化的最小实现
lib/main.dart 中只有约 50 行代码,MyApp 的 build 方法核心逻辑如下:
final List<DisplayFeature> displayFeatures = MediaQuery.of(context).displayFeatures;
displayFeatures.retainWhere(
(DisplayFeature feature) => feature.type == DisplayFeatureType.cutout,
);
// ...
final Rect cutout = displayFeatures[0].bounds;
if (cutout.top == 0) {
text = 'CutoutTop';
} else if (cutout.left == 0) {
text = 'CutoutLeft';
} else {
text = 'CutoutNeither';
}
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Text('Cutout status: $text', key: Key(text)),
);
源码注释解释了这段"对测试而言并非必需"的逻辑存在的目的:它在人工视觉调试或观看远端设备录像时很有用——屏幕上直接显示 CutoutTop / CutoutLeft / CutoutNone / CutoutMany / CutoutNeither 之一的状态文本,同时 Text 组件还携带一个与内容一致的 Key。测试代码正是依赖这个 Text 元素通过 find.byType(Text) 定位 BuildContext,进而读取 MediaQuery 数据,因此注释强调"Tests assume there is some text element displayed"。
测试用例详解:旋转、轮询与断言
integration_test/display_cutout_test.dart 使用 IntegrationTestWidgetsFlutterBinding.ensureInitialized() 初始化后,包含三个 testWidgets 用例与一个 tearDown:
1. 竖屏时刘海在顶部
await setOrientationAndWaitUntilRotation(tester, DeviceOrientation.portraitUp);
await tester.pumpWidget(const MyApp());
final BuildContext context = tester.element(find.byType(Text));
final Iterable<DisplayFeature> displayFeatures = getCutouts(tester, context);
expect(displayFeatures.length, 1, reason: 'Single cutout display feature expected');
expect(displayFeatures.first.bounds.top, 0, ...);
断言的 reason 写得很有工程经验:如果 top 不为 0,提示"测试设备上是否已有真实摄像头刘海或 window inset"——即合成刘海与真机硬件刘海可能叠加,导致断言失败时能快速定位原因。
2. 横屏向左时刘海在左侧
与用例 1 对称,旋转至 DeviceOrientation.landscapeLeft 后断言 displayFeatures.first.bounds.left == 0。
3. 旋转过程中刘海位置动态更新
这是唯一不重新 pumpWidget 新实例、而是复用同一 MyApp 实例的用例:先验证竖屏下 top == 0,再调用 setOrientationAndWaitUntilRotation 切到 landscapeLeft 并重新 pumpWidget,然后重新获取 BuildContext(tester.element(find.byType(Text)) 再次查询)再断言 left == 0。它验证的关键点是:旋转后 MediaQuery.displayFeatures 的数据不是陈旧的,会随窗口布局变化被框架刷新。
旋转等待与特征过滤的两个辅助函数
旋转等待函数体现了对底层异步链路的理解,源码注释写道:"Rotations have an async communication to engine which then has an async communication to the android operating system"——Dart 层发起旋转请求后,经由引擎与 Android 系统两次异步通信才能完成,因此需要轮询:
Future<void> setOrientationAndWaitUntilRotation(
WidgetTester tester,
DeviceOrientation orientation,
) async {
await SystemChrome.setPreferredOrientations(<DeviceOrientation>[orientation]);
// 将 DeviceOrientation 映射为期望的 Orientation(portrait/landscape)
while (true) {
final BuildContext context = tester.element(find.byType(Text));
if (context.mounted && expectedOrientation == MediaQuery.of(context).orientation) {
break;
}
await tester.pumpAndSettle();
}
}
getCutouts 则封装了对 displayFeatures 的过滤逻辑:只保留 feature.type == DisplayFeatureType.cutout 的特征,排除 fold、hinge 等其他类型:
Iterable<DisplayFeature> getCutouts(WidgetTester tester, BuildContext context) {
final List<DisplayFeature> displayFeatures = MediaQuery.of(context).displayFeatures;
return displayFeatures.where(
(DisplayFeature feature) => feature.type == DisplayFeatureType.cutout,
);
}
tearDown 中调用 SystemChrome.setPreferredOrientations(<DeviceOrientation>[]) 恢复设备默认方向,注释说明这是为了"避免测试污染"(test pollution),确保后续用例或同一设备上的其他任务不受方向设置影响。
底层原理:DisplayFeature 数据从 Android 到 Dart 的链路
从源码结构看,DisplayFeature 在本仓库中是一条贯穿 Android 嵌入层、引擎 Dart 层的数据模型:
- Dart 引擎侧:engine/src/flutter/lib/ui/platform_dispatcher.dart 定义了
DisplayFeature类,文档注释明确 "This is populated only on Android",包含三个字段:bounds:以逻辑像素表示、被硬件特征占据的区域;在双屏设备上坐标系从"左屏或上屏"的左上角 (0,0) 起算,横跨两块屏幕及中间视觉空间;type:DisplayFeatureType,其中hinge与cutout会遮挡显示区域,fold不会;state:折叠/铰链的姿态(如postureFlat、postureHalfOpened),而对cutout类型恒为DisplayFeatureState.unknown——构造函数中甚至有断言强制这一约束(!identical(type, cutout) || identical(state, unknown));
- Android 引擎侧:engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/renderer/FlutterRenderer.java 中存在对应的 Java
DisplayFeature类与DisplayFeatureType枚举,注释说明其"基于androidx.window.layout.DisplayFeature,并增加了对 cutout 的支持",其中CUTOUT(3)明确对应android.view.DisplayCutout,即 Android 系统对摄像头/传感器开孔区域的原生抽象。
这解释了测试断言的坐标系语义:bounds.top == 0 表示切出区域紧贴窗口顶部,bounds.left == 0 表示紧贴左侧——正是合成刘海在竖屏/横屏下的预期位置。
关键前提与限制
结合测试源码与 devicelab 任务实现,可以归纳出运行和解读该测试的几个约束:
| 约束 | 来源 | 说明 |
|---|---|---|
| 仅 Android | integration_tests.dart | device is! AndroidDevice 时任务直接失败 |
| Android API 30+ | integration_tests.dart | 开发者设置项在 API 28 加入、行为在 API 30 才完整 |
| 需开发者选项 + 合成刘海 | README | 手动运行时须先启用 com.android.internal.display.cutout.emulation.tall;devicelab 路径由 setup 自动注入 |
| 断言对真实硬件刘海敏感 | display_cutout_test.dart | 真机自带的摄像头开孔会叠加在合成刘海之上,导致 top/left == 0 断言或"恰好一个 cutout"断言失败,断言 reason 中已给出排查提示 |
cutout 的 state 恒为 unknown |
platform_dispatcher.dart | 引擎层构造断言保证,姿态信息仅对折叠类特征有意义 |
小结
display_cutout_rotation 用不到 200 行代码构建了一个高价值的端到端回归场景:通过 devicelab 注入合成刘海、通过 SystemChrome.setPreferredOrientations + 轮询模拟真实旋转、再通过 MediaQuery.displayFeatures 断言 cutout 的 bounds 随方向正确迁移。它既验证了 Android 嵌入层到引擎再到框架的 DisplayFeature 数据通路,也覆盖了旋转这类跨异步边界的时序场景,是理解 Flutter 如何感知折叠屏/刘海等硬件显示特征的一个精确而完整的入口。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00