☰
UE4 C++接入Steam好友系统:列表、头像与状态回调
2026/10/29 2:24:27 网站建设 项目流程

简介:SteamFriendsUE4 是一份面向 UE4 C++ 开发者的 Steam Friends API 集成演示工程,适合已有一定基础、准备为项目接入好友列表、邀请与会话加入能力的开发者参考。工程围绕 Steam 子系统展开:通过异步回调代理把好友列表请求封装成可在蓝图中执行的等待节点,借助蓝图函数库暴露邀请好友的调用入口,并用自定义 GameInstance 承接邀请接受后的会话加入逻辑,形成一条较为完整的最小闭环。压缩包共 7 个文件,包含 3 个 h 头文件、3 个 cpp 源文件及 1 个 md 说明文档,整体约 7KB,体量轻便,便于快速通读代码结构与调用关系。已有 311 人学习,说明其对 UE4 接入 Steamworks 的入门与排错具有一定参考价值。读者可从中了解异步蓝图节点的封装方式、Steam 好友数据获取流程、邀请接口的暴露方法,以及邀请被接受后加入会话的处理思路,适合作为二次开发与联机功能验证的起点。

1. SteamFriendsUE4:一个把好友列表、头像、状态回调串起来的最小 UE4 演示

策划把好友面板的交互稿拍在桌上,列表、头像、在线状态、"正在玩什么"四样全要,而你手上的 UE4 工程里只有一行孤零零的 SteamAPI_Init。SteamFriendsUE4 这个演示工程就是为这种时刻准备的:它把 Steam Friends API 里最常用的四类能力——好友枚举、Persona 名称与状态、头像位图、状态变更回调——在 UE4 的 C++ 模块里完整跑通,并且同时给出原生 ISteamFriends 和 OnlineSubsystem 两条调用路径。适合已经能把 UE4 C++ 工程编译起来、需要接平台好友系统的从业者;也适合只想搞清楚"为什么我的头像是张白图"的人。它不是什么大而全的框架,就是一份能对着抄的集成骨架。

2. 工程骨架:SDK 目录、模块依赖和三处配置到底改哪里

UE4 接平台好友系统,翻车点极少出在 C++ 逻辑上,八成死在工程骨架:SDK 放错目录,插件找不到头文件;模块依赖漏一个,链接期一堆 unresolved external;配置里 DefaultPlatformService 写错,运行时子系统直接回落成 NULL。这一章把这三件事按顺序拆开。

2.1 Steamworks SDK 的目录摆放与引擎版本对齐

UE4 的 OnlineSubsystemSteam 插件在 Build.cs 里是按固定相对路径去找 SDK 的,路径形如Engine/Source/ThirdParty/Steamworks/<版本目录>/sdk。你随手把 sdk 丢进项目 Plugins 目录,插件本身依然会去引擎路径翻,结果就是头文件能找到、链接却炸。稳妥做法是先看清引擎期望哪个目录名。

# 1) 先看引擎里已经存在的 Steamworks 版本目录名,目录名必须完全一致 ls "$UE_ROOT/Engine/Source/ThirdParty/Steamworks/" # 2) 假设输出里有 SteamvXYZ 这样的目录,把下载的 sdk 整个放进去 # 放完的结构:.../Steamworks/SteamvXYZ/sdk/steam/steam_api.h cp -r ~/Downloads/sdk "$UE_ROOT/Engine/Source/ThirdParty/Steamworks/SteamvXYZ/" # 3) 校验头文件和库文件都到位,缺一个后面都要返工 test -f "$UE_ROOT/Engine/Source/ThirdParty/Steamworks/SteamvXYZ/sdk/steam/steam_api.h" && echo "header OK" ls "$UE_ROOT/Engine/Source/ThirdParty/Steamworks/SteamvXYZ/sdk/redistributable_bin/win64/"

这里的UE_ROOT指你本机引擎安装根目录,SteamvXYZ必须替换成第 1 步ls出来的真实目录名。为什么要强调版本目录对齐:不同引擎版本的 OnlineSubsystemSteam 会把版本目录名写死在构建脚本里,名字对不上时编译期报的是"找不到 steam_api.h",很容易被误判成环境问题去重装 VS,白白浪费半天。第 3 步的redistributable_bin校验同样关键,头文件只是编译期依赖,运行时还得靠steam_api64.dll,这个 DLL 最终要跟着可执行文件走。

我一般会把 SDK 的public/和redistributable_bin/分开记:前者解决编译,后者解决运行,两件事永远分开排查。

2.2 Build.cs 与 .uproject 的模块依赖

骨架第二件事是模块依赖。原生接口需要Steamworks模块,抽象层需要OnlineSubsystem,蓝图侧还会用到OnlineSubsystemUtils。少写一个,症状不是编译报错而是链接期一片 unresolved external symbol,报错信息里全是 CSteamID 之类的符号名,看着像 SDK 没生效,其实是模块没进依赖表。

// Source/SteamFriendsUE4/SteamFriendsUE4.Build.cs public class SteamFriendsUE4 : ModuleRules { public SteamFriendsUE4(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", // 抽象层:IOnlineSubsystem / IOnlineFriends / IOnlineIdentity "OnlineSubsystem", // 蓝图节点与部分 Session 辅助,好友面板里做蓝图入口时会用到 "OnlineSubsystemUtils", // 原生层:ISteamFriends / ISteamUtils / CCallback "Steamworks" }); // OnlineSubsystemSteam 是运行时插件,不进这里的依赖列表; // 它是否参与编译由 .uproject 的插件开关决定,这一步漏了打包后就是个空壳 } }

参数说明:PublicDependencyModuleNames里放的是对外暴露头文件的模块,如果你的好友子系统头文件会在其他模块里被 include,OnlineSubsystem和Steamworks就必须放在 Public 而不是 Private 里,否则下游模块编译时会提示找不到steam_api.h。PCHUsage保持引擎模板默认值即可,改成NoSharedPCH会显著拉长编译时间。

配套的.uproject要显式打开插件,这一步是打包后能不能用的分水岭:

{ "FileVersion": 3, "Modules": [ { "Name": "SteamFriendsUE4", "Type": "Runtime", "LoadingPhase": "Default" } ], "Plugins": [ { "Name": "OnlineSubsystemSteam", "Enabled": true }, { "Name": "OnlineSubsystem", "Enabled": true } ] }

LoadingPhase用Default就够,不要图快去写PreDefault,好友子系统依赖引擎的 OnlineSubsystem 单例先就位,加载顺序反了会拿到空指针。

2.3 DefaultEngine.ini 与 steam_appid.txt 的配对

第三件事是配置。[OnlineSubsystem]段决定默认平台服务走谁,[OnlineSubsystemSteam]段决定 Steam 子系统自己的参数。这两段任意一段写错,日志里的表现都是"子系统名 = NULL"。

; Config/DefaultEngine.ini [OnlineSubsystem] ; 这一行决定 IOnlineSubsystem::Get() 返回哪个实现,大小写敏感 DefaultPlatformService=Steam [OnlineSubsystemSteam] bEnabled=true bRelaunchInSteam=true ; 开发期用平台提供的公共测试 AppID,正式发布换成自己的 SteamDevAppId=480 GameServerQueryPort=27015

DefaultPlatformService的值必须和插件注册的子系统名完全一致,写steam小写在某些引擎版本上会匹配失败,回落到 NULL 子系统。bRelaunchInSteam打开后,从编辑器启动时若检测到平台客户端在跑,会尝试以平台身份重启进程,方便调试身份相关逻辑;如果你的调试流程依赖附加进程,把它临时关掉更省事。SteamDevAppId只影响开发期,打包发布时用的是另一套 AppID 配置,别把 480 带进正式包。

steam_appid.txt是另一处高频坑:编辑器启动的进程工作目录是引擎的Binaries/Win64,不是项目的。文件放错地方,SteamAPI_Init 直接返回 false。

# 编辑器调试:引擎和项目两边都放一份,覆盖两种启动路径 echo 480 > "$UE_ROOT/Engine/Binaries/Win64/steam_appid.txt" echo 480 > "$PROJECT_ROOT/Binaries/Win64/steam_appid.txt"

配置齐了先别急着写好友逻辑,做一次自检把状态打进日志,比后面盲猜快得多。

// GameInstance 的 Init 里做一次性自检 void USteamFriendsGameInstance::Init() { Super::Init(); IOnlineSubsystem* Subsystem = IOnlineSubsystem::Get(); if (!Subsystem) { // 走到这里基本可以断定 DefaultPlatformService 写错或插件没启用 UE_LOG(LogTemp, Error, TEXT("[Steam] 未拿到任何 OnlineSubsystem")); return; } UE_LOG(LogTemp, Log, TEXT("[Steam] 子系统 = %s"), *Subsystem->GetSubsystemName().ToString()); IOnlineIdentityPtr Identity = Subsystem->GetIdentityInterface(); if (Identity.IsValid()) { // 编辑器下常见值为 NotStarted,不代表配置错 UE_LOG(LogTemp, Log, TEXT("[Steam] 登录状态 = %d"), (int32)Identity->GetLoginStatus(0)); } }

GetSubsystemName()返回 FName,日志里应该看到Steam。看到NULL就别往下写了,先回到 2.3 查 ini 和 appid 文件。这一行日志是整个集成过程里最省时间的黑匣子。

3. 好友列表与头像:两条接口路径和一次 int32 到 UTexture2D 的硬转换

好友面板真正难的不是"拿到好友",而是拿到之后怎么组织。原生 ISteamFriends 给你的是 CSteamID 加一堆 const char*,抽象层给你的是 FOnlineFriend 智能指针,两者数据结构完全不同。头像更极端,平台侧给你的只是一个 int32 句柄,要变成 UTexture2D 得自己走完四步。

3.1 原生 ISteamFriends 拉好友列表

原生接口的优点是快、字段全、没有异步窗口;缺点是把平台绑死了,将来要接别家得重写一遍。演示工程里这条路径主要用来对照,验证抽象层拿到的数据对不对。

#include "steam/steam_api.h" struct FFriendRow { FString PersonaName; FString SteamId; int32 PersonaState = 0; int32 AvatarHandle = -1; }; void USteamFriendsSubsystem::CollectFriendsNative(TArray<FFriendRow>& OutRows) { OutRows.Reset(); // k_EFriendFlagImmediate 只取真正的好友,不含忽略与屏蔽名单 const int32 FriendCount = SteamFriends()->GetFriendCount(k_EFriendFlagImmediate); if (FriendCount <= 0) { UE_LOG(LogTemp, Warning, TEXT("[Steam] 好友数为 0,先确认客户端已登录且账号有好友")); return; } for (int32 Index = 0; Index < FriendCount; ++Index) { const CSteamID FriendId = SteamFriends()->GetFriendByIndex(Index, k_EFriendFlagImmediate); FFriendRow Row; // Persona 名是 UTF-8,必须走 UTF8_TO_TCHAR,直接塞 std::string 会乱码 Row.PersonaName = UTF8_TO_TCHAR(SteamFriends()->GetFriendPersonaName(FriendId)); Row.SteamId = FString::Printf(TEXT("%llu"), FriendId.ConvertToUint64()); Row.PersonaState = (int32)SteamFriends()->GetFriendPersonaState(FriendId); Row.AvatarHandle = SteamFriends()->GetMediumFriendAvatar(FriendId); OutRows.Add(MoveTemp(Row)); } }

k_EFriendFlagImmediate是筛选位掩码,传入后只返回"直接好友",用k_EFriendFlagAll会把忽略、屏蔽、请求中的全捞出来,UI 上会显得很怪。GetFriendPersonaState返回的是平台定义的枚举值,直接从 0 开始的整数,映射到 UI 上的"在线/离开/忙碌"要自己建一张表。GetMediumFriendAvatar返回的 int32 是关键:0表示这个好友压根没设头像,-1表示头像图还在下载,只有正数才是可用的句柄。三种值在 UI 上的处理完全不同,混在一起就会画出一堆白块。

3.2 IOnlineFriends 的跨平台写法与异步窗口

抽象层的写法多一层间接,但换平台时只改 ini 不改代码。代价是必须处理异步:ReadFriendsList返回的时候列表一定是空的。

void USteamFriendsSubsystem::RequestFriendList() { IOnlineSubsystem* Subsystem = IOnlineSubsystem::Get(); if (!Subsystem) { return; } IOnlineFriendsPtr Friends = Subsystem->GetFriendsInterface(); if (!Friends.IsValid()) { UE_LOG(LogTemp, Error, TEXT("[Steam] 好友接口不可用,检查插件是否启用")); return; } // LocalUserNum 单机场景固定 0;回调返回前列表一定还没就绪 Friends->ReadFriendsList( 0, TEXT("Default"), FOnReadFriendsListComplete::CreateUObject( this, &USteamFriendsSubsystem::OnReadFriendsListComplete)); } void USteamFriendsSubsystem::OnReadFriendsListComplete( int32 LocalUserNum, bool bWasSuccessful, const FString& ListName, const FString& ErrorStr) { if (!bWasSuccessful) { UE_LOG(LogTemp, Error, TEXT("[Steam] ReadFriendsList 失败: %s"), *ErrorStr); return; } TArray<TSharedRef<FOnlineFriend>> RawFriends; // ListName 必须和 Read 时传的完全一致,传空字符串会拿到另一个桶 if (!IOnlineSubsystem::Get()->GetFriendsInterface()->GetFriendsList(0, ListName, RawFriends)) { return; } for (const TSharedRef<FOnlineFriend>& Friend : RawFriends) { const FString Name = Friend->GetDisplayName().ToString(); const FString UserId = Friend->GetUserId()->ToString(); const FString Presence = Friend->GetPresence().ToString(); UE_LOG(LogTemp, Verbose, TEXT("[Steam] %s | %s | %s"), *Name, *UserId, *Presence); } OnFriendListReady.Broadcast(RawFriends.Num()); }

ListName是最容易错的一个参数:ReadFriendsList和GetFriendsList两边必须传同一个常量,一边写空字符串一边写Default,读出来永远是 0 条,而回调还返回成功,症状非常有迷惑性。FOnReadFriendsListComplete用CreateUObject绑定,不要用CreateLambda捕获裸指针,UI 销毁后回调仍然可能回来。GetPresence()拿的是 Rich Presence 里的字符串,可能为空,UI 要有兜底文案。

3.3 头像从 int32 句柄到 UTexture2D 的四步转换

这一步是整个演示里最容易出玄学的地方,因为失败了不报错,只给你一张白图或黑图。四步顺序不能变:问尺寸、开缓冲、取像素、建纹理。

UTexture2D* USteamFriendsSubsystem::BuildAvatarTexture(int32 AvatarHandle) { if (AvatarHandle <= 0) { return nullptr; } // 0 无头像,-1 加载中 uint32 Width = 0, Height = 0; // 第一步:先问平台这张图多大,拿不到尺寸就没法开缓冲 if (!SteamUtils()->GetImageSize(AvatarHandle, &Width, &Height)) { return nullptr; } if (Width == 0 || Height == 0) { return nullptr; } // 第二步:RGBA 四通道,缓冲区大小必须严格等于 W*H*4 TArray<uint8> Pixels; Pixels.SetNumUninitialized(Width * Height * 4); if (!SteamUtils()->GetImageRGBA(AvatarHandle, Pixels.GetData(), Pixels.Num())) { // 图还没下载完,等 AvatarImageLoaded_t 回调回来再重试 return nullptr; } // 第三步:PF_B8G8R8A8 在小端机器上的内存序是 B,G,R,A,平台给的是 R,G,B,A for (uint32 i = 0; i < Width * Height; ++i) { Swap(Pixels[i * 4 + 0], Pixels[i * 4 + 2]); } UTexture2D* Texture = UTexture2D::CreateTransient(Width, Height, PF_B8G8R8A8); if (!Texture) { return nullptr; } Texture->SRGB = true; Texture->AddToRoot(); // 没有 UPROPERTY 引用时防 GC,配上 RemoveFromRoot // 第四步:写进 Mip0,必须 Unlock 之后再 UpdateResource void* Dest = Texture->PlatformData->Mips[0].BulkData.Lock(LOCK_READ_WRITE); FMemory::Memcpy(Dest, Pixels.GetData(), Pixels.Num()); Texture->PlatformData->Mips[0].BulkData.Unlock(); Texture->UpdateResource(); return Texture; }

参数说明:GetImageSize输出的是像素尺寸,不是字节数;Pixels.Num()必须是W*H*4,传给GetImageRGBA的第三个参数是缓冲区字节数,写小了会返回 false,写大了会越界写。PF_B8G8R8A8与平台返回的 RGBA 顺序差一个通道交换,不交换的结果就是红色和蓝色对调,人脸变成青紫色。AddToRoot是临时手段,纹理交给 UObject 持有后记得RemoveFromRoot,否则这张图在关卡切换后也不会被回收。

3.4 缓存与生命周期:TMap 里该存什么

好友列表一刷十几张头像,每帧重建就是灾难。我一般用一个句柄到弱指针的映射做缓存,键用AvatarHandle而不是 SteamID,因为同一个好友换头像时句柄会变,用 SteamID 做键会拿到旧图。

// 头文件里声明,注意用 TWeakObjectPtr 而不是裸指针 UPROPERTY() TMap<int32, TWeakObjectPtr<UTexture2D>> AvatarCache; UTexture2D* USteamFriendsSubsystem::GetOrLoadAvatar(int32 AvatarHandle) { if (TWeakObjectPtr<UTexture2D>* Found = AvatarCache.Find(AvatarHandle)) { if (Found->IsValid()) { return Found->Get(); } // 命中且未被回收 AvatarCache.Remove(AvatarHandle); // 命中但已被回收,重新建 } UTexture2D* NewTex = BuildAvatarTexture(AvatarHandle); if (NewTex) { AvatarCache.Add(AvatarHandle, NewTex); } return NewTex; }

用TWeakObjectPtr做值,配合UPROPERTY的 TMap,既能让 GC 正常回收,又不会因为缓存持有强引用导致纹理永不释放。命中但失效的分支一定要写,否则点击列表刷新时会拿到空指针。缓存上限建议按好友数设个软阈值,超过就清掉最久未访问的一半,别让一屏之外的头像一直占显存。

4. 回调与状态同步:RunCallbacks 挂错位置,前面全白干

好友系统跟普通数据接口最大的区别是它是事件驱动的,好友昵称改了、头像换了、从好友列表点了"加入游戏",这些都是回调推过来的。回调泵没跑起来,前面写的列表和头像就只是一张静态快照。

4.1 SteamAPI_RunCallbacks 到底该谁调

这里有一条分界线:如果你的工程是通过 OnlineSubsystemSteam 插件初始化的平台 API,插件模块自己会在 Tick 里调用SteamAPI_RunCallbacks,你不需要也不应该再调一次;如果你是直接调SteamAPI_Init绕过插件,那就必须自己挂。两条路线混着用,症状是回调偶尔触发、偶尔不触发,排查起来极其痛苦。

// 自己调 SteamAPI_Init 的情况下,回调泵必须挂在每帧都会执行到的地方 void USteamFriendsGameInstance::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); if (bOwnsSteamApi) { // 少了这一行,PersonaStateChange 之类的回调永远不会触发 SteamAPI_RunCallbacks(); } }

bOwnsSteamApi这个布尔量在 Init 成功之后置位、Shutdown 时清零,用来区分是不是自己接管了 API 生命周期。放在 GameInstance 的 Tick 里是因为它的生命周期覆盖整局游戏且一定在主线程;放在 Actor 的 Tick 上,Actor 一销毁回调就断了。DeltaSeconds在这里不用,但保留标准签名方便将来做节流。

4.2 STEAM_CALLBACK 的写法与生命周期陷阱

平台 SDK 的回调注册靠一个宏加一个成员变量,写法固定但极容易写错生命周期。

// 观察者对象必须是长生命周期的成员,不能是函数里的局部变量 class FSteamPersonaWatcher { public: void Bind(USteamFriendsSubsystem* InOwner) { Owner = InOwner; } private: void OnPersonaStateChange(PersonaStateChange_t* Param) { if (!Owner) { return; } const uint64 ChangedId = Param->m_ulSteamID; if (Param->m_nChangeFlags & k_EPersonaChangeName) { Owner->MarkRowDirty(ChangedId, EDirtyField::Name); } if (Param->m_nChangeFlags & k_EPersonaChangeAvatar) { Owner->InvalidateAvatar(ChangedId); // 旧纹理从缓存里摘掉 } } // 宏会在这里声明一个 CCallback 成员,构造时自动注册、析构时自动反注册 STEAM_CALLBACK(FSteamPersonaWatcher, OnPersonaStateChange, PersonaStateChange_t, m_PersonaStateChange); USteamFriendsSubsystem* Owner = nullptr; };

把FSteamPersonaWatcher写成函数里的局部变量是这套代码里最经典的翻车方式:构造函数注册回调,函数一返回析构函数立刻反注册,逻辑看起来完全正确但永远收不到事件。正确做法是用TUniquePtr<FSteamPersonaWatcher>挂在子系统上,子系统销毁时才释放。m_nChangeFlags是位掩码,一次变更可能同时带名称和头像两个标志,判断必须用按位与而不是相等比较。

4.3 PersonaStateChange_t 驱动 UI 刷新

回调本身不该直接改 UI,它只该打脏标记,由 UI 在下一帧统一消费。原因是同一次刷新里可能有十几个好友同时改状态,逐个触发重绘会让面板闪。

void USteamFriendsSubsystem::MarkRowDirty(uint64 SteamId, EDirtyField Field) { DirtyRows.FindOrAdd(SteamId) |= (uint8)Field; } // UMG 的 NativeTick 里消费脏标记,一帧只重建变化的那几行 void UFriendListWidget::NativeTick(const FGeometry& Geometry, float DeltaTime) { Super::NativeTick(Geometry, DeltaTime); if (Subsystem->DirtyRows.Num() == 0) { return; } for (const TPair<uint64, uint8>& Pair : Subsystem->DirtyRows) { RefreshRowById(Pair.Key, Pair.Value); } Subsystem->DirtyRows.Reset(); }

FindOrAdd会在键不存在时插入默认值再返回引用,位或赋值把多次变更加起来,一帧内同一个人改三次也只刷一次。DirtyRows建议用TMap<uint64, uint8>而不是TSet,因为要记录"哪几个字段变了",全量刷虽然简单但头像重建开销很大。

4.4 Rich Presence 与加入请求

好友面板上那句"正在玩什么"来自 Rich Presence,是键值对,由你主动设置;别人从好友列表点"加入游戏"时,平台会把连接串通过回调推给你。

// 设置自己的状态,好友那边就能看到 SteamFriends()->SetRichPresence("status", TEXT("在调好友列表")); SteamFriends()->SetRichPresence("connect", TEXT("+connect 127.0.0.1:7777")); // 监听别人点"加入游戏" void FSteamPersonaWatcher::OnGameJoinRequested(GameRichPresenceJoinRequested_t* Param) { const FString Connect = UTF8_TO_TCHAR(Param->m_rgchConnect); if (Connect.StartsWith(TEXT("+connect "))) { const FString Endpoint = Connect.RightChop(9); // Endpoint 形如 IP:Port,交给 Session 层去连 } }

SetRichPresence的键名没有强制约定,但connect是平台识别的固定键,好友列表里的"加入游戏"按钮就是读它。m_rgchConnect是 UTF-8 的 C 字符串,含+connect前缀,直接用会连不上。这套回调同样要注册在长生命周期对象上,和 4.2 是同一个坑。

5. 避坑排查:五个让好友面板当场翻车的写法

这一段是按我自己踩过的顺序排的,每一条都写成现象、原因、解决三段,方便对着日志逐条排除。

5.1 SteamAPI_Init 返回 false:三个最常见的原因

现象:PIE 一启动日志就刷初始化失败,自检打出来的子系统名是NULL,好友接口拿不到。

原因:九成是三个之一。其一,steam_appid.txt放错了目录,编辑器进程的工作目录是引擎的Binaries/Win64,文件放在项目目录下编辑器根本读不到;其二,平台客户端没启动或者没登录,SDK 初始化会直接失败;其三,steam_api64.dll没跟可执行文件放一起,编译期一切正常,运行期加载失败。

解决:先把自检日志跑一遍确认子系统名;再分别检查引擎和项目两边Binaries/Win64下的 appid 文件;最后确认 DLL 与 exe 同目录。我一般把这三步写成一个启动时的检查函数,失败时把路径直接打进日志,比对着文档猜快得多。

5.2 好友列表为空:ReadFriendsList 的异步窗口

现象:ReadFriendsList的回调返回bWasSuccessful = true,但紧接着GetFriendsList出来 0 条,UI 一片空白。

原因:最典型的是在ReadFriendsList调用之后同步取列表,列表还没填充;其次是两边ListName不一致,读的时候传TEXT(""),取的时候传TEXT("Default"),落进了不同的桶;还有一种是自己重复调用了两次ReadFriendsList,第二次把第一次的结果覆盖成空。

解决:把ListName提成一个文件内常量,读写共用;只在回调里取列表,不做同步取;加一个"请求中"标志位,请求未回时忽略新的请求。这个坑的迷惑性在于成功标志和空数据同时出现,如果不打字段日志会以为是平台账号没好友。

5.3 头像是白块或青紫色:RGBA 缓冲区的三个参数

现象:BuildAvatarTexture返回了非空纹理,UI 上却是一张纯白、纯黑或者人脸青紫的图。

原因:三个参数级错误。缓冲区大小算成W*H而不是W*H*4,GetImageRGBA会返回失败或写半张图;纹理格式用PF_B8G8R8A8却没做 R/B 通道交换,红蓝对调;SRGB没打开,颜色整体偏灰。

解决:先打印Width、Height和Pixels.Num()三个值,确认缓冲大小是W*H*4;再按 3.3 的循环做通道交换;最后确认SRGB = true。如果图还是纯白,检查BulkData.Unlock()之后有没有调UpdateResource(),只 Unlock 不更新是画不出来的。

5.4 状态回调不触发:观察者对象被提前析构

现象:好友改了昵称、换了头像,界面纹丝不动,重新打开面板才更新。

原因:FSteamPersonaWatcher写成了函数内的局部变量,构造时注册、函数返回时析构即反注册;或者观察者用new创建但没保存指针,注册表里挂着一个野对象;再或者bOwnsSteamApi为真却忘了在 Tick 里调SteamAPI_RunCallbacks。

解决:观察者改成子系统的TUniquePtr成员,跟着子系统一起生灭;回调泵的调用位置固定在 GameInstance 的 Tick 里;在回调函数入口打一行日志,先确认回调有没有进来,再查 UI 刷新逻辑。这行日志是整个排查流程里最值钱的一行,能一刀切开"没收到事件"和"收到了没刷新"两类问题。

5.5 编辑器正常、打包

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询