using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using UnityEditor; using UnityEditor.AddressableAssets; using UnityEngine; namespace BaseGames.Editor { /// /// Addressable Key 引用关系图窗口(架构 13_AssetPoolModule §11)。 /// 菜单:BaseGames/Tools/Asset Reference Graph /// /// 功能: /// - 扫描所有 .cs 文件中对 AddressKeys.X 的引用 /// - 列出每个 Key:声明位置、引用文件列表、是否存在于 Addressables /// - 孤儿 Key(有声明无引用)标红显示 /// - 无效 Key(有引用但不存在于 Addressables)标橙显示 /// - 一键导出 CSV /// public class AddressReferenceGraphWindow : EditorWindow { // ── State ────────────────────────────────────────────────────────── private List _entries; private Vector2 _scrollPos; private string _searchFilter = ""; private bool _showOrphansOnly; private bool _showMissingOnly; // ── Colors ───────────────────────────────────────────────────────── private static readonly Color ColOrphan = new Color(0.90f, 0.15f, 0.15f, 0.80f); // 孤儿 Key(无引用) private static readonly Color ColMissing = new Color(0.95f, 0.55f, 0.10f, 0.80f); // 无效 Key(不在 Addressables) private static readonly Color ColOk = new Color(0.20f, 0.75f, 0.30f, 0.80f); // 正常 [MenuItem("BaseGames/Addressables/Asset Reference Graph")] public static void OpenWindow() { var win = GetWindow("Asset Reference Graph"); win.minSize = new Vector2(900, 500); win.Show(); } // ── GUI ──────────────────────────────────────────────────────────── private void OnGUI() { DrawToolbar(); if (_entries == null) { EditorGUILayout.HelpBox("点击上方「扫描」按钮分析 AddressKeys 引用关系。", MessageType.Info); return; } DrawFilterRow(); DrawResults(); } // ── Toolbar ─────────────────────────────────────────────────────── private void DrawToolbar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); if (GUILayout.Button("扫描", EditorStyles.toolbarButton, GUILayout.Width(60))) RunScan(); if (_entries != null && GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(70))) ExportCsv(); GUILayout.FlexibleSpace(); if (_entries != null) { int orphans = _entries.Count(e => e.ReferenceCount == 0); int missing = _entries.Count(e => !e.ExistsInAddressables); EditorGUILayout.LabelField( $"共 {_entries.Count} 个 Key | 孤儿:{orphans} | 未在 Addressables:{missing}", EditorStyles.toolbarButton); } EditorGUILayout.EndHorizontal(); } // ── 过滤行 ──────────────────────────────────────────────────────── private void DrawFilterRow() { EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("搜索:", GUILayout.Width(40)); _searchFilter = EditorGUILayout.TextField(_searchFilter, GUILayout.ExpandWidth(true)); _showOrphansOnly = EditorGUILayout.ToggleLeft("仅显示孤儿", _showOrphansOnly, GUILayout.Width(90)); _showMissingOnly = EditorGUILayout.ToggleLeft("仅显示缺失", _showMissingOnly, GUILayout.Width(90)); EditorGUILayout.EndHorizontal(); } // ── 结果列表 ────────────────────────────────────────────────────── private void DrawResults() { var filtered = _entries.AsEnumerable(); if (_showOrphansOnly) filtered = filtered.Where(e => e.ReferenceCount == 0); if (_showMissingOnly) filtered = filtered.Where(e => !e.ExistsInAddressables); if (!string.IsNullOrEmpty(_searchFilter)) filtered = filtered.Where(e => e.FieldName.IndexOf(_searchFilter, System.StringComparison.OrdinalIgnoreCase) >= 0); var list = filtered.ToList(); // 表头 EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); EditorGUILayout.LabelField("状态", GUILayout.Width(50)); EditorGUILayout.LabelField("Key 名称", GUILayout.Width(280)); EditorGUILayout.LabelField("地址值", GUILayout.Width(300)); EditorGUILayout.LabelField("引用数", GUILayout.Width(60)); EditorGUILayout.EndHorizontal(); _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos); foreach (var entry in list) { bool isOrphan = entry.ReferenceCount == 0; bool isMissing = !entry.ExistsInAddressables; Color statusColor = isOrphan ? ColOrphan : (isMissing ? ColMissing : ColOk); string statusIcon = isOrphan ? "⊘" : (isMissing ? "⚠" : "✓"); var prevBg = GUI.backgroundColor; GUI.backgroundColor = statusColor * 0.6f; EditorGUILayout.BeginHorizontal("box"); GUI.backgroundColor = prevBg; EditorGUILayout.LabelField(statusIcon, GUILayout.Width(50)); EditorGUILayout.LabelField(entry.FieldName, GUILayout.Width(280)); // 地址值可点击 → Ping Addressable asset if (GUILayout.Button(entry.Value, isOrphan ? EditorStyles.label : EditorStyles.miniButtonMid, GUILayout.Width(300))) { PingAddressableAsset(entry.Value); } EditorGUILayout.LabelField( $"{entry.ReferenceCount}", GUILayout.Width(60)); EditorGUILayout.EndHorizontal(); // 展开:显示引用文件列表 if (entry.ReferenceCount > 0 && entry.ReferencedInFiles != null) { foreach (var file in entry.ReferencedInFiles) { EditorGUILayout.BeginHorizontal(); GUILayout.Space(60); EditorGUILayout.LabelField($" ↳ {file}", EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); } } } EditorGUILayout.EndScrollView(); } // ── 扫描逻辑 ────────────────────────────────────────────────────── private void RunScan() { _entries = new List(); // 1. 收集所有 AddressKeys 常量 var keyFields = typeof(BaseGames.Core.Assets.AddressKeys) .GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.FlattenHierarchy) .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); var keyDict = new Dictionary(); foreach (var f in keyFields) { var value = (string)f.GetRawConstantValue(); keyDict[f.Name] = new KeyEntry { FieldName = f.Name, Value = value, ExistsInAddressables = false, ReferencedInFiles = new List() }; } // 2. 检查 Addressables(直接内联,不依赖 Verification 程序集) var registeredAddressValues = GetRegisteredAddressableAddresses(); foreach (var kv in keyDict) kv.Value.ExistsInAddressables = registeredAddressValues.Contains(kv.Value.Value); // 3. 扫描 _Game/ 下所有 .cs 文件中对 AddressKeys 的引用 string gameDir = Path.Combine(Application.dataPath, "_Game"); if (!Directory.Exists(gameDir)) { Debug.LogWarning($"[AddressReferenceGraph] 未找到游戏目录:{gameDir}"); foreach (var kv in keyDict) _entries.Add(kv.Value); return; } var csFiles = Directory.GetFiles(gameDir, "*.cs", SearchOption.AllDirectories); foreach (var file in csFiles) { string content; try { content = File.ReadAllText(file); } catch { continue; } foreach (var kv in keyDict) { // 匹配 AddressKeys.FieldName(单词边界,避免前缀误匹配) if (Regex.IsMatch(content, $@"\bAddressKeys\.{Regex.Escape(kv.Key)}\b")) { string relativePath = "Assets" + file.Substring(Application.dataPath.Length).Replace('\\', '/'); kv.Value.ReferencedInFiles.Add(relativePath); } } } foreach (var kv in keyDict) _entries.Add(kv.Value); _entries.Sort((a, b) => { // 孤儿排最前,其次缺失,最后正常 int aScore = a.ReferenceCount == 0 ? 0 : (!a.ExistsInAddressables ? 1 : 2); int bScore = b.ReferenceCount == 0 ? 0 : (!b.ExistsInAddressables ? 1 : 2); return aScore != bScore ? aScore.CompareTo(bScore) : string.Compare(a.FieldName, b.FieldName); }); Debug.Log($"[AddressReferenceGraph] 扫描完成:{_entries.Count} 个 Key," + $"{_entries.Count(e => e.ReferenceCount == 0)} 孤儿," + $"{_entries.Count(e => !e.ExistsInAddressables)} 未在 Addressables。"); } // ── CSV 导出 ────────────────────────────────────────────────────── private void ExportCsv() { string path = EditorUtility.SaveFilePanel("导出 CSV", "", "AddressKeyReport", "csv"); if (string.IsNullOrEmpty(path)) return; using var writer = new StreamWriter(path, false, System.Text.Encoding.UTF8); writer.WriteLine("FieldName,Value,ExistsInAddressables,ReferenceCount,ReferencedFiles"); foreach (var e in _entries) { string files = e.ReferencedInFiles != null ? string.Join(" | ", e.ReferencedInFiles) : ""; writer.WriteLine($"{e.FieldName},{e.Value},{e.ExistsInAddressables},{e.ReferenceCount},{files}"); } Debug.Log($"[AddressReferenceGraph] CSV 已导出:{path}"); } // ── Addressables 辅助(独立实现,不依赖 Verification 程序集)───────── private static HashSet GetRegisteredAddressableAddresses() { var addresses = new HashSet(StringComparer.Ordinal); var settings = AddressableAssetSettingsDefaultObject.Settings; if (settings == null) return addresses; foreach (var group in settings.groups) { if (group == null) continue; foreach (var entry in group.entries) if (entry != null) addresses.Add(entry.address); } return addresses; } // ── Ping Addressable ────────────────────────────────────────────── private static void PingAddressableAsset(string address) { #if UNITY_EDITOR var guids = AssetDatabase.FindAssets($"\"{address}\""); if (guids.Length > 0) { var obj = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0])); if (obj != null) EditorGUIUtility.PingObject(obj); } #endif } // ── Data ────────────────────────────────────────────────────────── private class KeyEntry { public string FieldName; public string Value; public bool ExistsInAddressables; public List ReferencedInFiles; public int ReferenceCount => ReferencedInFiles?.Count ?? 0; } } }