diff --git a/GFramework.Game.Abstractions/Data/DataRepositoryOptions.cs b/GFramework.Game.Abstractions/Data/DataRepositoryOptions.cs
index e0774650..040e4ab1 100644
--- a/GFramework.Game.Abstractions/Data/DataRepositoryOptions.cs
+++ b/GFramework.Game.Abstractions/Data/DataRepositoryOptions.cs
@@ -14,22 +14,47 @@
namespace GFramework.Game.Abstractions.Data;
///
-/// 数据仓库配置选项
+/// 数据仓库配置选项。
///
+///
+/// 该选项描述的是仓库层的公开行为契约,而不是某一种固定的落盘格式。
+/// 因此不同实现可以分别使用“每项单文件”或“统一聚合文件”存储,只要对外遵守同一套备份与事件语义:
+///
+/// -
+///
+/// 在覆盖已有持久化数据前保留上一份可恢复快照。对于聚合型仓库,备份粒度是整个统一文件。
+///
+///
+/// -
+///
+/// 仅控制公开仓库操作产生的事件;内部缓存预热、迁移回写或批量保存中的子步骤不会额外发出单项事件。
+///
+///
+///
+///
public class DataRepositoryOptions
{
///
- /// 存储基础路径(如 "user://data/")
+ /// 获取或设置仓库使用的基础存储路径。
///
+ ///
+ /// 具体实现会在该路径下组织自己的键空间。调用方应将其视为仓库级根目录,而不是具体文件名。
+ ///
public string BasePath { get; set; } = "";
///
- /// 是否在保存时自动备份
+ /// 获取或设置是否在覆盖已有持久化数据前自动创建备份。
///
+ ///
+ /// 该选项只影响覆盖写入;首次写入不会生成备份。聚合型仓库会为统一文件创建单份备份,而不是为内部 section 分别备份。
+ ///
public bool AutoBackup { get; set; } = false;
///
- /// 是否启用加载/保存事件
+ /// 获取或设置是否启用仓库层加载、保存、删除与批量保存事件。
///
+ ///
+ /// 当该值为 时,SaveAllAsync 只会发出批量事件,不会重复发出每个条目的单项保存事件。
+ ///
public bool EnableEvents { get; set; } = true;
-}
\ No newline at end of file
+}
diff --git a/GFramework.Game.Tests/Data/PersistenceTests.cs b/GFramework.Game.Tests/Data/PersistenceTests.cs
index ef2a5e85..74f1e2e2 100644
--- a/GFramework.Game.Tests/Data/PersistenceTests.cs
+++ b/GFramework.Game.Tests/Data/PersistenceTests.cs
@@ -1,5 +1,11 @@
using System.IO;
+using GFramework.Core.Abstractions.Events;
+using GFramework.Core.Abstractions.Rule;
+using GFramework.Core.Architectures;
+using GFramework.Core.Events;
+using GFramework.Core.Ioc;
using GFramework.Game.Abstractions.Data;
+using GFramework.Game.Abstractions.Data.Events;
using GFramework.Game.Data;
using GFramework.Game.Serializer;
using GFramework.Game.Storage;
@@ -219,6 +225,272 @@ public class PersistenceTests
Assert.That(all[location.Key], Is.TypeOf());
}
+ ///
+ /// 验证通用数据仓库在覆盖已有数据时会创建备份文件,并保留覆盖前的旧值。
+ ///
+ /// 表示异步测试完成的任务。
+ [Test]
+ public async Task DataRepository_SaveAsync_Should_Create_Backup_When_Overwriting_Existing_Data()
+ {
+ var root = CreateTempRoot();
+ using var storage = new FileStorage(root, new JsonSerializer(), ".json");
+ var repository = new DataRepository(
+ storage,
+ new DataRepositoryOptions
+ {
+ AutoBackup = true,
+ EnableEvents = false
+ });
+ var location = new TestDataLocation("options", namespaceValue: "profile");
+
+ await repository.SaveAsync(location, new TestSimpleData { Value = 1 });
+ await repository.SaveAsync(location, new TestSimpleData { Value = 2 });
+
+ var current = await repository.LoadAsync(location);
+ var backup = await storage.ReadAsync("profile/options.backup");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(current.Value, Is.EqualTo(2));
+ Assert.That(backup.Value, Is.EqualTo(1));
+ });
+ }
+
+ ///
+ /// 验证通用数据仓库的批量保存只发送批量事件,不重复发送单项保存事件。
+ ///
+ /// 表示异步测试完成的任务。
+ [Test]
+ public async Task DataRepository_SaveAllAsync_Should_Emit_Only_Batch_Event()
+ {
+ var root = CreateTempRoot();
+ using var storage = new FileStorage(root, new JsonSerializer(), ".json");
+ var repository = new DataRepository(
+ storage,
+ new DataRepositoryOptions
+ {
+ AutoBackup = false,
+ EnableEvents = true
+ });
+ var context = CreateEventContext();
+ ((IContextAware)repository).SetContext(context);
+
+ var location1 = new TestDataLocation("graphics", namespaceValue: "settings");
+ var location2 = new TestDataLocation("audio", namespaceValue: "settings");
+ var savedEventCount = 0;
+ var batchEventCount = 0;
+
+ context.RegisterEvent>(_ => savedEventCount++);
+ context.RegisterEvent(_ => batchEventCount++);
+
+ await repository.SaveAllAsync(
+ [
+ (location1, (IData)new TestSimpleData { Value = 10 }),
+ (location2, (IData)new TestSimpleData { Value = 20 })
+ ]);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(savedEventCount, Is.Zero);
+ Assert.That(batchEventCount, Is.EqualTo(1));
+ });
+ }
+
+ ///
+ /// 验证统一设置仓库在批量覆盖时会为整个聚合文件创建备份,并只发送批量事件。
+ ///
+ /// 表示异步测试完成的任务。
+ [Test]
+ public async Task UnifiedSettingsDataRepository_SaveAllAsync_Should_Create_Backup_And_Emit_Only_Batch_Event()
+ {
+ var root = CreateTempRoot();
+ var location1 = new TestDataLocation("settings/graphics");
+ var location2 = new TestDataLocation("settings/audio");
+
+ using (var seedStorage = new FileStorage(root, new JsonSerializer(), ".json"))
+ {
+ var seedRepository = new UnifiedSettingsDataRepository(
+ seedStorage,
+ new JsonSerializer(),
+ new DataRepositoryOptions
+ {
+ AutoBackup = true,
+ EnableEvents = false
+ },
+ "settings.json");
+ seedRepository.RegisterDataType(location1, typeof(TestSimpleData));
+ seedRepository.RegisterDataType(location2, typeof(TestSimpleData));
+
+ await seedRepository.SaveAsync(location1, new TestSimpleData { Value = 1 });
+ }
+
+ using var storage = new FileStorage(root, new JsonSerializer(), ".json");
+ var repository = new UnifiedSettingsDataRepository(
+ storage,
+ new JsonSerializer(),
+ new DataRepositoryOptions
+ {
+ AutoBackup = true,
+ EnableEvents = true
+ },
+ "settings.json");
+ repository.RegisterDataType(location1, typeof(TestSimpleData));
+ repository.RegisterDataType(location2, typeof(TestSimpleData));
+
+ var context = CreateEventContext();
+ ((IContextAware)repository).SetContext(context);
+
+ var savedEventCount = 0;
+ var batchEventCount = 0;
+
+ context.RegisterEvent>(_ => savedEventCount++);
+ context.RegisterEvent(_ => batchEventCount++);
+
+ await repository.SaveAllAsync(
+ [
+ (location1, (IData)new TestSimpleData { Value = 2 }),
+ (location2, (IData)new TestSimpleData { Value = 3 })
+ ]);
+
+ var current = await repository.LoadAsync(location1);
+ var backupJson = File.ReadAllText(Path.Combine(root, "settings.json.backup.json"));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(current.Value, Is.EqualTo(2));
+ Assert.That(savedEventCount, Is.Zero);
+ Assert.That(batchEventCount, Is.EqualTo(1));
+ Assert.That(backupJson, Does.Contain("settings/graphics"));
+ Assert.That(backupJson, Does.Contain("\\\"Value\\\":1"));
+ });
+ }
+
+ ///
+ /// 验证统一设置仓库在删除某个 section 时会回写聚合文件,并保留删除前的统一文件备份。
+ ///
+ /// 表示异步测试完成的任务。
+ [Test]
+ public async Task UnifiedSettingsDataRepository_DeleteAsync_Should_Persist_Deletion_And_Create_Backup()
+ {
+ var root = CreateTempRoot();
+ var location1 = new TestDataLocation("settings/graphics");
+ var location2 = new TestDataLocation("settings/audio");
+
+ using (var storage = new FileStorage(root, new JsonSerializer(), ".json"))
+ {
+ var repository = new UnifiedSettingsDataRepository(
+ storage,
+ new JsonSerializer(),
+ new DataRepositoryOptions
+ {
+ AutoBackup = true,
+ EnableEvents = false
+ },
+ "settings.json");
+ repository.RegisterDataType(location1, typeof(TestSimpleData));
+ repository.RegisterDataType(location2, typeof(TestSimpleData));
+
+ await repository.SaveAllAsync(
+ [
+ (location1, (IData)new TestSimpleData { Value = 7 }),
+ (location2, (IData)new TestSimpleData { Value = 11 })
+ ]);
+ }
+
+ using var verifyStorage = new FileStorage(root, new JsonSerializer(), ".json");
+ var verifyRepository = new UnifiedSettingsDataRepository(
+ verifyStorage,
+ new JsonSerializer(),
+ new DataRepositoryOptions
+ {
+ AutoBackup = true,
+ EnableEvents = false
+ },
+ "settings.json");
+ verifyRepository.RegisterDataType(location1, typeof(TestSimpleData));
+ verifyRepository.RegisterDataType(location2, typeof(TestSimpleData));
+
+ await verifyRepository.DeleteAsync(location2);
+
+ var remaining = await verifyRepository.LoadAsync(location1);
+ var removedExists = await verifyRepository.ExistsAsync(location2);
+ var backupJson = File.ReadAllText(Path.Combine(root, "settings.json.backup.json"));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(remaining.Value, Is.EqualTo(7));
+ Assert.That(removedExists, Is.False);
+ Assert.That(backupJson, Does.Contain("settings/audio"));
+ Assert.That(backupJson, Does.Contain("\\\"Value\\\":11"));
+ });
+ }
+
+ ///
+ /// 验证统一设置仓库在启用事件时,只为显式仓库操作发送加载、保存、批量保存和删除事件。
+ ///
+ /// 表示异步测试完成的任务。
+ [Test]
+ public async Task UnifiedSettingsDataRepository_WithEvents_Should_Emit_Only_Public_Operation_Events()
+ {
+ var root = CreateTempRoot();
+ using var storage = new FileStorage(root, new JsonSerializer(), ".json");
+ var repository = new UnifiedSettingsDataRepository(
+ storage,
+ new JsonSerializer(),
+ new DataRepositoryOptions
+ {
+ AutoBackup = true,
+ EnableEvents = true
+ },
+ "settings.json");
+ var context = CreateEventContext();
+ ((IContextAware)repository).SetContext(context);
+
+ var location1 = new TestDataLocation("settings/graphics");
+ var location2 = new TestDataLocation("settings/audio");
+ repository.RegisterDataType(location1, typeof(TestSimpleData));
+ repository.RegisterDataType(location2, typeof(TestSimpleData));
+
+ var loadedEventCount = 0;
+ var savedEventCount = 0;
+ var batchEventCount = 0;
+ var deletedEventCount = 0;
+
+ context.RegisterEvent>(_ => loadedEventCount++);
+ context.RegisterEvent>(_ => savedEventCount++);
+ context.RegisterEvent(_ => batchEventCount++);
+ context.RegisterEvent(_ => deletedEventCount++);
+
+ _ = await repository.LoadAsync(location1);
+ await repository.SaveAsync(location1, new TestSimpleData { Value = 5 });
+ await repository.SaveAllAsync(
+ [
+ (location1, (IData)new TestSimpleData { Value = 6 }),
+ (location2, (IData)new TestSimpleData { Value = 7 })
+ ]);
+ await repository.DeleteAsync(location2);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(loadedEventCount, Is.EqualTo(1));
+ Assert.That(savedEventCount, Is.EqualTo(1));
+ Assert.That(batchEventCount, Is.EqualTo(1));
+ Assert.That(deletedEventCount, Is.EqualTo(1));
+ });
+ }
+
+ ///
+ /// 创建带事件总线的真实架构上下文,供上下文感知仓库测试使用。
+ ///
+ /// 可用于发送和监听事件的架构上下文。
+ private static ArchitectureContext CreateEventContext()
+ {
+ var container = new MicrosoftDiContainer();
+ container.Register(new EventBus());
+ container.Freeze();
+ return new ArchitectureContext(container);
+ }
+
private sealed class TestSaveMigrationV1ToV2 : ISaveMigration
{
public int FromVersion => 1;
diff --git a/GFramework.Game.Tests/Setting/SettingsSystemTests.cs b/GFramework.Game.Tests/Setting/SettingsSystemTests.cs
new file mode 100644
index 00000000..4a2786a5
--- /dev/null
+++ b/GFramework.Game.Tests/Setting/SettingsSystemTests.cs
@@ -0,0 +1,347 @@
+using GFramework.Core.Abstractions.Architectures;
+using GFramework.Core.Abstractions.Enums;
+using GFramework.Core.Abstractions.Events;
+using GFramework.Core.Abstractions.Rule;
+using GFramework.Core.Architectures;
+using GFramework.Core.Events;
+using GFramework.Core.Ioc;
+using GFramework.Game.Abstractions.Setting;
+using GFramework.Game.Setting;
+using GFramework.Game.Setting.Events;
+
+namespace GFramework.Game.Tests.Setting;
+
+///
+/// 覆盖 的系统层语义,确保系统对模型编排、事件发送和重置流程保持稳定。
+///
+[TestFixture]
+public sealed class SettingsSystemTests
+{
+ ///
+ /// 验证 会尝试应用全部 applicator,并为成功与失败结果分别发送事件。
+ ///
+ /// 表示异步测试完成的任务。
+ [Test]
+ public async Task ApplyAll_Should_Apply_All_Applicators_And_Publish_Result_Events()
+ {
+ var successfulApplicator = new PrimaryTestSettings();
+ var failingApplicator = new SecondaryTestSettings(throwOnApply: true);
+ var model = new FakeSettingsModel(successfulApplicator, failingApplicator);
+ var context = CreateContext(model);
+ var system = CreateSystem(context);
+
+ var applyingEventCount = 0;
+ var appliedEventCount = 0;
+ var failedEventCount = 0;
+
+ context.RegisterEvent>(_ => applyingEventCount++);
+ context.RegisterEvent>(eventData =>
+ {
+ appliedEventCount++;
+ if (!eventData.Success)
+ {
+ failedEventCount++;
+ }
+ });
+
+ await system.ApplyAll();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(successfulApplicator.ApplyCount, Is.EqualTo(1));
+ Assert.That(failingApplicator.ApplyCount, Is.EqualTo(1));
+ Assert.That(applyingEventCount, Is.EqualTo(2));
+ Assert.That(appliedEventCount, Is.EqualTo(2));
+ Assert.That(failedEventCount, Is.EqualTo(1));
+ });
+ }
+
+ ///
+ /// 验证 会直接委托给模型层统一保存。
+ ///
+ /// 表示异步测试完成的任务。
+ [Test]
+ public async Task SaveAll_Should_Delegate_To_Model()
+ {
+ var model = new FakeSettingsModel(new PrimaryTestSettings());
+ var system = CreateSystem(CreateContext(model));
+
+ await system.SaveAll();
+
+ Assert.That(model.SaveAllCallCount, Is.EqualTo(1));
+ }
+
+ ///
+ /// 验证 会先委托模型统一重置,再重新应用全部 applicator。
+ ///
+ /// 表示异步测试完成的任务。
+ [Test]
+ public async Task ResetAll_Should_Reset_Model_And_Reapply_All_Applicators()
+ {
+ var applicator = new PrimaryTestSettings();
+ var model = new FakeSettingsModel(applicator);
+ var system = CreateSystem(CreateContext(model));
+
+ await system.ResetAll();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(model.ResetAllCallCount, Is.EqualTo(1));
+ Assert.That(applicator.ApplyCount, Is.EqualTo(1));
+ });
+ }
+
+ ///
+ /// 验证 会重置目标数据类型,并只重新应用对应的 applicator。
+ ///
+ /// 表示异步测试完成的任务。
+ [Test]
+ public async Task Reset_Should_Reset_Target_Data_And_Reapply_Target_Applicator()
+ {
+ var applicator = new PrimaryTestSettings();
+ var model = new FakeSettingsModel(applicator);
+ var system = CreateSystem(CreateContext(model));
+
+ await system.Reset();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(model.ResetTypes, Is.EquivalentTo(new[] { typeof(PrimaryTestSettings) }));
+ Assert.That(applicator.ApplyCount, Is.EqualTo(1));
+ });
+ }
+
+ ///
+ /// 创建带事件总线和设置模型的真实架构上下文。
+ ///
+ /// 测试使用的设置模型。
+ /// 可供系统解析依赖与发送事件的上下文。
+ private static ArchitectureContext CreateContext(ISettingsModel model)
+ {
+ var container = new MicrosoftDiContainer();
+ container.Register(new EventBus());
+ container.Register(model);
+ container.Freeze();
+ return new ArchitectureContext(container);
+ }
+
+ ///
+ /// 创建并初始化绑定到指定上下文的设置系统。
+ ///
+ /// 系统运行所需的架构上下文。
+ /// 已完成初始化的设置系统实例。
+ private static SettingsSystem CreateSystem(IArchitectureContext context)
+ {
+ var system = new SettingsSystem();
+ ((IContextAware)system).SetContext(context);
+ system.Initialize();
+ return system;
+ }
+
+ ///
+ /// 用于系统层测试的简化设置模型,记录系统对模型的调用行为。
+ ///
+ private sealed class FakeSettingsModel : ISettingsModel
+ {
+ private readonly IReadOnlyDictionary _applicators;
+
+ ///
+ /// 初始化测试模型,并注册参与测试的 applicator 集合。
+ ///
+ /// 测试使用的 applicator。
+ public FakeSettingsModel(params IResetApplyAbleSettings[] applicators)
+ {
+ _applicators = applicators.ToDictionary(applicator => applicator.GetType());
+ }
+
+ ///
+ /// 获取保存全量设置的调用次数。
+ ///
+ public int SaveAllCallCount { get; private set; }
+
+ ///
+ /// 获取重置全部设置的调用次数。
+ ///
+ public int ResetAllCallCount { get; private set; }
+
+ ///
+ /// 获取被请求重置的设置数据类型列表。
+ ///
+ public List ResetTypes { get; } = [];
+
+ ///
+ public bool IsInitialized => true;
+
+ ///
+ public void SetContext(IArchitectureContext context)
+ {
+ }
+
+ ///
+ public IArchitectureContext GetContext()
+ {
+ throw new NotSupportedException("Fake settings model does not expose a context.");
+ }
+
+ ///
+ public void OnArchitecturePhase(ArchitecturePhase phase)
+ {
+ }
+
+ ///
+ public void Initialize()
+ {
+ }
+
+ ///
+ public T GetData() where T : class, ISettingsData, new()
+ {
+ return new T();
+ }
+
+ ///
+ public IEnumerable AllData()
+ {
+ return [];
+ }
+
+ ///
+ public ISettingsModel RegisterApplicator(T applicator) where T : class, IResetApplyAbleSettings
+ {
+ return this;
+ }
+
+ ///
+ public T? GetApplicator() where T : class, IResetApplyAbleSettings
+ {
+ return _applicators.TryGetValue(typeof(T), out var applicator) ? (T)applicator : null;
+ }
+
+ ///
+ public IEnumerable AllApplicators()
+ {
+ return _applicators.Values;
+ }
+
+ ///
+ public ISettingsModel RegisterMigration(ISettingsMigration migration)
+ {
+ return this;
+ }
+
+ ///
+ public Task InitializeAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task SaveAllAsync()
+ {
+ SaveAllCallCount++;
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task ApplyAllAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ public void Reset() where T : class, ISettingsData, new()
+ {
+ ResetTypes.Add(typeof(T));
+ }
+
+ ///
+ public void ResetAll()
+ {
+ ResetAllCallCount++;
+ }
+ }
+
+ ///
+ /// 为系统层测试提供的最小设置数据实现。
+ ///
+ private abstract class TestSettingsBase : ISettingsData, IResetApplyAbleSettings
+ {
+ ///
+ public int Version { get; set; } = 1;
+
+ ///
+ public DateTime LastModified { get; } = DateTime.UtcNow;
+
+ ///
+ /// 获取测试用的数值字段,用于确认重置与加载行为。
+ ///
+ public int Value { get; private set; }
+
+ ///
+ /// 获取应用操作被调用的次数。
+ ///
+ public int ApplyCount { get; private set; }
+
+ ///
+ public ISettingsData Data => this;
+
+ ///
+ public Type DataType => GetType();
+
+ ///
+ /// 获取或设置是否在应用时抛出异常。
+ ///
+ protected bool ThrowOnApply { get; init; }
+
+ ///
+ public void Reset()
+ {
+ Value = 0;
+ }
+
+ ///
+ public void LoadFrom(ISettingsData source)
+ {
+ if (source is TestSettingsBase data)
+ {
+ Value = data.Value;
+ Version = data.Version;
+ }
+ }
+
+ ///
+ public async Task Apply()
+ {
+ ApplyCount++;
+
+ await Task.Yield();
+
+ if (ThrowOnApply)
+ {
+ throw new InvalidOperationException("Test applicator failed.");
+ }
+ }
+ }
+
+ ///
+ /// 代表主设置分支的测试设置对象。
+ ///
+ private sealed class PrimaryTestSettings : TestSettingsBase
+ {
+ }
+
+ ///
+ /// 代表第二个设置分支的测试设置对象,可选择在应用时失败。
+ ///
+ private sealed class SecondaryTestSettings : TestSettingsBase
+ {
+ ///
+ /// 初始化第二个测试设置对象。
+ ///
+ /// 是否在应用时抛出异常。
+ public SecondaryTestSettings(bool throwOnApply = false)
+ {
+ ThrowOnApply = throwOnApply;
+ }
+ }
+}
diff --git a/GFramework.Game/Data/DataRepository.cs b/GFramework.Game/Data/DataRepository.cs
index 5276ec0b..b4820119 100644
--- a/GFramework.Game/Data/DataRepository.cs
+++ b/GFramework.Game/Data/DataRepository.cs
@@ -11,6 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+using System.Reflection;
using GFramework.Core.Abstractions.Storage;
using GFramework.Core.Extensions;
using GFramework.Core.Utility;
@@ -21,13 +22,17 @@ using GFramework.Game.Extensions;
namespace GFramework.Game.Data;
///
-/// 数据仓库类,用于管理游戏数据的存储和读取
+/// 数据仓库类,用于管理游戏数据的存储和读取。
///
/// 存储接口实例
/// 数据仓库配置选项
public class DataRepository(IStorage? storage, DataRepositoryOptions? options = null)
: AbstractContextUtility, IDataRepository
{
+ private static readonly MethodInfo SaveCoreGenericMethod =
+ typeof(DataRepository).GetMethod(nameof(SaveCoreAsync), BindingFlags.Instance | BindingFlags.NonPublic)
+ ?? throw new InvalidOperationException($"Method {nameof(SaveCoreAsync)} not found.");
+
private readonly DataRepositoryOptions _options = options ?? new DataRepositoryOptions();
private IStorage? _storage = storage;
@@ -65,20 +70,7 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options =
public async Task SaveAsync(IDataLocation location, T data)
where T : class, IData
{
- var key = location.ToStorageKey();
-
- // 自动备份
- if (_options.AutoBackup && await Storage.ExistsAsync(key))
- {
- var backupKey = $"{key}.backup";
- var existing = await Storage.ReadAsync(key);
- await Storage.WriteAsync(backupKey, existing);
- }
-
- await Storage.WriteAsync(key, data);
-
- if (_options.EnableEvents)
- this.SendEvent(new DataSavedEvent(data));
+ await SaveCoreAsync(location, data, emitSavedEvent: true);
}
///
@@ -98,6 +90,12 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options =
public async Task DeleteAsync(IDataLocation location)
{
var key = location.ToStorageKey();
+
+ if (!await Storage.ExistsAsync(key))
+ {
+ return;
+ }
+
await Storage.DeleteAsync(key);
if (_options.EnableEvents)
this.SendEvent(new DataDeletedEvent(location));
@@ -110,7 +108,13 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options =
public async Task SaveAllAsync(IEnumerable<(IDataLocation location, IData data)> dataList)
{
var valueTuples = dataList.ToList();
- foreach (var (location, data) in valueTuples) await SaveAsync(location, data);
+
+ // 批量保存对订阅者而言应视为一次显式提交,因此这里复用底层保存逻辑,
+ // 但抑制逐项 DataSavedEvent,避免监听器对同一批次收到重复语义的事件。
+ foreach (var (location, data) in valueTuples)
+ {
+ await SaveCoreUntypedAsync(location, data, emitSavedEvent: false);
+ }
if (_options.EnableEvents)
this.SendEvent(new DataBatchSavedEvent(valueTuples));
@@ -123,4 +127,56 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options =
{
_storage ??= this.GetUtility()!;
}
-}
\ No newline at end of file
+
+ ///
+ /// 执行单项保存的共享流程,并根据调用入口决定是否发送单项保存事件。
+ ///
+ /// 数据类型。
+ /// 目标数据位置。
+ /// 要保存的数据对象。
+ /// 是否在成功写入后发送单项保存事件。
+ private async Task SaveCoreAsync(IDataLocation location, T data, bool emitSavedEvent)
+ where T : class, IData
+ {
+ var key = location.ToStorageKey();
+
+ await BackupIfNeededAsync(key);
+ await Storage.WriteAsync(key, data);
+
+ if (emitSavedEvent && _options.EnableEvents)
+ {
+ this.SendEvent(new DataSavedEvent(data));
+ }
+ }
+
+ ///
+ /// 在覆盖旧值前为当前存储键创建备份。
+ ///
+ /// 即将被覆盖的存储键。
+ private async Task BackupIfNeededAsync(string key)
+ where T : class, IData
+ {
+ if (!_options.AutoBackup || !await Storage.ExistsAsync(key))
+ {
+ return;
+ }
+
+ var backupKey = $"{key}.backup";
+ var existing = await Storage.ReadAsync(key);
+ await Storage.WriteAsync(backupKey, existing);
+ }
+
+ ///
+ /// 使用数据对象的运行时类型执行保存流程,避免批量保存时因为编译期类型退化为 而破坏备份反序列化。
+ ///
+ /// 目标数据位置。
+ /// 要保存的数据对象。
+ /// 是否发送单项保存事件。
+ private Task SaveCoreUntypedAsync(IDataLocation location, IData data, bool emitSavedEvent)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
+ var closedMethod = SaveCoreGenericMethod.MakeGenericMethod(data.GetType());
+ return (Task)closedMethod.Invoke(this, [location, data, emitSavedEvent])!;
+ }
+}
diff --git a/GFramework.Game/Data/UnifiedSettingsDataRepository.cs b/GFramework.Game/Data/UnifiedSettingsDataRepository.cs
index 8fd693a7..4950ddf0 100644
--- a/GFramework.Game/Data/UnifiedSettingsDataRepository.cs
+++ b/GFramework.Game/Data/UnifiedSettingsDataRepository.cs
@@ -21,8 +21,13 @@ using GFramework.Game.Abstractions.Data.Events;
namespace GFramework.Game.Data;
///
-/// 使用单一文件存储所有设置数据的仓库实现
+/// 使用单一文件存储所有设置数据的仓库实现。
///
+///
+/// 该仓库通过内存缓存聚合所有设置 section,并在公开的保存或删除操作发生时整文件回写。
+/// 虽然底层不是“一项一个文件”,但它仍遵循 定义的统一契约:
+/// 启用自动备份时,覆盖写入前会为整个统一文件创建单份备份;批量保存只发出批量事件,不重复发出单项保存事件。
+///
public class UnifiedSettingsDataRepository(
IStorage? storage,
IRuntimeTypeSerializer? serializer,
@@ -66,7 +71,7 @@ public class UnifiedSettingsDataRepository(
var key = location.Key;
var result = _file!.Sections.TryGetValue(key, out var raw) ? Serializer.Deserialize(raw) : new T();
if (_options.EnableEvents)
- this.SendEvent(new DataLoadedEvent(result));
+ this.SendEvent(new DataLoadedEvent(result));
return result;
}
@@ -81,21 +86,11 @@ public class UnifiedSettingsDataRepository(
where T : class, IData
{
await EnsureLoadedAsync();
- await _lock.WaitAsync();
- try
- {
- var key = location.Key;
- var serialized = Serializer.Serialize(data);
+ await MutateAndPersistAsync(file => file.Sections[location.Key] = Serializer.Serialize(data));
- _file!.Sections[key] = serialized;
-
- await Storage.WriteAsync(UnifiedKey, _file);
- if (_options.EnableEvents)
- this.SendEvent(new DataSavedEvent(data));
- }
- finally
+ if (_options.EnableEvents)
{
- _lock.Release();
+ this.SendEvent(new DataSavedEvent(data));
}
}
@@ -118,13 +113,27 @@ public class UnifiedSettingsDataRepository(
public async Task DeleteAsync(IDataLocation location)
{
await EnsureLoadedAsync();
+ var removed = false;
- if (File.Sections.Remove(location.Key))
+ await _lock.WaitAsync();
+ try
{
- await SaveUnifiedFileAsync();
+ removed = File.Sections.Remove(location.Key);
+ if (!removed)
+ {
+ return;
+ }
- if (_options.EnableEvents)
- this.SendEvent(new DataDeletedEvent(location));
+ await WriteUnifiedFileCoreAsync();
+ }
+ finally
+ {
+ _lock.Release();
+ }
+
+ if (removed && _options.EnableEvents)
+ {
+ this.SendEvent(new DataDeletedEvent(location));
}
}
@@ -139,16 +148,17 @@ public class UnifiedSettingsDataRepository(
await EnsureLoadedAsync();
var valueTuples = dataList.ToList();
- foreach (var (location, data) in valueTuples)
- {
- var serialized = Serializer.Serialize(data);
- File.Sections[location.Key] = serialized;
- }
- await SaveUnifiedFileAsync();
+ await MutateAndPersistAsync(file =>
+ {
+ foreach (var (location, data) in valueTuples)
+ {
+ file.Sections[location.Key] = Serializer.Serialize(data);
+ }
+ });
if (_options.EnableEvents)
- this.SendEvent(new DataBatchSavedEvent(valueTuples.ToList()));
+ this.SendEvent(new DataBatchSavedEvent(valueTuples));
}
///
@@ -226,12 +236,13 @@ public class UnifiedSettingsDataRepository(
///
/// 将缓存中的所有数据保存到统一文件
///
- private async Task SaveUnifiedFileAsync()
+ private async Task MutateAndPersistAsync(Action mutation)
{
await _lock.WaitAsync();
try
{
- await Storage.WriteAsync(UnifiedKey, _file);
+ mutation(File);
+ await WriteUnifiedFileCoreAsync();
}
finally
{
@@ -240,11 +251,29 @@ public class UnifiedSettingsDataRepository(
}
///
- /// 获取统一文件的存储键名
+ /// 将当前缓存中的统一文件写回底层存储,并在需要时创建整个文件的备份。
///
- /// 完整的存储键名
+ ///
+ /// 该方法要求调用方已经持有 ,以保证“修改缓存 -> 备份旧文件 -> 写入新文件”观察到的是同一份一致状态。
+ ///
+ private async Task WriteUnifiedFileCoreAsync()
+ {
+ if (_options.AutoBackup && await Storage.ExistsAsync(UnifiedKey))
+ {
+ var backupKey = $"{UnifiedKey}.backup";
+ var existing = await Storage.ReadAsync(UnifiedKey);
+ await Storage.WriteAsync(backupKey, existing);
+ }
+
+ await Storage.WriteAsync(UnifiedKey, File);
+ }
+
+ ///
+ /// 获取统一文件的存储键名。
+ ///
+ /// 完整的存储键名。
protected virtual string GetUnifiedKey()
{
return string.IsNullOrEmpty(_options.BasePath) ? fileName : $"{_options.BasePath}/{fileName}";
}
-}
\ No newline at end of file
+}
diff --git a/docs/zh-CN/game/data.md b/docs/zh-CN/game/data.md
index 100e30eb..71a5ed28 100644
--- a/docs/zh-CN/game/data.md
+++ b/docs/zh-CN/game/data.md
@@ -48,6 +48,20 @@ public interface IDataRepository : IUtility
}
```
+`IDataRepository` 描述的是“仓库语义”,不是固定的落盘格式。实现可以选择每个数据项独立成文件,也可以把多个 section 聚合到同一个文件里。
+
+当前内建实现里:
+
+- `DataRepository` 采用“每个 location 一份持久化对象”的模型
+- `UnifiedSettingsDataRepository` 采用“所有设置聚合到一个统一文件”的模型
+
+两者对外遵守同一套约定:
+
+- `SaveAllAsync(...)` 视为一次批量提交,只发送 `DataBatchSavedEvent`,不会再为每个条目重复发送 `DataSavedEvent`
+- `DeleteAsync(...)` 只有在目标数据真实存在并被删除时才会发送删除事件
+- 当 `DataRepositoryOptions.AutoBackup = true` 时,覆盖已有数据前会先保留上一份快照
+- 对 `UnifiedSettingsDataRepository` 来说,备份粒度是整个统一文件,而不是单个设置 section
+
### 存档仓库
`ISaveRepository` 专门用于管理游戏存档:
@@ -413,6 +427,29 @@ public async Task SaveAllGameData()
}
```
+`SaveAllAsync(...)` 的事件语义和逐项调用 `SaveAsync(...)` 不同。它代表一次显式的批量提交,因此适合让监听器在收到 `DataBatchSavedEvent` 时统一刷新 UI、缓存或元数据,而不是对每个条目单独响应。
+
+### 聚合设置仓库
+
+如果你希望把设置统一保存到单个文件中,可以使用 `UnifiedSettingsDataRepository`:
+
+```csharp
+var settingsRepo = new UnifiedSettingsDataRepository(
+ storage,
+ serializer,
+ new DataRepositoryOptions
+ {
+ AutoBackup = true,
+ EnableEvents = true
+ },
+ "settings.json");
+```
+
+这个实现依然满足 `IDataRepository` 的通用契约,但有两个实现层面的差异需要明确:
+
+- 它把所有设置 section 缓存在内存中,并在保存或删除时整文件回写
+- 开启自动备份时,备份的是整个 `settings.json` 文件,因此适合做“上一次完整设置快照”的恢复,而不是 section 级别回滚
+
### 存档备份
```csharp