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); }
}