diff --git a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs index d8631e5d..f9a2492f 100644 --- a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs +++ b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs @@ -1,27 +1,37 @@ -using System; using System.IO; -using System.Linq; -using System.Threading.Tasks; +using System.Threading; using GFramework.Core.Architectures; +using GFramework.Core.Extensions; +using GFramework.Core.Utility; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Game.Config.Generated; -using NUnit.Framework; namespace GFramework.Game.Tests.Config; /// -/// 验证在 初始化流程中可以通过官方配置启动帮助器加载生成配置表,并通过表访问器读取数据。 +/// 验证 场景下的官方配置模块接入链路。 +/// 这些测试覆盖模块安装、utility 初始化顺序以及生成表访问,确保模块化入口能够替代手写 bootstrap 模板。 /// [TestFixture] public class ArchitectureConfigIntegrationTests { /// - /// 架构初始化期间,通过 收敛生成表注册、加载与注册表暴露, + /// 清理全局架构上下文,避免测试之间残留同类型架构绑定。 + /// + [SetUp] + [TearDown] + public void ResetGlobalArchitectureContext() + { + GameContext.Clear(); + } + + /// + /// 架构初始化期间,通过 收敛生成表注册、加载与注册表暴露, /// 并将 作为 utility 暴露给架构上下文读取。 /// [Test] - public async Task ConfigLoaderCanRunDuringArchitectureInitialization() + public async Task ConfigModuleCanRunDuringArchitectureInitialization() { var rootPath = CreateTempConfigRoot(); ConsumerArchitecture? architecture = null; @@ -45,6 +55,8 @@ public class ArchitectureConfigIntegrationTests Assert.That(retrieved!.Get(1).Name, Is.EqualTo("Slime")); Assert.That(architecture.Registry.TryGetItemTable(out _), Is.False); Assert.That(architecture.Context.GetUtility(), Is.SameAs(architecture.Registry)); + Assert.That(architecture.ConfigModule.IsInitialized, Is.True); + Assert.That(architecture.ConfigModule.IsHotReloadEnabled, Is.False); }); } finally @@ -58,6 +70,183 @@ public class ArchitectureConfigIntegrationTests } } + /// + /// 验证配置模块会在其他 utility 初始化之前完成首次加载, + /// 这样依赖配置的 utility 无需再自行阻塞等待配置系统完成启动。 + /// + [Test] + public async Task ConfigModuleShouldLoadConfigBeforeDependentUtilityInitialization() + { + var rootPath = CreateTempConfigRoot(); + ConsumerArchitecture? architecture = null; + var initialized = false; + try + { + architecture = new ConsumerArchitecture(rootPath); + await architecture.InitializeAsync(); + initialized = true; + + Assert.Multiple(() => + { + Assert.That(architecture.ProbeUtility.InitializedWithLoadedConfig, Is.True); + Assert.That(architecture.ProbeUtility.ObservedMonsterName, Is.EqualTo("Slime")); + Assert.That(architecture.ProbeUtility.ObservedDungeonMonsterCount, Is.EqualTo(2)); + }); + } + finally + { + if (architecture is not null && initialized) + { + await architecture.DestroyAsync(); + } + + DeleteDirectoryIfExists(rootPath); + } + } + + /// + /// 验证同一个模块实例不会被重复安装到多个架构中, + /// 避免共享内部 bootstrap 状态导致跨架构生命周期混淆。 + /// + [Test] + public async Task GameConfigModuleShouldRejectReusingTheSameModuleInstance() + { + var rootPath = CreateTempConfigRoot(); + ModuleOnlyArchitecture? firstArchitecture = null; + var firstDestroyed = false; + try + { + var module = CreateModule(rootPath); + + firstArchitecture = new ModuleOnlyArchitecture(module); + await firstArchitecture.InitializeAsync(); + var wasInitializedBeforeDestroy = module.IsInitialized; + await firstArchitecture.DestroyAsync(); + firstDestroyed = true; + firstArchitecture = null; + GameContext.Clear(); + + var secondArchitecture = new ModuleOnlyArchitecture(module); + var exception = + Assert.ThrowsAsync(async () => await secondArchitecture.InitializeAsync()); + + Assert.Multiple(() => + { + Assert.That(wasInitializedBeforeDestroy, Is.True); + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("cannot be installed more than once")); + }); + } + finally + { + if (firstArchitecture is not null && !firstDestroyed) + { + await firstArchitecture.DestroyAsync(); + } + + DeleteDirectoryIfExists(rootPath); + } + } + + /// + /// 验证配置模块在同步阻塞且存在 的线程上仍可完成架构初始化, + /// 直接覆盖 通过生命周期钩子执行同步桥接的真实路径。 + /// + [Test] + public void GameConfigModuleShouldSupportSynchronousBridgeOnBlockingSynchronizationContext() + { + var rootPath = CreateTempConfigRoot(); + ConsumerArchitecture? architecture = null; + var initialized = false; + try + { + architecture = new ConsumerArchitecture(rootPath); + + RunBlockingOnSynchronizationContext( + () => architecture.InitializeAsync(), + TimeSpan.FromSeconds(5)); + initialized = true; + + var monsterTable = architecture.Registry.GetMonsterTable(); + Assert.Multiple(() => + { + Assert.That(architecture.ConfigModule.IsInitialized, Is.True); + Assert.That(monsterTable.Get(1).Name, Is.EqualTo("Slime")); + Assert.That(monsterTable.FindByFaction("dungeon").Count(), Is.EqualTo(2)); + }); + } + finally + { + if (architecture is not null && initialized) + { + architecture.DestroyAsync().GetAwaiter().GetResult(); + } + + DeleteDirectoryIfExists(rootPath); + } + } + + /// + /// 验证模块在架构已经越过安装窗口时会拒绝安装, + /// 并且失败不会消耗模块实例,便于后续在新的架构上重试安装。 + /// + [Test] + public async Task GameConfigModuleShouldRejectLateInstallationWithoutConsumingTheModuleInstance() + { + var rootPath = CreateTempConfigRoot(); + ReadyOnlyArchitecture? readyArchitecture = null; + ModuleOnlyArchitecture? retryArchitecture = null; + var readyArchitectureInitialized = false; + var retryArchitectureInitialized = false; + try + { + var module = CreateModule(rootPath); + + readyArchitecture = new ReadyOnlyArchitecture(); + await readyArchitecture.InitializeAsync(); + readyArchitectureInitialized = true; + + var exception = Assert.Throws(() => readyArchitecture.InstallModule(module)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("BeforeUtilityInit")); + Assert.That(readyArchitecture.Context.GetUtilities(), Is.Empty); + Assert.That(module.IsInitialized, Is.False); + }); + + await readyArchitecture.DestroyAsync(); + readyArchitectureInitialized = false; + readyArchitecture = null; + GameContext.Clear(); + + retryArchitecture = new ModuleOnlyArchitecture(module); + await retryArchitecture.InitializeAsync(); + retryArchitectureInitialized = true; + + Assert.Multiple(() => + { + Assert.That(module.IsInitialized, Is.True); + Assert.That(retryArchitecture.Registry.GetMonsterTable().Get(1).Name, Is.EqualTo("Slime")); + }); + } + finally + { + if (retryArchitecture is not null && retryArchitectureInitialized) + { + await retryArchitecture.DestroyAsync(); + } + + if (readyArchitecture is not null && readyArchitectureInitialized) + { + await readyArchitecture.DestroyAsync(); + } + + DeleteDirectoryIfExists(rootPath); + } + } + private static string CreateTempConfigRoot() { var rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigArchitecture", Guid.NewGuid().ToString("N")); @@ -94,6 +283,76 @@ public class ArchitectureConfigIntegrationTests } } + /// + /// 在不处理消息队列的同步上下文线程上执行阻塞等待, + /// 用于回归验证初始化异步链不会依赖原上下文恢复 continuation。 + /// + /// 要同步阻塞执行的异步操作。 + /// 等待线程结束的超时时间。 + private static void RunBlockingOnSynchronizationContext(Func action, TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(action); + + Exception? capturedException = null; + var workerThread = new Thread(() => + { + SynchronizationContext.SetSynchronizationContext(new NonPumpingSynchronizationContext()); + + try + { + action().GetAwaiter().GetResult(); + } + catch (Exception exception) + { + capturedException = exception; + } + }) + { + IsBackground = true + }; + + workerThread.Start(); + if (!workerThread.Join(timeout)) + { + Assert.Fail("The blocking synchronization-context bridge did not complete within the expected timeout."); + } + + if (capturedException != null) + { + throw new AssertionException( + $"The blocking synchronization-context bridge failed: {capturedException}"); + } + } + + /// + /// 创建一个使用配置模块的模块实例。 + /// + /// 测试配置根目录。 + /// 已配置的模块实例。 + private static GameConfigModule CreateModule(string configRoot) + { + return new GameConfigModule(CreateBootstrapOptions(configRoot)); + } + + /// + /// 创建供测试复用的配置启动选项。 + /// + /// 测试配置根目录。 + /// 可用于模块或直接 bootstrap 的启动选项。 + private static GameConfigBootstrapOptions CreateBootstrapOptions(string configRoot) + { + return new GameConfigBootstrapOptions + { + RootPath = configRoot, + ConfigureLoader = static loader => + loader.RegisterAllGeneratedConfigTables( + new GeneratedConfigRegistrationOptions + { + IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain } + }) + }; + } + private const string MonsterSchemaJson = @"{ ""title"": ""Monster Config"", ""description"": ""Defines one monster entry for the generated consumer integration test."", @@ -132,12 +391,14 @@ public class ArchitectureConfigIntegrationTests private sealed class ConsumerArchitecture : Architecture { - private readonly GameConfigBootstrap _bootstrap; - - public IConfigRegistry Registry => _bootstrap.Registry; - - public MonsterTable MonsterTable { get; private set; } = null!; + private readonly GameConfigModule _configModule; + private readonly ConfigAwareProbeUtility _probeUtility = new(); + /// + /// 使用指定配置根目录创建一个消费者测试架构。 + /// + /// 测试配置根目录。 + /// 为空时抛出。 public ConsumerArchitecture(string configRoot) { if (configRoot == null) @@ -145,30 +406,151 @@ public class ArchitectureConfigIntegrationTests throw new ArgumentNullException(nameof(configRoot)); } - _bootstrap = new GameConfigBootstrap( - new GameConfigBootstrapOptions - { - RootPath = configRoot, - ConfigureLoader = static loader => - loader.RegisterAllGeneratedConfigTables( - new GeneratedConfigRegistrationOptions - { - IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain } - }) - }); + _configModule = CreateModule(configRoot); } + /// + /// 获取当前架构安装的配置模块。 + /// + /// + /// 该模块会在 中安装,并在架构进入 + /// 时完成首次加载。 + /// 调用方可通过该属性观察模块初始化状态,但不应在架构初始化完成前假定加载已经成功。 + /// + public GameConfigModule ConfigModule => _configModule; + + /// + /// 获取由配置模块暴露到架构上下文中的注册表。 + /// + /// + /// 该属性在模块安装后即可访问同一个注册表实例,但只有在模块首次加载完成后, + /// 其中的生成配置表读取才具备成功契约。 + /// + public IConfigRegistry Registry => _configModule.Registry; + + /// + /// 获取测试使用的怪物配置表。 + /// + /// 已经从 中解析出的怪物表包装。 + /// 当模块首次加载尚未完成时抛出。 + /// + /// 该属性用于断言生成访问器在架构初始化完成后可直接读取; + /// 它依赖模块在 utility 初始化阶段之前已经完成首次加载。 + /// + public MonsterTable MonsterTable => Registry.GetMonsterTable(); + + /// + /// 获取用于观测 utility 初始化阶段配置可见性的探针 utility。 + /// + /// + /// 该 utility 会在 中注册,并在 utility 初始化阶段读取配置表, + /// 用于验证配置模块是否按约定在更早的生命周期阶段完成首载。 + /// + public ConfigAwareProbeUtility ProbeUtility => _probeUtility; + + /// + /// 在用户初始化阶段安装配置模块,并注册一个依赖配置的测试 utility, + /// 以验证模块会在 utility 初始化前完成首次加载。 + /// protected override void OnInitialize() { - RegisterUtility(Registry); - _bootstrap.InitializeAsync().GetAwaiter().GetResult(); - MonsterTable = Registry.GetMonsterTable(); + InstallModule(_configModule); + RegisterUtility(_probeUtility); } + } - public override async ValueTask DestroyAsync() + /// + /// 用于验证模块复用限制的最小架构。 + /// + private sealed class ModuleOnlyArchitecture(GameConfigModule configModule) : Architecture + { + /// + /// 获取安装到当前测试架构中的配置模块。 + /// + /// + /// 该属性直接暴露传入的模块实例,便于测试验证同一模块实例跨架构复用时的生命周期约束。 + /// + public GameConfigModule ConfigModule => configModule; + + /// + /// 获取当前模块共享的配置注册表。 + /// + /// + /// 该注册表实例在模块安装后即与架构绑定,但只有在架构完成配置首载后, + /// 其中的强类型配置表访问才应被视为可用。 + /// + public IConfigRegistry Registry => configModule.Registry; + + /// + /// 安装外部传入的配置模块。 + /// + protected override void OnInitialize() + { + InstallModule(configModule); + } + } + + /// + /// 仅用于把架构推进到 Ready 阶段的空壳架构。 + /// + private sealed class ReadyOnlyArchitecture : Architecture + { + /// + /// 该测试架构不注册任何组件,仅验证模块的安装窗口约束。 + /// + protected override void OnInitialize() + { + } + } + + /// + /// 在 utility 初始化阶段直接读取配置表的探针工具。 + /// 如果模块没有在 utility 阶段开始前完成首次加载,这个探针会在初始化时失败。 + /// + private sealed class ConfigAwareProbeUtility : AbstractContextUtility + { + /// + /// 获取一个值,指示初始化时是否已经读取到有效配置。 + /// + public bool InitializedWithLoadedConfig { get; private set; } + + /// + /// 获取初始化期间读取到的怪物名称。 + /// + public string? ObservedMonsterName { get; private set; } + + /// + /// 获取初始化期间读取到的 dungeon 阵营怪物数量。 + /// + public int ObservedDungeonMonsterCount { get; private set; } + + /// + /// 读取架构上下文中的配置注册表并验证目标表已经可用。 + /// + protected override void OnInit() + { + var registry = this.GetUtility(); + var monsterTable = registry.GetMonsterTable(); + + ObservedMonsterName = monsterTable.Get(1).Name; + ObservedDungeonMonsterCount = monsterTable.FindByFaction("dungeon").Count(); + InitializedWithLoadedConfig = true; + } + } + + /// + /// 模拟一个不会主动处理 回调的阻塞线程上下文。 + /// 如果初始化链错误地捕获该上下文,continuation 会永久悬挂,从而暴露同步桥接死锁。 + /// + private sealed class NonPumpingSynchronizationContext : SynchronizationContext + { + /// + /// 丢弃异步投递的 continuation,模拟被同步阻塞且未泵消息的宿主线程。 + /// + /// 要执行的回调。 + /// 回调状态。 + public override void Post(SendOrPostCallback d, object? state) { - _bootstrap.Dispose(); - await base.DestroyAsync(); } } } diff --git a/GFramework.Game/Config/GameConfigBootstrap.cs b/GFramework.Game/Config/GameConfigBootstrap.cs index 242002be..9ffe34ec 100644 --- a/GFramework.Game/Config/GameConfigBootstrap.cs +++ b/GFramework.Game/Config/GameConfigBootstrap.cs @@ -1,3 +1,4 @@ +using System.Threading; using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; @@ -127,6 +128,11 @@ public sealed class GameConfigBootstrap : IDisposable /// 该方法只能成功调用一次,避免同一个生命周期对象在运行中被重新拼装为另一套加载约定。 /// /// 取消令牌。 + /// + /// 该入口会被 的同步生命周期钩子桥接调用, + /// 因此内部所有异步等待都必须使用 ConfigureAwait(false),避免在 Unity 主线程、 + /// UI Dispatcher 或带 的测试线程上发生同步阻塞死锁。 + /// /// 表示异步初始化流程的任务。 /// 当当前实例已释放时抛出。 /// 当当前实例已经初始化成功时抛出。 @@ -152,7 +158,7 @@ public sealed class GameConfigBootstrap : IDisposable { var loader = new YamlConfigLoader(RootPath); _options.ConfigureLoader!(loader); - await loader.LoadAsync(Registry, cancellationToken); + await loader.LoadAsync(Registry, cancellationToken).ConfigureAwait(false); if (_options.EnableHotReload) { diff --git a/GFramework.Game/Config/GameConfigModule.cs b/GFramework.Game/Config/GameConfigModule.cs new file mode 100644 index 00000000..1f964e04 --- /dev/null +++ b/GFramework.Game/Config/GameConfigModule.cs @@ -0,0 +1,206 @@ +using System; +using System.Threading; +using GFramework.Core.Abstractions.Architectures; +using GFramework.Core.Abstractions.Enums; +using GFramework.Core.Architectures; +using GFramework.Core.Utility; +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 提供基于 的官方配置模块接入入口。 +/// 该模块负责把 挂接到架构生命周期中,统一完成注册表暴露、 +/// 首次加载以及架构销毁时的资源回收。 +/// +/// +/// 使用该模块时,推荐在 Architecture.OnInitialize() 的较早位置调用 , +/// 以便其他 utility、model 和 system 在各自初始化阶段都能读取到已经完成首次加载的配置表。 +/// 如果消费项目不基于 ,则继续直接使用 更合适。 +/// +public sealed class GameConfigModule : IArchitectureModule +{ + private const int InstallStateNotInstalled = 0; + private const int InstallStateInstalling = 1; + private const int InstallStateConsumed = 2; + + private readonly GameConfigBootstrap _bootstrap; + private readonly ModuleBootstrapLifetimeUtility _lifetimeUtility; + private int _installState; + + /// + /// 使用指定的启动选项创建配置模块。 + /// + /// 配置启动帮助器选项。 + /// 为空时抛出。 + /// + /// 当 不满足 的构造约束时抛出。 + /// + public GameConfigModule(GameConfigBootstrapOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + _bootstrap = new GameConfigBootstrap(options); + _lifetimeUtility = new ModuleBootstrapLifetimeUtility(_bootstrap); + } + + /// + /// 获取当前模块复用的配置注册表。 + /// 该实例会在模块安装时注册为架构 utility,供其他组件通过上下文直接读取。 + /// + public IConfigRegistry Registry => _bootstrap.Registry; + + /// + /// 获取一个值,指示模块绑定的配置启动器是否已经完成首次加载。 + /// + public bool IsInitialized => _bootstrap.IsInitialized; + + /// + /// 获取一个值,指示模块绑定的开发期热重载是否已启用。 + /// + public bool IsHotReloadEnabled => _bootstrap.IsHotReloadEnabled; + + /// + /// 获取当前生效的 YAML 配置加载器。 + /// 只有在模块完成首次加载后该属性才可访问。 + /// + /// 当模块所属架构已销毁时抛出。 + /// 当首次加载尚未成功完成时抛出。 + public YamlConfigLoader Loader => _bootstrap.Loader; + + /// + /// 将配置模块安装到指定架构中。 + /// 安装后会立即暴露 ,并注册一个生命周期钩子, + /// 以便在 utility 初始化之前完成一次确定性的配置加载。 + /// + /// 目标架构实例。 + /// 为空时抛出。 + /// 当同一个模块实例被重复安装时抛出。 + /// + /// 生命周期阶段校验会在任何注册动作前执行,因此错过安装窗口的调用不会消耗当前模块实例。 + /// 一旦开始向架构注册 utility 或生命周期钩子,就不存在回滚 API;因此后续任何失败都会把该模块实例视为已消耗, + /// 调用方必须创建新的 再重试。 + /// + public void Install(IArchitecture architecture) + { + ArgumentNullException.ThrowIfNull(architecture); + + ValidateInstallationPhase(architecture); + + if (Interlocked.CompareExchange( + ref _installState, + InstallStateInstalling, + InstallStateNotInstalled) != InstallStateNotInstalled) + { + throw new InvalidOperationException( + "The same GameConfigModule instance cannot be installed more than once."); + } + + try + { + // 阶段窗口已经在前面做过无副作用校验,因此这里优先注册 utility, + // 让常见的容器/上下文接线失败在不可回滚的 hook 注册之前暴露出来。 + architecture.RegisterUtility(Registry); + architecture.RegisterUtility(_lifetimeUtility); + architecture.RegisterLifecycleHook(new BootstrapInitializationHook(_bootstrap)); + Volatile.Write(ref _installState, InstallStateConsumed); + } + catch + { + // 架构对 utility / hook 注册都不提供回滚入口,因此一旦进入注册阶段, + // 即使安装失败也必须禁止复用同一个模块实例,避免重复暴露共享注册表或挂上第二个 hook。 + Volatile.Write(ref _installState, InstallStateConsumed); + throw; + } + } + + /// + /// 在首次加载成功后显式启用开发期热重载。 + /// 该入口与 的语义保持一致, + /// 供已经保留模块实例引用的架构启动层按环境决定是否追加监听。 + /// + /// 热重载选项;为空时使用默认行为。 + public void StartHotReload(YamlConfigHotReloadOptions? options = null) + { + _bootstrap.StartHotReload(options); + } + + /// + /// 停止开发期热重载并释放监听资源。 + /// 该方法是幂等的,允许架构外部的开发期开关无条件调用。 + /// + public void StopHotReload() + { + _bootstrap.StopHotReload(); + } + + /// + /// 在 utility 初始化之前完成首次配置加载的生命周期钩子。 + /// 这样后续 utility、model 和 system 在各自初始化阶段就能直接依赖已加载的注册表。 + /// + private sealed class BootstrapInitializationHook(GameConfigBootstrap bootstrap) : IArchitectureLifecycleHook + { + /// + /// 在目标阶段触发配置加载。 + /// + /// 当前架构阶段。 + /// 相关架构实例;当前实现不直接使用,但保留用于接口契约一致性。 + public void OnPhase(ArchitecturePhase phase, IArchitecture architecture) + { + if (phase != ArchitecturePhase.BeforeUtilityInit) + { + return; + } + + // 架构生命周期钩子当前是同步接口,因此这里显式桥接到统一的 bootstrap 异步实现, + // 让 Architecture 模式和独立运行时模式保持同一套加载、诊断和热重载启动语义。 + bootstrap.InitializeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } + } + + /// + /// 验证模块仍处于允许接入的生命周期窗口。 + /// 该模块依赖 钩子完成首次加载, + /// 因此一旦架构已经离开 ,继续安装只会错过首载时机。 + /// + /// 目标架构实例。 + /// + /// 当目标架构已经开始组件初始化阶段时抛出。 + /// + private static void ValidateInstallationPhase(IArchitecture architecture) + { + if (architecture is not Architecture concreteArchitecture) + { + return; + } + + if (concreteArchitecture.CurrentPhase != ArchitecturePhase.None) + { + throw new InvalidOperationException( + "GameConfigModule must be installed before the architecture enters BeforeUtilityInit."); + } + } + + /// + /// 跟随架构 utility 生命周期释放底层 bootstrap 资源的薄封装。 + /// 该 utility 本身不承担加载职责,只负责在架构销毁时停止热重载并释放监听句柄。 + /// + private sealed class ModuleBootstrapLifetimeUtility(GameConfigBootstrap bootstrap) : AbstractContextUtility + { + /// + /// 该 utility 不需要在初始化阶段执行额外逻辑。 + /// 首次加载已经由 在 utility 初始化前完成。 + /// + protected override void OnInit() + { + } + + /// + /// 架构销毁时释放 bootstrap 资源,确保热重载监听句柄不会泄漏到架构生命周期之外。 + /// + protected override void OnDestroy() + { + bootstrap.Dispose(); + } + } +} diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 012caa32..15e38b1b 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -69,7 +69,8 @@ public sealed class YamlConfigLoader : IConfigLoader foreach (var registration in _registrations) { cancellationToken.ThrowIfCancellationRequested(); - loadedTables.Add(await registration.LoadAsync(_rootPath, _deserializer, cancellationToken)); + loadedTables.Add( + await registration.LoadAsync(_rootPath, _deserializer, cancellationToken).ConfigureAwait(false)); } CrossTableReferenceValidator.Validate(registry, loadedTables); @@ -442,7 +443,8 @@ public sealed class YamlConfigLoader : IConfigLoader if (!string.IsNullOrEmpty(SchemaRelativePath)) { var schemaPath = Path.Combine(rootPath, SchemaRelativePath); - schema = await YamlConfigSchemaValidator.LoadAsync(Name, schemaPath, cancellationToken); + schema = await YamlConfigSchemaValidator.LoadAsync(Name, schemaPath, cancellationToken) + .ConfigureAwait(false); referencedTableNames = schema.ReferencedTableNames; } @@ -463,7 +465,7 @@ public sealed class YamlConfigLoader : IConfigLoader string yaml; try { - yaml = await File.ReadAllTextAsync(file, cancellationToken); + yaml = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false); } catch (Exception exception) { @@ -987,8 +989,8 @@ public sealed class YamlConfigLoader : IConfigLoader { try { - await Task.Delay(_debounceDelay, reloadTokenSource.Token); - await ReloadTableAsync(tableName, reloadTokenSource.Token); + await Task.Delay(_debounceDelay, reloadTokenSource.Token).ConfigureAwait(false); + await ReloadTableAsync(tableName, reloadTokenSource.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (reloadTokenSource.IsCancellationRequested) { @@ -1018,7 +1020,7 @@ public sealed class YamlConfigLoader : IConfigLoader } var reloadLock = _reloadLocks[tableName]; - await reloadLock.WaitAsync(cancellationToken); + await reloadLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { @@ -1031,8 +1033,9 @@ public sealed class YamlConfigLoader : IConfigLoader foreach (var affectedTableName in affectedTableNames) { cancellationToken.ThrowIfCancellationRequested(); - loadedTables.Add(await _registrations[affectedTableName].LoadAsync(_rootPath, _deserializer, - cancellationToken)); + loadedTables.Add( + await _registrations[affectedTableName].LoadAsync(_rootPath, _deserializer, cancellationToken) + .ConfigureAwait(false)); } CrossTableReferenceValidator.Validate(_registry, loadedTables); @@ -1125,4 +1128,4 @@ public sealed class YamlConfigLoader : IConfigLoader } } } -} \ No newline at end of file +} diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index f435c10a..381f321c 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -51,7 +51,7 @@ internal static class YamlConfigSchemaValidator string schemaText; try { - schemaText = await File.ReadAllTextAsync(schemaPath, cancellationToken); + schemaText = await File.ReadAllTextAsync(schemaPath, cancellationToken).ConfigureAwait(false); } catch (Exception exception) { diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index cf6b7eda..5561dccd 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -168,7 +168,7 @@ GameProject/ 这段配置的作用: -- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap` 和只读表实现 +- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap`、`GameConfigModule` 和只读表实现 - 三个 `ProjectReference(... OutputItemType="Analyzer")` 把生成器接进当前消费者项目 - `GeWuYou.GFramework.SourceGenerators.targets` 自动把 `schemas/**/*.schema.json` 加入 `AdditionalFiles` @@ -404,7 +404,7 @@ if (monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster)) ### Architecture 推荐接入模板 -如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,并且当前宿主没有提供更上层的异步启动入口,可以把配置系统接到 `OnInitialize()` 阶段,并把 `GameConfigBootstrap.Registry` 注册为 utility: +如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐优先使用 `GameConfigModule`,而不是在 `OnInitialize()` 里手动拼装 `GameConfigBootstrap` 的注册、加载和销毁顺序: ```csharp using GFramework.Core.Architectures; @@ -414,11 +414,11 @@ using GFramework.Game.Config.Generated; public sealed class GameArchitecture : Architecture { - private readonly GameConfigBootstrap _configBootstrap; + private readonly GameConfigModule _configModule; public GameArchitecture(string configRootPath) { - _configBootstrap = new GameConfigBootstrap( + _configModule = new GameConfigModule( new GameConfigBootstrapOptions { RootPath = configRootPath, @@ -428,14 +428,7 @@ public sealed class GameArchitecture : Architecture protected override void OnInitialize() { - RegisterUtility(_configBootstrap.Registry); - _configBootstrap.InitializeAsync().GetAwaiter().GetResult(); - } - - public override async ValueTask DestroyAsync() - { - _configBootstrap.Dispose(); - await base.DestroyAsync(); + InstallModule(_configModule); } } ``` @@ -456,22 +449,28 @@ var slime = monsterTable.Get(1); 推荐遵循以下顺序: -- 先构造 `GameConfigBootstrap` -- 在 `OnInitialize()` 里注册 `bootstrap.Registry` -- 再调用 `bootstrap.InitializeAsync()` 完成首次加载 -- 架构销毁时释放 `GameConfigBootstrap` +- 先构造 `GameConfigModule` +- 在 `OnInitialize()` 的较早位置调用 `InstallModule(_configModule)` +- 让模块在 `BeforeUtilityInit` 阶段完成首次加载 +- 架构销毁时让模块跟随 utility 生命周期自动释放 `GameConfigBootstrap` - 初始化完成后只通过注册表和生成表包装访问配置 -当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + GameConfigBootstrap + RegisterAllGeneratedConfigTables()` 组合已经足够作为官方推荐接入路径。 +这样做的收益是: + +- `IConfigRegistry` 会在模块安装时立即注册为 utility,后续组件统一从上下文读取 +- 首次加载发生在 `BeforeUtilityInit`,因此依赖配置的 utility、model 和 system 在自己的初始化阶段就能直接读取表 +- 架构销毁时不再需要手写 `Dispose()` 样板来停止热重载句柄 + +如果你仍然需要在架构外直接控制 `InitializeAsync()`、`StartHotReload(...)` 或 `StopHotReload()` 的调用时机,继续直接使用 `GameConfigBootstrap` 仍然是合适的;`GameConfigModule` 是面向 `Architecture` 宿主的官方薄封装,而不是替代底层 bootstrap。 ### 热重载模板 -如果你希望把开发期热重载显式收敛为一个可选能力,推荐直接通过 `GameConfigBootstrap.StartHotReload(...)` 管理,而不是让监听句柄散落在启动层之外: +如果你希望把开发期热重载显式收敛为一个可选能力,在 `Architecture` 场景下可以直接保留上面示例中的 `_configModule` 字段并调用 `GameConfigModule.StartHotReload(...)`;非 `Architecture` 场景则继续直接通过 `GameConfigBootstrap.StartHotReload(...)` 管理,而不是让监听句柄散落在启动层之外: ```csharp -await bootstrap.InitializeAsync(); +await architecture.InitializeAsync(); -bootstrap.StartHotReload( +_configModule.StartHotReload( new YamlConfigHotReloadOptions { OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"),