Cocos Creator 红点系统设计实战:门面 + 组合 + 观察者,教科书级解耦
2026/7/18 3:41:27 网站建设 项目流程

摘要:红点是游戏与 App 最常见的引导 UI 之一。本文基于一套生产级 Cocos Creator 3.x + TypeScript 红点系统源码,从架构、数据流、设计原则三个维度做深度拆解。它通过「门面 + 组合 + 观察者」三种经典模式,以及独创的「双层注册模型」和「脏标记 + 微任务合并」刷新机制,系统性解决了红点系统常见的聚合漏报、视觉误显、时序错乱、高频刷新性能四大难题。文末附可直接落地的 API 速查与最佳实践。


一、为什么红点系统值得单独设计

很多项目初期的红点往往是这样的:

// 业务 A 里写 this.mailRedDot.active = true; // 业务 B 里写 this.mailRedDot.active = hasUnreadMail || hasSystemGift; // 主界面入口写 this.mailEntrance.active = mailInboxRedDot.active || mailSystemRedDot.active;

当功能模块达到十几个甚至几十个时,这种写法会迅速演变为:

  1. 聚合错误:某个子功能加了红点,但主入口没同步更新;
  2. 视觉误显:未开放的功能由于 prefab 默认 active=true,莫名其妙地红了;
  3. 时序崩溃:业务先 setState,UI 节点还没挂载,状态被吞掉;
  4. 高频刷新:登录瞬间几十条协议,每个红点都重算一次祖先树,主线程卡顿。

这正是需要一个专门红点系统的根本原因:把『红点的状态』与『红点的显示』从散落的业务代码中抽象出来,用一棵树、一个门面、一套事件机制统一管理。


二、整体架构:双层注册模型

这套红点系统最核心的设计决策是:把数据层与显示层彻底分离

图 1:红点系统整体架构(双层注册模型)

2.1 Layer1 数据层:自动注册,常驻内存

启动期调用registerAllRedDots(),系统遍历RedDotPath常量全集,自动把所有路径ensureNode到树上。这意味着:

  • 即使某个子功能面板尚未打开、其显示组件尚未挂载,父级聚合红点仍然能反映该子功能的真实数据
  • 彻底杜绝「聚合漏报」。

2.2 Layer2 显示层:被动绑定,随组件生灭

RedDotComponentonLoad中调用bindComponent,在onDestroy中解绑。未挂载组件的场景节点不会显示任何红点,因此:

  • 未加载功能不会产生视觉误显;
  • 显示节点的生命周期与 UI 组件严格一致。

一句话总结:数据常驻保证聚合正确,显示随生灭保证视觉正确。


三、模块拆解

3.1 RedDotDefine.ts:路径常量与自动注册

RedDotPath不仅是常量表,更是数据层节点的唯一事实源

xport const RedDotPath = { Mail: 'Mail', MailInbox: 'Mail/Inbox', MailSystem: 'Mail/System', Event: 'Event', Event_Daily: 'Event/Daily', // ... 更多 } as const; export function registerAllRedDots(): void { const paths = Object.values(RedDotPath); for (const path of paths) RedDotManager.instance.ensureNode(path); }

启动期只需一行:registerAllRedDots(),即可自动完成所有数据节点的注册。新增红点时,无需改动核心引擎,OCP 扩展友好

3.2 RedDotNode.ts:组合节点

RedDotNode是组合模式的核心。每个节点既是叶子也是容器:

  • 叶子:由setStatesetCheckFn直接设置状态;
  • 容器:通过_recalculate()聚合子节点状态。

聚合逻辑非常清晰,单次遍历完成:

private _recalculate(): boolean { let newIsOn = false; let newCount = 0; let hasCount = false; // 单次遍历:同时计算 isOn、count 和 hasCount // 跳过功能关闭(enabled=false)的子节点,使其不参与父节点聚合 for (const child of this._children.values()) { if (!child._enabled) continue; if (child._isOn) { newIsOn = true; newCount += child._count > 0 ? child._count : 1; } if (child._count > 0) { hasCount = true; } } const changed = this._isOn !== newIsOn || this._count !== newCount; this._isOn = newIsOn; // 对于非叶子节点,count 仅在任一子节点有 count 时累加显示 this._count = hasCount ? newCount : 0; if (changed) { this._notifyListeners(); } return changed; }

这里有两个细节值得品味:

  1. enabled关闭的节点在聚合时被continue跳过,从父节点视角看「这个子功能不存在」;
  2. 父节点只有在至少一个子节点带数字时才显示数字,否则只显示红点圆点。

3.3 RedDotManager.ts:门面与编排

RedDotManager以单例作为全局入口,对外提供极简 API,屏蔽内部复杂度。它内部维护了两张「暂存表」|

3.4 RedDotComponent.ts:显示层被动绑定

// 被动绑定:将本组件关联到状态节点,并自动应用业务暂存的状态 / 检查函数。 // 声明于 RedDotPath 的路径已由 Layer1 自动注册;未声明的动态路径由 Manager 按需创建。 RedDotManager.instance.bindComponent(this.redPath); // 监听红点变化(on() 会立即以当前有效状态回调一次,完成初始显隐与数字设置, // 同时避免 prefab 默认 active=true 误显红点,故无需再冗余读取 isOn/count) this._unsub = RedDotManager.instance.on(this.redPath, (isOn: boolean, count: number) => { this.node.active = isOn; this.updateNumber(count); }); onDestroy(): void { this._unsub?.(); // 被动解绑:仅解除显示订阅登记,状态节点保留以维持父级聚合正确性 if (this.redPath) { RedDotManager.instance.unbindComponent(this.redPath); } }

注意:解绑只影响显示订阅,不会销毁状态节点。因为状态节点仍要参与父级聚合,如果误删,父入口的红点就会错误熄灭。

四、数据流:一次 setState 背后发生了什么

可以分解为三个关键动作:

  1. 写暂存:即使状态节点尚未创建,_stateStore也会把状态留住;
  2. 置脏+冒泡setState只把脏标记向上传播,重算被延迟;
  3. 后序合并刷新:在微任务中统一做 DFS 后序遍历,子节点先刷新,父节点再聚合,保证结果正确。

这种设计叫做coalescing(合并刷新)。假设登录瞬间有 20 个子红点同时变更,传统的写法会触发 20 次祖先重算;而这套系统最多只触发一次后序遍历,性能稳定。


五、设计原则:从源码看软件工程

5.1 单一职责原则(SRP)

职责拆开后,单测可以只针对RedDotNode的聚合逻辑做,无需拉起整个 UI 场景。

5.2 开闭原则(OCP)

新增一个红点的成本:

  1. RedDotPath中新增一条常量;
  2. 在 UI 上挂一个RedDotComponent并填路径。

不需要修改RedDotManagerRedDotNode的任何逻辑,这就是对扩展开放、对修改关闭。

5.3 门面模式(Facade)

业务代码无需关心ensureNode_stateStore_flushScheduled等内部概念,只需与RedDotManager.instance交互。门面把内部复杂度封装在一个简洁 API 后面,降低了接入门槛。

5.4 组合模式(Composite)

RedDotNode统一表示叶子与分支。_recalculate无需区分「这是叶子还是父节点」,对所有节点一致处理。树形结构的递归聚合因此非常自然。

5.5 观察者模式(Observer)

RedDotNode._listeners是观察者集合,on()订阅并立即回调当前有效状态。RedDotComponent通过订阅响应状态变化,数据与 UI 彻底解耦。新增任意数量的订阅者都不需要修改节点逻辑。

5.6 事件驱动 + 脏标记延迟合并

这是性能设计的核心:

  • setState只做轻量置脏(O(深度)),不重算;
  • 真正重算延迟到微任务统一执行,多次变更合并为一次;
  • flush(force)借脏标记短路:本节点不脏则整棵子树跳过,单次变更只算变更分支(O(树深));
  • batch()进一步支持把一段操作合并到回调结束才刷新。

isOncount的 getter 会自动flush脏状态,因此即使外部同步读取,也能得到一致结果。

5.7 数据与显示分离(双层注册)

这是该系统最具辨识度的设计。许多项目要么把数据节点和显示节点绑在一起(导致聚合错),要么把数据节点砍掉导致聚合漏报。双层注册同时做到:

  • 数据常驻:父节点永远基于完整数据聚合;
  • 显示随生灭:未挂载组件不会产生视觉误显。

5.8 存储转发(State Stashing)解决时序

业务代码可以先写状态,UI 后挂载。_stateStore_checkFnStore把状态或函数先存下来,等bindComponent时自动应用。这消除了「先写后挂」与「先挂后写」的时序差异。

5.9 失效软化(Fail-soft)

  • on()对未注册路径返回空订阅函数,不抛错;
  • checkFn和监听器回调都包try/catch,单点异常不拖垮整树;
  • DEV模式下校验路径拼写,提前暴露问题。

这套系统在线上极稳,但开发者要注意:错误被静默吞掉时,需要依赖debugTree()和警告日志排查。


六、关键代码赏析

6.1 脏标记向上传播:去重

private _markDirty(): void { if (!this._dirty) { // 已脏则不再冒泡,去重 this._dirty = true; if (this._parent) { // 到达根(parent=null)自然停止 this._parent._markDirty(); } } } !this._dirty 这个判断虽小,却是性能关键:避免同一分支重复冒泡。

6.2 微任务合并刷新

private _scheduleFlush(): void { if (!this._flushScheduled) { // 只调度一次 this._flushScheduled = true; Promise.resolve().then(() => this.flush()); } } 多次 setState 只触发一次微任务,整棵树在下一个微任务中统一刷新。

6.3 功能开关的双层防线

// 聚合层:直接跳过 disabled 子节点 if (!child._enabled) continue; // 读取层:getter 对 disabled 返回 false/0 return this.enabled ? this.isOn : false; return this.enabled ? this.count : 0; ``` 两条防线同时生效,确保未开放功能既不显示红点,也不误触父节点红点。

6.4 初始状态兜底

// on() 订阅时会立即以当前有效状态(isOn/count)回调一次 this._unsub = RedDotManager.instance.on(this.redPath, (isOn, count) => { this.node.active = isOn; this.updateNumber(count); }); // 无需再冗余读取:立即回调已覆盖 prefab 默认 active=true,避免视觉误显 on() 的立即回调天然完成初始显隐与数字设置,因此 onLoad 不再需要末尾显式读取 isOn/count。

6.5 脏标记短路(性能优化)

flush(force)force=false(默认,由setState调度)时会做脏标记短路:

flush(force: boolean = false): void { if (!force && !this._dirty) return; // 本节点不脏 => 整棵子树都不脏,整棵跳过 for (const child of this._children.values()) child.flush(force); this._dirty = false; // 在重算/通知前清除,避免重入 flush // ... checkFn / _recalculate }

脏标记只沿父链向上传播,从不向下,因此「本节点不脏」⇔「整棵子树都不脏」——单次变更的传播复杂度从整棵树 O(N) 回到 O(变更分支深度)。refresh(path)bindComponent使用flush(true)强制重算,无视脏标记,保证 checkFn 叶子刷新 / 挂载即生效。


七、踩坑与最佳实践

7.1 不要直接操控显示节点 active

错误:

this.mailRedDot.active = false; // 错误!会被下一次刷新覆盖

正确:

RedDotManager.instance.setState(RedDotPath.Mail_Inbox, false, 0); // 或关闭功能 RedDotManager.instance.setNodeEnabled(RedDotPath.Mail_Friend, false);

7.2 批量刷新用 batch

batch(callback: () => void): void { this._batchDepth++; try { callback(); } finally { this._batchDepth--; if (this._batchDepth === 0) { this.flush(); } } }

7.3 检查函数优先于频繁 setState

RedDotManager.instance.setCheckFn(RedDotPath.Task_Daily, () => { return this.taskModel.hasDailyReward; }, () => this.taskModel.dailyRewardCount); // 数据变化后统一 refresh RedDotManager.instance.refresh(); ``` 当状态依赖数据模型时,使用 setCheckFn 比到处 setState 更集中、更容易维护。

7.4 路径常量严格匹配

redPath必须与RedDotPath常量完全一致,DEV 模式下有拼写检查。建议把路径写成常量,避免硬编码字符串:

RedDotManager.instance.setState(RedDotPath.Mail_Inbox, true, 1);

八、总结

这套红点系统是一个将经典设计模式落到工业级细节的范例:

  • 门面模式给了业务一个干净入口;
  • 组合模式让层级聚合自然优雅;
  • 观察者模式把数据与 UI 彻底解耦;
  • 双层注册模型同时解决了聚合正确性与显示安全性;
  • 脏标记 + 微任务合并让高频刷新性能可控;
  • 存储转发 + 失效软化让时序与异常都不再致命。

最值得借鉴的设计哲学是:把两个看似矛盾的目标(数据常驻 vs 显示随生灭)通过分层分离同时达成;把易错的时序问题通过『永远先暂存』转化为确定性的状态应用。

如果你正在设计或重构自己的红点系统,建议直接参考这个架构:

  1. 常量表作为数据节点的唯一事实源;
  2. 自动注册保证数据节点常驻,而非手动按需创建;
  3. 组件生命周期驱动显示层绑定,而非手动订阅;
  4. 合并刷新暂存表处理高频写入和时序问题;
  5. 功能开关 + 写入约束而非删除节点关闭未开放功能:setStategetNode不自动创建节点(未注册路径绝不产生僵尸节点),运行时用setNodeEnabled(false)临时关闭。

本文基于 CCFW 框架Framework/reddot模块最新源码(Cocos Creator 3.x + TypeScript)分析而成,涵盖flush(force)脏标记短路、功能开关双层防线等已落地优化。如果你对这个话题感兴趣,欢迎在评论区交流你的红点系统实践经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询