跳转到主要内容

crayonxiaoxin

Flutter 语义树崩溃与骨架屏的艰难实现:一个 Flutter 3.41 框架 Bug 的修復之旅

前言

在 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 树是静态的,只有数据变化,没有节点增删。

讨论

还没有留言,来留下第一条评论吧!

留下足迹