using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; using UnityEngine.UI; using UnityEngine.U2D; using Unity.Profiling; using MFrame; using Object = UnityEngine.Object; /// /// Runs in a Play Mode test account and measures registered panels one at a time. /// It intentionally does not run while editing because most panels require initialized game services. /// internal static class UIRuntimePerformanceAudit { private const int WarmupFrameCount = 10; private const int SampleFrameCount = 60; private const int CloseVerificationFrameCount = 5; private const float OpenTimeoutSeconds = 10f; private enum AuditState { Idle, Opening, AwaitingOpen, Warmup, Sampling, Closing, Finished } private sealed class PanelEntry { public string ConstType; public string PanelName; public string Category; public GameObject Prefab; public string PrefabPath; public bool Selected; public bool DefaultSafe; } private sealed class PanelResult { public string PanelName; public GameObject Prefab; public string Status; public float OpenMs; public float StaySeconds; public float AverageFrameMs; public float P95FrameMs; public float MaxFrameMs; public long PeakGcBytes; public int ResidualObjectCount; public string Reason; } private sealed class ManualSession { public PanelEntry Entry; public float StartTime; public float EndTime; public float DetectedOpenMs; public long PeakGcBytes; public readonly List FrameSamples = new List(); } private static readonly List Entries = new List(); private static readonly List Results = new List(); private static readonly List FrameSamples = new List(); private static Vector2 scroll; private static AuditState state; private static int currentIndex; private static int frameCount; private static float requestTime; private static float openedTime; private static PanelEntry activeEntry; private static PanelResult activeResult; private static ProfilerRecorder gcRecorder; private static bool recorderStarted; private static bool isSubscribed; private static Dictionary baselineActiveObjects = new Dictionary(); private static readonly List ManualSessions = new List(); private static readonly Dictionary ActiveManualSessions = new Dictionary(); private static readonly Dictionary ManualPanelNameLookup = new Dictionary(StringComparer.Ordinal); private static bool isManualRecording; private static float lastManualPollTime; public static void DrawEmbedded() { EditorGUILayout.HelpBox( "在已进入游戏、使用测试账号且停留在安全主界面时执行。工具会按勾选项依次 Show/Close 面板;会触发面板的正常初始化,请勿在正式账号或战斗中运行。\n" + "采样指标:首次打开耗时、稳定 60 帧的平均/P95/最大帧耗时、单帧 GC 峰值。手动录制的“打开 ms*”为发现 UI 激活的单帧近似值。关闭后会额外等待 5 帧,检查本次新增且仍激活的 UI 对象。编辑器 Play Mode 仅用于筛查,最终请在目标真机复测。", MessageType.Warning); EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("发现已注册界面", GUILayout.Width(130))) DiscoverPanels(); using (new EditorGUI.DisabledScope(Entries.Count == 0 || isManualRecording || state != AuditState.Idle && state != AuditState.Finished)) { if (GUILayout.Button("选择默认安全项", GUILayout.Width(120))) SelectDefaultSafe(); if (GUILayout.Button("全选", GUILayout.Width(60))) SetSelection(true); if (GUILayout.Button("全不选", GUILayout.Width(70))) SetSelection(false); if (GUILayout.Button("开始巡检", GUILayout.Width(90))) Start(); } using (new EditorGUI.DisabledScope(state == AuditState.Idle || state == AuditState.Finished)) { if (GUILayout.Button("停止", GUILayout.Width(60))) Stop("已由用户停止"); } if (GUILayout.Button("导出 CSV", GUILayout.Width(80))) ExportCsv(); EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); using (new EditorGUI.DisabledScope(Entries.Count == 0 || isManualRecording || state != AuditState.Idle && state != AuditState.Finished)) { if (GUILayout.Button("开始手动录制", GUILayout.Width(120))) StartManualRecording(); } using (new EditorGUI.DisabledScope(!isManualRecording)) { if (GUILayout.Button("停止并生成报告", GUILayout.Width(130))) StopManualRecording(); } GUILayout.Label(isManualRecording ? "录制中:请正常操作游戏,结束后点击停止。" : "手动录制:自动记录实际打开的已注册 UI。", EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); string stateText = isManualRecording ? "手动录制中" : state == AuditState.Idle ? "未开始" : state.ToString(); EditorGUILayout.LabelField($"已发现 {Entries.Count} 个注册界面;已勾选 {Entries.Count(x => x.Selected)} 个;状态:{stateText}"); if (!EditorApplication.isPlaying) EditorGUILayout.HelpBox("请先进入 Play Mode,等待登录和主界面完全初始化后再点击“开始巡检”。", MessageType.Info); scroll = EditorGUILayout.BeginScrollView(scroll); EditorGUILayout.LabelField("待测界面", EditorStyles.boldLabel); foreach (PanelEntry entry in Entries) { EditorGUILayout.BeginHorizontal(); entry.Selected = EditorGUILayout.Toggle(entry.Selected, GUILayout.Width(22)); GUILayout.Label(entry.PanelName, GUILayout.Width(260)); GUILayout.Label(entry.Category, EditorStyles.miniLabel, GUILayout.Width(150)); GUILayout.Label(entry.ConstType, EditorStyles.miniLabel, GUILayout.Width(220)); GUILayout.Label(entry.DefaultSafe ? "默认勾选" : "需人工确认", EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); } if (Results.Count > 0) { GUILayout.Space(8); EditorGUILayout.LabelField("巡检结果(按 P95 帧耗时降序)", EditorStyles.boldLabel); EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.Label("界面", EditorStyles.miniBoldLabel, GUILayout.Width(150)); GUILayout.Label("Prefab(可点击定位)", EditorStyles.miniBoldLabel, GUILayout.Width(230)); GUILayout.Label("状态", EditorStyles.miniBoldLabel, GUILayout.Width(100)); GUILayout.Label("打开 ms*", EditorStyles.miniBoldLabel, GUILayout.Width(70)); GUILayout.Label("停留 s", EditorStyles.miniBoldLabel, GUILayout.Width(55)); GUILayout.Label("平均 / P95 / 最大 ms", EditorStyles.miniBoldLabel, GUILayout.Width(165)); GUILayout.Label("GC", EditorStyles.miniBoldLabel, GUILayout.Width(65)); GUILayout.Label("原因", EditorStyles.miniBoldLabel); EditorGUILayout.EndHorizontal(); foreach (PanelResult result in Results.OrderByDescending(x => x.P95FrameMs)) { EditorGUILayout.BeginHorizontal(); if (GUILayout.Button(result.PanelName, EditorStyles.miniButton, GUILayout.Width(150))) { Selection.activeObject = result.Prefab; if (result.Prefab != null) EditorGUIUtility.PingObject(result.Prefab); } EditorGUILayout.ObjectField(result.Prefab, typeof(GameObject), false, GUILayout.Width(230)); GUILayout.Label(result.Status, EditorStyles.miniLabel, GUILayout.Width(100)); GUILayout.Label(result.OpenMs > 0 ? result.OpenMs.ToString("F1") : "-", EditorStyles.miniLabel, GUILayout.Width(70)); GUILayout.Label(result.StaySeconds > 0 ? result.StaySeconds.ToString("F1") : "-", EditorStyles.miniLabel, GUILayout.Width(55)); GUILayout.Label(result.AverageFrameMs > 0 ? $"{result.AverageFrameMs:F1} / {result.P95FrameMs:F1} / {result.MaxFrameMs:F1}" : "-", EditorStyles.miniLabel, GUILayout.Width(165)); GUILayout.Label((result.PeakGcBytes / 1024f).ToString("F1") + " KB", EditorStyles.miniLabel, GUILayout.Width(65)); GUILayout.Label(result.Reason ?? string.Empty, EditorStyles.wordWrappedMiniLabel); EditorGUILayout.EndHorizontal(); } } EditorGUILayout.EndScrollView(); } private static void DiscoverPanels() { Entries.Clear(); Results.Clear(); ManualPanelNameLookup.Clear(); string[] wrapperGuids = AssetDatabase.FindAssets("GameInitWrapper t:Script"); if (wrapperGuids.Length == 0) return; string wrapperPath = AssetDatabase.GUIDToAssetPath(wrapperGuids[0]); string wrapperSource = File.ReadAllText(GetAbsolutePath(wrapperPath)); System.Text.RegularExpressions.MatchCollection matches = System.Text.RegularExpressions.Regex.Matches(wrapperSource, @"AddItem<(?[A-Za-z0-9_]+)>"); HashSet seen = new HashSet(StringComparer.Ordinal); foreach (System.Text.RegularExpressions.Match match in matches) { string typeName = match.Groups["type"].Value; if (!seen.Add(typeName)) continue; string panelName; GameObject prefab; string prefabPath; if (!TryFindPanelDefinition(typeName, out panelName, out prefab, out prefabPath)) continue; if (string.IsNullOrEmpty(panelName)) continue; string category = ClassifyPanel(panelName); bool defaultSafe = category == "常规界面"; Entries.Add(new PanelEntry { ConstType = typeName, PanelName = panelName, Category = category, Prefab = prefab, PrefabPath = prefabPath, DefaultSafe = defaultSafe, Selected = defaultSafe }); ManualPanelNameLookup[panelName] = panelName; if (prefab != null) ManualPanelNameLookup[prefab.name] = panelName; } Entries.Sort((a, b) => string.Compare(a.PanelName, b.PanelName, StringComparison.Ordinal)); } private static bool TryFindPanelDefinition(string constType, out string panelName, out GameObject prefab, out string prefabPath) { panelName = null; prefab = null; prefabPath = null; foreach (string guid in AssetDatabase.FindAssets(constType + " t:Script")) { string path = AssetDatabase.GUIDToAssetPath(guid); if (!string.Equals(Path.GetFileNameWithoutExtension(path), constType, StringComparison.Ordinal)) continue; string source = File.ReadAllText(GetAbsolutePath(path)); System.Text.RegularExpressions.Match nameMatch = System.Text.RegularExpressions.Regex.Match(source, @"const\s+string\s+ui_name\s*=\s*""(?[^""]+)"""); if (!nameMatch.Success) continue; panelName = nameMatch.Groups["name"].Value; System.Text.RegularExpressions.Match pathMatch = System.Text.RegularExpressions.Regex.Match(source, @"const\s+string\s+path\s*=\s*""(?[^""]+)"""); if (pathMatch.Success) { prefabPath = "Assets/Resources/Resources_moved/" + pathMatch.Groups["path"].Value; prefab = AssetDatabase.LoadAssetAtPath(prefabPath); } return true; } return false; } private static string ClassifyPanel(string panelName) { string value = panelName.ToLowerInvariant(); if (value.IndexOf("tip", StringComparison.Ordinal) >= 0 || value.IndexOf("confirm", StringComparison.Ordinal) >= 0) return "提示/确认(手动关闭)"; if (value.IndexOf("fight", StringComparison.Ordinal) >= 0 || value.IndexOf("result", StringComparison.Ordinal) >= 0 || value.IndexOf("fb", StringComparison.Ordinal) >= 0) return "场景/结算依赖"; if (value.IndexOf("loading", StringComparison.Ordinal) >= 0 || value.IndexOf("login", StringComparison.Ordinal) >= 0 || value.IndexOf("guide", StringComparison.Ordinal) >= 0) return "启动/引导依赖"; return "常规界面"; } private static void SelectDefaultSafe() { foreach (PanelEntry entry in Entries) entry.Selected = entry.DefaultSafe; } private static void SetSelection(bool value) { foreach (PanelEntry entry in Entries) entry.Selected = value; } private static void StartManualRecording() { if (!EditorApplication.isPlaying) { EditorUtility.DisplayDialog("需要 Play Mode", "请进入 Play Mode,等待游戏初始化完成后再开始手动录制。", "确定"); return; } Results.Clear(); ManualSessions.Clear(); ActiveManualSessions.Clear(); lastManualPollTime = Time.realtimeSinceStartup; StartRecorder(); isManualRecording = true; Subscribe(); } private static void StopManualRecording() { if (!isManualRecording) return; float now = Time.realtimeSinceStartup; foreach (ManualSession session in ActiveManualSessions.Values) session.EndTime = now; ActiveManualSessions.Clear(); isManualRecording = false; Results.Clear(); foreach (ManualSession session in ManualSessions) Results.Add(CreateManualResult(session)); if (state == AuditState.Idle || state == AuditState.Finished) Finish(); } private static void TickManualRecording() { if (!isManualRecording) return; if (!EditorApplication.isPlaying) { StopManualRecording(); return; } float now = Time.realtimeSinceStartup; float detectionIntervalMs = Math.Max(0f, now - lastManualPollTime) * 1000f; lastManualPollTime = now; HashSet visiblePanels = GetVisibleRegisteredPanels(); foreach (PanelEntry entry in Entries) { bool visible = visiblePanels.Contains(entry.PanelName); bool recording = ActiveManualSessions.TryGetValue(entry.PanelName, out ManualSession session); if (visible && !recording) { session = new ManualSession { Entry = entry, StartTime = now, DetectedOpenMs = detectionIntervalMs }; ManualSessions.Add(session); ActiveManualSessions.Add(entry.PanelName, session); } if (visible && session != null) { session.FrameSamples.Add(Time.unscaledDeltaTime * 1000f); if (recorderStarted && gcRecorder.Valid) session.PeakGcBytes = Math.Max(session.PeakGcBytes, gcRecorder.LastValue); } if (!visible && recording) { session.EndTime = now; ActiveManualSessions.Remove(entry.PanelName); } } } private static HashSet GetVisibleRegisteredPanels() { HashSet visible = new HashSet(StringComparer.Ordinal); foreach (GameObject item in Resources.FindObjectsOfTypeAll()) { if (item == null || !item.activeInHierarchy || !item.scene.IsValid() || !item.scene.isLoaded) continue; string name = item.name.EndsWith("(Clone)", StringComparison.Ordinal) ? item.name.Substring(0, item.name.Length - 7) : item.name; if (ManualPanelNameLookup.TryGetValue(name, out string panelName)) visible.Add(panelName); } return visible; } // TODO(精确手动录制耗时):在 MFrame.PanelMgr 中增加 UI 生命周期事件。 // 1. 在 Show(name, data, completeHandler) 入口记录 startTime,并触发 OnPanelShowRequested(name, startTime)。 // 2. 在资源加载完成且 PanelBase._OnShow 执行完成后的 completeHandler 中,触发 OnPanelShown(name, startTime, elapsedMs)。 // 3. 在 Close(name) 成功后触发 OnPanelClosed(name)。 // 4. 本工具订阅上述事件,以 name 关联会话,便可得到真实的“请求 Show 到可见”耗时, // 不再依赖当前轮询 GameObject 激活状态所得的一帧近似值。 private static PanelResult CreateManualResult(ManualSession session) { List samples = session.FrameSamples.OrderBy(x => x).ToList(); PanelResult result = new PanelResult { PanelName = session.Entry.PanelName, Prefab = session.Entry.Prefab, Status = "手动录制", // 近似值:两次轮询之间首次发现 UI 激活的间隔,不代表真实的资源加载耗时。 OpenMs = session.DetectedOpenMs, StaySeconds = Math.Max(0f, session.EndTime - session.StartTime), PeakGcBytes = session.PeakGcBytes, Reason = "实际操作录制" }; if (samples.Count > 0) { result.AverageFrameMs = samples.Average(); result.P95FrameMs = samples[Mathf.Clamp(Mathf.CeilToInt(samples.Count * 0.95f) - 1, 0, samples.Count - 1)]; result.MaxFrameMs = samples[samples.Count - 1]; } return result; } private static void Start() { if (!EditorApplication.isPlaying) { EditorUtility.DisplayDialog("需要 Play Mode", "请进入 Play Mode 并等待游戏初始化完成后再执行巡检。", "确定"); return; } if (!Entries.Any(x => x.Selected)) return; Results.Clear(); currentIndex = 0; state = AuditState.Opening; activeEntry = null; activeResult = null; StartRecorder(); Subscribe(); } private static void Subscribe() { if (isSubscribed) return; EditorApplication.update += Tick; isSubscribed = true; } private static void Tick() { if (isManualRecording) { TickManualRecording(); return; } if (!EditorApplication.isPlaying) { Stop("已退出 Play Mode"); return; } if (state == AuditState.Opening) { while (currentIndex < Entries.Count && !Entries[currentIndex].Selected) currentIndex++; if (currentIndex >= Entries.Count) { Finish(); return; } activeEntry = Entries[currentIndex++]; activeResult = new PanelResult { PanelName = activeEntry.PanelName, Prefab = activeEntry.Prefab, Status = "打开中" }; Results.Add(activeResult); FrameSamples.Clear(); frameCount = 0; requestTime = Time.realtimeSinceStartup; baselineActiveObjects = CaptureActiveSceneObjects(); state = AuditState.AwaitingOpen; try { PanelMgr.Instance.Show(activeEntry.PanelName, null, panel => { if (state != AuditState.AwaitingOpen || activeEntry == null) return; openedTime = Time.realtimeSinceStartup; activeResult.OpenMs = (openedTime - requestTime) * 1000f; activeResult.Status = "预热中"; state = AuditState.Warmup; }); } catch (Exception exception) { FailCurrent("打开异常: " + exception.GetType().Name, "缺少运行时数据或初始化条件不满足"); } return; } if (state == AuditState.AwaitingOpen && Time.realtimeSinceStartup - requestTime > OpenTimeoutSeconds) { FailCurrent("打开超时", "未完成初始化:请检查运行时数据、参数或资源加载。"); return; } if (state == AuditState.Warmup) { if (++frameCount >= WarmupFrameCount) { frameCount = 0; activeResult.Status = "采样中"; state = AuditState.Sampling; } return; } if (state == AuditState.Sampling) { FrameSamples.Add(Time.unscaledDeltaTime * 1000f); if (recorderStarted && gcRecorder.Valid) activeResult.PeakGcBytes = Math.Max(activeResult.PeakGcBytes, gcRecorder.LastValue); if (++frameCount >= SampleFrameCount) { CompleteCurrent(); return; } } if (state == AuditState.Closing && ++frameCount >= CloseVerificationFrameCount) FinishClosing(); } private static void CompleteCurrent() { FrameSamples.Sort(); activeResult.AverageFrameMs = FrameSamples.Count == 0 ? 0f : FrameSamples.Average(); activeResult.P95FrameMs = FrameSamples.Count == 0 ? 0f : FrameSamples[Mathf.Clamp(Mathf.CeilToInt(FrameSamples.Count * 0.95f) - 1, 0, FrameSamples.Count - 1)]; activeResult.MaxFrameMs = FrameSamples.Count == 0 ? 0f : FrameSamples[FrameSamples.Count - 1]; activeResult.Status = "完成"; CloseCurrent(); } private static void FailCurrent(string reason, string detail = null) { if (activeResult != null) { activeResult.Status = reason; activeResult.Reason = detail; } CloseCurrent(); } private static void CloseCurrent() { try { if (activeEntry != null) PanelMgr.Instance.Close(activeEntry.PanelName); } catch (Exception exception) { if (activeResult != null) { activeResult.Status += ";关闭异常"; activeResult.Reason = "关闭接口执行失败。"; } } frameCount = 0; state = AuditState.Closing; } private static void FinishClosing() { Dictionary currentActiveObjects = CaptureActiveSceneObjects(); int residualCount = currentActiveObjects.Count(x => !baselineActiveObjects.ContainsKey(x.Key)); if (residualCount > 0 && activeResult != null) { activeResult.ResidualObjectCount = residualCount; activeResult.Status += ";疑似未关闭"; activeResult.Reason = "关闭后仍有新增 UI 保持激活;提示类可能需要点击空白处关闭。"; } activeEntry = null; activeResult = null; baselineActiveObjects.Clear(); state = AuditState.Opening; } private static void Stop(string reason) { if (activeResult != null && activeResult.Status != "完成") activeResult.Status = reason; try { if (activeEntry != null) PanelMgr.Instance.Close(activeEntry.PanelName); } catch { } Finish(); } private static void Finish() { state = AuditState.Finished; activeEntry = null; activeResult = null; if (isSubscribed) { EditorApplication.update -= Tick; isSubscribed = false; } if (recorderStarted) { gcRecorder.Dispose(); recorderStarted = false; } } private static void StartRecorder() { if (recorderStarted) gcRecorder.Dispose(); gcRecorder = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame"); recorderStarted = true; } private static void ExportCsv() { if (Results.Count == 0) { EditorUtility.DisplayDialog("没有数据", "请先完成一次巡检。", "确定"); return; } string path = EditorUtility.SaveFilePanel("导出界面性能报告", Application.dataPath, "UI性能巡检报告", "csv"); if (string.IsNullOrEmpty(path)) return; List lines = new List { "界面,Prefab,状态,打开耗时ms,停留秒,平均帧耗时ms,P95帧耗时ms,最大帧耗时ms,GC峰值KB,疑似残留对象数,原因" }; foreach (PanelResult item in Results.OrderByDescending(x => x.P95FrameMs)) lines.Add(Csv(item.PanelName) + "," + Csv(item.Prefab == null ? string.Empty : AssetDatabase.GetAssetPath(item.Prefab)) + "," + Csv(item.Status) + "," + item.OpenMs.ToString("F2") + "," + item.StaySeconds.ToString("F2") + "," + item.AverageFrameMs.ToString("F2") + "," + item.P95FrameMs.ToString("F2") + "," + item.MaxFrameMs.ToString("F2") + "," + (item.PeakGcBytes / 1024f).ToString("F2") + "," + item.ResidualObjectCount + "," + Csv(item.Reason)); File.WriteAllLines(path, lines, new System.Text.UTF8Encoding(true)); EditorUtility.RevealInFinder(path); } private static string GetAbsolutePath(string assetPath) => Path.Combine(Directory.GetParent(Application.dataPath).FullName, assetPath); private static string Csv(string text) => "\"" + (text ?? string.Empty).Replace("\"", "\"\"") + "\""; private static Dictionary CaptureActiveSceneObjects() { Dictionary result = new Dictionary(); foreach (GameObject item in Resources.FindObjectsOfTypeAll()) { if (item == null || !item.activeInHierarchy || !item.scene.IsValid() || !item.scene.isLoaded) continue; result[item.GetInstanceID()] = item.name; } return result; } } /// /// Read-only UI resource reference scanner. It is deliberately kept in this /// already-imported Editor script so the current project imports its menu. /// public class UIPrefabCheck : EditorWindow { private const string DefaultPrefabRoot = "Assets/Resources/Resources_moved/gui/panel"; private const int PageSize = 100; private const long LargeImageFileSizeBytes = 500L * 1024L; private const int LargeImageDimension = 1024; private const int SimilarImageMaxHashDistance = 6; private static readonly string[] UnusedImageRoots = { "Assets/Resources/Resources_moved/gui", "Assets/PSD2UI/UI" }; private class Record { public string PrefabPath; public GameObject Prefab; public string ObjectPath; public string Component; public UnityEngine.Object Asset; public string AssetPath; public string Atlases; public string Warning; } private class SharedRecord { public UnityEngine.Object Asset; public string AssetPath; public string Atlases; public List PrefabPaths; } private class UnusedImageRecord { public Texture2D Asset; public string AssetPath; public string Reason; } private class PrefabNodeRecord { public GameObject Prefab; public string PrefabPath; public int NodeCount; public string Type; public int SuggestedNodeCount; public string Suggestion; public bool NeedsOptimization; } private class LargeImageRecord { public Texture2D Asset; public string AssetPath; public long FileSizeBytes; public int Width; public int Height; public string Reason; } private class ImageFingerprint { public Texture2D Asset; public string AssetPath; public long FileSizeBytes; public int Width; public int Height; public float AspectRatio; public ulong DifferenceHash; } private class SimilarImagePair { public ImageFingerprint Left; public ImageFingerprint Right; public int HashDistance; } private enum ViewTab { 引用明细, 多预制体共用, 疑似无用图片, 节点统计, 大图标记, 相似图, 界面性能 } private readonly List records = new List(); private readonly List filteredRecords = new List(); private readonly List sharedRecords = new List(); private readonly List filteredSharedRecords = new List(); private readonly List unusedImageRecords = new List(); private readonly List filteredUnusedImageRecords = new List(); private readonly List prefabNodeRecords = new List(); private readonly List filteredPrefabNodeRecords = new List(); private readonly List largeImageRecords = new List(); private readonly List filteredLargeImageRecords = new List(); private readonly List similarImagePairs = new List(); private readonly List filteredSimilarImagePairs = new List(); private readonly List atlases = new List(); private readonly Dictionary atlasCache = new Dictionary(); private readonly Dictionary prefabCache = new Dictionary(); private readonly Dictionary prefabLastWriteTimesUtc = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly HashSet prefabDependencies = new HashSet(StringComparer.OrdinalIgnoreCase); private string prefabRoot = DefaultPrefabRoot; private string filter = string.Empty; private Vector2 scroll; private bool warningsOnly; private int prefabCount; private int duplicateGroups; private int skippedSimilarImageCount; private int page; private ViewTab activeTab; private string lastFilter = string.Empty; private bool lastWarningsOnly; [MenuItem("Tools/UI/Prefab 和图片优化扫描工具")] private static void OpenScanner() { UIPrefabCheck window = GetWindow(); window.titleContent = new GUIContent("Prefab 和图片优化扫描工具"); window.minSize = new Vector2(960, 500); } [MenuItem("Tools/UI/界面性能巡检")] private static void OpenPerformanceAudit() { UIPrefabCheck window = GetWindow(); window.titleContent = new GUIContent("界面性能巡检"); window.minSize = new Vector2(960, 500); window.activeTab = ViewTab.界面性能; window.Repaint(); } private void OnGUI() { EditorGUILayout.LabelField("Prefab 和图片优化扫描工具(只读)", EditorStyles.boldLabel); EditorGUILayout.HelpBox("扫描 Image、RawImage、SpriteRenderer;不会修改任何图片、预制体或图集。", MessageType.Info); EditorGUILayout.BeginHorizontal(); prefabRoot = EditorGUILayout.TextField("Prefab 扫描目录", prefabRoot); if (GUILayout.Button("扫描", GUILayout.Width(90))) Scan(); if (GUILayout.Button("导出 CSV", GUILayout.Width(90))) ExportCsv(); EditorGUILayout.EndHorizontal(); EditorGUILayout.LabelField($"Prefab:{prefabCount} 图片引用:{records.Count} 图集:{atlases.Count} 同名跨目录组:{duplicateGroups}"); EditorGUILayout.BeginHorizontal(); string newFilter = EditorGUILayout.TextField("筛选", filter); bool newWarningsOnly = EditorGUILayout.ToggleLeft("仅显示需确认项", warningsOnly, GUILayout.Width(130)); EditorGUILayout.EndHorizontal(); if (newFilter != filter || newWarningsOnly != warningsOnly) { filter = newFilter; warningsOnly = newWarningsOnly; page = 0; } ViewTab newTab = (ViewTab)GUILayout.Toolbar((int)activeTab, new[] { "引用明细", "多预制体共用", "疑似无用图片", "节点统计", "大图标记", "相似图", "界面性能" }); if (newTab != activeTab) { activeTab = newTab; page = 0; scroll = Vector2.zero; } if (activeTab == ViewTab.界面性能) { UIRuntimePerformanceAudit.DrawEmbedded(); } else if (activeTab == ViewTab.引用明细) { DrawReferencePage(); } else { if (activeTab == ViewTab.多预制体共用) DrawSharedPage(); else if (activeTab == ViewTab.疑似无用图片) DrawUnusedImagePage(); else if (activeTab == ViewTab.节点统计) DrawNodeStatisticsPage(); else if (activeTab == ViewTab.大图标记) DrawLargeImagePage(); else DrawSimilarImagePage(); } } private void DrawReferencePage() { RefreshFilteredRecordsIfNeeded(); DrawPager(filteredRecords.Count); EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.Label("Prefab(最近修改优先)", EditorStyles.miniBoldLabel, GUILayout.Width(210)); GUILayout.Label("节点 / 组件", EditorStyles.miniBoldLabel, GUILayout.Width(230)); GUILayout.Label("图片资源", EditorStyles.miniBoldLabel, GUILayout.Width(280)); GUILayout.Label("图集", EditorStyles.miniBoldLabel, GUILayout.Width(140)); GUILayout.Label("需确认", EditorStyles.miniBoldLabel); EditorGUILayout.EndHorizontal(); scroll = EditorGUILayout.BeginScrollView(scroll); int start = page * PageSize; int end = Math.Min(start + PageSize, filteredRecords.Count); string previousPrefabPath = null; for (int i = start; i < end; i++) { Record record = filteredRecords[i]; if (previousPrefabPath != null && previousPrefabPath != record.PrefabPath) { EditorGUILayout.LabelField($"──────────────────── {Path.GetFileNameWithoutExtension(record.PrefabPath)} ────────────────────", EditorStyles.centeredGreyMiniLabel); } EditorGUILayout.BeginHorizontal(); EditorGUILayout.ObjectField(record.Prefab, typeof(GameObject), false, GUILayout.Width(210)); GUILayout.Label(record.ObjectPath + "\n" + record.Component, EditorStyles.miniLabel, GUILayout.Width(230)); EditorGUILayout.ObjectField(record.Asset, typeof(UnityEngine.Object), false, GUILayout.Width(280)); DrawAtlasLink(record.Atlases); GUILayout.Label(record.Warning, EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); previousPrefabPath = record.PrefabPath; } EditorGUILayout.EndScrollView(); } private void DrawSharedPage() { RefreshFilteredSharedRecords(); DrawPager(filteredSharedRecords.Count); EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.Label("图片资源", EditorStyles.miniBoldLabel, GUILayout.Width(300)); GUILayout.Label("图集", EditorStyles.miniBoldLabel, GUILayout.Width(160)); GUILayout.Label("引用 Prefab 数", EditorStyles.miniBoldLabel, GUILayout.Width(100)); GUILayout.Label("引用它的 Prefab", EditorStyles.miniBoldLabel); EditorGUILayout.EndHorizontal(); scroll = EditorGUILayout.BeginScrollView(scroll); int start = page * PageSize; int end = Math.Min(start + PageSize, filteredSharedRecords.Count); for (int i = start; i < end; i++) { SharedRecord record = filteredSharedRecords[i]; EditorGUILayout.BeginHorizontal(); EditorGUILayout.ObjectField(record.Asset, typeof(UnityEngine.Object), false, GUILayout.Width(300)); GUILayout.Label(record.Atlases, EditorStyles.miniLabel, GUILayout.Width(160)); GUILayout.Label(record.PrefabPaths.Count.ToString(), EditorStyles.miniLabel, GUILayout.Width(100)); GUILayout.Label(string.Join("\n", record.PrefabPaths), EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); } private void DrawUnusedImagePage() { EditorGUILayout.HelpBox( "只检查标准 UI 图片目录。结果表示未发现 Prefab 依赖或静态 C# 引用;配置表、字符串拼接、反射和运行时下载资源无法可靠识别,因此请人工确认后再处理。", MessageType.Warning); if (prefabCount == 0) { EditorGUILayout.HelpBox("请先点击“扫描”,建立当前 Prefab 范围的依赖清单。", MessageType.Info); return; } if (GUILayout.Button("扫描疑似无用图片", GUILayout.Height(28))) ScanPotentiallyUnusedImages(); if (unusedImageRecords.Count == 0) { EditorGUILayout.LabelField("尚未扫描,或未发现疑似无用图片。", EditorStyles.miniLabel); return; } RefreshFilteredUnusedImages(); DrawPager(filteredUnusedImageRecords.Count); EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.Label("图片资源", EditorStyles.miniBoldLabel, GUILayout.Width(320)); GUILayout.Label("资源路径", EditorStyles.miniBoldLabel, GUILayout.Width(390)); GUILayout.Label("判定", EditorStyles.miniBoldLabel); EditorGUILayout.EndHorizontal(); scroll = EditorGUILayout.BeginScrollView(scroll); int start = page * PageSize; int end = Math.Min(start + PageSize, filteredUnusedImageRecords.Count); for (int i = start; i < end; i++) { UnusedImageRecord record = filteredUnusedImageRecords[i]; EditorGUILayout.BeginHorizontal(); EditorGUILayout.ObjectField(record.Asset, typeof(Texture2D), false, GUILayout.Width(320)); GUILayout.Label(record.AssetPath, EditorStyles.miniLabel, GUILayout.Width(390)); GUILayout.Label(record.Reason, EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); } private void DrawNodeStatisticsPage() { EditorGUILayout.HelpBox( "节点数包含所有激活和未激活的子节点。类型与建议值是基于常见移动游戏 Prefab 的经验阈值,用于优先定位复杂 Prefab,而非强制限制。", MessageType.Info); if (prefabNodeRecords.Count == 0) { EditorGUILayout.HelpBox("请先点击“扫描”。", MessageType.Info); return; } RefreshFilteredNodeRecords(); DrawPager(filteredPrefabNodeRecords.Count); EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.Label("Prefab", EditorStyles.miniBoldLabel, GUILayout.Width(270)); GUILayout.Label("类型", EditorStyles.miniBoldLabel, GUILayout.Width(110)); GUILayout.Label("节点数", EditorStyles.miniBoldLabel, GUILayout.Width(65)); GUILayout.Label("建议节点数", EditorStyles.miniBoldLabel, GUILayout.Width(90)); GUILayout.Label("建议", EditorStyles.miniBoldLabel); EditorGUILayout.EndHorizontal(); scroll = EditorGUILayout.BeginScrollView(scroll); int start = page * PageSize; int end = Math.Min(start + PageSize, filteredPrefabNodeRecords.Count); for (int i = start; i < end; i++) { PrefabNodeRecord record = filteredPrefabNodeRecords[i]; EditorGUILayout.BeginHorizontal(); EditorGUILayout.ObjectField(record.Prefab, typeof(GameObject), false, GUILayout.Width(270)); GUILayout.Label(record.Type, EditorStyles.miniLabel, GUILayout.Width(110)); GUILayout.Label(record.NodeCount.ToString(), EditorStyles.miniLabel, GUILayout.Width(65)); GUILayout.Label("≤ " + record.SuggestedNodeCount, EditorStyles.miniLabel, GUILayout.Width(90)); GUILayout.Label(record.Suggestion, EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); } private void DrawLargeImagePage() { EditorGUILayout.HelpBox("标记源文件大于 500 KB,或导入后宽和高均不小于 1024 像素的 UI 图片。", MessageType.Info); if (GUILayout.Button("扫描大图", GUILayout.Height(28))) ScanLargeImages(); if (largeImageRecords.Count == 0) { EditorGUILayout.LabelField("尚未扫描,或未发现满足条件的图片。", EditorStyles.miniLabel); return; } RefreshFilteredLargeImages(); DrawPager(filteredLargeImageRecords.Count); EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.Label("图片资源", EditorStyles.miniBoldLabel, GUILayout.Width(300)); GUILayout.Label("源文件大小", EditorStyles.miniBoldLabel, GUILayout.Width(95)); GUILayout.Label("导入分辨率", EditorStyles.miniBoldLabel, GUILayout.Width(100)); GUILayout.Label("标记原因", EditorStyles.miniBoldLabel, GUILayout.Width(190)); GUILayout.Label("资源路径", EditorStyles.miniBoldLabel); EditorGUILayout.EndHorizontal(); scroll = EditorGUILayout.BeginScrollView(scroll); int start = page * PageSize; int end = Math.Min(start + PageSize, filteredLargeImageRecords.Count); for (int i = start; i < end; i++) { LargeImageRecord record = filteredLargeImageRecords[i]; EditorGUILayout.BeginHorizontal(); EditorGUILayout.ObjectField(record.Asset, typeof(Texture2D), false, GUILayout.Width(300)); GUILayout.Label(FormatFileSize(record.FileSizeBytes), EditorStyles.miniLabel, GUILayout.Width(95)); GUILayout.Label(record.Width + " × " + record.Height, EditorStyles.miniLabel, GUILayout.Width(100)); GUILayout.Label(record.Reason, EditorStyles.miniLabel, GUILayout.Width(190)); GUILayout.Label(record.AssetPath, EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); } private void DrawSimilarImagePage() { EditorGUILayout.HelpBox( "仅比较 PNG/JPG/JPEG 图片。工具会先去除透明边缘、统一缩放,再计算感知差异哈希;距离越小,视觉内容越接近。结果仅供人工确认。", MessageType.Info); if (GUILayout.Button("扫描相似图", GUILayout.Height(28))) ScanSimilarImages(); EditorGUILayout.LabelField($"相似候选对:{similarImagePairs.Count} 跳过不支持格式或无法读取的图片:{skippedSimilarImageCount}", EditorStyles.miniLabel); if (similarImagePairs.Count == 0) { EditorGUILayout.LabelField("尚未扫描,或未发现相似候选。", EditorStyles.miniLabel); return; } RefreshFilteredSimilarImagePairs(); DrawPager(filteredSimilarImagePairs.Count); EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.Label("图片 A", EditorStyles.miniBoldLabel, GUILayout.Width(180)); GUILayout.Label("A 大小 / 尺寸", EditorStyles.miniBoldLabel, GUILayout.Width(125)); GUILayout.Label("图片 B", EditorStyles.miniBoldLabel, GUILayout.Width(180)); GUILayout.Label("B 大小 / 尺寸", EditorStyles.miniBoldLabel, GUILayout.Width(125)); GUILayout.Label("相似度", EditorStyles.miniBoldLabel, GUILayout.Width(60)); GUILayout.Label("距离", EditorStyles.miniBoldLabel, GUILayout.Width(45)); GUILayout.Label("路径", EditorStyles.miniBoldLabel); EditorGUILayout.EndHorizontal(); scroll = EditorGUILayout.BeginScrollView(scroll); int start = page * PageSize; int end = Math.Min(start + PageSize, filteredSimilarImagePairs.Count); for (int i = start; i < end; i++) { SimilarImagePair pair = filteredSimilarImagePairs[i]; EditorGUILayout.BeginHorizontal(); EditorGUILayout.ObjectField(pair.Left.Asset, typeof(Texture2D), false, GUILayout.Width(180)); GUILayout.Label(FormatFileSize(pair.Left.FileSizeBytes) + "\n" + pair.Left.Width + " × " + pair.Left.Height, EditorStyles.miniLabel, GUILayout.Width(125)); EditorGUILayout.ObjectField(pair.Right.Asset, typeof(Texture2D), false, GUILayout.Width(180)); GUILayout.Label(FormatFileSize(pair.Right.FileSizeBytes) + "\n" + pair.Right.Width + " × " + pair.Right.Height, EditorStyles.miniLabel, GUILayout.Width(125)); GUILayout.Label(GetSimilarityPercentage(pair.HashDistance), EditorStyles.miniLabel, GUILayout.Width(60)); GUILayout.Label(pair.HashDistance.ToString(), EditorStyles.miniLabel, GUILayout.Width(45)); GUILayout.Label(pair.Left.AssetPath + "\n" + pair.Right.AssetPath, EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); } private void Scan() { if (!AssetDatabase.IsValidFolder(prefabRoot)) { EditorUtility.DisplayDialog("扫描失败", "请输入 Assets 下有效的 Prefab 目录。", "确定"); return; } records.Clear(); filteredRecords.Clear(); sharedRecords.Clear(); filteredSharedRecords.Clear(); unusedImageRecords.Clear(); filteredUnusedImageRecords.Clear(); prefabNodeRecords.Clear(); filteredPrefabNodeRecords.Clear(); largeImageRecords.Clear(); filteredLargeImageRecords.Clear(); similarImagePairs.Clear(); filteredSimilarImagePairs.Clear(); atlasCache.Clear(); prefabCache.Clear(); prefabLastWriteTimesUtc.Clear(); prefabDependencies.Clear(); atlases.Clear(); duplicateGroups = 0; skippedSimilarImageCount = 0; page = 0; foreach (string guid in AssetDatabase.FindAssets("t:SpriteAtlas")) { SpriteAtlas atlas = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); if (atlas != null) atlases.Add(atlas); } string[] prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { prefabRoot }); prefabCount = prefabGuids.Length; try { for (int i = 0; i < prefabGuids.Length; i++) { string path = AssetDatabase.GUIDToAssetPath(prefabGuids[i]); EditorUtility.DisplayProgressBar("扫描 UI 图片引用", path, prefabGuids.Length == 0 ? 1f : (float)i / prefabGuids.Length); ScanPrefab(path); foreach (string dependency in AssetDatabase.GetDependencies(path, true)) prefabDependencies.Add(dependency); } } finally { EditorUtility.ClearProgressBar(); } MarkDuplicateNames(); SortReferenceRecords(); BuildSharedRecords(); RefreshFilteredRecords(); Repaint(); } private void ScanPrefab(string prefabPath) { GameObject root = PrefabUtility.LoadPrefabContents(prefabPath); try { prefabNodeRecords.Add(BuildNodeRecord(prefabPath, root)); foreach (Image item in root.GetComponentsInChildren(true)) Add(prefabPath, item.transform, "Image.sprite", item.sprite); foreach (RawImage item in root.GetComponentsInChildren(true)) Add(prefabPath, item.transform, "RawImage.texture", item.texture); foreach (SpriteRenderer item in root.GetComponentsInChildren(true)) Add(prefabPath, item.transform, "SpriteRenderer.sprite", item.sprite); } finally { PrefabUtility.UnloadPrefabContents(root); } } private PrefabNodeRecord BuildNodeRecord(string prefabPath, GameObject root) { string type = GetPrefabType(root, prefabPath); int suggestedCount = GetSuggestedNodeCount(type); int nodeCount = root.GetComponentsInChildren(true).Length; bool needsOptimization = nodeCount > suggestedCount; string suggestion = !needsOptimization ? "节点数在建议范围内" : nodeCount <= suggestedCount * 1.5f ? "建议关注:可合并静态节点或复用组件" : "建议拆分:检查层级、重复节点与可复用组件"; return new PrefabNodeRecord { Prefab = GetPrefab(prefabPath), PrefabPath = prefabPath, NodeCount = nodeCount, Type = type, SuggestedNodeCount = suggestedCount, Suggestion = suggestion, NeedsOptimization = needsOptimization }; } private static string GetPrefabType(GameObject root, string prefabPath) { bool hasUi = root.transform is RectTransform || root.GetComponentInChildren(true) != null; if (hasUi) { bool isPanel = prefabPath.IndexOf("/panel/", StringComparison.OrdinalIgnoreCase) >= 0 || root.name.IndexOf("Panel", StringComparison.OrdinalIgnoreCase) >= 0; return isPanel ? "UI 面板" : "UI 组件"; } if (root.GetComponentInChildren(true) != null) return "特效"; if (root.GetComponentInChildren(true) != null || root.GetComponentInChildren(true) != null) return "角色/动态对象"; return "通用对象"; } private static int GetSuggestedNodeCount(string type) { switch (type) { case "UI 面板": return 150; case "UI 组件": return 50; case "特效": return 80; case "角色/动态对象": return 100; default: return 80; } } private void Add(string prefabPath, Transform transform, string component, UnityEngine.Object asset) { if (asset == null) return; string assetPath = AssetDatabase.GetAssetPath(asset); if (string.IsNullOrEmpty(assetPath)) return; records.Add(new Record { PrefabPath = prefabPath, Prefab = GetPrefab(prefabPath), ObjectPath = TransformPath(transform), Component = component, Asset = asset, AssetPath = assetPath, Atlases = FindAtlases(asset, assetPath), Warning = IsStandardUiPath(assetPath) ? string.Empty : "非标准 UI 资源目录" }); } private string FindAtlases(UnityEngine.Object asset, string assetPath) { Sprite sprite = asset as Sprite; if (sprite == null) return "—"; if (atlasCache.TryGetValue(assetPath, out string result)) return result; List names = new List(); foreach (SpriteAtlas atlas in atlases) if (atlas.CanBindTo(sprite)) names.Add(atlas.name); result = names.Count == 0 ? "未加入图集" : string.Join("; ", names); atlasCache.Add(assetPath, result); return result; } private void DrawAtlasLink(string atlasNames) { if (atlasNames == "—" || atlasNames == "未加入图集") { GUILayout.Label(atlasNames, EditorStyles.miniLabel, GUILayout.Width(140)); return; } if (!GUILayout.Button(new GUIContent(atlasNames, "点击定位图集"), EditorStyles.miniButton, GUILayout.Width(140))) return; string[] names = atlasNames.Split(new[] { "; " }, StringSplitOptions.RemoveEmptyEntries); List linkedAtlases = atlases.Where(atlas => names.Any(name => string.Equals(name, atlas.name, StringComparison.OrdinalIgnoreCase))).ToList(); if (linkedAtlases.Count == 0) return; if (linkedAtlases.Count == 1) { SelectAndPingAtlas(linkedAtlases[0]); return; } GenericMenu menu = new GenericMenu(); foreach (SpriteAtlas atlas in linkedAtlases) { SpriteAtlas target = atlas; menu.AddItem(new GUIContent(AssetDatabase.GetAssetPath(target)), false, () => SelectAndPingAtlas(target)); } menu.ShowAsContext(); } private static void SelectAndPingAtlas(SpriteAtlas atlas) { Selection.activeObject = atlas; EditorGUIUtility.PingObject(atlas); } private void MarkDuplicateNames() { foreach (IGrouping group in records.GroupBy(x => Path.GetFileName(x.AssetPath), StringComparer.OrdinalIgnoreCase)) { if (group.Select(x => x.AssetPath).Distinct(StringComparer.OrdinalIgnoreCase).Count() <= 1) continue; duplicateGroups++; foreach (Record record in group) record.Warning = string.IsNullOrEmpty(record.Warning) ? "同名跨目录" : record.Warning + "; 同名跨目录"; } } private void SortReferenceRecords() { records.Sort((left, right) => { int modifiedTimeComparison = GetPrefabLastWriteTimeUtc(right.PrefabPath).CompareTo(GetPrefabLastWriteTimeUtc(left.PrefabPath)); if (modifiedTimeComparison != 0) return modifiedTimeComparison; int prefabComparison = string.Compare(left.PrefabPath, right.PrefabPath, StringComparison.OrdinalIgnoreCase); if (prefabComparison != 0) return prefabComparison; int atlasComparison = string.Compare(AtlasSortKey(left.Atlases), AtlasSortKey(right.Atlases), StringComparison.OrdinalIgnoreCase); if (atlasComparison != 0) return atlasComparison; int assetComparison = string.Compare(left.AssetPath, right.AssetPath, StringComparison.OrdinalIgnoreCase); if (assetComparison != 0) return assetComparison; return string.Compare(left.ObjectPath, right.ObjectPath, StringComparison.OrdinalIgnoreCase); }); } private DateTime GetPrefabLastWriteTimeUtc(string prefabPath) { if (prefabLastWriteTimesUtc.TryGetValue(prefabPath, out DateTime lastWriteTimeUtc)) return lastWriteTimeUtc; string absolutePath = GetAbsoluteAssetPath(prefabPath); lastWriteTimeUtc = File.Exists(absolutePath) ? File.GetLastWriteTimeUtc(absolutePath) : DateTime.MinValue; prefabLastWriteTimesUtc.Add(prefabPath, lastWriteTimeUtc); return lastWriteTimeUtc; } private static string AtlasSortKey(string atlasName) { return atlasName == "未加入图集" ? "~" : atlasName; } private bool Matches(Record record) { if (warningsOnly && string.IsNullOrEmpty(record.Warning)) return false; if (string.IsNullOrEmpty(filter)) return true; string text = record.PrefabPath + record.ObjectPath + record.Component + record.AssetPath + record.Atlases + record.Warning; return text.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0; } private GameObject GetPrefab(string prefabPath) { if (!prefabCache.TryGetValue(prefabPath, out GameObject prefab)) { prefab = AssetDatabase.LoadAssetAtPath(prefabPath); prefabCache.Add(prefabPath, prefab); } return prefab; } private void RefreshFilteredRecordsIfNeeded() { if (lastFilter == filter && lastWarningsOnly == warningsOnly) return; RefreshFilteredRecords(); } private void RefreshFilteredRecords() { filteredRecords.Clear(); foreach (Record record in records) if (Matches(record)) filteredRecords.Add(record); lastFilter = filter; lastWarningsOnly = warningsOnly; int maxPage = Math.Max(0, (filteredRecords.Count - 1) / PageSize); page = Math.Min(page, maxPage); } private void BuildSharedRecords() { sharedRecords.Clear(); foreach (IGrouping group in records.GroupBy(x => x.AssetPath, StringComparer.OrdinalIgnoreCase)) { List prefabPaths = group.Select(x => x.PrefabPath).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(); if (prefabPaths.Count < 2) continue; Record first = group.First(); sharedRecords.Add(new SharedRecord { Asset = first.Asset, AssetPath = first.AssetPath, Atlases = first.Atlases, PrefabPaths = prefabPaths }); } sharedRecords.Sort((left, right) => right.PrefabPaths.Count.CompareTo(left.PrefabPaths.Count)); } private void RefreshFilteredSharedRecords() { filteredSharedRecords.Clear(); foreach (SharedRecord record in sharedRecords) { string text = record.AssetPath + record.Atlases + string.Join(" ", record.PrefabPaths); if (string.IsNullOrEmpty(filter) || text.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0) filteredSharedRecords.Add(record); } int maxPage = Math.Max(0, (filteredSharedRecords.Count - 1) / PageSize); page = Math.Min(page, maxPage); } private void ScanPotentiallyUnusedImages() { unusedImageRecords.Clear(); filteredUnusedImageRecords.Clear(); page = 0; string codeText = ReadAllProjectCode(); string[] imageGuids = AssetDatabase.FindAssets("t:Texture2D", UnusedImageRoots); try { for (int i = 0; i < imageGuids.Length; i++) { string assetPath = AssetDatabase.GUIDToAssetPath(imageGuids[i]); EditorUtility.DisplayProgressBar("扫描疑似无用图片", assetPath, imageGuids.Length == 0 ? 1f : (float)i / imageGuids.Length); if (prefabDependencies.Contains(assetPath) || HasStaticCodeReference(assetPath, imageGuids[i], codeText)) continue; Texture2D image = AssetDatabase.LoadAssetAtPath(assetPath); if (image != null) unusedImageRecords.Add(new UnusedImageRecord { Asset = image, AssetPath = assetPath, Reason = "未发现 Prefab 依赖或静态代码引用" }); } } finally { EditorUtility.ClearProgressBar(); } unusedImageRecords.Sort((left, right) => string.Compare(left.AssetPath, right.AssetPath, StringComparison.OrdinalIgnoreCase)); RefreshFilteredUnusedImages(); Repaint(); } private static string ReadAllProjectCode() { List code = new List(); foreach (string file in Directory.GetFiles(Application.dataPath, "*.cs", SearchOption.AllDirectories)) { try { code.Add(File.ReadAllText(file)); } catch (IOException) { } catch (UnauthorizedAccessException) { } } return string.Join("\n", code); } private static bool HasStaticCodeReference(string assetPath, string guid, string codeText) { if (codeText.IndexOf(guid, StringComparison.OrdinalIgnoreCase) >= 0) return true; if (codeText.IndexOf(assetPath, StringComparison.OrdinalIgnoreCase) >= 0) return true; const string ResourcesMarker = "/Resources/"; int markerIndex = assetPath.IndexOf(ResourcesMarker, StringComparison.OrdinalIgnoreCase); if (markerIndex < 0) return false; string resourcePath = assetPath.Substring(markerIndex + ResourcesMarker.Length); resourcePath = Path.ChangeExtension(resourcePath, null).Replace('\\', '/'); return codeText.IndexOf(resourcePath, StringComparison.OrdinalIgnoreCase) >= 0; } private void RefreshFilteredUnusedImages() { filteredUnusedImageRecords.Clear(); foreach (UnusedImageRecord record in unusedImageRecords) { if (string.IsNullOrEmpty(filter) || record.AssetPath.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0) filteredUnusedImageRecords.Add(record); } int maxPage = Math.Max(0, (filteredUnusedImageRecords.Count - 1) / PageSize); page = Math.Min(page, maxPage); } private void RefreshFilteredNodeRecords() { filteredPrefabNodeRecords.Clear(); foreach (PrefabNodeRecord record in prefabNodeRecords) { if (warningsOnly && !record.NeedsOptimization) continue; string text = record.PrefabPath + record.Type + record.Suggestion; if (string.IsNullOrEmpty(filter) || text.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0) filteredPrefabNodeRecords.Add(record); } filteredPrefabNodeRecords.Sort((left, right) => right.NodeCount.CompareTo(left.NodeCount)); int maxPage = Math.Max(0, (filteredPrefabNodeRecords.Count - 1) / PageSize); page = Math.Min(page, maxPage); } private void ScanLargeImages() { largeImageRecords.Clear(); filteredLargeImageRecords.Clear(); page = 0; string[] imageGuids = AssetDatabase.FindAssets("t:Texture2D", UnusedImageRoots); try { for (int i = 0; i < imageGuids.Length; i++) { string assetPath = AssetDatabase.GUIDToAssetPath(imageGuids[i]); EditorUtility.DisplayProgressBar("扫描大图", assetPath, imageGuids.Length == 0 ? 1f : (float)i / imageGuids.Length); Texture2D image = AssetDatabase.LoadAssetAtPath(assetPath); if (image == null) continue; long fileSize = GetAssetFileSize(assetPath); bool isLargeFile = fileSize > LargeImageFileSizeBytes; bool isLargeDimension = image.width >= LargeImageDimension && image.height >= LargeImageDimension; if (!isLargeFile && !isLargeDimension) continue; List reasons = new List(); if (isLargeFile) reasons.Add("源文件 > 500 KB"); if (isLargeDimension) reasons.Add("分辨率 ≥ 1024 × 1024"); largeImageRecords.Add(new LargeImageRecord { Asset = image, AssetPath = assetPath, FileSizeBytes = fileSize, Width = image.width, Height = image.height, Reason = string.Join(";", reasons) }); } } finally { EditorUtility.ClearProgressBar(); } largeImageRecords.Sort((left, right) => right.FileSizeBytes.CompareTo(left.FileSizeBytes)); RefreshFilteredLargeImages(); Repaint(); } private static long GetAssetFileSize(string assetPath) { string absolutePath = GetAbsoluteAssetPath(assetPath); return File.Exists(absolutePath) ? new FileInfo(absolutePath).Length : 0; } private static string GetAbsoluteAssetPath(string assetPath) { string projectRoot = Directory.GetParent(Application.dataPath).FullName; return Path.Combine(projectRoot, assetPath); } private void RefreshFilteredLargeImages() { filteredLargeImageRecords.Clear(); foreach (LargeImageRecord record in largeImageRecords) { if (string.IsNullOrEmpty(filter) || record.AssetPath.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0) filteredLargeImageRecords.Add(record); } int maxPage = Math.Max(0, (filteredLargeImageRecords.Count - 1) / PageSize); page = Math.Min(page, maxPage); } private static string FormatFileSize(long sizeInBytes) { return (sizeInBytes / 1024f).ToString("F1") + " KB"; } private void ScanSimilarImages() { similarImagePairs.Clear(); filteredSimilarImagePairs.Clear(); skippedSimilarImageCount = 0; page = 0; List fingerprints = new List(); string[] imageGuids = AssetDatabase.FindAssets("t:Texture2D", UnusedImageRoots); try { for (int i = 0; i < imageGuids.Length; i++) { string assetPath = AssetDatabase.GUIDToAssetPath(imageGuids[i]); EditorUtility.DisplayProgressBar("生成图片感知指纹", assetPath, imageGuids.Length == 0 ? 1f : (float)i / imageGuids.Length); if (TryCreateFingerprint(assetPath, out ImageFingerprint fingerprint)) fingerprints.Add(fingerprint); else skippedSimilarImageCount++; } FindSimilarImagePairs(fingerprints); } finally { EditorUtility.ClearProgressBar(); } similarImagePairs.Sort((left, right) => { long leftMaxSize = Math.Max(left.Left.FileSizeBytes, left.Right.FileSizeBytes); long rightMaxSize = Math.Max(right.Left.FileSizeBytes, right.Right.FileSizeBytes); int sizeComparison = rightMaxSize.CompareTo(leftMaxSize); if (sizeComparison != 0) return sizeComparison; long leftMaxPixels = Math.Max((long)left.Left.Width * left.Left.Height, (long)left.Right.Width * left.Right.Height); long rightMaxPixels = Math.Max((long)right.Left.Width * right.Left.Height, (long)right.Right.Width * right.Right.Height); int dimensionComparison = rightMaxPixels.CompareTo(leftMaxPixels); return dimensionComparison != 0 ? dimensionComparison : left.HashDistance.CompareTo(right.HashDistance); }); RefreshFilteredSimilarImagePairs(); Repaint(); } private bool TryCreateFingerprint(string assetPath, out ImageFingerprint fingerprint) { fingerprint = null; string extension = Path.GetExtension(assetPath).ToLowerInvariant(); if (extension != ".png" && extension != ".jpg" && extension != ".jpeg") return false; string absolutePath = GetAbsoluteAssetPath(assetPath); if (!File.Exists(absolutePath)) return false; Texture2D decoded = new Texture2D(2, 2, TextureFormat.RGBA32, false); try { if (!ImageConversion.LoadImage(decoded, File.ReadAllBytes(absolutePath), false)) return false; if (!TryGetDifferenceHash(decoded, out ulong differenceHash)) return false; Texture2D asset = AssetDatabase.LoadAssetAtPath(assetPath); if (asset == null) return false; fingerprint = new ImageFingerprint { Asset = asset, AssetPath = assetPath, FileSizeBytes = GetAssetFileSize(assetPath), Width = decoded.width, Height = decoded.height, AspectRatio = decoded.height == 0 ? 0f : (float)decoded.width / decoded.height, DifferenceHash = differenceHash }; return true; } finally { Object.DestroyImmediate(decoded); } } private static bool TryGetDifferenceHash(Texture2D image, out ulong hash) { hash = 0; Color32[] pixels = image.GetPixels32(); int minX = image.width, minY = image.height, maxX = -1, maxY = -1; for (int y = 0; y < image.height; y++) { for (int x = 0; x < image.width; x++) { if (pixels[y * image.width + x].a <= 10) continue; minX = Math.Min(minX, x); minY = Math.Min(minY, y); maxX = Math.Max(maxX, x); maxY = Math.Max(maxY, y); } } if (maxX < minX || maxY < minY) return false; int cropWidth = maxX - minX + 1; int cropHeight = maxY - minY + 1; byte[,] luminance = new byte[8, 9]; for (int y = 0; y < 8; y++) { int sourceY = minY + Mathf.RoundToInt((cropHeight - 1) * y / 7f); for (int x = 0; x < 9; x++) { int sourceX = minX + Mathf.RoundToInt((cropWidth - 1) * x / 8f); Color32 color = pixels[sourceY * image.width + sourceX]; luminance[y, x] = (byte)((77 * color.r + 150 * color.g + 29 * color.b) / 256); } } int bitIndex = 0; for (int y = 0; y < 8; y++) { for (int x = 0; x < 8; x++) { if (luminance[y, x] > luminance[y, x + 1]) hash |= 1UL << bitIndex; bitIndex++; } } return true; } private void FindSimilarImagePairs(List fingerprints) { Dictionary> buckets = new Dictionary>(); HashSet comparedPairs = new HashSet(); for (int i = 0; i < fingerprints.Count; i++) { ImageFingerprint left = fingerprints[i]; for (int chunk = 0; chunk < 8; chunk++) { int bucketKey = (chunk << 8) | (int)((left.DifferenceHash >> (chunk * 8)) & 0xFF); if (buckets.TryGetValue(bucketKey, out List candidates)) { foreach (int candidateIndex in candidates) { ulong pairKey = ((ulong)(uint)candidateIndex << 32) | (uint)i; if (!comparedPairs.Add(pairKey)) continue; ImageFingerprint right = fingerprints[candidateIndex]; if (Mathf.Abs(left.AspectRatio - right.AspectRatio) > 0.05f) continue; int distance = GetBitDistance(left.DifferenceHash, right.DifferenceHash); if (distance <= SimilarImageMaxHashDistance) similarImagePairs.Add(new SimilarImagePair { Left = left, Right = right, HashDistance = distance }); } } else { candidates = new List(); buckets.Add(bucketKey, candidates); } candidates.Add(i); } } } private static int GetBitDistance(ulong left, ulong right) { ulong value = left ^ right; int count = 0; while (value != 0) { value &= value - 1; count++; } return count; } private static string GetSimilarityPercentage(int hashDistance) { return ((64 - hashDistance) * 100f / 64f).ToString("F0") + "%"; } private void RefreshFilteredSimilarImagePairs() { filteredSimilarImagePairs.Clear(); foreach (SimilarImagePair pair in similarImagePairs) { string text = pair.Left.AssetPath + pair.Right.AssetPath; if (string.IsNullOrEmpty(filter) || text.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0) filteredSimilarImagePairs.Add(pair); } int maxPage = Math.Max(0, (filteredSimilarImagePairs.Count - 1) / PageSize); page = Math.Min(page, maxPage); } private void DrawPager(int resultCount) { int pageCount = Math.Max(1, (resultCount + PageSize - 1) / PageSize); EditorGUILayout.BeginHorizontal(); using (new EditorGUI.DisabledScope(page == 0)) { if (GUILayout.Button("上一页", GUILayout.Width(70))) { page--; scroll = Vector2.zero; } } EditorGUILayout.LabelField($"第 {page + 1} / {pageCount} 页(每页 {PageSize} 条,筛选结果 {resultCount} 条)", GUILayout.Width(310)); using (new EditorGUI.DisabledScope(page >= pageCount - 1)) { if (GUILayout.Button("下一页", GUILayout.Width(70))) { page++; scroll = Vector2.zero; } } EditorGUILayout.EndHorizontal(); } private void ExportCsv() { if (records.Count == 0) { EditorUtility.DisplayDialog("没有数据", "请先执行扫描。", "确定"); return; } string path = EditorUtility.SaveFilePanel("导出 UI 图片引用报告", Application.dataPath, "UI图片引用报告", "csv"); if (string.IsNullOrEmpty(path)) return; List lines = new List { "Prefab,节点,组件,图片路径,图集,需确认" }; foreach (Record record in records) lines.Add(Csv(record.PrefabPath) + "," + Csv(record.ObjectPath) + "," + Csv(record.Component) + "," + Csv(record.AssetPath) + "," + Csv(record.Atlases) + "," + Csv(record.Warning)); File.WriteAllLines(path, lines, new System.Text.UTF8Encoding(true)); EditorUtility.RevealInFinder(path); } private static string Csv(string text) { return "\"" + (text ?? string.Empty).Replace("\"", "\"\"") + "\""; } private static bool IsStandardUiPath(string path) { return path.StartsWith("Assets/Resources/Resources_moved/gui/", StringComparison.OrdinalIgnoreCase) || path.StartsWith("Assets/PSD2UI/UI/", StringComparison.OrdinalIgnoreCase); } private static string TransformPath(Transform transform) { List names = new List(); while (transform != null) { names.Add(transform.name); transform = transform.parent; } names.Reverse(); return string.Join("/", names); } }