ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

UE4接入Steam Friends API实战:好友列表、状态同步与头像显示完整指南

UE4接入Steam Friends API实战:好友列表、状态同步与头像显示完整指南 简介面向 UE4 开发者的 Steam Friends API 集成演示工程以 C 实现演示如何拉取好友列表、发送好友邀请并在接受邀请后加入对应会话。工程规模精简压缩包共 7 个文件含 3 个头文件与 3 个实现文件另附 1 份说明文档整体仅 7KB轻量到可直接逐行阅读源码并按 Public/Private 目录分离头文件与实现结构清晰。目前已有 310 人学习下载。核心代码包含三类模块好友列表回调代理作为可执行网络等待的蓝图节点从 Steam 子系统异步获取好友列表蓝图函数库封装好友邀请功能供蓝图直接调用自定义游戏实例则在被邀请接受时负责加入对应会话。三者共同构成完整的好友邀请与加入链路适合用来理解异步回调、蓝图节点封装与游戏实例协作方式。透过这三个类的分工还能看出网络回调结果如何转译为蓝图事件并掌握在 GameInstance 中管理会话切换的思路。对想快速上手 Steam 好友系统、参考最小可用实现的开发者而言这是一份轻量且直接的入门资料建议搭配官方文档边读边验证。1. 把 Steam Friends API 接进 UE4比想象中多三步少一步就白屏用 UE4 做联机游戏把 Steam Friends API 接进来是那种“听起来半天、做起来三天”的活。拉个好友列表并不难SteamFriends() 一次调用就能拿名字真正让人翻车的是异步回调、头像通道顺序和打包后 DLL 丢失这三连环。我见过不止一个项目卡在进游戏后好友头像红蓝颠倒、列表出来了但好友状态一直不动、编辑器跑得好好的 Package 一跑就崩。这个方向适合两类人一是要在主菜单或大厅里展示好友、做邀请功能的 UE4 开发者二是想把玩家状态同步成 Steam 在线状态并支持一键加入的联机项目。下面我会按最常见的可靠做法把初始化、拉数据、转贴图、调回调的完整链路拆开讲每一步都给出能直接抄的代码和参数。2. 弄懂调用模型回调、Tick 与 AppID 三件事直接在工程里调 Steamworks SDK很多人误以为“不就是包一层 C 调用”实际落地时最先被绊倒的往往是三件事SteamFriends() 返回的 ISteamFriends 接口是有存活前提的回调派发依赖你周期性地喂它AppID 不对时初始化会静默失败。把这三件事理顺后面写功能就是体力活。2.1 ISteamFriends 接口好友数据的真实位置SteamFriends() 返回 ISteamFriends*它背后是 Steam 客户端进程里维护的好友列表缓存。你的 UE4 游戏只是“租客”并不直接与 Valve 后端建立长连接所有好友数据都由 Steam 客户端同步好API 只负责读取和订阅变化。这也是为什么本机没启动 Steam 或者初始化失败时好友列表会空得不讲道理。UE4 自带的 OnlineSubsystemSteam 也封装了这层能力通过 IOnlineFriends 暴露给蓝图。但封装层较厚头像纹理转换、状态枚举映射、邀请参数这些细节都被包住了遇到要求严格的 UI 反而难调。演示类项目多数直接调 Steamworks SDK标题既然点名 Friends API下面我按直连 SDK 的路线讲你看完原理后想切回 OnlineSubsystem 也不难。2.2 最小工程准备SDK 放置、Build.cs 与 steam_appid.txt从 Steamworks 开发者页面下载 SDK 后常见做法是放到项目根目录的 ThirdParty/Steamworks 下。public/ 放头文件lib/ 放静态库redistributable_bin/ 里是给运行时分发的动态库。UE 侧通过模块的 Build.cs 把它们挂给指定模块// MyGame.Build.cs using System.IO; public class MyGame : ModuleRules { public MyGame(ReadOnlyTargetRules Target) : base(Target) { PCHUsage PCHUsageMode.UseExplicitOrSharedPCHs; string SteamPath Path.Combine(ModuleDirectory, ../../ThirdParty/Steamworks); PublicIncludePaths.Add(Path.Combine(SteamPath, public)); PublicAdditionalLibraries.Add(Path.Combine(SteamPath, lib/win64/steam_api64.lib)); PublicDelayLoadDLLs.Add(steam_api64.dll); RuntimeDependencies.Add(Path.Combine(SteamPath, redistributable_bin/win64/steam_api64.dll)); } }这段代码有四个关键点PublicDelayLoadDLLs 让引擎在模块加载时不立即解析 DLL避免依赖顺序问题RuntimeDependencies 负责把 DLL 一起打包进分发版本少了这一行编辑器里一切正常正式包一跑就提示找不到 steam_api64.dll如果没有在 PublicAdditionalLibraries 里指定 .lib链接阶段会报一堆 unresolved external路径按实际目录调整别写死。编辑器调试时还需要一个 steam_appid.txt放在 Binaries/Win64 下内容只写你的 AppID。注意这个文件是给本地非 Steam 启动场景用的调试工具正式发布包不应该带上它。Steam 生态里正常玩家从 Steam 启动游戏时AppID 由客户端传入包内多余的 txt 反而会让 Steam 误判和报错。2.3 UE4 侧生命周期Subsystem 里初始化别写进 GameModeSteam 回调机制依赖你周期性调用 SteamAPI_RunCallbacks()这个函数必须在游戏线程执行。最省心的是把它挂在 UGameInstanceSubsystem 上Initialize 里 SteamAPI_InitTick 里 RunCallbacksDeinitialize 里 Shutdown。这样无论切地图、进大厅还是打开好友面板生命周期都跟着 GameInstance 走不会因为关卡切换被销毁。// SteamFriendsSubsystem.h #pragma once #include CoreMinimal.h #include Subsystems/GameInstanceSubsystem.h #include Tickable.h #include SteamFriendsSubsystem.generated.h UCLASS() class MYGAME_API USteamFriendsSubsystem : public UGameInstanceSubsystem, public FTickableGameObject { GENERATED_BODY() public: virtual void Initialize(FSubsystemCollectionBase Collection) override; virtual void Deinitialize() override; virtual void Tick(float DeltaTime) override; virtual TStatId GetStatId() const override; bool IsSteamReady() const { return bSteamReady; } private: bool bSteamReady false; };// SteamFriendsSubsystem.cpp #include SteamFriendsSubsystem.h #include steam/steam_api.h void USteamFriendsSubsystem::Initialize(FSubsystemCollectionBase Collection) { Super::Initialize(Collection); bSteamReady SteamAPI_Init(); if (!bSteamReady) { UE_LOG(LogTemp, Warning, TEXT(SteamAPI_Init failed, friends feature disabled.)); } } void USteamFriendsSubsystem::Deinitialize() { if (bSteamReady) { SteamAPI_Shutdown(); bSteamReady false; } Super::Deinitialize(); } void USteamFriendsSubsystem::Tick(float DeltaTime) { if (bSteamReady) { SteamAPI_RunCallbacks(); } } TStatId USteamFriendsSubsystem::GetStatId() const { return GetStatFName(); }为什么不放进 GameMode因为 GameMode 只在服务器端存在客户端跑起来根本没有 GameMode 实例。放进 GameInstanceSubsystem 后单机菜单、客户端、监听服务器都能用。另外注意 SteamAPI_Init 失败后不要继续调用任何 SteamFriends() 接口否则拿到的指针是空悬的访问即崩溃。排查初始化失败时先确认本地 Steam 客户端已启动并登录再检查 steam_appid.txt 放的目录对不对最后确认编辑器和 DLL 都是 64 位。3. 拉取好友列表并监听变化Friends API 的核心数据流初始化跑通后数据流分三段好友列表本身、状态变更通知、头像异步回调。这三段相互独立很多人只拉了一个列表就以为完事了结果好友上线状态不刷新、头像加载不出来才回头补回调。3.1 获取全部好友FriendCount、FriendByIndex 与 UTF-8 昵称Steam Friends API 的好友枚举方式是索引式读取先 GetFriendCount 拿到数量再按索引 GetFriendByIndex 逐个取 CSteamID最后用 GetFriendPersonaName 读取昵称。返回的昵称是 UTF-8 编码的 const char*必须转成 UE 的 FString否则中文昵称全是乱码。USTRUCT(BlueprintType) struct FSteamFriendInfo { GENERATED_BODY() UPROPERTY(BlueprintReadOnly) FString Name; UPROPERTY(BlueprintReadOnly) int32 PersonaState 0; UPROPERTY(BlueprintReadOnly) bool bInGame false; UPROPERTY(BlueprintReadOnly) int64 SteamId 0; };TArrayFSteamFriendInfo USteamFriendsSubsystem::RefreshFriends() { TArrayFSteamFriendInfo Result; if (!bSteamReady) return Result; int32 Count SteamFriends()-GetFriendCount(k_EFriendFlagImmediate); for (int32 i 0; i Count; i) { CSteamID FriendId SteamFriends()-GetFriendByIndex(i, k_EFriendFlagImmediate); FSteamFriendInfo Info; Info.Name FString(UTF8_TO_TCHAR(SteamFriends()-GetFriendPersonaName(FriendId))); Info.PersonaState (int32)SteamFriends()-GetFriendPersonaState(FriendId); Info.SteamId FriendId.ConvertToUint64(); FriendGameInfo_t GameInfo; Info.bInGame SteamFriends()-GetFriendGamePlayed(FriendId, GameInfo); Result.Add(Info); } return Result; }k_EFriendFlagImmediate 是关键筛选参数它只返回直接好友不会混入关注对象或社区组关系。想省流量的场景不要用 k_EFriendFlagAll那会把所有单向关注关系一起拉进来界面和性能都会出问题。CSteamID 在蓝图里很难直接用统一转成 int64 存入结构体后面传给 Steam 接口时再构造 CSteamID。GetFriendGamePlayed 还有一个隐藏价值GameInfo.m_gameID 可以判断好友正在玩的游戏是不是当前游戏这是做“加入游戏”按钮的前置条件。3.2 状态同步PersonaStateChange_t 回调与状态映射好友列表不是静态数据Steam 客户端会在好友上线、下线、改名、换状态时推送事件。要在 UE4 里收到这些变化必须注册 PersonaStateChange_t 回调。这里不建议在蓝图里轮询轮询既慢又吃 Steam 客户端连接用回调才是正规做法。// 头文件里新增成员 #include steam/steam_api.h CCallbackUSteamFriendsSubsystem, PersonaStateChange_t PersonaStateChangeCallback;// Initialize 中SteamAPI_Init 成功后注册 PersonaStateChangeCallback.Register(this, USteamFriendsSubsystem::HandlePersonaStateChange);void USteamFriendsSubsystem::HandlePersonaStateChange(PersonaStateChange_t* pParam) { if (!pParam) return; // m_nChangeFlags 可判断是昵称变了还是在线状态变了 UE_LOG(LogTemp, Log, TEXT(Friend %llu changed, flags: %d), pParam-m_ulSteamID, pParam-m_nChangeFlags); // 拿到变化的好友 ID刷新对应行 int64 ChangedId (int64)pParam-m_ulSteamID; OnFriendStateChanged.Broadcast(ChangedId); }回调对象必须是类成员不能是函数局部变量。局部 CCallback 在函数结束时析构注册会被自动销毁回调自然永不触发——这是最典型的“代码看着没问题、就是不执行”场景。PersonaStateChange_t 的 m_nChangeFlags 里可以按位判断具体变化类型例如昵称变化和状态变化用不同逻辑处理。状态值映射建议用一个表固定下来方便 UI 层统一展示EPersonaState 值含义建议 UI 展示0离线灰色头像1在线绿点2忙碌红点3离开灰点4打盹灰点5想交易黄点6想玩游戏黄点3.3 头像异步链路ImageHandle 与 AvatarImageLoaded_t头像和好友列表是两条独立的链路。调用 SteamFriends()-GetSmallFriendAvatar(FriendId) 时如果 Steam 客户端已经有头像缓存会直接返回一个 ImageHandle如果还没准备好返回 0后续通过 AvatarImageLoaded_t 回调通知你。头像请求是异步的不能在返回值里同步拿像素。void USteamFriendsSubsystem::RequestAvatar(int64 SteamId) { if (!bSteamReady) return; CSteamID FriendId((uint64)SteamId); int32 Handle SteamFriends()-GetSmallFriendAvatar(FriendId); if (Handle 0) { // Steam 客户端还没缓存头像等 AvatarImageLoaded_t PendingAvatarRequests.AddUnique(SteamId); return; } // 已有 handle立即转纹理 ConvertSteamAvatarHandle(Handle, SteamId); }AvatarImageLoaded_t 回调收到后同样要判断回调参数里的 m_steamIDFriend 是否在 PendingAvatarRequests 里避免无谓的重复转换。头像尺寸有讲究GetSmallFriendAvatar 是 32x32GetMediumFriendAvatar 是 64x64GetLargeFriendAvatar 最大 184x184。好友列表、主菜单、好友详情三种 UI 要按用途选尺寸别全用 184内存和带宽都会吃紧。4. 把好友列表装进 UMG头像纹理、ListView 与邀请动作API 层跑通后UE4 侧真正的麻烦才开始Steam 给的是原始 RGBA 字节UE 要的是 UTexture2D列表刷新在回调线程与 GameThread 之间怎么衔接点击好友后邀请参数怎么拼。这一章解决“显示出来且能交互”的问题。4.1 头像字节转 UTexture2DRGBA 与 PF_B8G8R8A8 的错位Steam 的 GetImageRGBA 输出的是 R、G、B、A 顺序的原始字节而 UE 的 PF_B8G8R8A8 纹理内存期望的是 B、G、R、A。直接把字节 Memcpy 进 Mip 数据头像整体会变成红蓝颠倒看起来像中毒了一样。这是集成时最容易出现的“玄学” Bug。#include Engine/Texture2D.h UTexture2D* USteamFriendsSubsystem::ConvertSteamAvatarHandle(int32 ImageHandle, int64 SteamId) { uint32 Width 0, Height 0; if (!SteamUtils()-GetImageSize(ImageHandle, Width, Height)) { return nullptr; } TArrayuint8 Buffer; Buffer.SetNum(Width * Height * 4); if (!SteamUtils()-GetImageRGBA(ImageHandle, Buffer.GetData(), Buffer.Num())) { return nullptr; } // Steam 给的是 RGBAPF_B8G8R8A8 期望 BGRA交换 R/B for (uint32 i 0; i Width * Height; i) { Swap(Buffer[i * 4], Buffer[i * 4 2]); } UTexture2D* Tex UTexture2D::CreateTransient(Width, Height, PF_B8G8R8A8); if (!Tex) { return nullptr; } Tex-SRGB false; if (Tex-GetPlatformData()-Mips.Num() 0) { Tex-GetPlatformData()-Mips.AddDefaulted(1); } FTexture2DMipMap Mip Tex-GetPlatformData()-Mips[0]; Mip.SizeX Width; Mip.SizeY Height; void* Data Mip.BulkData.Lock(LOCK_READ_WRITE); FMemory::Memcpy(Data, Buffer.GetData(), Buffer.Num()); Mip.BulkData.Unlock(); Tex-UpdateResource(); return Tex; }这段代码有两个参数容易被忽略。一是 SRGB 必须设成 false否则引擎会对灰度头像再做一次色彩矫正结果就是整体偏色且对比度异常二是 UpdateResource() 必须在游戏线程调用如果你在异步回调里处理头像要把纹理创建和刷新派发到 GameThread 上。做法简单点把 ConvertSteamAvatarHandle 放到 Subsystem 的 Tick 里处理待转换队列或者用 AsyncTask 包装再挂到主线程。CreateTransient 创建的纹理不会自动被 GC 追踪记得在玩家退出好友面板时清理不再使用的头像纹理否则长时间挂着 UI 会持续涨内存。4.2 用 UListView 绑定好友行与状态更新UE4.26 以后 UListView 是列表首选条目多时自带回收机制比 VBox ScrollBox 性能好不少。每行需要一个 UObject 数据源建议把 FSteamFriendInfo 包装成 UFriendRowDataBlueprintReadOnly 字段绑定到 UMG 里的 TextBlock、Image 和状态色块。UCLASS(BlueprintType) class UFriendRowData : public UObject { GENERATED_BODY() public: UPROPERTY(BlueprintReadOnly) FString DisplayName; UPROPERTY(BlueprintReadOnly) UTexture2D* Avatar nullptr; UPROPERTY(BlueprintReadOnly) int64 SteamId 0; };void UFriendListPanel::Refresh() { if (!FriendList || !FriendsSubsystem) return; FriendList-ClearListItems(); for (const FSteamFriendInfo Info : FriendsSubsystem-RefreshFriends()) { UFriendRowData* Row NewObjectUFriendRowData(this); Row-DisplayName Info.Name; Row-SteamId Info.SteamId; Row-Avatar FriendsSubsystem-GetAvatarCachedOrPlaceholder(Info.SteamId); FriendList-AddItem(Row); } }RefreshFriends 里包含 GetFriendGamePlayed它依赖 Steam 客户端的实时数据不能放在渲染线程所以我建议这个函数本身只被 GameThread 调用好友面板打开或手动点击刷新时触发一次即可。头像取回来后通过委托通知面板更新对应行不要整表 Clear 重建否则滚动位置和选中状态都会丢。4.3 邀请与加入InviteUserToGame 与 GetFriendGamePlayed好友 UI 最核心的动作是邀请进当前游戏。Steam Friends API 提供 InviteUserToGame第二个参数是启动命令字符串好友接受后 Steam 会启动游戏并把这段命令透传给游戏进程。void USteamFriendsSubsystem::InviteToGame(int64 FriendSteamId, const FString ConnectCommand) { if (!bSteamReady) return; CSteamID FriendId((uint64)FriendSteamId); SteamFriends()-InviteUserToGame(FriendId, TCHAR_TO_UTF8(*ConnectCommand)); }ConnectCommand 的格式取决于你的联机架构。监听服务器一般是connect 192.168.1.10:7777大厅模式则先创建大厅再用steam://joinlobby/AppID/LobbyId或直接传 lobby ID。记住一个边界如果好友已经在玩你的游戏InviteUserToGame 弹的是“加入游戏”如果没在玩弹的是“接受邀请并启动”。判断好友“能不能加入”要用 GetFriendGamePlayed 检查对方是否正在运行同一个 AppID而不是只看在线状态。在线状态为“在线”但正在玩别的游戏右键一个“加入”按钮发出去Steam 会有几秒钟的静默失败体验很伤。正确做法是bool USteamFriendsSubsystem::CanJoinGame(int64 FriendSteamId) { if (!bSteamReady) return false; CSteamID FriendId((uint64)FriendSteamId); FriendGameInfo_t Info; if (!SteamFriends()-GetFriendGamePlayed(FriendId, Info)) { return false; } return Info.m_gameID.AppID() SteamUtils()-GetAppID(); }只有这个函数返回 true才把“加入游戏”按钮置亮。如果想支持大厅直接加入Info.m_steamIDLobby 在对方已加入大厅时是有效的 CSteamID直接用 SteamMatchmaking()-JoinLobby 就能跳过去这一步是很多演示项目没做完的部分。5. SteamFriends 集成避坑清单五个能盯半小时的翻车现场这一章写我在这条路上实际踩过、也帮别人排查过的五类问题。每一条都按“现象 - 原因 - 解决”的顺序写建议直接对照你的项目状态检查。5.1 SteamAPI_Init 一直返回 false日志只留下一句 warning现象Initialize 里打出的日志是 SteamAPI_Init failed好友列表功能整体灰掉。常见原因有三个编辑器以管理员权限运行导致与 Steam 客户端的 IPC 通信被拦steam_appid.txt 放错目录本机 Steam 客户端没登录。解决方法是先关掉管理员权限再检查 Binaries/Win64 下是否有正确 AppID 的 txt最后确认 Steam 客户端处于在线状态。还有一个容易被忽略的点如果项目里同时混入了 32 位和 64 位 DLLSteamAPI_Init 也会失败确认你挂的是 steam_api64.lib 而不是 steam_api.lib。5.2 好友状态不变回调断点永远进不去现象好友上线了UI 还是离线状态在 HandlePersonaStateChange 里打断点怎么都触发不了。原因分两类一类是 SteamAPI_RunCallbacks 没人调用回调消息堆积在 Steam 客户端队列里另一类是 CCallback 对象被定义在某个函数内部函数结束就析构了Steam 的回调注册也被连带注销。解决确认 Subsystem 的 Tick 里调用了 SteamAPI_RunCallbacks并且回调对象是 Subsystem 的成员变量注册发生在 SteamAPI_Init 成功之后。回调不触发还有一个很隐蔽的情况你在 Blueprint 里创建了一个临时子系统的对象回调注册到了那个临时对象上等蓝图变量销毁后回调跟着没了。5.3 头像整体红蓝颠倒像戴了劣质 3D 眼镜现象好友头像变成蓝色调或红蓝交错图片细节还在就是颜色全错。原因就是 4.1 里说的字节序问题Steam 给的是 RGBAUE 的 PF_B8G8R8A8 期望 BGRA。解决在拷贝进 Mip 前逐像素交换 R 和 B。注意如果用了 PF_R8G8B8A8 创建纹理就不需要交换但移动端对 R8G8B8A8 的支持相对弱PC 上首选还是交换字节并保持 B8G8R8A8。这条坑最麻烦的地方在于它不报错视觉上又很“合理”很多团队盯了一晚上才看出是通道问题。5.4 编辑器里一切正常打包后启动即崩或好友全空现象Development Editor 跑得欢Package 出来的包一进游戏就白屏闪退或者 SteamAPI_Init 返回 false。原因大多数是 steam_api64.dll 没有被带入最终包。Build.cs 里如果只用 PublicDelayLoadDLLs没有加 RuntimeDependencies打包器不会自动把 ThirdParty 下的 DLL 作为运行时依赖只有编辑器环境能靠 Binaries 目录下的 DLL 撑住。解决在 Build.cs 里用 RuntimeDependencies.Add 绑定还原的分发 DLL打包后到 WindowsNoEditor 目录确认一次 DLL 是否存在。顺带提醒发布包不要带 steam_appid.txt打包时把它从 Binaries 目录里删掉或放到排除列表里。5.5 UE4 Linux 目标libsteam_api.so 缺失导致初始化失败现象把项目交叉编译到 Linux 后SteamAPI_Init 返回 false甚至启动时直接报缺少动态库。原因Windows 的 steam_api64.dll 不能用于 Linux 目标打包时必须把 linux64/libsteam_api.so 一起带上并且运行环境需要能找到 Steam 客户端提供的 steamclient.so。解决Build.cs 里按目标平台区分 RuntimeDependenciesLinux 下添加 libsteam_api.so同时打包脚本里把 so 的权限改成可执行。如果跑的是 Linux 专用服务器那台机器必须安装了 Steam 客户端或 steamcmd否则 libsteam_api.so 初始化时会找不到 steamclient.so 而静默失败这算 UE4 Linux 联机部署最容易翻车的一环建议在 CI 脚本里把 so 是否存在作为构建产物检查项。6. 进阶头像缓存、Rich Presence 与双账号验证API 跑通、UI 能点之后再往前一步就是工程化。头像如果每次打开好友面板都重新拉取Steam 客户端会频繁走网络请求好友多时面板打开明显卡顿。我把头像缓存藏到 Subsystem 里用 TMap 按 SteamId 存 UTexture2D*第一次加载后直接复用。占位图用软引用加载避免刚进菜单时一堆空白格子闪过。UTexture2D* USteamFriendsSubsystem::GetAvatarCachedOrPlaceholder(int64 SteamId) { if (UTexture2D** Cached AvatarCache.Find(SteamId)) { return *Cached; } return PlaceholderAvatar.LoadSynchronous(); }Rich Presence 是很多演示项目没做但在联机体验上很加分的一环。用 SteamFriends()-SetRichPresence 可以把你当前在游戏中的状态同步到好友列表例如SteamFriends()-SetRichPresence(steam_display, #Status_Playing); SteamFriends()-SetRichPresence(status, InMatch);steam_display 指向的是本地化字符串 keystatus 是给外部程序读取的原始字段。设置后好友在 Steam 好友列表里能看到“正在玩 YourGameInMatch”。配合之前说的 GetFriendGamePlayed好友那边右键就能出现“加入游戏”选项体验链路就完整了。这里有个很小的惯性坑SetRichPresence 的 key 值如果带空格Steam 客户端可能显示异常尽量用下划线命名。最后建议你准备两个 Steam 账号和两台机器做验证这是这个方向唯一靠谱的自测方式。测试清单看四条登录第二个号后好友状态是否在 5 秒内自动变化头像加载后颜色是否正常A 号发起 InviteUserToGameB 号接受后游戏是否能被正确拉起并执行连接命令再把 Windows 包和 Linux 包各跑一遍确认 DLL 和 so 都齐全。我的习惯是每次升 UE 版本后先跑一遍这套双账号测试再改 UI。头像红蓝颠倒这条坑改一次就长记性了但不说出来总会有人再踩一遍。希望帮到你。本文还有配套的精品资源点击获取
返回列表