Files
5.24BuYu/Assets/MyGame/Scripts/Debug/AABundleMonitor.cs
T

513 lines
20 KiB
C#
Raw Normal View History

2026-10-08 17:04:24 +08:00
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Profiling;
/// <summary>
/// Addressables / AssetBundle 已加载包实时监控悬浮窗(调试插件)。
///
/// 【用途】
/// 实时查看当前进程里加载了哪些 AA 包(bundle)、数量与内存概况,
/// 用于定位"进房加载资源时内存暴涨 / 被 iOS jetsam 杀掉"这类问题。
/// 注意:本项目所有 Addressables 组都是 LZMA 压缩,包一旦被打开就会整体解压进内存
/// (实测 fish_hhwk 解压 487MB、fish_flzy 436MB、fish_xjmh 219MB),
/// 所以看到哪个大包出现时,概览里的内存数字会立刻抬升,可直接对上。
///
/// 【使用方式】
/// 1. 编辑器 Play / Development Build:自动出现在左上角,无需任何操作;
/// 2. 其他构建(如 Release 真机包):加 AA_MONITOR 宏,或把本组件手动挂到任意常驻物体上。
///
/// 【操作】
/// - 左上角小标签:可拖动;轻点 = 展开 / 收起面板(编辑器下 F9 也可切换)
/// - 面板:上一页 / 下一页翻页;"导出日志"把完整列表 + 内存打入 Player Log(真机看 Xcode / idevice 日志)
/// - 每次有新包加载 / 卸载时自动打日志(logOnChange,默认开)
///
/// 【注意】
/// 项目 Active Input Handling = Input System(New),IMGUI 收不到点击事件,
/// 所以本窗口的交互(拖动 / 轻点 / 翻页)全部用新输入系统自行采样实现,不依赖 GUILayout 按钮;
/// 也因此悬浮层上的点击无法从游戏输入里"吃掉",测试时的小误触属正常现象。
/// </summary>
public class AABundleMonitor : MonoBehaviour
{
#region 配置
/// <summary>新包加载 / 卸载时自动输出日志(真机排查用,建议保持开启)</summary>
public static bool logOnChange = true;
/// <summary>刷新间隔(秒):0.5 秒扫一次已加载包列表,避免每帧开销</summary>
private const float RefreshInterval = 0.5f;
/// <summary>新增包高亮保持时长(秒)</summary>
private const float HighlightDuration = 5f;
#endregion
#region 自动安装
#if UNITY_EDITOR || DEVELOPMENT_BUILD || AA_MONITOR
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void AutoInstall()
{
if (FindFirstObjectByType<AABundleMonitor>() != null) return;
var go = new GameObject("[AABundleMonitor]");
DontDestroyOnLoad(go);
go.AddComponent<AABundleMonitor>();
}
#endif
#endregion
#region 数据
/// <summary>单个已加载包的记录</summary>
private sealed class BundleRecord
{
public string name; // 完整 bundle 名(导出日志用)
public string shortName; // 截断后的显示名
public float firstSeen; // 首次出现时间(realtimeSinceStartup)
public bool streamedScene; // 是否是场景包
public string tag; // 内容标识(扫描包内资源路径得出,如 XJMH/HHWK/FLZY/LOGIN)
public int assetCount; // 包内资源数量(GetAllAssetNames 只读目录,不会加载资源)
public List<string> samplePaths; // 样例资源路径(导出日志用,帮助辨认包内容)
}
private readonly Dictionary<string, BundleRecord> _records = new Dictionary<string, BundleRecord>();
private readonly List<BundleRecord> _sorted = new List<BundleRecord>(); // 按加载时间倒序(最新在前)
private readonly HashSet<string> _loadedNow = new HashSet<string>(); // 本次扫描到的包名
private readonly List<string> _removed = new List<string>(); // 本次扫描发现已卸载的包名
private float _appStartTime;
private float _nextRefreshTime;
// 悬浮标签(可拖动)
private Vector2 _ballPos = new Vector2(16f, 16f);
private Rect _ballRect;
private bool _ballDragging;
private Vector2 _ballDragOffset;
private bool _ballMoved;
// 面板
private bool _panelOpen = true;
private int _page;
private int _rowsPerPage = 12;
// 上一帧 OnGUI 画出的按钮区域(供自采样点击命中,IMGUI 事件在本项目不可用)
private Rect _prevPageRect, _nextPageRect, _exportRect;
// 样式缓存
private GUIStyle _styleTitle, _styleLine, _styleOld, _styleNew, _styleButton, _styleWrap;
// 编辑器 PlayMode Script 名称(仅编辑器下有效,反射读取,失败为空)
private string _editorPlayModeName;
#endregion
#region 生命周期
private void Awake()
{
_appStartTime = Time.realtimeSinceStartup;
if (Application.isEditor) _editorPlayModeName = ReadEditorPlayModeName();
Refresh();
}
private void Update()
{
PollPointer();
if (Time.unscaledTime >= _nextRefreshTime)
Refresh();
}
#endregion
#region 扫描已加载包
private void Refresh()
{
_nextRefreshTime = Time.unscaledTime + RefreshInterval;
int addedCount = 0;
_loadedNow.Clear();
var bundles = AssetBundle.GetAllLoadedAssetBundles();
if (bundles != null)
{
foreach (var b in bundles)
{
if (b == null) continue;
string n = b.name;
_loadedNow.Add(n);
if (!_records.ContainsKey(n))
{
string baseName = n.EndsWith(".bundle") ? n.Substring(0, n.Length - 7) : n;
var rec = new BundleRecord
{
name = n,
shortName = baseName.Length > 16 ? baseName.Substring(0, 16) + "…" : baseName,
firstSeen = Time.realtimeSinceStartup,
streamedScene = b.isStreamedSceneAssetBundle
};
// ⭐ 扫描包内资源清单辨认内容(GetAllAssetNames 只读包目录、不会把资源加载进内存)
try
{
var assets = b.GetAllAssetNames();
if (assets != null && assets.Length > 0)
{
rec.assetCount = assets.Length;
rec.tag = GuessTag(assets);
rec.samplePaths = new List<string>();
for (int i = 0; i < assets.Length && rec.samplePaths.Count < 4; i++)
rec.samplePaths.Add(assets[i]);
}
}
catch { }
_records[n] = rec;
addedCount++;
if (logOnChange)
Debug.Log(string.IsNullOrEmpty(rec.tag)
? $"[AA包监控] + {n}({rec.assetCount} 项)"
: $"[AA包监控] + {n}【{rec.tag}】({rec.assetCount} 项)");
}
}
}
// 已卸载的包:从记录中移除
_removed.Clear();
foreach (var kv in _records)
if (!_loadedNow.Contains(kv.Key)) _removed.Add(kv.Key);
foreach (var n in _removed)
{
_records.Remove(n);
if (logOnChange)
Debug.Log($"[AA包监控] - {n}");
}
// 排序:最新加载的排最前(进房时新增的包会直接顶到列表上方)
_sorted.Clear();
_sorted.AddRange(_records.Values);
_sorted.Sort((a, b) => b.firstSeen.CompareTo(a.firstSeen));
if (logOnChange && (addedCount > 0 || _removed.Count > 0))
Debug.Log($"[AA包监控] 变动:+{addedCount} / -{_removed.Count},当前共 {_records.Count} 个包 | {MemSummary()}");
}
private static string MemSummary()
{
long allocated = Profiler.GetTotalAllocatedMemoryLong();
long reserved = Profiler.GetTotalReservedMemoryLong();
long mono = Profiler.GetMonoUsedSizeLong();
return $"内存: 已分配 {allocated / 1048576f:F1}MB / 预留 {reserved / 1048576f:F1}MB / Mono {mono / 1048576f:F1}MB";
}
/// <summary>
/// 根据包内资源路径猜内容标识:优先识别玩法关键词(XJMH/HHWK/FLZY),
/// 否则取出现最多的路径段(如 login/home/zhanshen 等),识别不出返回空。
/// </summary>
private static string GuessTag(string[] assets)
{
int sample = Mathf.Min(assets.Length, 400);
int xjmh = 0, hhwk = 0, flzy = 0;
var freq = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < sample; i++)
{
string a = assets[i].ToLowerInvariant();
if (a.Contains("xjmh")) xjmh++;
else if (a.Contains("hhwk")) hhwk++;
else if (a.Contains("flzy") || a.Contains("fenglei")) flzy++;
var parts = a.Split('/');
for (int k = 1; k < parts.Length && k <= 3; k++)
{
string seg = parts[k];
if (seg.Length == 0 || seg.Length > 12) continue;
if (seg == "assets" || seg == "mygame" || seg == "art" || seg == "game" || seg == "aa" || seg == "ui") continue;
freq.TryGetValue(seg, out int c);
freq[seg] = c + 1;
}
}
int minHits = Mathf.Max(3, sample / 5);
if (xjmh >= minHits && xjmh >= hhwk && xjmh >= flzy) return "XJMH";
if (hhwk >= minHits && hhwk >= xjmh && hhwk >= flzy) return "HHWK";
if (flzy >= minHits && flzy >= xjmh && flzy >= hhwk) return "FLZY";
string best = null;
int bestCount = 0;
foreach (var kv in freq)
if (kv.Value > bestCount) { bestCount = kv.Value; best = kv.Key; }
return bestCount >= minHits ? best.ToUpperInvariant() : null;
}
/// <summary>
/// 编辑器里显示当前 Addressables Play Mode Script 状态:
/// Use Asset Database / Simulate Groups 模式下资源直接走工程读取、不产生 bundle,
/// 本窗口看不到包属正常;0 个包时附带"去哪看真实包"的操作指引。
/// </summary>
private string EditorStatusLine()
{
if (!Application.isEditor) return null;
string mode = string.IsNullOrEmpty(_editorPlayModeName) ? "未知" : _editorPlayModeName;
if (_records.Count == 0 && mode != "Use Existing Build")
return $"编辑器 PlayMode:{mode} —— 此模式资源不走 bundle,0 个包属正常。" +
"要看真实包:构建 Windows 内容后切 Use Existing Build,或用真机 Dev Build";
return $"编辑器 PlayMode:{mode}";
}
/// <summary>
/// 反射读取 Addressables 编辑器设置里的 PlayMode Script 索引
/// (0=Use Asset Database 1=Simulate Groups 2=Use Existing Build 3=Default Build Script)。
/// 用反射是为了不依赖 Unity.Addressables.Editor 程序集,运行时/真机编译不受影响。
/// </summary>
private static string ReadEditorPlayModeName()
{
try
{
var t = Type.GetType("UnityEditor.AddressableAssets.Settings.ProjectConfigData, Unity.Addressables.Editor");
var prop = t?.GetProperty("ActivePlayModeIndex", BindingFlags.Public | BindingFlags.Static);
if (prop != null)
{
int idx = (int)prop.GetValue(null);
switch (idx)
{
case 0: return "Use Asset Database";
case 1: return "Simulate Groups";
case 2: return "Use Existing Build";
case 3: return "Default Build Script";
}
return $"index={idx}";
}
}
catch { }
return "未知";
}
#endregion
#region 输入(新输入系统自采样:拖动标签 / 轻点切换 / 翻页 / 导出)
private void PollPointer()
{
// 键盘便捷开关(编辑器 / 带键盘设备;真机为 null 自动跳过)
var kb = Keyboard.current;
if (kb != null && kb.f9Key.wasPressedThisFrame)
_panelOpen = !_panelOpen;
Vector2 screenPos;
bool isPressed, wasPressed, wasReleased;
var ts = Touchscreen.current;
var m = Mouse.current;
bool touchActive = ts != null &&
(ts.primaryTouch.press.isPressed || ts.primaryTouch.press.wasPressedThisFrame || ts.primaryTouch.press.wasReleasedThisFrame);
if (touchActive)
{
screenPos = ts.primaryTouch.position.ReadValue();
isPressed = ts.primaryTouch.press.isPressed;
wasPressed = ts.primaryTouch.press.wasPressedThisFrame;
wasReleased = ts.primaryTouch.press.wasReleasedThisFrame;
}
else if (m != null)
{
screenPos = m.position.ReadValue();
isPressed = m.leftButton.isPressed;
wasPressed = m.leftButton.wasPressedThisFrame;
wasReleased = m.leftButton.wasReleasedThisFrame;
}
else
{
return;
}
// 输入系统的坐标原点在左下,OnGUI 在左上
var guiPos = new Vector2(screenPos.x, Screen.height - screenPos.y);
if (wasPressed)
OnPointerPressed(guiPos);
if (_ballDragging)
{
if (isPressed)
{
Vector2 target = guiPos - _ballDragOffset;
if (Vector2.Distance(target, _ballPos) > 6f) _ballMoved = true;
_ballPos = ClampToScreen(target);
}
else if (wasReleased)
{
_ballDragging = false;
if (!_ballMoved) _panelOpen = !_panelOpen; // 轻点 = 切换面板
}
}
}
private void OnPointerPressed(Vector2 guiPos)
{
_ballDragging = false;
_ballMoved = false;
if (_ballRect.Contains(guiPos))
{
_ballDragging = true;
_ballDragOffset = guiPos - _ballPos;
return;
}
if (!_panelOpen) return;
if (_prevPageRect.Contains(guiPos)) { _page--; return; }
if (_nextPageRect.Contains(guiPos)) { _page++; return; }
if (_exportRect.Contains(guiPos)) { ExportLog(); return; }
}
private static Vector2 ClampToScreen(Vector2 pos)
{
return new Vector2(
Mathf.Clamp(pos.x, 0f, Mathf.Max(0f, Screen.width - 160f)),
Mathf.Clamp(pos.y, 0f, Mathf.Max(0f, Screen.height - 64f)));
}
#endregion
#region 绘制
private void OnGUI()
{
EnsureStyles();
DrawBall();
if (_panelOpen)
DrawPanel();
}
private void DrawBall()
{
_ballRect = new Rect(_ballPos.x, _ballPos.y, 152f, 56f);
GUI.color = new Color(0f, 0f, 0f, 0.72f);
GUI.DrawTexture(_ballRect, Texture2D.whiteTexture);
GUI.color = Color.white;
GUI.Label(new Rect(_ballRect.x + 12f, _ballRect.y + 3f, 140f, 26f), $"AA包 {_records.Count}", _styleTitle);
GUI.Label(new Rect(_ballRect.x + 12f, _ballRect.y + 28f, 140f, 24f), _panelOpen ? "点我收起" : "点我展开", _styleLine);
}
private void DrawPanel()
{
float w = Mathf.Min(Screen.width - 32f, 980f);
float h = Mathf.Min(Screen.height - 140f, 1500f);
float x = Mathf.Clamp(_ballPos.x, 8f, Mathf.Max(8f, Screen.width - w - 8f));
float y = Mathf.Clamp(_ballPos.y + 64f, 8f, Mathf.Max(8f, Screen.height - h - 8f));
var panel = new Rect(x, y, w, h);
GUI.color = new Color(0f, 0f, 0f, 0.84f);
GUI.DrawTexture(panel, Texture2D.whiteTexture);
GUI.color = Color.white;
string status = EditorStatusLine();
float statusH = status == null ? 0f : 58f;
float headerH = 108f + statusH;
const float footerH = 64f;
const float rowH = 30f;
// ---- 概要 ----
GUI.Label(new Rect(panel.x + 16f, panel.y + 8f, w - 32f, 30f), $"AA 已加载包监控 共 {_records.Count} 个", _styleTitle);
GUI.Label(new Rect(panel.x + 16f, panel.y + 42f, w - 32f, 26f), MemSummary(), _styleLine);
GUI.Label(new Rect(panel.x + 16f, panel.y + 70f, w - 32f, 26f),
$"运行 {(Time.realtimeSinceStartup - _appStartTime):F0}s 黄色 = 近 {HighlightDuration:F0} 秒新增(新加载的包在列表最上方)", _styleLine);
if (status != null)
GUI.Label(new Rect(panel.x + 16f, panel.y + 96f, w - 32f, statusH), status, _styleWrap);
// ---- 列表分页 ----
_rowsPerPage = Mathf.Max(4, (int)((panel.height - headerH - footerH - 12f) / rowH));
int totalPages = Mathf.Max(1, Mathf.CeilToInt(_sorted.Count / (float)_rowsPerPage));
_page = Mathf.Clamp(_page, 0, totalPages - 1);
int start = _page * _rowsPerPage;
float rowY = panel.y + headerH;
for (int i = 0; i < _rowsPerPage; i++)
{
int idx = start + i;
if (idx >= _sorted.Count) break;
var r = _sorted[idx];
bool isNew = Time.realtimeSinceStartup - r.firstSeen < HighlightDuration;
var style = isNew ? _styleNew : _styleOld;
string sceneTag = r.streamedScene ? "[场景] " : "";
string info = string.IsNullOrEmpty(r.tag)
? (r.assetCount > 0 ? $"{r.assetCount}项" : "")
: $"【{r.tag}】{r.assetCount}项";
string text = $"{sceneTag}{r.shortName} {info} (+{r.firstSeen - _appStartTime:F0}s)";
GUI.Label(new Rect(panel.x + 18f, rowY, w - 36f, rowH), text, style);
rowY += rowH;
}
// ---- 底部按钮(自采样点击,IMGUI 按钮不可用)----
float btnY = panel.yMax - 52f;
float btnW = 150f;
_prevPageRect = new Rect(panel.x + 16f, btnY, btnW, 44f);
_nextPageRect = new Rect(panel.x + 16f + btnW + 12f, btnY, btnW, 44f);
_exportRect = new Rect(panel.xMax - btnW - 16f, btnY, btnW, 44f);
DrawFakeButton(_prevPageRect, "上一页");
DrawFakeButton(_nextPageRect, "下一页");
DrawFakeButton(_exportRect, "导出日志");
GUI.Label(new Rect(panel.x + 16f + (btnW + 12f) * 2f, btnY + 10f, 160f, 26f), $"{_page + 1} / {totalPages}", _styleLine);
}
private void DrawFakeButton(Rect r, string text)
{
GUI.color = new Color(1f, 1f, 1f, 0.22f);
GUI.DrawTexture(r, Texture2D.whiteTexture);
GUI.color = Color.white;
GUI.Label(new Rect(r.x, r.y + 8f, r.width, 28f), text, _styleButton);
}
private void EnsureStyles()
{
if (_styleTitle != null) return;
_styleTitle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold };
_styleTitle.normal.textColor = Color.white;
_styleLine = new GUIStyle(GUI.skin.label) { fontSize = 22 };
_styleLine.normal.textColor = new Color(0.82f, 0.9f, 1f);
_styleOld = new GUIStyle(GUI.skin.label) { fontSize = 23 };
_styleOld.normal.textColor = Color.white;
_styleNew = new GUIStyle(GUI.skin.label) { fontSize = 23, fontStyle = FontStyle.Bold };
_styleNew.normal.textColor = new Color(1f, 0.82f, 0.2f);
_styleButton = new GUIStyle(GUI.skin.label) { fontSize = 24, alignment = TextAnchor.UpperCenter };
_styleButton.normal.textColor = Color.white;
_styleWrap = new GUIStyle(GUI.skin.label) { fontSize = 20, wordWrap = true };
_styleWrap.normal.textColor = new Color(0.95f, 0.9f, 0.62f);
}
#endregion
#region 导出
private void ExportLog()
{
var sb = new StringBuilder();
sb.AppendLine($"[AA包监控] 导出时间 {DateTime.Now:yyyy-MM-dd HH:mm:ss},共 {_records.Count} 个包 | {MemSummary()}");
foreach (var r in _sorted)
sb.AppendLine($" {(r.streamedScene ? "[场景] " : "")}{r.name} (+{r.firstSeen - _appStartTime:F1}s)");
string text = sb.ToString();
Debug.Log(text);
try { GUIUtility.systemCopyBuffer = text; } catch { }
}
#endregion
}