1434 lines
70 KiB
C#
1434 lines
70 KiB
C#
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); }
|
||
}
|