简介:这份压缩包围绕在 UE4 中集成 Steam Friends API 展开,面向已具备 C++ 与蓝图基础、希望打通 Steam 好友列表、邀请与会话加入流程的开发者。包内共 7 个文件,由 3 个 .h 头文件、3 个 .cpp 源文件和 1 个 .md 说明文件组成,整体仅 7KB,结构轻量,便于快速浏览与二次移植。核心示例覆盖三个环节:通过可暴露给蓝图的回调代理节点从 Steam 子系统获取好友列表并处理网络等待任务;借助蓝图函数库封装邀请好友的调用;以及自定义 GameInstance 在邀请被接受后加入对应会话,形成较完整的好友交互链路。读者可据此理解 UE4 中 Steam 子系统的接入方式、异步回调与蓝图暴露方法,并参考其模块组织搭建自己的联机好友功能原型。已有 311 人学习,适合作为 UE4 网络与 Steam 集成方向的学习样例。
1. 从好友列表一直是空的说起:这套 UE4 集成 Demo 到底能解决什么
在 UE4 里集成 Steam Friends API,最令人头疼的往往不是接口本身,而是那些完全没有提示的失败现象:SteamAPI_Init()返回 true,日志里没有任何报错,但GetFriendCount()一直返回 0;或者好友名字全部显示成方块乱码,头像是一团花屏。这套 SteamFriendsUE4 演示工程把「C++ 侧接入 Steamworks、拉取好友列表、转换头像位图、读写富状态、唤起覆盖层」这条流程完整实现了一遍,并用UGameInstanceSubsystem封装成一个可以直接在蓝图里调用的模块。它适合两类人:一类是刚接触 UE4 与 Steam 对接、想找一个能跑通的起点,另一类是在项目里负责在线功能、需要确认「哪一步调用顺序错了」的开发者。
2. 拆解工程骨架:Steamworks SDK 放进 UE4 模块的四个改动点
拿到这种演示工程,先看目录,不要急着编译。第三方 SDK 与 UE4 模块之间的依赖关系如果整理不清楚,后面所有报错都会指向同一个根因:链接器找不到符号,或者运行期找不到 dll。这一章把工程骨架拆开,讲清楚 SDK 放哪、Build.cs怎么改、初始化放在哪个层级。
2.1 目录布局与第三方库的位置安排
演示工程采用的布局是“SDK 随仓库一起走”,不依赖引擎自带的Engine/Source/ThirdParty/Steamworks。这样做的好处是版本可控,换机器、换引擎小版本都不会因为引擎目录被清理而失效;代价是仓库体积变大,需要自己管理 dll 的打包分发。
| 路径 | 作用 | 是否入库 |
|---|---|---|
Source/SteamFriendsUE4/ | 游戏模块,业务代码 | 是 |
Source/SteamFriendsUE4/SteamFriendsUE4.Build.cs | 声明 SDK 头文件、lib、dll 依赖 | 是 |
ThirdParty/Steamworks/sdk/public/steam/steam_api.h | 对外头文件,PublicIncludePaths指向 public 层 | 是 |
ThirdParty/Steamworks/sdk/redistributable_bin/win64/steam_api64.lib | 链接期导入库 | 是 |
ThirdParty/Steamworks/sdk/redistributable_bin/win64/steam_api64.dll | 运行期动态库,必须随包分发 | 是 |
steam_appid.txt | 仅开发期使用,写死 AppID | 建议加 ignore,不随正式包走 |
注意一个常见误解:很多人以为把steam_api64.lib加进PublicAdditionalLibraries就结束了,结果编辑器能跑、打包出来双击闪退。原因是 lib 只负责链接期的符号解析,真正干活的是 dll,而 UE4 的打包流程不会自动把第三方 dll 拷到输出目录。这一点后面第 5 章会展开。
2.2 Build.cs 里真正起作用的三段配置
Build.cs的改动看起来零碎,实际只有三段是决定性的:头文件路径、导入库、运行时依赖。其余都是常规的 UMG、Slate 模块声明。
using UnrealBuildTool; using System.IO; public class SteamFriendsUE4 : ModuleRules { public SteamFriendsUE4(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; // UMG/Slate 是给好友列表 UI 用的,其余是常规依赖 PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "UMG", "Slate", "SlateCore" }); // 按自己仓库实际布局改这一行,别照抄相对层级 string SdkRoot = Path.GetFullPath(Path.Combine(ModuleDirectory, "../../ThirdParty/Steamworks/sdk")); // 头文件对外暴露,所以用 Public 而不是 Private PublicIncludePaths.Add(Path.Combine(SdkRoot, "public")); if (Target.Platform == UnrealTargetPlatform.Win64) { string BinDir = Path.Combine(SdkRoot, "redistributable_bin/win64"); // 链接期:告诉链接器符号去哪找 PublicAdditionalLibraries.Add(Path.Combine(BinDir, "steam_api64.lib")); // 运行期:这一段才是打包不闪退的关键 string DllPath = Path.Combine(BinDir, "steam_api64.dll"); RuntimeDependencies.Add("$(BinaryOutputDir)/steam_api64.dll", DllPath); PublicDelayLoadDLLs.Add("steam_api64.dll"); } } }逐段说明。ModuleDirectory是当前模块目录,也就是Source/SteamFriendsUE4,往上两级才是工程根目录,所以../../ThirdParty/...是对的;如果你把 SDK 放在Plugins下,这个相对层级必须重算,否则PublicIncludePaths指向一个不存在的目录时,编译器不会报「路径不存在」,只会在#include时提示找不到头文件,排查成本很高。
PublicIncludePaths指向的是sdk/public,因为 SDK 内部头文件之间用的是#include "steam/steam_api.h"这种带前缀的相对路径,如果你直接指到public/steam,自己的代码能编过,SDK 内部互相引用就会失败。这个细节比想象中更容易导致编译错误。
RuntimeDependencies.Add的第二个参数是源文件绝对路径,第一个参数$(BinaryOutputDir)是目标位置宏,打包时会自动展开成可执行文件所在目录。PublicDelayLoadDLLs则是告诉 UE4 的加载器这个 dll 是延迟加载的,缺了它某些平台下会在模块加载阶段直接崩,连日志都来不及打印。
2.3 初始化时机:为什么放在 GameInstance 子系统而不是 Actor
SteamAPI_Init()只应该调用一次,并且必须在任何SteamFriends()、SteamUtils()调用之前完成。放在AGameMode的BeginPlay里是新手常见写法,问题在于:多人游戏里 GameMode 只在服务器存在,客户端根本不会执行;而且 Actor 可能被销毁重建,你会不小心调两次SteamAPI_Init()。
演示工程用的是UGameInstanceSubsystem,随 GameInstance 创建,生命周期覆盖整个进程,还能被蓝图Get Game Instance Subsystem直接拿到,是最省心的选择。
void USteamFriendsSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); // 打包版专用:发现自己不是被 Steam 客户端拉起的,就请求重启一次 #if !WITH_EDITOR if (SteamAPI_RestartAppIfNecessary(kAppId)) { FPlatformMisc::RequestExit(false); return; } #endif // 这两个日志在排查「找不到 steam_appid.txt」时价值极高 UE_LOG(LogSteamFriends, Log, TEXT("[Steam] CWD = %s"), FPlatformProcess::GetCurrentDirectory()); UE_LOG(LogSteamFriends, Log, TEXT("[Steam] ProjectDir = %s"), *FPaths::ProjectDir()); if (!SteamAPI_Init()) { UE_LOG(LogSteamFriends, Error, TEXT("[Steam] SteamAPI_Init failed, AppID=%u"), kAppId); return; } bSteamReady = true; // 回调在这里一次性注册,后面只等分发 m_PersonaCallback.Register(this, &USteamFriendsSubsystem::OnPersonaStateChange); m_RichCallback.Register(this, &USteamFriendsSubsystem::OnRichPresenceUpdate); m_OverlayCallback.Register(this, &USteamFriendsSubsystem::OnOverlayActivated); }kAppId是一个static const uint32,值填 Steamworks 后台分配给你的 AppID,不要填别人的。SteamAPI_RestartAppIfNecessary在编辑器里非常碍事——编辑器进程本身不是被 Steam 拉起的,它会一直判定“需要重启”,所以整段用#if !WITH_EDITOR包起来,编辑器里直接跳过。
两个UE_LOG看起来多余,实际是最有价值的排查手段。SteamAPI_Init()找steam_appid.txt用的是当前工作目录,而编辑器的工作目录通常是引擎的Binaries/Win64,不是你想象的工程根目录。把 CWD 打出来,一眼就能知道文件该往哪放,比反复猜测快得多。
3. 好友数据的获取方式:ISteamFriends 三类接口与 C++ 封装
初始化成功只是拿到“进入资格”,真正的数据都在ISteamFriends里。这个接口有几十个函数,实际做好友面板会用到的只有三类:好友枚举与 Persona 信息、头像位图、富状态与覆盖层。这一章按调用顺序讲,每一步都对应演示工程里的一个函数。
3.1 回调分发:Callback 和 CallResult 的区别,以及 Tick 必须做的事
Steamworks 的异步结果有两条通道。CCallback用于广播类事件,比如某个好友改了昵称、上下线、富状态变化,这些事件不属于任何一次具体请求,只要注册了就能收到。CCallResult用于“一次请求对应一次响应”,比如查询关注数、请求用户信息,必须带上一个SteamAPICall_t才能匹配回来。
好友列表这块基本只用CCallback。但注册了不等于会触发——所有回调都是由SteamAPI_RunCallbacks()驱动的,这个函数不调用,注册的回调永远躺在队列里。
// USteamFriendsSubsystem 同时继承 UGameInstanceSubsystem 与 FTickableGameObject virtual void Tick(float DeltaTime) override { if (!bSteamReady) { return; } // 所有 CCallback / CCallResult 的分发入口都在这,漏了它好友状态永远不会更新 SteamAPI_RunCallbacks(); } virtual bool IsTickable() const override { return bSteamReady; } virtual ETickableTickType GetTickableTickType() const override { return ETickableTickType::Conditional; } virtual TStatId GetStatId() const override { RETURN_QUICK_DECLARE_CYCLE_STAT(USteamFriendsSubsystem, STATGROUP_Tickables); }GetTickableTickType返回Conditional配合IsTickable,可以在 Steam 没初始化成功时完全不 tick,避免无意义开销。GetStatId是FTickableGameObject的纯虚函数,不实现编不过,用RETURN_QUICK_DECLARE_CYCLE_STAT宏声明即可。
频率上不需要每帧都跑。RunCallbacks本身很轻,主要工作是遍历回调队列并分发给对应的注册者,放到每帧开头是业内常规做法;如果你的项目对帧时间敏感,可以降到每 0.1 秒一次,上限就是好友状态更新的延迟。
这里有个边界:SteamAPI_RunCallbacks()必须在游戏线程调用。如果你在异步任务或渲染线程里调用它,回调会在线程安全的前提下被分发到错误的上下文,UI 层直接操作 Slate 就会崩。
3.2 好友列表与 Persona 状态的读取顺序
GetFriendCount接受一个EFriendFlags参数,这是最容易踩的坑之一。k_EFriendFlagAll会把“已屏蔽”“请求中”“被忽略”的条目全算进来,导致数量看起来对、实际列表里混了一堆不该显示的人。正确做法是用k_EFriendFlagImmediate,只取真正建立了好友关系的。
TArray<FSteamFriend> USteamFriendsSubsystem::GetFriendList() const { TArray<FSteamFriend> Out; if (!bSteamReady) { return Out; } const int32 Count = SteamFriends()->GetFriendCount(k_EFriendFlagImmediate); Out.Reserve(Count); for (int32 i = 0; i < Count; ++i) { CSteamID Id = SteamFriends()->GetFriendByIndex(i, k_EFriendFlagImmediate); FSteamFriend& Item = Out.AddDefaulted_GetRef(); // SteamID 是 uint64,蓝图不支持 64 位整型,转字符串最省事 Item.SteamId = FString::Printf(TEXT("%llu"), Id.ConvertToUint64()); // PersonaName 返回的是 UTF-8 裸指针,直接赋给 FString 会乱码 Item.PersonaName = UTF8_TO_TCHAR(SteamFriends()->GetFriendPersonaName(Id)); Item.State = static_cast<int32>(SteamFriends()->GetFriendPersonaState(Id)); Item.AvatarHandle = SteamFriends()->GetMediumFriendAvatar(Id); } return Out; }三个参数细节值得单独说。GetFriendByIndex的索引顺序不保证稳定,好友上下线后同一个索引可能指向不同的人,所以列表刷新必须整体重建,不能做“按索引差分更新”。GetFriendPersonaName返回const char*,内容是 UTF-8 编码,中文昵称必须走UTF8_TO_TCHAR,用ANSI_TO_TCHAR会得到一段乱码。GetFriendPersonaState返回的是枚举,蓝图侧要显示成“在线”“离开”“游戏中”,需要在 UI 层做一次映射,别指望引擎帮你翻译。
另外,GetFriendPersonaName在好友信息还没从服务器同步下来时会短暂返回空串,这是正常现象。等PersonaStateChange_t回调到达后再刷一次就能拿到,所以在 UI 层要做“名称为空时显示 SteamID 后四位”的保底处理。
3.3 头像位图到 UTexture2D 的转换
头像是最容易出问题的一环。GetSmallFriendAvatar/GetMediumFriendAvatar/GetLargeFriendAvatar返回的int32并不是图片指针,而是一个句柄;真正的像素数据要通过ISteamUtils::GetImageSize和GetImageRGBA去取,而且这两个函数在图片下载完成前会返回 false。
UTexture2D* USteamFriendsSubsystem::GetAvatarTexture(int32 Handle) { if (Handle <= 0) { return nullptr; } if (UTexture2D** Cached = AvatarCache.Find(Handle)) { return *Cached; } uint32 W = 0, H = 0; // 没下载完就返回 false,这时候不要当错误处理,等 AvatarImageLoaded_t 再试 if (!SteamUtils()->GetImageSize(Handle, &W, &H) || W == 0 || H == 0) { return nullptr; } TArray<uint8> RGBA; RGBA.SetNumUninitialized(W * H * 4); if (!SteamUtils()->GetImageRGBA(Handle, RGBA.GetData(), RGBA.Num())) { return nullptr; } UTexture2D* Tex = UTexture2D::CreateTransient(W, H, PF_B8G8R8A8); if (!Tex) { return nullptr; } Tex->AddToRoot(); // 临时贴图不入根会被 GC 掉 void* Dst = Tex->PlatformData->Mips[0].BulkData.Lock(LOCK_READ_WRITE); uint8* DstPtr = static_cast<uint8*>(Dst); for (uint32 i = 0; i < W * H; ++i) { // Steam 给的是 RGBA,PF_B8G8R8A8 要的是 BGRA,红蓝通道互换 DstPtr[i * 4 + 0] = RGBA[i * 4 + 2]; DstPtr[i * 4 + 1] = RGBA[i * 4 + 1]; DstPtr[i * 4 + 2] = RGBA[i * 4 + 0]; DstPtr[i * 4 + 3] = RGBA[i * 4 + 3]; } Tex->PlatformData->Mips[0].BulkData.Unlock(); Tex->UpdateResource(); AvatarCache.Add(Handle, Tex); return Tex; }参数上要盯住三处。GetImageSize的两个参数都是出参指针,传引用地址,别写成传值。缓冲区大小必须是W * H * 4,少一个字节GetImageRGBA直接返回 false,且不告诉你为什么。通道顺序是最隐蔽的问题——不换通道画面照样出得来,只是所有人的皮肤都偏蓝,很多人在这一步误以为是 UE4 的色彩空间设置问题,实际是 RGBA 与 BGRA 的差异。
AddToRoot()是必须的。CreateTransient出来的贴图没有任何 UObject 引用它,下一轮 GC 就会回收,表现是列表滚动几次之后头像变成纯白。配合缓存TMap保存强引用后,AddToRoot可以在模块卸载时统一RemoveFromRoot,或者干脆依赖TMap的强引用,两种方式选一种,别都不做。
3.4 富状态与覆盖层唤起
富状态(Rich Presence)是好友列表里那句“正在比赛中”的来源。它的机制是:游戏本地设置若干 key-value,Steam 只负责同步,具体的展示文案由后台配置的本地化规则决定。
void USteamFriendsSubsystem::SetRichPresence(const FString& Key, const FString& Value) { if (!bSteamReady) { return; } // 入参必须是 UTF-8,中文必须走转换,否则会被静默截断 SteamFriends()->SetRichPresence(TCHAR_TO_UTF8(*Key), TCHAR_TO_UTF8(*Value)); } // 面板上最终显示什么,由 steam_display 指向后台配置的 token SetRichPresence(TEXT("steam_display"), TEXT("#Status_InLobby")); SetRichPresence(TEXT("status"), TEXT("在大厅等人"));两个参数的含义不同:status是给本地读的原始值,steam_display是让 Steam 去查本地化表。如果后台没配#Status_InLobby这一条,好友那边看到的就是原样的#Status_InLobby字符串。开发期建议 UI 层直接读status,别依赖后台配置,否则你会花半天时间怀疑是代码没生效。
覆盖层唤起只要一行,但前提是 Steam 客户端在运行且覆盖层没被关掉:
// 打开好友面板 SteamFriends()->ActivateGameOverlay("friends"); // 打开指定好友的资料页 SteamFriends()->ActivateGameOverlayToUser("steamid", FriendId);"friends"、"chat"、"players"这些是固定字符串,拼错不会崩溃,只会什么都不发生,这也是排查时容易被忽略的地方——先确认字符串拼写,再怀疑环境。
4. 排查清单:五个让好友列表一直是空的坑
演示工程跑不通的时候,现象往往只有一句“列表是空的”,但根因可能分布在初始化、环境、线程、渲染、账号权限五个层面上。这一章按层拆开,每条都给出可复现的现象、根因和验证手法。
4.1 初始化与环境层的三个坑
坑一:SteamAPI_Init()返回 false,日志里只有一行失败。
现象:子系统初始化直接 return,后面所有接口调用都拿不到数据,但没有任何更具体的错误信息。
原因:steam_appid.txt不在当前工作目录下。Steam 的查找逻辑是基于进程 CWD 的,编辑器启动时 CWD 通常是引擎的Binaries/Win64,不是你打开工程的那个目录。
解决:先看第 2 章里那两个UE_LOG打印出来的 CWD,把steam_appid.txt放到那个位置;或者干脆在启动参数里显式设置工作目录。文件内容只有一行 AppID 数字,不要加空格、不要加换行以外的东西,编码用 ASCII。
坑二:编辑器里正常,打包出来双击闪退。
现象:编辑器 PIE 下一切正常,打包成 Win64 后双击 exe,进程起来一两秒就消失,日志都没生成。
原因:steam_api64.dll没被拷到 exe 旁边。PublicAdditionalLibraries只在链接期起作用,它不会把 dll 打进包体。
解决:确认Build.cs里那段RuntimeDependencies.Add("$(BinaryOutputDir)/steam_api64.dll", DllPath)存在,并且打包后去输出目录看 dll 是否真的躺在那儿。同时把PublicDelayLoadDLLs补上,否则某些加载顺序下会表现为“模块加载失败”而不是“找不到 dll”。
坑三:SteamAPI_RestartAppIfNecessary让编辑器不断退出。
现象:在编辑器里点 Play,游戏窗口刚出现就退出,日志显示请求了进程退出。
原因:编辑器进程不是被 Steam 客户端拉起的,这个函数会判定“需要通过 Steam 重启”并返回 true。
解决:把整段调用用#if !WITH_EDITOR包住,编辑器下直接跳过。打包版才需要它——以便用户直接双击 exe 时,程序能请求 Steam 客户端把自己重新拉起,从而拿到覆盖层和好友通知的能力。
4.2 数据与渲染层的两个坑
坑四:好友数量对不上,多出一堆“不在线的人”。
现象:GetFriendCount返回的数字比 Steam 客户端里显示的好友数多。
原因:用了k_EFriendFlagAll而不是k_EFriendFlagImmediate,把已屏蔽、待处理、被忽略的条目都算进去了。
解决:枚举和取元素两处必须用同一个 flag,即GetFriendCount(k_EFriendFlagImmediate)配GetFriendByIndex(i, k_EFriendFlagImmediate)。两处不一致时,索引会越界或错位,表现是列表里出现重复好友或者最后几个人丢失。
坑五:头像一出来就是花屏或者纯白。
现象:列表能显示,名字正常,头像区域是彩色噪点,或者滚动几次之后变成白块。
原因:两种不同的根因。彩色噪点是 RGBA / BGRA 通道顺序没换;白块是CreateTransient出的贴图没有强引用,被 GC 回收了。
解决:换通道那段循环不能省;贴图要么AddToRoot(),要么用TMap存强引用并在模块卸载时清理。另外GetImageRGBA的缓冲区大小必须是W * H * 4,用SetNumZeroed或SetNumUninitialized都行,但长度算错会静默失败。
提示:头像下载是异步的,首次调用
GetImageSize返回 false 属于正常流程,不要当成错误打 Error 日志,否则日志会被刷爆,真正的错误反而被埋掉。
5. 打包与双账号验证:把 Demo 从编辑器搬到客户端
编辑器里跑通只能算完成一半。好友列表、富状态、覆盖层这三个功能有一个共同点:它们都依赖 Steam 客户端这一层外部环境,编辑器 PIE 下的表现和打包后经常不一致。这一章讲怎么把验证流程固定下来。
5.1 运行库随包分发与 steam_appid.txt 的边界
分发这块的核心原则是:dll 跟着包走,appid 不跟着包走。
steam_api64.dll必须出现在最终 exe 的同级目录。第 2 章的RuntimeDependencies已经处理了这一步,打包后建议手动确认一次文件是否存在,因为路径宏在某些自定义打包脚本里可能不展开。
steam_appid.txt则是开发期的临时手段。它的作用是让程序在没有被 Steam 客户端拉起的情况下也能拿到 AppID 并初始化。正式分发时不应该带这个文件——带了意味着 AppID 被硬编码在安装目录里,用户装了别的版本很容易互相干扰;正确做法是让程序始终通过 Steam 客户端启动,由客户端注入 AppID。
一个折中的做法是:开发构建里保留steam_appid.txt,Release 配置下用一个 UAT 后处理脚本把它从输出目录删掉。这比在代码里做条件编译更可靠,因为文件系统层面的事最好在文件系统层面解决。
5.2 双账号可复现的验证流程
验证好友功能最少需要两个账号,这是硬性要求。单人单号只能验证“自己能看到自己”,覆盖不到好友列表、状态同步、覆盖层跳转这三块。
| 步骤 | 操作 | 预期结果 | 失败时先看什么 |
|---|---|---|---|
| 1 | A 账号登录客户端,启动打包版 | 进程正常启动,日志出现SteamAPI_Init成功 | dll 是否在 exe 同级目录 |
| 2 | B 账号在另一台机器(或同一台切号)登录并加 A 为好友 | A 的好友列表在 5 秒内出现 B | SteamAPI_RunCallbacks是否在 tick |
| 3 | A 修改富状态为“在大厅等人” | B 的客户端好友列表能看到这句文案 | 是否只设了status没设steam_display |
| 4 | A 调用ActivateGameOverlay("friends") | 覆盖层弹出好友面板 | 客户端设置里覆盖层是否被关 |
| 5 | B 下线再上线 | A 的列表状态从离线变在线,无需重启 | PersonaStateChange_t是否注册 |
第 5 步是最容易被漏掉的。很多人测完前四步就认为完成了,实际上状态同步走的是回调通道,如果RunCallbacks没在跑,静态列表看着完全正常,只有状态变化时才会暴露问题。
另外,测试账号需要在 Steamworks 后台被加进测试名单,否则用未授权的账号登录会初始化失败。这一条在日志里通常只表现为一个泛化的错误码,不查后台根本想不到。
5.3 验收清单与日志埋点
把上面这些固化成一份清单,每次改完代码走一遍,比事后回忆“上次是怎么好的”高效得多。日志方面建议单独定义一个LogSteamFriends类别,别全都塞进LogTemp,否则打包版里过滤不出来。
DECLARE_LOG_CATEGORY_EXTERN(LogSteamFriends, Log, All); DEFINE_LOG_CATEGORY(LogSteamFriends); // 关键路径上打点:初始化、回调分发、列表刷新、头像加载 UE_LOG(LogSteamFriends, Verbose, TEXT("[Steam] RunCallbacks, pending=%d"), 0); UE_LOG(LogSteamFriends, Log, TEXT("[Steam] FriendList refreshed, count=%d"), List.Num()); UE_LOG(LogSteamFriends, Warning, TEXT("[Steam] Avatar not ready, handle=%d"), Handle);Verbose级别用于每帧都会触发的路径,默认不输出,需要时改配置打开;Log级别只放在状态真正变化的地方,比如列表重建、状态切换。这个分层做不做,直接决定你在打包环境里排查问题时是看几百行有效日志还是一屏幕重复内容。
6. 进阶:用事件驱动把好友模块做成可复用的子系统
最后说一个把 Demo 变成生产代码的关键改动:把“每次打开面板就全量重建列表”改成事件驱动。
演示工程里最直接的做法是 UI 打开时调一次GetFriendList(),简单可靠,但好友一多就会卡顿——每次调用都要遍历全部好友、拼字符串、查头像句柄,几百人的列表在低端机上能明显感到掉帧。我一般会改成两层:子系统内部维护一份TMap<FString, FSteamFriend>作为只读快照,只在收到PersonaStateChange_t时做增量更新;UI 层订阅一个OnFriendListChanged委托,只在快照真正变化时重建可见行。
void USteamFriendsSubsystem::OnPersonaStateChange(PersonaStateChange_t* Param) { if (!Param || Param->m_nChangeFlags == 0) { return; } const CSteamID Id = CSteamID(Param->m_ulSteamID); const FString Key = FString::Printf(TEXT("%llu"), Param->m_ulSteamID); // 只处理已建立好友关系的对象,避免把陌生人塞进快照 if (!SteamFriends()->HasFriend(Id, k_EFriendFlagImmediate)) { return; } FSteamFriend& Item = FriendSnapshot.FindOrAdd(Key); Item.SteamId = Key; Item.PersonaName = UTF8_TO_TCHAR(SteamFriends()->GetFriendPersonaName(Id)); Item.State = static_cast<int32>(SteamFriends()->GetFriendPersonaState(Id)); // 全部处理完再广播一次,避免 UI 收到 N 次刷新 OnFriendListChanged.Broadcast(); }这里有两个参数要注意。m_nChangeFlags是按位或的标志集合,昵称变化、头像变化、状态变化各占一位,只在需要的位上做重建,能省掉大量无意义的字符串转换。HasFriend的第二个参数必须和列表枚举时用的 flag 一致,否则会出现“回调收到了但快照里没这个人”的情况——这是我在早期版本里改了两天才定位到的问题,因为回调函数本身没有任何返回值可以判断。
头像的加载也建议异步化:在AvatarImageLoaded_t回调里拿到句柄,再去调用GetAvatarTexture,而不是在遍历列表时同步调用。同步调用的后果是首次打开面板时会有一次明显的卡顿,好友越多越明显,而这次卡顿在性能分析工具里不容易被归因到 Steam 身上。
回归习惯上,我现在的做法是每次改动涉及Build.cs或者 SDK 目录结构,都强制走一遍“打包 + 双账号”的完整流程,哪怕只改了一行路径。因为这一类问题在编辑器里永远不会暴露,等到发版前才发现,代价就不是几分钟的事了。这套 SteamFriendsUE4 演示把坑都踩过一遍,照着走能省掉不少时间,希望帮到你。
本文还有配套的精品资源,点击获取