1. 为什么一个日志系统值得单独拿出来做
做过Unity项目的人都有一个共识:编辑器里跑得好好的东西,打包出去就变了个样。玩家反馈"卡死了""闪退了""道具没到账",你问具体什么情况,对方只能回你一句"就是不行"。这时候如果没有一份靠谱的日志文件,排查基本靠猜。
Unity自带的Debug.Log在编辑器里能看到,但发布版本里这些信息去了哪里?默认情况下,它们被写进了系统级的日志文件里,Windows在%USERPROFILE%\AppData\LocalLow\公司名\产品名\Player.log,安卓要用adb才能抓,iOS得连Xcode。让玩家自己去翻这些路径,显然不现实。
所以一个能在发布版本中自动记录日志、按规则写入本地文件、方便事后取回的日志系统,几乎是每个上线项目的标配。它解决的核心问题就三个:信息不丢失、文件不爆炸、取回不费劲。不管你是做单机、网游还是工具类应用,这套东西都用得上。下面我把自己在多个项目中反复打磨过的一套方案完整拆开讲,从设计思路到代码落地,再到踩过的坑,尽量说透。
2. 日志系统的整体设计与关键取舍
2.1 先想清楚日志要解决什么问题
很多人一上来就写代码,结果做到一半发现方向不对。我在动手之前会先问自己几个问题:日志给谁看?是给开发者自己排查崩溃,还是给客服用来定位玩家问题?日志要保留多久?一个玩家玩三个月,日志文件会不会涨到几个G?日志写入会不会拖慢主线程?
这几个问题的答案直接决定了架构。给开发者看的日志可以详细一点,堆栈、变量值都往里塞;给客服用的日志就得克制,只记录关键节点,否则客服根本看不懂。保留策略上,我一般用"按天切分+总量上限"的组合,既保证能追溯最近几天的问题,又不会让磁盘被撑爆。写入性能方面,同步写文件在移动端是灾难,必须走异步队列。
2.2 为什么不用现成的日志插件
市面上确实有一些日志相关的插件,功能也挺全。但在实际项目里我倾向于自己写一套轻量的,原因有几个。第一,第三方插件往往带着一堆你用不上的功能,包体白白增大。第二,发布版本出问题的时候,如果日志系统本身是黑盒,你连它有没有正常工作都不确定。第三,自己写的代码可以精确控制写入时机、文件格式、清理策略,跟项目的其他系统(比如崩溃上报、埋点)对接起来也更顺。
当然,如果你的项目已经在用某个成熟的日志框架,那没必要推倒重来,理解它的原理、把文件写入这块补上就行。核心思路是通用的。
2.3 整体架构长什么样
我这套方案分四层。最上面是调用层,也就是业务代码里调用的Log.Info、Log.Error这些接口,尽量做得跟Unity原生的Debug.Log用法接近,降低接入成本。往下一层是格式化层,负责给每条日志加上时间戳、日志等级、线程ID、调用堆栈这些元信息。再往下是缓冲队列层,日志先进内存队列,不直接碰磁盘。最底下是写入层,由一个后台线程或者协程负责把队列里的内容批量刷到文件,同时处理文件切分和清理。
这个分层的好处是每一层职责单一,出问题好定位。比如发现日志丢了,先看队列是不是满了,再看写入线程是不是卡住了,一层层排查很快。
2.4 同步写和异步写的取舍
这是整个设计里最关键的一个决策。同步写就是每次Log调用都直接写文件,实现简单,但性能极差。我实测过,在移动端每帧写几十条日志,帧率能从60掉到20以下,因为文件IO是阻塞操作。
异步写就是日志先入队,后台慢慢刷。好处是不阻塞主线程,坏处是有丢日志的风险——如果程序崩溃,队列里还没刷盘的内容就没了。我的做法是折中:普通日志走异步,但Error和Exception级别的日志强制同步刷盘。因为崩溃往往就发生在错误之后,这时候必须保证关键信息已经落盘。这个策略在实际项目里救过我好几次。
3. 核心细节拆解与实操要点
3.1 日志等级怎么划分才合理
Unity原生只有Log、Warning、Error、Exception几种,粒度太粗。我在项目里一般扩展成五级:Debug、Info、Warning、Error、Fatal。Debug只在开发期输出,发布版本自动屏蔽;Info记录正常的关键流程,比如"进入战斗场景""领取奖励成功";Warning是那些不影响运行但需要关注的情况;Error是明确的错误;Fatal是会导致崩溃的严重问题。
等级划分的意义在于发布版本可以动态调整输出级别。线上出问题时,如果能通过配置把级别临时调到Debug,就能拿到更详细的信息。我一般会留一个配置文件或者远程开关来控制这个级别,不用重新发版。
3.2 每条日志应该包含哪些信息
一条有用的日志,光有内容是不够的。我固定会加这几样:精确到毫秒的时间戳、日志等级、线程ID(多线程项目必备)、调用来源(哪个类哪个方法)。调用来源这块,可以用StackTrace自动抓,但要注意性能——抓堆栈是有开销的,所以只在Warning及以上级别才抓,Info和Debug就不抓了。
格式上我用的是这种:
[2024-01-15 14:23:45.123][INFO][MainThread][BattleManager] 战斗开始,敌方数量:5方括号分隔,人眼好读,程序也好解析。时间戳用yyyy-MM-dd HH:mm:ss.fff,排序和检索都方便。
3.3 文件切分策略:按天还是按大小
两种策略我都用过,最后的选择是按天切分为主,按大小为辅。按天切分的好处是文件命名清晰,log_2024-01-15.txt,找某天的日志一目了然。但如果某天日志量特别大,单个文件可能涨到几百兆,打开都费劲。所以我会再加一个大小上限,比如单文件超过10MB就切一个新文件,命名加个序号后缀。
具体实现上,每次写入前检查当前文件的大小和日期,满足切分条件就关闭当前文件、开新文件。这个检查不用每条日志都做,可以每写N条或者每隔几秒检查一次,减少开销。
3.4 日志清理:别让玩家的手机被撑爆
这是最容易被忽略的一点。日志文件如果只增不减,玩家玩上一个月,几个G的日志能把手机存储占满,然后就是各种莫名其妙的崩溃。我的清理策略是保留最近7天的日志,且总大小不超过50MB。超过就删最旧的。
清理时机放在程序启动时做一次,运行期间每天零点再做一次。删除的时候要注意,正在写入的文件不能删,得先判断文件是否被占用。另外,清理操作本身也要记一条日志,方便追溯。
提示:清理阈值不要设得太激进。我见过有项目只保留1天日志,结果玩家第二天来反馈问题,日志已经没了,白白浪费一次排查机会。7天是个比较稳妥的平衡点。
3.5 多线程安全怎么保证
Unity里虽然大部分逻辑在主线程,但网络回调、文件下载这些往往在子线程。如果多个线程同时往队列里塞日志,不加锁就会出问题。我的做法是用一个ConcurrentQueue或者自己加lock的普通队列。ConcurrentQueue用起来省心,但在某些老版本的运行时上性能一般,所以如果日志量特别大,我会用lock加List自己实现一个简单的环形缓冲。
写入端只有一个线程在消费队列,所以写入本身不存在竞争。关键是入队和出队之间的同步要做好。
4. 完整实操:从零搭一个能用的日志系统
4.1 定义日志等级和配置
先定义等级枚举和配置类。配置类里放输出级别、文件路径、单文件大小上限、保留天数、总量上限这些参数。
public enum LogLevel { Debug = 0, Info = 1, Warning = 2, Error = 3, Fatal = 4 } [Serializable] public class LogConfig { public LogLevel minLevel = LogLevel.Info; public string fileNamePrefix = "game"; public int maxFileSizeKB = 10240; // 单文件10MB public int retainDays = 7; public int maxTotalSizeMB = 50; }minLevel设成Info意味着Debug级别的日志在发布版本里不会输出。开发期可以把它改成Debug。
4.2 日志核心类的实现
核心类负责对外提供接口、格式化日志、入队。这里给出关键部分。
public static class GameLog { private static LogConfig _config; private static ConcurrentQueue<string> _queue = new ConcurrentQueue<string>(); private static string _logDir; public static void Init(LogConfig config) { _config = config; _logDir = Path.Combine(Application.persistentDataPath, "Logs"); if (!Directory.Exists(_logDir)) Directory.CreateDirectory(_logDir); LogWriter.Start(_logDir, _config, _queue); } public static void Info(string tag, string message) { Write(LogLevel.Info, 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 < _config.minLevel) return; string time = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); string threadId = Thread.CurrentThread.ManagedThreadId.ToString(); string line = $"[{time}][{level}][T{threadId}][{tag}] {message}"; if (level >= LogLevel.Error) { // 错误级别抓堆栈,且同步落盘 line += "\n" + StackTraceUtility.ExtractStackTrace(); LogWriter.WriteSync(line); } else { _queue.Enqueue(line); } } }这里有个细节:Application.persistentDataPath在不同平台指向不同目录,但都是可读写且不会被系统随意清理的位置,适合放日志。不要用StreamingAssets,那个目录在移动端是只读的。
4.3 写入线程与文件管理
写入端用一个独立线程循环消费队列,同时负责文件切分和清理。
public static class LogWriter { private static ConcurrentQueue<string> _queue; private static LogConfig _config; private static string _dir; private static string _currentFile; private static StreamWriter _writer; private static long _currentSize; private static DateTime _currentDate; private static readonly object _fileLock = new object(); public static void Start(string dir, LogConfig config, ConcurrentQueue<string> queue) { _dir = dir; _config = config; _queue = queue; _currentDate = DateTime.Now.Date; OpenNewFile(); Thread t = new Thread(Loop) { IsBackground = true }; t.Start(); } private static void Loop() { while (true) { if (_queue.TryDequeue(out string line)) { WriteSync(line); } else { Thread.Sleep(50); } } } public static void WriteSync(string line) { lock (_fileLock) { CheckRotate(); _writer.WriteLine(line); _writer.Flush(); _currentSize += line.Length; } } private static void CheckRotate() { bool dateChanged = DateTime.Now.Date != _currentDate; bool sizeExceeded = _currentSize > _config.maxFileSizeKB * 1024L; if (dateChanged || sizeExceeded) { _writer.Close(); _currentDate = DateTime.Now.Date; OpenNewFile(); } } private static void OpenNewFile() { string name = $"{_config.fileNamePrefix}_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log"; _currentFile = Path.Combine(_dir, name); _writer = new StreamWriter(_currentFile, true, Encoding.UTF8); _writer.AutoFlush = false; _currentSize = 0; } }Flush的调用频率是个权衡点。每条都Flush最安全但最慢,攒一批再Flush快但可能丢。我这里的做法是每条都刷,因为日志量本身不大,实测对帧率影响可以忽略。如果你的项目日志量极大,可以改成每10条或者每200毫秒刷一次。
4.4 启动时清理旧日志
清理逻辑放在Init里调用一次。
private static void CleanOldLogs() { var files = new DirectoryInfo(_logDir).GetFiles("*.log") .OrderByDescending(f => f.LastWriteTime).ToList(); DateTime deadline = DateTime.Now.AddDays(-_config.retainDays); long totalSize = 0; long maxSize = _config.maxTotalSizeMB * 1024L * 1024L; foreach (var f in files) { totalSize += f.Length; bool tooOld = f.LastWriteTime < deadline; bool tooBig = totalSize > maxSize; if (tooOld || tooBig) { try { f.Delete(); } catch { /* 文件被占用,跳过 */ } } } }注意try-catch那块,正在写入的文件删不掉会抛异常,直接忽略就行,下次启动再处理。
4.5 在业务代码里怎么用
接入很简单,在游戏启动的入口初始化一次,之后业务代码里直接调。
void Awake() { var config = new LogConfig { minLevel = LogLevel.Info }; GameLog.Init(config); GameLog.Info("Boot", "游戏启动完成"); } void OnBattleStart(int enemyCount) { GameLog.Info("Battle", $"战斗开始,敌方数量:{enemyCount}"); } void OnRewardFailed(string reason) { GameLog.Error("Reward", $"奖励发放失败:{reason}"); }tag参数用模块名,这样日志文件里按模块检索很方便。比如想看所有奖励相关的问题,搜[Reward]就行。
5. 常见问题与排查技巧实录
5.1 日志文件在哪,怎么让玩家发给你
这是最实际的问题。日志存在persistentDataPath下,但玩家不知道这个路径。我的做法是在游戏设置界面加一个"导出日志"按钮,点击后把日志目录打包成一个zip,然后调用系统分享接口让玩家发到指定渠道。如果不想做这么复杂,至少要在设置界面把日志路径显示出来,告诉玩家怎么找。
不同平台的路径大概是这样的:
| 平台 | 路径特征 |
|---|---|
| Windows | C:\Users\用户名\AppData\LocalLow\公司名\产品名\Logs |
| Android | /storage/emulated/0/Android/data/包名/files/Logs |
| iOS | 应用沙盒的Documents目录下 |
注意:安卓11以后对
Android/data目录的访问有限制,普通文件管理器可能进不去。所以导出功能最好在应用内实现,别指望玩家自己找。
5.2 日志写入导致卡顿怎么办
如果发现加了日志系统后帧率下降,先确认是不是每条日志都在抓堆栈。StackTrace的开销比想象中大,Info级别千万别抓。其次看Flush频率,改成批量刷。再不行就把写入线程的优先级调低,让它别跟主线程抢CPU。
还有一个隐蔽的坑:字符串拼接。$"..."这种插值在日志被屏蔽的情况下也会执行拼接,白白浪费性能。正确做法是先判断等级再拼接:
if (GameLog.IsEnabled(LogLevel.Debug)) GameLog.Debug("Tag", $"复杂对象:{Serialize(obj)}");5.3 崩溃时日志丢失怎么破
程序崩溃时,队列里没刷盘的内容会丢。解决办法就是前面说的,Error和Fatal级别强制同步写。另外可以注册Application.logMessageReceived回调,把Unity自己捕获的异常也导进我们的日志系统。这样即使某处代码没写try-catch,异常信息也能落盘。
void OnEnable() { Application.logMessageReceived += HandleUnityLog; } void HandleUnityLog(string condition, string stackTrace, LogType type) { if (type == LogType.Exception || type == LogType.Error) { GameLog.Error("Unity", condition + "\n" + stackTrace); } }5.4 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 日志文件是空的 | 写入线程没启动或队列没消费 | 检查Init是否调用、线程是否存活 |
| 日志乱码 | 编码不一致 | 统一用UTF-8,写入时指定Encoding |
| 文件删不掉 | 被写入流占用 | 关闭流再删,或跳过等下次 |
| 日志时间不对 | 用了UTC时间 | 改用DateTime.Now本地时间 |
| 发布版本没日志 | minLevel设太高 | 检查配置,确认Info级别被允许 |
| 日志量暴涨 | 循环里打日志 | 排查高频调用点,加频率限制 |
5.5 几个我踩过的坑
第一个坑是在析构函数里打日志。程序退出时对象析构顺序不确定,日志系统可能已经关了,这时候调用会抛异常,反而导致退出崩溃。解决办法是在OnApplicationQuit里主动关闭日志系统,之后不再接受写入。
第二个坑是日志文件被多个进程同时写。有些项目会开多个实例,或者有独立的崩溃上报进程,同时写一个文件会互相覆盖。解决办法是文件名里带上进程ID或者用文件锁。
第三个坑是移动端存储权限。安卓上如果没申请存储权限,写persistentDataPath虽然一般没问题,但导出到公共目录就会失败。导出功能要做好权限检查和失败提示。
第四个坑是日志内容包含敏感信息。玩家昵称、账号ID这些如果直接写进日志,导出后可能泄露隐私。我一般会在写入前做一层脱敏,把敏感字段替换成哈希值或者掩码。
6. 进阶:让日志系统更好用
6.1 加一个日志查看器
如果能在游戏内直接查看日志,调试效率会高很多。我做过一个简单的查看器:一个滚动列表,实时显示最近的日志,支持按等级过滤。实现上就是维护一个内存里的环形缓冲,最多存500条,写入时同步更新UI。这个功能在测试阶段特别有用,测试同学不用连电脑就能看到报错。
6.2 对接崩溃上报
日志系统和崩溃上报是天然的一对。崩溃发生时,把最近的日志(比如最后100条)一起上报,能极大提升定位效率。实现方式是在内存里保留一个最近日志的环形缓冲,崩溃回调触发时把缓冲内容序列化后一起发送。
6.3 远程调整日志级别
线上出问题时,如果能不改包就调整日志级别,价值巨大。做法是在启动时拉一个远程配置,里面包含日志级别。甚至可以做到针对特定玩家开启详细日志,其他玩家保持正常级别,这样既能排查问题又不影响整体性能。
6.4 性能数据参考
我在一台中端安卓机上做过测试,配置是Info级别、每条都Flush、单线程写入。结果是每秒写入1000条日志时,主线程帧率基本无影响,写入线程CPU占用约3%。每秒10000条时开始出现明显卡顿,主要是字符串拼接和IO的开销。所以正常项目的日志量完全不用担心性能,只要别在Update里无脑打日志就行。
7. 一些收尾的经验之谈
日志系统这东西,平时感觉不到它的存在,出问题的时候才知道它有多重要。我的建议是项目一开始就把这套东西搭好,别等到上线了才想起来补。搭的时候不用追求大而全,先把"能记录、能落盘、能取回"这三件事做好,后面再慢慢加功能。
另外,日志的内容比格式更重要。与其纠结时间戳精确到毫秒还是微秒,不如想想每条日志有没有把关键信息说清楚。我见过太多日志写着"操作失败"却不说是哪个操作、为什么失败,这种日志有等于没有。写日志的时候多问自己一句:如果三个月后的我看到这条日志,能不能还原出当时发生了什么?能,这条日志就合格了。
最后分享一个小习惯:每次修完一个线上bug,回头看看日志系统有没有帮上忙。如果没帮上,想想缺了什么信息,下次补上。日志系统就是这样一点点打磨出来的,没有一步到位的方案,只有不断迭代的积累。