使用说明 · 从零到能写业务

日志系统 · 使用说明

这份文档只回答一个问题:我该怎么用它?(一行分级输出、零配置、正式包零成本)

读完你能做到:3 分钟打出第一条日志 · 分清五个级别各花多少钱 · 知道出事前的现场去哪看 · 看不到日志时 3 分钟定位

〇它是干什么的

先花 30 秒建立直觉,再动手写。

只学 4 个东西就能干活(真的)
RevLog.Info("登录成功");                            // ① 普通信息(正式包保留)
RevLog.Warn("配置缺失,用默认值", "Config");         // ② 告警 + tag(第二个参数就是 tag)
RevLog.Error("数据库连不上", "Network");             // ③ 错误(自动带堆栈)
RevLog.Debug("仅编辑器可见,正式包零成本");              // ④ 调试(编译期删除,连拼接都不发生)

异常用 RevLog.Exception(e, "战斗开始失败", "Battle") —— 控制台里堆栈可点击跳转。
剩下的(落盘、上报、面板、自定义通道……)都是用到再看的增值项。

人话 日志系统 = 你只说"什么级别、哪个模块、说了什么",剩下的(往哪输出、要不要落盘、要不要上报、会不会刷屏)它全包。

它甚至不需要你配置:Unity 下第一次用到就自动装好控制台通道;纯 C# 环境下退到标准错误 —— 任何情况都不会静默丢日志。

它替你解决的问题怎么做的
正式包不想带日志Debug 用 [Conditional] 编译期删除:连参数表达式都不会求值
日志太多刷屏连续重复抑制:同一句只留首条,出现不同日志时补一条"重复 N 次"
出问题后没有现场环形缓冲常驻最近 2048 条,RevLog.Dump(200) 直接拿走
手机上没有日志文件EnableFileLog(目录) 一行开异步落盘(不卡帧、大小/份数双上限)
想接自己的崩溃平台RevLog.OnReport += ...(默认不接 = 不上报,不硬编码任何 SDK)
框架各处各打一套日志全框架出口已统一到 RevLog(见第八节)

一3 分钟跑起来(可粘贴)

不用配置、不用挂脚本、不用写 Update。

// ① 随便找个 MonoBehaviour,在 Start 里写:
void Start()
{
    RevLog.Info("游戏启动");
    RevLog.Warn("配置表缺字段,用默认值", "Config");
    RevLog.Debug("这条只有编辑器和带 REVLOG_DEBUG 的包能看到");
}

// ② 打开 Unity 控制台 → 应该看到三行:
//    "HH:mm:ss.fff I [General] 游戏启动 frame:123"
//    "HH:mm:ss.fff W [Config] 配置表缺字段,用默认值 frame:123"
//    "HH:mm:ss.fff D [General] 这条只有编辑器… frame:123"

// ③ 想看现场(最近 200 行,一键复制):
Debug.Log(RevLog.Dump(200));
就这样,没有第 4 步 没有初始化调用、没有场景物体、没有配置文件。能打出来就说明已经在工作; 打不出来看第十一节。

二"我要做 X" 对照表(全部 API)

左边找需求,右边抄一行。所有方法签名都写在一行里,参数不用换行。

我想…这么写
打普通信息 / 告警 / 错误RevLog.Info(msg, tag) · RevLog.Warn(msg, tag) · RevLog.Error(msg, tag)
打调试日志(正式包零成本)RevLog.Debug(msg, tag)
记录一个异常(带堆栈)RevLog.Exception(e, msg, tag)
按自己的级别打RevLog.Log(RevLogLevel.Warn, msg, tag)
热循环里先问一句(省拼接)if (RevLog.IsEnabled(RevLogLevel.Info, "Net")) RevLog.Info("...", "Net")
调输出阈值RevLog.MinLevel = RevLogLevel.Warn;
按模块关日志RevLog.MuteTag("Network") / RevLog.MuteTag("Network", false) / RevLog.IsTagMuted("Network")
落盘(异步、不卡帧)RevLog.EnableFileLog(Application.persistentDataPath + "/revlog")
把队列里的日志立刻写下去RevLog.Flush()
接崩溃上报RevLog.OnReport += (e, msg) => 你的平台.Report(e, msg);
看现场 / 清现场RevLog.Dump(200) · RevLog.Recent(50) · RevLog.Count · RevLog.Clear()
接自己的输出后端RevLog.AddSink(自定义) · RevLog.RemoveSink(自定义) · RevLog.Fallback = 你的出口
自检:日志是不是在丢RevLog.SinkErrors(0 = 正常;>0 = 某个通道坏了)
tag 是"第二个参数",不是重载 参考实现有两个参数位置不同、语义不同的"频道"重载(Debug(eLog, msg) 与 Debug(msg, "Module")), 极易写反。这里只有一个 tag 轴,而且位置固定:RevLog.Warn(消息, tag) —— 不可能写反。

三级别就是成本契约(最该记住)

级别不只是"严重程度",更是"调用要花多少钱"。

级别成本正式包里什么时候用
Debug零(调用点连同参数表达式被编译器删除)不存在(编译期删除)开发期的过程细节;无所谓刷屏
Info便宜(不采堆栈)保留关键流程节点(登录、进战斗、加载完成)
Warn便宜(不采堆栈)保留"可疑但还能跑":用了默认值、重试成功、被降级
Error较贵(默认采堆栈,微秒级)保留必须修的错:状态不对、数据非法、流程中断
Exception最贵(堆栈 + 可触发上报)保留捕获到的异常对象

3.1 Debug 为什么是"零成本"

// 你写:
RevLog.Debug("坐标 = " + Heavy().ToString());      // Heavy() 很贵

// 正式包里编译出来(IL):什么都没有 —— 连 Heavy() 都不会被调用
// 对比"运行期判断":if (isDebug) { ... } 参数已经求值、GC 已经产生,钱已经花了
想让 Debug 在正式包里也出现?加个宏 Debug 上是 [Conditional("UNITY_EDITOR"), Conditional("REVLOG_DEBUG")]。 在自己的工程里加 Scripting Define Symbol REVLOG_DEBUG(或 CI 里给"带日志包"加), Debug 调用点就会重新编译进去 —— 不需要改任何业务代码。

3.2 热循环里别拼字符串

✗ 每条都拼(贵)
for (...)
    RevLog.Info($"hp={hp} pos={pos}");
✓ 先问再拼(便宜)
for (...)
    if (RevLog.IsEnabled(RevLogLevel.Info, "HP"))
        RevLog.Info($"hp={hp} pos={pos}", "HP");

RevLog.IsEnabled 只做两次比较(阈值 + tag 静音表),是热路径里唯一值得的守卫。

四两个开关:阈值与按模块静音

"一切有开关" —— 日志系统自己也不例外。

开关作用例子
RevLog.MinLevel全局阈值(默认 Info)RevLog.MinLevel = RevLogLevel.Warn;(只留告警以上)
RevLog.MuteTag(tag)按模块/标签静音RevLog.MuteTag("Network");(网络层刷屏时先关它)
RevLog.IsEnabled(level, tag)查询(热循环守卫)if (RevLog.IsEnabled(RevLogLevel.Info, "Net")) ...
[Conditional](编译期)Debug 存不存在加 REVLOG_DEBUG 宏
推荐的 tag 命名 用模块名("Network"、"Battle"、"UI"、"Config")而不是具体类名 —— 静音时的粒度才刚好。框架自己的标签:Event / Pool / Timer / UI / StateMachine / RevAB。

五出事前的现场(环形缓冲)

线上最需要的不是"从现在开始记",而是"出事之前发生了什么"。

RevLog.Dump(200);        // 最近 200 行拼成多行文本(一键复制)
RevLog.Recent(50);      // 最近 50 条结构化条目(时间/级别/tag/消息/堆栈/帧号)
RevLog.Count;           // 当前留了多少条
RevLog.Clear();         // 清空历史(通道不动)
容量是特性,不是限制 缓冲固定 2048 条:内存有硬顶(日志刷屏也吃不光内存)、写入 O(1) 且零分配、满了覆盖最旧的。 "日志的价值随时间衰减" —— 留最近的就够了。
字段说明
LevelChar级别单字母:D / I / W / E / X(写文件时第一列就是它)
Tag模块标签(没传就是 General)
Time本地时间(精确到毫秒)
FrameUnity 渲染帧号;没有来源时是 -1(不用 0 冒充:第 0 帧是合法帧号)
Stack堆栈(只有 Error / Exception 采)
Repeat>1 表示"上一行重复了 N 次"(见第九节)
Error原始异常对象(只有 Exception 级别有)

六落盘与上报(默认都不开)

"要不要写文件、要不要上报"是产品决策,不是框架替你决定的事。

6.1 落盘:一行,异步,不卡帧

RevLog.EnableFileLog(Application.persistentDataPath + "/revlog");
特性说明
异步主线程只入队(微秒级),Lowest 优先级后台线程格式化并写盘;连 ToString 的分配都不在主线程
双上限单文件 4MB(按 UTF-8 字节统计,中文日志不会超)+ 保留 10 份(文件名带毫秒时间戳,天然按时间排序)
目录不可写禁用通道 + 明确报到 RevLog.SinkErrors(不是静默降级)
退出前Unity 退出时钩子自动 Flush;你也可以随时手动 RevLog.Flush()(≤1 秒排空,超时自己兜底写完)

6.2 上报:接一行,不接就不报

RevLog.OnReport += (e, msg) => CrashSightAgent.ReportException(e, msg);   // 你的平台
RevLog.OnReport += (e, msg) => 自建上报.Post(e, msg);                     // 或自建
上报只在 Exception 级别触发 日志与上报是两件事:Error 只是"必须修的错",不进上报; 只有你显式用 RevLog.Exception(e, ...) 记录异常对象时才触发 OnReport。 这样"滥用上报"的成本问题就不再是纪律问题,而是 API 语义。

七接自己的输出通道

门面自己不写任何地方 —— 输出全靠通道(Sink)。

RevLog.Warn(...)
→
过滤(阈值 + tag)
→
重复抑制
→
环形缓冲留底
→
逐个 Sink 输出
自定义通道(20 行就能写一个)
场景:把日志同时发给你的 WebSocket 面板 / 上报平台 / 文件格式自定义
sealed class MySink : IRevLogSink
{
    public string Name => "MySink";
    public void Write(in RevLogEntry entry) => 你的队列.Enqueue(entry);   // 别在这里做慢活
    public void Flush()   => 你的队列.排空();
    public void Dispose() => 你的队列.收尾();
}

RevLog.AddSink(new MySink());
通道里抛异常会被 RevLog 隔离(计数 + 不传染调用方),先加后坏不会拖垮游戏。
通道装在哪 / 谁装
Console(默认)Unity 下由 RevLogUnityHooks 自动装;Error 走红字,Exception 额外给可点击跳转的堆栈
File你调 RevLog.EnableFileLog(目录) 才装(默认不写文件)
你的通道RevLog.AddSink(...);想整体换后端就同时设 RevLog.Fallback

八全框架统一出口(接入现状)

框架从"每个模块各打一套"变成了"只有一个日志出口"。

模块tag接入方式
事件系统EventRevEvent.Log / OnException → RevLog
对象池PoolRevPoolLog.Sink → RevLog(内核兜底也走 RevLog)
计时器TimerRevTimer.Log / OnException → RevLog(内核兜底从"静默"改成 RevLog)
UI 系统UIRevUILog.Error / Warning / Info 默认实现 → RevLog
状态机StateMachineRevHeavyFsmLog 默认出口 → RevLog
宿主适配层Timer / ActionSequence / Res计时器驱动、动作序列驱动、编辑器直读策略的裸 Debug.Log* → RevLog
RevAB 打包工具(Editor)RevAB工具自己的出口类 RevABLog → RevLog(28 处调用统一)
音效 / 资源加载—不接:它们的契约是"失败带原因码 + Failed 事件 / handle.ErrorReason",业务订阅即可
一键换后端 只要动三个口子,业务调用点一行都不用改:RevLog.AddSink/RemoveSink(往哪输出)、 RevLog.Fallback(没通道时的兜底)、RevLog.OnReport(上报)。

九怎么防止日志刷屏(重复抑制)

"每帧都在刷同一句话"是日志系统最真实的头号杀手。

RevLog.Warn("每帧都在刷同一句话");   // 连刷 5 次
RevLog.Warn("换了一句话");           // 出现不同日志时,补一条汇总

// 控制台看到:
//   W [General] 每帧都在刷同一句话 frame:10
//   W [General] 每帧都在刷同一句话(重复 5 次) frame:10   ← 汇总(条目的 Repeat 字段)
//   W [General] 换了一句话 frame:10
规则说明
只抑制连续相同的a, b, a 三条都在(不改变日志顺序语义)
首条立刻可见不是"攒够再报" —— 出事那一刻你就能看到
汇总在"换话"时补出一条 Repeat = N 的条目告诉你刷了多少次
收益10 万次相同日志 = 1 条记录 + 0 字节分配(实测)
看起来像"日志丢了"? 如果你确实需要每一条都留(例如每次都要看坐标),说明那些日志并不是真的相同 —— 消息里带上变化的字段即可。 想立刻结束汇总可以调一次 RevLog.Flush()。

十新手最容易踩的 6 个坑

每条都会让人白干一阵子。

#坑正解
1以为 RevLog.Debug 正式包里"打得出来但被过滤"它是编译期删除(连参数都不求值)。要在正式包看,给工程加 REVLOG_DEBUG 宏
2在热循环里拼字符串先 IsEnabled 再拼;Debug 例外(它零成本)
3什么都往 Error 上糊Error 默认采堆栈(有成本)。"可疑但能跑"用 Warn
4忘了 Flush文件通道是异步的(这是不卡帧的代价)。切场景/退后台/出包前调一次(Unity 退出时自动调)
5看到"重复 N 次"以为日志丢了那是重复抑制在工作;见第九节
6SinkErrors > 0 却继续跑说明某个通道在坏(磁盘满 / 目录无权限 / 自定义 Sink 抛异常)—— 它在提醒你"日志可能在丢"

十一看不到日志怎么查(3 分钟)

按顺序排除,每一步都能立刻验证。

① 阈值?
→
② tag 被静音?
→
③ 是 Debug?
→
④ 没有通道?
→
⑤ 被抑制了?
现象查这里
完全看不到任何日志RevLog.Fallback 被设成了什么都不做的委托?把 RevLog.AddSink(new RevConsoleSink()) 补回来(Unity 下正常是钩子自动装的)
Info 看不到、Warn 能看到RevLog.MinLevel 被调高了 → RevLog.MinLevel = RevLogLevel.Info;
某个模块看不到被 MuteTag 了 → RevLog.IsTagMuted("Network")
正式包里 Debug 全没有预期行为(编译期删除)→ 加 REVLOG_DEBUG 宏
只看到一条、后面没了连续重复被抑制了 → 看条目的 Repeat,或 RevLog.Dump 看全貌
文件里没有没调 EnableFileLog,或目录不可写(查 RevLog.SinkErrors)
一条能救命的命令 Debug.Log(RevLog.Dump(300)); —— 不管日志是什么时候打的、有没有开文件, 最近 300 行现场一直都在环形缓冲里。

十二附:文件清单 / 验收 / 设计来源

想深入看代码或判断可信度时读这一节。

12.1 文件都在哪(你只需要读一个)

文件行数说明
Facade\RevLog.cs156★ 唯一入口:五个级别 + 开关 + 通道 + 现场 + 出口
Core\RevLogDefines.cs125级别 = 成本契约、条目字段、上限(纯 C#)
Core\RevLogRing.cs94环形缓冲(纯 C#,零分配写入)
Interfaces\IRevLogSink.cs37输出通道契约
Implementation\RevLogCore.cs260内核:过滤 → 重复抑制 → 留底 → 派发(不用读)
Implementation\RevFileSink.cs259异步写文件 + 双上限轮转(不用读)
Support\RevConsoleSink.cs58Unity 控制台通道(不用读)
Support\RevLogUnityHooks.cs64★ 全框架日志接入点(不用读)

12.2 验收情况

项结果
工程外行为断言(纯 C#,脱离 Unity)41 / 41 通过:级别阈值、tag 静音、堆栈按级别采、帧号缺失值 -1、重复抑制、通道隔离、环形缓冲顺序与覆盖、异常对象与上报出口、异步落盘与轮转、目录不可写时的失能可见
零分配10 万次相同日志 = 0 B;10 万条不同日志 = 0 B(除日志条目本身)
环形缓冲硬顶 2048 条(写满覆盖最旧的)
编译Revolution.Runtime 0 错 0 警;Revolution.Editor 0 错
依赖无(内核纯 C#;只有控制台通道与钩子用 UnityEngine)

12.3 设计来源(参考实现日志系统)

抄来的(精华)本框架怎么落
[Conditional] 编译期删除调用点与参数表达式保留(Debug 上的两个 Conditional;业务加 REVLOG_DEBUG 就能在正式包看到)
级别即成本契约(早期版本用注释三遍强调纪律)升级成结构性保障:堆栈只在 Error 及以上采
门面不写文件、输出通道解耦IRevLogSink 列表(早期版本是单一事件,只能挂一个、还会漏挂)
异步文件写入(Lowest 线程 + 双缓冲)同款;但入队的是条目不是字符串 —— 主线程连一次分配都没有
崩溃前尽量落盘Flush() 等排空(≤1 秒)后自己兜底写完;Unity 退出自动 Flush
启动时探测写权限,失败不影响游戏保留 + 升级:失败明确计入 RevLog.SinkErrors
早期版本承认的空白这里怎么做
无重复日志抑制 / 采样 / 聚合(刷屏只能靠人工纪律)连续重复抑制(首条立刻可见 + "重复 N 次"汇总;10 万次 = 0 分配)
无模块级开关表(文档承诺了、实现里没有)RevLog.MuteTag(tag)(运行期可改)
无单文件大小上限(只有"份数")大小 + 份数双上限(4MB / 10 份)
日志系统失能不可观测(目录无权限就静默降级)RevLog.SinkErrors(通道坏 / 写盘失败 / 目录不可写都计入)
无"出事之前的现场"环形缓冲 2048 条 → RevLog.Dump(200)
两个"频道"参数位置/语义冲突,极易写反只有一个 tag 轴、位置固定(Warn(消息, tag))
配套文档 代码目录里还有一份 Assets/Revolution/Runtime/RevLog/README.md(3 分钟上手 + 6 个坑 + 排障),内容与本文一致、更适合对着代码看。