Unreal Engine 保存系统笔记

UPROPERTY 里的 SaveGame 到底怎么用

SaveGame 不是 meta=(SaveGame) 元数据,而是一个 UPROPERTY 属性说明符。它会给属性打上 CPF_SaveGame 标记;只有当序列化归档处在 SaveGame 模式时,这个标记才会被用来过滤字段。

快速结论

最容易混淆的一点:SaveGame 标记本身不会把对象保存到磁盘。它只是一个属性标记。保存到磁盘的是 UGameplayStatics::SaveGameToSlotAsyncSaveGameToSlot 或你自己写的文件/字节流逻辑。

如果你只是做普通存档

创建一个继承自 USaveGame 的类,把要保存的数据作为 UPROPERTY 放进去,然后调用 SaveGameToSlot。这个流程是 UE 官方文档推荐的基本用法。

如果你想保存 Actor 字段

需要自己枚举目标 Actor,并用 ArIsSaveGame = true 的归档调用 Serialize。这时只有标了 SaveGame 的属性会被序列化。

SaveGame 是什么

在 C++ 里,正确写法是把 SaveGame 放在 UPROPERTY(...) 的说明符列表里:

UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Save")
int32 Coins = 0;

它不是 metadata。下面这种写法表达的是元数据,不是这个属性说明符的正确用法:

// 不要这样写
UPROPERTY(EditAnywhere, meta = (SaveGame))
int32 Coins = 0;

引擎层面上,SaveGame 对应的是属性 flag。普通序列化不会因为它自动发生变化;当归档的 IsSaveGame() 为真时,UE 的属性序列化会跳过没有 SaveGame 标记的字段。

1

属性写成 UPROPERTY(SaveGame)

2

UHT 给属性打上 SaveGame flag。

3

自定义归档设置 ArIsSaveGame = true

4

序列化时只保留带该 flag 的字段。

标准 USaveGame 用法

最常见的存档方式是定义一个 USaveGame 子类。把角色等级、背包数据、关卡进度、设置等可重建的数据放进去,保存时填充对象,读取时再把数据应用回游戏对象。

SaveGame 类

// MyPlayerSaveGame.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MyPlayerSaveGame.generated.h"

USTRUCT(BlueprintType)
struct FInventoryEntry
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame)
    FName ItemId;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame)
    int32 Count = 0;
};

UCLASS()
class UMyPlayerSaveGame : public USaveGame
{
    GENERATED_BODY()

public:
    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, SaveGame, Category = "Player")
    int32 PlayerLevel = 1;

    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, SaveGame, Category = "Player")
    FTransform PlayerTransform;

    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, SaveGame, Category = "Inventory")
    TArray<FInventoryEntry> Inventory;
};

保存

#include "Kismet/GameplayStatics.h"
#include "MyPlayerSaveGame.h"

void UMySaveSubsystem::SavePlayer(ACharacter* Player)
{
    const FString SlotName = TEXT("Profile_0");
    constexpr int32 UserIndex = 0;

    UMyPlayerSaveGame* SaveObject = Cast<UMyPlayerSaveGame>(
        UGameplayStatics::CreateSaveGameObject(UMyPlayerSaveGame::StaticClass()));

    if (!SaveObject || !Player)
    {
        return;
    }

    SaveObject->PlayerLevel = CurrentLevel;
    SaveObject->PlayerTransform = Player->GetActorTransform();
    SaveObject->Inventory = BuildInventorySnapshot();

    UGameplayStatics::SaveGameToSlot(SaveObject, SlotName, UserIndex);
}

读取

void UMySaveSubsystem::LoadPlayer(ACharacter* Player)
{
    const FString SlotName = TEXT("Profile_0");
    constexpr int32 UserIndex = 0;

    UMyPlayerSaveGame* SaveObject = Cast<UMyPlayerSaveGame>(
        UGameplayStatics::LoadGameFromSlot(SlotName, UserIndex));

    if (!SaveObject || !Player)
    {
        return;
    }

    CurrentLevel = SaveObject->PlayerLevel;
    Player->SetActorTransform(SaveObject->PlayerTransform);
    ApplyInventorySnapshot(SaveObject->Inventory);
}

官方 API 文档对 SaveGameToSlot / SaveGameToMemory 有一个容易被忽略的说明:标准保存 USaveGame 对象时会写入非 transient 属性,SaveGame flag 本身不作为过滤条件。也就是说,在普通 USaveGame 类里,真正最低要求是字段必须是 UPROPERTY 且不能是 Transient。不过仍建议给存档字段加上 SaveGame,因为它能表达意图,也方便以后切到自定义归档或自动采集方案。

自定义 Archive 用法

当你想做“自动保存某些 Actor/Component 的字段”时,SaveGame 标记才是主角。思路是:你自己决定要保存哪些对象,然后创建一个 SaveGame 模式的归档,让对象执行 Serialize

#include "Serialization/ObjectAndNameAsStringProxyArchive.h"

struct FMySaveGameArchive : public FObjectAndNameAsStringProxyArchive
{
    explicit FMySaveGameArchive(FArchive& InnerArchive)
        : FObjectAndNameAsStringProxyArchive(InnerArchive, true)
    {
        ArIsSaveGame = true;
    }
};

保存 Actor 的标记字段

USTRUCT()
struct FSavedActorRecord
{
    GENERATED_BODY()

    UPROPERTY(SaveGame)
    FName ActorName;

    UPROPERTY(SaveGame)
    FTransform Transform;

    UPROPERTY(SaveGame)
    TArray<uint8> Bytes;
};

FSavedActorRecord MakeActorRecord(AActor* Actor)
{
    FSavedActorRecord Record;
    Record.ActorName = Actor->GetFName();
    Record.Transform = Actor->GetActorTransform();

    FMemoryWriter Writer(Record.Bytes, true);
    FMySaveGameArchive Archive(Writer);
    Actor->Serialize(Archive);

    return Record;
}

读取 Actor 的标记字段

void ApplyActorRecord(AActor* Actor, const FSavedActorRecord& Record)
{
    if (!Actor)
    {
        return;
    }

    Actor->SetActorTransform(Record.Transform);

    FMemoryReader Reader(Record.Bytes, true);
    FMySaveGameArchive Archive(Reader);
    Actor->Serialize(Archive);
}

这种方案下,Actor 内部只有标记了 SaveGame 的属性会被写入。没有标记的运行时缓存、组件引用、临时状态会自然被跳过。

UCLASS()
class ASavableChest : public AActor
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame)
    bool bOpened = false;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame)
    TArray<FName> LootItemIds;

    // 不保存:运行时缓存,下次可重建。
    UPROPERTY(Transient)
    TObjectPtr<UStaticMeshComponent> CachedMesh = nullptr;
};

Blueprint 里怎么勾

Blueprint 变量详情面板里的 SaveGame 复选框,设置的是同一个属性标记。它常在变量的 Advanced / 高级区域里。

  • 普通 SaveGame 蓝图:创建继承自 SaveGame 的蓝图类,把变量放进去,再用 Create Save Game ObjectSave Game To SlotLoad Game From Slot
  • 自动序列化 Actor:Actor 蓝图里的变量也要勾 SaveGame,并且仍然需要你的 C++ 或插件逻辑去枚举 Actor、创建 Archive、写入字节。
  • Struct 变量:外层 struct 变量和 struct 内部真正需要保存的成员都建议标记。只标外层通常不够稳。

常见坑

问题 原因 建议
SaveGame 写成了 meta=(SaveGame) 它是 property specifier,不是 metadata。 写成 UPROPERTY(EditAnywhere, SaveGame)
以为勾了 SaveGame 就自动存盘 标记只是筛选条件,不是保存动作。 仍然要调用 SaveGameToSlot,或自己写文件/Archive 逻辑。
普通 C++ 成员变量没有保存 UE 反射序列化只认识 UPROPERTY 需要保存的字段必须进入反射系统。
保存 Actor 指针后读回来无效 运行时对象地址不能跨进程/跨启动保存。 保存稳定 ID、类路径、Transform、数据快照,加载时重建或查找对象。
Struct 里部分字段丢失 自定义 SaveGame Archive 会按成员属性过滤。 外层字段和内部成员都使用 UPROPERTY(SaveGame)
保存大型数据时卡顿 同步保存会占用主线程或触发明显 IO 等待。 较大存档优先用 AsyncSaveGameToSlot,并在回调里处理结果。

推荐存什么

  • 玩家进度:等级、经验、任务状态、已解锁内容。
  • 世界状态快照:Actor 稳定 ID、Transform、开关状态、生命值、掉落物数据。
  • 资源引用:优先存 FSoftObjectPathTSoftObjectPtr 或资产 ID。
  • 可重建数据:背包条目、装备 ID、数量、随机种子,而不是完整运行时对象图。

参考资料