摘要红点是游戏与 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 默认 activetrue莫名其妙地红了时序崩溃业务先 setStateUI 节点还没挂载状态被吞掉高频刷新登录瞬间几十条协议每个红点都重算一次祖先树主线程卡顿。这正是需要一个专门红点系统的根本原因把『红点的状态』与『红点的显示』从散落的业务代码中抽象出来用一棵树、一个门面、一套事件机制统一管理。二、整体架构双层注册模型这套红点系统最核心的设计决策是把数据层与显示层彻底分离。图 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 // 跳过功能关闭enabledfalse的子节点使其不参与父节点聚合 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 默认 activetrue 误显红点故无需再冗余读取 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 组合模式CompositeRedDotNode统一表示叶子与分支。_recalculate无需区分「这是叶子还是父节点」对所有节点一致处理。树形结构的递归聚合因此非常自然。5.5 观察者模式ObserverRedDotNode._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-softon()对未注册路径返回空订阅函数不抛错checkFn和监听器回调都包try/catch单点异常不拖垮整树DEV模式下校验路径拼写提前暴露问题。这套系统在线上极稳但开发者要注意错误被静默吞掉时需要依赖debugTree()和警告日志排查。六、关键代码赏析6.1 脏标记向上传播去重private _markDirty(): void { if (!this._dirty) { // 已脏则不再冒泡去重 this._dirty true; if (this._parent) { // 到达根(parentnull)自然停止 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 默认 activetrue避免视觉误显 on() 的立即回调天然完成初始显隐与数字设置因此 onLoad 不再需要末尾显式读取 isOn/count。6.5 脏标记短路性能优化flush(force)在forcefalse默认由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 批量刷新用 batchbatch(callback: () void): void { this._batchDepth; try { callback(); } finally { this._batchDepth--; if (this._batchDepth 0) { this.flush(); } } }7.3 检查函数优先于频繁 setStateRedDotManager.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)脏标记短路、功能开关双层防线等已落地优化。如果你对这个话题感兴趣欢迎在评论区交流你的红点系统实践经验。