using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEditor.AddressableAssets; using UnityEditor.AddressableAssets.Settings; using UnityEngine; namespace BaseGames.Editor { /// /// Addressable 规则同步窗口。 /// /// 功能: /// 1. 扫描所有已注册的 Addressable 资产 /// 2. 根据 中的规则计算期望分组与期望标签 /// 3. 对比实际值,显示所有不符合规范的条目(分组错误 / 标签缺失 / 标签多余) /// 4. 一键自动修复全部问题 /// 5. 导出 CSV 报告供存档或 Code Review /// /// 菜单:BaseGames → Addressables → Rule Sync /// public class AddressableRuleSyncWindow : EditorWindow { // ── 内部数据结构 ─────────────────────────────────────────────────────── private enum IssueKind { None, WrongGroup, MissingLabel, ExtraLabel } private class EntryReport { public string Address; public string AssetPath; public string CurrentGroup; public string ExpectedGroup; // null = 规则未覆盖,维持现状 public string[] CurrentLabels; public string[] ExpectedLabels; public string[] MissingLabels; // 应有但没有 public string[] ExtraLabels; // 有但不应有 public bool GroupOk => ExpectedGroup == null || CurrentGroup == ExpectedGroup; public bool LabelsOk => MissingLabels.Length == 0 && ExtraLabels.Length == 0; public bool IsOk => GroupOk && LabelsOk; } // ── 状态 ────────────────────────────────────────────────────────────── private List _reports = new(); private Vector2 _scrollPos; private bool _showOk = false; private bool _scanned = false; private string _searchFilter = ""; // ── 样式(惰性初始化)──────────────────────────────────────────────── private GUIStyle _okStyle; private GUIStyle _warnStyle; private GUIStyle _errorStyle; private GUIStyle _boldStyle; private GUIStyle _rowEven; private GUIStyle _rowOdd; private bool _stylesReady; // ── 颜色 ───────────────────────────────────────────────────────────── private static readonly Color ColOk = new(0.20f, 0.78f, 0.35f, 1f); private static readonly Color ColWarn = new(0.95f, 0.75f, 0.10f, 1f); private static readonly Color ColError = new(0.90f, 0.25f, 0.20f, 1f); private static readonly Color ColRowEven = new(0.22f, 0.22f, 0.22f, 0.4f); // ── 菜单入口 ────────────────────────────────────────────────────────── [MenuItem("BaseGames/Addressables/Rule Sync", priority = 110)] public static void OpenWindow() { var win = GetWindow("Addressable Rule Sync"); win.minSize = new Vector2(900, 520); win.Show(); } // ── GUI ─────────────────────────────────────────────────────────────── private void OnGUI() { EnsureStyles(); if (AddressableAssetSettingsDefaultObject.Settings == null) { EditorGUILayout.HelpBox( "Addressable Settings 未初始化。\n" + "请先执行 Window → Asset Management → Addressables → Groups → Create Addressables Settings。", MessageType.Error); return; } DrawToolbar(); DrawStats(); DrawTable(); DrawFooter(); } // ── 工具栏 ──────────────────────────────────────────────────────────── private void DrawToolbar() { using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { if (GUILayout.Button("扫描", EditorStyles.toolbarButton, GUILayout.Width(80))) Scan(); GUILayout.Space(8); _showOk = GUILayout.Toggle(_showOk, "显示正常项", EditorStyles.toolbarButton, GUILayout.Width(80)); GUILayout.Space(8); EditorGUILayout.LabelField("搜索:", GUILayout.Width(42)); _searchFilter = EditorGUILayout.TextField(_searchFilter, EditorStyles.toolbarSearchField, GUILayout.Width(200)); GUILayout.FlexibleSpace(); GUI.enabled = _scanned && _reports.Any(r => !r.IsOk); if (GUILayout.Button("✦ 修复所有问题", EditorStyles.toolbarButton, GUILayout.Width(120))) FixAll(); GUI.enabled = _scanned; if (GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(80))) ExportCsv(); GUI.enabled = true; } } // ── 统计行 ──────────────────────────────────────────────────────────── private void DrawStats() { if (!_scanned) return; int total = _reports.Count; int ok = _reports.Count(r => r.IsOk); int issues = total - ok; int wrongGrp = _reports.Count(r => !r.GroupOk); int misLabel = _reports.Count(r => r.MissingLabels.Length > 0); int extLabel = _reports.Count(r => r.ExtraLabels.Length > 0); EditorGUILayout.Space(2); using (new EditorGUILayout.HorizontalScope()) { GUILayout.Label($"共 {total} 条目", EditorStyles.miniLabel); GUILayout.Space(12); DrawColoredLabel($"✅ 正常 {ok}", ColOk); GUILayout.Space(12); DrawColoredLabel($"⚠ 问题 {issues}", issues > 0 ? ColWarn : ColOk); GUILayout.Space(20); GUILayout.Label($"分组错误 {wrongGrp} | 标签缺失 {misLabel} | 标签多余 {extLabel}", EditorStyles.miniLabel); GUILayout.FlexibleSpace(); } EditorGUILayout.Space(2); } // ── 主表格 ──────────────────────────────────────────────────────────── private void DrawTable() { // 表头 using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { GUILayout.Label("Address", _boldStyle, GUILayout.Width(200)); GUILayout.Label("当前分组", _boldStyle, GUILayout.Width(130)); GUILayout.Label("期望分组", _boldStyle, GUILayout.Width(130)); GUILayout.Label("缺失标签", _boldStyle, GUILayout.Width(140)); GUILayout.Label("多余标签", _boldStyle, GUILayout.Width(120)); GUILayout.Label("状态", _boldStyle, GUILayout.Width(80)); } _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.ExpandHeight(true)); if (!_scanned) { EditorGUILayout.HelpBox("点击「扫描」按钮开始分析已注册的 Addressable 资产。", MessageType.Info); } else { var display = _reports .Where(r => _showOk || !r.IsOk) .Where(r => string.IsNullOrEmpty(_searchFilter) || r.Address.IndexOf(_searchFilter, StringComparison.OrdinalIgnoreCase) >= 0) .ToList(); if (display.Count == 0) { EditorGUILayout.HelpBox( _showOk ? "没有匹配搜索条件的条目。" : "✅ 所有资产均符合规范!", MessageType.Info); } for (int i = 0; i < display.Count; i++) DrawRow(display[i], i); } EditorGUILayout.EndScrollView(); } private void DrawRow(EntryReport r, int idx) { var bg = idx % 2 == 0 ? _rowEven : GUIStyle.none; using (new EditorGUILayout.HorizontalScope(bg, GUILayout.Height(20))) { // Address(点击可 Ping) if (GUILayout.Button(r.Address, EditorStyles.linkLabel, GUILayout.Width(200))) PingAsset(r.AssetPath); // 当前分组 var grpColor = r.GroupOk ? ColOk : ColError; DrawColoredLabel(r.CurrentGroup ?? "—", grpColor, GUILayout.Width(130)); // 期望分组 var expGrpText = r.ExpectedGroup ?? "(规则未覆盖)"; var expGrpColor = r.GroupOk ? ColOk : ColWarn; DrawColoredLabel(expGrpText, expGrpColor, GUILayout.Width(130)); // 缺失标签 var missingText = r.MissingLabels.Length > 0 ? string.Join(", ", r.MissingLabels) : "—"; DrawColoredLabel(missingText, r.MissingLabels.Length > 0 ? ColError : ColOk, GUILayout.Width(140)); // 多余标签 var extraText = r.ExtraLabels.Length > 0 ? string.Join(", ", r.ExtraLabels) : "—"; DrawColoredLabel(extraText, r.ExtraLabels.Length > 0 ? ColWarn : ColOk, GUILayout.Width(120)); // 状态 + 单条修复按钮 if (r.IsOk) { DrawColoredLabel("✅ 正常", ColOk, GUILayout.Width(80)); } else { DrawColoredLabel("⚠ 需修复", ColWarn, GUILayout.Width(60)); if (GUILayout.Button("修复", EditorStyles.miniButton, GUILayout.Width(40))) FixEntry(r); } } } // ── 底栏 ────────────────────────────────────────────────────────────── private void DrawFooter() { EditorGUILayout.Space(4); EditorGUILayout.HelpBox( "规则来源:Docs/Standards/AddressablesLabelSpec.md §3 分组规则:AssetFolderSpec.md §8.1\n" + "「修复所有问题」仅修改已注册资产的分组/标签,不注册新资产(请用 Addressable Batch Tool)。", MessageType.None); } // ── 扫描逻辑 ────────────────────────────────────────────────────────── private void Scan() { _reports.Clear(); var settings = AddressableAssetSettingsDefaultObject.Settings; if (settings == null) return; // 收集所有全局标签供"多余标签"判断 var allKnownLabels = new HashSet(settings.GetLabels(), StringComparer.Ordinal); foreach (var group in settings.groups) { if (group == null) continue; foreach (var entry in group.entries) { if (entry == null) continue; var address = entry.address; var expectedGroup = AddressableRules.GetExpectedGroup(address); var expectedLbls = AddressableRules.GetExpectedLabels(address); var currentLbls = entry.labels.ToArray(); var missing = expectedLbls.Except(currentLbls, StringComparer.Ordinal).ToArray(); var extra = currentLbls.Except(expectedLbls, StringComparer.Ordinal).ToArray(); _reports.Add(new EntryReport { Address = address, AssetPath = entry.AssetPath, CurrentGroup = group.name, ExpectedGroup = expectedGroup, CurrentLabels = currentLbls, ExpectedLabels = expectedLbls, MissingLabels = missing, ExtraLabels = extra, }); } } // 问题项排前面,正常项排后面;同类按 Address 字母序 _reports = _reports .OrderBy(r => r.IsOk) .ThenBy(r => r.Address, StringComparer.Ordinal) .ToList(); _scanned = true; Repaint(); Debug.Log($"[AddressableRuleSync] 扫描完成:{_reports.Count} 个条目," + $"{_reports.Count(r => !r.IsOk)} 个需要修复。"); } // ── 修复逻辑 ────────────────────────────────────────────────────────── private void FixAll() { var issues = _reports.Where(r => !r.IsOk).ToList(); if (issues.Count == 0) return; int fixedCount = 0; foreach (var r in issues) { if (FixEntry(r)) fixedCount++; } SaveSettings(); Scan(); // 修复后重新扫描以更新结果 Debug.Log($"[AddressableRuleSync] 修复完成:共处理 {fixedCount} 个条目。"); } private bool FixEntry(EntryReport r) { var settings = AddressableAssetSettingsDefaultObject.Settings; if (settings == null) return false; var entry = FindEntry(settings, r.Address); if (entry == null) { Debug.LogWarning($"[AddressableRuleSync] 找不到条目:{r.Address}"); return false; } bool changed = false; // 修复分组 if (!r.GroupOk && r.ExpectedGroup != null) { var targetGroup = GetOrCreateGroup(settings, r.ExpectedGroup); if (targetGroup != null && entry.parentGroup != targetGroup) { settings.MoveEntry(entry, targetGroup, false, false); r.CurrentGroup = r.ExpectedGroup; changed = true; } } // 添加缺失标签 foreach (var lbl in r.MissingLabels) { EnsureLabelExists(settings, lbl); entry.SetLabel(lbl, true, true); changed = true; } // 移除多余标签 foreach (var lbl in r.ExtraLabels) { entry.SetLabel(lbl, false, true); changed = true; } return changed; } // ── 导出 CSV ────────────────────────────────────────────────────────── private void ExportCsv() { if (_reports.Count == 0) return; var path = EditorUtility.SaveFilePanel( "导出 Addressable Rule 报告", "", "AddressableRuleReport.csv", "csv"); if (string.IsNullOrEmpty(path)) return; var sb = new StringBuilder(); sb.AppendLine("Address,CurrentGroup,ExpectedGroup,GroupOk,MissingLabels,ExtraLabels,Status"); foreach (var r in _reports) { var status = r.IsOk ? "OK" : "ISSUE"; sb.AppendLine( $"\"{r.Address}\"," + $"\"{r.CurrentGroup}\"," + $"\"{r.ExpectedGroup ?? "(uncovered)"}\"," + $"{r.GroupOk}," + $"\"{string.Join(";", r.MissingLabels)}\"," + $"\"{string.Join(";", r.ExtraLabels)}\"," + $"{status}"); } File.WriteAllText(path, sb.ToString(), Encoding.UTF8); Debug.Log($"[AddressableRuleSync] CSV 报告已导出:{path}"); } // ── 辅助方法 ────────────────────────────────────────────────────────── private static AddressableAssetEntry FindEntry(AddressableAssetSettings settings, string address) { foreach (var group in settings.groups) { if (group == null) continue; foreach (var e in group.entries) if (e != null && e.address == address) return e; } return null; } private static AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings, string groupName) { var existing = settings.groups.FirstOrDefault(g => g != null && g.name == groupName); if (existing != null) return existing; var template = settings.GroupTemplateObjects.FirstOrDefault() as AddressableAssetGroupTemplate; var newGroup = settings.CreateGroup(groupName, false, false, true, template != null ? new List(template.SchemaObjects) : null); if (newGroup != null) Debug.Log($"[AddressableRuleSync] 已自动创建分组:{groupName}"); return newGroup ?? settings.DefaultGroup; } private static void EnsureLabelExists(AddressableAssetSettings settings, string label) { var labels = settings.GetLabels(); if (!labels.Contains(label)) { settings.AddLabel(label, true); Debug.Log($"[AddressableRuleSync] 已创建标签:{label}"); } } private static void SaveSettings() { AssetDatabase.SaveAssets(); AddressableAssetSettingsDefaultObject.Settings?.SetDirty( AddressableAssetSettings.ModificationEvent.EntryModified, null, true); } private static void PingAsset(string assetPath) { if (string.IsNullOrEmpty(assetPath)) return; var obj = AssetDatabase.LoadMainAssetAtPath(assetPath); if (obj != null) EditorGUIUtility.PingObject(obj); } // ── 样式初始化 ──────────────────────────────────────────────────────── private void EnsureStyles() { if (_stylesReady) return; _boldStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 11 }; _okStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = ColOk } }; _warnStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = ColWarn } }; _errorStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = ColError } }; _rowEven = new GUIStyle(); _rowEven.normal.background = MakeTexture(1, 1, ColRowEven); _stylesReady = true; } private void DrawColoredLabel(string text, Color color, params GUILayoutOption[] options) { var prev = GUI.color; GUI.color = color; GUILayout.Label(text, EditorStyles.miniLabel, options); GUI.color = prev; } private static Texture2D MakeTexture(int width, int height, Color color) { var tex = new Texture2D(width, height); tex.SetPixel(0, 0, color); tex.Apply(); return tex; } } }