乐于分享
好东西不私藏

赋能UE运行态编辑平台: 网络图片下载的插件改造与复盘

赋能UE运行态编辑平台: 网络图片下载的插件改造与复盘

背景

在数字孪生、数据看板、设备监控这类 UE 项目里,经常会遇到一个需求:运行时从服务器下载图片,然后显示到 UI、材质或场景对象上。

比如我们目前在做的是一个UE 运行态下的POI点位资产编辑平台,要能够编辑点位的名称,信息,图标,尺寸等,如下图所示,其中图标是从网络加载的:

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

单张图片下载看起来不复杂,但真正做成一个可复用插件后,会遇到不少 UE 特有的问题:异步回调、蓝图节点、运行时纹理、GC 生命周期、图片格式识别、中文 URL 编码,以及跨项目打包后的偶发崩溃。

这篇文章复盘一次 ImageDownloader 插件的改造过程。

1. 为什么不做同步下载

最开始的需求是批量下载图片。直觉上似乎可以做一个“同步下载”:按顺序下载 A、B、C,全部完成后再继续。

但在 UE 运行时,这不是一个好选择。

网络请求耗时不可控,如果在 GameThread 上同步等待,游戏画面、UI 响应都会被阻塞。多张图片连续下载时,卡顿会叠加;如果请求超时或失败,等待时间更不可控。

所以最终方案不是同步下载,而是批量异步下载:

  • HTTP 请求异步发起
  • 图片二进制解码放到后台线程
  • UTexture2D 创建回到 GameThread
  • 蓝图通过回调接收单张完成和全部完成结果

核心原则是:耗时数据处理离开 GameThread,UObject 和渲染资源操作回到 GameThread。

2. 批量下载的核心设计批量下载节点接收一个 URL 数组,然后为每个 URL 创建独立 HTTP 请求。

每张图片完成后触发一次单图回调:

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 里读取结果数组即可。

3. 蓝图异步节点的一个隐藏限制

这个插件最开始使用 UBlueprintAsyncActionBase 实现标准蓝图异步节点。

它的工作方式大致是:

  1. 静态工厂函数创建异步代理对象
  2. 蓝图绑定输出事件
  3. UE 调用 Activate()
  4. 异步任务启动
  5. 完成后广播委托

这里有一个容易踩坑的点:在 UE 5.2 中,异步节点虽然可以有多个 BlueprintAssignable 输出事件,但数据输出 pin 往往只按照第一个委托签名生成。

也就是说,如果第一个事件是:

Index, ImageURL, Texture

第二个事件是:

Textures, SuccessStates

蓝图节点上可能只显示第一组数据 pin,导致总完成事件拿不到数组。

解决方式有两个。

第一种是把两个事件统一成同一个完整签名,让节点一次性生成所有数据 pin。

第二种是新增“事件参数模式”,使用动态单播委托作为普通函数参数,让蓝图直接接 Create Event 或自定义事件。

最终插件保留了两套入口:

Download Images Async
Download Images With Events

前者适合快速使用,后者适合需要明确绑定自定义事件、不同回调签名更清晰的场景。

4. 运行时创建 Texture2D 的崩溃

这次改造里最关键的崩溃来自运行时纹理创建。

原代码大致是:

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 failedinvalid 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

否则虽然不一定崩溃,但可能出现红蓝通道错乱。

5. 图片格式不要只看 URL 后缀

早期格式判断只依赖 URL 后缀,例如 .png.jpg

但实际项目里经常遇到这些情况:

  • URL 没有扩展名
  • URL 带查询参数
  • CDN 地址后缀不可靠
  • 服务端返回格式和文件名不一致

因此后续改成优先根据图片二进制数据识别格式:

ImageWrapperModule.DetectImageFormat(ImageData.GetData(), ImageData.Num());

如果二进制检测失败,再用 URL 后缀兜底。

这个改动不影响蓝图接口,但能明显提高下载器的容错能力。

6. 中文 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

7. 异步对象生命周期比空指针更危险

在 UE 里,很多崩溃不是真正的 nullptr,而是 UObject 被 GC 后,异步回调还在访问旧对象。

这类问题本质是悬空指针或 use-after-free。

风险主要来自两点:

  • 异步代理对象通过 NewObject 创建,但没有显式生命周期托管
  • 后台线程或 GameThread lambda 直接捕获裸 this

更稳的做法是:

  • 使用 RegisterWithGameInstance(WorldContextObject) 托管异步代理
  • 完成后调用 SetReadyToDestroy()
  • 跨线程 lambda 使用 TWeakObjectPtr
  • 回到 GameThread 后先判断对象是否仍然有效

运行时创建的 UTexture2D 也一样需要可达引用。如果蓝图只是临时拿到纹理并设置 UI,但没有保存到成员变量或数组,后续也可能被 GC 影响。

8. 大块图片数据传递要注意拷贝成本

图片解码后的原始像素数据通常很大。

例如:

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 运行时开发的多个关键点:

  • 不要在 GameThread 同步等待网络请求
  • 后台线程只做纯数据处理
  • UTexture2D 创建和蓝图广播回到 GameThread
  • 批量异步结果要按输入索引回填
  • 蓝图异步节点存在委托 pin 生成限制
  • CreateTransient() 后不要手动追加无效 mip
  • 图片格式优先从二进制头识别
  • 中文 URL 需要正确百分号编码
  • UObject 异步代理必须考虑 GC 生命周期
  • 大块像素数据传递应尽量使用移动语义

真正稳定的运行时图片下载器,不只是 HTTP 请求成功就结束了。它还要处理线程、纹理资源、蓝图节点、生命周期和各种真实项目里的输入异常。

这些细节做好以后,插件才更适合从一个项目迁移到另一个项目,也更能经得住打包环境和复杂蓝图流程的考验。

欢迎关注ITman彪叔,可视化服务专家,提供三维可视化,数据中心,智慧园区,智慧楼宇,工业组态, 2d拓扑,大屏呈现等可视化专业服务。