ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity照片墙性能优化实战:从布局到异步加载与对象池

Unity照片墙性能优化实战:从布局到异步加载与对象池 简介这份资源面向Unity开发者与游戏视觉设计学习者聚焦在引擎中实现照片墙效果这一具体场景适合具备一定编辑器操作基础、希望提升场景表现力的中级开发者。压缩包共310个文件约1.54MB以png图片素材、meta资源索引、asset场景配置、unity场景文件与cs脚本为主另含dll插件、xml与json配置等覆盖从素材到工程配置的完整结构。已有525人学习下载说明该效果在实战项目中具有较高参考价值。资源围绕平面几何体搭建展示面、自定义材质与纹理贴图、C#脚本控制图片切换与淡入淡出过渡、灯光增强立体感以及点击缩放拖动等交互展开并涉及Asset Store插件与项目目录规范管理可帮助读者快速理解照片墙从布局、材质到交互的完整实现思路并直接对照工程文件进行复现与二次开发。1. 照片墙不只是排个网格Unity 里做「能跑起来」的相册墙到底难在哪很多人第一次接到「Unity 实现照片墙效果」这个需求时脑子里浮现的是 UGUI 里拖几个 Image、挂个 GridLayoutGroup完事。真上手才发现几十张照片一铺帧率掉得比想象中快滚动一卡一卡图片加载还时不时白块。照片墙在 Unity 里本质是三个问题叠在一起布局怎么排、图片怎么异步加载进内存、滚动时怎么复用和裁剪。它适合做数字展厅、相册类 App、大屏互动、VR 相册这类场景也常被拿来做 unity 数字孪生项目里的素材墙。这篇不聊虚的从最小可跑版本讲到滚动复用和内存控制把参数、命令和踩过的坑都摊开新手能照着搭熟手能直接拿去改。2. 照片墙的三种实现路线UGUI、UI Toolkit 还是自己算坐标2.1 先想清楚照片墙的规模再决定用哪套 UI选型不是看哪个新是看你的照片数量和交互复杂度。我一般按下面这张表来分方案适合照片量优点代价UGUI GridLayoutGroup20 张以内拖拽即用零代码数量一多布局重算开销大UGUI 手动坐标 对象池50500 张可控、复用成熟要自己写滚动和回收UI Toolkit ListView100 张以上虚拟化内建性能好生态和调试习惯要重新适应标题里说的是「照片墙效果」绝大多数落地场景是几十到几百张还要支持滚动、点击放大。GridLayoutGroup 在数量上去之后每次布局都会触发整棵 UI 树的重建这是卡顿的根源。所以中间规模我推荐 UGUI 手动算坐标加对象池这也是目前社区里最稳、资料最多的做法。UI Toolkit 的 ListView 自带虚拟化但和传统 UGUI 混用时层级、输入事件容易打架团队不熟就别硬上。2.2 用 GridLayoutGroup 搭一个最小可跑的照片墙先给一个能立刻看到效果的最小版本适合验证素材和交互不适合直接上生产。using UnityEngine; using UnityEngine.UI; public class SimplePhotoWall : MonoBehaviour { public GameObject photoPrefab; // 带 Image 的预制体 public Sprite[] photos; // 先拖几张测试图 void Start() { var grid GetComponentGridLayoutGroup(); // 单元格尺寸要和图片宽高比一致否则会拉伸变形 grid.cellSize new Vector2(200, 200); grid.spacing new Vector2(10, 10); grid.constraint GridLayoutGroup.Constraint.FixedColumnCount; grid.constraintCount 4; // 每行 4 张 foreach (var sp in photos) { var go Instantiate(photoPrefab, transform); go.GetComponentImage().sprite sp; } } }逻辑说明GridLayoutGroup 负责排列cellSize 决定每格大小constraintCount 决定列数。参数上最容易翻车的是 cellSize 和图片宽高比不一致照片会被拉扁。测试阶段这样够用但一旦照片超过二三十张每次增删子物体都会触发一次完整布局重建滚动时尤其明显。所以这个版本只用来确认视觉和点击逻辑真正上线要换成手动布局加对象池。2.3 手动算坐标把布局控制权拿回来手动布局的核心是把「第 i 张照片放在第几行第几列」算成一个纯函数不依赖任何 Layout 组件。// 根据索引算出在内容容器里的 anchoredPosition Vector2 GetPosition(int index, int columns, Vector2 cell, Vector2 spacing) { int row index / columns; int col index % columns; float x col * (cell.x spacing.x); float y -row * (cell.y spacing.y); // UGUI 的 y 轴向下为负 return new Vector2(x, y); }逻辑说明UGUI 里 RectTransform 的锚点如果设在左上角x 向右增大y 向下减小所以行号要取负。参数 columns 是每行列数cell 是单元格尺寸spacing 是间距。这个函数不产生任何 GC可以每帧调用。把内容容器的 pivot 设成 (0,1)也就是左上角坐标计算最直观。这一步做完布局开销从「整棵树重建」变成「一次乘法」这是照片墙能不能扛住几百张的分水岭。3. 图片异步加载与内存照片墙真正的性能瓶颈3.1 为什么不能直接 Resources.Load 一把梭新手最常见的写法是循环里 Resources.Load 所有图片或者干脆把几十张图全拖进 Sprite 数组。前者会把整个 Resources 文件夹打进包体后者让所有纹理常驻内存。一张 1024×1024 的 RGBA32 纹理占 4MB一百张就是 400MB手机上直接崩。照片墙的正确姿势是只加载可视区域内的图片滚出视野的释放或回池。加载方式优先用 Addressables 或 AssetBundle 做异步加载本地测试可以用 UnityWebRequest 读 StreamingAssets 下的文件。3.2 用 UnityWebRequest 异步加载本地图片并转成 Sprite下面这段是本地相册场景里最常用的加载方式支持 jpg/png异步不卡主线程。using System.Collections; using UnityEngine; using UnityEngine.Networking; using UnityEngine.UI; public class PhotoLoader : MonoBehaviour { public RawImage target; // 用 RawImage 省一次纹理拷贝 public IEnumerator Load(string path) { // file:// 前缀在部分平台必须加否则请求失败 using (var req UnityWebRequestTexture.GetTexture(file:// path)) { yield return req.SendWebRequest(); if (req.result ! UnityWebRequest.Result.Success) { Debug.LogError($加载失败 {path}: {req.error}); yield break; } var tex DownloadHandlerTexture.GetContent(req); target.texture tex; } } }逻辑说明UnityWebRequestTexture 会把下载和解码放到工作线程主线程只做赋值。参数上注意两点一是路径前缀Windows 和 Android 上不加 file:// 会直接报错二是这里用 RawImage 而不是 Image因为 Image 需要 Sprite从 Texture2D 转 Sprite 会多一次内存拷贝。如果一定要用 Image记得 Sprite.Create 之后管理好生命周期否则纹理泄漏。加载失败一定要打日志照片墙最常见的白块就是路径拼错或权限问题。3.3 纹理压缩与尺寸省内存最狠的一刀加载方式决定峰值纹理设置决定常驻。照片墙的缩略图根本不需要原图分辨率一张 200×200 的格子放 1024 的图是浪费。我一般会在导入设置里把缩略图 Max Size 压到 512格式用 ASTC 6x6移动端或 DXT1桌面端不带透明通道时。下面这张表是我实测过的内存对比同一张 1024 图设置单张内存100 张常驻RGBA32 10244 MB400 MBRGBA32 5121 MB100 MBASTC 6x6 512约 0.33 MB33 MB参数怎么改选中纹理Inspector 里 Max Size 设 512Compression 选对应平台格式勾上 Generate Mip Maps 只在需要缩小时才开照片墙通常不需要。注意压缩格式在不同平台不通用打包前按平台分别设置别一套设置走天下。4. 滚动复用与对象池让几百张照片也能满帧4.1 可视区域裁剪的判断逻辑照片墙滚动的本质是内容容器在动但只有落在视口矩形内的格子才需要显示。判断一个格子的世界坐标是否在视口内用 RectTransformUtility 最稳。// 判断某个格子的 RectTransform 是否和视口相交 bool IsVisible(RectTransform cell, RectTransform viewport, Camera cam) { var cellRect RectTransformUtility.CalculateRelativeRectTransformBounds(cam, cell); var viewRect RectTransformUtility.CalculateRelativeRectTransformBounds(cam, viewport); return cellRect.Intersects(viewRect); }逻辑说明CalculateRelativeRectTransformBounds 返回的是世界空间包围盒两个盒子相交就说明可见。参数 cam 在 Overlay 模式的 Canvas 下传 null 也能工作但 Screen Space Camera 模式必须传对应相机。这个判断每帧对几百个格子跑一遍开销不小实际项目里我会用索引范围代替根据滚动位置直接算出当前可见的行号区间只遍历这个区间复杂度从 O(n) 降到 O(可见数量)。4.2 对象池的回收与复用对象池要解决的是「滚动时不停 Instantiate 和 Destroy」带来的 GC 尖峰。核心就两个队列空闲池和活跃列表。using System.Collections.Generic; using UnityEngine; public class PhotoPool { private readonly StackGameObject pool new StackGameObject(); private readonly GameObject prefab; private readonly Transform parent; public PhotoPool(GameObject prefab, Transform parent) { this.prefab prefab; this.parent parent; } public GameObject Get() { var go pool.Count 0 ? pool.Pop() : Object.Instantiate(prefab, parent); go.SetActive(true); return go; } public void Release(GameObject go) { go.SetActive(false); pool.Push(go); // 不 Destroy留着复用 } }逻辑说明Get 时优先从池里取取不到才实例化Release 时只 SetActive(false) 并压回栈绝不 Destroy。参数上要注意池的容量上限如果照片总量固定池最多开到「一屏可见数量 缓冲行数」就够了开太大反而占内存。滚动时对滚出视野的格子调 Release对新进入视野的调 Get 并重新赋图。这里有个坑重新赋图时如果上一张纹理没释放内存会持续涨所以 Release 时要顺手把 RawImage.texture 置空并触发一次 Resources.UnloadUnusedAssets或者用引用计数管理纹理。4.3 滚动惯性用 ScrollRect 还是自己写能用 ScrollRect 就用它惯性、回弹、滚动条都是现成的你只需要接管它的内容布局和复用。做法是把 ScrollRect 的 content 设成手动布局的容器监听 onValueChanged在回调里根据 content.anchoredPosition 算出可见区间做池的 Get/Release。自己写滚动只在需要特殊手感比如弧形墙、3D 环绕时才值得普通照片墙没必要重复造轮子。注意 ScrollRect 的 movementType 设成 Elastic 时回弹会带来额外布局计算照片量大时改成 Clamped 更稳。5. 照片墙避坑清单这五个坑我基本每个都踩过5.1 图片显示成紫红色现象照片墙里部分格子是紫红色方块。原因材质或 Shader 丢失常见于打包后 Shader 没被正确包含或者用了 URP 却还在用内置管线的默认材质。解决确认项目渲染管线UI 用 UI/Default 这类内置 Shader打包时把用到的 Shader 加进 Always Included Shaders或者用 ShaderVariantCollection 收集。5.2 滚动时白块一闪而过现象快速滚动时新进入视野的格子先白一下再显示图片。原因异步加载还没完成就赋给了 RawImage纹理为空。解决加载完成前先显示占位图或纯色加载回调里判断格子是否还在可见区间不在就直接丢弃结果避免给已经复用的格子赋错图。5.3 内存只涨不降现象来回滚动几次后内存持续上升Profiler 里纹理数量只增不减。原因旧纹理没有释放或者 Sprite.Create 出来的对象没 Destroy。解决Release 时清空引用统一用引用计数管理纹理确认没有其他格子引用后再 Destroy 并调用 Resources.UnloadUnusedAssets。注意 UnloadUnusedAssets 开销大别每帧调滚动停止后延迟调用。5.4 点击放大后返回位置错乱现象点开大图再返回照片墙滚动位置跳回顶部或错位。原因返回时重建了内容容器anchoredPosition 被重置。解决进入大图前记录 content.anchoredPosition返回时恢复并且复用同一批格子对象不要重新实例化。5.5 不同分辨率下格子大小不一致现象在手机和大屏上照片墙列数一样但格子被拉伸。原因cellSize 写死了像素值没考虑 CanvasScaler 的缩放。解决CanvasScaler 用 Scale With Screen Size参考分辨率设一个基准cellSize 按参考分辨率算或者干脆按屏幕宽度动态算列数和格子宽度保证宽高比不变。6. 进阶把照片墙做成能扛住真实项目的组件前面讲的都是单机本地照片。真实项目里照片墙往往还要面对动态增删、网络图、点击预览、甚至 unity 数字孪生场景里的实时贴图更新。我一般会把照片墙抽成一个独立组件对外只暴露三个接口SetData(列表)、ScrollTo(index)、OnClick 回调。内部把布局、加载、池、可见性判断全部封装外部不关心实现。验证一个照片墙做得好不好我有个土办法拿 500 张 512×512 的图在目标机型上快速来回滚 30 秒看 Profiler 里 GC Alloc 是不是接近 0纹理内存是不是稳定在一个平台期。如果 GC 每帧都在涨说明布局或加载里有临时对象没处理好如果纹理内存一直爬说明释放逻辑有漏。这两个指标过了基本就能上线。还有个容易被忽略的点是图片的宽高比。真实照片有横有竖如果强行塞进正方形格子要么裁要么变形。我的习惯是格子固定图片用 RawImage 的 uvRect 做等比裁剪填充或者干脆做瀑布流每列宽度固定、高度按比例算。瀑布流比网格复杂但视觉上更像真实相册值不值得做取决于产品定位。最后说个我自己的教训早期做照片墙我图省事把所有图打进 Resources测试机上跑得好好的一到真机就崩。后来改成 Addressables 按需加载包体小了内存也稳了。照片墙这种「看起来简单」的需求性能问题几乎全在资源和内存上布局反而是最简单的部分。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进