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; // 规则不要求且在 KnownLabels 中(多余规则标签),红色错误 public string[] UnknownLabels; // 规则不要求且不在 KnownLabels 中(自定义标签),黄色警告,不自动删除 public bool GroupOk => ExpectedGroup == null || CurrentGroup == ExpectedGroup; public bool LabelsOk => MissingLabels.Length == 0 && ExtraLabels.Length == 0; public bool IsOk => GroupOk && LabelsOk; public bool HasWarnings => UnknownLabels.Length > 0; } // ── 状态 ────────────────────────────────────────────────────────────── 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(1040, 540); 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(); if (GUILayout.Button("🔄 刷新", EditorStyles.toolbarButton, GUILayout.Width(60))) 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 = _reports.Count(r => !r.IsOk); int warnings = _reports.Count(r => r.IsOk && r.HasWarnings); 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); int unkLabel = _reports.Count(r => r.UnknownLabels.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 ? ColError : ColOk); GUILayout.Space(8); DrawColoredLabel($"⚠ 自定义标签 {unkLabel}", unkLabel > 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(120)); GUILayout.Label("期望分组", _boldStyle, GUILayout.Width(120)); GUILayout.Label("缺失标签", _boldStyle, GUILayout.Width(130)); GUILayout.Label("多余规则标签", _boldStyle, GUILayout.Width(110)); GUILayout.Label("自定义标签", _boldStyle, GUILayout.Width(110)); 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(120)); // 期望分组 var expGrpText = r.ExpectedGroup ?? "(规则未覆盖)"; var expGrpColor = r.GroupOk ? ColOk : ColWarn; DrawColoredLabel(expGrpText, expGrpColor, GUILayout.Width(120)); // 缺失标签(红色,须补齐) var missingText = r.MissingLabels.Length > 0 ? string.Join(", ", r.MissingLabels) : "—"; DrawColoredLabel(missingText, r.MissingLabels.Length > 0 ? ColError : ColOk, GUILayout.Width(130)); // 多余规则标签(红色,将被 FixEntry 移除) var extraText = r.ExtraLabels.Length > 0 ? string.Join(", ", r.ExtraLabels) : "—"; DrawColoredLabel(extraText, r.ExtraLabels.Length > 0 ? ColError : ColOk, GUILayout.Width(110)); // 自定义标签(黄色警告,不会被自动删除,建议写入规范) var unknownText = r.UnknownLabels.Length > 0 ? string.Join(", ", r.UnknownLabels) : "—"; DrawColoredLabel(unknownText, r.UnknownLabels.Length > 0 ? ColWarn : ColOk, GUILayout.Width(110)); // 状态 + 单条修复按钮 if (r.IsOk) { var statusColor = r.HasWarnings ? ColWarn : ColOk; var statusText = r.HasWarnings ? "⚠ 自定义标签" : "✅ 正常"; DrawColoredLabel(statusText, statusColor, GUILayout.Width(80)); } else { DrawColoredLabel("❌ 需修复", ColError, 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" + "「修复所有问题」仅修改已注册资产的分组/标签,不注册新资产,不删除自定义标签(黄色警告项)。\n" + "新增资产工作流:① Addressable Batch Tool → ⚡ 全量扫描 _Game/ → 注册所有 ② 返回此窗口 → 扫描 → 修复所有问题", MessageType.None); } // ── 扫描逻辑 ────────────────────────────────────────────────────────── private void Scan() { _reports.Clear(); var settings = AddressableAssetSettingsDefaultObject.Settings; if (settings == null) return; 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(); // 区分两类"多余标签": // extra = 规则已知标签(KnownLabels)中规则不要求的 → 红色,FixEntry 会移除 // unknown = 不在 KnownLabels 中的自定义标签 → 黄色警告,FixEntry 保留,建议写入规范 var notExpected = currentLbls.Except(expectedLbls, StringComparer.Ordinal); var extra = notExpected.Where(l => AddressableRules.KnownLabels.Contains(l)).ToArray(); var unknown = notExpected.Where(l => !AddressableRules.KnownLabels.Contains(l)).ToArray(); _reports.Add(new EntryReport { Address = address, AssetPath = entry.AssetPath, CurrentGroup = group.name, ExpectedGroup = expectedGroup, CurrentLabels = currentLbls, ExpectedLabels = expectedLbls, MissingLabels = missing, ExtraLabels = extra, UnknownLabels = unknown, }); } } // 问题项排前面,仅有警告的次之,正常项排最后;同类按 Address 字母序 _reports = _reports .OrderBy(r => r.IsOk ? (r.HasWarnings ? 1 : 2) : 0) .ThenBy(r => r.Address, StringComparer.Ordinal) .ToList(); _scanned = true; Repaint(); int issues = _reports.Count(r => !r.IsOk); int warnings = _reports.Count(r => r.IsOk && r.HasWarnings); Debug.Log($"[AddressableRuleSync] 扫描完成:{_reports.Count} 个条目," + $"{issues} 个需要修复,{warnings} 个含自定义标签警告。"); } // ── 修复逻辑 ────────────────────────────────────────────────────────── private void FixAll() { var issues = _reports.Where(r => !r.IsOk).ToList(); if (issues.Count == 0) return; int moveCount = issues.Count(r => !r.GroupOk); int addCount = issues.Sum(r => r.MissingLabels.Length); int removeCount = issues.Sum(r => r.ExtraLabels.Length); // 干跑预览对话框 bool confirmed = EditorUtility.DisplayDialog( "确认修复所有问题", $"将对 {issues.Count} 个条目执行以下操作:\n\n" + $" • 移动分组:{moveCount} 个\n" + $" • 添加标签:{addCount} 个\n" + $" • 移除多余规则标签:{removeCount} 个\n\n" + "⚠ 自定义标签(黄色警告项)不会被删除。\n" + "此操作不可撤销,请确认后继续。", "确认修复", "取消"); if (!confirmed) 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; } // 移除多余规则标签(ExtraLabels 只包含 KnownLabels 中规则不要求的标签; // UnknownLabels 是用户自定义标签,刻意保留,不做删除) 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; } } }