Merge remote-tracking branch 'origin/feature/日志系统翻新' into feature/GameManager相关重构
This commit is contained in:
@@ -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.
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -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` 才是长期留档重点。
|
||||
|
||||
Reference in New Issue
Block a user