Unity Hierarchy层级窗口的控件同名检测工具
Unity Hierarchy层级窗口的控件同名检测工具
需求背景
在游戏 UI 拼接过程中,一个界面通常会包含大量层级节点。为了区分普通装饰节点和需要程序控制的节点,项目会约定使用特定前缀命名,例如使用 p_ 表示该控件需要被程序查找、绑定或修改。
随着界面规模增大,设计师经常会通过复制现有节点快速搭建界面。复制后的节点可能分散在不同面板或不同层级中,仅从 Hierarchy 外观很难及时发现它们使用了相同名称。
实际痛点
程序在绑定 UI 控件时,通常依赖控件名称建立名称与对象之间的对应关系。同一个界面中出现多个同名的程序控件后,绑定结果可能被覆盖,也可能因为无法唯一匹配而获取不到正确对象。
这类问题最终可能表现为:
- 控件引用为空。
- 界面图片、文字或按钮显示空白。
- 点击事件绑定到错误对象。
- 某个控件可以正常工作,另一个同名控件却没有反应。
- 问题只有运行游戏并打开对应界面后才暴露。
相比普通装饰节点,真正需要关注的是带有程序约定前缀的节点。如果对整个 Hierarchy 的所有对象进行检查,背景、特效、布局和美术装饰节点会产生大量无关结果,反而降低排查效率。
工具目标
制作一个可停靠在 Unity 编辑器中的 Hierarchy 同名检测窗口:
- 手动指定需要检测的界面根对象。
- 用户可以输入程序控件的名称前缀,例如
p_。 - 只检查名称带指定前缀的控件。
- 支持检测未激活对象。
- 按重复名称分组显示完整 Hierarchy 路径。
- 点击检测结果可以直接定位到对应对象。
- 提供检查和清空按钮。
- 结果只显示在工具窗口中,不使用阻塞操作的弹窗。
使用效果
设计师完成界面拼接后,在提交或交付程序之前执行一次检查,就能提前发现带 p_ 前缀的同名控件。
这样可以把原本需要在运行阶段发现的问题提前到编辑阶段处理,减少程序与设计师之间反复确认层级、查找节点和重新导出界面的时间。

~~点击查看代码~~
#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

浙公网安备 33010602011771号