一.简介
1.说明
本文为游戏中使用的红点系统的分析,该红点系统基于前缀树完成,代码来源于GitHub开源项目:GitHub - HNGZY/RedDotSystem: 基于前缀数的红点系统。在阅读源码的过程中添加了很多注释,也进行了一些小改动,下面粘贴的源码都是经过我的修改了的,如果需要未经改动的源码可以直接到GitHub上下载。本文使用的红点系统为2021年6月12日下载,推荐Unity版本为2019.4以上(作者使用的版本为2019.4)。
2.红点系统简介
在游戏中,我们经常可以看到有很多红点或者标识提醒我们有未完成的任务或者有未领取的奖励等,这就是红点系统。
3.前缀树简介
前缀树是使用<string,node>的键值对存储的树结构,参考博客:数据结构丨前缀树 - vincent1997 - 博客园 (cnblogs.com)。
二.核心源码分析
1.Assets目录结构分析:
在使用Unity打开下载的工程后,可以看到如下图所示Assets目录结构:
Assets目录中有两个子目录,分别是存储demo代码和场景的Example目录和存储核心代码的Reddot目录。在Reddot目录中,Runtime目录是红点系统的核心代码,Editor目录是作者添加的一些自定义视图窗口。接下来我们将分析Runtime目录下的3个核心代码文件。
2.RangeString:该红点系统使用前缀树的数据结构,前缀树使用了键为字符串、值为节点类的字典存储所有节点信息,RangeString是对string的封装,更方便地作为前缀树字典中的键使用:
using System; /// <summary> /// 范围字符串 /// 表示在Source字符串中,从StartIndex到EndIndex范围的字符构成的字符串 /// 作为前缀树的键使用 /// </summary> public struct RangeString : IEquatable<RangeString> { /// <summary> /// 源字符串 /// </summary> private string m_Source; /// <summary> /// 开始索引 /// </summary> private int m_StartIndex; /// <summary> /// 结束范围 /// </summary> private int m_EndIndex; /// <summary> /// 长度 /// </summary> private int m_Length; /// <summary> /// 源字符串是否为Null或Empty /// </summary> private bool m_IsSourceNullOrEmpty; /// <summary> /// 哈希码 /// </summary> private int m_HashCode; public RangeString(string source, int startIndex, int endIndex) { m_Source = source; m_StartIndex = startIndex; m_EndIndex = endIndex; m_Length = endIndex - startIndex + 1; m_IsSourceNullOrEmpty = string.IsNullOrEmpty(source); m_HashCode = 0; } public bool Equals(RangeString other) { bool isOtherNullOrEmpty = string.IsNullOrEmpty(other.m_Source); if (m_IsSourceNullOrEmpty && isOtherNullOrEmpty) { return true; } if (m_IsSourceNullOrEmpty || isOtherNullOrEmpty) { return false; } if (m_Length != other.m_Length) { return false; } for (int i = m_StartIndex, j = other.m_StartIndex; i <= m_EndIndex; i++, j++) { if (m_Source[i] != other.m_Source[j]) { return false; } } return true; } public override int GetHashCode() { if (m_HashCode == 0 && !m_IsSourceNullOrEmpty) { for (int i = m_StartIndex; i <= m_EndIndex; i++) { m_HashCode = 31 * m_HashCode + m_Source[i]; } } return m_HashCode; } public override string ToString() { ReddotMananger.Instance.CachedSb.Clear(); for (int i = m_StartIndex; i <= m_EndIndex; i++) { ReddotMananger.Instance.CachedSb.Append(m_Source[i]); } string str = ReddotMananger.Instance.CachedSb.ToString(); return str; } }
3.TreeNode:前缀树的节点类,其中存储的数据为一个int数字,这个数字对应游戏中在UI的小图标右上角显示的数字,代表未完成的任务数或者未领取的奖励数等;在节点类中还提供了值改变的监听方法,使用红点系统管理器类管理:
using System; using System.Collections.Generic; /// <summary> /// 树节点 /// </summary> public class TreeNode { #region 属性 /// <summary> /// 子节点 /// </summary> private Dictionary<RangeString, TreeNode> m_Children; /// <summary> /// 节点值改变回调 /// </summary> private Action<int> m_ChangeCallback; /// <summary> /// 完整路径 /// </summary> private string m_FullPath; /// <summary> /// 节点名 /// </summary> public string Name { get; private set; } /// <summary> /// 完整路径 /// </summary> public string FullPath { get { if (string.IsNullOrEmpty(m_FullPath)) { if (Parent == null || Parent == ReddotMananger.Instance.Root) { m_FullPath = Name; } else { m_FullPath = Parent.FullPath + ReddotMananger.Instance.SplitChar + Name; } } return m_FullPath; } } /// <summary> /// 节点值 /// </summary> public int Value { get; private set; } /// <summary> /// 父节点 /// </summary> public TreeNode Parent { get; private set; } /// <summary> /// 子节点 /// </summary> public Dictionary<RangeString, TreeNode>.ValueCollection Children { get { return m_Children?.Values; } } /// <summary> /// 子节点数量 /// </summary> public int ChildrenCount { get { if (m_Children == null) { return 0; } int sum = m_Children.Count; foreach (TreeNode node in m_Children.Values) { sum += node.ChildrenCount; } return sum; } } #endregion #region Constructors public TreeNode(string name) { Name = name; Value = 0; m_ChangeCallback = null; } public TreeNode(string name, TreeNode parent) : this(name) { Parent = parent; } #endregion #region 节点值改变监听委托的移除、添加等方法,当前节点值发生改动会调用监听方法 /// <summary> /// 添加节点值监听 /// </summary> public void AddListener(Action<int> callback) { m_ChangeCallback += callback; } /// <summary> /// 移除节点值监听 /// </summary> public void RemoveListener(Action<int> callback) { m_ChangeCallback -= callback; } /// <summary> /// 移除所有节点值监听 /// </summary> public void RemoveAllListener() { m_ChangeCallback = null; } #endregion /// <summary> /// 改变节点值(使用传入的新值,只能在叶子节点上调用) /// 节点值得改变只能从叶子节点开始层层向上传递 /// </summary> public void ChangeValue(int newValue) { //校验是否叶子节点 //这个地方可以自行修改,在实际游戏中直接return最好,不要抛错 if (m_Children != null && m_Children.Count != 0) { return; //throw new Exception("不允许直接改变非叶子节点的值:" + FullPath); } //调用真正改变值的方法 InternalChangeValue(newValue); } /// <summary> /// 改变节点值(根据子节点值计算新值,只对非叶子节点有效) /// </summary> public void ChangeValue() { int sum = 0; //校验是非叶子节点才进入循环 if (m_Children != null && m_Children.Count != 0) { //循环遍历统计子结点中的数据和 //父节点中的数据始终是所有子结点的数据和 foreach (KeyValuePair<RangeString, TreeNode> child in m_Children) { sum += child.Value.Value; } InternalChangeValue(sum); } else { return; } } /// <summary> /// 获取子节点,如果不存在则添加 /// </summary> public TreeNode GetOrAddChild(RangeString key) { TreeNode child = GetChild(key); if (child == null) { child = AddChild(key); } return child; } /// <summary> /// 获取子节点 /// </summary> public TreeNode GetChild(RangeString key) { if (m_Children == null) { return null; } m_Children.TryGetValue(key, out TreeNode child); return child; } /// <summary> /// 添加子节点 /// </summary> public TreeNode AddChild(RangeString key) { if (m_Children == null) { m_Children = new Dictionary<RangeString, TreeNode>(); } else if (m_Children.ContainsKey(key)) { throw new Exception("子节点添加失败,不允许重复添加:" + FullPath); } TreeNode child = new TreeNode(key.ToString(), this); m_Children.Add(key, child); ReddotMananger.Instance.NodeNumChangeCallback?.Invoke(); return child; } /// <summary> /// 移除子节点 /// </summary> public bool RemoveChild(RangeString key) { if (m_Children == null || m_Children.Count == 0) { return false; } TreeNode child = GetChild(key); if (child != null) { //子节点被删除 需要进行一次父节点刷新 ReddotMananger.Instance.MarkDirtyNode(this);//当前节点进行脏标记 m_Children.Remove(key);//移除子节点 ReddotMananger.Instance.NodeNumChangeCallback?.Invoke(); return true; } return false; } /// <summary> /// 移除所有子节点 /// </summary> public void RemoveAllChild() { if (m_Children == null || m_Children.Count == 0) { return; } m_Children.Clear(); ReddotMananger.Instance.MarkDirtyNode(this); ReddotMananger.Instance.NodeNumChangeCallback?.Invoke(); } public override string ToString() { return FullPath; } /// <summary> /// 改变节点值 /// </summary> private void InternalChangeValue(int newValue) { if (Value == newValue) { return; } Value = newValue; m_ChangeCallback?.Invoke(newValue); ReddotMananger.Instance.NodeValueChangeCallback?.Invoke(this, Value); //标记父节点为脏节点 //父节点被标记后,接下来ReddotManager中就会自动更新父节点的值,然后继续标记父节点 ReddotMananger.Instance.MarkDirtyNode(Parent); } }
4.ReddotManager:这是前缀树的实现类或者前缀树节点的管理类。这个类中暴露了很多方法,常用的有获取节点GetTreeNode、移除节点RemoveTreeNode、更新节点Update、更改具体节点的数据ChangeValue、获取具体节点的数据GetValue等方法:
using System; using System.Collections; using System.Collections.Generic; using System.Text; using UnityEngine; /// <summary> /// 红点管理器 /// </summary> public class ReddotMananger { #region 单例模块,线程不安全 private static ReddotMananger m_Instance; public static ReddotMananger Instance { get { if (m_Instance == null) { m_Instance = new ReddotMananger(); } return m_Instance; } } #endregion #region 属性 /// <summary> /// 所有节点集合 /// </summary> private Dictionary<string, TreeNode> m_AllNodes; /// <summary> /// 脏节点集合 /// 脏节点为值发生改变或子节点值发生改变的节点,需要轮询更新 /// </summary> private HashSet<TreeNode> m_DirtyNodes; /// <summary> /// 临时脏节点集合 /// </summary> private List<TreeNode> m_TempDirtyNodes; /// <summary> /// 节点数量改变回调 /// </summary> public Action NodeNumChangeCallback; /// <summary> /// 节点值改变回调 /// </summary> public Action<TreeNode,int> NodeValueChangeCallback; /// <summary> /// 路径分隔字符 /// </summary> public char SplitChar { get; private set; } /// <summary> /// 缓存的StringBuild /// </summary> public StringBuilder CachedSb { get; private set; } /// <summary> /// 红点树根节点 /// </summary> public TreeNode Root { get; private set; } #endregion #region Constructor public ReddotMananger() { SplitChar = '/'; m_AllNodes = new Dictionary<string, TreeNode>(); Root = new TreeNode("Root"); m_DirtyNodes = new HashSet<TreeNode>(); m_TempDirtyNodes = new List<TreeNode>(); CachedSb = new StringBuilder(); } #endregion #region 添加、移除具体节点监听委托的方法 /// <summary> /// 添加节点值监听 /// </summary> public TreeNode AddListener(string path,Action<int> callback) { if (callback == null) { return null; } //获取目标节点,如果没有获取到会自动创建到这个节点并返回 TreeNode node = GetTreeNode(path); node.AddListener(callback); return node; } /// <summary> /// 移除节点值监听 /// </summary> public void RemoveListener(string path,Action<int> callback) { if (callback == null) { return; } TreeNode node = GetTreeNode(path); node.RemoveListener(callback); } /// <summary> /// 移除所有节点值监听 /// </summary> public void RemoveAllListener(string path) { TreeNode node = GetTreeNode(path); node.RemoveAllListener(); } #endregion #region 改变、获取具体节点的值的方法 /// <summary> /// 改变节点值 /// </summary> public void ChangeValue(string path,int newValue) { TreeNode node = GetTreeNode(path); node.ChangeValue(newValue); } /// <summary> /// 获取节点值 /// </summary> public int GetValue(string path) { TreeNode node = GetTreeNode(path); if (node == null) { return 0; } return node.Value; } #endregion #region 获取(没有则自动添加)、移除具体节点的相关方法 /// <summary> /// 获取节点 /// 如果根据path不能获取到节点,此方法会根据path一路创建节点直到创建出key值为path的叶子节点 /// </summary> public TreeNode GetTreeNode(string path) { if (string.IsNullOrEmpty(path)) { throw new Exception("路径不合法,不能为空"); } //尝试获取目标节点,如果获取到就直接返回,没有获取到会执行接下来的代码块创建这个节点及其缺失的父节点 if (m_AllNodes.TryGetValue(path,out TreeNode node)) { return node; } TreeNode cur = Root;//当前节点为根节点 int length = path.Length;//路径长度 int startIndex = 0; for (int i = 0; i < length; i++)//遍历路径 { //判断当前字符是否为分隔符,这里每次遇到分隔符都停下来将cur赋值为子结点,没有子结点创建子结点 if (path[i] == SplitChar) { if (i == length - 1) { throw new Exception("路径不合法,不能以路径分隔符结尾:" + path); } int endIndex = i - 1;//更新endIndex if (endIndex < startIndex) { throw new Exception("路径不合法,不能存在连续的路径分隔符或以路径分隔符开头:" + path); } //获取子结点,没有获取到会自动创建子结点 TreeNode child = cur.GetOrAddChild(new RangeString(path,startIndex,endIndex)); //更新startIndex startIndex = i + 1; cur = child;//当前节点为子结点,继续查找子结点 } } //最后一个节点 直接用length - 1作为endIndex TreeNode target = cur.GetOrAddChild(new RangeString(path, startIndex, length - 1));//创建目标节点,目标节点是叶子节点 m_AllNodes.Add(path, target);//添加新创建的节点 return target; } /// <summary> /// 移除节点 /// </summary> public bool RemoveTreeNode(string path) { if (!m_AllNodes.ContainsKey(path)) { return false; } TreeNode node = GetTreeNode(path); m_AllNodes.Remove(path); return node.Parent.RemoveChild(new RangeString(node.Name, 0, node.Name.Length - 1)); } /// <summary> /// 移除所有节点 /// </summary> public void RemoveAllTreeNode() { Root.RemoveAllChild(); m_AllNodes.Clear(); } #endregion /// <summary> /// 管理器轮询 /// 在脚本Test的Update函数中会调用此方法,此方法定时处理脏节点(节点内容或其子节点内容有更改的节点) /// 由于脏节点的处理需要一定时间,为了安全性考虑,引入了脏节点缓存,先将要处理的脏节点移动到缓存中,再统一处理缓存中的脏节点 /// </summary> public void Update() { if (m_DirtyNodes.Count == 0) { return; } m_TempDirtyNodes.Clear();//清除临时脏节点集合 foreach (TreeNode node in m_DirtyNodes) { m_TempDirtyNodes.Add(node);//将所有脏节点转存到临时脏节点集合中 } m_DirtyNodes.Clear();//清除脏节点集合 //处理所有脏节点 for (int i = 0; i < m_TempDirtyNodes.Count; i++) { m_TempDirtyNodes[i].ChangeValue(); } } /// <summary> /// 标记脏节点 /// </summary> public void MarkDirtyNode(TreeNode node) { //根结点不能被标记为脏节点 if (node == null || node.Name == Root.Name) { return; } m_DirtyNodes.Add(node); } }
三.demo代码和使用分析
1.文件位置:作者提供的demo脚本在Scripts目录下,下面先分析这两个脚本。
2.Test:这个脚本挂载在场景中的一个名叫Test的空物体上,脚本中主要注意在Update函数中调用了管理器的轮询Update函数:
using System.Collections; using System.Collections.Generic; //using Unity.Mathematics; using UnityEngine; public class Test : MonoBehaviour { private List<string> strs; // Start is called before the first frame update void Start() { Application.targetFrameRate = 30; strs = new List<string>(10000); for (int i = 0; i < 10000; i++) { strs.Add(i.ToString()); } } void Update() { //调用管理器的轮询函数,这个函数会将所有的脏节点进行处理 ReddotMananger.Instance.Update(); //按D将对视图窗口中的节点进行查找,对主界面没有影响 if (Input.GetKeyDown(KeyCode.D)) { //对已存在的节点进行1w次查找操作 UnityEngine.Profiling.Profiler.BeginSample("1w FindNode"); for (int i = 0; i < 10000; i++) { ReddotMananger.Instance.GetTreeNode("First/Second1/Third1"); } UnityEngine.Profiling.Profiler.EndSample(); } //按F将在视图窗口中创建新节点,主视图没有影响 if (Input.GetKeyDown(KeyCode.F)) { //1w个新节点的创建操作 UnityEngine.Profiling.Profiler.BeginSample("1w CreateNode"); for (int i = 0; i < strs.Count; i++) { ReddotMananger.Instance.GetTreeNode(strs[i]); } UnityEngine.Profiling.Profiler.EndSample(); } } }
3.ReddotUI:游戏中每一个红点系统节点对应的游戏物体上都挂载了这个脚本,脚本实现了UI和红点系统的交互,UI接受点击会触发红点管理器中的相应方法更新红点系统节点中的值,红点系统中节点值更新又会触发脚本中注册到红点系统节点中的回调函数。重点代码是Start函数中添加红点系统中对应节点的值改变回调函数监听的代码调用和OnPointerClick函数中获取和改变对应节点信息的代码调用:
using System; using System.Collections; using System.Collections.Generic; using TreeEditor; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; /// <summary> /// UI管理器 /// 游戏启动后,canvas下的每个游戏物体都有一个text子物体,每个物体又都会挂载此脚本用于管理text /// 继承IPointerClickHandler接口,实现了对鼠标点击的监听 /// </summary> public class ReddotUI : MonoBehaviour,IPointerClickHandler { public string Path;//当前节点在前缀树中的键名 private Text txt;//节点上用于显示信息的子物体 private void Awake() { //获取text组件 txt = GetComponentInChildren<Text>(); } void Start() { //当前脚本会被自动添加到每一个节点上,被添加后需要在前缀树中创建一个对应的节点实例,并添加相应的监听方法监听节点中值的改变 TreeNode node = ReddotMananger.Instance.AddListener(Path, ReddotCallback); gameObject.name = node.FullPath;//将节点的键和游戏物体名同步 } /// <summary> /// 监听对应节点的值改变的方法 /// </summary> /// <param name="value"></param> private void ReddotCallback(int value) { Debug.Log("红点刷新,路径:" + Path + ",当前帧数:" + Time.frameCount + ",值:" + value); txt.text = value.ToString(); } /// <summary> /// 点击事件的监听 /// </summary> /// <param name="eventData"></param> public void OnPointerClick(PointerEventData eventData) { int value = ReddotMananger.Instance.GetValue(Path);//获取节点的数据 if (eventData.button == PointerEventData.InputButton.Left)//左键点击,节点数据加1 { ReddotMananger.Instance.ChangeValue(Path, value + 1);//直接调用管理器的ChangeValue方法改变根结点的值,红点系统会自动标记父节点并更新父节点的数据,触发父节点的数据改变回调 } else if (eventData.button == PointerEventData.InputButton.Right)//右键点击,节点数据减一,但是不能小于0 { ReddotMananger.Instance.ChangeValue(Path, Mathf.Clamp(value - 1,0, value)); } } }
4.总结:在使用的过程中,一般直接调用红点系统的管理器中开放出来的相关方法即可使用这个红点系统,但还是需要注意一些问题。使用过程中需要让管理器的Update函数进行轮询调用,可以直接放在MonoBehaviour脚本的Update函数中,也可以自己提供时钟管理轮询事件。在前缀树中,根结点的名称为Root,根结点不会被标记为脏节点,也不会进行值更新,所以根结点一般不会对应游戏中的物体实例。游戏中UI和红点系统的交互可以有以下几类:1)UI中的按钮被按下,可以调用管理器中的具体方法更新前缀树中相应节点的数据;2)在UI管理脚本中将监听方法注册到红点系统管理器和具体的前缀树节点中,红点系统提供了前缀树节点数量改变监听和具体节点的数据改变监听两种监听方法;3)红点系统管理器中提供了增删查改前缀树中的节点的方法供UI管理脚本调用。
四.自定义测试案例演示
1.脚本:
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class GameManager : MonoBehaviour { //节点对应的游戏物体实例,采用拖动赋值 public GameObject node; //定义节点位置的节点父物体,拖动赋值 public Transform[] fathers; //节点的创建路径 public string[] paths = {"0", "0/1", "0/2", "0/1/3", "0/1/4","0/2/5" }; void Start() { for(int i = 0;i < fathers.Length;i++) { var tf = Instantiate(node).transform; tf.gameObject.name = i.ToString(); tf.SetParent(fathers[i]); tf.localPosition = Vector3.zero; tf.localRotation = Quaternion.identity; tf.localScale = Vector3.one; string temp = paths[i]; //红点被点击后触发 tf.GetComponentInChildren<Button>().onClick.AddListener(() => { ReddotMananger.Instance.ChangeValue(temp, ReddotMananger.Instance.GetValue(temp) + 1); }); //添加当前红点对应的前缀树中节点值改变时的事件监听 ReddotMananger.Instance.AddListener(temp, data => { tf.GetComponentInChildren<Text>().text = data.ToString(); }); } } void Update() { //必须执行管理器轮询函数,前缀树中节点信息改变才能通知其父节点,同步所有节点数据 ReddotMananger.Instance.Update(); } }
2.测试结果: