这章的目标只有一个:让你以后每写一个系统,都能“出问题时立刻定位”。
很多 Unity 项目早期最大的坑不是功能做不出来,而是功能做出来以后:
- 一报错就只剩
NullReferenceException,不知道谁在什么时候把它变成了 null。 - 日志满屏飘,找不到关键路径。
- 线上/测试机复现不了,无法对齐同一条执行链。
所以第一章我不教 UI、不教玩法,先把“看得见”做出来。
本章交付物
做完你应该得到一套可复制到任意 Unity 项目的最小基础设施:
Log:统一格式、级别、开关、上下文。Assert:在开发期尽早失败,在发布期降级成可观测告警。Runtime 开关:不用重新打包也能临时打开 Debug 日志。
约束(Unity 老版本兼容)
- 不依赖新语法(按 C# 7.x 思路写)。
- 不依赖外部日志库(避免包冲突)。
- 不用反射扫描全工程(避免启动慢)。
目录结构(建议)
Assets/
Runtime/
Foundation/
Logging/
Log.cs
LogLevel.cs
LogScope.cs
Assert.cs
Settings/
RuntimeToggles.cs
设计原则
1) 日志要“少而准”
你要的是“看到一行就知道发生了什么”,不是“把所有变量都打印出来”。
建议固定四个字段:
t:时间(秒或毫秒)lvl:级别tag:模块名msg:消息正文
再加一个可选字段:
ctx:上下文(例如玩家 id、关卡 id、请求 id)
2) 断言分两类:开发期硬失败、发布期软失败
- 开发期(Editor/Development Build):断言失败直接
throw,立刻暴露根因。 - 发布期(Release):断言失败记录告警 + 安全降级,不让玩家直接卡死。
实现:LogLevel
public enum LogLevel
{
Trace = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
None = 5,
}
实现:Log(统一入口)
这个版本先做到“够用且可控”。重点是:统一格式 + 统一开关。
using UnityEngine;
public static class Log
{
public static LogLevel Level = LogLevel.Info;
public static void Debug(string tag, string message) { Write(LogLevel.Debug, tag, message); }
public static void Info(string tag, string message) { Write(LogLevel.Info, tag, message); }
public static void Warn(string tag, string message) { Write(LogLevel.Warn, tag, message); }
public static void Error(string tag, string message) { Write(LogLevel.Error, tag, message); }
private static void Write(LogLevel level, string tag, string message)
{
if (level < Level) return;
var time = Time.realtimeSinceStartup;
var line = string.Format("t={0:0.000} lvl={1} tag={2} msg={3}", time, level, tag, message);
if (level >= LogLevel.Error) UnityEngine.Debug.LogError(line);
else if (level >= LogLevel.Warn) UnityEngine.Debug.LogWarning(line);
else UnityEngine.Debug.Log(line);
}
}
注意:这里我没有用
string拼接链式+,而是用string.Format,原因是旧版 C# 环境里可读性更稳定,而且你后面会把它抽成可替换的 formatter。
实现:LogScope(上下文串联)
真实项目里你经常需要把“同一件事”的日志串起来。
比如:一次关卡结算从按钮点击 → 结算计算 → 保存存档 → 上报统计。
最简单的办法:给这一条链一个 scopeId。
using System;
public sealed class LogScope : IDisposable
{
private readonly string _tag;
private readonly string _id;
public LogScope(string tag, string id)
{
_tag = tag;
_id = id;
Log.Info(_tag, "scope.begin id=" + _id);
}
public void Dispose()
{
Log.Info(_tag, "scope.end id=" + _id);
}
public string Id { get { return _id; } }
}
用法:
using (var scope = new LogScope("settlement", Guid.NewGuid().ToString("N")))
{
Log.Info("settlement", "calc.start id=" + scope.Id);
// ...
}
这比“全局静态 requestId”更安全,也更容易定位。
实现:Assert(开发期硬失败)
using System;
using UnityEngine;
public static class Assert
{
public static void NotNull(object value, string tag, string message)
{
if (value != null) return;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
throw new Exception("ASSERT NotNull failed: " + tag + " " + message);
#else
Log.Error(tag, "ASSERT NotNull failed: " + message);
#endif
}
public static void IsTrue(bool condition, string tag, string message)
{
if (condition) return;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
throw new Exception("ASSERT IsTrue failed: " + tag + " " + message);
#else
Log.Error(tag, "ASSERT IsTrue failed: " + message);
#endif
}
}
这段代码有两个价值:
- 开发期快速失败:把问题卡在“第一次出现的位置”。
- 发布期仍然可观测:不会静默吞掉错误。
运行时开关:临时打开 Debug
做法:用一个 PlayerPrefs 开关读取并覆盖 Log.Level。
using UnityEngine;
public static class RuntimeToggles
{
private const string KeyLogLevel = "u3dc.log.level";
public static void Apply()
{
var value = PlayerPrefs.GetInt(KeyLogLevel, (int)LogLevel.Info);
Log.Level = (LogLevel)value;
}
public static void SetLogLevel(LogLevel level)
{
PlayerPrefs.SetInt(KeyLogLevel, (int)level);
PlayerPrefs.Save();
Apply();
}
}
建议在游戏启动(Bootstrap)时调用一次:
RuntimeToggles.Apply();
Log.Info("boot", "runtime toggles applied");
本章验收
你做完后,用下面清单自检:
- 任意脚本都只通过
Log.*输出日志,不直接Debug.Log。 - 能通过
RuntimeToggles.SetLogLevel(LogLevel.Debug)打开 Debug 日志。 Assert.NotNull在 Editor 下会直接抛异常并定位到调用栈。
下一章预告
下一章我们做“配置与版本”:
- ScriptableObject 做默认配置
- JsonUtility 做本地覆盖
- 配置版本号 + 回滚策略
这会让你后面做技能、关卡、数值曲线时不再靠硬编码。