1. 项目概述与核心价值在Unity移动端开发中调试一直是个让人头疼的问题。你不可能在真机上频繁连接Profiler而Debug.Log的输出在发布版本中要么被剥离要么只能通过ADB Logcat查看效率极低。想象一下测试同事在另一台设备上发现了一个偶现的Bug他只能口头描述或者拍个模糊的视频你根本无法得知那一刻游戏内部变量的具体状态。这种“盲人摸象”式的调试严重拖慢了开发节奏。这时一个能在游戏画面上实时显示日志、变量甚至性能数据的调试面板就成了移动端开发的“刚需”。而Unity自带的OnGUI系统虽然古老且性能一般但它有一个无可替代的优势简单、直接、无需依赖任何第三方插件或复杂配置。你只需要几行代码就能在游戏画面的任何位置绘制出文本、按钮和滑块实时监控和调整游戏状态。这就像是给移动端游戏装上了一块“仪表盘”所有运行数据一目了然。今天要分享的就是如何用OnGUI打造这样一个移动端调试神器。我不会只给你一个干巴巴的脚本而是会拆解出5个最实用的技巧从基础显示到高级功能并附上完整的、可直接粘贴使用的代码。无论你是想快速监控某个变量的变化还是需要构建一个临时的作弊菜单这些技巧都能让你在移动端调试中游刃有余。我们核心要解决的就是三个问题信息可视化、实时交互、以及极低的接入成本。2. 调试系统整体设计与思路拆解2.1 为什么选择OnGUI而非UGUI/UIToolkit很多开发者第一反应可能是现在都202X年了为什么不用更现代的UGUI或者UIToolkit来构建调试界面这是一个非常好的问题也直接关系到我们方案的核心设计思路。核心原因在于“零依赖”和“运行时动态创建”。UGUI需要预设体Prefab和CanvasUIToolkit需要UXML和USS样式表。这些资源都需要提前制作并打包到应用中。而我们的调试工具其核心诉求是在任意项目的任意时刻仅通过一段脚本代码就能立刻生成一个可用的调试界面。OnGUI完全运行在代码层面所有界面元素都由GUI.Label、GUI.Button这些函数即时绘制不依赖任何场景中的GameObject或资源文件。这意味着你可以把这个脚本挂到任何一个空的GameObject上甚至通过代码动态添加调试面板立刻就出现了。这种便捷性是其他UI系统难以比拟的。其次是对运行时的零侵入。一个设计良好的OnGUI调试脚本应该可以通过一个全局开关例如#define ENABLE_DEBUG_PANEL彻底禁用。在发布正式版本时整个调试系统的代码都会被编译器剥离不会留下任何性能开销或资源引用。而UGUI的Canvas即使隐藏也依然存在于场景树中带来额外的Draw Call和渲染开销。当然我们必须正视OnGUI的缺点性能。OnGUI的渲染效率很低每帧都会触发多次GUI布局计算如果绘制大量元素会对帧率造成明显影响。因此我们的设计思路非常明确这个调试面板是给开发者看的功能优先但必须极其克制地使用。我们遵循以下原则信息精炼只显示最关键的数据如FPS、内存、关键对象状态。按需显示面板默认可以折叠或隐藏仅在需要时打开。优化绘制避免在OnGUI中执行复杂逻辑或字符串拼接所有显示内容应提前计算好。2.2 核心架构单例管理与绘制分层一个健壮的调试系统不应该有多个实例。我们采用经典的单例模式来确保全局只有一个调试管理器。这个管理器负责收集日志、计算性能数据、管理调试面板的显示状态。在绘制层面我们将面板内容分层基础信息层常驻显示如FPS、简易日志。面积小透明度高。详细面板层通过快捷键如手机三指下滑唤出包含完整的日志列表、变量监视器、功能按钮等。交互控制层在详细面板中提供清空日志、暂停日志滚动、修改数值等交互功能。这种分层设计确保了在不需要深度调试时屏幕上的干扰信息最少当需要排查问题时又能快速获得一个功能全面的控制台。2.3 数据流设计日志捕获与性能采样日志数据流是核心。我们不仅需要显示Debug.Log最好还能捕获Debug.LogError和Debug.LogWarning并用不同颜色区分。关键在于重定向Unity的日志回调。Application.logMessageReceived HandleLog;在HandleLog函数中我们将日志的时间、类型、信息、堆栈轨迹存储到一个线程安全的列表中。这里有一个关键点必须使用Queue或List配合锁因为日志回调可能来自子线程。如果直接在回调中修改UI相关的字符串可能会引发线程安全问题。性能数据采样如FPS帧率、内存占用则需要放在Update中定期计算。FPS的计算通常采用平滑平均的方式避免数字跳动过于剧烈。void Update() { // 计算FPS m_FrameCount; float deltaTime Time.unscaledDeltaTime; m_FrameTimeAccumulator deltaTime; if (m_FrameTimeAccumulator 0.5f) { // 每0.5秒更新一次更平滑 m_CurrentFPS m_FrameCount / m_FrameTimeAccumulator; m_FrameCount 0; m_FrameTimeAccumulator 0f; } // 采样内存 m_TotalMemory System.GC.GetTotalMemory(false) / (1024 * 1024); // 转换为MB }3. 5个实用技巧的深度解析与实现3.1 技巧一构建一个可折叠、可拖动的浮动窗口一个永远挡在屏幕正中央的调试面板是令人讨厌的。我们需要一个可以随意拖动、并且能最小化的窗口。实现核心GUI.Window函数。这个函数能创建一个带标题栏的窗口并且自动处理拖动逻辑。我们给窗口定义一个唯一的ID如0并在OnGUI中调用if (m_ShowDebugWindow) { m_WindowRect GUI.Window(0, m_WindowRect, DrawDebugWindow, Debug Panel); }在DrawDebugWindow函数内部我们绘制窗口的具体内容。m_WindowRect存储了窗口的位置和大小GUI.Window会自动更新它的位置。窗口记忆化为了更好的体验我们应该将窗口的位置和大小m_WindowRect以及显示状态m_ShowDebugWindow保存到PlayerPrefs中。这样下次打开游戏时调试窗口会出现在上次关闭时的位置。void LoadWindowSettings() { if (PlayerPrefs.HasKey(DebugWindowRectX)) { float x PlayerPrefs.GetFloat(DebugWindowRectX); float y PlayerPrefs.GetFloat(DebugWindowRectY); float w PlayerPrefs.GetFloat(DebugWindowRectW); float h PlayerPrefs.GetFloat(DebugWindowRectH); m_WindowRect new Rect(x, y, w, h); } m_ShowDebugWindow PlayerPrefs.GetInt(DebugWindowShown, 0) 1; }注意GUI.Window的拖动区域是整个标题栏。在移动端为了触摸友好可以适当增加标题栏的高度比如用GUILayout.Height(30)。同时要限制窗口的拖动范围防止被拖出屏幕外。m_WindowRect.x Mathf.Clamp(m_WindowRect.x, 0, Screen.width - m_WindowRect.width); m_WindowRect.y Mathf.Clamp(m_WindowRect.y, 0, Screen.height - m_WindowRect.height);3.2 技巧二实现带颜色区分与过滤的日志列表纯文本的日志流在问题排查时效率很低。我们需要用颜色一眼区分出普通信息、警告和错误。实现方法在存储日志时同时存储其类型LogType。在绘制时根据类型决定颜色。private void DrawLogList() { m_LogScrollPos GUILayout.BeginScrollView(m_LogScrollPos); foreach (var log in m_LogList) { GUI.color GetLogColor(log.type); // 根据类型返回颜色 GUILayout.Label($[{log.time:HH:mm:ss}] {log.message}); GUI.color Color.white; // 重置颜色 // 如果点击了某条日志可以展开显示其完整堆栈 if (log.showStack !string.IsNullOrEmpty(log.stackTrace)) { GUILayout.Label(log.stackTrace, GUILayout.Width(m_WindowRect.width - 30)); } } GUILayout.EndScrollView(); }颜色可以这样定义信息用白色或灰色警告用黄色错误用红色。这能极大提升日志的可读性。日志过滤功能在窗口顶部添加几个Toggle按钮GUI.Toggle分别对应Log、Warning、Error。在遍历m_LogList绘制之前先根据这些Toggle的状态进行过滤。你也可以增加一个输入框GUI.TextField实现关键词搜索过滤这在日志量很大时非常有用。自动滚动与暂停日志列表应该默认自动滚动到底部最新日志。可以通过一个布尔值m_AutoScroll来控制。实现方式是在每次添加新日志后如果m_AutoScroll为真则将滚动位置m_LogScrollPos.y设为一个很大的值如Mathf.Infinity。同时提供一个“暂停滚动”的按钮方便查看某一段历史日志。3.3 技巧三添加实时性能监控FPS内存性能问题是移动端的重中之重。在调试面板中实时显示FPS和内存能帮助快速定位性能瓶颈。FPS计算如前所述在Update中使用平滑算法计算。在OnGUI中显示即可。可以设定颜色阈值例如FPS50绿色30FPS50黄色FPS30红色让性能状态一目了然。内存显示显示当前Mono堆内存和总内存占用。Unity提供了Profiler.GetTotalAllocatedMemoryLong()等API但注意部分API在开发包和发布包中行为可能不同。一个更通用的方法是使用System.GC.GetTotalMemory它返回当前认为已分配的内存字节数。同时可以显示Profiler.usedHeapSizeLong如果开启了Development Build。实现一个简易图表文字数字不够直观我们可以用GUI的DrawTexture或简单的GUI.Box来绘制一个FPS历史曲线图。原理是维护一个固定长度的浮点数数组如最近100帧的FPS值在OnGUI中用GUI.Box根据这些值绘制出折线。这能让你一眼看出帧率的波动趋势。// 在Update中记录 m_FpsHistory[m_HistoryIndex] m_CurrentFPS; m_HistoryIndex (m_HistoryIndex 1) % m_FpsHistory.Length; // 在OnGUI中绘制 Rect graphRect GUILayoutUtility.GetRect(200, 50); GUI.Box(graphRect, ); // 绘制背景框 float maxFps Mathf.Max(m_FpsHistory.Max(), 60f); // 确定纵坐标最大值 for (int i 1; i m_FpsHistory.Length; i) { float x1 graphRect.x (i-1) * (graphRect.width / m_FpsHistory.Length); float y1 graphRect.y graphRect.height - (m_FpsHistory[i-1] / maxFps) * graphRect.height; float x2 graphRect.x i * (graphRect.width / m_FpsHistory.Length); float y2 graphRect.y graphRect.height - (m_FpsHistory[i] / maxFps) * graphRect.height; Drawing.DrawLine(new Vector2(x1, y1), new Vector2(x2, y2), Color.green, 2f); // 需要自定义DrawLine函数 }实操心得性能监控本身不能有明显性能开销。因此历史数组不宜过大100-200点足够且绘制图表的逻辑应只在调试窗口打开时执行。计算FPS时使用Time.unscaledDeltaTime避免受到游戏时间缩放的影响。3.4 技巧四创建变量监视器与运行时修改器这是调试面板的“高级玩法”。我们不仅想看到变量的值还想在游戏运行时动态修改它们比如调整敌人的血量、玩家的移动速度来快速测试游戏平衡性。实现思路使用C#的反射Reflection和委托Delegate。注册变量提供一个静态方法让其他脚本将需要监视的变量“注册”到调试管理器。注册时需要提供变量名称、获取其值的委托Funcobject以及设置其值的委托Actionobject。public static void WatchVariable(string name, Funcobject getter, Actionobject setter null) { m_WatchedVariables[name] new VariableInfo { name name, getter getter, setter setter }; }绘制与交互在调试窗口的变量监视器区域遍历所有已注册的变量。通过getter委托获取当前值并显示。如果该变量提供了setter委托则根据变量类型绘制相应的UI控件int/float使用GUI.HorizontalSlider或GUI.TextField需类型转换。bool使用GUI.Toggle。string使用GUI.TextField。Enum可以绘制成下拉选择稍微复杂需要遍历枚举值。 当UI控件的值发生变化时调用setter委托将新值应用回去。示例在玩家脚本中注册速度变量。void Start() { DebugPanel.WatchVariable(Player Speed, () moveSpeed, // getter (value) { moveSpeed Convert.ToSingle(value); } // setter ); }这样在调试面板中就会出现一个“Player Speed”的滑块拖动滑块就能实时改变玩家的移动速度。注意事项反射和委托有一定性能开销且类型转换容易出错。务必做好错误处理try-catch并且仅在开发版本中启用此功能。对于值类型如int, float的setter要注意装箱和拆箱。3.5 技巧五设计移动端专属的呼出与交互方式在PC上我们可以用~键呼出控制台。在移动端没有物理键盘需要设计直观的触控手势。常用呼出方式多指触控检测三指或四指同时按下Input.touchCount 3并持续一段时间如1秒即可呼出或隐藏调试面板。这种方式不易误触。特定区域点击在屏幕角落如左上角或右上角定义一个“神秘区域”连续点击5次呼出面板。这类似于开发者选项的开启方式。摇一摇利用手机加速度计检测剧烈的摇晃动作。可以通过Input.acceleration计算手机加速度的幅值当短时间内幅值超过阈值多次时触发。实现示例四指呼出void Update() { // 检测呼出手势 if (Input.touchCount 4) { m_FourFingerTimer Time.deltaTime; if (m_FourFingerTimer 1.0f !m_GestureTriggered) { m_ShowDebugWindow !m_ShowDebugWindow; m_GestureTriggered true; SaveWindowSettings(); } } else { m_FourFingerTimer 0f; m_GestureTriggered false; } }移动端UI适配OnGUI的控件默认尺寸对于手机触摸来说太小了。必须放大。使用GUI.skin.button.fixedHeight、GUI.skin.label.fontSize等来全局调整控件样式。或者更简单粗暴地在每个控件中使用GUILayout.Height(50)、GUILayout.Width(80)来指定足够大的触摸区域。按钮之间使用GUILayout.Space(10)增加间隔防止误触。4. 完整代码实现与集成步骤4.1MobileDebugPanel核心脚本下面是一个整合了上述所有技巧的、相对完整的MobileDebugPanel单例类代码框架。出于篇幅这里展示核心结构完整代码需包含所有细节。using System; using System.Collections.Generic; using System.Text; using UnityEngine; public class MobileDebugPanel : MonoBehaviour { #region Singleton private static MobileDebugPanel s_Instance; public static MobileDebugPanel Instance { get { if (s_Instance null) { GameObject go new GameObject([MobileDebugPanel]); s_Instance go.AddComponentMobileDebugPanel(); DontDestroyOnLoad(go); } return s_Instance; } } #endregion #region 配置与状态 public bool enableOnGUI true; // 总开关 private bool m_ShowDebugWindow false; private Rect m_WindowRect new Rect(20, 20, 350, 500); private Vector2 m_LogScrollPos Vector2.zero; private bool m_AutoScroll true; private bool m_ShowLog true, m_ShowWarning true, m_ShowError true; private string m_SearchFilter ; #endregion #region 日志系统 private struct LogEntry { public string time; public string message; public string stackTrace; public LogType type; public bool showStack; } private ListLogEntry m_LogList new ListLogEntry(); private const int MAX_LOG_COUNT 200; #endregion #region 性能监控 private float m_CurrentFPS 0f; private int m_FrameCount 0; private float m_FrameTimeAccumulator 0f; private float[] m_FpsHistory new float[100]; private int m_HistoryIndex 0; #endregion #region 变量监视 private class VariableInfo { public string name; public Funcobject getter; public Actionobject setter; public System.Type type; } private static Dictionarystring, VariableInfo m_WatchedVariables new Dictionarystring, VariableInfo(); #endregion #region Unity生命周期 void Awake() { if (s_Instance ! null s_Instance ! this) { Destroy(gameObject); return; } s_Instance this; DontDestroyOnLoad(gameObject); LoadWindowSettings(); Application.logMessageReceived HandleLog; InitGUISkinForMobile(); // 初始化移动端皮肤 } void Update() { // 1. 计算FPS m_FrameCount; m_FrameTimeAccumulator Time.unscaledDeltaTime; if (m_FrameTimeAccumulator 0.5f) { m_CurrentFPS m_FrameCount / m_FrameTimeAccumulator; // 记录历史 m_FpsHistory[m_HistoryIndex] m_CurrentFPS; m_HistoryIndex (m_HistoryIndex 1) % m_FpsHistory.Length; m_FrameCount 0; m_FrameTimeAccumulator 0f; } // 2. 检测呼出手势示例四指长按 CheckGesture(); } void OnGUI() { if (!enableOnGUI) return; // 绘制常驻的微型状态栏如FPS DrawMiniStatusBar(); // 绘制主调试窗口 if (m_ShowDebugWindow) { GUI.skin mobileSkin; // 应用移动端皮肤 m_WindowRect GUI.Window(0, m_WindowRect, DrawDebugWindow, 移动调试面板); // 限制窗口不超出屏幕 m_WindowRect.x Mathf.Clamp(m_WindowRect.x, 0, Screen.width - m_WindowRect.width); m_WindowRect.y Mathf.Clamp(m_WindowRect.y, 0, Screen.height - m_WindowRect.height); } } void OnDestroy() { Application.logMessageReceived - HandleLog; SaveWindowSettings(); } #endregion #region 核心功能方法 private void HandleLog(string logString, string stackTrace, LogType type) { // 在主线程中安全地添加日志 // 注意此回调可能来自子线程实际项目中应考虑线程安全这里为简化示例 LogEntry entry new LogEntry() { time DateTime.Now.ToString(HH:mm:ss), message logString, stackTrace stackTrace, type type, showStack false }; m_LogList.Add(entry); // 限制日志数量 if (m_LogList.Count MAX_LOG_COUNT) { m_LogList.RemoveAt(0); } // 自动滚动到底部 if (m_AutoScroll) { m_LogScrollPos.y Mathf.Infinity; } } private void DrawMiniStatusBar() { // 在屏幕顶部绘制一个半透明的状态栏显示FPS和内存 GUI.Box(new Rect(5, 5, 100, 30), $FPS: {m_CurrentFPS:F1}\nMem: {System.GC.GetTotalMemory(false) / (1024*1024):F1}MB); } private void DrawDebugWindow(int windowId) { // 绘制窗口内容区域 GUILayout.BeginVertical(); // 1. 工具栏过滤、清空、暂停滚动 DrawToolbar(); // 2. 日志列表 DrawLogList(); GUILayout.Space(10); // 3. 性能图表 DrawPerformanceGraph(); GUILayout.Space(10); // 4. 变量监视器 DrawVariableWatcher(); GUILayout.EndVertical(); // 允许窗口拖动 GUI.DragWindow(new Rect(0, 0, m_WindowRect.width, 30)); } private void DrawToolbar() { GUILayout.BeginHorizontal(); m_ShowLog GUILayout.Toggle(m_ShowLog, Log, GUI.skin.button, GUILayout.Height(40)); m_ShowWarning GUILayout.Toggle(m_ShowWarning, Warn, GUI.skin.button, GUILayout.Height(40)); m_ShowError GUILayout.Toggle(m_ShowError, Error, GUI.skin.button, GUILayout.Height(40)); GUILayout.Space(10); if (GUILayout.Button(清空, GUILayout.Height(40), GUILayout.Width(80))) { m_LogList.Clear(); } m_AutoScroll GUILayout.Toggle(m_AutoScroll, 自动滚动, GUI.skin.button, GUILayout.Height(40)); GUILayout.EndHorizontal(); // 搜索框 GUILayout.BeginHorizontal(); GUILayout.Label(搜索:, GUILayout.Width(40)); m_SearchFilter GUILayout.TextField(m_SearchFilter); GUILayout.EndHorizontal(); } private void DrawLogList() { // 具体绘制逻辑包含过滤和颜色区分 // ... } private void DrawPerformanceGraph() { GUILayout.Label($实时帧率: {m_CurrentFPS:F1}); // 绘制FPS历史曲线图 // ... } private void DrawVariableWatcher() { if (m_WatchedVariables.Count 0) return; GUILayout.Label( 变量监视 ); foreach (var kvp in m_WatchedVariables) { DrawVariableField(kvp.Value); } } private void DrawVariableField(VariableInfo info) { GUILayout.BeginHorizontal(); GUILayout.Label(info.name, GUILayout.Width(100)); object currentValue info.getter?.Invoke(); GUILayout.Label(currentValue?.ToString() ?? null, GUILayout.Width(100)); // 如果可写绘制输入控件示例针对float类型 if (info.setter ! null info.type typeof(float)) { float floatVal Convert.ToSingle(currentValue); float newVal GUILayout.HorizontalSlider(floatVal, 0f, 100f, GUILayout.Width(150)); if (Mathf.Abs(newVal - floatVal) 0.001f) { info.setter.Invoke(newVal); } } GUILayout.EndHorizontal(); } private void CheckGesture() { // 四指长按手势检测 // ... } private void InitGUISkinForMobile() { // 创建或修改GUI.skin使按钮等控件更大更适合触摸 // ... } private void LoadWindowSettings() { /* 从PlayerPrefs加载 */ } private void SaveWindowSettings() { /* 保存到PlayerPrefs */ } #endregion #region 静态API供其他脚本调用 public static void Watch(string varName, Funcobject getter, Actionobject setter null) { Instance.RegisterVariable(varName, getter, setter); } public static void Log(string message) { // 可以扩展为不同频道 Debug.Log($[DEBUG] {message}); } #endregion // 内部注册方法 private void RegisterVariable(string name, Funcobject getter, Actionobject setter) { // ... 实现注册逻辑 } }4.2 如何集成到你的项目创建脚本在你的Unity项目中创建一个名为Scripts/Runtime/Debug的文件夹或其他你喜欢的目录。将上述MobileDebugPanel脚本需补充完整放入其中。初始化确保在游戏启动时MobileDebugPanel实例被创建。最简单的方法是在游戏初始场景中创建一个空物体并挂载此脚本或者在任何肯定会执行的脚本的Awake或Start方法中访问一下MobileDebugPanel.Instance属性触发单例创建。条件编译关键为了确保调试代码不会被打包到正式版本中强烈建议使用条件编译指令。#if DEVELOPMENT_BUILD || UNITY_EDITOR // 整个MobileDebugPanel类的定义或者至少是OnGUI()调用和Update中的手势检测 enableOnGUI true; #else enableOnGUI false; #endif你可以在Unity的Player Settings-Scripting Define Symbols中为开发版本添加DEVELOPMENT_BUILD符号。这样在打Release包时所有调试UI和逻辑都会被移除。使用Watch API在你的游戏逻辑脚本中注册你想监视的变量。public class PlayerController : MonoBehaviour { public float moveSpeed 5f; void Start() { #if DEVELOPMENT_BUILD || UNITY_EDITOR MobileDebugPanel.Watch(玩家速度, () moveSpeed, (v) { moveSpeed (float)v; }); MobileDebugPanel.Watch(是否在地面, () isGrounded); #endif } }5. 常见问题、优化与避坑指南5.1 性能开销分析与优化问题OnGUI本身很耗如果每帧绘制大量文本和控件在低端移动设备上可能导致明显的帧率下降。优化策略按需绘制这是最重要的原则。只有调试面板可见时m_ShowDebugWindow为真才执行复杂的绘制逻辑。常驻的微型状态栏FPS显示应只包含极少量的GUI.Label。限制日志数量如代码所示限制m_LogList的最大数量例如200条避免列表无限增长导致遍历和绘制变慢。避免在OnGUI中计算所有用于显示的数据如FPS、变量值都应在Update中提前计算好OnGUI只负责读取和显示。简化皮肤自定义的GUI.skin不要使用复杂样式避免使用GUILayout进行过于复杂的自动布局因为布局计算也有开销。必要时使用GUI函数并指定精确的Rect来代替GUILayout。分帧绘制如果日志列表极长可以考虑分帧绘制即每帧只绘制其中一部分但这会牺牲实时性一般不推荐。5.2 移动端手势冲突与误触问题四指呼出手势可能与游戏本身的多点触控操作如双指缩放旋转视角冲突。解决方案使用非常用手势例如四指长按比三指更不易与游戏操作冲突。或者使用“摇一摇”这几乎不会与任何触屏操作冲突。增加手势触发难度如要求四指长按超过1秒并同时检测四指位置相对集中避免用户正常操作时误触发。提供开关在调试面板内部提供一个“禁用/启用手势”的按钮当需要测试游戏本身的多点触控时可以临时关闭调试面板的呼出手势。5.3 日志回调的线程安全问题问题Application.logMessageReceived回调可能在非主线程子线程中被调用。如果直接在回调中修改m_LogList一个ListLogEntry然后在主线程的OnGUI中读取可能会引发线程安全问题导致崩溃或数据错乱。解决方案使用线程安全的集合将ListLogEntry替换为ConcurrentQueueLogEntry需要.NET 4.x或更高版本它在多线程环境下是安全的。使用锁Lock在修改和读取m_LogList时使用lock关键字进行同步。private readonly object m_LogLock new object(); private void HandleLog(string logString, string stackTrace, LogType type) { lock (m_LogLock) { m_LogList.Add(...); if (m_LogList.Count MAX_LOG_COUNT) { m_LogList.RemoveAt(0); } } } private void DrawLogList() { lock (m_LogLock) { foreach (var log in m_LogList) { // 绘制... } } }主线程队列在回调中只将日志加入一个线程安全的队列然后在Update中从队列取出并添加到主线程的List中。这是更经典的Unity多线程数据处理方式。5.4 发布时的代码剥离核心要点必须确保调试代码不会影响正式包的体积和性能。条件编译是最佳实践如前所述使用#if DEVELOPMENT_BUILD包裹整个调试面板的核心逻辑包括类本身的Update和OnGUI方法。确保在非开发版本中这些方法体为空或直接返回。使用[Conditional]属性对于你提供的静态API如DebugPanel.Log、DebugPanel.Watch可以为它们添加[System.Diagnostics.Conditional(DEVELOPMENT_BUILD)]属性。这样在非开发版本中调用这些方法的代码在编译时会被完全移除。[System.Diagnostics.Conditional(DEVELOPMENT_BUILD)] public static void Watch(string varName, Funcobject getter, Actionobject setter null) { // ... 实现 }这样你在游戏代码中写的DebugPanel.Watch(...)调用在打Release包时会被自动删除连参数计算的开销都没有。移除初始化确保创建MobileDebugPanel实例的代码如在某个GameController的Start中也被条件编译包裹。5.5 扩展思路从OnGUI到更现代的方案虽然OnGUI方案简单快捷但如果你需要一个功能更强大、性能更好、UI更美观的移动端调试工具可以考虑以下进化路径UGUI 运行时动态创建使用代码动态实例化UGUI的Canvas、Text、Button等组件。这比OnGUI性能好且UI更美观易控。你可以预制几个面板Prefab运行时加载。开源插件社区有一些优秀的开源调试插件如UnityIngameDebugConsole一个在游戏内显示的控制台Demigiant Debug强大的日志和断言工具。它们通常功能更完善但需要集成到项目中。自定义渲染对于性能极度敏感的场景可以考虑使用GL库或CommandBuffer进行最底层的绘制但这需要较高的图形学知识。我个人在实际移动端项目中的体会是对于快速原型、临时调试和小型项目本文的OnGUI方案是性价比最高的选择五分钟集成立刻能用。对于中大型项目建议初期使用此方案快速搭建调试能力后期随着项目复杂再逐步迁移到基于UGUI的、更模块化和可配置的内部调试系统。记住工具的目的是提升效率不要陷入过度优化工具的陷阱能解决问题、节省时间的就是好工具。最后一个小技巧你可以把这个调试面板的呼出手势和显示状态通过一个简单的网络接口暴露出来这样在真机测试时测试人员也可以通过电脑浏览器远程打开或关闭调试面板实现更灵活的调试协作。