前言
在 Yam TV 的 Flutter 客户端中,我一直想實現首頁加载时的骨架屏效果。看似简单的需求——用骨架屏占位替代圆形 ProgressBar——却因为在 Flutter 3.41+ 中遇到了 semantics.parentDataDirty 断言崩溃,花了整整七次修改才最终解决。
问题现象
App 首页需要通过 API 获取收藏列表和观看历史。加载过程中展示圆形 ProgressBar,加载完成后替换为内容。需求很简单:把 ProgressBar 换成 WebStylePosterSkeletonGrid(骨架屏网格)。
结果一运行,直接崩溃:
_RenderObjectSemantics.debugCheckForParentData.debugCheckParentDataNotDirty
PipelineOwner.flushSemantics
第一次尝试:CustomScrollView + SliverToBoxAdapter
最直接的想法:loading 时返回 SliverToBoxAdapter(骨架屏),data 时返回 SliverList(内容)。
body: CustomScrollView(
slivers: [
summaryAsync.when(
loading: () => SliverToBoxAdapter(child: 骨架屏),
data: (s) => SliverList(delegate: ...),
),
],
)
崩溃了。SliverToBoxAdapter 和 SliverList 是不同类型的 Sliver,CustomScrollView 在处理这种切换时语义树出错。
后续尝试记录
- SliverList + SliverChildListDelegate(加载/数据都返回 SliverList,但 delegate 不同)→ 崩溃
- ListView(代替 CustomScrollView)→ 仍然崩溃
- IndexedStack(三个子节点永久存在,只切换 index)→ 空白屏
- UniqueKey 强制 remount → 崩溃
- Opacity + Stack 叠加(骨架屏和内容同时存在)→ 崩溃
这里的关键教训是:改变 Column/CustomScrollView 子节点的类型或数量,都会触发 Flutter 的语义树断言。这与布局无关,而是与 Accessibility 系统有关。parentDataDirty 意味着某个节点的父数据在新的 layout 周期中没有被正确更新。
最终方案
研究了一下其他 Flutter 应用的源代码,发现他们的做法完全不同:所有 Section 永远在 widget 树中,骨架屏是 Section 内部的默认状态,不是在 loading/data 之间切换 Widget 类型。
return Column(
children: [
// Header: 胶囊 Tab 或普通标题 — 始终存在
_HomeSectionHeader(...),
// 收藏区 — 始终存在,数据为空时显示骨架 tile
_FavoritesSection(favorites: favs),
// 历史记录区 — 始终存在,数据为空时 Offstage 隐藏
Offstage(offstage: isLoading, child: _HistorySection(history: hist)),
],
);
关键区别:Column 的子节点数量永远是 3 个,从不增删。FavoritesSection 和 HistorySection 内部各自处理「数据为空→显示骨架 tile」和「数据就绪→显示真实内容」的切换。使用 Offstage 而非条件式 if 来隐藏不需要的 section,保持 widget 树结构稳定。
背后的原理
Flutter 的语义树(Semantics Tree)用于 accessibility 屏幕阅读器。当 widget 树结构变化时,语义树需要相应更新。但如果变化发生在同一个 frame 内且父节点的数据类型没有在 layout 完成后及时更新,flushSemantics 就会触发断言。
根本解决方案:永远不要让 widget 树的结构(子节点类型、数量)在运行时改变。所有动态变化应该在固定的 widget 结构内部通过数据驱动来实现。
代码精简对比
// 错误做法:Column 子节点数量会变化
return Column(
children: [
if (loading) 骨架屏,
if (data) 内容,
],
);
// 正确做法:Column 子节点数量固定
return Column(
children: [
Offstage(offstage: !show, child: 骨架屏),
Offstage(offstage: loading, child: 内容),
],
);
总结
Flutter 3.41+ 中的 semantics.parentDataDirty 是一个框架层的 bug,在 dynamic widget child 场景下一定会触发。解决方案不是去找 workaround,而是改变架构范式——从「条件式渲染不同 Widget」切换到「固定 Widget 结构 + Offstage/Optacity 切换可见性」。
这种架构也更接近 React/Vue 的「数据驱动 UI」思路:widget 树是静态的,只有数据变化,没有节点增删。
讨论
还没有留言,来留下第一条评论吧!