Unity Hierarchy层级窗口的控件同名检测工具

Unity Hierarchy层级窗口的控件同名检测工具

需求背景

在游戏 UI 拼接过程中,一个界面通常会包含大量层级节点。为了区分普通装饰节点和需要程序控制的节点,项目会约定使用特定前缀命名,例如使用 p_ 表示该控件需要被程序查找、绑定或修改。

随着界面规模增大,设计师经常会通过复制现有节点快速搭建界面。复制后的节点可能分散在不同面板或不同层级中,仅从 Hierarchy 外观很难及时发现它们使用了相同名称。
image

实际痛点

程序在绑定 UI 控件时,通常依赖控件名称建立名称与对象之间的对应关系。同一个界面中出现多个同名的程序控件后,绑定结果可能被覆盖,也可能因为无法唯一匹配而获取不到正确对象。

这类问题最终可能表现为:

  • 控件引用为空。
  • 界面图片、文字或按钮显示空白。
  • 点击事件绑定到错误对象。
  • 某个控件可以正常工作,另一个同名控件却没有反应。
  • 问题只有运行游戏并打开对应界面后才暴露。

相比普通装饰节点,真正需要关注的是带有程序约定前缀的节点。如果对整个 Hierarchy 的所有对象进行检查,背景、特效、布局和美术装饰节点会产生大量无关结果,反而降低排查效率。

工具目标

制作一个可停靠在 Unity 编辑器中的 Hierarchy 同名检测窗口:

  1. 手动指定需要检测的界面根对象。
  2. 用户可以输入程序控件的名称前缀,例如 p_
  3. 只检查名称带指定前缀的控件。
  4. 支持检测未激活对象。
  5. 按重复名称分组显示完整 Hierarchy 路径。
  6. 点击检测结果可以直接定位到对应对象。
  7. 提供检查和清空按钮。
  8. 结果只显示在工具窗口中,不使用阻塞操作的弹窗。

使用效果

设计师完成界面拼接后,在提交或交付程序之前执行一次检查,就能提前发现带 p_ 前缀的同名控件。

这样可以把原本需要在运行阶段发现的问题提前到编辑阶段处理,减少程序与设计师之间反复确认层级、查找节点和重新导出界面的时间。
image

~~点击查看代码~~
#if UNITY_EDITOR

using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

/// <summary>
/// Hierarchy 同名对象检测窗口。
///
/// 安装方法:
/// 1. 在空白 Unity 项目中创建 Assets/Editor 文件夹。
/// 2. 将本文件复制到 Assets/Editor/HierarchyDuplicateNameChecker.cs。
/// 3. 等待 Unity 编译完成。
/// 4. 通过菜单 Tools/Hierarchy层级窗口/同名控件检测 打开窗口。
///
/// 这个窗口是普通 EditorWindow:
/// - 可以像 Inspector、Project 一样停靠。
/// - 不会弹出模态对话框阻止操作。
/// - 重复点击菜单只会聚焦已有窗口,不会不断创建新窗口。
/// - 检测结果直接保留在当前窗口中。
///
/// 本工具只使用 Unity 原生 Editor API,不依赖任何第三方插件。
/// </summary>
public sealed class HierarchyDuplicateNameChecker : EditorWindow
{
    /// <summary>
    /// 一条重复对象记录。
    /// 保存对象引用是为了点击结果时能够定位到 Hierarchy 对象。
    /// </summary>
    [Serializable]
    private sealed class DuplicateEntry
    {
        public string HierarchyPath;
        public GameObject Target;
    }

    /// <summary>
    /// 一组名称相同的对象。
    /// 例如 Name 为 btn_close,Entries 中保存所有同名对象。
    /// </summary>
    [Serializable]
    private sealed class DuplicateGroup
    {
        public string Name;
        public bool Expanded = true;
        public List<DuplicateEntry> Entries = new List<DuplicateEntry>();
    }

    /// <summary>
    /// 当前需要检测的根对象。
    /// 检测范围是这个对象以及它的全部子对象。
    /// </summary>
    [SerializeField]
    private GameObject _rootObject;

    /// <summary>
    /// 只检测名称带有这个前缀的控件。
    ///
    /// 例如填写 p_:
    /// - p_close、p_title 会参与同名检测。
    /// - bg、decor、txt_desc 不会参与检测。
    ///
    /// 留空时不限制前缀,会检测全部控件。
    /// 前缀匹配区分英文大小写。
    /// </summary>
    [SerializeField]
    private string _namePrefix = "p_";

    /// <summary>
    /// 是否检查未激活的对象。
    /// UI 节点经常会默认隐藏,所以建议保持开启。
    /// </summary>
    [SerializeField]
    private bool _includeInactive = true;

    /// <summary>
    /// 是否把根对象自身也加入名称统计。
    /// 根对象通常只有一个,默认不需要参与查重。
    /// </summary>
    [SerializeField]
    private bool _includeRootObject;

    /// <summary>
    /// 检测结果。
    /// 使用 SerializeField 可以让窗口重绘或脚本轻微刷新时保留显示数据。
    /// </summary>
    [SerializeField]
    private List<DuplicateGroup> _duplicateGroups =
        new List<DuplicateGroup>();

    [SerializeField]
    private string _statusMessage = "请选择一个根对象,然后点击“检查同名”。";

    [SerializeField]
    private int _scannedObjectCount;

    private Vector2 _scrollPosition;

    /// <summary>
    /// Unity 菜单入口。
    ///
    /// GetWindow 会优先获取已经打开的同类型窗口。
    /// 因此反复点击菜单只会显示并聚焦同一个窗口。
    /// </summary>
    [MenuItem("Tools/Hierarchy层级窗口/同名控件检测")]
    private static void OpenWindow()
    {
        HierarchyDuplicateNameChecker window =
            GetWindow<HierarchyDuplicateNameChecker>();

        window.titleContent = new GUIContent("Hierarchy 同名控件检测");
        window.minSize = new Vector2(460f, 360f);

        window.Show();
        window.Focus();
    }

    /// <summary>
    /// 绘制整个工具窗口。
    /// EditorWindow 每次需要刷新界面时都会调用 OnGUI。
    /// </summary>
    private void OnGUI()
    {
        EditorGUILayout.Space(8f);
        EditorGUILayout.LabelField(
            "Hierarchy 同名控件检测",
            EditorStyles.boldLabel);

        EditorGUILayout.HelpBox(
            "选择一个根对象后,检查其全部子对象是否存在同名节点。" +
            "结果只显示在本窗口中,不会弹出阻塞 Unity 操作的对话框。",
            MessageType.Info);

        DrawRootObjectSettings();
        DrawActionButtons();
        DrawStatus();
        DrawResults();
    }

    /// <summary>
    /// 绘制根对象和检测选项。
    /// </summary>
    private void DrawRootObjectSettings()
    {
        EditorGUILayout.Space(4f);

        EditorGUI.BeginChangeCheck();

        _rootObject = (GameObject)EditorGUILayout.ObjectField(
            new GUIContent(
                "检测根对象",
                "检测这个对象以及它的全部子对象。"),
            _rootObject,
            typeof(GameObject),
            true);

        _namePrefix = EditorGUILayout.TextField(
            new GUIContent(
                "检测名称前缀",
                "只检测名称以此前缀开头的控件;留空时检测全部控件。"),
            _namePrefix);

        _includeInactive = EditorGUILayout.Toggle(
            new GUIContent(
                "包含未激活对象",
                "开启后,Hierarchy 中未激活的灰色对象也会参与检测。"),
            _includeInactive);

        _includeRootObject = EditorGUILayout.Toggle(
            new GUIContent(
                "包含根对象自身",
                "开启后,根对象也会加入名称统计。"),
            _includeRootObject);

        // 检测条件发生变化时,旧结果可能已经不准确,所以主动清空。
        if (EditorGUI.EndChangeCheck())
        {
            ClearResults("检测条件已变化,请重新检查。");
        }
    }

    /// <summary>
    /// 绘制“检查”和“清空”两个主要按钮。
    /// </summary>
    private void DrawActionButtons()
    {
        EditorGUILayout.Space(6f);

        using (new EditorGUILayout.HorizontalScope())
        {
            if (GUILayout.Button("检查同名", GUILayout.Height(30f)))
            {
                CheckDuplicateNames();
            }

            if (GUILayout.Button("清空数据", GUILayout.Height(30f)))
            {
                ClearResults("检测结果已清空。");
            }
        }
    }

    /// <summary>
    /// 显示当前检测状态和扫描数量。
    /// </summary>
    private void DrawStatus()
    {
        EditorGUILayout.Space(6f);

        MessageType messageType =
            _duplicateGroups.Count > 0
                ? MessageType.Warning
                : MessageType.None;

        EditorGUILayout.HelpBox(_statusMessage, messageType);

        if (_scannedObjectCount > 0)
        {
            EditorGUILayout.LabelField(
                $"参与同名检测的对象数量:{_scannedObjectCount}");
        }
    }

    /// <summary>
    /// 在当前窗口中绘制所有同名结果。
    /// 每条路径都是可点击按钮,点击后会在 Hierarchy 中选中对应对象。
    /// </summary>
    private void DrawResults()
    {
        if (_duplicateGroups.Count == 0)
        {
            return;
        }

        EditorGUILayout.Space(6f);
        EditorGUILayout.LabelField(
            $"重复名称列表({_duplicateGroups.Count} 组)",
            EditorStyles.boldLabel);

        _scrollPosition =
            EditorGUILayout.BeginScrollView(_scrollPosition);

        foreach (DuplicateGroup group in _duplicateGroups)
        {
            using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
            {
                group.Expanded = EditorGUILayout.Foldout(
                    group.Expanded,
                    $"{group.Name}({group.Entries.Count} 个)",
                    true);

                if (!group.Expanded)
                {
                    continue;
                }

                for (int i = 0; i < group.Entries.Count; i++)
                {
                    DuplicateEntry entry = group.Entries[i];

                    using (new EditorGUILayout.HorizontalScope())
                    {
                        EditorGUILayout.LabelField(
                            (i + 1).ToString(),
                            GUILayout.Width(24f));

                        // 对象已经被删除时,引用会变成 null。
                        using (new EditorGUI.DisabledScope(
                                   entry.Target == null))
                        {
                            string buttonText =
                                entry.Target == null
                                    ? entry.HierarchyPath + "(对象已删除)"
                                    : entry.HierarchyPath;

                            if (GUILayout.Button(
                                    buttonText,
                                    EditorStyles.miniButton))
                            {
                                SelectAndPingObject(entry.Target);
                            }
                        }
                    }
                }
            }
        }

        EditorGUILayout.EndScrollView();
    }

    /// <summary>
    /// 执行同名检测。
    ///
    /// 算法:
    /// 1. 获取根对象下的所有 Transform。
    /// 2. 按 GameObject.name 分组。
    /// 3. 同一名称数量大于 1 时记录为重复。
    /// 4. 生成相对 Hierarchy 路径,便于区分同名对象的位置。
    /// </summary>
    private void CheckDuplicateNames()
    {
        _duplicateGroups.Clear();
        _scrollPosition = Vector2.zero;
        _scannedObjectCount = 0;

        if (_rootObject == null)
        {
            _statusMessage =
                "检查失败:请先把一个 Hierarchy 根对象拖入检测根对象框。";
            return;
        }

        Transform rootTransform = _rootObject.transform;

        // true 表示包含未激活对象,false 表示只检查激活对象。
        Transform[] transforms =
            rootTransform.GetComponentsInChildren<Transform>(
                _includeInactive);

        Dictionary<string, List<Transform>> objectsByName =
            new Dictionary<string, List<Transform>>(
                StringComparer.Ordinal);

        // Unity 序列化字符串时通常不会保存 null,
        // 这里仍做保护,避免字符串比较时出现空引用问题。
        string prefix = _namePrefix ?? string.Empty;

        foreach (Transform current in transforms)
        {
            if (!_includeRootObject && current == rootTransform)
            {
                continue;
            }

            // 用户填写前缀时,只检测名称以该前缀开头的对象。
            // 使用 Ordinal 表示按字符精确匹配,并区分英文大小写。
            if (!string.IsNullOrEmpty(prefix) &&
                !current.name.StartsWith(
                    prefix,
                    StringComparison.Ordinal))
            {
                continue;
            }

            _scannedObjectCount++;

            if (!objectsByName.TryGetValue(
                    current.name,
                    out List<Transform> sameNameObjects))
            {
                sameNameObjects = new List<Transform>();
                objectsByName.Add(current.name, sameNameObjects);
            }

            sameNameObjects.Add(current);
        }

        int duplicateObjectCount = 0;

        foreach (KeyValuePair<string, List<Transform>> pair
                 in objectsByName)
        {
            if (pair.Value.Count <= 1)
            {
                continue;
            }

            DuplicateGroup group = new DuplicateGroup
            {
                Name = pair.Key
            };

            foreach (Transform duplicateTransform in pair.Value)
            {
                group.Entries.Add(new DuplicateEntry
                {
                    HierarchyPath = GetHierarchyPath(
                        rootTransform,
                        duplicateTransform),
                    Target = duplicateTransform.gameObject
                });
            }

            // 同一组内按照路径排序,让结果保持稳定、便于阅读。
            group.Entries.Sort(
                (left, right) => string.Compare(
                    left.HierarchyPath,
                    right.HierarchyPath,
                    StringComparison.Ordinal));

            duplicateObjectCount += group.Entries.Count;
            _duplicateGroups.Add(group);
        }

        // 不同重名组按照名称排序。
        _duplicateGroups.Sort(
            (left, right) => string.Compare(
                left.Name,
                right.Name,
                StringComparison.Ordinal));

        string prefixDescription =
            string.IsNullOrEmpty(prefix)
                ? "全部名称"
                : $"前缀“{prefix}”";

        if (_scannedObjectCount == 0)
        {
            _statusMessage =
                $"检查完成:没有找到名称符合{prefixDescription}的控件。";
        }
        else if (_duplicateGroups.Count == 0)
        {
            _statusMessage =
                $"检查完成:{prefixDescription}共检测 " +
                $"{_scannedObjectCount} 个对象," +
                "没有发现同名控件。";
        }
        else
        {
            _statusMessage =
                $"检查完成:{prefixDescription}发现 " +
                $"{_duplicateGroups.Count} 组重复名称," +
                $"涉及 {duplicateObjectCount} 个对象。";
        }

        Repaint();
    }

    /// <summary>
    /// 生成对象相对于检测根节点的完整 Hierarchy 路径。
    ///
    /// 示例:
    /// Canvas/PanelTop/btn_close
    /// Canvas/Popup/btn_close
    /// </summary>
    private static string GetHierarchyPath(
        Transform root,
        Transform target)
    {
        List<string> names = new List<string>();
        Transform current = target;

        while (current != null)
        {
            names.Add(current.name);

            if (current == root)
            {
                break;
            }

            current = current.parent;
        }

        names.Reverse();
        return string.Join("/", names);
    }

    /// <summary>
    /// 清空当前窗口中的检测数据。
    /// 不会删除或修改 Scene 中的任何 GameObject。
    /// </summary>
    private void ClearResults(string message)
    {
        _duplicateGroups.Clear();
        _scannedObjectCount = 0;
        _scrollPosition = Vector2.zero;
        _statusMessage = message;
        Repaint();
    }

    /// <summary>
    /// 在 Hierarchy 中选中并高亮目标对象。
    /// 只有用户主动点击结果时才执行,不会自动打断当前操作。
    /// </summary>
    private static void SelectAndPingObject(GameObject target)
    {
        if (target == null)
        {
            return;
        }

        Selection.activeGameObject = target;
        EditorGUIUtility.PingObject(target);
    }
}

#endif


posted @ 2026-07-31 17:35  mdyyyds_blog  阅读(1)  评论(0)    收藏  举报