Unity实现通用红点树

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

业务结构

RedPointSystem--->	红点树结构:
     main
|    |----club
|    |----msg
|    |    |----msg2
|    |    |----msg3
|    |    |----msg1
|    |----dress
|    |    |----d1
|    |    |----d2

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

代码实现

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

local class = require("middleclass")

---@class RedPointNode
local RedPointNode = class("RedPointNode")

local log = function(...)
    print("zcl RedPointNode--->", ...)
end

---@param numChangeFunc function 
function RedPointNode:initialize()
    ---@type string
    self.nodeName = ""
    ---@type number
    self.pointNum = 0
    ---@type RedPointNode
    self.parentNode = nil
    ---@type function
    self.numChangeFunc = nil
    ---key:name value:RedPointNode
    ---@type table<string, RedPointNode>
    self.childNodeMap = {}
end

function RedPointNode:SetRedPointNum(num)
    if self:Size() > 0 then
        log("红点数量只能设置叶子节点")
        return
    end

    self.pointNum = num
    self:NotifyPointNumChange()
    if self.parentNode then
        self.parentNode:ChangePredPointNum()
    end
end

function RedPointNode:ChangePredPointNum()
    local num = 0
    for key, value in pairs(self.childNodeMap) do
        num = num + value.pointNum
    end

    if num ~= self.pointNum then --- 红点数量变化
        self.pointNum = num
        self:NotifyPointNumChange()
    end
end

function RedPointNode:NotifyPointNumChange()
    if self.numChangeFunc then
        self:numChangeFunc(self)
    end

    --- 通知上级Node
    if self.parentNode then
        self.parentNode:ChangePredPointNum()
    end
end

function RedPointNode:Size()
    local count = 0
    for key, value in pairs(self.childNodeMap) do
        count = count + 1
    end
    return count
end

return RedPointNode
local class = require("middleclass")

---@type RedPointNode
local RedPointNode = require(App.ModName.."/config/lua_lib/RedPointNode")

local log = function(...)
    print("zcl RedPointSystem--->", ...)
end

---@class RedPointSystem
local RedPointSystem = class("RedPointSystem")

---@alias ChangeHandler fun(node: RedPointNode)
---@param nodeNameList table 红点节点名字列表(根结点必须放在第一个位置)
---@param changeCallback ChangeHandler 红点数量变化回调
function RedPointSystem:initialize(nodeNameList)
    ---@type RedPointNode
    self.rootNode = nil
    ---@type table<RedPointNode>
    self.nodeList = {}
    -- g_Log("zcl RedPointNode--->", RedPointNode)
    --- 根据名称列表初始化出节点
    self:InitRootNode(nodeNameList)
end

function RedPointSystem:InitRootNode(nodeNameList)
    self.rootNode = RedPointNode:new()
    self.rootNode.nodeName = nodeNameList[1]
    for i = 2, #nodeNameList do
        local node = self.rootNode       
        local names = self:split(nodeNameList[i], ".")
        if #names > 1 then
            for index, value in ipairs(names) do
                if node.childNodeMap[value] == nil then
                    local newNode = RedPointNode:new()
                    node.childNodeMap[value] = newNode
                end
                node.childNodeMap[value].parentNode = node
                node.childNodeMap[value].nodeName = value
                node = node.childNodeMap[value]
            end
        end
    end
    
    --- print node by tree
    -- self:PrintNode(self.rootNode)
    local result = {}
    self:TraverseNode(self.rootNode.childNodeMap["main"], 0, result)
    local logStr = table.concat(result, "\n")
    log("红点树结构:\n", logStr)
end

---@param node RedPointNode
function RedPointSystem:TraverseNode(node, indent, result)
    indent = indent or 0
    local space = string.rep("|    ", indent)
    if indent > 0 then
        space = space .. "|----"
    end
    table.insert(result, space .. node.nodeName)
    for _, childNode in pairs(node.childNodeMap) do
        self:TraverseNode(childNode, indent + 1, result)
    end
end

---@param nodeStr string
---@param changeCallback ChangeHandler
function RedPointSystem:SetRedPointNodeCallBack(nodeStr, changeCallback)
    local nodeList = self:split(nodeStr, ".")
    if #nodeList == 1 and nodeList[1] ~= "main" then
        log("唯一节点还是非根节点,错误!!")
        return
    end

    local node = self.rootNode
    for index, value in ipairs(nodeList) do
        if node.childNodeMap[value] == nil then
            log("不存在的Node --->", value)
            return
        end

        node = node.childNodeMap[value]
        if index == #nodeList then
            --- 最后一个节点
            node.numChangeFunc = changeCallback
            return
        end
    end
end

--- 设置节点数量
---@param nodeStr string
---@param num number
function RedPointSystem:SetInvoke(nodeStr, num)
    local nodeList = self:split(nodeStr, ".")
    if #nodeList == 1 and nodeList[1] ~= "main" then
        log("唯一节点还是非根节点,错误!!")
        return
    end

    local node = self.rootNode
    for index, value in ipairs(nodeList) do
        if node.childNodeMap[value] == nil then
            log("不存在的Node --->", value)
            return
        end

        node = node.childNodeMap[value]
        if index == #nodeList then
            --- 最后一个节点
            node:SetRedPointNum(num)
        end
    end
end

function RedPointSystem:split(str, delimiter)
    local result = {}
    local pattern = string.format("([^%s]+)", delimiter)
    for token in string.gmatch(str, pattern) do
        table.insert(result, token)
    end
    return result
end

return RedPointSystem

实际使用示例:

local NodeEnum = {
    MAIN = "main",
    MAIN_CLUB = "main.club",
    MAIN_DRESS = "main.dress",
    MAIN_DRESS_D1 = "main.dress.d1",
    MAIN_DRESS_D2 = "main.dress.d2",
    MAIN_MSG = "main.msg",
    MAIN_MSG_MSG1 = "main.msg.msg1",
    MAIN_MSG_MSG2 = "main.msg.msg2",
    MAIN_MSG_MSG3 = "main.msg.msg3",
}

---@param worldElement CS.Tal.framesync.WorldElement
function FsyncElement:initialize(worldElement)
    FsyncElement.super.initialize(self, worldElement)
    self:InitService()
    self:InitView()

    ---@type RedPointSystem
    local RedPointSystem = require(App.ModName .. "/config/lua_lib/RedPointSystem")
    ---@type RedPointSystem
    self.rps = RedPointSystem:new(
        {
            NodeEnum.MAIN,
            NodeEnum.MAIN_CLUB,
            NodeEnum.MAIN_DRESS,
            NodeEnum.MAIN_DRESS_D1,
            NodeEnum.MAIN_DRESS_D2,
            NodeEnum.MAIN_MSG,
            NodeEnum.MAIN_MSG_MSG1,
            NodeEnum.MAIN_MSG_MSG2,
            NodeEnum.MAIN_MSG_MSG3
        }
    )

    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN, function(node) 
        -- log("node--->", node.nodeName, node.pointNum)
        self.treeRootText.text = "消息总数量:" .. node.pointNum
    end)

    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_CLUB, function(node) 
        self.clubNodeText.text = "" .. node.pointNum
    end)


    ------------------------
    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_DRESS, function(node) 
        self.dressNodeText.text = "" .. node.pointNum
    end)
    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_DRESS_D1, function(node) 
        self.d1NodeText.text = "日常装扮:" .. node.pointNum
    end)
    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_DRESS_D2, function(node) 
        self.d2NodeText.text = "战斗装扮" .. node.pointNum
    end)

    ------------------------
    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_MSG, function(node) 
        self.msgNodeText.text = "" .. node.pointNum
    end)
    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_MSG_MSG1, function(node) 
        self.msg1NodeText.text = "好友邮件:" .. node.pointNum
    end)
    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_MSG_MSG2, function(node) 
        self.msg2NodeText.text = "战队邮件:" .. node.pointNum
    end)
    self.rps:SetRedPointNodeCallBack(NodeEnum.MAIN_MSG_MSG3, function(node) 
        self.msg3NodeText.text = "系统邮件:" .. node.pointNum
    end)

    self:InitEventListener()
end

function FsyncElement:InitEventListener()
    self.commonService:AddEventListener(self.clubAddbtn, "onClick", function()
        self.clubNum = self.clubNum + 1
        self.rps:SetInvoke(NodeEnum.MAIN_CLUB, self.clubNum)
    end)
    self.commonService:AddEventListener(self.clubSubbtn, "onClick", function()
        self.clubNum = self.clubNum - 1
        if self.clubNum < 0 then self.clubNum = 0 end
        self.rps:SetInvoke(NodeEnum.MAIN_CLUB, self.clubNum)
    end)

    self.commonService:AddEventListener(self.d1Addbtn, "onClick", function()
        self.d1Num = self.d1Num + 1
        self.rps:SetInvoke(NodeEnum.MAIN_DRESS_D1, self.d1Num)
    end)
    self.commonService:AddEventListener(self.d1Subbtn, "onClick", function()
        self.d1Num = self.d1Num - 1
        if self.d1Num < 0 then self.d1Num = 0 end
        self.rps:SetInvoke(NodeEnum.MAIN_DRESS_D1, self.d1Num)
    end)
    self.commonService:AddEventListener(self.d2Addbtn, "onClick", function()
        self.d2Num = self.d2Num + 1
        self.rps:SetInvoke(NodeEnum.MAIN_DRESS_D2, self.d2Num)
    end)
    self.commonService:AddEventListener(self.d2Subbtn, "onClick", function()
        self.d2Num = self.d2Num - 1
        if self.d2Num < 0 then self.d2Num = 0 end
        self.rps:SetInvoke(NodeEnum.MAIN_DRESS_D2, self.d2Num)
    end)



    self.commonService:AddEventListener(self.msg1Addbtn, "onClick", function()
        self.mgs1Num = self.mgs1Num + 1
        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG1, self.mgs1Num)
    end)
    self.commonService:AddEventListener(self.msg1Subbtn, "onClick", function()
        self.mgs1Num = self.mgs1Num - 1
        if self.mgs1Num < 0 then self.mgs1Num = 0 end
        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG1, self.mgs1Num)
    end)
    self.commonService:AddEventListener(self.msg2Addbtn, "onClick", function()
        self.msg2Num = self.msg2Num + 1
        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG2, self.msg2Num)
    end)
    self.commonService:AddEventListener(self.msg2Subbtn, "onClick", function()
        self.msg2Num = self.msg2Num - 1
        if self.msg2Num < 0 then self.msg2Num = 0 end
        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG2, self.msg2Num)
    end)
    self.commonService:AddEventListener(self.msg3Addbtn, "onClick", function()
        self.msg3Num = self.msg3Num + 1        
        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG3, self.msg3Num)
    end)
    self.commonService:AddEventListener(self.msg3Subbtn, "onClick", function()
        self.msg3Num = self.msg3Num - 1
        if self.msg3Num < 0 then self.msg3Num = 0 end
        self.rps:SetInvoke(NodeEnum.MAIN_MSG_MSG3, self.msg3Num)
    end)
end

function FsyncElement:InitView()
    ---@type CS.UnityEngine.RectTransform
    self.rootUI = self.VisElement.gameObject.transform:Find("屏幕画布")
    --- 红点树的根
    ---@type CS.UnityEngine.RectTransform
    self.treeRootText = self.rootUI:Find("主页/num").gameObject:GetComponent("Text")

    ---@type CS.UnityEngine.RectTransform
    self.clubNode = self.rootUI:Find("工会")
    self.clubNodeText = self.clubNode:Find("num").gameObject:GetComponent("Text")
    self.clubAddbtn = self.clubNode:Find("addbtn").gameObject:GetComponent("Button")
    self.clubSubbtn = self.clubNode:Find("subbtn").gameObject:GetComponent("Button")
    self.clubNum = 0

    ---@type CS.UnityEngine.RectTransform
    self.dressNode = self.rootUI:Find("装扮")
    self.dressNodeText = self.dressNode:Find("num").gameObject:GetComponent("Text")

    ---@type CS.UnityEngine.RectTransform
    self.d1Node = self.rootUI:Find("日常装扮")
    self.d1NodeText = self.d1Node:Find("文本属性").gameObject:GetComponent("Text")
    self.d1Addbtn = self.d1Node:Find("addbtn").gameObject:GetComponent("Button")
    self.d1Subbtn = self.d1Node:Find("subbtn").gameObject:GetComponent("Button")
    self.d1Num = 0

    ---@type CS.UnityEngine.RectTransform
    self.d2Node = self.rootUI:Find("战斗装扮")
    self.d2NodeText = self.d2Node:Find("文本属性").gameObject:GetComponent("Text")
    self.d2Addbtn = self.d2Node:Find("addbtn").gameObject:GetComponent("Button")
    self.d2Subbtn = self.d2Node:Find("subbtn").gameObject:GetComponent("Button")
    self.d2Num = 0

    ---@type CS.UnityEngine.RectTransform
    self.msgNode = self.rootUI:Find("邮件")
    self.msgNodeText = self.msgNode:Find("num").gameObject:GetComponent("Text")

    ---@type CS.UnityEngine.RectTransform
    self.msg1 = self.rootUI:Find("好友邮件")
    self.msg1NodeText = self.msg1:Find("文本属性").gameObject:GetComponent("Text")
    self.msg1Addbtn = self.msg1:Find("addbtn").gameObject:GetComponent("Button")
    self.msg1Subbtn = self.msg1:Find("subbtn").gameObject:GetComponent("Button")
    self.mgs1Num = 0

    ---@type CS.UnityEngine.RectTransform
    self.msg2 = self.rootUI:Find("战队邮件")
    self.msg2NodeText = self.msg2:Find("文本属性").gameObject:GetComponent("Text")
    self.msg2Addbtn = self.msg2:Find("addbtn").gameObject:GetComponent("Button")
    self.msg2Subbtn = self.msg2:Find("subbtn").gameObject:GetComponent("Button")
    self.msg2Num = 0

    ---@type CS.UnityEngine.RectTransform
    self.msg3 = self.rootUI:Find("系统邮件")
    self.msg3NodeText = self.msg3:Find("文本属性").gameObject:GetComponent("Text")
    self.msg3Addbtn = self.msg3:Find("addbtn").gameObject:GetComponent("Button")
    self.msg3Subbtn = self.msg3:Find("subbtn").gameObject:GetComponent("Button")
    self.msg3Num = 0
end

核心要点

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

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

function RedPointNode:NotifyPointNumChange()
    if self.numChangeFunc then
        self:numChangeFunc(self)
    end

    --- 通知上级Node
    if self.parentNode then
        self.parentNode:ChangePredPointNum()
    end
end

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

1、红点节点定义

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

2、驱动层和表现层分离

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

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

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

可以优化的点

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

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