Pure Live 共用列表页布局审计:提示区、状态页与分页栏的边界修复实录
Pure Live 共用列表页布局审计:提示区、状态页与分页栏的边界修复实录
本文围绕 pure_live 仓库中的共用列表页组件
BasePageView与桌面分页栏DesktopPaginationBar展开,剖析一次围绕“提示、恢复操作与分页布局”的专项审计(对应 docs/BASE_PAGE_LAYOUT_AUDIT_2026_09_10.md):四条真实缺陷的根因定位、修复方案、25 项新增 Widget 测试与 172 项联合回归的验证方式,以及这些改动在源码中的落点。读完本文,你将掌握这套跨平台共用列表页在「多提示叠加」「长错误页」「下拉刷新接线」「窄窗口分页栏」四类场景下的布局边界设计,并能直接对照源码理解其高度约束与滚动所有权的划分思路。
一、审计背景与结果边界
该审计基于本地冻结的上游提交 c6c9bd70aedc503c003110dae10a83ad0bb891d8 与共同基线 527fea1b40885e3621d53c9646b523dd8522290c 做比较,未重新拉取或合并上游。修复提交为 cacf5611668eb1e42211f5cf3b3c4c3636324058(父提交 2b20d959ef38989423be9500cbaf239f789d8ef2)。
结果边界非常明确:
- 172/172 定向测试、4 文件
dart analyze --fatal-infos严格分析通过; - 新增 25 项 Widget 测试,并修订一项既有长提示操作测试;
- 修复的四个问题:共用页多条提示挤占正文、长错误页操作不可达、窄 Windows 分页栏横向溢出、移动宽度空状态页下拉刷新未触发;
- 仅源码与本机 Widget 验证,没有 Android、Windows GUI、在线接口或正式发布证据;版本、数据库、候选均保持不变;
- Android
d4adcb77、Windows2fb471d3均未包含本批及前一批搜索布局修订(见 SEARCH_PAGE_LAYOUT_AUDIT_2026_09_10.md)。
二、四个缺陷的根因定位
| 项目 | 来源 | 第一个错误状态与处理 |
|---|---|---|
| 多提示组合 | fork-regression,继承固定 Column 后继续扩展提示区 | pageNotice 来自 f9791b7c,inline error 来自 58546f51;与既有蜂窝提示叠加时先占满高度,再给 Expanded 正文分配空间。提示区改为自然高度、最多半视口的独立滚动区域,正文至少保留剩余半视口 |
| 长状态页 | upstream-existing | 桌面/禁止刷新时只 Center,无滚动;移动刷新分支用固定 0.8 × 视口高度 SizedBox 装状态。改为最小高度而非固定高度,长文字与按钮自然排版、可滚动 |
| 状态页下拉刷新 | upstream-existing 的显式物理参数路径 | 子滚动视图显式使用 PureLiveScrollPhysics,未使用 EasyRefresh 的刷新物理参数;分段真实拖动后 refresh 调用数仍为 0。使用 EasyRefresh.builder,把提供的 physics 传给唯一状态滚动视图,真实手势触发一次刷新 |
| 分页栏与输入控件 | upstream-existing | 所有控件置于固定可用宽度的 Row,页码/选择器/输入框又有固定高度。分页栏改为有界横向滚动,宽窗口仍居中;控件使用最小高度或自然高度,保留字号、输入过滤、跳转上限和回调 |
从来源可以看出,四类缺陷分别来自两个通道:多提示组合来自 fork 后的回归(继承固定 Column 后继续叠加提示导致正文被挤没),其余三个来自上游既有问题(upstream-existing)。修复时提示区保留全文,内联错误不再用三行省略;蜂窝提示的操作按钮置于文字下方,避免长按钮挤压正文宽度;“不再显示”的静态标记行为保持原样。
三、为什么没有把共用页直接改成一个外层列表
一个直觉方案是:既然提示、状态、正文都可能需要滚动,干脆用一份统一的外层 ListView 包住全部内容。审计明确否决了该方案,原因记录在文档中,而源码佐证了它的合理性。
查看 base_page_view.dart 的接口定义,BasePageView 的 contentBuilder 可以返回 TabBarView、独立列表,甚至是自己拥有刷新的嵌套页面:
class BasePageView<C extends BasePageScrollAndStateBone<T>, T> extends StatelessWidget {
final C controller;
final Widget Function(BuildContext context, List<T> list, ScrollController scrollController) contentBuilder;
final bool enableRefresh;
final bool enableLoadMore;
final bool wrapMobileRefresh;
final bool preserveContentWhenEmpty;
final bool? showScrollToTopBtn;
final bool showPageSizeSelector;
final List<int> pageSizeOptions;
...
}
直接包进另一份统一滚动列表会改变高度约束、分页和所有权。例如关注页(favorite_page.dart)的正文就是 TabBarView 加每平台独立 ScrollController;分类房间页(area_rooms_page.dart)则让 GridView 自己持有滚动控制器并实现触底加载。这些嵌套滚动体要求外层只负责布局,不能强加滚动。因此最终方案是:保留原正文、控制器与刷新边界,只给提示区设置独立、非 primary 的滚动区域;普通短提示按自然高度显示,没有提示时占用 0 高度。
实现上,提示区被一个 ConstrainedBox(maxHeight: pageConstraints.maxHeight / 2) 约束,内部是 primary: false 的 SingleChildScrollView(key 为 base-page-notices),并使用 PureLiveBoundedScrollPhysics——这是一个平台无关的硬边界物理模型(见 pure_live_scroll_physics.dart),导航条、分页这类有限选择器永远不允许出现超出首项/末项的偏移。下方正文使用 Expanded 保证至少占满剩余半视口。
ConstrainedBox(
constraints: BoxConstraints(maxHeight: pageConstraints.maxHeight / 2),
child: SingleChildScrollView(
key: const ValueKey('base-page-notices'),
primary: false,
physics: const PureLiveBoundedScrollPhysics(),
child: Column(mainAxisSize: MainAxisSize.min, children: [ /* notices */ ]),
),
),
Expanded(child: /* 正文 */),
四、多提示组合:自然高度 + 有界独立滚动区
当 pageNotice(来自 f9791b7c)、蜂窝提示(_buildCellularBanner)与 inline error(来自 58546f51)同时出现时,旧实现由于继承固定 Column 会先占满高度再给正文分空间,导致正文被挤出可视区。
修复后的 build 中,三类提示按顺序进入同一个非 primary 滚动列:
controller.pageNotice:平台目录的持久性说明文案,来自LiveDirectoryNotice接口(live_directory.dart),正文使用bodySmall字号、EdgeInsets.fromLTRB(12, 8, 12, 4)内边距;_buildCellularBanner:移动数据联网时的蜂窝流量警示条,带圆形信号图标与“不再显示”按钮,点击后设置BaseController.neverShowCellularBanner = true(base_controller.dart)并立即隐藏当前条;- inline error(
showInlineError分支):当列表非空但页面出错时,在正文上方用MaterialBanner展示错误信息与重试按钮,forceActionsBelow: true确保长错误文案不再三行省略,操作按钮始终位于文字下方。
inline error 的驱动状态来自控制器:errorMsg、pageError、showInlineError 都是响应式字段(base_controller.dart),只有当 list.isNotEmpty && pageError && errorMsg 非空时才渲染;重试按钮在 loadding 时禁用,点击调用 controller.retryData()。
在真实调用点上,LiveDirectoryNotice 目前被花椒、映客、克拉克拉、OPENREC、TTingLive、小红书等平台实现(例如 xiaohongshu_site.dart 提供 directoryNoticeKey => 'xiaohongshu_directory_scope')。pageNotice 通过控制器统一取词(live_directory_controller.dart):
String? get pageNotice =>
directory is LiveDirectoryNotice ? i18n((directory as LiveDirectoryNotice).directoryNoticeKey) : null;
组合测试属于共用 UI 合同的压力输入,不代表小红书实际目录返回了房间列表——这点在审计中特别注明,避免把测试压力误读为真实数据行为。
五、长状态页:从固定高度到最小高度
未登录、出错、空数据的全屏状态页(_buildLoginStatus、_buildErrorStatus、空视图)旧实现有两种写法:桌面/禁止刷新时只 Center 无滚动;移动刷新分支用固定 0.8 × 视口高度 的 SizedBox 装状态。长错误文字 + 按钮在这种布局下会超出视口、按钮不可达。
修复后的 _buildScrollableStatus(base_page_view.dart)统一为:SingleChildScrollView 包 ConstrainedBox(minHeight: constraint.maxHeight * (isDesktop || !enableRefresh ? 1 : 0.8)),再 Center 状态视图。最小高度而非固定高度让长内容自然排版并可滚动,短内容则保持原有视觉位置:
Widget buildScrollable(ScrollPhysics physics) => SingleChildScrollView(
key: const ValueKey('base-page-status'),
primary: false,
physics: physics,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraint.maxHeight * (isDesktop || !enableRefresh ? 1 : 0.8)),
child: Center(child: statusView),
),
);
桌面(或禁止刷新)分支使用 PureLiveScrollPhysics(parent: AlwaysScrollableScrollPhysics())——即使内容不足一屏也能下拉,但内容列表本身仍保留 iOS/macOS 的弹性回弹(见 pure_live_scroll_physics.dart)。
只读检查的实际调用点(热门 popular_grid_view.dart、分类 areas_grid_view.dart、分类房间、关注、用户管理 user_manage_page.dart)的自定义空视图均最终使用 AppStatusView/EmptyView,没有依赖有限高度 Expanded 的自定义空状态,因此统一状态页方案不会破坏它们。
六、状态页下拉刷新:把 EasyRefresh 的 physics 真正接上
移动端空状态页的下拉刷新是本次最“隐蔽”的缺陷:子滚动视图显式使用了 PureLiveScrollPhysics,没有使用 EasyRefresh 的刷新物理参数,导致显式的 overscroll 策略在刷新武装之前就消费掉了拖动,分段真实拖动后 refresh 调用数仍为 0。
修复使用 EasyRefresh.builder,把其提供的 physics 直接传给唯一的状态滚动视图:
return EasyRefresh.builder(
header: indicators.header,
footer: indicators.footer,
controller: controller.easyRefreshController,
triggerAxis: Axis.vertical,
onRefresh: () => controller.refreshData(),
// 唯一状态滚动视图必须使用刷新所有者的 physics,
// 否则其显式 overscroll 策略会在刷新武装前消费掉拖动。
childBuilder: (context, physics) => buildScrollable(physics),
);
这条规则同样体现在正文侧。移动端正文的刷新包装在 BasePageViewContentExtension.buildActualContent 中([base_page_view_extension.dart](https://gitcode.com/gh_mirrors/pur/pure_live/blob/489aaf85949bae4e98a2aed93e965584d930f09f/lib/core/pagination/base_page_view_extension.dart?utm_source=gitcode_repo_files#L42-L59)):`EasyRefresh` 直接包住 contentBuilder,onRefresh 在 enableRefresh 时指向 controller.refreshData,onLoad 仅在 canLoadMore 时启用。刷新完成与否由控制器的 easyRefreshController 统一收尾(finishRefreshControllers,桌面分页模式直接跳过)。
测试验证还纠正了一个误判:在正确的移动分支下,单次拖动与分段拖动原本都不会触发刷新;修订 EasyRefresh physics 接线后真实手势成功触发。结束测试时按组件实际 header.processedDuration 推进测试时钟并完成回弹,消除尚未走完提示保留周期的测试计时器——没有修改生产保留时长。
七、桌面分页栏:有界横向滚动 + 最小交互尺寸
桌面分页栏(desktop_components.dart)的旧实现把所有控件塞进固定可用宽度的 Row,页码块、页大小选择器、跳转输入框又使用固定高度。当窗口变窄(如 700px 级别)且字号放大时,整条分页栏横向溢出、输入框被挤出。
修复后的结构是 LayoutBuilder + 横向 SingleChildScrollView:
- 外层
SingleChildScrollView(scrollDirection: Axis.horizontal, physics: PureLiveBoundedScrollPhysics)提供有界横向滚动——窄窗口下不会溢出布局,且永远不允许滚动超出首尾; - 内层
ConstrainedBox(minWidth: constraints.maxWidth)+Row(mainAxisAlignment: MainAxisAlignment.center)保证宽窗口时分页栏依旧居中; - 页码块、选择器、跳转输入框统一改为
kMinInteractiveDimension(48dp)最小尺寸或自然高度,保留字号、输入过滤、跳转上限与回调。
跳转输入逻辑 _executeJump 保留了完整的边界语义:
final int? targetPage = int.tryParse(text);
if (targetPage == null || targetPage < 1) return;
if (isFixedMode && targetPage > maxPage) {
controller.goToPage(maxPage); // 固定页大小模式:超限截到实际总页数
} else {
controller.goToPage(targetPage);
}
_inputController.clear();
FocusScope.of(context).unfocus(); // 提交后清空输入并收起键盘焦点
输入框使用 FilteringTextInputFormatter.digitsOnly(仅数字)、TextInputType.number、居中对齐,onSubmitted 触发跳转。测试断言覆盖:输入 999 后按实际总页数截到第 3 页、输入提交后清空、无布局异常。页大小选择 CompactPageSizeSelector 用 PopupMenuButton<int> 实现,选项来自 pageSizeOptions,选中即调 controller.setPageSize(newValue);当前页大小不在选项列表时回退到 options.first。
桌面模式还通过 CallbackShortcuts 注册了左右方向键翻页快捷键(base_page_view_extension.dart):arrowLeft 上一页、arrowRight 下一页(仅在 canLoadMore 且未加载中时有效)。分页栏仅在 list 非空或 currentPage > 1 时渲染,空首页展示自己的空状态与重试入口,避免“无可翻之页”的尴尬栏。
桌面与移动布局的切换由 BasePageScrollAndStateBone.checkAndNotifyLayoutChange 管理(base_page_scroll_bone.dart):断点 >680px 且非移动平台判定为桌面;切换带 120ms 防抖,等待当前异步分页操作完成后再提交,桌面模式取设置中的默认页大小并重算当前页位置,移动模式恢复页大小 20 并回到第 1 页后重新拉取。
八、验证体系:25 项新增 Widget 测试
新增测试(审计文档记录于 ../test/base_page_layout_test.dart 相对 docs 的位置,即仓库级 test/base_page_layout_test.dart)使用实际 BasePageView、DesktopPaginationBar、设置、本地化、滚动与 EasyRefresh;数据和业务请求回调由本机测试控制器接收,不产生外部请求。
| 新增场景 | 数量 | 断言 |
|---|---|---|
| 中英文、三视口、多提示 | 6 | 320×640、640×240、900×320;两倍字体无溢出;重试可点击;提示变化与提示滚动不替换列表位置,300 像素偏移保持 |
| 中英文、两视口、刷新开/关、完整错误页 | 8 | 320×240、900×200;长错误文字和重试按钮均可滚动到达,调用实际按钮 |
| 中英文、700 像素桌面分页 | 2 | 下一页、页大小 40、输入 999 后按实际总页数截到第 3 页,输入提交后清空,无布局异常 |
| 中英文、蜂窝提示关闭 | 2 | 提示最多半视口、正文保留至少半视口;实际点击关闭后标记更新,不刷新数据或改变列表偏移 |
| 中英文、空页/未登录、三倍字体 | 4 | 320×320,长提示与状态可滚动显示,无布局异常 |
| 空内容保留、实际下拉刷新、关闭桌面分页 | 3 | 空/非空切换保持同一位置;真实分段拖动触发一次刷新;关闭分页后正文完整占用视口 |
| 合计 | 25 | 不是宏观验收大组数量 |
联合回归共 172 项,覆盖 base_page_layout_test、live_directory_controller_test、xiaohongshu_application_test、search_page_layout_test、area_category_switch_test、area_category_paging_test、favorite_pull_refresh_test。四文件 dart analyze --fatal-infos 无诊断(两个生产文件 + 两个改动测试文件)。原游标恢复、容量上限恢复、分类分页/切换、关注下拉、搜索页及小红书目录入口测试均通过——但这不构成对应平台全部功能验收。
失败记录与测试器修订
- 初始 16 项布局红测均失败;第一次修订后 16 项通过。早期测试只覆盖渲染约束尺寸,
MediaQuery仍可能保留默认测试窗口尺寸,不足以证明移动/桌面分支选择; - 扩展后修正测试器为实际设置 test view 的 physicalSize 和 DPR=1,重验移动/桌面分支,并保留早期输出、不记为 Android 实机证据;
- 既有容量提示测试原先假定三行省略后按钮立即可点,现改为在提示区实际滚动到按钮再点击,原请求序列、保留数据和成功恢复断言保持不变;
- 最终 172/172 + 严格分析通过,未用直接调用
onRefresh代替手势验证。
本机证据目录:local-artifacts/base-page-layout-20260910/。每轮日志含时间戳,失败与成功输出均独立保留。最终测试日志 20260909T210947707Z-final-verified.log、分析日志 20260909T210947707Z-analyze.log;最终资源记录 20260909T211107979Z-base-page-final-verified.json 记载:80.154 秒,监测重型进程峰值 CPU 49.64%、工作集 10556416000 B,结束活跃重型进程 0,记录含实际命令、修订前提交、四文件输入哈希,提交后再次对账。重型测试与分析串行取得 build_resource_guard,缓存保持,无打包或 Gradle 缓存命中推断。
九、后续待验项与回滚策略
- 下一份累计候选纳入本批及 搜索页修订,再按签名/内容门禁与设备包装器安排保留数据安装;
- Android 实际字号、系统键盘与手势,Windows 实际窗口、鼠标横向滚动和键盘焦点继续待验;共享正文列表的各类自定义 physics/刷新组合仍需逐调用点检查,本批只明确修复了状态页刷新;
- 本次未覆盖播放器多模式、录制长时、全部平台能力或发布门禁;剩余工作中的 42 个历史大组及 8 组未注册参考平台,不因新增 Widget 数量直接减少。
回滚可撤销 cacf5611 的四文件,回到基线 2b20d959;本次改动不涉及持久化格式、设置默认值或设备数据,回滚风险面很小。
十、小结:一次可复用的共用页布局范式
本次审计为 pure_live 的共用列表页沉淀了一套可复用的边界范式:
- 提示区:自然高度 + 非 primary + 有界滚动 + 半视口上限,正文至少保留剩余半视口;
- 状态页:最小高度约束取代固定高度,任何长度内容都可滚动到达操作按钮;
- 刷新接线:状态滚动视图必须使用刷新所有者的 physics(
EasyRefresh.builder的childBuilder),不能让子视图自己的 overscroll 策略先消费拖动; - 分页栏:有界横向滚动 +
minWidth保持居中 + 最小交互尺寸,输入过滤、跳转上限与回调一字不少; - 验证纪律:物理尺寸/DPR 真实设置、真实手势而非直调
onRefresh、失败输出独立保留。
这些原则在 base_page_view.dart、base_page_view_extension.dart、desktop_components.dart 与 base_page_scroll_bone.dart 中均有直接实现可查,其他平台或模块如需共用列表页,可直接复用这套结构与约束模型。