1. 为什么需要自己实现TreeView?
在Unity开发中,TreeView(树形视图)是一个非常常见的UI组件,常用于文件浏览器、配置面板、技能树等场景。虽然Unity Asset Store中有不少现成的TreeView插件,但很多时候它们要么功能过于复杂,要么性能不够理想,要么定制化程度不够高。我在实际项目中就遇到过这样的情况:需要一个轻量级、高性能的TreeView来展示PDF文档列表,但找了一圈发现没有特别合适的插件。
传统实现TreeView的方式通常会在Unity中构建与数据结构完全对应的层级关系,也就是让UI的父子关系与数据结构的父子关系保持一致。这种方法虽然直观,但在处理折叠/展开时需要进行复杂的递归高度计算,性能开销大,代码也容易变得臃肿。后来我发现,利用UGUI的VerticalLayoutGroup和ContentSizeFitter组件,配合简单的节点禁用机制,可以非常优雅地实现TreeView的核心功能。
2. 核心实现原理
2.1 关键组件的作用
实现这个TreeView的核心在于巧妙利用两个UGUI组件:
- VerticalLayoutGroup:自动将子物体垂直排列,省去了手动计算位置的麻烦
- ContentSizeFitter:根据子物体自动调整Content区域的大小,确保滚动条正常工作
这两个组件组合使用时有个非常重要的特性:它们会自动忽略被禁用的子物体。这意味着当我们折叠一个节点时,只需要禁用它的所有子节点,VerticalLayoutGroup就会自动将后面的节点"提上来",ContentSizeFitter也会自动调整Content区域的大小。
2.2 与传统实现的对比
传统实现TreeView通常采用递归计算高度的方式:
- 展开时:递归计算所有子节点的高度并累加
- 折叠时:递归隐藏所有子节点并减去相应高度
这种方法有以下几个缺点:
- 计算复杂度高,特别是对于深层级树结构
- 需要手动维护每个节点的位置
- 展开/折叠时容易出现布局抖动
而我们采用的新方法:
- 完全依赖UGUI的自动布局系统
- 折叠时只需禁用子节点,展开时启用
- 无需手动计算任何高度或位置
- 性能更好,代码更简洁
3. 具体实现步骤
3.1 基础结构搭建
首先创建一个基本的TreeView结构:
// TreeView.cs public class TreeView : MonoBehaviour { [SerializeField] private RectTransform m_Content; [SerializeField] private GameObject m_ItemPrefab; [SerializeField] private float m_IndentationWidth = 20f; private List<TreeItem> m_Items = new List<TreeItem>(); public TreeItem AppendItem(string text, Sprite icon, TreeItem parent = null) { GameObject itemObj = Instantiate(m_ItemPrefab, m_Content); TreeItem item = itemObj.GetComponent<TreeItem>(); item.Initialize(this, text, icon, parent); m_Items.Add(item); return item; } }3.2 TreeItem的实现
每个树节点的核心逻辑:
// TreeItem.cs public class TreeItem : MonoBehaviour { [SerializeField] private RectTransform m_ContentPanel; [SerializeField] private Button m_ExpandButton; [SerializeField] private Text m_Text; [SerializeField] private Image m_Icon; private TreeView m_Tree; private TreeItem m_Parent; private List<TreeItem> m_Children = new List<TreeItem>(); private int m_IndentationLevel = -1; public void Initialize(TreeView tree, string text, Sprite icon, TreeItem parent) { m_Tree = tree; m_Text.text = text; m_Icon.sprite = icon; Parent = parent; // 这会触发重新计算位置 } public TreeItem Parent { get { return m_Parent; } set { if (m_Parent != value) { // 从原父节点移除 if (m_Parent != null) m_Parent.m_Children.Remove(this); // 设置新父节点 m_Parent = value; // 计算新位置 int index = m_Parent != null ? m_Parent.LastSiblingIndex : 0; transform.SetSiblingIndex(index); // 添加到新父节点的子列表 if (m_Parent != null) m_Parent.m_Children.Add(this); // 重新计算缩进 RecalcIndentation(); } } } public int LastSiblingIndex { get { if (m_Children.Count > 0) return m_Children[m_Children.Count - 1].LastSiblingIndex; return transform.GetSiblingIndex(); } } private void RecalcIndentation() { m_IndentationLevel = m_Parent != null ? m_Parent.m_IndentationLevel + 1 : 0; Vector2 offset = m_ContentPanel.offsetMin; offset.x = m_Tree.IndentationWidth * m_IndentationLevel; m_ContentPanel.offsetMin = offset; foreach (var child in m_Children) child.RecalcIndentation(); } }3.3 折叠/展开逻辑
折叠和展开的实现非常简单:
// 在TreeItem.cs中继续添加 private bool m_IsExpanded = true; public bool IsExpanded { get { return m_IsExpanded; } set { if (m_IsExpanded != value) { m_IsExpanded = value; UpdateChildrenActiveState(); } } } private void UpdateChildrenActiveState() { foreach (var child in m_Children) { child.gameObject.SetActive(m_IsExpanded); if (m_IsExpanded) child.UpdateChildrenActiveState(); } }4. 性能优化技巧
在实际使用中,我发现以下几个优化点可以显著提升TreeView的性能:
4.1 避免频繁的布局重建
UGUI的布局重建是比较耗时的操作。当我们需要批量添加或移动节点时,可以暂时禁用ContentSizeFitter,等所有操作完成后再启用:
public void BeginBatchOperation() { LayoutRebuilder.MarkLayoutForRebuild(m_Content); m_Content.GetComponent<ContentSizeFitter>().enabled = false; } public void EndBatchOperation() { m_Content.GetComponent<ContentSizeFitter>().enabled = true; LayoutRebuilder.ForceRebuildLayoutImmediate(m_Content); }4.2 对象池技术
对于动态更新的TreeView,使用对象池可以避免频繁的Instantiate和Destroy操作:
private Stack<GameObject> m_ItemPool = new Stack<GameObject>(); private GameObject GetItemFromPool() { if (m_ItemPool.Count > 0) return m_ItemPool.Pop(); return Instantiate(m_ItemPrefab); } private void ReturnItemToPool(GameObject item) { item.SetActive(false); m_ItemPool.Push(item); }4.3 延迟加载
对于大型树结构,可以采用延迟加载策略,只有当父节点展开时才加载其子节点:
public class LazyTreeItem : TreeItem { private bool m_IsLoaded = false; protected override void OnExpand() { if (!m_IsLoaded) { LoadChildren(); m_IsLoaded = true; } base.OnExpand(); } private void LoadChildren() { // 从数据源加载子节点 } }5. 实际应用案例
5.1 文件浏览器实现
下面是一个简单的文件浏览器实现示例:
public class FileBrowser : MonoBehaviour { [SerializeField] private TreeView m_Tree; [SerializeField] private Sprite m_FolderIcon; [SerializeField] private Sprite m_FileIcon; private void Start() { string path = Application.dataPath; BuildTree(null, path); } private void BuildTree(TreeItem parent, string path) { try { // 添加目录 foreach (var dir in Directory.GetDirectories(path)) { TreeItem item = m_Tree.AppendItem(Path.GetFileName(dir), m_FolderIcon, parent); item.IsExpanded = false; // 递归构建子目录 BuildTree(item, dir); } // 添加文件 foreach (var file in Directory.GetFiles(path)) { if (!file.EndsWith(".meta")) // 忽略Unity的meta文件 { m_Tree.AppendItem(Path.GetFileName(file), m_FileIcon, parent); } } } catch (Exception e) { Debug.LogError($"加载目录失败: {e.Message}"); } } }5.2 配置面板实现
TreeView也非常适合用于复杂的配置面板:
public class SettingsPanel : MonoBehaviour { [SerializeField] private TreeView m_Tree; private void Start() { TreeItem graphics = m_Tree.AppendItem("图形设置", null); m_Tree.AppendItem("分辨率", null, graphics); m_Tree.AppendItem("画质等级", null, graphics); m_Tree.AppendItem("垂直同步", null, graphics); TreeItem audio = m_Tree.AppendItem("音频设置", null); m_Tree.AppendItem("主音量", null, audio); m_Tree.AppendItem("音乐音量", null, audio); m_Tree.AppendItem("音效音量", null, audio); TreeItem controls = m_Tree.AppendItem("控制设置", null); m_Tree.AppendItem("键盘设置", null, controls); m_Tree.AppendItem("鼠标设置", null, controls); m_Tree.AppendItem("手柄设置", null, controls); } }6. 常见问题与解决方案
6.1 节点顺序错乱问题
在使用transform.SetSiblingIndex()时,有时会出现节点顺序不正确的情况。这是因为在设置一个节点的位置时,它的子节点可能还没有被正确放置。解决方案是确保在设置父节点时,先设置所有子节点的位置:
public TreeItem Parent { set { if (m_Parent != value) { // 先处理子节点 foreach (var child in m_Children) child.Parent = null; // 原来的父节点逻辑... // 重新设置子节点 foreach (var child in m_Children) child.Parent = this; } } }6.2 折叠/展开动画卡顿
如果需要添加折叠/展开动画,直接修改子节点的active状态可能会导致卡顿。可以使用CanvasGroup来替代:
private IEnumerator ToggleAnimation(bool show) { CanvasGroup canvasGroup = GetComponent<CanvasGroup>(); float targetAlpha = show ? 1f : 0f; float duration = 0.2f; float elapsed = 0f; while (elapsed < duration) { canvasGroup.alpha = Mathf.Lerp(canvasGroup.alpha, targetAlpha, elapsed / duration); elapsed += Time.deltaTime; yield return null; } canvasGroup.alpha = targetAlpha; gameObject.SetActive(show); }6.3 大数据量性能问题
当树结构非常大时(如超过1000个节点),即使有对象池和延迟加载,性能也可能受到影响。这时可以考虑:
- 虚拟滚动:只渲染可视区域内的节点
- 分页加载:每次只加载一定数量的节点
- 多级缓存:缓存已加载的节点数据
7. 扩展功能实现
7.1 多选功能
实现TreeView的多选功能需要维护一个选中的节点列表:
public class MultiSelectTreeView : TreeView { private List<TreeItem> m_SelectedItems = new List<TreeItem>(); public void SelectItem(TreeItem item, bool additive = false) { if (!additive) ClearSelection(); if (!m_SelectedItems.Contains(item)) { m_SelectedItems.Add(item); item.SetSelected(true); } } public void ClearSelection() { foreach (var item in m_SelectedItems) item.SetSelected(false); m_SelectedItems.Clear(); } }7.2 拖拽排序
实现节点的拖拽排序功能:
public class DraggableTreeItem : TreeItem, IBeginDragHandler, IDragHandler, IEndDragHandler { private Transform m_OriginalParent; private int m_OriginalIndex; public void OnBeginDrag(PointerEventData eventData) { m_OriginalParent = transform.parent; m_OriginalIndex = transform.GetSiblingIndex(); transform.SetAsLastSibling(); // 确保拖拽时显示在最上层 } public void OnDrag(PointerEventData eventData) { transform.position = eventData.position; } public void OnEndDrag(PointerEventData eventData) { // 检查是否拖到了另一个节点上 TreeItem newParent = FindDropTarget(eventData); if (newParent != null && newParent != this && !IsChildOf(newParent)) { Parent = newParent; } else { // 恢复原位置 transform.SetParent(m_OriginalParent); transform.SetSiblingIndex(m_OriginalIndex); } } private TreeItem FindDropTarget(PointerEventData eventData) { // 通过射线检测找到目标节点 List<RaycastResult> results = new List<RaycastResult>(); EventSystem.current.RaycastAll(eventData, results); foreach (var result in results) { TreeItem item = result.gameObject.GetComponent<TreeItem>(); if (item != null) return item; } return null; } private bool IsChildOf(TreeItem item) { TreeItem parent = Parent; while (parent != null) { if (parent == item) return true; parent = parent.Parent; } return false; } }7.3 搜索过滤
为TreeView添加搜索过滤功能:
public class SearchableTreeView : TreeView { public void ApplyFilter(string searchText) { foreach (var item in m_Items) { bool match = string.IsNullOrEmpty(searchText) || item.Text.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) >= 0; item.gameObject.SetActive(match); // 如果匹配,确保所有父节点都是展开的 if (match) { TreeItem parent = item.Parent; while (parent != null) { parent.IsExpanded = true; parent = parent.Parent; } } } } }8. 最佳实践与建议
在实际项目中使用这个TreeView组件时,我总结了以下几点经验:
- 保持树结构扁平化:虽然我们的实现支持深层级,但建议不要超过5层,否则用户体验会变差
- 合理设置缩进宽度:通常20-30像素的缩进比较合适,太大会浪费空间,太小会难以分辨层级
- 使用图标区分节点类型:比如文件夹图标、文件图标等,可以大大提高可读性
- 添加悬停效果:为节点添加悬停高亮效果,提升交互体验
- 考虑添加键盘导航:支持上下箭头选择、左右箭头展开/折叠等键盘操作
- 性能监控:在编辑器中添加性能统计,如节点数量、刷新频率等,便于优化
这个TreeView实现虽然简单,但经过多次项目验证,性能表现非常出色,特别是在处理动态更新的树结构时,相比传统实现方式有显著优势。它的核心思想是利用UGUI已有的布局系统,而不是自己重新实现一套,这既减少了代码量,又保证了性能。