diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index c4d5e81..236bf34 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -9,8 +9,6 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public class YamlConfigLoaderTests
{
- private string _rootPath = null!;
-
///
/// 为每个测试创建独立临时目录,避免文件系统状态互相污染。
///
@@ -33,6 +31,8 @@ public class YamlConfigLoaderTests
}
}
+ private string _rootPath = null!;
+
///
/// 验证加载器能够扫描 YAML 文件并将结果写入注册表。
///
@@ -331,6 +331,143 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。
+ ///
+ [Test]
+ public async Task EnableHotReload_Should_Update_Registered_Table_When_Config_File_Changes()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 10
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "hp": { "type": "integer" }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+ await loader.LoadAsync(registry);
+
+ var reloadTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var hotReload = loader.EnableHotReload(
+ registry,
+ onTableReloaded: tableName => reloadTaskSource.TrySetResult(tableName),
+ debounceDelay: TimeSpan.FromMilliseconds(150));
+
+ try
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 25
+ """);
+
+ var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(tableName, Is.EqualTo("monster"));
+ Assert.That(registry.GetTable("monster").Get(1).Hp, Is.EqualTo(25));
+ });
+ }
+ finally
+ {
+ hotReload.UnRegister();
+ }
+ }
+
+ ///
+ /// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。
+ ///
+ [Test]
+ public async Task EnableHotReload_Should_Keep_Previous_Table_When_Schema_Change_Makes_Reload_Fail()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 10
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "hp": { "type": "integer" }
+ }
+ }
+ """);
+
+ var loader = new YamlConfigLoader(_rootPath)
+ .RegisterTable("monster", "monster", "schemas/monster.schema.json",
+ static config => config.Id);
+ var registry = new ConfigRegistry();
+ await loader.LoadAsync(registry);
+
+ var reloadFailureTaskSource =
+ new TaskCompletionSource<(string TableName, Exception Exception)>(TaskCreationOptions
+ .RunContinuationsAsynchronously);
+ var hotReload = loader.EnableHotReload(
+ registry,
+ onTableReloadFailed: (tableName, exception) =>
+ reloadFailureTaskSource.TrySetResult((tableName, exception)),
+ debounceDelay: TimeSpan.FromMilliseconds(150));
+
+ try
+ {
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "rarity"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "hp": { "type": "integer" },
+ "rarity": { "type": "string" }
+ }
+ }
+ """);
+
+ var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(failure.TableName, Is.EqualTo("monster"));
+ Assert.That(failure.Exception.Message, Does.Contain("rarity"));
+ Assert.That(registry.GetTable("monster").Get(1).Hp, Is.EqualTo(10));
+ });
+ }
+ finally
+ {
+ hotReload.UnRegister();
+ }
+ }
+
///
/// 创建测试用配置文件。
///
@@ -358,6 +495,24 @@ public class YamlConfigLoaderTests
CreateConfigFile(relativePath, content);
}
+ ///
+ /// 在限定时间内等待异步任务完成,避免文件监听测试无限挂起。
+ ///
+ /// 任务结果类型。
+ /// 要等待的任务。
+ /// 超时时间。
+ /// 任务结果。
+ private static async Task WaitForTaskWithinAsync(Task task, TimeSpan timeout)
+ {
+ var completedTask = await Task.WhenAny(task, Task.Delay(timeout));
+ if (!ReferenceEquals(completedTask, task))
+ {
+ Assert.Fail($"Timed out after {timeout} while waiting for file watcher notification.");
+ }
+
+ return await task;
+ }
+
///
/// 用于 YAML 加载测试的最小怪物配置类型。
///
diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs
index 11f2d99..6570035 100644
--- a/GFramework.Game/Config/YamlConfigLoader.cs
+++ b/GFramework.Game/Config/YamlConfigLoader.cs
@@ -1,3 +1,4 @@
+using GFramework.Core.Abstractions.Events;
using GFramework.Game.Abstractions.Config;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@@ -71,6 +72,35 @@ public sealed class YamlConfigLoader : IConfigLoader
}
}
+ ///
+ /// 启用开发期热重载。
+ /// 该能力会监听已注册配置表对应的配置目录和 schema 文件,并在检测到文件变更后按表粒度重新加载。
+ /// 重载失败时会保留注册表中的旧表,避免开发期错误配置直接破坏当前运行时状态。
+ ///
+ /// 要被热重载更新的配置注册表。
+ /// 单个配置表重载成功后的可选回调。
+ /// 单个配置表重载失败后的可选回调。
+ /// 防抖延迟;为空时默认使用 200 毫秒。
+ /// 用于停止热重载监听的注销句柄。
+ /// 当 为空时抛出。
+ public IUnRegister EnableHotReload(
+ IConfigRegistry registry,
+ Action? onTableReloaded = null,
+ Action? onTableReloadFailed = null,
+ TimeSpan? debounceDelay = null)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+
+ return new HotReloadSession(
+ _rootPath,
+ _deserializer,
+ registry,
+ _registrations,
+ onTableReloaded,
+ onTableReloadFailed,
+ debounceDelay ?? TimeSpan.FromMilliseconds(200));
+ }
+
///
/// 注册一个 YAML 配置表定义。
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
@@ -193,6 +223,21 @@ public sealed class YamlConfigLoader : IConfigLoader
///
private interface IYamlTableRegistration
{
+ ///
+ /// 获取配置表名称。
+ ///
+ string Name { get; }
+
+ ///
+ /// 获取相对配置根目录的子目录。
+ ///
+ string RelativePath { get; }
+
+ ///
+ /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。
+ ///
+ string? SchemaRelativePath { get; }
+
///
/// 从指定根目录加载配置表。
///
@@ -337,4 +382,290 @@ public sealed class YamlConfigLoader : IConfigLoader
}
}
}
+
+ ///
+ /// 封装开发期热重载所需的文件监听与按表重载逻辑。
+ /// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。
+ ///
+ private sealed class HotReloadSession : IUnRegister, IDisposable
+ {
+ private readonly TimeSpan _debounceDelay;
+ private readonly IDeserializer _deserializer;
+ private readonly object _gate = new();
+ private readonly Action? _onTableReloaded;
+ private readonly Action? _onTableReloadFailed;
+ private readonly Dictionary _registrations = new(StringComparer.Ordinal);
+ private readonly IConfigRegistry _registry;
+ private readonly Dictionary _reloadLocks = new(StringComparer.Ordinal);
+ private readonly Dictionary _reloadTokens = new(StringComparer.Ordinal);
+ private readonly string _rootPath;
+ private readonly List _watchers = new();
+ private bool _disposed;
+
+ ///
+ /// 初始化一个热重载会话并立即开始监听文件变更。
+ ///
+ /// 配置根目录。
+ /// YAML 反序列化器。
+ /// 要更新的配置注册表。
+ /// 已注册的配置表定义。
+ /// 单表重载成功回调。
+ /// 单表重载失败回调。
+ /// 监听事件防抖延迟。
+ public HotReloadSession(
+ string rootPath,
+ IDeserializer deserializer,
+ IConfigRegistry registry,
+ IEnumerable registrations,
+ Action? onTableReloaded,
+ Action? onTableReloadFailed,
+ TimeSpan debounceDelay)
+ {
+ ArgumentNullException.ThrowIfNull(rootPath);
+ ArgumentNullException.ThrowIfNull(deserializer);
+ ArgumentNullException.ThrowIfNull(registry);
+ ArgumentNullException.ThrowIfNull(registrations);
+
+ _rootPath = rootPath;
+ _deserializer = deserializer;
+ _registry = registry;
+ _onTableReloaded = onTableReloaded;
+ _onTableReloadFailed = onTableReloadFailed;
+ _debounceDelay = debounceDelay;
+
+ foreach (var registration in registrations)
+ {
+ _registrations.Add(registration.Name, registration);
+ _reloadLocks.Add(registration.Name, new SemaphoreSlim(1, 1));
+ CreateWatchersForRegistration(registration);
+ }
+ }
+
+ ///
+ /// 释放热重载会话持有的文件监听器与等待资源。
+ ///
+ public void Dispose()
+ {
+ List watchersToDispose;
+ List reloadTokensToDispose;
+ List reloadLocksToDispose;
+
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ watchersToDispose = _watchers.ToList();
+ _watchers.Clear();
+ reloadTokensToDispose = _reloadTokens.Values.ToList();
+ _reloadTokens.Clear();
+ reloadLocksToDispose = _reloadLocks.Values.ToList();
+ _reloadLocks.Clear();
+ }
+
+ foreach (var reloadToken in reloadTokensToDispose)
+ {
+ reloadToken.Cancel();
+ reloadToken.Dispose();
+ }
+
+ foreach (var watcher in watchersToDispose)
+ {
+ watcher.Dispose();
+ }
+
+ foreach (var reloadLock in reloadLocksToDispose)
+ {
+ reloadLock.Dispose();
+ }
+ }
+
+ ///
+ /// 停止热重载监听。
+ ///
+ public void UnRegister()
+ {
+ Dispose();
+ }
+
+ private void CreateWatchersForRegistration(IYamlTableRegistration registration)
+ {
+ var configDirectoryPath = Path.Combine(_rootPath, registration.RelativePath);
+ AddWatcher(configDirectoryPath, "*.yaml", registration.Name);
+ AddWatcher(configDirectoryPath, "*.yml", registration.Name);
+
+ if (string.IsNullOrEmpty(registration.SchemaRelativePath))
+ {
+ return;
+ }
+
+ var schemaFullPath = Path.Combine(_rootPath, registration.SchemaRelativePath);
+ var schemaDirectoryPath = Path.GetDirectoryName(schemaFullPath);
+ if (string.IsNullOrWhiteSpace(schemaDirectoryPath))
+ {
+ schemaDirectoryPath = _rootPath;
+ }
+
+ AddWatcher(schemaDirectoryPath, Path.GetFileName(schemaFullPath), registration.Name);
+ }
+
+ private void AddWatcher(string directoryPath, string filter, string tableName)
+ {
+ if (!Directory.Exists(directoryPath))
+ {
+ return;
+ }
+
+ var watcher = new FileSystemWatcher(directoryPath, filter)
+ {
+ IncludeSubdirectories = false,
+ NotifyFilter = NotifyFilters.FileName |
+ NotifyFilters.LastWrite |
+ NotifyFilters.Size |
+ NotifyFilters.CreationTime |
+ NotifyFilters.DirectoryName
+ };
+
+ watcher.Changed += (_, _) => ScheduleReload(tableName);
+ watcher.Created += (_, _) => ScheduleReload(tableName);
+ watcher.Deleted += (_, _) => ScheduleReload(tableName);
+ watcher.Renamed += (_, _) => ScheduleReload(tableName);
+ watcher.Error += (_, eventArgs) =>
+ {
+ var exception = eventArgs.GetException() ?? new InvalidOperationException(
+ $"Hot reload watcher for table '{tableName}' encountered an unknown error.");
+ InvokeReloadFailed(tableName, exception);
+ };
+
+ watcher.EnableRaisingEvents = true;
+
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ watcher.Dispose();
+ return;
+ }
+
+ _watchers.Add(watcher);
+ }
+ }
+
+ private void ScheduleReload(string tableName)
+ {
+ CancellationTokenSource reloadTokenSource;
+
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (_reloadTokens.TryGetValue(tableName, out var previousTokenSource))
+ {
+ previousTokenSource.Cancel();
+ previousTokenSource.Dispose();
+ }
+
+ reloadTokenSource = new CancellationTokenSource();
+ _reloadTokens[tableName] = reloadTokenSource;
+ }
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await Task.Delay(_debounceDelay, reloadTokenSource.Token);
+ await ReloadTableAsync(tableName, reloadTokenSource.Token);
+ }
+ catch (OperationCanceledException) when (reloadTokenSource.IsCancellationRequested)
+ {
+ // 新事件会替换旧任务;取消属于正常防抖行为。
+ }
+ finally
+ {
+ lock (_gate)
+ {
+ if (_reloadTokens.TryGetValue(tableName, out var currentTokenSource) &&
+ ReferenceEquals(currentTokenSource, reloadTokenSource))
+ {
+ _reloadTokens.Remove(tableName);
+ }
+ }
+
+ reloadTokenSource.Dispose();
+ }
+ });
+ }
+
+ private async Task ReloadTableAsync(string tableName, CancellationToken cancellationToken)
+ {
+ if (!_registrations.TryGetValue(tableName, out var registration))
+ {
+ return;
+ }
+
+ var reloadLock = _reloadLocks[tableName];
+ await reloadLock.WaitAsync(cancellationToken);
+
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var (name, table) = await registration.LoadAsync(_rootPath, _deserializer, cancellationToken);
+ RegistrationDispatcher.Register(_registry, name, table);
+ InvokeReloaded(name);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // 防抖替换或会话关闭导致的取消不应视为错误。
+ }
+ catch (Exception exception)
+ {
+ InvokeReloadFailed(tableName, exception);
+ }
+ finally
+ {
+ reloadLock.Release();
+ }
+ }
+
+ private void InvokeReloaded(string tableName)
+ {
+ if (_onTableReloaded == null)
+ {
+ return;
+ }
+
+ try
+ {
+ _onTableReloaded(tableName);
+ }
+ catch
+ {
+ // 诊断回调不应反向破坏热重载流程。
+ }
+ }
+
+ private void InvokeReloadFailed(string tableName, Exception exception)
+ {
+ if (_onTableReloadFailed == null)
+ {
+ return;
+ }
+
+ try
+ {
+ _onTableReloadFailed(tableName, exception);
+ }
+ catch
+ {
+ // 诊断回调不应反向破坏热重载流程。
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index 87a0cbe..591febc 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -94,6 +94,40 @@ var slime = monsterTable.Get(1);
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
+## 开发期热重载
+
+如果你希望在开发期修改配置文件后自动刷新运行时表,可以在初次加载完成后启用热重载:
+
+```csharp
+using GFramework.Game.Config;
+
+var registry = new ConfigRegistry();
+var loader = new YamlConfigLoader("config-root")
+ .RegisterTable(
+ "monster",
+ "monster",
+ "schemas/monster.schema.json",
+ static config => config.Id);
+
+await loader.LoadAsync(registry);
+
+var hotReload = loader.EnableHotReload(
+ registry,
+ onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"),
+ onTableReloadFailed: (tableName, exception) =>
+ Console.WriteLine($"Reload failed: {tableName}, {exception.Message}"));
+```
+
+当前热重载行为如下:
+
+- 监听已注册表对应的配置目录
+- 监听该表绑定的 schema 文件
+- 检测到变更后按表粒度重载
+- 重载成功后替换该表在 `IConfigRegistry` 中的注册
+- 重载失败时保留旧表,并通过失败回调提供诊断
+
+这项能力默认定位为开发期工具,不承诺生产环境热更新平台语义。
+
## 生成器接入约定
配置生成器会从 `*.schema.json` 生成配置类型和表包装类。
@@ -118,7 +152,6 @@ var slime = monsterTable.Get(1);
以下能力尚未完全完成:
-- 运行时热重载
- 跨表引用校验
- 更完整的 JSON Schema 支持
- 更强的 VS Code 表单编辑器