Files
5.24BuYu/Assets/MyGame/Scripts/Debug/AABundleMonitor.cs
T
2026-10-08 17:04:24 +08:00

513 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.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
}