diff --git a/GFramework.Game.Abstractions/Data/ISaveMigration.cs b/GFramework.Game.Abstractions/Data/ISaveMigration.cs
new file mode 100644
index 00000000..4c11e470
--- /dev/null
+++ b/GFramework.Game.Abstractions/Data/ISaveMigration.cs
@@ -0,0 +1,44 @@
+// Copyright (c) 2026 GeWuYou
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System;
+
+namespace GFramework.Game.Abstractions.Data;
+
+///
+/// 定义存档数据迁移接口,用于将旧版本存档升级到较新的版本。
+///
+///
+/// 存档数据类型。该类型通常需要实现 ,
+/// 以便仓库在加载时判断当前版本并串联迁移链。
+///
+public interface ISaveMigration
+ where TSaveData : class, IData
+{
+ ///
+ /// 获取迁移前的版本号。
+ ///
+ int FromVersion { get; }
+
+ ///
+ /// 获取迁移后的目标版本号。
+ ///
+ int ToVersion { get; }
+
+ ///
+ /// 将旧版本存档转换为新版本存档。
+ ///
+ /// 待升级的旧版本存档数据。
+ /// 迁移完成后的存档数据。
+ TSaveData Migrate(TSaveData oldData);
+}
diff --git a/GFramework.Game.Abstractions/Data/ISaveRepository.cs b/GFramework.Game.Abstractions/Data/ISaveRepository.cs
index 52e160ac..363ae62e 100644
--- a/GFramework.Game.Abstractions/Data/ISaveRepository.cs
+++ b/GFramework.Game.Abstractions/Data/ISaveRepository.cs
@@ -11,6 +11,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
using GFramework.Core.Abstractions.Utility;
namespace GFramework.Game.Abstractions.Data;
@@ -22,6 +25,20 @@ namespace GFramework.Game.Abstractions.Data;
public interface ISaveRepository : IUtility
where TSaveData : class, IData, new()
{
+ ///
+ /// 注册存档迁移器。
+ ///
+ ///
+ /// 负责将某个旧版本存档升级到新版本的迁移器。
+ /// 仅当 实现 时该功能才有效。
+ ///
+ /// 当前存档仓库实例,便于链式注册多个迁移器。
+ /// 为 。
+ ///
+ /// 未实现 ,因此无法建立版本迁移管线。
+ ///
+ ISaveRepository RegisterMigration(ISaveMigration migration);
+
///
/// 检查指定槽位是否存在存档
///
diff --git a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs
index 9324b7b8..440df286 100644
--- a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs
+++ b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs
@@ -1,3 +1,4 @@
+using System;
using GFramework.Game.Abstractions.Data;
using GFramework.Game.Abstractions.Enums;
@@ -14,7 +15,20 @@ internal sealed class TestSaveData : IData
public string Name { get; set; } = string.Empty;
}
+internal sealed class TestVersionedSaveData : IVersionedData
+{
+ public string Name { get; set; } = string.Empty;
+
+ public int Level { get; set; }
+
+ public int Experience { get; set; }
+
+ public int Version { get; set; } = 3;
+
+ public DateTime LastModified { get; set; } = DateTime.UtcNow;
+}
+
internal sealed class TestSimpleData : IData
{
public int Value { get; set; }
-}
\ No newline at end of file
+}
diff --git a/GFramework.Game.Tests/Data/PersistenceTests.cs b/GFramework.Game.Tests/Data/PersistenceTests.cs
index c5937034..68f64edb 100644
--- a/GFramework.Game.Tests/Data/PersistenceTests.cs
+++ b/GFramework.Game.Tests/Data/PersistenceTests.cs
@@ -59,6 +59,84 @@ public class PersistenceTests
Assert.That(await repository.ExistsAsync(1), Is.False);
}
+ [Test]
+ public async Task SaveRepository_LoadAsync_Should_Apply_Migrations_And_Persist_Upgraded_Save()
+ {
+ var root = CreateTempRoot();
+ using var storage = new FileStorage(root, new JsonSerializer());
+ var config = new SaveConfiguration
+ {
+ SaveRoot = "saves",
+ SaveSlotPrefix = "slot_",
+ SaveFileName = "save"
+ };
+
+ var writer = new SaveRepository(storage, config);
+ await writer.SaveAsync(1, new TestVersionedSaveData
+ {
+ Name = "hero",
+ Level = 5,
+ Experience = 0,
+ Version = 1
+ });
+
+ var repository = new SaveRepository(storage, config)
+ .RegisterMigration(new TestSaveMigrationV1ToV2())
+ .RegisterMigration(new TestSaveMigrationV2ToV3());
+
+ var loaded = await repository.LoadAsync(1);
+ var persisted = await storage.ReadAsync("saves/slot_1/save");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(loaded.Version, Is.EqualTo(3));
+ Assert.That(loaded.Experience, Is.EqualTo(500));
+ Assert.That(loaded.Name, Is.EqualTo("hero-v2"));
+ Assert.That(persisted.Version, Is.EqualTo(3));
+ Assert.That(persisted.Experience, Is.EqualTo(500));
+ Assert.That(persisted.Name, Is.EqualTo("hero-v2"));
+ });
+ }
+
+ [Test]
+ public void SaveRepository_RegisterMigration_For_NonVersioned_Save_Should_Throw()
+ {
+ var root = CreateTempRoot();
+ using var storage = new FileStorage(root, new JsonSerializer());
+ var config = new SaveConfiguration();
+ var repository = new SaveRepository(storage, config);
+
+ Assert.Throws(() => repository.RegisterMigration(new TestNonVersionedMigration()));
+ }
+
+ [Test]
+ public async Task SaveRepository_LoadAsync_Should_Throw_When_Migration_Chain_Is_Incomplete()
+ {
+ var root = CreateTempRoot();
+ using var storage = new FileStorage(root, new JsonSerializer());
+ var config = new SaveConfiguration
+ {
+ SaveRoot = "saves",
+ SaveSlotPrefix = "slot_",
+ SaveFileName = "save"
+ };
+
+ var writer = new SaveRepository(storage, config);
+ await writer.SaveAsync(1, new TestVersionedSaveData
+ {
+ Name = "legacy",
+ Level = 3,
+ Experience = 0,
+ Version = 1
+ });
+
+ var repository = new SaveRepository(storage, config)
+ .RegisterMigration(new TestSaveMigrationV1ToV2());
+
+ var exception = Assert.ThrowsAsync(async () => await repository.LoadAsync(1));
+ Assert.That(exception!.Message, Does.Contain("from version 2"));
+ }
+
[Test]
public async Task UnifiedSettingsDataRepository_RoundTripsDataAndLoadAll()
{
@@ -92,4 +170,57 @@ public class PersistenceTests
Assert.That(all.Keys, Contains.Item(location.Key));
Assert.That(all[location.Key], Is.TypeOf());
}
-}
\ No newline at end of file
+
+ private sealed class TestSaveMigrationV1ToV2 : ISaveMigration
+ {
+ public int FromVersion => 1;
+
+ public int ToVersion => 2;
+
+ public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
+ {
+ return new TestVersionedSaveData
+ {
+ Name = $"{oldData.Name}-v2",
+ Level = oldData.Level,
+ Experience = oldData.Level * 100,
+ Version = 2,
+ LastModified = oldData.LastModified
+ };
+ }
+ }
+
+ private sealed class TestSaveMigrationV2ToV3 : ISaveMigration
+ {
+ public int FromVersion => 2;
+
+ public int ToVersion => 3;
+
+ public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
+ {
+ return new TestVersionedSaveData
+ {
+ Name = oldData.Name,
+ Level = oldData.Level,
+ Experience = oldData.Experience,
+ Version = 3,
+ LastModified = oldData.LastModified
+ };
+ }
+ }
+
+ private sealed class TestNonVersionedMigration : ISaveMigration
+ {
+ public int FromVersion => 1;
+
+ public int ToVersion => 2;
+
+ public TestSaveData Migrate(TestSaveData oldData)
+ {
+ return new TestSaveData
+ {
+ Name = oldData.Name
+ };
+ }
+ }
+}
diff --git a/GFramework.Game/Data/SaveRepository.cs b/GFramework.Game/Data/SaveRepository.cs
index f7eb57f9..b916c79b 100644
--- a/GFramework.Game/Data/SaveRepository.cs
+++ b/GFramework.Game/Data/SaveRepository.cs
@@ -11,7 +11,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+using System;
+using System.Collections.Generic;
using System.Globalization;
+using System.Linq;
+using System.Threading.Tasks;
using GFramework.Core.Abstractions.Storage;
using GFramework.Core.Utility;
using GFramework.Game.Abstractions.Data;
@@ -27,6 +31,7 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository
where TSaveData : class, IData, new()
{
private readonly SaveConfiguration _config;
+ private readonly Dictionary> _migrations = new();
private readonly IStorage _rootStorage;
///
@@ -43,6 +48,32 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository
_rootStorage = new ScopedStorage(storage, config.SaveRoot);
}
+ ///
+ /// 注册存档迁移器,使仓库在加载旧版本存档时自动执行升级。
+ ///
+ /// 要注册的存档迁移器。
+ /// 当前存档仓库实例,支持链式调用。
+ /// 为 。
+ ///
+ /// 未实现 ,无法使用版本化迁移。
+ ///
+ /// 迁移器的目标版本不大于源版本。
+ public ISaveRepository RegisterMigration(ISaveMigration migration)
+ {
+ ArgumentNullException.ThrowIfNull(migration);
+ EnsureVersionedSaveType();
+
+ if (migration.ToVersion <= migration.FromVersion)
+ {
+ throw new ArgumentException(
+ $"Migration for {typeof(TSaveData).Name} must advance the version number.",
+ nameof(migration));
+ }
+
+ _migrations[migration.FromVersion] = migration;
+ return this;
+ }
+
///
/// 检查指定槽位是否存在存档
///
@@ -64,7 +95,10 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository
var storage = GetSlotStorage(slot);
if (await storage.ExistsAsync(_config.SaveFileName))
- return await storage.ReadAsync(_config.SaveFileName);
+ {
+ var loaded = await storage.ReadAsync(_config.SaveFileName);
+ return await MigrateIfNeededAsync(slot, storage, loaded);
+ }
return new TSaveData();
}
@@ -137,10 +171,114 @@ public class SaveRepository : AbstractContextUtility, ISaveRepository
return new ScopedStorage(_rootStorage, $"{_config.SaveSlotPrefix}{slot}");
}
+ ///
+ /// 在加载旧版本存档时按注册顺序执行迁移,并在成功后自动回写升级结果。
+ ///
+ /// 当前加载的存档槽位。
+ /// 对应槽位的存储对象。
+ /// 原始加载出来的存档数据。
+ /// 迁移后的最新存档;如果无需迁移则返回原始对象。
+ ///
+ /// 当前运行时缺少必要的迁移链、读取到更高版本的存档,或迁移器返回了非法版本。
+ ///
+ private async Task MigrateIfNeededAsync(int slot, IStorage storage, TSaveData data)
+ {
+ if (data is not IVersionedData versionedData)
+ {
+ return data;
+ }
+
+ var latestTemplate = new TSaveData();
+ if (latestTemplate is not IVersionedData latestVersionedData)
+ {
+ return data;
+ }
+
+ var currentVersion = versionedData.Version;
+ var targetVersion = latestVersionedData.Version;
+
+ if (currentVersion > targetVersion)
+ {
+ throw new InvalidOperationException(
+ $"Save slot {slot} for {typeof(TSaveData).Name} is version {currentVersion}, " +
+ $"which is newer than the current runtime version {targetVersion}.");
+ }
+
+ if (currentVersion == targetVersion)
+ {
+ return data;
+ }
+
+ EnsureVersionedSaveType();
+
+ var migrated = data;
+
+ // 迁移链按“当前版本 -> 下一个已注册迁移器”推进;任何缺口都表示运行时无法安全解释旧存档。
+ while (currentVersion < targetVersion)
+ {
+ if (!_migrations.TryGetValue(currentVersion, out var migration))
+ {
+ throw new InvalidOperationException(
+ $"No save migration is registered for {typeof(TSaveData).Name} from version {currentVersion}.");
+ }
+
+ migrated = migration.Migrate(migrated) ??
+ throw new InvalidOperationException(
+ $"Save migration for {typeof(TSaveData).Name} from version {currentVersion} returned null.");
+
+ if (migrated is not IVersionedData migratedVersionedData)
+ {
+ throw new InvalidOperationException(
+ $"Save migration for {typeof(TSaveData).Name} must return data implementing {nameof(IVersionedData)}.");
+ }
+
+ // 显式校验迁移器声明与实际结果,避免版本号不前进导致死循环或把旧数据错误回写为“已升级”。
+ if (migration.ToVersion != migratedVersionedData.Version)
+ {
+ throw new InvalidOperationException(
+ $"Save migration for {typeof(TSaveData).Name} declared target version {migration.ToVersion} " +
+ $"but returned version {migratedVersionedData.Version}.");
+ }
+
+ if (migratedVersionedData.Version <= currentVersion)
+ {
+ throw new InvalidOperationException(
+ $"Save migration for {typeof(TSaveData).Name} must advance beyond version {currentVersion}.");
+ }
+
+ if (migratedVersionedData.Version > targetVersion)
+ {
+ throw new InvalidOperationException(
+ $"Save migration for {typeof(TSaveData).Name} produced version {migratedVersionedData.Version}, " +
+ $"which exceeds the current runtime version {targetVersion}.");
+ }
+
+ currentVersion = migratedVersionedData.Version;
+ }
+
+ await storage.WriteAsync(_config.SaveFileName, migrated);
+ return migrated;
+ }
+
+ ///
+ /// 验证当前存档类型支持基于版本号的迁移流程。
+ ///
+ ///
+ /// 未实现 。
+ ///
+ private static void EnsureVersionedSaveType()
+ {
+ if (!typeof(IVersionedData).IsAssignableFrom(typeof(TSaveData)))
+ {
+ throw new InvalidOperationException(
+ $"{typeof(TSaveData).Name} must implement {nameof(IVersionedData)} to use save migrations.");
+ }
+ }
+
///
/// 初始化逻辑
///
protected override void OnInit()
{
}
-}
\ No newline at end of file
+}
diff --git a/GFramework.csproj b/GFramework.csproj
index b34d43f5..76f9a088 100644
--- a/GFramework.csproj
+++ b/GFramework.csproj
@@ -136,9 +136,4 @@
-
-
-
-
-
diff --git a/docs/zh-CN/game/data.md b/docs/zh-CN/game/data.md
index 607f7fb0..100e30eb 100644
--- a/docs/zh-CN/game/data.md
+++ b/docs/zh-CN/game/data.md
@@ -56,6 +56,7 @@ public interface IDataRepository : IUtility
public interface ISaveRepository : IUtility
where TSaveData : class, IData, new()
{
+ ISaveRepository RegisterMigration(ISaveMigration migration);
Task ExistsAsync(int slot);
Task LoadAsync(int slot);
Task SaveAsync(int slot, TSaveData data);
@@ -64,6 +65,18 @@ public interface ISaveRepository : IUtility
}
```
+`ISaveMigration` 定义单步迁移:
+
+```csharp
+public interface ISaveMigration
+ where TSaveData : class, IData
+{
+ int FromVersion { get; }
+ int ToVersion { get; }
+ TSaveData Migrate(TSaveData oldData);
+}
+```
+
### 版本化数据
`IVersionedData` 支持数据版本管理:
@@ -265,67 +278,77 @@ public partial class AutoSaveController : IController
### 数据版本迁移
-`SaveRepository` 当前负责槽位存档的读取、写入、删除和列举,并没有内建“注册迁移器后自动升级存档”的统一迁移管线。
+`SaveRepository` 现在支持注册正式的迁移器,并在 `LoadAsync(slot)` 时自动升级旧版本存档。
-下面示例展示的是应用层迁移策略:加载后检查版本,调用你自己的迁移逻辑,再决定是否回写新版本数据。
+迁移规则如下:
+
+- `TSaveData` 需要实现 `IVersionedData`
+- 仓库以 `new TSaveData().Version` 作为当前运行时目标版本
+- 每个迁移器负责一个 `FromVersion -> ToVersion` 跳转
+- 加载时仓库会按链路连续执行迁移,并在成功后自动回写升级后的存档
+- 如果缺少中间迁移器,或者读到了比当前运行时更高的版本,`LoadAsync` 会抛出异常,避免静默加载错误数据
```csharp
-// 版本 1 的数据
-public class SaveDataV1 : IVersionedData
-{
- public int Version { get; set; } = 1;
- public string PlayerName { get; set; }
- public int Level { get; set; }
-}
-
-// 版本 2 的数据(添加了新字段)
-public class SaveDataV2 : IVersionedData
+public sealed class SaveData : IVersionedData
{
+ // 当前运行时代码支持的最新版本
public int Version { get; set; } = 2;
public string PlayerName { get; set; }
public int Level { get; set; }
- public int Experience { get; set; } // 新增字段
- public DateTime LastPlayTime { get; set; } // 新增字段
+ public int Experience { get; set; }
+ public DateTime LastModified { get; set; }
}
-// 数据迁移器
-public class SaveDataMigrator
+public sealed class SaveDataMigrationV1ToV2 : ISaveMigration
{
- public SaveDataV2 Migrate(SaveDataV1 oldData)
+ public int FromVersion => 1;
+
+ public int ToVersion => 2;
+
+ public SaveData Migrate(SaveData oldData)
{
- return new SaveDataV2
+ return new SaveData
{
Version = 2,
PlayerName = oldData.PlayerName,
Level = oldData.Level,
- Experience = oldData.Level * 100, // 根据等级计算经验
- LastPlayTime = DateTime.Now
+ Experience = oldData.Level * 100,
+ LastModified = DateTime.UtcNow
};
}
}
-// 加载后由应用层决定是否迁移
-public async Task LoadWithMigration(int slot)
+public sealed class SaveModule : AbstractModule
{
- var saveRepo = this.GetUtility>();
- var data = await saveRepo.LoadAsync(slot);
-
- if (data.Version < 2)
+ public override void Install(IArchitecture architecture)
{
- // 需要迁移:此处调用应用层迁移器
- var oldData = data as SaveDataV1;
- var migrator = new SaveDataMigrator();
- var newData = migrator.Migrate(oldData);
+ var storage = architecture.GetUtility();
+ var saveConfig = new SaveConfiguration
+ {
+ SaveRoot = "saves",
+ SaveSlotPrefix = "slot_",
+ SaveFileName = "save"
+ };
- // 保存迁移后的数据
- await saveRepo.SaveAsync(slot, newData);
- return newData;
+ var saveRepo = new SaveRepository(storage, saveConfig)
+ .RegisterMigration(new SaveDataMigrationV1ToV2());
+
+ architecture.RegisterUtility>(saveRepo);
}
+}
- return data;
+public async Task LoadGame(int slot)
+{
+ var saveRepo = this.GetUtility>();
+
+ // 如果槽位里是 v1,仓库会自动迁移到 v2,并把新版本重新写回存储。
+ return await saveRepo.LoadAsync(slot);
}
```
+`ISaveMigration` 接收和返回的是同一个存档类型。也就是说,框架提供的是“当前类型内的版本升级管线”,
+而不是跨 CLR 类型的双模型反序列化系统。如果旧版本缺失了新字段,反序列化会先使用当前类型的默认值,再由迁移器补齐。
+
### 使用数据仓库
```csharp
@@ -514,15 +537,14 @@ await saveRepo.SaveAsync(3, saveData); // 槽位 3
### 问题:如何处理数据版本升级?
**解答**:
-实现 `IVersionedData` 并在加载后检查版本。当前框架不会自动为 `ISaveRepository` 执行迁移,需要由业务层决定迁移规则与回写时机:
+实现 `IVersionedData`,并在仓库初始化阶段注册 `ISaveMigration`。之后 `LoadAsync(slot)` 会自动执行迁移并回写:
```csharp
+var saveRepo = new SaveRepository(storage, saveConfig)
+ .RegisterMigration(new SaveDataMigrationV1ToV2())
+ .RegisterMigration(new SaveDataMigrationV2ToV3());
+
var data = await saveRepo.LoadAsync(slot);
-if (data.Version < CurrentVersion)
-{
- data = MigrateData(data);
- await saveRepo.SaveAsync(slot, data);
-}
```
### 问题:存档数据保存在哪里?
diff --git a/docs/zh-CN/game/setting.md b/docs/zh-CN/game/setting.md
index bbf16f2d..33a4313d 100644
--- a/docs/zh-CN/game/setting.md
+++ b/docs/zh-CN/game/setting.md
@@ -196,4 +196,4 @@ public interface ISettingsMigration
- 设置迁移是内建能力
- 设置持久化是内建能力
- 设置如何应用到具体引擎由 applicator 决定
-- 存档系统的迁移能力不等同于设置系统;`ISaveRepository` 当前仍需要业务层自己实现迁移策略
+- 存档系统也支持内建版本迁移,但入口位于 `ISaveRepository.RegisterMigration(...)`,语义是槽位存档升级而不是设置节初始化