using System; using System.Collections.Generic; using System.Reflection; using System.Text; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.Profiling; /// /// 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 按钮; /// 也因此悬浮层上的点击无法从游戏输入里"吃掉",测试时的小误触属正常现象。 /// public class AABundleMonitor : MonoBehaviour { #region 配置 /// 新包加载 / 卸载时自动输出日志(真机排查用,建议保持开启) public static bool logOnChange = true; /// 刷新间隔(秒):0.5 秒扫一次已加载包列表,避免每帧开销 private const float RefreshInterval = 0.5f; /// 新增包高亮保持时长(秒) private const float HighlightDuration = 5f; #endregion #region 自动安装 #if UNITY_EDITOR || DEVELOPMENT_BUILD || AA_MONITOR [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void AutoInstall() { if (FindFirstObjectByType() != null) return; var go = new GameObject("[AABundleMonitor]"); DontDestroyOnLoad(go); go.AddComponent(); } #endif #endregion #region 数据 /// 单个已加载包的记录 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 samplePaths; // 样例资源路径(导出日志用,帮助辨认包内容) } private readonly Dictionary _records = new Dictionary(); private readonly List _sorted = new List(); // 按加载时间倒序(最新在前) private readonly HashSet _loadedNow = new HashSet(); // 本次扫描到的包名 private readonly List _removed = new List(); // 本次扫描发现已卸载的包名 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(); 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"; } /// /// 根据包内资源路径猜内容标识:优先识别玩法关键词(XJMH/HHWK/FLZY), /// 否则取出现最多的路径段(如 login/home/zhanshen 等),识别不出返回空。 /// private static string GuessTag(string[] assets) { int sample = Mathf.Min(assets.Length, 400); int xjmh = 0, hhwk = 0, flzy = 0; var freq = new Dictionary(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; } /// /// 编辑器里显示当前 Addressables Play Mode Script 状态: /// Use Asset Database / Simulate Groups 模式下资源直接走工程读取、不产生 bundle, /// 本窗口看不到包属正常;0 个包时附带"去哪看真实包"的操作指引。 /// 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}"; } /// /// 反射读取 Addressables 编辑器设置里的 PlayMode Script 索引 /// (0=Use Asset Database 1=Simulate Groups 2=Use Existing Build 3=Default Build Script)。 /// 用反射是为了不依赖 Unity.Addressables.Editor 程序集,运行时/真机编译不受影响。 /// 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 }