Flutter 折叠屏适配实战:窗口布局、双栏路由与状态连续性

0 阅读19分钟

本文发布于公众号: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 树末端的 LayoutBuilderCustomMultiChildLayout 中,在 Layout 阶段 完成局部重排与约束传递,避免上层 Widget 的不必要重建。


3. 折叠屏适配核心策略

3.1 响应式断点:先测量窗口,再测量组件

结论:断点描述空间,不描述设备;窗口类决定导航形式,容器约束决定内容列数——两者分层判断。

断点描述空间,不描述设备

可以从三个窗口宽度等级开始:

窗口宽度类别建议的默认表现
小于 600Compact底部导航、单栏内容
600 至不足 840Medium侧边导航或居中单栏,网格增加列数
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;DetailNested 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,完全展开允许连续内容,不强制拆两栏
foldpostureHalfOpened按方向与内容能力考虑左右或上下布局
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

  • 局部弹性化:使用 LayoutBuilderTextScaler 确保单栏具备完全可缩放能力。

  • 路由宿主改造:引入 GoRouterStatefulShellRoute 或稳健的双栏 Nested Navigator。

  • 铰链与局部坐标映射:使用局部坐标转换补齐 DisplayFeature 避让。

  • 抗摧毁与持久化:补齐 RestorationMixin,确保 Activity 重建时状态连续。

5.2 验证维度与验收清单

测试维度覆盖主要断言
断点599/600/601、839/840/841 及两端外延导航与内容决策分层,边界不溢出
低高度横屏、键盘、矮浮窗主要操作可达,无负高度、无界 Flex
铰链非居中、横/纵向、零厚度、多特征不漏有效分隔,不把挖孔当双屏
姿态尺寸不变,仅 flat/halfOpened 变化特征更新触发布局
连续缩放窄 → 宽 → 分屏 → 还原,循环路由、输入、滚动、资源身份符合保留策略
文本与输入大字体、非线性缩放、长译文、RTL、硬键盘文本可读、焦点可见、键盘操作完整
原生与系统Activity 重建、Scene 切换、进程恢复不重复创建任务,不误恢复过期状态

Widget 测试注入窗口尺寸与 DisplayFeature,验证几何关系、状态保持、返回行为。Golden 测试只比较视觉——不能证明设备实际上报铰链,也不能证明系统窗口管理、原生表面、输入法行为正确。真机至少覆盖不同折叠形态、双屏跨屏和不同尺寸 iPad;只能用模拟器覆盖的硬件,把缺口写进验证记录。


参考资料