Unity实现通用红点树

在使用Unity开发游戏的时候经常用到红点系统,当玩家点击之后,或者收到服务器数据之后,都需要刷新红点的显示。如果每个人都自己写自己的红点模块,会增加不少的重复任务量,因此迫切需要一个通用的红点系统,其他模块只需要编写自己模块的表现层代码即可。所以单独抽取了一个红点树的逻辑。简单用UGUI拼了个界面,可以先看效果(暂时以数字表示红点数,0就是没有红点):

业务结构

 1RedPointSystem--->	红点树结构:
 2     main
 3|    |----club
 4|    |----msg
 5|    |    |----msg2
 6|    |    |----msg3
 7|    |    |----msg1
 8|    |----dress
 9|    |    |----d1
10|    |    |----d2

这是初始化的时候打印的红点树结构日志,其中 club 为工会,msg 为邮件系统,msg1-3分别表示好友、战队、系统邮件,dress表示装扮,d1 日常装扮,d2 战斗装扮。

代码实现

下面是使用 middleclass 和 lua 实现的静态红点树的代码:

 1local class = require("middleclass")
 2
 3---@class RedPointNode
 4local RedPointNode = class("RedPointNode")
 5
 6local log = function(...)
 7    print("zcl RedPointNode--->", ...)
 8end
 9
10---@param numChangeFunc function 
11function RedPointNode:initialize()
12    ---@type string
13    self.nodeName = ""
14    ---@type number
15    self.pointNum = 0
16    ---@type RedPointNode
17    self.parentNode = nil
18    ---@type function
19    self.numChangeFunc = nil
20    ---key:name value:RedPointNode
21    ---@type table<string, RedPointNode>
22    self.childNodeMap = {}
23end
24
25function RedPointNode:SetRedPointNum(num)
26    if self:Size() > 0 then
27        log("红点数量只能设置叶子节点")
28        return
29    end
30
31    self.pointNum = num
32    self:NotifyPointNumChange()
33    if self.parentNode then
34        self.parentNode:ChangePredPointNum()
35    end
36end
37
38function RedPointNode:ChangePredPointNum()
39    local num = 0
40    for key, value in pairs(self.childNodeMap) do
41        num = num + value.pointNum
42    end
43
44    if num ~= self.pointNum then --- 红点数量变化
45        self.pointNum = num
46        self:NotifyPointNumChange()
47    end
48end
49
50function RedPointNode:NotifyPointNumChange()
51    if self.numChangeFunc then
52        self:numChangeFunc(self)
53    end
54
55    --- 通知上级Node
56    if self.parentNode then
57        self.parentNode:ChangePredPointNum()
58    end
59end
60
61function RedPointNode:Size()
62    local count = 0
63    for key, value in pairs(self.childNodeMap) do
64        count = count + 1
65    end
66    return count
67end
68
69return RedPointNode
  1local class = require("middleclass")
  2
  3---@type RedPointNode
  4local RedPointNode = require(App.ModName.."/config/lua_lib/RedPointNode")
  5
  6local log = function(...)
  7    print("zcl RedPointSystem--->", ...)
  8end
  9
 10---@class RedPointSystem
 11local RedPointSystem = class("RedPointSystem")
 12
 13---@alias ChangeHandler fun(node: RedPointNode)
 14---@param nodeNameList table 红点节点名字列表(根结点必须放在第一个位置)
 15---@param changeCallback ChangeHandler 红点数量变化回调
 16function RedPointSystem:initialize(nodeNameList)
 17    ---@type RedPointNode
 18    self.rootNode = nil
 19    ---@type table<RedPointNode>
 20    self.nodeList = {}
 21    -- g_Log("zcl RedPointNode--->", RedPointNode)
 22    --- 根据名称列表初始化出节点
 23    self:InitRootNode(nodeNameList)
 24end
 25
 26function RedPointSystem:InitRootNode(nodeNameList)
 27    self.rootNode = RedPointNode:new()
 28    self.rootNode.nodeName = nodeNameList[1]
 29    for i = 2, #nodeNameList do
 30        local node = self.rootNode       
 31        local names = self:split(nodeNameList[i], ".")
 32        if #names > 1 then
 33            for index, value in ipairs(names) do
 34                if node.childNodeMap[value] == nil then
 35                    local newNode = RedPointNode:new()
 36                    node.childNodeMap[value] = newNode
 37                end
 38                node.childNodeMap[value].parentNode = node
 39                node.childNodeMap[value].nodeName = value
 40                node = node.childNodeMap[value]
 41            end
 42        end
 43    end
 44    
 45    --- print node by tree
 46    -- self:PrintNode(self.rootNode)
 47    local result = {}
 48    self:TraverseNode(self.rootNode.childNodeMap["main"], 0, result)
 49    local logStr = table.concat(result, "\n")
 50    log("红点树结构:\n", logStr)
 51end
 52
 53---@param node RedPointNode
 54function RedPointSystem:TraverseNode(node, indent, result)
 55    indent = indent or 0
 56    local space = string.rep("|    ", indent)
 57    if indent > 0 then
 58        space = space .. "|----"
 59    end
 60    table.insert(result, space .. node.nodeName)
 61    for _, childNode in pairs(node.childNodeMap) do
 62        self:TraverseNode(childNode, indent + 1, result)
 63    end
 64end
 65
 66---@param nodeStr string
 67---@param changeCallback ChangeHandler
 68function RedPointSystem:SetRedPointNodeCallBack(nodeStr, changeCallback)
 69    local nodeList = self:split(nodeStr, ".")
 70    if #nodeList == 1 and nodeList[1] ~= "main" then
 71        log("唯一节点还是非根节点,错误!!")
 72        return
 73    end
 74
 75    local node = self.rootNode
 76    for index, value in ipairs(nodeList) do
 77        if node.childNodeMap[value] == nil then
 78            log("不存在的Node --->", value)
 79            return
 80        end
 81
 82        node = node.childNodeMap[value]
 83        if index == #nodeList then
 84            --- 最后一个节点
 85            node.numChangeFunc = changeCallback
 86            return
 87        end
 88    end
 89end
 90
 91--- 设置节点数量
 92---@param nodeStr string
 93---@param num number
 94function RedPointSystem:SetInvoke(nodeStr, num)
 95    local nodeList = self:split(nodeStr, ".")
 96    if #nodeList == 1 and nodeList[1] ~= "main" then
 97        log("唯一节点还是非根节点,错误!!")
 98        return
 99    end
100
101    local node = self.rootNode
102    for index, value in ipairs(nodeList) do
103        if node.childNodeMap[value] == nil then
104            log("不存在的Node --->", value)
105            return
106        end
107
108        node = node.childNodeMap[value]
109        if index == #nodeList then
110            --- 最后一个节点
111            node:SetRedPointNum(num)
112        end
113    end
114end
115
116function RedPointSystem:split(str, delimiter)
117    local result = {}
118    local pattern = string.format("([^%s]+)", delimiter)
119    for token in string.gmatch(str, pattern) do
120        table.insert(result, token)
121    end
122    return result
123end
124
125return RedPointSystem

实际使用示例:

  1local NodeEnum = {
  2    MAIN = "main",
  3    MAIN_CLUB = "main.club",
  4    MAIN_DRESS = "main.dress",
  5    MAIN_DRESS_D1 = "main.dress.d1",
  6    MAIN_DRESS_D2 = "main.dress.d2",
  7    MAIN_MSG = "main.msg",
  8    MAIN_MSG_MSG1 = "main.msg.msg1",
  9    MAIN_MSG_MSG2 = "main.msg.msg2",
 10    MAIN_MSG_MSG3 = "main.msg.msg3",
 11}
 12
 13---@param worldElement CS.Tal.framesync.WorldElement
 14function FsyncElement:initialize(worldElement)
 15    FsyncElement.super.initialize(self, worldElement)
 16    self:InitService()
 17    self:InitView()
 18
 19    ---@type RedPointSystem
 20    local RedPointSystem = require(App.ModName .. "/config/lua_lib/RedPointSystem")
 21    ---@type RedPointSystem
 22    self.rps = RedPointSystem:new(
 23        {
 24            NodeEnum.MAIN,
 25            NodeEnum.MAIN_CLUB,
 26            NodeEnum.MAIN_DRESS,
 27            NodeEnum.MAIN_DRESS_D1,
 28            NodeEnum.MAIN_DRESS_D2,
 29            NodeEnum.MAIN_MSG,
 30            NodeEnum.MAIN_MSG_MSG1,
 31            NodeEnum.MAIN_MSG_MSG2,
 32            NodeEnum.MAIN_MSG_MSG3
 33        }
 34    )
 35
 36    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN, function(node) 
 37        -- log("node--->", node.nodeName, node.pointNum)
 38        self.treeRootText.text = "消息总数量:" .. node.pointNum
 39    end)
 40
 41    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_CLUB, function(node) 
 42        self.clubNodeText.text = "" .. node.pointNum
 43    end)
 44
 45
 46    ------------------------
 47    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_DRESS, function(node) 
 48        self.dressNodeText.text = "" .. node.pointNum
 49    end)
 50    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_DRESS_D1, function(node) 
 51        self.d1NodeText.text = "日常装扮:" .. node.pointNum
 52    end)
 53    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_DRESS_D2, function(node) 
 54        self.d2NodeText.text = "战斗装扮" .. node.pointNum
 55    end)
 56
 57    ------------------------
 58    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_MSG, function(node) 
 59        self.msgNodeText.text = "" .. node.pointNum
 60    end)
 61    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_MSG_MSG1, function(node) 
 62        self.msg1NodeText.text = "好友邮件:" .. node.pointNum
 63    end)
 64    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_MSG_MSG2, function(node) 
 65        self.msg2NodeText.text = "战队邮件:" .. node.pointNum
 66    end)
 67    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_MSG_MSG3, function(node) 
 68        self.msg3NodeText.text = "系统邮件:" .. node.pointNum
 69    end)
 70
 71    self:InitEventListener()
 72end
 73
 74function FsyncElement:InitEventListener()
 75    self.commonService:AddEventListener(self.clubAddbtn, "onClick", function()
 76        self.clubNum = self.clubNum + 1
 77        self.rps:SetInvoke(NodeEnum.MAIN_CLUB, self.clubNum)
 78    end)
 79    self.commonService:AddEventListener(self.clubSubbtn, "onClick", function()
 80        self.clubNum = self.clubNum - 1
 81        if self.clubNum < 0 then self.clubNum = 0 end
 82        self.rps:SetInvoke(NodeEnum.MAIN_CLUB, self.clubNum)
 83    end)
 84
 85    self.commonService:AddEventListener(self.d1Addbtn, "onClick", function()
 86        self.d1Num = self.d1Num + 1
 87        self.rps:SetInvoke(NodeEnum.MAIN_DRESS_D1, self.d1Num)
 88    end)
 89    self.commonService:AddEventListener(self.d1Subbtn, "onClick", function()
 90        self.d1Num = self.d1Num - 1
 91        if self.d1Num < 0 then self.d1Num = 0 end
 92        self.rps:SetInvoke(NodeEnum.MAIN_DRESS_D1, self.d1Num)
 93    end)
 94    self.commonService:AddEventListener(self.d2Addbtn, "onClick", function()
 95        self.d2Num = self.d2Num + 1
 96        self.rps:SetInvoke(NodeEnum.MAIN_DRESS_D2, self.d2Num)
 97    end)
 98    self.commonService:AddEventListener(self.d2Subbtn, "onClick", function()
 99        self.d2Num = self.d2Num - 1
100        if self.d2Num < 0 then self.d2Num = 0 end
101        self.rps:SetInvoke(NodeEnum.MAIN_DRESS_D2, self.d2Num)
102    end)
103
104
105
106    self.commonService:AddEventListener(self.msg1Addbtn, "onClick", function()
107        self.mgs1Num = self.mgs1Num + 1
108        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG1, self.mgs1Num)
109    end)
110    self.commonService:AddEventListener(self.msg1Subbtn, "onClick", function()
111        self.mgs1Num = self.mgs1Num - 1
112        if self.mgs1Num < 0 then self.mgs1Num = 0 end
113        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG1, self.mgs1Num)
114    end)
115    self.commonService:AddEventListener(self.msg2Addbtn, "onClick", function()
116        self.msg2Num = self.msg2Num + 1
117        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG2, self.msg2Num)
118    end)
119    self.commonService:AddEventListener(self.msg2Subbtn, "onClick", function()
120        self.msg2Num = self.msg2Num - 1
121        if self.msg2Num < 0 then self.msg2Num = 0 end
122        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG2, self.msg2Num)
123    end)
124    self.commonService:AddEventListener(self.msg3Addbtn, "onClick", function()
125        self.msg3Num = self.msg3Num + 1        
126        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG3, self.msg3Num)
127    end)
128    self.commonService:AddEventListener(self.msg3Subbtn, "onClick", function()
129        self.msg3Num = self.msg3Num - 1
130        if self.msg3Num < 0 then self.msg3Num = 0 end
131        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG3, self.msg3Num)
132    end)
133end
134
135function FsyncElement:InitView()
136    ---@type CS.UnityEngine.RectTransform
137    self.rootUI = self.VisElement.gameObject.transform:Find("屏幕画布")
138    --- 红点树的根
139    ---@type CS.UnityEngine.RectTransform
140    self.treeRootText = self.rootUI:Find("主页/num").gameObject:GetComponent("Text")
141
142    ---@type CS.UnityEngine.RectTransform
143    self.clubNode = self.rootUI:Find("工会")
144    self.clubNodeText = self.clubNode:Find("num").gameObject:GetComponent("Text")
145    self.clubAddbtn = self.clubNode:Find("addbtn").gameObject:GetComponent("Button")
146    self.clubSubbtn = self.clubNode:Find("subbtn").gameObject:GetComponent("Button")
147    self.clubNum = 0
148
149    ---@type CS.UnityEngine.RectTransform
150    self.dressNode = self.rootUI:Find("装扮")
151    self.dressNodeText = self.dressNode:Find("num").gameObject:GetComponent("Text")
152
153    ---@type CS.UnityEngine.RectTransform
154    self.d1Node = self.rootUI:Find("日常装扮")
155    self.d1NodeText = self.d1Node:Find("文本属性").gameObject:GetComponent("Text")
156    self.d1Addbtn = self.d1Node:Find("addbtn").gameObject:GetComponent("Button")
157    self.d1Subbtn = self.d1Node:Find("subbtn").gameObject:GetComponent("Button")
158    self.d1Num = 0
159
160    ---@type CS.UnityEngine.RectTransform
161    self.d2Node = self.rootUI:Find("战斗装扮")
162    self.d2NodeText = self.d2Node:Find("文本属性").gameObject:GetComponent("Text")
163    self.d2Addbtn = self.d2Node:Find("addbtn").gameObject:GetComponent("Button")
164    self.d2Subbtn = self.d2Node:Find("subbtn").gameObject:GetComponent("Button")
165    self.d2Num = 0
166
167    ---@type CS.UnityEngine.RectTransform
168    self.msgNode = self.rootUI:Find("邮件")
169    self.msgNodeText = self.msgNode:Find("num").gameObject:GetComponent("Text")
170
171    ---@type CS.UnityEngine.RectTransform
172    self.msg1 = self.rootUI:Find("好友邮件")
173    self.msg1NodeText = self.msg1:Find("文本属性").gameObject:GetComponent("Text")
174    self.msg1Addbtn = self.msg1:Find("addbtn").gameObject:GetComponent("Button")
175    self.msg1Subbtn = self.msg1:Find("subbtn").gameObject:GetComponent("Button")
176    self.mgs1Num = 0
177
178    ---@type CS.UnityEngine.RectTransform
179    self.msg2 = self.rootUI:Find("战队邮件")
180    self.msg2NodeText = self.msg2:Find("文本属性").gameObject:GetComponent("Text")
181    self.msg2Addbtn = self.msg2:Find("addbtn").gameObject:GetComponent("Button")
182    self.msg2Subbtn = self.msg2:Find("subbtn").gameObject:GetComponent("Button")
183    self.msg2Num = 0
184
185    ---@type CS.UnityEngine.RectTransform
186    self.msg3 = self.rootUI:Find("系统邮件")
187    self.msg3NodeText = self.msg3:Find("文本属性").gameObject:GetComponent("Text")
188    self.msg3Addbtn = self.msg3:Find("addbtn").gameObject:GetComponent("Button")
189    self.msg3Subbtn = self.msg3:Find("subbtn").gameObject:GetComponent("Button")
190    self.msg3Num = 0
191end

核心要点

:::note{title=“NOTE”} 整个系统参考了 《Unity游戏开发】客户端红点系统架构》

原文中有一处BUG,当超过三级节点的时候并子节点没有反馈红点变化事件,因为必须要通知上级Node

 1function RedPointNode:NotifyPointNumChange()
 2    if self.numChangeFunc then
 3        self:numChangeFunc(self)
 4    end
 5
 6    --- 通知上级Node
 7    if self.parentNode then
 8        self.parentNode:ChangePredPointNum()
 9    end
10end

::: 其实理解起来很简单,现在归纳总结一下核心要点:

1、红点节点定义

这一步很重要,因为整个树结构都依赖于这种通过名称定义的方式,通过名称确定 ParentNode、ChildNodes,驱动时也是按照名称去驱动的。

2、驱动层和表现层分离

Node之需要关心当前节点的消息数量即可,至于如何处理UI层,红点树系统本身并不关心,交给回调函数处理

3、只能修改叶子节点的规则

只能修改叶子节点,这样通过层层向上驱动,各个层级的 ParentNode 包括 RootNode 才能正确的更新,具体更新逻辑就是叶子节点发生改变,通知其 ParentNode 扫描本层的节点数量,如果没变,则不会向上层反馈。

可以优化的点

1、同一帧多个子节点同时改变状态,将导致父节点进行额外的无用刷新的问题。

2、可以丰富 Node 信息,根据 type 驱动不同表现层逻辑