
简介面向 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子系统负责好友枚举可用但字段少GetFriendCountGetFriendByIndex原生头像纹理不直接提供GetMediumFriendAvatarGetImageRGBA原生在线状态详情粗粒度GetFriendPersonaState Rich Presence原生Overlay 呼出无ActivateGameOverlay原生邀请加入走 Session 接口InviteUserToGame 回调两边配合有一点必须提前说清楚只要DefaultEngine.ini里把DefaultPlatformService设成SteamOnlineSubsystemSteam就会自己完成SteamAPI_Init并且在自己的 tick 里驱动SteamAPI_RunCallbacks。这时候你再手动调一次SteamAPI_Init()它会返回 false。所以工程里的初始化代码不拿返回值当唯一判据而是用SteamAPI_IsSteamRunning()和SteamUser()-BLoggedOn()判断真实状态这样两种启动方式都不会误判。2.2 目录结构和模块依赖工程按“接入层 / 数据层 / UI 层”三层切开好处是后面换 UI 不用动接入代码SteamFriendsDemo/ ├── Config/ │ ├── DefaultEngine.ini # 指定 DefaultPlatformServiceSteam │ └── 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] DefaultPlatformServiceSteam [OnlineSubsystemSteam] bEnabledtrue SteamDevAppId480 bInitServerOnClienttruebInitServerOnClient是给“客户端也要当监听服务器”的场景准备的单人调试好友邀请时开着更省事。2.3 数据结构与启动时序好友信息统一收敛到一个 USTRUCTUI 层只认这个结构不认 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_castint32(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; } TArrayuint8 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()修饰的TMapuint64, UTexture2D*挂在子系统上做缓存好友数几十上百也就几 MB比每次刷 UI 都重建纹理划算得多。3.3 回调驱动状态变化与 RunCallbacks好友列表不是拉一次就完事得订阅变化。Steamworks 用的是回调对象声明成成员变量保证生命周期void USteamSubsystem::RegisterCallbacks() { // 昵称、头像、在线状态变化都会走这里 PersonaStateChangeCallback CallbackPersonaStateChange_t::CreateUObject( this, USteamSubsystem::OnPersonaStateChange); // 好友通过 Overlay 或者 Rich Presence 点加入游戏时触发 JoinRequestedCallback CallbackGameRichPresenceJoinRequested_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 对应本地化文件里的 tokenstatus 是给不支持的客户端兜底的 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_lobbyxxx启动你的OnJoinRequested回调里从Param-m_rgchConnect取出来解析再走加入大厅逻辑。如果不写这段参数邀请能弹出来但点了之后只是启动游戏进不了你的房间。SetRichPresence的steam_display字段指向本地化 tokenSteam 客户端会拿它渲染成“正在某游戏中”比纯文本的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里的DefaultPlatformServiceSteam和bEnabledtrue都在这是子系统接管初始化的前提。第二步在项目根目录和.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 就绪, AppID480, 当前用户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_lobbyxxx是拼到对方启动命令行上的接收端需要在GameInstance初始化阶段读FCommandLine::Get()并解析出 lobby id再走加入流程。只看OnJoinRequested回调是不够的——那个回调只在游戏已经在运行时才触发。解决办法是两条路都实现运行中的客户端走GameRichPresenceJoinRequested_t回调未运行的客户端走命令行解析。两边最终调同一个JoinLobbyById函数逻辑就不会分叉。提示调试邀请功能时至少要两个 Steam 账号一个登录编辑器一个登录打包版本用同一个测试 AppID否则好友关系在 Steam 侧不成立怎么调都是 0。6. 二次开发改造把演示级好友面板做成可复用的社交模块演示工程里的好友面板是直接绑在SteamSubsystem上的去掉 Steam 就全废。真要拿去接项目第一件事是把“好友数据源”抽成接口让 UI 只依赖IFriendProviderSteam 只是其中一个实现class IFriendProvider { public: virtual ~IFriendProvider() default; virtual void Refresh() 0; virtual const TArrayFSteamFriendInfo 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 好友不卡帧3Rich Presence 接本地化中状态文案正式化4邀请流程接 Session 层大真正能进房间5头像缓存那块给个具体写法别只是TMap无限涨// 简易上限超过 128 个就清掉不在当前好友列表里的条目 void USteamSubsystem::TrimAvatarCache(const TSetuint64 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——先把数据层看清楚界面那点事反而最不值钱。希望帮到你。本文还有配套的精品资源点击获取