Merge remote-tracking branch 'origin/feature/日志系统翻新' into feature/GameManager相关重构

This commit is contained in:
2026-07-18 17:33:31 +08:00
8 changed files with 926 additions and 106 deletions
@@ -244,3 +244,14 @@ MonoBehaviour:
m_PreInfinity: 0
m_PostInfinity: 0
m_RotationOrder: 0
- m_From: '**ANY CAMERA**'
m_To: Dream Camera
m_Blend:
m_Style: 0
m_Time: 2
m_CustomCurve:
serializedVersion: 2
m_Curve: []
m_PreInfinity: 0
m_PostInfinity: 0
m_RotationOrder: 0
Binary file not shown.
Binary file not shown.
+231 -78
View File
@@ -1,107 +1,260 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
namespace AibisDream.Kit
{
internal class FileLogger
internal sealed class FileLogger
{
private StreamWriter _fileWriter;
private readonly ConcurrentQueue<LogEntry> _queue = new();
private readonly AutoResetEvent _wakeEvent = new(false);
private readonly ManualResetEventSlim _drainedEvent = new(true);
private readonly List<LogEntry> _batch = new();
private readonly string _directory;
private readonly LogSettings _settings;
private readonly Thread _worker;
private readonly object _writerLock = new();
private readonly string _filename;
private readonly string _filePath;
private readonly int _saveDays;
private volatile bool _isRunning = true;
private StreamWriter _writer;
private string _currentFilePath;
private long _currentFileBytes;
private int _queuedCount;
private int _droppedLowPriorityCount;
private static string CurrentDay => DateTime.Now.ToString("yyyyMMdd");
private string _currentLogDay;
private string _preSavePath;
public string CurrentFilePath => _currentFilePath;
/// <summary>
/// 开始打印
/// </summary>
public FileLogger(string filename, string filePath, int saveDays, string version = null)
public FileLogger(string directory, LogSettings settings)
{
_filename = filename;
_filePath = filePath.TrimEnd('/') + "/";
_saveDays = saveDays;
StartNewFile();
DeleteOldFile();
_currentLogDay = CurrentDay;
// 在日志开头写入版本号信息
if (!string.IsNullOrEmpty(version))
_directory = directory;
_settings = settings ?? LogSettings.CreateDefault();
Directory.CreateDirectory(_directory);
DeleteOldFiles();
OpenNewFile();
_worker = new Thread(WriterLoop)
{
Write($"Version: {version}\n");
}
Write("*********************************************************\nSystem Start at " +
DateTime.Now.ToString("HH:mm:ss") +
"\n*********************************************************\n");
}
/// <summary>
/// 新建文件
/// </summary>
private void StartNewFile()
{
if (_fileWriter != null)
{
_fileWriter.Close();
_fileWriter.Dispose();
}
_fileWriter = null;
if (!Directory.Exists(_filePath))
{
Directory.CreateDirectory(_filePath);
}
_fileWriter = File.AppendText(_filePath + string.Format(_filename, DateTime.Now));
_fileWriter.AutoFlush = true; // 启用自动刷新,实现流式写入
IsBackground = true,
Name = "AIBIS File Logger"
};
_worker.Start();
}
public void Write(string content)
{
System.Globalization.CultureInfo.CurrentCulture.ClearCachedData();
if (_currentLogDay != CurrentDay)
{
StartNewFile();
DeleteOldFile();
_currentLogDay = CurrentDay;
}
if (_fileWriter is { BaseStream: { CanWrite: true } })
_fileWriter.WriteLine(content);
Write(LogEntry.System(LogLevel.Info, LogCategory.General, content));
}
void DeleteOldFile()
public void Write(LogEntry entry)
{
var fileList = Directory.GetFiles(_filePath);
if (fileList.Length > _saveDays)
if (!_isRunning || !_settings.ShouldWrite(entry.Level))
return;
if (TryEnqueue(entry))
{
_preSavePath = _filePath + string.Format(this._filename, DateTime.Now.AddDays(-_saveDays + 1));
foreach (var t in fileList)
{
if (string.CompareOrdinal(_preSavePath, t) == 1)
{
File.Delete(t);
}
}
if (entry.Level >= LogLevel.Error)
_wakeEvent.Set();
return;
}
if (entry.Level < LogLevel.Error)
Interlocked.Increment(ref _droppedLowPriorityCount);
}
public void Flush(TimeSpan timeout)
{
_wakeEvent.Set();
_drainedEvent.Wait(timeout);
lock (_writerLock)
{
_writer?.Flush();
}
}
public void OnDestroy()
{
if (_fileWriter == null)
_isRunning = false;
_wakeEvent.Set();
if (!_worker.Join(_settings.ShutdownDrainMilliseconds))
Flush(TimeSpan.FromMilliseconds(_settings.ShutdownDrainMilliseconds));
lock (_writerLock)
{
_writer?.Flush();
_writer?.Dispose();
_writer = null;
}
if (!_worker.IsAlive)
{
_wakeEvent.Dispose();
_drainedEvent.Dispose();
}
}
private bool TryEnqueue(LogEntry entry)
{
while (true)
{
var current = Volatile.Read(ref _queuedCount);
if (current >= _settings.QueueCapacity && entry.Level < LogLevel.Error)
return false;
if (Interlocked.CompareExchange(ref _queuedCount, current + 1, current) == current)
break;
}
_drainedEvent.Reset();
_queue.Enqueue(entry);
_wakeEvent.Set();
return true;
}
private void WriterLoop()
{
while (_isRunning || !_queue.IsEmpty)
{
_wakeEvent.WaitOne(_settings.FlushIntervalMilliseconds);
DrainQueue();
}
DrainQueue();
}
private void DrainQueue()
{
_batch.Clear();
while (_batch.Count < _settings.BatchSize && _queue.TryDequeue(out var entry))
{
Interlocked.Decrement(ref _queuedCount);
_batch.Add(entry);
}
var dropped = Interlocked.Exchange(ref _droppedLowPriorityCount, 0);
if (dropped > 0)
{
_batch.Add(LogEntry.System(
LogLevel.Warning,
LogCategory.General,
$"Dropped {dropped} low-priority log entries because the queue was full."));
}
if (_batch.Count == 0)
{
if (_queue.IsEmpty)
_drainedEvent.Set();
return;
}
lock (_writerLock)
{
foreach (var entry in _batch)
{
WriteEntry(entry);
}
_writer?.Flush();
}
if (!_queue.IsEmpty)
_wakeEvent.Set();
if (_queue.IsEmpty)
_drainedEvent.Set();
}
private void WriteEntry(LogEntry entry)
{
try
{
var line = LogFormatter.Format(entry);
var byteCount = Encoding.UTF8.GetByteCount(line) + Environment.NewLine.Length;
if (_currentFileBytes + byteCount > _settings.MaxFileBytes)
OpenNewFile();
_writer.WriteLine(line);
_currentFileBytes += byteCount;
}
catch
{
_isRunning = false;
}
}
private void OpenNewFile()
{
_writer?.Flush();
_writer?.Dispose();
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var platform = _settings.Platform;
var sessionId = _settings.SessionId;
var baseFileName = $"{timestamp}_{platform}_v{_settings.AppVersion}_session-{sessionId}";
_currentFilePath = BuildUniquePath(baseFileName);
_writer = new StreamWriter(new FileStream(_currentFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read), Encoding.UTF8)
{
AutoFlush = false
};
_currentFileBytes = 0;
DeleteOldFiles();
}
private void DeleteOldFiles()
{
var directory = new DirectoryInfo(_directory);
if (!directory.Exists)
return;
Write("*********************************************************\nSystem Shutdown at " +
DateTime.Now.ToString("HH:mm:ss") +
"\n*********************************************************\n");
var files = directory.GetFiles("*.log");
Array.Sort(files, (a, b) => b.LastWriteTimeUtc.CompareTo(a.LastWriteTimeUtc));
_fileWriter.Flush();
_fileWriter.Close();
_fileWriter = null;
long totalBytes = 0;
for (var i = 0; i < files.Length; i++)
{
totalBytes += files[i].Length;
var shouldDelete = i >= _settings.MaxSessionFiles || totalBytes > _settings.MaxTotalBytes;
if (!shouldDelete)
continue;
try
{
files[i].Delete();
}
catch
{
// Best-effort cleanup only.
}
}
}
private static string SanitizeFileName(string fileName)
{
foreach (var invalid in Path.GetInvalidFileNameChars())
{
fileName = fileName.Replace(invalid, '_');
}
return fileName;
}
private string BuildUniquePath(string baseFileName)
{
var safeBase = SanitizeFileName(baseFileName);
var path = Path.Combine(_directory, safeBase + ".log");
var index = 1;
while (File.Exists(path))
{
path = Path.Combine(_directory, $"{safeBase}_{index}.log");
index++;
}
return path;
}
}
}
}
+362 -24
View File
@@ -1,54 +1,88 @@
using System;
using UnityEngine;
using System.Text;
using System.Threading;
using AibisDream.Utility;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace AibisDream.Kit
{
/// <summary>
/// 真机文件日志:拦截 <see cref="Debug"/> 输出并写入 <see cref="ConstRef.LogFilePath"/>。
/// Editor 与 Android 不启用。
/// Runtime file logging entry point. Captures Unity logs and routes them through
/// an asynchronous file writer so mobile builds do not block on every log line.
/// </summary>
internal static class LogKit
{
/// <summary>
/// 文件名称格式
/// 请注意,不要删除时间格式,否则会造成保存不成功
/// </summary>
public static string LogFileName => "Log{0:_yyyy_MM_dd}.txt";
/// <summary>
/// 日志保存最近几天的内容
/// </summary>
public const int SaveDays = 30;
/// <summary>
/// 每一行的打印内容
/// </summary>
private static string LogContent => Time + ": {0}\n{1}";
private static FileLogger _fileLogger;
private static LogSettings _settings;
private static bool _isInitialized;
private static bool _isForwardingToUnity;
private static string Time => DateTime.Now.ToString("[HH:mm:ss.fffd]");
internal static bool IsInitialized => _isInitialized;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize()
{
#if !UNITY_EDITOR && !UNITY_ANDROID
if (_isInitialized)
return;
_fileLogger = new FileLogger(LogFileName, ConstRef.LogFilePath, SaveDays);
LogMessage($"Ver.{Application.version}", "", LogType.Log);
_settings = LogSettings.CreateDefault();
if (!_settings.Enabled)
return;
LogEntry.SetMainThreadId(Thread.CurrentThread.ManagedThreadId);
_fileLogger = new FileLogger(ConstRef.LogFilePath, _settings);
_fileLogger.Write(LogEntry.System(LogLevel.Info, LogCategory.General, BuildStartupHeader()));
Application.logMessageReceivedThreaded += LogMessage;
Application.quitting += Shutdown;
Application.focusChanged += OnFocusChanged;
_isInitialized = true;
#endif
}
internal static void Write(LogEntry entry)
{
if (!_isInitialized || _fileLogger == null)
return;
_fileLogger.Write(entry);
}
internal static void ForwardToUnity(Action logAction)
{
if (logAction == null)
return;
_isForwardingToUnity = true;
try
{
logAction.Invoke();
}
finally
{
_isForwardingToUnity = false;
}
}
internal static bool ShouldWriteToConsole(LogLevel level)
{
return _settings == null || _settings.EchoToConsole && level >= _settings.ConsoleMinLevel;
}
private static void LogMessage(string condition, string stackTrace, LogType type)
{
_fileLogger?.Write(string.Format(LogContent, condition, stackTrace));
if (_isForwardingToUnity)
return;
Write(LogEntry.FromUnity(condition, stackTrace, type, _settings.ShouldIncludeStackTrace));
}
private static void OnFocusChanged(bool hasFocus)
{
if (!hasFocus)
_fileLogger?.Flush(TimeSpan.FromMilliseconds(_settings?.ShutdownDrainMilliseconds ?? 500));
}
private static void Shutdown()
@@ -58,9 +92,313 @@ namespace AibisDream.Kit
Application.logMessageReceivedThreaded -= LogMessage;
Application.quitting -= Shutdown;
Application.focusChanged -= OnFocusChanged;
_fileLogger?.Write(LogEntry.System(LogLevel.Info, LogCategory.General, "System shutdown."));
_fileLogger?.OnDestroy();
_fileLogger = null;
_isInitialized = false;
}
private static string BuildStartupHeader()
{
return string.Join(
" | ",
"System start",
$"version={Application.version}",
$"platform={Application.platform}",
$"unity={Application.unityVersion}",
$"device={SystemInfo.deviceModel}",
$"os={SystemInfo.operatingSystem}",
$"graphics={SystemInfo.graphicsDeviceName}",
$"maxTexture={SystemInfo.maxTextureSize}",
$"memory={SystemInfo.systemMemorySize}MB");
}
}
}
public enum LogLevel
{
Debug = 0,
Info = 1,
Warning = 2,
Error = 3,
Fatal = 4
}
public enum LogCategory
{
General,
Unity,
Save,
Yarn,
Resource,
Scene,
UI,
Audio,
FixSystem,
MiniGame
}
public static class GameLog
{
public static void Debug(LogCategory category, string message, string context = null)
{
Write(LogLevel.Debug, category, message, context, null);
}
public static void Info(LogCategory category, string message, string context = null)
{
Write(LogLevel.Info, category, message, context, null);
}
public static void Warn(LogCategory category, string message, string context = null)
{
Write(LogLevel.Warning, category, message, context, null);
}
public static void Error(LogCategory category, string message, string context = null)
{
Write(LogLevel.Error, category, message, context, null);
}
public static void Exception(LogCategory category, Exception exception, string context = null)
{
var message = exception == null ? "Unknown exception" : exception.Message;
Write(LogLevel.Error, category, message, context, exception);
}
private static void Write(LogLevel level, LogCategory category, string message, string context, Exception exception)
{
var entry = LogEntry.FromGame(level, category, message, context, exception);
LogKit.Write(entry);
if (!LogKit.ShouldWriteToConsole(level))
return;
var consoleMessage = $"[{category}] {message}";
if (!string.IsNullOrEmpty(context))
consoleMessage += $" | {context}";
LogKit.ForwardToUnity(() =>
{
if (exception != null)
{
UnityEngine.Debug.LogException(exception);
return;
}
switch (level)
{
case LogLevel.Warning:
UnityEngine.Debug.LogWarning(consoleMessage);
break;
case LogLevel.Error:
case LogLevel.Fatal:
UnityEngine.Debug.LogError(consoleMessage);
break;
default:
UnityEngine.Debug.Log(consoleMessage);
break;
}
});
}
}
internal sealed class LogSettings
{
public bool Enabled { get; private set; }
public bool EchoToConsole { get; private set; }
public LogLevel MinLevel { get; private set; }
public LogLevel ConsoleMinLevel { get; private set; }
public int QueueCapacity { get; private set; }
public int BatchSize { get; private set; }
public int FlushIntervalMilliseconds { get; private set; }
public int ShutdownDrainMilliseconds { get; private set; }
public long MaxFileBytes { get; private set; }
public long MaxTotalBytes { get; private set; }
public int MaxSessionFiles { get; private set; }
public bool IncludeWarningStackTrace { get; private set; }
public string SessionId { get; private set; }
public string AppVersion { get; private set; }
public string Platform { get; private set; }
public static string PlatformName
{
get
{
#if UNITY_ANDROID
return "android";
#elif UNITY_STANDALONE_WIN
return "windows";
#elif UNITY_STANDALONE_OSX
return "mac";
#elif UNITY_STANDALONE_LINUX
return "linux";
#else
return UnityEngine.Application.platform.ToString().ToLowerInvariant();
#endif
}
}
public bool ShouldWrite(LogLevel level)
{
return Enabled && level >= MinLevel;
}
public bool ShouldIncludeStackTrace(LogLevel level)
{
return level >= LogLevel.Error || IncludeWarningStackTrace && level == LogLevel.Warning;
}
public static LogSettings CreateDefault()
{
return Create(LogSettingsProfiles.Current);
}
private static LogSettings Create(LogSettingsProfile profile)
{
return new LogSettings
{
Enabled = profile.Enabled,
EchoToConsole = profile.EchoToConsole,
MinLevel = profile.MinLevel,
ConsoleMinLevel = profile.ConsoleMinLevel,
QueueCapacity = profile.QueueCapacity,
BatchSize = profile.BatchSize,
FlushIntervalMilliseconds = profile.FlushIntervalMilliseconds,
ShutdownDrainMilliseconds = profile.ShutdownDrainMilliseconds,
MaxFileBytes = profile.MaxFileBytes,
MaxTotalBytes = profile.MaxTotalBytes,
MaxSessionFiles = profile.MaxSessionFiles,
IncludeWarningStackTrace = profile.IncludeWarningStackTrace,
SessionId = Guid.NewGuid().ToString("N").Substring(0, 8),
AppVersion = UnityEngine.Application.version,
Platform = PlatformName
};
}
}
internal readonly struct LogEntry
{
private static int _mainThreadId;
public readonly DateTime TimestampUtc;
public readonly LogLevel Level;
public readonly LogCategory Category;
public readonly string Message;
public readonly string StackTrace;
public readonly string Context;
public readonly string SceneName;
public readonly int FrameCount;
public readonly int ThreadId;
private LogEntry(LogLevel level, LogCategory category, string message, string stackTrace, string context)
{
TimestampUtc = DateTime.UtcNow;
Level = level;
Category = category;
Message = message ?? string.Empty;
StackTrace = stackTrace ?? string.Empty;
Context = context ?? string.Empty;
var isMainThread = Thread.CurrentThread.ManagedThreadId == _mainThreadId;
SceneName = isMainThread ? GetSceneName() : string.Empty;
FrameCount = isMainThread ? Time.frameCount : -1;
ThreadId = Thread.CurrentThread.ManagedThreadId;
}
public static void SetMainThreadId(int threadId)
{
_mainThreadId = threadId;
}
public static LogEntry System(LogLevel level, LogCategory category, string message)
{
return new LogEntry(level, category, message, string.Empty, string.Empty);
}
public static LogEntry FromUnity(string condition, string stackTrace, LogType type, Func<LogLevel, bool> shouldIncludeStackTrace)
{
var level = ToLogLevel(type);
var stack = shouldIncludeStackTrace != null && shouldIncludeStackTrace(level) ? stackTrace : string.Empty;
return new LogEntry(level, LogCategory.Unity, condition, stack, string.Empty);
}
public static LogEntry FromGame(LogLevel level, LogCategory category, string message, string context, Exception exception)
{
var stack = exception == null ? string.Empty : exception.ToString();
return new LogEntry(level, category, message, stack, context);
}
private static LogLevel ToLogLevel(LogType type)
{
switch (type)
{
case LogType.Warning:
return LogLevel.Warning;
case LogType.Error:
return LogLevel.Error;
case LogType.Exception:
case LogType.Assert:
return LogLevel.Fatal;
default:
return LogLevel.Info;
}
}
private static string GetSceneName()
{
try
{
return SceneManager.GetActiveScene().name;
}
catch
{
return string.Empty;
}
}
}
internal static class LogFormatter
{
public static string Format(LogEntry entry)
{
var builder = new StringBuilder(256);
builder.Append(entry.TimestampUtc.ToString("O"));
builder.Append(" | ");
builder.Append(entry.Level);
builder.Append(" | ");
builder.Append(entry.Category);
builder.Append(" | scene=");
builder.Append(Escape(entry.SceneName));
builder.Append(" | frame=");
builder.Append(entry.FrameCount);
builder.Append(" | thread=");
builder.Append(entry.ThreadId);
if (!string.IsNullOrEmpty(entry.Context))
{
builder.Append(" | context=");
builder.Append(Escape(entry.Context));
}
builder.Append(" | ");
builder.Append(Escape(entry.Message));
if (!string.IsNullOrEmpty(entry.StackTrace))
{
builder.Append(" | stack=");
builder.Append(Escape(entry.StackTrace));
}
return builder.ToString();
}
private static string Escape(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return value.Replace("\r", "\\r").Replace("\n", "\\n");
}
}
}
@@ -0,0 +1,143 @@
namespace AibisDream.Kit
{
internal readonly struct LogSettingsProfile
{
public readonly bool Enabled;
public readonly bool EchoToConsole;
public readonly LogLevel MinLevel;
public readonly LogLevel ConsoleMinLevel;
public readonly int QueueCapacity;
public readonly int BatchSize;
public readonly int FlushIntervalMilliseconds;
public readonly int ShutdownDrainMilliseconds;
public readonly long MaxFileBytes;
public readonly long MaxTotalBytes;
public readonly int MaxSessionFiles;
public readonly bool IncludeWarningStackTrace;
public LogSettingsProfile(
bool enabled,
bool echoToConsole,
LogLevel minLevel,
LogLevel consoleMinLevel,
int queueCapacity,
int batchSize,
int flushIntervalMilliseconds,
int shutdownDrainMilliseconds,
long maxFileBytes,
long maxTotalBytes,
int maxSessionFiles,
bool includeWarningStackTrace)
{
Enabled = enabled;
EchoToConsole = echoToConsole;
MinLevel = minLevel;
ConsoleMinLevel = consoleMinLevel;
QueueCapacity = queueCapacity;
BatchSize = batchSize;
FlushIntervalMilliseconds = flushIntervalMilliseconds;
ShutdownDrainMilliseconds = shutdownDrainMilliseconds;
MaxFileBytes = maxFileBytes;
MaxTotalBytes = maxTotalBytes;
MaxSessionFiles = maxSessionFiles;
IncludeWarningStackTrace = includeWarningStackTrace;
}
}
internal static class LogSettingsProfiles
{
private const long Megabyte = 1024L * 1024L;
public static LogSettingsProfile Current
{
get
{
#if UNITY_EDITOR
return Editor;
#elif UNITY_ANDROID
#if DEVELOPMENT_BUILD
return AndroidDevelopment;
#else
return AndroidRelease;
#endif
#else
#if DEVELOPMENT_BUILD
return StandaloneDevelopment;
#else
return StandaloneRelease;
#endif
#endif
}
}
private static LogSettingsProfile Editor => new LogSettingsProfile(
enabled: false,
echoToConsole: true,
minLevel: LogLevel.Info,
consoleMinLevel: LogLevel.Debug,
queueCapacity: 2048,
batchSize: 128,
flushIntervalMilliseconds: 1000,
shutdownDrainMilliseconds: 1000,
maxFileBytes: 10L * Megabyte,
maxTotalBytes: 100L * Megabyte,
maxSessionFiles: 20,
includeWarningStackTrace: false);
private static LogSettingsProfile AndroidDevelopment => new LogSettingsProfile(
enabled: true,
echoToConsole: true,
minLevel: LogLevel.Info,
consoleMinLevel: LogLevel.Debug,
queueCapacity: 1024,
batchSize: 64,
flushIntervalMilliseconds: 1500,
shutdownDrainMilliseconds: 1000,
maxFileBytes: 5L * Megabyte,
maxTotalBytes: 30L * Megabyte,
maxSessionFiles: 6,
includeWarningStackTrace: false);
private static LogSettingsProfile AndroidRelease => new LogSettingsProfile(
enabled: true,
echoToConsole: true,
minLevel: LogLevel.Warning,
consoleMinLevel: LogLevel.Warning,
queueCapacity: 1024,
batchSize: 64,
flushIntervalMilliseconds: 1500,
shutdownDrainMilliseconds: 1000,
maxFileBytes: 5L * Megabyte,
maxTotalBytes: 30L * Megabyte,
maxSessionFiles: 6,
includeWarningStackTrace: false);
private static LogSettingsProfile StandaloneDevelopment => new LogSettingsProfile(
enabled: true,
echoToConsole: true,
minLevel: LogLevel.Info,
consoleMinLevel: LogLevel.Debug,
queueCapacity: 2048,
batchSize: 128,
flushIntervalMilliseconds: 1000,
shutdownDrainMilliseconds: 1000,
maxFileBytes: 10L * Megabyte,
maxTotalBytes: 100L * Megabyte,
maxSessionFiles: 20,
includeWarningStackTrace: false);
private static LogSettingsProfile StandaloneRelease => new LogSettingsProfile(
enabled: true,
echoToConsole: true,
minLevel: LogLevel.Info,
consoleMinLevel: LogLevel.Info,
queueCapacity: 2048,
batchSize: 128,
flushIntervalMilliseconds: 1000,
shutdownDrainMilliseconds: 1000,
maxFileBytes: 10L * Megabyte,
maxTotalBytes: 100L * Megabyte,
maxSessionFiles: 20,
includeWarningStackTrace: false);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a8f67c393a6748d3aa8c6fd4a05d34a1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+164
View File
@@ -0,0 +1,164 @@
# 日志系统改进留档
## 背景与目的
旧日志系统直接订阅 Unity 日志回调,把 `Debug.*` 输出同步写入文件。这个实现有几个明显问题:
- 每条日志同步 `WriteLine`,并且 `AutoFlush = true`,移动端写盘成本容易造成卡顿。
- Android 因性能问题被编译条件直接禁用文件日志,真机问题缺少可追踪依据。
- 日志格式只接近“控制台文本转储”,缺少等级、分类、场景、帧号、线程、会话等排查信息。
- 文件按天追加,同一天多次启动会混在一起,不利于定位一次具体测试或玩家反馈。
- `Application.logMessageReceivedThreaded` 可能从多线程进入,但旧实现直接操作同一个 `StreamWriter`,线程安全不足。
本次改进目标是建立一套可以在 PC 与 Android 都安全启用的轻量文件日志系统:降低写盘卡顿风险,保留 Unity 日志兼容入口,并为后续业务结构化日志打基础。
## 已完成内容
核心代码位于 `Assets/Scripts/Framework/LogKit/`
- `LogKit.cs`
- 保留运行时自动初始化入口。
- 继续订阅 `Application.logMessageReceivedThreaded`,捕获旧有 `Debug.Log/Warning/Error`
- 新增 `GameLog` 业务日志入口,支持按 `LogCategory` 分类输出。
- 新增 `LogLevel``LogCategory``LogEntry``LogFormatter`,把日志记录转换为结构化文本行。
- 应用失焦和退出时触发 flush,降低日志丢失概率。
- `FileLogger.cs`
- 从同步写文件改为线程安全队列 + 后台线程批量写入。
- 关闭每条日志 `AutoFlush`,改为批量 flush。
- 队列满时丢弃低优先级日志,并记录 dropped count。
- `Error` 及以上等级优先保留。
- 按 session 创建日志文件,并支持大小轮转、总大小/文件数量保留。
- 日志写入器内部异常静默降级,避免日志系统自身递归刷错误。
- `LogSettingsProfiles.cs`
- 将平台配置从 `LogKit.cs` 中抽离为轻量 C# profile。
- 不使用 JSON、StreamingAssets 或 ScriptableObject,避免额外资源加载和 Unity asset 依赖。
- 当前 profile 包括 `Editor``AndroidDevelopment``AndroidRelease``StandaloneDevelopment``StandaloneRelease`
## 日志文件与格式
日志目录仍使用 `ConstRef.LogFilePath`,即 `Application.persistentDataPath/LogFile`
文件按启动会话生成,命名格式类似:
```text
20260708_153012_android_v1.2.0_session-abcd1234.log
```
单行日志包含:
```text
timestamp | level | category | scene | frame | thread | context | message | stack
```
说明:
- `timestamp` 使用 UTC ISO 格式。
- `category` 可区分 Unity 捕获日志和业务系统日志。
- 非主线程日志不会读取 Unity 场景和帧号,避免在线程回调中访问 Unity 主线程 API。
- 普通日志默认不写堆栈;`Error/Exception/Assert` 写入堆栈。
## 平台默认配置
当前配置集中在 `LogSettingsProfiles.cs`
### Editor
- 默认不写文件日志。
- 仍保留 Unity Console 的正常输出。
### Android Development
- 文件日志启用。
- 最低等级:`Info`
- 队列容量:`1024`
- 批量写入:`64` 条。
- Flush 间隔:`1500ms`
- 单文件上限:`5MB`
- 总保留上限:`30MB`
- Session 文件保留:`6` 个。
### Android Release
- 文件日志启用。
- 最低等级:`Warning`
- Console 最低等级:`Warning`
- 其余写入参数与 Android Development 保持一致。
### Standalone Development / Release
- 文件日志启用。
- Development 最低等级:`Info`Console 最低等级:`Debug`
- Release 最低等级:`Info`Console 最低等级:`Info`
- 队列容量:`2048`
- 批量写入:`128` 条。
- Flush 间隔:`1000ms`
- 单文件上限:`10MB`
- 总保留上限:`100MB`
- Session 文件保留:`20` 个。
## 如何使用
旧代码中的 `Debug.Log/Warning/Error` 不需要立刻迁移,仍会被捕获到文件日志。
新代码或关键链路建议使用 `GameLog`
```csharp
GameLog.Info(LogCategory.Save, "Auto save completed", "slot=0");
GameLog.Warn(LogCategory.Resource, "Addressable load slow", "key=Scene/ClinicOut");
GameLog.Error(LogCategory.Yarn, "Node missing", "node=Start");
```
优先迁移高价值排查链路,而不是一次性替换全项目日志:
- 存档/读档:`SaveRestoreOrchestrator``SaveSystem`
- 对话:`DialogController`、Yarn node 进入/退出
- 资源:`ResourceSystem``SceneResourceLoader`
- 场景:`SceneLoader`
- 维修系统入口:`FixSystemCenter`
## 已验证内容
本次实现后已执行:
```bash
git diff --check -- Assembly-CSharp.csproj Assets/Scripts/Framework/LogKit
dotnet build Assembly-CSharp.csproj --no-restore
```
结果:
- 空白检查通过。
- `Assembly-CSharp.csproj` 构建通过,0 个错误。
- 构建中的 warning 为项目既有 warning,与日志系统改动无关。
## 仍需补足
后续建议按优先级补足以下内容:
1. Android 真机压测
- 在 Android Development build 中压测高频 `Debug.Log`
- 对比启用日志前后的帧率和卡顿尖刺。
- 验证切后台、返回、退出时日志能正常 flush。
2. 关键业务链路迁移到 `GameLog`
- 先迁移 Save、Resource、Dialog、Scene 这些排查价值最高的系统。
- 不建议批量替换所有旧 `Debug.Log`,避免制造无意义 diff。
3. 重复日志限流
- 当前已有队列满时低等级丢弃机制。
- 仍可补充同一 message 短时间重复出现时的合并策略,减少高频系统刷屏。
4. 日志导出入口
- 增加获取当前日志目录或当前 session 日志路径的 API。
- 后续可接入设置界面或测试菜单,方便测试人员导出日志。
5. 配置开关扩展
- 当前 profile 是代码内固定配置。
- 如果未来测试需要临时打开 Release `Info` 日志,可考虑增加启动参数、调试菜单或本地轻量覆盖开关。
6. Unity 工程文件同步
- 本次为了命令行构建验证,手动把 `LogSettingsProfiles.cs` 加入了 `Assembly-CSharp.csproj`
- Unity 重新生成工程文件时可能覆盖 `.csproj`,这是 Unity 生成文件的正常行为;源码和 `.meta` 才是长期留档重点。