简介:这份演示工程围绕在 UE4 中接入 Steam Friends API 展开,面向已具备 C++ 与蓝图基础、希望实现好友列表读取与邀请联机流程的开发者,可用于理解 Steam 子系统调用、异步回调与会话加入之间的衔接方式。压缩包共 7 个文件,以 3 个 .h 头文件、3 个 .cpp 源文件和 1 个 README.md 为主,整体约 7KB,代码规模精简,便于快速定位关键实现与目录结构。工程中 GetFriendsListCallBackProxy 以完整蓝图节点形式封装好友列表获取,并支持网络等待任务;NetBlueprintFunctionLibrary 提供邀请好友的蓝图函数库;NetGameInstance 则在邀请被接受后负责加入对应会话,形成从读取好友到发起、接受邀请的闭环示例。目前已有 311 人学习,适合用作 UE4 Steam 联机功能的最小原型参考,也可据此扩展自己的好友与邀请逻辑。
1. 从“好友列表永远是空的”说起
在 UE4 里接 Steam Friends API,最常见的失败原因往往不是编译报错:代码一次编译通过,运行起来好友列表却永远是空数组。这份演示工程把 C++ 侧整条链路铺开:OnlineSubsystem 初始化、IOnlineFriends 异步拉列表、好友状态增量刷新、头像纹理生成,再往上接富状态和邀请进房。适合已经能把 UE4 工程打成可执行文件、想在联机功能里补上“好友”这一块的 C++ 开发者;如果你还在纠结蓝图能不能做,答案是能,但回调时序、CSteamID 转换、GC 这些点绕不开代码。下面按“接口怎么选、列表怎么拉、状态怎么刷、包怎么打”的顺序拆开讲,顺序别跳,跳过第二步后面全是玄学。
2. 接口选型:OnlineSubsystem 的封装边界与 Steamworks 原生直连
动手前要先想明白一件事:UE4 的 OnlineSubsystem(下称 OSS)是一层跨平台抽象,它把好友、会话、成就都收敛成统一接口,代价是抽象层只暴露各平台都有的能力。Steam 特有的富状态自定义字段、好友头像像素、分组折叠规则,OSS 一概不管。所以真正的工程做法是混着用,而不是二选一。
2.1 两条链路:抽象层与原生 SDK 各管哪一段
判断标准很朴素:这段数据要不要跨平台。好友列表、会话创建、邀请 UI 这些必须走 OSS,因为它们背后还牵涉 NetDriver 和会话管理;富状态、头像、好友分组这些只有 Steam 有,直连 Steamworks 反而更短。中间最容易被忽略的一点是回调由谁驱动:OSS 的 Steam 子系统在每帧 Tick 里已经调过SteamAPI_RunCallbacks(),你自己再调一次就会导致回调重复触发,甚至在编辑器里崩在CCallbackBase的断言上。
| 能力 | OSS 接口 | 原生 SDK 接口 | 建议走哪条 |
|---|---|---|---|
| 好友列表读取 | IOnlineFriends::ReadFriendsList | GetFriendCount/GetFriendByIndex | OSS,异步回调已经封装好 |
| 好友在线状态 | FOnlineUserPresence | GetFriendPersonaState | OSS 够用,枚举更细时补原生 |
| 富状态自定义字段 | IOnlinePresence能力有限 | SetRichPresence | 原生 |
| 好友头像 | 无对应接口 | GetMediumFriendAvatar+GetImageRGBA | 原生 |
| 邀请弹窗 | IOnlineExternalUI::ShowInviteUI | ActivateGameOverlayInviteDialog | OSS,跨平台且和会话绑定 |
| 创建/加入会话 | IOnlineSession | Steam Lobby 原生 API | OSS,别自己造轮子 |
表里“建议”那一列是踩过坑之后的结论。早期我用原生GetFriendCount拿列表,好处是能一次拿到头像 handle,坏处是会话邀请进来之后本地用户 ID 和 OSS 的对不上,最后还是得回来重做一遍映射。
2.2 工程骨架:Build.cs 与 Target.cs 里必须打开的两个开关
模块依赖漏一个,症状是编译期报“无法打开源文件 steam/steam_api.h”,或者链接期一堆SteamAPI_*未解析符号。下面这份Build.cs把三个模块分开列,注意OnlineSubsystemSteam放在 Public 里:
// Source/FriendsDemo/FriendsDemo.Build.cs using UnrealBuildTool; public class FriendsDemo : ModuleRules { public FriendsDemo(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "OnlineSubsystem", // IOnlineSubsystem / IOnlineFriends 等抽象头 "OnlineSubsystemUtils", // 蓝图函数库、IpNetDriver 回退 "OnlineSubsystemSteam" // 只要公共头里出现 Steam 类型,就必须放 Public }); } }OnlineSubsystemSteam放 Public 而不是 Private,是因为只要你的某个 Public 头文件里 include 了steam/steam_api.h或者出现了FUniqueNetIdSteam,下游模块就必须能看见这个依赖路径,放 Private 会在一堆不相干的地方报头文件找不到。这是 UE4 模块系统里最典型的血泪经验之一。
Target.cs 里还要打开bUsesSteam,它影响的是链接阶段的库引用:
// Source/FriendsDemo.Target.cs public class FriendsDemoTarget : TargetRules { public FriendsDemoTarget(TargetInfo Target) : base(Target) { Type = TargetType.Game; ExtraModuleNames.Add("FriendsDemo"); bUsesSteam = true; // 漏掉它的典型症状:链接期 SteamAPI_* 全部未解析 } }另外 Steamworks SDK 本身要放到引擎的第三方目录下(Engine/Source/ThirdParty/Steamworks/下按引擎版本对应的那个sdk目录,目录名带版本前缀),放错层级的症状是 UBT 找不到steam_api64.lib,报错信息里会直接打印它搜索过的路径,照着路径倒推就行。
2.3 启动链路:DefaultEngine.ini 与 steam_appid.txt 的落位
配置项决定了IOnlineSubsystem::Get()到底返回哪个实现。返回空实现时,所有接口都是 nullptr,好友列表当然是空的——这就是很多人“代码没问题但没数据”的根因。
; Config/DefaultEngine.ini [/Script/Engine.GameEngine] +NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemSteam.SteamNetDriver",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver") [OnlineSubsystem] DefaultPlatformService=Steam bHasVoiceEnabled=true [OnlineSubsystemSteam] bEnabled=true SteamDevAppId=480 bInitServerOnClient=true bUseSteamNetworking=true [/Script/OnlineSubsystemSteam.SteamNetDriver] NetConnectionClassName="OnlineSubsystemSteam.SteamNetConnection"逐项说清楚,改错任何一个都会表现成“能跑但不对”:
DefaultPlatformService=Steam是总开关。写成空或NULL,拿到的是空实现,日志里不会有任何报错,只是所有接口返回无效。SteamDevAppId=480是公开的测试 AppID,本地单机自测用它最省事;正式发布换成自己的 AppID,并且要和steam_appid.txt里的数字完全一致,不一致时SteamAPI_Init直接失败。bInitServerOnClient=true让客户端也初始化 GameServer 那一套,本地起 listen server 调试方便;纯客户端联机可以关,关了之后本机当房主会失败。bUseSteamNetworking=true走 Steam 的 P2P 传输,NAT 穿透省事,代价是流量走中继、延迟比直连高,竞技类项目要实测。- 最上面那两行
NetDriverDefinitions是把 UE 默认的 IpNetDriver 换成 SteamNetDriver。缺了它,会话能创建成功、好友能看到房间,但点“加入”之后永久卡在 connecting。
然后是steam_appid.txt,内容只有一行数字,位置极其讲究:必须和可执行文件同目录。编辑器下是引擎的Binaries/Win64/,打包后是<包目录>/Binaries/Win64/。放在工程根目录一份是没用的,这是新手最常见的误解。
注意:编辑器启动时 OnlineSubsystemSteam 就完成了一次
SteamAPI_Init。如果启动编辑器时 Steam 客户端没开,后面再开客户端也不会自动重连,必须重启编辑器。
3. 好友数据落地:C++ 拉列表、状态刷新与头像纹理
这一部分是这个演示工程的主体。三段代码分别对应“拉一次全量”“变更时增量刷”“把头像变成能贴到 UMG 上的东西”,三者串起来才是一个能用的好友面板。我习惯把它们塞进一个UGameInstanceSubsystem,生命周期跟着 GameInstance 走,不用管关卡切换时的清理。
3.1 ReadFriendsList:异步读取的完整写法与返回值处理
先看头文件。数据结构用 USTRUCT 而不是裸结构体,是为了后面能直接往蓝图抛;委托用动态多播,蓝图侧才能 Bind。
// FriendsSubsystem.h #pragma once #include "CoreMinimal.h" #include "Subsystems/GameInstanceSubsystem.h" #include "Interfaces/OnlineFriendsInterface.h" #include "FriendsSubsystem.generated.h" USTRUCT(BlueprintType) struct FFriendEntry { GENERATED_BODY() UPROPERTY(BlueprintReadOnly, Category = "Friends") FString DisplayName; UPROPERTY(BlueprintReadOnly, Category = "Friends") FString PresenceText; UPROPERTY(BlueprintReadOnly, Category = "Friends") bool bOnline = false; UPROPERTY(BlueprintReadOnly, Category = "Friends") bool bPlayingThisGame = false; UPROPERTY(BlueprintReadOnly, Category = "Friends") FString SteamId; }; DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnFriendsReady, const TArray<FFriendEntry>&, Friends); UCLASS() class FRIENDSDEMO_API UFriendsSubsystem : public UGameInstanceSubsystem { GENERATED_BODY() public: UFUNCTION(BlueprintCallable, Category = "Friends") void RefreshFriendsList(); UPROPERTY(BlueprintAssignable, Category = "Friends") FOnFriendsReady OnFriendsReady; private: void HandleReadFriendsListComplete(int32 LocalUserNum, bool bWasSuccessful, const FString& ListName, const FString& ErrorStr); UPROPERTY() TMap<FString, UTexture2D*> AvatarCache; // 防止头像被 GC };实现里有两个判断不能省:OSS 实例是否存在、本地用户 ID 是否有效。后者是“列表一直为空”的头号原因。
// FriendsSubsystem.cpp #include "FriendsSubsystem.h" #include "OnlineSubsystem.h" #include "Interfaces/OnlineIdentityInterface.h" void UFriendsSubsystem::RefreshFriendsList() { IOnlineSubsystem* OSS = IOnlineSubsystem::Get(); if (!OSS) { UE_LOG(LogTemp, Error, TEXT("没有 OnlineSubsystem 实例,检查 DefaultPlatformService")); return; } IOnlineFriendsPtr Friends = OSS->GetFriendsInterface(); IOnlineIdentityPtr Identity = OSS->GetIdentityInterface(); if (!Friends.IsValid() || !Identity.IsValid()) { UE_LOG(LogTemp, Error, TEXT("Friends/Identity 接口无效,多半是 SteamAPI_Init 失败")); return; } // 0 是本地玩家序号;本地多账号时这里是循环变量 if (!Identity->GetUniquePlayerId(0).IsValid()) { UE_LOG(LogTemp, Warning, TEXT("本地用户尚未登录,下一帧重试")); GetWorld()->GetTimerManager().SetTimerForNextTick(this, &UFriendsSubsystem::RefreshFriendsList); return; } const FString ListName = EFriendsLists::ToString(EFriendsLists::Default); Friends->ReadFriendsList( 0, ListName, FOnReadFriendsListComplete::CreateUObject(this, &UFriendsSubsystem::HandleReadFriendsListComplete)); }ReadFriendsList是纯异步的,Vytautas 一点:它不会阻塞,回调可能在几帧之后、也可能在几百毫秒之后触发,中间界面必须能显示“加载中”。ListName用EFriendsLists::ToString(Default)而不是硬编码字符串,是因为后续如果切到OnlinePlayers(只要在线的好友)或RecentPlayers(最近一起玩过的),只需要换一个枚举值。
回调里做三件事:判成功、判列表可读、转成自己的结构体。注意GetFriendsList返回的是TArray<TSharedRef<FOnlineFriend>>,是共享引用,别把它存到成员变量里长期持有。
void UFriendsSubsystem::HandleReadFriendsListComplete(int32 LocalUserNum, bool bWasSuccessful, const FString& ListName, const FString& ErrorStr) { if (!bWasSuccessful) { UE_LOG(LogTemp, Warning, TEXT("ReadFriends <p> <a href="https://download.csdn.net/download/weixin_42127937/19733659" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>