diff --git a/Assets/CameraData/Main Camera Blends.asset b/Assets/CameraData/Main Camera Blends.asset index af8cc3079..79429ade6 100644 --- a/Assets/CameraData/Main Camera Blends.asset +++ b/Assets/CameraData/Main Camera Blends.asset @@ -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 diff --git a/Assets/Font/Assets/ChillBitmap_16px SDF.asset b/Assets/Font/Assets/ChillBitmap_16px SDF.asset index 9e08b358c..b09bfd888 100644 --- a/Assets/Font/Assets/ChillBitmap_16px SDF.asset +++ b/Assets/Font/Assets/ChillBitmap_16px SDF.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f7b60b463f038cc41cd5105bcf00f29e4018f4720d98b44203f7b28aaa88e2c -size 68983050 +oid sha256:170c0afe6570a4dbf24dd9e83b44510413ac2d9e61b47962383b2217b637113f +size 18639284 diff --git a/Assets/Font/Assets/WenQuanYi Bitmap Song 14px SDF.asset b/Assets/Font/Assets/WenQuanYi Bitmap Song 14px SDF.asset index 0b5453247..9ad3e167c 100644 --- a/Assets/Font/Assets/WenQuanYi Bitmap Song 14px SDF.asset +++ b/Assets/Font/Assets/WenQuanYi Bitmap Song 14px SDF.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0dda62cf1d5d85b9e7f4da321263cdc540466620ee803ccc9bcac5e8ada4c5b5 -size 69086225 +oid sha256:b79cb79daf452cc55e371ddbbf1d40503926e494577e2e69af8974334a9483fc +size 10242249 diff --git a/Assets/Scripts/Framework/LogKit/FileLogger.cs b/Assets/Scripts/Framework/LogKit/FileLogger.cs index 0928211e3..b47048ba9 100644 --- a/Assets/Scripts/Framework/LogKit/FileLogger.cs +++ b/Assets/Scripts/Framework/LogKit/FileLogger.cs @@ -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 _queue = new(); + private readonly AutoResetEvent _wakeEvent = new(false); + private readonly ManualResetEventSlim _drainedEvent = new(true); + private readonly List _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; - /// - /// 开始打印 - /// - 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"); - } - - /// - /// 新建文件 - /// - 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; } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Framework/LogKit/LogKit.cs b/Assets/Scripts/Framework/LogKit/LogKit.cs index 2d2006493..386b157f9 100644 --- a/Assets/Scripts/Framework/LogKit/LogKit.cs +++ b/Assets/Scripts/Framework/LogKit/LogKit.cs @@ -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 { /// - /// 真机文件日志:拦截 输出并写入 。 - /// 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. /// internal static class LogKit { - /// - /// 文件名称格式 - /// 请注意,不要删除时间格式,否则会造成保存不成功 - /// - public static string LogFileName => "Log{0:_yyyy_MM_dd}.txt"; - - /// - /// 日志保存最近几天的内容 - /// public const int SaveDays = 30; - /// - /// 每一行的打印内容 - /// - 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"); + } } -} \ No newline at end of file + + 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 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"); + } + } +} diff --git a/Assets/Scripts/Framework/LogKit/LogSettingsProfiles.cs b/Assets/Scripts/Framework/LogKit/LogSettingsProfiles.cs new file mode 100644 index 000000000..12c6a83f1 --- /dev/null +++ b/Assets/Scripts/Framework/LogKit/LogSettingsProfiles.cs @@ -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); + } +} diff --git a/Assets/Scripts/Framework/LogKit/LogSettingsProfiles.cs.meta b/Assets/Scripts/Framework/LogKit/LogSettingsProfiles.cs.meta new file mode 100644 index 000000000..5e6e167b5 --- /dev/null +++ b/Assets/Scripts/Framework/LogKit/LogSettingsProfiles.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a8f67c393a6748d3aa8c6fd4a05d34a1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Docs/日志系统改进留档.md b/Docs/日志系统改进留档.md new file mode 100644 index 000000000..ecc2a085a --- /dev/null +++ b/Docs/日志系统改进留档.md @@ -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` 才是长期留档重点。 +