简介:面向 UE4 开发者的一份 Steam Friends API 集成演示工程,聚焦在虚幻引擎中打通「获取好友列表—邀请好友—接受后加入会话」的完整链路,适合已具备一定 C++ 与蓝图基础、希望把 Steam 社交功能接入自己项目的初中级开发者参考。压缩包共 7 个文件,以 3 个 .h 头文件与 3 个 .cpp 源文件为主体,另附 1 份 md 说明文档,整体约 7KB,体量轻巧但结构完整:头文件负责接口声明,源文件承载具体实现,文档交代使用前提与构建要点。代码按职责分层,GetFriendsListCallBackProxy 被封装为可直接拖入蓝图的异步节点,从 Steam 子系统拉取好友列表并处理网络等待;NetBlueprintFunctionLibrary 提供邀请好友的函数库入口;NetGameInstance 则在邀请被接受时负责加入对应会话,形成闭环。已有 311 人学习,读者可从中获得 Steam 子系统调用、蓝图异步节点编写与 GameInstance 生命周期处理的实践样本。
1. 好友列表拉不起来的那半小时:这个演示工程解决的正是这件事
联机部分都跑通了,点头像想加个好友,结果好友列表一片空白——这大概是 UE4 项目里最早翻车的一批功能点。SteamFriendsUE4 就是冲这个场景来的:一份 C++ 写的 UE4 演示工程,把 Steam Friends API 里最常被问到的几件事——好友枚举、PersonaState 映射、头像从 int 句柄变成 UTexture2D、Rich Presence 上报、Overlay 邀请,还有 GameRichPresenceJoinRequested 回调——串成一条能跑通、也能下手改的链路。
它不是一个完整的社交系统,更像接入层的骨架:从“Steam 客户端明明在线,游戏里却一个人都没有”开始,一路做到“好友点邀请能直接进你的大厅”。适合已经能写一点 UE4 C++、准备给自己项目补上好友和邀请功能的人;纯蓝图党也能用,工程里留了一层蓝图函数库的封装。下面的拆解按“结构 → 链路 → 跑起来 → 排查 → 改造”走。
2. 工程结构与技术选型:OnlineSubsystemSteam 和裸调 Steamworks 怎么分工
2.1 两条路各管一段,别混着写
UE4 接 Steam 有两条常见路径。一条走OnlineSubsystemSteam提供的IOnlineFriends,好处是跨平台抽象、接口统一;另一条直接#include "steam/steam_api.h"调原生 API,好处是字段全、能拿到头像和 Rich Presence。这个演示工程的做法是分工而不是二选一:会话、联机身份、大厅这些交子系统;好友细节、头像纹理、Overlay、状态上报走原生接口。
原因是IOnlineFriends暴露的字段实在不够用。好友列表能读,但拿不到头像句柄;状态只有粗粒度的枚举,想显示“正在某张地图里等人”这种文案,还得靠 Rich Presence。而头像这一块,GetMediumFriendAvatar返回的是一个int句柄,后面必须再走GetImageRGBA才能拿到像素,IOnlineFriends根本没这个口子。
| 能力 | OnlineSubsystemSteam | 原生 Steamworks API | 工程里的选择 |
|---|---|---|---|
| 登录与唯一 ID | 封装完整 | SteamAPI_Init/SteamUser | 子系统负责 |
| 好友枚举 | 可用但字段少 | GetFriendCount+GetFriendByIndex | 原生 |
| 头像纹理 | 不直接提供 | GetMediumFriendAvatar+GetImageRGBA | 原生 |
| 在线状态详情 | 粗粒度 | GetFriendPersonaState+ Rich Presence | 原生 |
| Overlay 呼出 | 无 | ActivateGameOverlay | 原生 |
| 邀请加入 | 走 Session 接口 | InviteUserToGame+ 回调 | 两边配合 |
有一点必须提前说清楚:只要DefaultEngine.ini里把DefaultPlatformService设成Steam,OnlineSubsystemSteam就会自己完成SteamAPI_Init,并且在自己的 tick 里驱动SteamAPI_RunCallbacks。这时候你再手动调一次SteamAPI_Init(),它会返回 false。所以工程里的初始化代码不拿返回值当唯一判据,而是用SteamAPI_IsSteamRunning()和SteamUser()->BLoggedOn()判断真实状态,这样两种启动方式都不会误判。
2.2 目录结构和模块依赖
工程按“接入层 / 数据层 / UI 层”三层切开,好处是后面换 UI 不用动接入代码:
SteamFriendsDemo/ ├── Config/ │ ├── DefaultEngine.ini # 指定 DefaultPlatformService=Steam │ └── DefaultGame.ini ├── Source/ │ ├── SteamFriendsDemo.Target.cs │ ├── SteamFriendsDemoEditor.Target.cs │ └── SteamFriendsDemo/ │ ├── SteamFriendsDemo.Build.cs │ ├── Steam/ │ │ ├── SteamSubsystem.h/.cpp # GameInstanceSubsystem,统一入口 │ │ ├── SteamFriendsLibrary.h/.cpp # 蓝图函数库,头像转纹理 │ │ └── SteamTypes.h # FSteamFriendInfo 等 USTRUCT │ ├── UI/ │ │ ├── SFriendListWidget.h/.cpp │ │ └── SFriendEntryWidget.h/.cpp │ └── Game/ │ └── SteamSessionGameMode.h/.cpp ├── Content/ └── steam_appid.txt # 测试阶段写 480模块依赖写在Build.cs里,少一个都会在链接阶段炸出来:
// SteamFriendsDemo.Build.cs PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "UMG", // 好友列表用 UMG 搭 "OnlineSubsystem", // IOnlineSession 等基础接口 "OnlineSubsystemUtils", "OnlineSubsystemSteam", // 关键:只有引了它,steam_api.h 的头文件路径才会被加进来 });OnlineSubsystemSteam这个依赖不是可选项。它的构建脚本会把 Steamworks SDK 的public/目录加到包含路径里,没有它,#include "steam/steam_api.h"直接编译不过。同理Config/DefaultEngine.ini里要有一段:
[OnlineSubsystem] DefaultPlatformService=Steam [OnlineSubsystemSteam] bEnabled=true SteamDevAppId=480 bInitServerOnClient=truebInitServerOnClient是给“客户端也要当监听服务器”的场景准备的,单人调试好友邀请时开着更省事。
2.3 数据结构与启动时序
好友信息统一收敛到一个 USTRUCT,UI 层只认这个结构,不认 Steam 的裸类型:
USTRUCT(BlueprintType) struct FSteamFriendInfo { GENERATED_BODY() UPROPERTY(BlueprintReadOnly) FString SteamId; // 64 位 ID 的十进制字符串 UPROPERTY(BlueprintReadOnly) FString PersonaName; // 昵称 UPROPERTY(BlueprintReadOnly) int32 PersonaState = 0; // 对应 EPersonaState UPROPERTY(BlueprintReadOnly) bool bInThisGame = false; // 是否也在玩本作 UPROPERTY(BlueprintReadOnly) UTexture2D* Avatar = nullptr; };SteamId用字符串而不是uint64,是为了直接塞给蓝图和存档用;uint64在蓝图层会被截断成 32 位,这个坑踩过一次就记住了。
启动时序是:GameInstance 初始化 → 子系统Initialize→ 判断 Steam 是否就绪 → 拉全量好友 → 头像批量转纹理 → 注册PersonaStateChange_t和GameRichPresenceJoinRequested_t两个回调。选UGameInstanceSubsystem而不是某个 Actor,是因为好友数据要跨关卡存活,放在 Actor 上每次切图都得重拉一遍,白白多几百毫秒。
3. 核心链路拆解:好友枚举、头像加载与富状态上报的完整实现
3.1 好友枚举与状态映射
拉好友是整条链路的第一环,k_EFriendFlagImmediate表示只取“真正的好友”,不含忽略列表和已屏蔽的人:
void USteamSubsystem::RefreshFriendList() { CachedFriends.Reset(); // 只取互为好友的关系,忽略列表和屏蔽名单在这里被排除 const int32 FriendCount = SteamFriends()->GetFriendCount(k_EFriendFlagImmediate); UE_LOG(LogSteamFriends, Log, TEXT("好友总数: %d"), FriendCount); for (int32 Index = 0; Index < FriendCount; ++Index) { const CSteamID FriendID = SteamFriends()->GetFriendByIndex(Index, k_EFriendFlagImmediate); FSteamFriendInfo Info; Info.SteamId = FString::Printf(TEXT("%llu"), FriendID.ConvertToUint64()); Info.PersonaName = UTF8_TO_TCHAR(SteamFriends()->GetFriendPersonaName(FriendID)); Info.PersonaState = static_cast<int32>(SteamFriends()->GetFriendPersonaState(FriendID)); Info.bInThisGame = SteamFriends()->GetFriendGamePlayed( FriendID, nullptr) && SteamFriends()->GetFriendGamePlayed(FriendID, &FriendGameInfo) && FriendGameInfo.m_gameID.AppID() == SteamUtils()->GetAppID(); CachedFriends.Add(Info); } OnFriendListRefreshed.Broadcast(); // 蓝图侧监听这个委托刷新 UI }几个参数值得单独说。GetFriendByIndex的索引在一次会话内是稳定的,但好友增删后会变,所以不要在别处缓存索引值,要缓存CSteamID。GetFriendPersonaName返回const char*,空指针是可能的(比如网络还没同步完),所以外面套一层UTF8_TO_TCHAR之后就别再假设非空。GetFriendGamePlayed要传两个参数,一个判存在一个取详情,代码里连调两次看着啰嗦,但这是 API 的原始设计。
PersonaState那一段我一般会再做一层映射,把k_EPersonaStateOnline、k_EPersonaStateAway、k_EPersonaStateSnooze翻译成 UI 上的绿点、黄点、月亮图标,避免 UI 层直接依赖 Steam 的枚举值——日后换平台,只改这一层映射就够了。
3.2 头像:从一个 int 句柄到 UTexture2D
头像这块是全篇最容易翻车的地方。GetMediumFriendAvatar返回的int不是纹理 ID,也不是指针,而是一个图片句柄,像素数据还得单独取:
UTexture2D* USteamFriendsLibrary::LoadFriendAvatar(const FString& SteamIdStr) { const CSteamID FriendID(FCString::Strtoui64(*SteamIdStr, nullptr, 10)); if (!FriendID.IsValid()) { return nullptr; } // 返回的只是一个句柄,<=0 表示好友没设头像,或图片还没从网络拉下来 const int32 Handle = SteamFriends()->GetMediumFriendAvatar(FriendID); if (Handle <= 0) { return nullptr; } uint32 Width = 0, Height = 0; if (!SteamUtils()->GetImageSize(Handle, &Width, &Height) || Width == 0 || Height == 0) { return nullptr; } TArray<uint8> Pixels; Pixels.SetNumUninitialized(Width * Height * 4); if (!SteamUtils()->GetImageRGBA(Handle, Pixels.GetData(), Pixels.Num())) { return nullptr; // 像素数据还没就绪,过一帧再试 } // 血泪经验:Steam 给的是 BGR(A) 序,直接塞进 PF_B8G8R8A8 会红蓝互换 for (uint32 i = 0; i < Width * Height; ++i) { Swap(Pixels[i * 4 + 0], Pixels[i * 4 + 2]); } UTexture2D* AvatarTex = UTexture2D::CreateTransient(Width, Height, PF_B8G8R8A8); if (!AvatarTex) { return nullptr; } AvatarTex->SRGB = true; FTexture2DMipMap& Mip = AvatarTex->PlatformData->Mips[0]; void* Dest = Mip.BulkData.Lock(LOCK_READ_WRITE); FMemory::Memcpy(Dest, Pixels.GetData(), Pixels.Num()); Mip.BulkData.Unlock(); AvatarTex->UpdateResource(); return AvatarTex; }逻辑上分四步:拿句柄、查尺寸、取像素、建纹理。每一步都可能返回空,所以上层调用方必须能接受nullptr并回退到默认头像,不能假设一定成功。
GetImageRGBA失败的原因通常是异步拉取还没完成——Steam 是在后台从网络下头像的,你第一次请求时它可能只返回了句柄还没返回数据。工程里的处理是“这一帧返回空,下一帧由PersonaStateChange_t的k_EPersonaChangeAvatar标志再触发一次加载”,而不是原地死循环重试。
纹理创建完必须有人持有引用,否则下一次 GC 就没了。我用一个UPROPERTY()修饰的TMap<uint64, UTexture2D*>挂在子系统上做缓存,好友数几十上百也就几 MB,比每次刷 UI 都重建纹理划算得多。
3.3 回调驱动:状态变化与 RunCallbacks
好友列表不是拉一次就完事,得订阅变化。Steamworks 用的是回调对象,声明成成员变量保证生命周期:
void USteamSubsystem::RegisterCallbacks() { // 昵称、头像、在线状态变化都会走这里 PersonaStateChangeCallback = Callback<PersonaStateChange_t>::CreateUObject( this, &USteamSubsystem::OnPersonaStateChange); // 好友通过 Overlay 或者 Rich Presence 点"加入游戏"时触发 JoinRequestedCallback = Callback<GameRichPresenceJoinRequested_t>::CreateUObject( this, &USteamSubsystem::OnJoinRequested); } void USteamSubsystem::OnPersonaStateChange(PersonaStateChange_t* Param) { const CSteamID ChangedID(Param->m_ulSteamID); if (Param->m_nChangeFlags & k_EPersonaChangeAvatar) { // 只刷新这一个好友的头像,别全量重建 ReloadAvatarFor(ChangedID); } if (Param->m_nChangeFlags & k_EPersonaChangeStatus) { UpdateCachedState(ChangedID); } if (Param->m_nChangeFlags & k_EPersonaChangeName) { UpdateCachedName(ChangedID); } }m_nChangeFlags是位掩码,用&判断而不是==,因为一次变化可能同时包含状态和名字。这一点看着基础,但真有人写成等号,然后发现“改名之后状态不更新了”。
回调注册了不代表会执行,还得有人周期性调SteamAPI_RunCallbacks()。如果走OnlineSubsystemSteam,它在自己的 tick 里已经调了,你别重复调;如果是完全手动初始化,就要自己挂一个FTSTicker每帧驱动一次。这是两套初始化方式最容易混的地方,也是我见过最多人卡住的一步。
3.4 Rich Presence 与邀请加入
状态上报和邀请是好友功能里“看起来最像游戏”的部分:
void USteamSubsystem::SetLobbyPresence(const FString& LobbyId, int32 PlayerCount) { // steam_display 对应本地化文件里的 token,status 是给不支持的客户端兜底的 SteamFriends()->SetRichPresence("status", TCHAR_TO_UTF8(*FString::Printf(TEXT("大厅 %s / %d 人"), *LobbyId, PlayerCount))); SteamFriends()->SetRichPresence("steam_display", "#Status_InLobby"); SteamFriends()->SetRichPresence("lobby_id", TCHAR_TO_UTF8(*LobbyId)); } bool USteamSubsystem::InviteFriend(const FString& FriendSteamId, const FString& LobbyId) { const CSteamID FriendID(FCString::Strtoui64(*FriendSteamId, nullptr, 10)); if (!FriendID.IsValid()) { return false; } // 第二个参数会作为命令行拼到对方客户端上,游戏启动时解析它自动进大厅 return SteamFriends()->InviteUserToGame(FriendID, TCHAR_TO_UTF8(*FString::Printf( TEXT("+connect_lobby %s"), *LobbyId))); }InviteUserToGame的第二个参数是关键,它不是提示文案,而是一段会被拼到对方启动命令行的字符串。对方接受邀请后,游戏进程带着+connect_lobby=xxx启动,你的OnJoinRequested回调里从Param->m_rgchConnect取出来解析,再走加入大厅逻辑。如果不写这段参数,邀请能弹出来,但点了之后只是启动游戏,进不了你的房间。
SetRichPresence的steam_display字段指向本地化 token,Steam 客户端会拿它渲染成“正在某游戏中”,比纯文本的status好看得多。两个都设,兼容性最好。
4. 跑起来:SDK 接入、AppID 配置与日志验证的落地步骤
4.1 环境准备与首次配置
需要的环境不多,但缺一个就跑不通:UE4 4.26 或 4.27(这两个版本对OnlineSubsystemSteam的支持最稳)、Visual Studio 2019 带 C++ 工作负载、Steam 客户端已登录、Steamworks SDK 解压到本地。SDK 一般在引擎的Engine/Source/ThirdParty/Steamworks/下已经带了一份,工程直接依赖OnlineSubsystemSteam就能用,不用自己再配路径。
配置分两步。第一步确认DefaultEngine.ini里的DefaultPlatformService=Steam和bEnabled=true都在,这是子系统接管初始化的前提。第二步在项目根目录(和.uproject同级)放一个steam_appid.txt,内容就一行:
480480 是公开的测试 AppID,拿来验证好友、Overlay、邀请这些接口够用了。真上线时换成自己的 AppID,并且打包后steam_appid.txt要么一起拷到 exe 同级目录,要么改用环境变量SteamAppId注入,别扔在Content里——它不参与打包。
4.2 编辑器里跑和独立进程跑的区别
直接在编辑器点 Play 有时拿不到完整的 Steam 上下文,尤其是 Overlay 和邀请回调。更稳的做法是用独立进程启动:
# Windows 下从命令行拉起编辑器并强制启用 Steam 子系统 "D:\Epic\UE_4.27\Engine\Binaries\Win64\UnrealEditor.exe" ^ "D:\Work\SteamFriendsDemo\SteamFriendsDemo.uproject" ^ -steam -log -game-steam是明确告诉引擎优先初始化 Steam 子系统,不加的话在某些配置下会退回Null平台服务,然后你就得到一个永远空的好友列表。-log打开独立日志窗口,好友这块的调试信息基本全靠它。-game跳过编辑器直接进游戏模式,Overlay 的表现更接近打包后的真实情况。
打包版本则是把steam_appid.txt拷到Binaries/Win64/旁边,从 Steam 客户端里以“添加非 Steam 游戏”的方式启动,或者确保客户端正在运行再直接双击 exe。
4.3 用日志确认初始化到底成没成
好友功能出问题,九成能在启动日志的前 50 行里看出端倪。工程在子系统Initialize里埋了几条关键日志:
void USteamSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); if (!SteamAPI_IsSteamRunning()) { UE_LOG(LogSteamFriends, Error, TEXT("Steam 客户端未运行,好友接口不可用")); return; } // 子系统可能已经初始化过,这里的返回值仅供参考 SteamAPI_Init(); if (!SteamUser()->BLoggedOn()) { UE_LOG(LogSteamFriends, Error, TEXT("Steam 未登录,当前用户状态无效")); return; } UE_LOG(LogSteamFriends, Log, TEXT("Steam 就绪, AppID=%u, 当前用户=%llu"), SteamUtils()->GetAppID(), SteamUser()->GetSteamID().ConvertToUint64()); RegisterCallbacks(); RefreshFriendList(); }正常的日志长这样,三行都对上才说明链路通了:
LogOnline: Display: STEAM: Steam API 初始化成功 LogSteamFriends: Log: Steam 就绪, AppID=480, 当前用户=7656119xxxxxxxxxx LogSteamFriends: Log: 好友总数: 23如果第二行没出来,问题在初始化;如果第二行有但第三行是 0,问题在 AppID 不匹配——你登录的账号根本没加过任何玩这个 AppID 的好友;如果三行都有但 UI 还是空,那就是刷新委托没绑上,去查 UI 层的BindDynamic。
5. 常见问题排查:好友列表为空、头像花屏与邀请收不到的定位思路
5.1 好友列表永远是 0,日志里连“好友总数”都没有
现象是 UI 空白,日志窗口里只有LogOnline那几行,没有自己的LogSteamFriends输出。
原因是子系统压根没被创建。UGameInstanceSubsystem只有在被引用或者配置成自动创建时才会实例化,纯 C++ 写了类但没有任何地方GetSubsystem拿过它,它就不会初始化 _$。另一种情况是DefaultPlatformService写成了NULL或者拼错,OnlineSubsystemSteam没加载。
解决办法是二选一:在DefaultEngine.ini里加[/Script/Engine.Engine] GameInstanceClass=指向自定义 GameInstance 并显式GetSubsystem,或者在项目设置的GameInstance里挂一个蓝图子类,在Init里调一次。验证方式是启动后第一帧就应该看到日志,看不到就是没创建。
5.2 头像花屏、发紫,或者一片纯色
现象是好友条目能显示名字和状态,但头像颜色明显不对,皮肤发蓝、红色变成紫色。
原因几乎都是通道顺序。GetImageRGBA这个名字容易让人以为返回的就是 RGBA 顺序,但实际塞进PF_B8G8R8A8之后红蓝互换,说明数据是 BGRA 序。另一种可能是纹理的SRGB没设成true,颜色会整体偏暗。
解决办法就是在Memcpy之前交换第 0 和第 2 字节,同时确认AvatarTex->SRGB = true。如果交换后颜色又反了,说明你那版 SDK 返回的确实是 RGBA,把交换那段去掉即可——这个差异在 SDK 版本之间有过变化,别死记。
5.3 好友状态变了,游戏里不刷新
现象是好友在客户端上从“在线”切到“离开”,游戏 UI 里还是绿点,得重启游戏才更新。
原因是回调注册了但没人驱动SteamAPI_RunCallbacks()。如果你用的是手动初始化而不是走子系统,就必须自己挂 ticker;走了子系统的话,要检查是不是自己又调了一次SteamAPI_Init把子系统的那套覆盖掉了。
解决办法是分清楚初始化归属:走OnlineSubsystemSteam就不要手动SteamAPI_Init,也不要在自己的 tick 里再调RunCallbacks;手动初始化就把FTSTicker::GetCoreTicker().AddTicker挂上,回调句柄用成员变量持有,别用局部变量——局部变量出了作用域回调就失效,这个坑很隐蔽。
5.4 邀请弹出来了,但点了之后只是启动游戏
现象是好友收到邀请提示,点接受后游戏启动了,却停在大厅外面,没有自动加入。
原因是InviteUserToGame的第二个参数没传,或者传了但接收端没解析。那段+connect_lobby=xxx是拼到对方启动命令行上的,接收端需要在GameInstance初始化阶段读FCommandLine::Get()并解析出 lobby id,再走加入流程。只看OnJoinRequested回调是不够的——那个回调只在游戏已经在运行时才触发。
解决办法是两条路都实现:运行中的客户端走GameRichPresenceJoinRequested_t回调,未运行的客户端走命令行解析。两边最终调同一个JoinLobbyById函数,逻辑就不会分叉。
提示:调试邀请功能时至少要两个 Steam 账号,一个登录编辑器,一个登录打包版本,用同一个测试 AppID,否则好友关系在 Steam 侧不成立,怎么调都是 0。
6. 二次开发改造:把演示级好友面板做成可复用的社交模块
演示工程里的好友面板是直接绑在SteamSubsystem上的,去掉 Steam 就全废。真要拿去接项目,第一件事是把“好友数据源”抽成接口,让 UI 只依赖IFriendProvider,Steam 只是其中一个实现:
class IFriendProvider { public: virtual ~IFriendProvider() = default; virtual void Refresh() = 0; virtual const TArray<FSteamFriendInfo>& GetFriends() const = 0; virtual bool Invite(const FString& FriendId, const FString& LobbyId) = 0; virtual FSimpleMulticastDelegate& OnRefreshed() = 0; };这样做的直接收益是编辑器里能挂一个MockFriendProvider,不用每次调 UI 都启动 Steam 客户端——改一行布局要等十几秒初始化的滋味不好受。第二收益是以后要接其他平台,UI 一行不用动。
改造优先级我一般按这个顺序排:
| 改造点 | 工作量 | 收益 | 建议顺序 |
|---|---|---|---|
抽IFriendProvider接口 | 小 | 编辑器可脱机调试 | 1 |
| 头像缓存加 LRU 上限 | 小 | 好友多时不炸内存 | 2 |
| 好友列表分页/虚拟滚动 | 中 | 200+ 好友不卡帧 | 3 |
| Rich Presence 接本地化 | 中 | 状态文案正式化 | 4 |
| 邀请流程接 Session 层 | 大 | 真正能进房间 | 5 |
头像缓存那块给个具体写法,别只是TMap无限涨:
// 简易上限:超过 128 个就清掉不在当前好友列表里的条目 void USteamSubsystem::TrimAvatarCache(const TSet<uint64>& AliveIds) { for (auto It = AvatarCache.CreateIterator(); It; ++It) { if (!AliveIds.Contains(It->Key)) { It->Value->RemoveFromRoot(); It.RemoveCurrent(); } } }RemoveFromRoot一定要跟AddToRoot成对出现,否则纹理永远不释放;但如果你用的是UPROPERTY()容器持有引用,就不要碰AddToRoot,两者混用会导致重复释放。
验证方式很土但有效:加一个控制台命令steam.Friends.Dump,把当前缓存的 SteamId、名字、状态、头像是否为空打一张表出来,对着 Steam 客户端的好友列表人工比对一遍,差异一目了然。比在 UI 上点点点快得多。
从那以后我每次接第三方 SDK,都强制先写一个 dump 命令再写 UI——先把数据层看清楚,界面那点事反而最不值钱。希望帮到你。
本文还有配套的精品资源,点击获取