












在数字孪生、数据看板、设备监控这类 UE 项目里,经常会遇到一个需求:运行时从服务器下载图片,然后显示到 UI、材质或场景对象上。
比如我们目前在做的是一个UE 运行态下的POI点位资产编辑平台,要能够编辑点位的名称,信息,图标,尺寸等,如下图所示,其中图标是从网络加载的:

其中图片资源在平台服务器端,用户自已上传图标,如下图所示:

单张图片下载看起来不复杂,但真正做成一个可复用插件后,会遇到不少 UE 特有的问题:异步回调、蓝图节点、运行时纹理、GC 生命周期、图片格式识别、中文 URL 编码,以及跨项目打包后的偶发崩溃。
这篇文章复盘一次 ImageDownloader 插件的改造过程。
最开始的需求是批量下载图片。直觉上似乎可以做一个“同步下载”:按顺序下载 A、B、C,全部完成后再继续。
但在 UE 运行时,这不是一个好选择。
网络请求耗时不可控,如果在 GameThread 上同步等待,游戏画面、UI 响应都会被阻塞。多张图片连续下载时,卡顿会叠加;如果请求超时或失败,等待时间更不可控。
所以最终方案不是同步下载,而是批量异步下载:
UTexture2D 创建回到 GameThread核心原则是:耗时数据处理离开 GameThread,UObject 和渲染资源操作回到 GameThread。
每张图片完成后触发一次单图回调:
OnImageDownloaded(Index, ImageURL, Texture)
所有图片都结束后触发总回调:
OnAllComplete(Textures, SuccessStates)
这里有一个很重要的设计:结果数组保持输入顺序。
比如输入是:
0: A.png
1: B.png
2: C.png
即使实际完成顺序是 B、C、A,最终结果仍然写回原始索引:
Textures[Index] = Texture;
SuccessStates[Index] = Texture != nullptr;
这样蓝图侧不需要自己维护完成计数,也不需要处理异步完成顺序,只要在 OnAllComplete 里读取结果数组即可。
这个插件最开始使用 UBlueprintAsyncActionBase 实现标准蓝图异步节点。
它的工作方式大致是:
Activate()这里有一个容易踩坑的点:在 UE 5.2 中,异步节点虽然可以有多个 BlueprintAssignable 输出事件,但数据输出 pin 往往只按照第一个委托签名生成。
也就是说,如果第一个事件是:
Index, ImageURL, Texture
第二个事件是:
Textures, SuccessStates
蓝图节点上可能只显示第一组数据 pin,导致总完成事件拿不到数组。
解决方式有两个。
第一种是把两个事件统一成同一个完整签名,让节点一次性生成所有数据 pin。
第二种是新增“事件参数模式”,使用动态单播委托作为普通函数参数,让蓝图直接接 Create Event 或自定义事件。
最终插件保留了两套入口:
Download Images Async
Download Images With Events
前者适合快速使用,后者适合需要明确绑定自定义事件、不同回调签名更清晰的场景。
这次改造里最关键的崩溃来自运行时纹理创建。
原代码大致是:
UTexture2D* Texture = UTexture2D::CreateTransient(Width, Height, PF_B8G8R8A8);
Texture->GetPlatformData()->Mips.Add(new FTexture2DMipMap());
问题在于:CreateTransient() 已经创建好了第 0 层 mip。
后面再手动 Mips.Add(),会追加一个没有正确设置尺寸和 BulkData 的无效 mip。等 UpdateResource() 构建纹理资源时,UE 读取到无效 mip,就可能触发 GetMipData failed、invalid GUID,甚至直接断言崩溃。
正确做法是直接写入已有的 Mips[0]:
UTexture2D* Texture = UTexture2D::CreateTransient(Width, Height, PF_B8G8R8A8);
Texture->SRGB = true;
Texture->NeverStream = true;
void* TextureData = Texture->GetPlatformData()->Mips[0].BulkData.Lock(LOCK_READ_WRITE);
FMemory::Memcpy(TextureData, RawData.GetData(), RawData.Num());
Texture->GetPlatformData()->Mips[0].BulkData.Unlock();
Texture->UpdateResource();
同时,图片解码格式要和纹理像素格式匹配。
如果纹理格式是:
PF_B8G8R8A8
解码时就应使用:
ERGBFormat::BGRA
否则虽然不一定崩溃,但可能出现红蓝通道错乱。
早期格式判断只依赖 URL 后缀,例如 .png、.jpg。
但实际项目里经常遇到这些情况:
因此后续改成优先根据图片二进制数据识别格式:
ImageWrapperModule.DetectImageFormat(ImageData.GetData(), ImageData.Num());
如果二进制检测失败,再用 URL 后缀兜底。
这个改动不影响蓝图接口,但能明显提高下载器的容错能力。
项目里还遇到过一种情况:浏览器可以打开图片,但插件下载失败。
例如:
http://192.168.31.70:8090/images/球机.png
浏览器通常会自动转成:
http://192.168.31.70:8090/images/%E7%90%83%E6%9C%BA.png
UE HTTP 请求不会总是帮你做这件事,所以中文路径需要进行 UTF-8 百分号编码。
但不能直接对整个 URL 调用通用编码函数,否则 http://、/、: 也会被转义,URL 结构会被破坏。
最终做法是:只编码中文字符,保留 URL 结构字符不变。
这样:
http://192.168.31.70:8090/images/球机.png
会被转换为:
http://192.168.31.70:8090/images/%E7%90%83%E6%9C%BA.png
在 UE 里,很多崩溃不是真正的 nullptr,而是 UObject 被 GC 后,异步回调还在访问旧对象。
这类问题本质是悬空指针或 use-after-free。
风险主要来自两点:
NewObject 创建,但没有显式生命周期托管this更稳的做法是:
RegisterWithGameInstance(WorldContextObject) 托管异步代理SetReadyToDestroy()TWeakObjectPtr运行时创建的 UTexture2D 也一样需要可达引用。如果蓝图只是临时拿到纹理并设置 UI,但没有保存到成员变量或数组,后续也可能被 GC 影响。
图片解码后的原始像素数据通常很大。
例如:
2048 x 2048 x 4 = 16 MB
4096 x 4096 x 4 = 64 MB
如果每次跨函数、跨 lambda 都复制一份,内存峰值和性能开销都会上升。
因此在解码后传递 TArray<uint8> 时,使用 MoveTemp 是合理的:
CreateTextureOnGameThread(Width, Height, MoveTemp(RawData), OnComplete);
它不是为了“让代码能跑”,而是为了避免额外复制大块图片数据。
在图片下载、解码、创建纹理这种高吞吐链路里,移动语义是非常实用的工程优化。
这次图片下载插件改造,看起来只是“下载图片并显示”,实际涉及了 UE 运行时开发的多个关键点:
UTexture2D 创建和蓝图广播回到 GameThreadCreateTransient() 后不要手动追加无效 mip真正稳定的运行时图片下载器,不只是 HTTP 请求成功就结束了。它还要处理线程、纹理资源、蓝图节点、生命周期和各种真实项目里的输入异常。
这些细节做好以后,插件才更适合从一个项目迁移到另一个项目,也更能经得住打包环境和复杂蓝图流程的考验。
最后,关注公号“ITMan彪叔” 可以添加作者微信进行交流,及时收到更多有价值的文章。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。