本文发布于公众号:stringwu的工程笔记 Flutter 折叠屏适配实战:窗口布局、双栏路由与状态连续性
1. 概述:折叠屏改变了哪些交互条件
用户在窄窗口里打开一条详情,把设备展开,再将应用拖入分屏。短短几秒,页面可能经历三种宽度、一次密度变化和一次焦点切换——用户仍然期待看到刚才的内容,输入框里的文字没有消失,返回按钮也没有突然改变含义。
这正是折叠屏适配需要解决的问题:让同一项任务在持续变化的显示空间中不中断地进行。
“当前设备是手机还是平板”很难回答页面该怎么画。一台大屏设备上的浮动窗口可能只有几百个逻辑像素宽;一块完全展开的柔性屏也可能没有需要避让的物理间隙。窗口尺寸、显示特征、设备姿态必须分别建模、分别消费。
| 形态 | 布局条件 | 交互重点 |
|---|---|---|
| Android 外屏与展开内屏 | 窗口大小、宽高比,甚至像素密度发生变化 | 保留当前路由、输入与任务状态 |
| Android 双屏跨屏 | 一个窗口包含两块显示区域和中间的铰链 | 文字、按钮和菜单避开不可交互区域 |
| Android 半折姿态 | 折痕可能形成上下或左右分区 | 根据区域大小安排主要内容和辅助操作 |
| iPad Split View、浮窗与 Stage Manager | 窗口可缩放,安全区、键盘和焦点可能独立变化 | 窄窗仍可操作,拖动窗口时持续重排 |
这里需要先澄清一个平台边界。当前 Flutter 的 DisplayFeature 文档说明,该数据只在 Android 上填充。因此,不能承诺直接用 MediaQuery.displayFeatures 识别 iOS 的物理铰链。iOS 当前可实施的目标是窗口、安全区、输入和多任务适配;铰链能力应保留为平台提供公开信息后的扩展入口。空特征列表表示没有获得相应信息,并不能证明硬件一定没有铰链.
本文按通用架构展开,示例使用 Flutter 与 Dart 公开 API,不依赖业务框架或私有组件。代码直接放在对应的讲解段落中,包括断点、导航切换、双栏宿主、嵌套路由、铰链分区和状态保存,无需跳转到其他代码文件。尺寸均为 Flutter logical pixels,后文简称逻辑像素。
2. 项目现状架构抽象:找到尺寸、路由和状态的归属
2.1 一种常见的手机应用结构
传统手机应用通常可以抽象为以下结构:
Application Bootstrap
└─ Application Services
└─ MaterialApp / Router
├─ Theme、Localizations、Overlay
└─ Root Navigator
└─ Application Shell
├─ Bottom Navigation
├─ Retained Tab Content
└─ List → Detail → Editor
应用启动时注册服务,由根导航管理页面。主页面保留多个 Tab,详情页通过路由进入。主题和公共组件提供统一尺寸,弹窗挂载在 Overlay 中。
我们的适配工作不是要重写这套结构。首先要查清楚:窗口变化时,谁负责重新布局,谁必须保持原来的生命周期。
2.2 常见的尺寸假设
在动手之前,先消除三类常见假设——它们是改造范围的直接来源:
-
按设计稿等比缩放。 设计宽 400、窗口 1,000 时,字号和按钮统一乘 2.5,得到 40 的字号和 120 的按钮。空间变大了,信息密度没有任何改善。
-
组件直接读窗口宽度。 组件被放进左侧 Pane 后,内部读取的仍是整个窗口宽度;外层包一个
SizedBox不会改变全局尺寸读取的结果。 -
按宽度整页替换。 紧凑模式返回页面 A,展开模式返回页面 B——即使数据相同,输入控制器、滚动状态和嵌套导航也会被销毁重建。
另有一个高频漏点:Overlay。正文已适配双栏,菜单弹窗仍按全窗口居中,最终落在铰链上或盖住错误的一侧。弹层归属必须在改造清单中单列
2.3 改造后的职责划分与渲染管线解耦
建议在现有应用壳中增加窗口适配层,而不是让每个页面独立判断设备形态。
View Metrics + Display Features
↓
Window Environment (尺寸、安全区,监听窗口状态,将尺寸变化收敛并防抖)
↓
Layout Policy (可用区域、导航形式、布局方式)
↓
Stable Shell + Stable Navigators
↓
Master-Detail / Responsive Grid / Rail Navigation
分层依据:几何与资源分离。 窗口环境、布局策略随窗口变化;会话、下载、媒体处理等应用服务由原有作用域管理,布局层没有理由因为窗口变宽而重启它们。
再者,从 Flutter 渲染管线的角度来看,频繁依赖 MediaQuery.sizeOf(context) 会导致在窗口拖动(如 iPad Stage Manager 或 Android 分屏 resize)时,整棵 Widget 树在 Build 阶段 被反复强制重建(Rebuild),引发严重的绘制卡顿(Jank)。
架构设计上必须区分 Build 阶段 与 Layout 阶段:
-
Window Environment:在应用根部监听窗口状态,将尺寸变化收敛并防抖。
-
Layout Policy:尽量将响应式逻辑下放到 UI 树末端的
LayoutBuilder或CustomMultiChildLayout中,在 Layout 阶段 完成局部重排与约束传递,避免上层 Widget 的不必要重建。
3. 折叠屏适配核心策略
3.1 响应式断点:先测量窗口,再测量组件
结论:断点描述空间,不描述设备;窗口类决定导航形式,容器约束决定内容列数——两者分层判断。
断点描述空间,不描述设备
可以从三个窗口宽度等级开始:
| 窗口宽度 | 类别 | 建议的默认表现 |
|---|---|---|
| 小于 600 | Compact | 底部导航、单栏内容 |
| 600 至不足 840 | Medium | 侧边导航或居中单栏,网格增加列数 |
| 840 及以上 | Expanded | 允许主从双栏或辅助面板 |
600 和 840 是起步值,不是"到点必切双栏"的硬规则。最终判据永远是扣掉侧导航、边距和铰链后的剩余空间
import 'dart:ui' show DisplayFeatureType;
import 'package:flutter/material.dart';
enum WindowClass { compact, medium, expanded }
WindowClass classifyWindow(double width) => switch (width) {
< 600 => WindowClass.compact,
< 840 => WindowClass.medium,
_ => WindowClass.expanded,
};
bool canUseTwoPanes(BoxConstraints constraints) {
const minimumMasterWidth = 320.0;
const gap = 24.0;
const minimumDetailWidth = 400.0;
return constraints.hasBoundedWidth &&
constraints.hasBoundedHeight &&
constraints.maxWidth >=
minimumMasterWidth + gap + minimumDetailWidth &&
constraints.maxHeight >= 480;
}
这两个判断服务于不同层级:窗口类别决定导航形式,canUseTwoPanes 判断当前内容容器是否放得下两栏。示例中的 320、400 和 480 是布局设计参数,实际项目应根据文字、操作区和输入需求调整。
导航容器的切换只取决于窗口类与高度:
下面的函数在窄窗口使用 NavigationBar, 在较宽且高度足够的窗口使用 NavigationRail。两种导航共享选中索引、目的地和回调;内容始终位于同一个 Row 的第二个槽位。
Widget buildAdaptiveNavigation({
required BuildContext context,
required int selectedIndex,
required List<NavigationDestination> destinations,
required ValueChanged<int> onSelected,
required Widget content,
}) {
assert(destinations.length >= 2);
assert(selectedIndex >= 0 && selectedIndex < destinations.length);
final size = MediaQuery.sizeOf(context);
final useRail = size.width >= 600 && size.height >= 480;
return Scaffold(
body: SafeArea(
child: Row(
children: [
if (useRail)
NavigationRail(
selectedIndex: selectedIndex,
onDestinationSelected: onSelected,
labelType: NavigationRailLabelType.all,
destinations: [
for (final destination in destinations)
NavigationRailDestination(
icon: destination.icon,
selectedIcon: destination.selectedIcon,
label: Text(destination.label),
),
],
)
else
const SizedBox.shrink(),
Expanded(child: content),
],
),
),
bottomNavigationBar: useRail
? null
: NavigationBar(
selectedIndex: selectedIndex,
destinations: destinations,
onDestinationSelected: onSelected,
),
);
}
调用点位于 MaterialApp 下,content 是内容视图,不再嵌套第二份完整 Scaffold。此示例只管无物理分隔时的导航切换;目的地过多或字体放大时,需验证 Rail 高度与可达性,必要时换可滚动导航容器。
MediaQuery 与 LayoutBuilder 各管一层
窗口根部用 MediaQuery.sizeOf 读当前 View 的逻辑尺寸;Pane 内部用 LayoutBuilder 的约束决定列数、卡片宽度和文字容器。MediaQuery 可被祖先局部覆盖,必须明确当前 context 属于窗口还是 Pane
一个通用的正文容器可以这样写:
Widget buildReadingFrame(Widget child) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth.clamp(0.0, 720.0).toDouble();
return Align(
alignment: Alignment.topCenter,
child: SizedBox(width: width, child: child),
);
},
);
}
要求调用位置具有有效的宽度约束;只限阅读宽度,滚动由内容自理。720 不再乘窗口缩放——否则最大阅读宽度又会随设备膨胀。网格列数同理:由卡片最小宽度、间距和容器约束求列数并设上限,不因为"运行在平板上"就固定三列。
组件密度与窗口占用分开
迁移既有尺寸工具时,保留统一的字号、间距、圆角入口,但组件缩放设上限;列宽、剩余空间、正文最大宽度交给约束系统。禁止在左右 Pane 各初始化一份全局单例尺寸工具——局部初始化写入同一单例,两侧组件会争用不同的缩放配置。系统文字缩放用 TextScaler 保留非线性缩放能力,以弹性高度和换行适配大字;禁止拿 textScaler.scale(1) 当通用倍率,禁止为截图整齐全局关闭系统字体偏好。
3.2 双栏布局与路由:导航状态与呈现方式解耦
核心原则:窗口展开或折叠只是导航状态的呈现方式变化,不构成一次新的导航动作。
3.2.1 先确定主从关系,再决定路由结构
Master-Detail 的基本状态可以归纳为“当前选中哪个条目,以及详情内部处在哪一步”。
| 导航状态 | 紧凑窗口 | 宽窗口 |
|---|---|---|
| 未选中条目 | 展示列表 | 左侧列表,右侧占位 |
| 已选中条目 | 展示详情 | 左侧列表及选中项,右侧详情 |
| 进入详情的下一步 | 展示详情子页面 | 在详情区域内继续导航 |
由此得出三条禁令:禁止在尺寸回调里 push/pop;禁止因布局类别切换重建根路由栈;深链解析后先更新同一份选中状态,页面按当前空间自行呈现。
3.2.2 Nested Navigator 推荐结构
根 Navigator 管应用级页面;稳定的工作区宿主持有 Master;Detail 用 Nested Navigator 管理局部路由。Navigator key 在宿主 State 中创建一次,禁止每次 build 重新生成。选中 ID 是唯一可追溯的导航状态,与路由包选型无关(GoRouter 下同样要求:路由配置与 key 长期稳定,Shell 承载布局差异
Widget buildDetailNavigator({
required GlobalKey<NavigatorState> navigatorKey,
required String? selectedId,
required bool active,
required Widget placeholder,
required Widget Function(BuildContext context, String id) detailBuilder,
required ValueChanged<String> onDetailClosed,
}) {
final id = selectedId;
final detailKey = id == null ? null : ValueKey<String>('detail:$id');
return NavigatorPopHandler<Object?>(
enabled: active,
onPopWithResult: (result) {
if (active) navigatorKey.currentState?.maybePop(result);
},
child: Navigator(
key: navigatorKey,
requestFocus: active,
pages: <Page<Object?>>[
MaterialPage<Object?>(
key: const ValueKey<String>('detail-root'),
child: placeholder,
),
if (id != null)
MaterialPage<Object?>(
key: detailKey,
child: Builder(
builder: (context) => detailBuilder(context, id),
),
),
],
onDidRemovePage: (page) {
if (id != null && page.key == detailKey) {
onDetailClosed(id);
}
},
),
);
}
两处容易出错的契约:
-
onDidRemovePage必须同步外部选中状态。否则下次构建时,被移除的页面会再次出现在pages里。宿主收到onDetailClosed(id)后,仅当当前选中项仍是该 id 才清空选择。 -
NavigatorPopHandler把可处理的系统返回交给嵌套导航
3.2.3 State 保持需要稳定的元素结构
仅给详情组件加 ValueKey,不能保证它从一个新建的 Row 移到另一个新建的 Column 时保留原 State——key 匹配有父级作用域,祖先结构替换仍会触发卸载。正确做法是用固定槽位承载两侧内容,只改变槽位的矩形:
Widget buildPaneSlot({
required String slotId,
required Rect bounds,
required bool visible,
required Widget child,
}) {
return Positioned.fromRect(
key: ValueKey<String>(slotId),
rect: bounds,
child: Offstage(
offstage: !visible,
child: TickerMode(
enabled: visible,
child: ExcludeFocus(
excluding: !visible,
child: child,
),
),
),
);
}
Widget buildPaneStack({
required Rect masterBounds,
required Rect detailBounds,
required bool showMaster,
required bool showDetail,
required Widget master,
required Widget detail,
}) {
return Stack(
fit: StackFit.expand,
children: [
buildPaneSlot(
slotId: 'master-slot',
bounds: masterBounds,
visible: showMaster,
child: master,
),
buildPaneSlot(
slotId: 'detail-slot',
bounds: detailBounds,
visible: showDetail,
child: detail,
),
],
);
}
容器必须置于宽高有界的位置。单栏时两侧共用同一可用矩形、只显示当前侧;双栏时传入分别计算的矩形——无铰链按内容宽度分配,有铰链用真实屏区。
槽位保活的边界要清楚:Offstage 隐藏 ≠ 停止后台工作。TickerMode 只暂停 Flutter ticker;计时器、网络请求、原生媒体播放遵守各自的生命周期策略(隐藏输入区还须处理焦点,避免键盘继续关联不可见控件。
3.3 姿态感应与铰链避让:坐标系转换与局部计算
结论:分区是几何问题,先算区域、再赋角色;任何一步都不依赖对硬件形态的猜测。
3.3.1 坐标系偏差防坑:全局坐标转换为局部坐标
MediaQuery.displayFeatures 是特征列表,不能只取第一项——第一项可能是挖孔,也可能存在多条折叠分隔:
| 特征 | 处理原则 |
|---|---|
hinge | 作为分隔或遮挡处理,关键交互不得跨越 |
fold,完全展开 | 允许连续内容,不强制拆两栏 |
fold,postureHalfOpened | 按方向与内容能力考虑左右或上下布局 |
cutout | 避让局部障碍,不据此创建两个 Pane |
| 空列表 / 未知姿态 | 按当前窗口布局,保留已知遮挡,不猜硬件 |
一个容易踩的坑:半折线可能没有厚度——竖向折痕宽度为 0,横向折痕高度为 0。用 bounds.isEmpty 或矩形相交面积过滤会漏掉有效分隔。
通过 RenderBox.globalToLocal 将全局铰链 Rect 转换为当前坐标系下的局部分隔:
class HingedTwoPaneLayout extends StatelessWidget {
final Widget master;
final Widget detail;
const HingedTwoPaneLayout({
super.key,
required this.master,
required this.detail,
});
@override
Widget build(BuildContext context) {
final media = MediaQuery.of(context);
final displayFeatures = media.displayFeatures;
return LayoutBuilder(
builder: (context, constraints) {
final renderBox = context.findRenderObject() as RenderBox?;
final globalOffset = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final currentBounds = globalOffset & Size(constraints.maxWidth, constraints.maxHeight);
DisplayFeature? activeHinge;
for (final feature in displayFeatures) {
if (feature.type == DisplayFeatureType.hinge) {
if (feature.bounds.overlaps(currentBounds)) {
activeHinge = feature;
break;
}
}
}
if (activeHinge == null) {
return Row(
children: [
Expanded(child: master),
Expanded(child: detail),
],
);
}
final localHingeLeft = (activeHinge.bounds.left - globalOffset.dx).clamp(0.0, constraints.maxWidth);
final localHingeRight = (activeHinge.bounds.right - globalOffset.dx).clamp(0.0, constraints.maxWidth);
return Row(
children: [
SizedBox(width: localHingeLeft, child: master),
SizedBox(width: localHingeRight - localHingeLeft), // 铰链避让隔离带
Expanded(child: detail),
],
);
},
);
}
}
3.3.2 复用公开分区能力
Flutter 的 DisplayFeatureSubScreen 可把窗口障碍转换为子屏,也可按 anchor 为内容选择子屏;默认分隔条件考虑正面积障碍与半展开姿态,且分隔必须贯穿相应方向。以下函数从窗口数据计算几何区域:保留半折线,对 hinge 报告的零厚度边界采取保守分隔:
List<Rect> partitionView(MediaQueryData media) {
final size = media.size;
if (size.isEmpty || !size.width.isFinite || !size.height.isFinite) {
return const <Rect>[];
}
final viewport = Offset.zero & size;
final candidates = <Rect>{
...DisplayFeatureSubScreen.avoidBounds(media),
for (final feature in media.displayFeatures)
if (feature.type == DisplayFeatureType.hinge) feature.bounds,
};
final clipped = <Rect>[];
for (final rect in candidates) {
final coordinates = [rect.left, rect.top, rect.right, rect.bottom];
if (!coordinates.every((value) => value.isFinite)) continue;
if (rect.width < 0 || rect.height < 0) continue;
if (rect.right < 0 || rect.bottom < 0 ||
rect.left > size.width || rect.top > size.height) {
continue;
}
clipped.add(rect.intersect(viewport));
}
return DisplayFeatureSubScreen.subScreensInBounds(viewport, clipped)
.where((region) => region.width > 0 && region.height > 0)
.toList(growable: false);
}
函数返回的只是几何区域,"有两块区域"不等于"能展示两栏":后续要扣安全区和导航占位、校验每区最小宽高、按文字方向与最近交互确定角色。某一侧放不下详情时,在可操作区域显示单栏并提供切换入口;空间不足时宁可退单栏,也不把按钮压到铰链上。非贯穿的挖孔不形成子屏,由安全区或局部障碍策略避让。窗口尺寸无效时,等待新快照或显示安全占位——空结果不能解释为"整个窗口可交互"。
横向半折可用"上内容、下操作"的 tabletop 布局,前提有二:页面有明确的内容/控制分工;两区扣除安全区和键盘后仍可用。注意姿态变化时尺寸可能完全不变——只监听窗口大小不够,布局必须依赖特征列表本身。
坐标系纪律
- Flutter 的显示特征 bounds 已是当前 FlutterView 的逻辑像素,不要再除 DPR;原生接口返回的像素坐标先平移到当前 View 原点,再按同一次测量的 DPR 换算。
- 分区在完整窗口坐标下完成。先居中限宽再拿窗口级 hinge 坐标切局部容器,结果必然偏移。
- 窗口旋转或 View 重建后,旧尺寸对应的特征快照作废。
- Pane 内优先
LayoutBuilder。确需局部 MediaQuery 时,尺寸、边缘 inset、特征局部坐标三者一起调整;不要假设选子屏的 Widget 会自动完成全部坐标转换。
3.3.3 弹窗落点控制
标准 showDialog 可以传入 anchor,选择靠近操作位置的子屏:
Future<void> openAnchoredDialog({
required BuildContext context,
required Rect triggerBoundsInView,
required WidgetBuilder builder,
}) {
return showDialog<void>(
context: context,
anchorPoint: triggerBoundsInView.center,
builder: builder,
);
}
触发矩形必须与目标 Navigator 同坐标系;嵌套导航或局部 Overlay 需做相应转换。anchor 只解决物理子屏选择——无铰链的大屏双栏仍需给弹窗明确的 Pane 范围和最大宽度。输入弹窗 resize 时保留同一编辑控制器和结果 Future,仅重算位置;短暂菜单可在锚点失效时关闭,不得因重新定位重复触发确认回调。
3.4 状态保存与无缝切换:应对 Activity 重建
3.4.1状态分类与恢复策略
结论:状态的保存位置由"恢复成本"决定,不由页面决定。
| 状态 | 保存位置 | 窗口变化时的要求 |
|---|---|---|
| 当前条目、详情子步骤 | 路由状态或页面模型 | 保留逻辑目标 |
| 输入文本、selection、composing | 稳定 State 中的控制器 | 不丢字、不重置输入法组合 |
| 列表阅读位置 | ScrollController、PageStorage 或内容锚点 | 重排后找回原内容 |
| 网络会话、后台任务、媒体资源 | 应用或会话作用域 | 不因重新布局重启 |
| 跨进程草稿、导航目标 | 本地持久化 / 状态恢复机制 | 重启后校验并恢复 |
列表位置用 PageStorageKey:
PageStorageKey 适合保存列表滚动位置:
Widget buildMasterList({
required int itemCount,
required IndexedWidgetBuilder itemBuilder,
}) {
return ListView.builder(
key: const PageStorageKey<String>('master-list-position'),
itemCount: itemCount,
itemBuilder: itemBuilder,
);
}
其能力边界要清楚:依赖保留的 PageStorage bucket,不保存文本输入、选中项或整个路由栈,不能代替跨进程持久化。多个列表用不同 key;同一个 ScrollController 不得同时连接两份仍在显示的滚动视图。
输入控制器放在稳定 State 中创建,不在 build 或尺寸分支中临时创建:
3.4.2 抗系统摧毁:引入 State Restoration 机制
在 Android 折叠屏展开/折叠时,除了内存中的尺寸变化外,极其容易触发 Android Activity 的重新创建(Recreation)。单纯依靠内存 State 只能应对同实例 resize,无法解决 Activity 被系统销毁重建时的状态丢失。
针对输入框等关键状态,必须使用 Flutter 的 RestorationMixin 结合 RestorableTextEditingController,这才能保障在 Activity 彻底重建后,输入法 IME 的拼音组合状态(Composing Region)与文本内容不丢失:
class ResilientDraftPane extends StatefulWidget {
const ResilientDraftPane({super.key});
@override
State<ResilientDraftPane> createState() => _ResilientDraftPaneState();
}
class _ResilientDraftPaneState extends State<ResilientDraftPane>
with RestorationMixin {
final RestorableTextEditingController _editorController =
RestorableTextEditingController();
@override
String? get restorationId => 'draft_pane_editor';
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_editorController, 'editor_text');
}
@override
void dispose() {
_editorController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _editorController.value,
maxLines: null,
decoration: const InputDecoration(hintText: '输入草稿...'),
);
}
}
4. Android 与 iOS 平台的针对性处理
4.1 Android:多窗口、系统限制与 PlatformView 防御
结论:清单声明可缩放 + Dart 层不锁方向,是双栏能力的前提;Activity 重建与进程终止是独立场景,必须分别覆盖。
4.1.1 清单与方向配置
清单中的准确属性拼写是 android:resizeableActivity:
<activity
android:name=".MainActivity"
android:resizeableActivity="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" />
Dart 层取消方向锁:
import 'package:flutter/services.dart';
Future<void> allowSystemOrientations() {
return SystemChrome.setPreferredOrientations(
const <DeviceOrientation>[],
);
}
configChanges 只避免部分配置变化走 Activity 重建,不能消除所有重建场景,更不能保证进程存活。适配必须同时覆盖"同实例更新尺寸"和"系统重建后恢复"。
4.1.2 Android 强制解除方向限制规则
Android 16 对 target API 36 的应用,在符合大屏条件的显示环境中会覆盖部分方向、宽高比和可调整大小限制。target API 37 时,相应的开发者退出机制不再可用
因此,固定竖屏或禁止 resize 不能作为长期规避手段。
4.1.3 PlatformView(混合开发原生视图)防卡死防御
若页面包含 Native 视图(如地图、WebView、原生视频播放器等),折叠屏展开引发窗口重排时,Android 底层的 Surface 会被销毁并重新创建。这极易导致 Flutter 与 Native 纹理同步失败,出现黑屏、闪烁或 SurfaceDestroyed 崩溃。
架构级防御措施:
-
尺寸变化节流(Throttle):在拖动 resize 期间,暂停 Native 视口的帧更新或降低 Native View 的渲染频率。
-
生命周期重新绑定:在 resize 稳定后,触发
AndroidViewController.dispatchCreated或更新PlatformView的 UniqueKey 促使其无缝 Reattach。
4.2 iOS:Stage Manager、Split View 与动态窗口
结论:单窗口多任务不要求多 Scene;先看构建产物的最终配置,再谈运行时行为。
4.2.1 配置与多 Scene 管理
支持 iPadOS 动态窗口必须避免依赖已弃用的 UIRequiresFullScreen。针对支持 Stage Manager 拖拽的场景,窗口尺寸会在毫秒级发生连续变化。
4.2.2 连续拖拽时的性能防抖
在窗口拖拽过程中,每秒可能会触发数十次 Layout:
- Layout 必须高频响应:布局与 Flex 关系必须随约束即时更新,不可防抖,以防视觉拉伸变形。
- 昂贵开销必须延迟(Debounce):高分辨率图像重采样、复杂 Chart 重绘或网络请求,需通过
Timer防抖等 resize 彻底停下来(如连续 150ms 尺寸无变化)后再执行。
5. 最佳实践总结与建议
5.1 用渐进迁移控制改造范围
-
基线盘点:清查全局尺寸单例、方向锁、公共弹层与
PlatformView。 -
局部弹性化:使用
LayoutBuilder和TextScaler确保单栏具备完全可缩放能力。 -
路由宿主改造:引入
GoRouter的StatefulShellRoute或稳健的双栏 Nested Navigator。 -
铰链与局部坐标映射:使用局部坐标转换补齐
DisplayFeature避让。 -
抗摧毁与持久化:补齐
RestorationMixin,确保 Activity 重建时状态连续。
5.2 验证维度与验收清单
| 测试维度 | 覆盖 | 主要断言 |
|---|---|---|
| 断点 | 599/600/601、839/840/841 及两端外延 | 导航与内容决策分层,边界不溢出 |
| 低高度 | 横屏、键盘、矮浮窗 | 主要操作可达,无负高度、无界 Flex |
| 铰链 | 非居中、横/纵向、零厚度、多特征 | 不漏有效分隔,不把挖孔当双屏 |
| 姿态 | 尺寸不变,仅 flat/halfOpened 变化 | 特征更新触发布局 |
| 连续缩放 | 窄 → 宽 → 分屏 → 还原,循环 | 路由、输入、滚动、资源身份符合保留策略 |
| 文本与输入 | 大字体、非线性缩放、长译文、RTL、硬键盘 | 文本可读、焦点可见、键盘操作完整 |
| 原生与系统 | Activity 重建、Scene 切换、进程恢复 | 不重复创建任务,不误恢复过期状态 |
Widget 测试注入窗口尺寸与 DisplayFeature,验证几何关系、状态保持、返回行为。Golden 测试只比较视觉——不能证明设备实际上报铰链,也不能证明系统窗口管理、原生表面、输入法行为正确。真机至少覆盖不同折叠形态、双屏跨屏和不同尺寸 iPad;只能用模拟器覆盖的硬件,把缺口写进验证记录。
参考资料
- Flutter API:DisplayFeature
- Flutter 文档:General approach to adaptive apps
- Flutter API:Navigator (onDidRemovePage)
- Flutter API:NavigatorPopHandler
- Flutter API:DisplayFeatureSubScreen
- Flutter API:showDialog
- Flutter API:AppLifecycleState
- Flutter API:AppLifecycleListener
- Flutter API:PageStorage
- Flutter API:RestorationMixin
- Android 平台文档:App orientation, aspect ratio, and resizability
- iPadOS 平台文档:UIRequiresFullScreen