首页
/ Flutter 显示刘海(Display Cutout)与屏幕旋转集成测试:display_cutout_rotation 深度解析

Flutter 显示刘海(Display Cutout)与屏幕旋转集成测试:display_cutout_rotation 深度解析

2026-09-04 14:54:27作者:盛欣凯Ernestine

本文围绕 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,并在以下三种场景中断言:

  1. 竖屏(portraitUp)时,存在唯一一个 cutout 类型特征,且其 bounds.top == 0(刘海贴顶);
  2. 横屏向左(landscapeLeft)时,同样只有一个 cutout,且 bounds.left == 0(刘海贴左侧);
  3. 在同一 App 实例内从竖屏旋转到横屏,特征查询结果能从"顶部"切换为"左侧",验证旋转过程中数据的动态更新。

项目结构与运行方式

该测试工程位于 dev/integration_tests/display_cutout_rotation/,是一个标准的 Flutter 集成测试应用,由三部分组成:

  • lib/main.dart:被测的极简 App,负责把当前刘海状态以文本形式渲染出来;
  • integration_test/display_cutout_test.dart:基于 integration_test 包的端到端测试用例;
  • pubspec.yaml:依赖 flutterflutter_testflutter_driverintegration_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 行代码,MyAppbuild 方法核心逻辑如下:

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,然后重新获取 BuildContexttester.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 的特征,排除 foldhinge 等其他类型:

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) 起算,横跨两块屏幕及中间视觉空间;
    • typeDisplayFeatureType,其中 hingecutout 会遮挡显示区域,fold 不会;
    • state:折叠/铰链的姿态(如 postureFlatpostureHalfOpened),而对 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 中已给出排查提示
cutoutstate 恒为 unknown platform_dispatcher.dart 引擎层构造断言保证,姿态信息仅对折叠类特征有意义

小结

display_cutout_rotation 用不到 200 行代码构建了一个高价值的端到端回归场景:通过 devicelab 注入合成刘海、通过 SystemChrome.setPreferredOrientations + 轮询模拟真实旋转、再通过 MediaQuery.displayFeatures 断言 cutout 的 bounds 随方向正确迁移。它既验证了 Android 嵌入层到引擎再到框架的 DisplayFeature 数据通路,也覆盖了旋转这类跨异步边界的时序场景,是理解 Flutter 如何感知折叠屏/刘海等硬件显示特征的一个精确而完整的入口。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
981
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384