Article

C# 实战课 01:为 Unity 项目搭建可复用日志与断言(可观测性起点)

这章的目标只有一个:让你以后每写一个系统,都能“出问题时立刻定位”

很多 Unity 项目早期最大的坑不是功能做不出来,而是功能做出来以后:

  • 一报错就只剩 NullReferenceException,不知道谁在什么时候把它变成了 null。
  • 日志满屏飘,找不到关键路径。
  • 线上/测试机复现不了,无法对齐同一条执行链。

所以第一章我不教 UI、不教玩法,先把“看得见”做出来。

本章交付物

做完你应该得到一套可复制到任意 Unity 项目的最小基础设施:

  1. Log:统一格式、级别、开关、上下文。
  2. Assert:在开发期尽早失败,在发布期降级成可观测告警。
  3. 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");

本章验收

你做完后,用下面清单自检:

  1. 任意脚本都只通过 Log.* 输出日志,不直接 Debug.Log
  2. 能通过 RuntimeToggles.SetLogLevel(LogLevel.Debug) 打开 Debug 日志。
  3. Assert.NotNull 在 Editor 下会直接抛异常并定位到调用栈。

下一章预告

下一章我们做“配置与版本”:

  • ScriptableObject 做默认配置
  • JsonUtility 做本地覆盖
  • 配置版本号 + 回滚策略

这会让你后面做技能、关卡、数值曲线时不再靠硬编码。