一.簡介
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.測試結果: