Files
2026-09-10 17:29:24 +08:00

1434 lines
70 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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.
/// </summary>
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<float> FrameSamples = new List<float>();
}
private static readonly List<PanelEntry> Entries = new List<PanelEntry>();
private static readonly List<PanelResult> Results = new List<PanelResult>();
private static readonly List<float> FrameSamples = new List<float>();
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<int, string> baselineActiveObjects = new Dictionary<int, string>();
private static readonly List<ManualSession> ManualSessions = new List<ManualSession>();
private static readonly Dictionary<string, ManualSession> ActiveManualSessions = new Dictionary<string, ManualSession>();
private static readonly Dictionary<string, string> ManualPanelNameLookup = new Dictionary<string, string>(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<(?<type>[A-Za-z0-9_]+)>");
HashSet<string> seen = new HashSet<string>(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*""(?<name>[^""]+)""");
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*""(?<path>[^""]+)""");
if (pathMatch.Success)
{
prefabPath = "Assets/Resources/Resources_moved/" + pathMatch.Groups["path"].Value;
prefab = AssetDatabase.LoadAssetAtPath<GameObject>(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<string> 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<string> GetVisibleRegisteredPanels()
{
HashSet<string> visible = new HashSet<string>(StringComparer.Ordinal);
foreach (GameObject item in Resources.FindObjectsOfTypeAll<GameObject>())
{
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<float> 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<int, string> 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<string> lines = new List<string> { "界面,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<int, string> CaptureActiveSceneObjects()
{
Dictionary<int, string> result = new Dictionary<int, string>();
foreach (GameObject item in Resources.FindObjectsOfTypeAll<GameObject>())
{
if (item == null || !item.activeInHierarchy || !item.scene.IsValid() || !item.scene.isLoaded) continue;
result[item.GetInstanceID()] = item.name;
}
return result;
}
}
/// <summary>
/// Read-only UI resource reference scanner. It is deliberately kept in this
/// already-imported Editor script so the current project imports its menu.
/// </summary>
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<string> 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<Record> records = new List<Record>();
private readonly List<Record> filteredRecords = new List<Record>();
private readonly List<SharedRecord> sharedRecords = new List<SharedRecord>();
private readonly List<SharedRecord> filteredSharedRecords = new List<SharedRecord>();
private readonly List<UnusedImageRecord> unusedImageRecords = new List<UnusedImageRecord>();
private readonly List<UnusedImageRecord> filteredUnusedImageRecords = new List<UnusedImageRecord>();
private readonly List<PrefabNodeRecord> prefabNodeRecords = new List<PrefabNodeRecord>();
private readonly List<PrefabNodeRecord> filteredPrefabNodeRecords = new List<PrefabNodeRecord>();
private readonly List<LargeImageRecord> largeImageRecords = new List<LargeImageRecord>();
private readonly List<LargeImageRecord> filteredLargeImageRecords = new List<LargeImageRecord>();
private readonly List<SimilarImagePair> similarImagePairs = new List<SimilarImagePair>();
private readonly List<SimilarImagePair> filteredSimilarImagePairs = new List<SimilarImagePair>();
private readonly List<SpriteAtlas> atlases = new List<SpriteAtlas>();
private readonly Dictionary<string, string> atlasCache = new Dictionary<string, string>();
private readonly Dictionary<string, GameObject> prefabCache = new Dictionary<string, GameObject>();
private readonly Dictionary<string, DateTime> prefabLastWriteTimesUtc = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> prefabDependencies = new HashSet<string>(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<UIPrefabCheck>();
window.titleContent = new GUIContent("Prefab 和图片优化扫描工具");
window.minSize = new Vector2(960, 500);
}
[MenuItem("Tools/UI/界面性能巡检")]
private static void OpenPerformanceAudit()
{
UIPrefabCheck window = GetWindow<UIPrefabCheck>();
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<SpriteAtlas>(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<Image>(true)) Add(prefabPath, item.transform, "Image.sprite", item.sprite);
foreach (RawImage item in root.GetComponentsInChildren<RawImage>(true)) Add(prefabPath, item.transform, "RawImage.texture", item.texture);
foreach (SpriteRenderer item in root.GetComponentsInChildren<SpriteRenderer>(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<Transform>(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<Graphic>(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<ParticleSystem>(true) != null) return "特效";
if (root.GetComponentInChildren<Animator>(true) != null || root.GetComponentInChildren<Renderer>(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<string> names = new List<string>();
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<SpriteAtlas> 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<string, Record> 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<GameObject>(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<string, Record> group in records.GroupBy(x => x.AssetPath, StringComparer.OrdinalIgnoreCase))
{
List<string> 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<Texture2D>(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<string> code = new List<string>();
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<Texture2D>(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<string> reasons = new List<string>();
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<ImageFingerprint> fingerprints = new List<ImageFingerprint>();
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<Texture2D>(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<ImageFingerprint> fingerprints)
{
Dictionary<int, List<int>> buckets = new Dictionary<int, List<int>>();
HashSet<ulong> comparedPairs = new HashSet<ulong>();
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<int> 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<int>();
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<string> lines = new List<string> { "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<string> names = new List<string>(); while (transform != null) { names.Add(transform.name); transform = transform.parent; } names.Reverse(); return string.Join("/", names); }
}