前缀树红点系统之lua快速实现

257 阅读8分钟

首先简单介绍下前缀树红点系统:

游戏中有很多红点用于标识我们有未完成的任务和未领取的奖励等,比如一个公会红点,里面包含公会任务,公会商店,公会技能等红点,公会任务下又包含积分红点,任务奖励红点等等...如下图:

红点树.png

根据上图中的红点结构图可知,在管理红点系统的时候利用树结构管理最为合理。

在通常的红点管理代码中,我们通常会实现所有叶结点(任务奖励红点,积分奖励红点)的红点检查函数,例如 :

  • CheckTaskRewardRedDot()
  • CheckTaskPointRedDot()
  • CheckShopType1RedDot()
  • CheckOrdinarySkillRedDot()
  • CheckCoreSkillRedDot()

然后通过组合 CheckTaskRewardRedDot()CheckTaskPointRedDot() 来实现公会任务的红点检查函数 CheckTaskRedDot(), 其他同理,于是分别有了 CheckShopRedDot(),CheckSkillRedDot 红点检查函数,随后再组合成公会总红点检查函数 CheckFactionRedDot()

这是最直接的红点系统方案,缺点也很明显,比如红点的检查是由上及下的,比如主界面要检查公会红点,就要调用 CheckFactionRedDot(),而 CheckFactionRedDot() 要调用 CheckTaskRedDot(),CheckShopRedDot(),CheckSkillRedDot(),也就是要检查所有的公会红点,如果只是一个简单的任务奖励红点改变了,这个时候发出红点更新的通知,主界面监听到更新红点却要更新整个公会红点,花费十分巨大,很不划算。倘若是主界面还有定时器更新红点更新函数,那更是一场灾难!

还有一个缺点是主界面需要监听所有红点改变的通知,才能正确刷新红点状态。

所以我们的首个改变思路是可以将由上及下的红点检查,改为由下及上的,让叶子结点红点变化后自发的通知它的父节点也去更新,一层一层的通知到 Root 结点。这样的话对于红点检查的算力的安排就十分合理。不过要这样做的话,我们需要构建一个红点树,并且让子节点和父结点之间能够相互关联。

那么当子节点更新发出通知后,父节点如何判断自己的是否该显示红点呢?我们在结点代码中定义了一个value 值,这 value 值是的它子节点的 value 值的和,当 value 大于 0 就代表它的子结点中有正在展示红点的结点,它本身也就需要展示红点。而叶节点的 value 值就根据它自身的红点检查函数来获取

还有一个问题需要解决的是结点的查找问题,如果采用遍历的话也可以,虽然解决了由上及下的问题,却增加了遍历结点算力消耗问题,并不是很划算,自此,便可以引入前缀树了。

前缀树的优点是查找时间复杂度是O (k), k 为路径的长度,并且通过路径结构将树划分。接下来看代码。

  • 前缀树红点结点

    ---@class RedDotTreeNode : LuaClass
    local RedDotTreeNode = class("RedDotTreeNode")
    
    ---@param name string
    ---@param parent RedDotTreeNode
    function RedDotTreeNode:Ctor(name, parent)
        self:SuperCall(RedDotTreeNode)
        
        self.Value = 0
        self.Name = name or ""
        ---@type RedDotTreeNode
        self.Parent = parent
        self.m_fullPath = ""
        ---@type table<integer, fun(value:number):nil>
        self.m_changeCallBackList = {}
        ---@type table<string, RedDotTreeNode>
        self.m_children = {}
    end
    
    ---@return string
    function RedDotTreeNode:GetFullPath()
        if self.m_fullPath == "" then
            if self.Parent and self.Parent ~= RedDotManager.GetInstance().Root then
                self.m_fullPath = self.Parent:GetFullPath() .. RedDotManager.SplitChar .. self.Name
            else
                self.m_fullPath = self.Name
            end
        end
        return self.m_fullPath
    end
    
    ---@return integer
    function RedDotTreeNode:GetChildCount()
        return table.nums(self.m_children)
    end
    
    ---添加监听,禁止传入匿名函数
    ---@param func fun(value:number):nil
    function RedDotTreeNode:AddListener(func)
        for _, callBackFunc in ipairs(self.m_changeCallBackList) do
            if callBackFunc == func then
                return
            end
        end
        table.insert(self.m_changeCallBackList, func)
    end
    
    ---移除监听,禁止传入匿名函数
    ---@param func fun(value:number):nil
    function RedDotTreeNode:RemoveListener(func)
        for index, callBackFunc in ipairs(self.m_changeCallBackList) do
            if callBackFunc == func then
                table.remove(self.m_changeCallBackList, index)
                break
            end
        end
    end
    
    ---改变节点值
    ---@param value? number
    function RedDotTreeNode:ChangeValue(value)
        if value then
            if self:GetChildCount() > 0 then
                error("不允许直接改变非叶子节点的值" .. self)
            end
            self:InternalChangeValue(value)
        else
            local sum = 0
            if self:GetChildCount() > 0 then
                for _, v in pairs(self.m_children) do
                    sum = sum + v.Value
                end
            end
            self:InternalChangeValue(sum)
        end
    end
    
    ---@param key string
    ---@return RedDotTreeNode
    function RedDotTreeNode:GetOrAddChild(key)
        local child = self:GetChild(key)
        if not child then
            child = self:AddChild(key)
        end
        return child;
    end
    
    ---@param key string
    ---@return RedDotTreeNode
    function RedDotTreeNode:AddChild(key)
        if self.m_children[key] then
            error("子节点添加失败,不允许重复添加:" .. self:GetFullPath())
        end
    
        ---@type RedDotTreeNode
        local child = RedDotTreeNode.New(key, self)
        self.m_children[key] = child
        return child
    end
    
    ---@return RedDotTreeNode | nil
    function RedDotTreeNode:GetChild(key)
        return self.m_children[key]
    end
    
    ---@param path string
    ---@return boolean
    function RedDotTreeNode:RemoveChild(path)
        if self:GetChildCount() == 0 then
            return false
        end
        local child = self:GetChild(path)
        if child then
            RedDotManager.GetInstance():MarkDirtyNode(child)
            self.m_children[path] = nil
            return true
        end
        return false
    end
    
    function RedDotTreeNode:RemoveAllChild()
        self.m_children = {}
    end
    
    ---改变节点值
    ---@private
    function RedDotTreeNode:InternalChangeValue(newValue)
        if self.Value == newValue then
            return
        end
        self.Value = newValue
        if self.m_changeCallBackList then
            for _, callBackFunc in ipairs(self.m_changeCallBackList) do
                callBackFunc(newValue)
            end
        end
        RedDotManager.GetInstance():MarkDirtyNode(self.Parent)
    end
    
    RedDotTreeNode.__tostring = function (self)
        return self:GetFullPath()
    end
    
    return RedDotTreeNode
    
  • 前缀树红点系统管理代码

    local RedDotTreeNode = require("Reddot/RedDotTreeNode")
    
    ---@class RedDotManager : LuaClass
    local RedDotManager = class("RedDotManager")
    
    RedDotManager.SplitChar = "/"
    
    function RedDotManager:Ctor()
        ---@type RedDotManager
        self.m_instance = nil
        ---@type table<string, RedDotTreeNode>
        self.m_allNodes = {}
        ---@type table<string, RedDotTreeNode>
        self.m_dirtyNodes = {}
        ---@type RedDotTreeNode[]
        self.m_tempDirtyNodes = {}
        ---@type RedDotTreeNode
        self.Root = RedDotTreeNode.New("Root")
    end
    
    ---@return RedDotManager
    function RedDotManager.GetInstance()
        if RedDotManager.m_instance == nil then
            RedDotManager.m_instance = RedDotManager.New()
        end
        return RedDotManager.m_instance
    end
    
    ---添加监听,禁止传入匿名函数,AddListener与RemoveListener必须成对出现
    ---@param path string
    ---@param callBackFunc fun(value:number):nil
    ---@return RedDotTreeNode | nil
    function RedDotManager:AddListener(path, callBackFunc)
        if not callBackFunc then
            return nil
        end
        local node = self:GetTreeNode(path)
        if node then
            node:AddListener(callBackFunc)
        end
        return node
    end
    
    ---删除监听,禁止传入匿名函数
    ---@param path string
    ---@param callBackFunc fun(value:number):nil
    ---@return RedDotTreeNode | nil
    function RedDotManager:RemoveListener(path, callBackFunc)
        if not callBackFunc then
            return
        end
        local node = self:GetTreeNode(path)
        node:RemoveListener(callBackFunc)
    end
    
    function RedDotManager:RemoveAllListener(path)
        local node = self:GetTreeNode(path)
        node:RemoveAllListener()
    end
    
    ---@return number
    function RedDotManager:GetValue(path)
        local node = self:GetTreeNode(path)
        if not node then
            return 0
        end
        return node.Value
    
    end
    
    ---@param path string
    ---@return RedDotTreeNode
    function RedDotManager:GetTreeNode(path)
        if path == "" then
            error("路径不能为空")
        end
    
        if self.m_allNodes[path] then
            return self.m_allNodes[path]
        end
    
    	-- 这里根据路径插入前缀树,并保存起来
        local cur = self.Root
        local length = #path
        local startIndex = 1
        for i = 1, length do
            local char = path:sub(i, i)
            if char == RedDotManager.SplitChar then
                if i == length then
                    error("路径不合法,不能以分隔符结尾" .. path)
                end
                local endIndex = i - 1
                if endIndex < startIndex then
                    error("路径不合法,不能存在连续的路径分隔符或以路径分隔符开头:" .. path)
                end
    
                local child = cur:GetOrAddChild(path:sub(startIndex, endIndex))
                startIndex = i + 1
                cur = child
            end
        end
    
        local target = cur:GetOrAddChild(path:sub(startIndex, length))
        self.m_allNodes[path] = target
        return target
    end
    
    ---@param path string
    ---@return boolean
    function RedDotManager:RemoveTreeNode(path)
        if not self.m_allNodes[path] then
            return false
        end
        local node = self:GetTreeNode(path)
        self.m_allNodes[path] = nil
        if node.Parent then
            return node.Parent:RemoveChild(node.Name)
        else
            return true
        end
    end
    
    function RedDotManager:RemoveAllTreeNode()
        self.Root:RemoveAllChild()
        self.m_allNodes = {}
    end
    
    ---@param node RedDotTreeNode
    function RedDotManager:MarkDirtyNode(node)
        if not node or node == self.Root then
            return
        end
        self.m_dirtyNodes[node:GetFullPath()] = node
    end
    
    ---管理器轮询
    function RedDotManager:Update()
        if table.nums(self.m_dirtyNodes) == 0 then
            return
        end
        self.m_tempDirtyNodes = {}
        -- 这里可以控制每帧更新多少个结点
        for _, v in pairs(self.m_dirtyNodes) do
            table.insert(self.m_tempDirtyNodes, v)
        end
        self.m_dirtyNodes = {}
        for _, v in ipairs(self.m_tempDirtyNodes) do
            v:ChangeValue()
        end
    end
    
    return RedDotManager
    
  • 构建红点系统代码

    ---@class FactionRedDotUtil : LuaClass
    local FactionRedDotUtil = class("FactionRedDotUtil")
    
    -- 这里每一个路径代表一个结点,多层路径的代表是子节点
    FactionRedDotUtil.RedDotPathDic = {
        Faction = "Faction",
    
    	-- 大厅
        Lobby = "Faction/Lobby",
    
        -- 技能
        Skill = "Faction/Skill",
    
      	-- 任务
        Mission = "Faction/Mission",
        MissionPointReward = "Faction/Mission/MissionPointReward",
        MissionReward = "Faction/Mission/MissionReward",
        
    	-- GVG    
        GVG = "Faction/GVG",
    }
    
    FactionRedDotUtil.isInit = false
    
    function FactionRedDotUtil.Init()
        if not FactionRedDotUtil.isInit then
            ---@type table<string, fun(value:number):nil>
            FactionRedDotUtil.RedDotChangeCallFuncDic = {
                [FactionRedDotUtil.RedDotPathDic.Faction] = FactionRedDotUtil.FactionReddotChangeCallback,
                [FactionRedDotUtil.RedDotPathDic.Lobby] = FactionRedDotUtil.LobbyReddotChangeCallback,
                [FactionRedDotUtil.RedDotPathDic.Skill] = FactionRedDotUtil.SkillReddotChangeCallback,
                [FactionRedDotUtil.RedDotPathDic.Mission] = FactionRedDotUtil.MissionReddotChangeCallback,
                [FactionRedDotUtil.RedDotPathDic.MissionPointReward] = FactionRedDotUtil.MissionPointRewardReddotChangeCallback,
                [FactionRedDotUtil.RedDotPathDic.MissionReward] = FactionRedDotUtil.MissionRewardReddotChangeCallback,
                [FactionRedDotUtil.RedDotPathDic.GVG] = FactionRedDotUtil.FactionGVGReddotChangeCallback,
            }
    
            for _, v in pairs(FactionRedDotUtil.RedDotPathDic) do
                RedDotManager.GetInstance():AddListener(v, FactionRedDotUtil.RedDotPathDic[v])
            end
    
            FactionRedDotUtil.isInit = true
        end
    end
    
    -- 公会主红点值改变回调
    function FactionRedDotUtil.FactionReddotChangeCallback(value)
        LuaAppFacade.getIns():SendNotification(NoticeConst.FACTION_MAIN_REDDOT_CHANGE, value)
    end
    
    -- 公会大厅红点值改变回调
    function FactionRedDotUtil.LobbyReddotChangeCallback(value)
        LuaAppFacade.getIns():SendNotification(NoticeConst.FACTION_LOBBY_REDDOT_CHANGE, value)
    end
    
    -- 公会技能红点值改变回调
    function FactionRedDotUtil.SkillReddotChangeCallback(value)
        LuaAppFacade.getIns():SendNotification(NoticeConst.FACTION_SKILL_REDDOT_CHANGE, value)
    end
    
    -- 公会任务红点值改变回调
    function FactionRedDotUtil.MissionReddotChangeCallback(value)
        LuaAppFacade.getIns():SendNotification(NoticeConst.FACTION_MISSION_REDDOT_CHANGE, value)
    end
    
    -- 公会任务积分奖励红点值改变回调
    function FactionRedDotUtil.MissionPointRewardReddotChangeCallback(value)
        LuaAppFacade.getIns():SendNotification(NoticeConst.FACTION_MISSION_POINT_REWARD_REDDOT_CHANGE, value)
    end
    
    -- 公会任务奖励红点值改变回调
    function FactionRedDotUtil.MissionRewardReddotChangeCallback(value)
        LuaAppFacade.getIns():SendNotification(NoticeConst.FACTION_MISSION_REWARD_REDDOT_CHANGE, value)
    end
    
    -- 公会GVG红点值改变回调
    function FactionRedDotUtil.FactionGVGReddotChangeCallback(value)
        -- GVG模块主红点
        -- 这里不用发出通知的原因是GVG被设计成了一个单独模块,也有属于自己模块的前缀树系统,只需要监听GVG模块的前缀树红点系统就可以
    	-- 不过也可以放在这里通知并监听,这样会让模块更加内聚
    end
    
    FactionRedDotUtil.Init()
    
    return FactionRedDotUtil
    

    LuaAppFacade.getIns():SendNotification (NoticeConst. FACTION_MISSION_REWARD_REDDOT_CHANGE, value) 是本项目中一种监听机制,读者可替换为其他。

  • 红点值监听代码
    此时,一个模块的红点系统已经构建好了,如果我们需要关注某一个红点的更新,只需要去监听这个红点改变回调发出的通知即可,比如主界面需要关注公会总红点更新,就需要去监听 NoticeConst.FACTION_MAIN_REDDOT_CHANGE 这个通知。公会界面需要关注任务总红点的更新,就需要去监听 NoticeConst.FACTION_MISSION_REDDOT_CHANGE 这个通知。

    ---@class MainUI : LuaModule
    local MainUI = class("MainUI", LuaModule)
    function MainUI:ModuleAwake( ... )
        self:SuperCall(MainUI, "ModuleAwake");
        
        self.m_dicNoticeHandle[NoticeConst.FACTION_MAIN_REDDOT_CHANGE] = self.UpdateFactionPageRed
    end
    
    ---更新公会红点
    function MianUI:UpdateFactionPageRedDot(value)
        value = value or RedDotManager.GetInstance():GetValue(FactionRedDotUtil.RedDotPathDic.Faction)
        self.btnFactionRedDot:SetActive(value > 0)
    end
    
    return MainUI
    
    ---@class Faction : LuaModule
     local Faction = class("Faction", LuaModule)
     function Faction:ModuleAwake( ... )
         self:SuperCall(Faction, "ModuleAwake");
         
         self.m_dicNoticeHandle[NoticeConst.FACTION_MISSION_REDDOT_CHANGE] = self.UpdateFactionMissionRedDot
     end
     
     ---更新公会任务红点
     function Faction:UpdateFactionMissionRedDot(value)
         value = value or RedDotManager.GetInstance():GetValue(FactionRedDotUtil.RedDotPathDic.Mission)
         self.btnMissionRedDot:SetActive(value > 0)
     end
     
     return Faction
    
  • 红点值控制代码
    红点系统和红点值监听都搭建好了以后,我们就要根据实际情况去实现更新红点值的代码,来使整个红点系统运作起来

    ---@Controller : SimpleCommand
    local Controller = class("Controller", Puremvc.SimpleCommand)
    
    function Controller:Ctor()
    	 self.m_noticeHandle = {
    	 	
    	 	-- 这里只简单展示任务红点的更新
    	 
    		 ---角色value值初始化
    		 -- 由于主界面一上线就要显示各个模块的红点,此时需要根据角色信息来初始化红点系统的值,和主界面初始化刷新红点是一个道理
    		 -- LOGIN_UPDATE_ROLE_ALL_VALUES 这个通知就是上线角色数据改变的通知
                    [NoticeConst.LOGIN_UPDATE_ROLE_ALL_VALUES] = function ()
                        self:UpdateMissionPointRedDot()
                        self:UpdateMissionRedDot()
                    end,
                    [NoticeConst.VALUE_ROLE_FACTION_MISSION_AWARD_CHANGE] = self.UpdateMissionPointRedDot,
                    [NoticeConst.VALUE_ROLE_FACTION_MISSION_POINT_CHANGE] = self.UpdateMissionPointRedDot, 
                    [NoticeConst.TASK_FINISH_AWARD_RET] = self.UpdateMissionRedDot,
                    [NoticeConst.TASK_NOTIFY_DATA] = self.UpdateMissionRedDot,
    	 }
    end
    
    -- 任务积分红点值更新
    function Controller:UpdateMissionPointRedDot()
    	-- GameData.factionData.CheckMissionPointRewardReddot()是任务积分的红点检查函数
        local value = GameData.factionData.CheckMissionPointRewardReddot() and 1 or 0
        local redDotTreeNode = RedDotManager.GetInstance():GetTreeNode(FactionRedDotUtil.RedDotPathDic.MissionPointReward)
        if redDotTreeNode:GetChildCount() == 0 then
        	-- 只有叶子结点可以改变红点值
            redDotTreeNode:ChangeValue(value)
        end
    end
    
    -- 任务奖励红点值更新
    function Controller:UpdateMissionRedDot()
        local value = GameData.factionData.CheckMissionRewardReddot() and 1 or 0
        local redDotTreeNode = RedDotManager.GetInstance():GetTreeNode(FactionRedDotUtil.RedDotPathDic.MissionReward)
        if redDotTreeNode:GetChildCount() == 0 then
    		-- 只有叶子结点可以改变红点值	    
            redDotTreeNode:ChangeValue(value)
        end
    end
    
    return Controller
    

大功告成!此红点系统的优点就是易于使用,可以很方便的融入当前的项目,且大概率不和以前的旧红点系统产生冲突。如果是新项目,也可以不分模块来创建红点系统,直接一个创建一个总的红点系统管理所有红点。