mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1538 字
4 分钟
脏标记(Dirty Flag)
2026-06-13

一、为什么需要脏标记#

你正在做一个游戏引擎,场景图有 1000 个节点,每个节点有局部坐标和世界坐标。当父节点移动时,所有子孙节点的世界坐标都需要重算。最朴素的做法是:每次 setPosition() 都递归更新整棵子树的世界坐标。

问题来了:一帧里父节点可能被移动 5 次(动画系统改一次、物理引擎改一次、脚本改一次……),每次都触发子树重算。如果子树有 500 个节点,一帧就做了 2500 次矩阵乘法,而实际渲染时只需要最终位置。更糟糕的是,如果本帧不渲染(窗口被最小化),这些计算全白做了。

脏标记的核心思路:变更时只设一个布尔标记,重算推迟到值被实际读取时。三次 setPosition() 只设三次标记,一次 getWorldPosition() 才真正计算一次。写入频繁、读取稀疏的场景下,这个策略能把计算量降到原来的几十分之一。

二、现实类比#

酒店房间门上的「需要打扫」标牌。客房服务只进标记为脏的房间。如果客人没用过房间,标牌保持干净,客房服务直接跳过——零浪费。客人退房时标记脏了,但打扫不是立刻发生,而是等到客房服务下次巡楼时才执行。

三、核心思想#

脏标记模式通过追踪派生状态是否过期来避免冗余计算。当源值改变时,不立即重新计算所有依赖值,而只是设置一个「脏」标记。昂贵的重计算仅在派生值被实际请求时才发生,重算后清除标记。这以每次读取时的布尔检查代价,换取可能永远不需要的昂贵计算。

stateDiagram-v2 [*] --> Clean Clean --> Dirty: set() 变更 Dirty --> Clean: get() 重算 + 清除 Dirty --> Dirty: set() 再次变更

时间线上看,set(x) set(y) set(z) 连续三次变更只标记脏一次,get() 时才重算一次。如果没有脏标记,每次 set() 都要重算,三次变更就是三次重算。

属性
变更代价O(1) 仅设置布尔标记
读取代价(干净)O(1) 返回缓存值
读取代价(脏)O(recompute) 计算 + 缓存 + 清除标记
空间每个追踪值 O(1) 一个布尔标记

四、变体与对比#

模式关系区别
观察者(Observer)观察者在状态变更时立即通知脏标记将反应延迟到需要时
依赖图(Dependency Graph)脏传播沿依赖图边标记下游节点依赖图管全局顺序,脏标记管局部延迟
位掩码(Bitmask)脏标记作为位掩码中的位高效存储位掩码是存储优化,脏标记是延迟策略
双缓冲(Double Buffering)脏标记追踪哪个缓冲区已更改双缓冲管显示切换,脏标记管计算时机

一个重要的组合模式:React 的 Fiber 架构用位掩码实现多种脏标记。一个 Fiber 节点可能需要 Placement(新 DOM 节点)、Update(props 变更)、Deletion 等多种操作,每个位是针对特定关注点的独立脏标记。单个布尔值只能说「有东西变了」,位掩码能编码「什么变了」,让 commit 阶段可以分别处理每种工作。

五、多语言实现#

5.1 Go 实现#

package dirtyflag
// DirtyFlag 延迟计算包装器:只在脏时重算
type DirtyFlag[T any] struct {
dirty bool
cached T
compute func() T
}
// New 创建一个脏标记包装器,初始为脏
func New[T any](compute func() T) *DirtyFlag[T] {
return &DirtyFlag[T]{dirty: true, compute: compute}
}
// MarkDirty 标记为脏,下次 Get() 时重算
func (d *DirtyFlag[T]) MarkDirty() {
d.dirty = true
}
// Get 获取值,脏时重算并缓存
func (d *DirtyFlag[T]) Get() T {
if d.dirty {
d.cached = d.compute()
d.dirty = false
}
return d.cached
}
// IsDirty 返回当前是否为脏
func (d *DirtyFlag[T]) IsDirty() bool {
return d.dirty
}

变换层级中的脏传播示例:

// TransformNode 带脏标记世界坐标的场景节点
type TransformNode struct {
localX, localY int
worldX, worldY int
worldDirty bool
children []*TransformNode
parent *TransformNode
}
func (n *TransformNode) SetPosition(x, y int) {
n.localX, n.localY = x, y
n.markWorldDirty() // 级联标记子孙节点
}
func (n *TransformNode) GetWorldPosition() (int, int) {
if n.worldDirty {
if n.parent != nil {
px, py := n.parent.GetWorldPosition()
n.worldX, n.worldY = px+n.localX, py+n.localY
} else {
n.worldX, n.worldY = n.localX, n.localY
}
n.worldDirty = false
}
return n.worldX, n.worldY
}
func (n *TransformNode) markWorldDirty() {
n.worldDirty = true
for _, child := range n.children {
child.markWorldDirty()
}
}

5.2 TypeScript 实现#

// 脏标记延迟计算包装器
class DirtyFlag<T> {
private dirty = true;
private cached: T | undefined;
constructor(private compute: () => T) {}
// 标记为脏,下次 get() 时重算
markDirty(): void {
this.dirty = true;
}
// 获取值,脏时重算并缓存
get(): T {
if (this.dirty) {
this.cached = this.compute();
this.dirty = false;
}
return this.cached!;
}
get isDirty(): boolean {
return this.dirty;
}
}

场景图 1000 个节点,根节点移动,所有子孙变脏。但本帧只渲染 3 个节点——只会发生 3 次重算(加上祖先节点的缓存命中)。标记 1000 个节点为脏只是翻转 1000 个布尔值,代价极低。关键洞察:脏标记的开销与被读取的节点数成正比,而非与被标脏的节点数成正比。

六、生产验证#

  • Chromium/Blinklayout_object.h#L1425-L1430NeedsLayout() 返回布局对象的几何是否脏。CSS 属性变更时 SetNeedsLayout() 将节点及祖先标记为脏,布局计算仅在下一个布局阶段执行——将数百次 DOM 变更批处理为单次布局计算。
  • ReactReactFiberFlags.js#L18-L22 中 Fiber 标志如 PlacementUpdateDeletion 是 fiber 节点上的脏标记。状态变更时 fiber 被标记,commit 阶段仅处理具有非零标志的 fiber,完全跳过未变化的子树。
  • Unity EngineTransform.hasChanged 标记延迟世界矩阵重计算,是游戏引擎中最广泛使用的脏标记实现。

七、小结#

何时使用:

  • UI 布局引擎——样式变更时标记节点为脏,批量执行布局计算
  • 游戏场景图——脏世界变换从父节点级联到子节点,仅在渲染时重计算
  • 电子表格单元格——输入变化时标记依赖单元格为脏,显示时重计算
  • 构建系统——源文件变化时标记目标为脏,仅重建需要的部分

何时不用:

  • 重计算成本低——如果计算只需纳秒级,标记检查反而增加了无益的开销
  • 每次变更都需要结果——每次写入后都要读取时,只是在每个操作上增加了标记检查
  • 无同步的并发访问——脏标记本质上是可变共享状态,并发读写需要锁或原子操作

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

脏标记(Dirty Flag)
https://blog.souloss.com/posts/programming/system-patterns/system-patterns-dirty-flag/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时