〇它是干什么的
先花 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 | 本地时间(精确到毫秒) |
Frame | Unity 渲染帧号;没有来源时是 -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 | 接入方式 |
|---|---|---|
| 事件系统 | Event | RevEvent.Log / OnException → RevLog |
| 对象池 | Pool | RevPoolLog.Sink → RevLog(内核兜底也走 RevLog) |
| 计时器 | Timer | RevTimer.Log / OnException → RevLog(内核兜底从"静默"改成 RevLog) |
| UI 系统 | UI | RevUILog.Error / Warning / Info 默认实现 → RevLog |
| 状态机 | StateMachine | RevHeavyFsmLog 默认出口 → 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 次"以为日志丢了 | 那是重复抑制在工作;见第九节 |
| 6 | SinkErrors > 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.cs | 156 | ★ 唯一入口:五个级别 + 开关 + 通道 + 现场 + 出口 |
Core\RevLogDefines.cs | 125 | 级别 = 成本契约、条目字段、上限(纯 C#) |
Core\RevLogRing.cs | 94 | 环形缓冲(纯 C#,零分配写入) |
Interfaces\IRevLogSink.cs | 37 | 输出通道契约 |
Implementation\RevLogCore.cs | 260 | 内核:过滤 → 重复抑制 → 留底 → 派发(不用读) |
Implementation\RevFileSink.cs | 259 | 异步写文件 + 双上限轮转(不用读) |
Support\RevConsoleSink.cs | 58 | Unity 控制台通道(不用读) |
Support\RevLogUnityHooks.cs | 64 | ★ 全框架日志接入点(不用读) |
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 个坑 + 排障),内容与本文一致、更适合对着代码看。