摘要:红点是游戏与 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;当功能模块达到十几个甚至几十个时,这种写法会迅速演变为:
- 聚合错误:某个子功能加了红点,但主入口没同步更新;
- 视觉误显:未开放的功能由于 prefab 默认 active=true,莫名其妙地红了;
- 时序崩溃:业务先 setState,UI 节点还没挂载,状态被吞掉;
- 高频刷新:登录瞬间几十条协议,每个红点都重算一次祖先树,主线程卡顿。
这正是需要一个专门红点系统的根本原因:把『红点的状态』与『红点的显示』从散落的业务代码中抽象出来,用一棵树、一个门面、一套事件机制统一管理。
二、整体架构:双层注册模型
这套红点系统最核心的设计决策是:把数据层与显示层彻底分离。
图 1:红点系统整体架构(双层注册模型)
2.1 Layer1 数据层:自动注册,常驻内存
启动期调用registerAllRedDots(),系统遍历RedDotPath常量全集,自动把所有路径ensureNode到树上。这意味着:
- 即使某个子功能面板尚未打开、其显示组件尚未挂载,父级聚合红点仍然能反映该子功能的真实数据;
- 彻底杜绝「聚合漏报」。
2.2 Layer2 显示层:被动绑定,随组件生灭
RedDotComponent在onLoad中调用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是组合模式的核心。每个节点既是叶子也是容器:
- 叶子:由
setState或setCheckFn直接设置状态; - 容器:通过
_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; }这里有两个细节值得品味:
enabled关闭的节点在聚合时被continue跳过,从父节点视角看「这个子功能不存在」;- 父节点只有在至少一个子节点带数字时才显示数字,否则只显示红点圆点。
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 背后发生了什么
可以分解为三个关键动作:
- 写暂存:即使状态节点尚未创建,
_stateStore也会把状态留住; - 置脏+冒泡:
setState只把脏标记向上传播,重算被延迟; - 后序合并刷新:在微任务中统一做 DFS 后序遍历,子节点先刷新,父节点再聚合,保证结果正确。
这种设计叫做coalescing(合并刷新)。假设登录瞬间有 20 个子红点同时变更,传统的写法会触发 20 次祖先重算;而这套系统最多只触发一次后序遍历,性能稳定。
五、设计原则:从源码看软件工程
5.1 单一职责原则(SRP)
职责拆开后,单测可以只针对RedDotNode的聚合逻辑做,无需拉起整个 UI 场景。
5.2 开闭原则(OCP)
新增一个红点的成本:
- 在
RedDotPath中新增一条常量; - 在 UI 上挂一个
RedDotComponent并填路径。
不需要修改RedDotManager或RedDotNode的任何逻辑,这就是对扩展开放、对修改关闭。
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()进一步支持把一段操作合并到回调结束才刷新。
isOn和count的 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 显示随生灭)通过分层分离同时达成;把易错的时序问题通过『永远先暂存』转化为确定性的状态应用。
如果你正在设计或重构自己的红点系统,建议直接参考这个架构:
- 用常量表作为数据节点的唯一事实源;
- 用自动注册保证数据节点常驻,而非手动按需创建;
- 用组件生命周期驱动显示层绑定,而非手动订阅;
- 用合并刷新与暂存表处理高频写入和时序问题;
- 用功能开关 + 写入约束而非删除节点关闭未开放功能:
setState用getNode不自动创建节点(未注册路径绝不产生僵尸节点),运行时用setNodeEnabled(false)临时关闭。
本文基于 CCFW 框架
Framework/reddot模块最新源码(Cocos Creator 3.x + TypeScript)分析而成,涵盖flush(force)脏标记短路、功能开关双层防线等已落地优化。如果你对这个话题感兴趣,欢迎在评论区交流你的红点系统实践经验。