☰
Unity照片墙从零搭建:ScrollView+GridLayoutGroup实战指南
2026/10/8 9:35:09 网站建设 项目流程

简介:照片墙交互式视觉项目是一份基于Unity引擎、可直接导入运行的示例工程,面向希望学习UGUI、Animator与C#事件交互的中初级Unity开发者。压缩包内共824个文件,涵盖png图片素材、cs脚本、unity场景、asset资源及meta、info、xml等工程元数据,整体仅3.33MB,体量小巧。项目实现了鼠标悬停某张图片时该图放大、相邻图片向两侧平滑让位的动态效果,核心知识点包括Canvas与Image组件搭建UI、RectTransform锚点与偏移量控制图片位置、Animator状态机与AnimationClip实现缩放平移动画、OnPointerEnter与OnPointerExit监听指针事件、C#脚本驱动图片状态更新;此外还可延伸至粒子系统增强视觉过渡、精灵图集合并减少Draw Call、网格或随机分布算法实现自动排列,以及物理系统带来的真实碰撞反馈。借由场景结构、C#源代码与注释,读者能快速理解事件触发、动画播放和布局计算三者如何协同工作,并将同一套交互逻辑复用到相册浏览、作品展示或画廊漫游类项目中。目前已有3328人学习下载,对于需要掌握Unity界面动效与交互设计的学习者具有较高参考价值。

1. Unity照片墙是什么:一个 ScrollView 就能撑起八成需求

拿到「Unity照片墙」这个需求时,先别急着想 3D 展厅、VR 相册那些花活。绝大多数场景——数字孪生项目里查看现场照片、文旅小程序里的全景导览、甚至面试题里问你的 UGUI 功底——本质都是同一件事:把一批图片用网格排布在可滚动的区域内,点击后能看大图。这个闭环用 UGUI 的 ScrollView + GridLayoutGroup 就能在半天内跑通,再往上加 3D 墙面、缩略图加载策略,都属于锦上添花。

我见过不少人一上来就折腾 Viewport、Mask、动态对象池,结果卡在「图片一多就卡」「滚动时闪烁」这类基本功上。这篇文章会按「先搭最小可用版本 → 再换真实数据加载 → 补全交互 → 最后谈性能和进阶」的顺序,把照片墙从零到能交付的路径完整拆一遍。适合刚学 Unity 不久、想用照片墙练手的人,也适合要在正式项目里集成照片墙但不想踩重复坑的工程师。读完你至少能回答一个问题:给你一个文件夹的照片,怎么在一个下午变成 Unity 里能滚动、能点击、不卡顿的照片墙。

2. 搭照片墙第一版:ScrollView + GridLayoutGroup 的排参细节

2.1 为什么选 UGUI 这套组合,而不是其他方案

照片墙的常见做法是「ScrollView 管滚动 + GridLayoutGroup 管排列 + Content 子物体承载图片」。这个组合的好处是网格布局全自动,你只管往里塞子物体,排列密度、换行逻辑、滚动范围都由 LayoutGroup 计算。相比手写位置计算,省掉大量边界判断。

另一个方案是把图片放在 World Space Canvas 上,配合相机旋转做展厅效果。但第一版不建议这么干,因为你还没验证「图片加载」和「点击交互」这两件更关键的事。UGUI 方案里,Image 组件自带 RaycastTarget,点击事件直接挂在图片上,和 ScrollView 的滚动天然兼容,改造成本最低。2D 跑通了,再往 3D 迁移,逻辑依然复用的上。

2.2 手工搭场景,先不要急着写代码

打开 Unity 后,按下列步骤把照片墙的骨架在编辑器里搭出来,这个过程能让你理解每个节点的作用,以后排错时知道该看谁。

  • 创建 Canvas,命名为PhotoWallCanvas。
  • 在 Canvas 下创建空物体ScrollView,挂上 ScrollRect 组件。
  • 在ScrollView下创建Viewport,挂 Mask 组件,用于把超出可视区域的图片裁掉。
  • 在Viewport下创建Content,挂 VerticalLayoutGroup 或 GridLayoutGroup。
  • 在Content下创建 3~5 个 Image 子物体作为测试图。

这里有一个关键点:ScrollRect 的content字段必须指向Content,viewport字段指向Viewport,否则滚动和裁剪都不会生效。编辑器里拖一次就记住,但代码里动态创建时经常忘,后面会讲。

测试阶段建议用 3~5 张纯色图片,直接用 Unity 内置的生成 Sprite 功能,或者从资源商店随便导入几张测试纹理。别一上来就挂真实业务图,等你把滚动调顺了再换不迟。

2.3 GridLayoutGroup 的四个参数决定墙面观感

GridLayoutGroup 是照片墙的排版核心,它的参数直接决定墙面长什么样。下面是一份常用配置,适用 1080×1920 Portrait 分辨率的移动端或模拟器环境。

参数推荐值说明
Cell Size300 × 300每张照片的网格尺寸,不是图片原始尺寸
Spacing10照片间距,单位是像素
Start CornerUpper Left照片从 Content 的左上角开始排
ConstraintFixed Column Count固定列数,移动端通常 2~3 列

Cell Size 不填图片原始尺寸,而是填「展示尺寸」,图片会被拉伸或等比裁剪到这个范围内。实际项目里这两者经常不一致,我会在下一章讲怎么让图不被拉伸变形。Constraint 选Fixed Column Count后,下方会出现Constraint Count栏,填 3 表示一行放三张,这是移动端最常见的照片墙密度。如果你的 Content 宽度是 960,三列 300 的 Cell 加上两个 10 的 Spacing,正好 920,两边留 20 像素边距,看起来就很舒服。

2.4 动态生成格子:一段可复用的 C# 代码

编辑器搭骨架只是第一步,实际交付时图片数量不定,必须用代码动态创建 Image 格子。下面是一段最小可用的创建代码,放在Content挂的 MonoBehaviour 上。

using UnityEngine; using UnityEngine.UI; public class PhotoWallBuilder : MonoBehaviour { public GameObject photoItemPrefab; // 照片格子预制体 public int columnCount = 3; // 每行列数 public int totalCount = 30; // 照片总数 void Start() { GridLayoutGroup grid = GetComponent<GridLayoutGroup>(); grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount; grid.constraintCount = columnCount; for (int i = 0; i < totalCount; i++) { GameObject item = Instantiate(photoItemPrefab, transform); Image img = item.GetComponent<Image>(); img.color = new Color(Random.value, Random.value, Random.value); } } }

这段代码的逻辑很简单:读取自身挂的 GridLayoutGroup,强制设置为固定列数,然后循环实例化格子并随机赋色,用来验证布局效果。totalCount和columnCount暴露在 Inspector 里,不需要改代码就能调密度。

GridLayoutGroup 会根据子物体数量自动计算 Content 的高度,所以不需要手动设置 Content 的 sizeDelta。但有一个前提:Content 的anchorMin和anchorMax的 y 轴要设成(0, 1),锚定顶部,否则格子会从中心向外扩,表现是「照片墙从中间开始长」,第一次用的人容易蒙。

2.5 滚动卡顿的第一嫌疑:Content 没有正确裁剪

照片墙滚动时最常遇到的翻车现象是「照片越界显示」——第一行的图跑到 Viewport 外面去了,或者滚动时看到图片叠在别的 UI 上面。这多半不是 GridLayoutGroup 的问题,而是 Mask 没生效。Mask 组件要求 Viewport 上必须有 Image 作为渲染载体,如果 Viewport 是纯空物体,Mask 会失效,图片直接穿出边界。

解法是给 Viewport 挂一个Image组件,把Color设成全透明(alpha = 0),并勾选Maskable。Mask 用这个 Image 的透明度做裁剪计算。另一个隐患是 ScrollRect 的viewRect没正确绑定,代码动态创建时常见,检查 Inspector 里 ScrollRect 的Viewport槽位是否指向 Viewport 节点即可。

3. 照片从哪来:三种加载方式与缓存思路

3.1 静态打包进 Resources:小项目最省事,但有个坑

照片量少、更新不频繁的项目,直接丢进Resources文件夹,用Resources.Load加载是最快的。很多 Unity 下载教程、操作入门示例里都是这条路。起步成本低,但有一个必须提前知道的坑:Resources 里的所有资源都会打进最终包体,照片一多包体体积直接失控。我见过一个数字孪生项目,往 Resources 里塞了两千张巡检照片,包体直接多了 1.5GB,后面不得不迁到远程加载。

Resources 加载的代码很简单,下面这段是完整写法:

using UnityEngine; using UnityEngine.UI; public class PhotoLoader : MonoBehaviour { public string folderPath = "Photos/"; // Resources 下的相对路径 public GameObject photoItemPrefab; void Start() { string[] photoNames = new string[] { "photo_001", "photo_002", "photo_003" }; foreach (string name in photoNames) { Sprite sprite = Resources.Load<Sprite>(folderPath + name); if (sprite == null) { Debug.LogWarning("找不到图片: " + name); continue; } GameObject item = Instantiate(photoItemPrefab, transform); item.GetComponent<Image>().sprite = sprite; } } }

这段代码的关键判断在sprite == null:Resources.Load 找不到资源时不报错,只返回 null。如果你忽略了空判断,后续设 sprite 会直接抛 NullReferenceException。folderPath是相对路径,不用带后缀名,也不允许带Resources这个目录名本身。另外一个注意点:图片的Texture Type必须设为Sprite (2D and UI),否则加载出来的对象没法赋给 Image.sprite,这是最常见的「加载出来了但显示紫红色」的原因之一。

3.2 用 URL 加载远程图片:协程琢磨明白,代码讲清楚了

照片存在服务器、需要动态更新的场景,用UnityWebRequestTexture加载。这种模式下照片不占包体,还能实现「运营后台换图,客户端不用发版」的效果。常见做法是把照片 URL 列表存在一个 JSON 配置里,客户端先拉配置,再逐张加载图片,类似照片墙版的资源热更新。

下面这段代码封装了一个「按 URL 列表加载缩略图」的组件,用协程实现逐张加载,避免一次性发起几十个并发请求把带宽打满。

using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Networking; using UnityEngine.UI; public class RemotePhotoLoader : MonoBehaviour { public string[] photoUrls; // 照片墙的远程图片地址 public GameObject photoItemPrefab; void Start() { StartCoroutine(LoadAllPhotos()); } IEnumerator LoadAllPhotos() { foreach (string url in photoUrls) { yield return StartCoroutine(LoadOnePhoto(url)); } } IEnumerator LoadOnePhoto(string url) { UnityWebRequest request = UnityWebRequestTexture.GetTexture(url); yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { Debug.LogError("加载失败: " + url + " 原因: " + request.error); yield break; } Texture2D tex = DownloadHandlerTexture.GetContent(request); Sprite sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); GameObject item = Instantiate(photoItemPrefab, transform); item.GetComponent<Image>().sprite = sprite; } }

LoadAllPhotos里用yield return StartCoroutine(LoadOnePhoto(url))做串行等待,意思是「等这张加载完,再加载下一张」。这样做的代价是速度慢,但好处是可控——不会出现并发创建几十个UnityWebRequest把内存顶上去的情况。如果你要追求批量加载,可以改成并发启动协程,但记得用一张Dictionary<string, Texture2D>做缓存。

DownloadHandlerTexture.GetContent会把下载到的字节解码成Texture2D,而Sprite.Create负责把 Texture 包成 UI 可用的 Sprite。注意Sprite.Create的后两个参数:new Rect(0, 0, tex.width, tex.height)表示取整张图,new Vector2(0.5f, 0.5f)表示 Sprite 中心在图片中心,不设这两个值会导致图片在 UI 上显示位置偏移,属于很隐蔽的玄学问题。

3.3 加载完不缓存等同于白加载:内存友好型方案

远程加载的图如果只在显示时用一次,翻回去再滚上来又得重新下载或者重新解码,这是很不划算的。手机用户流量费和加载等待时间都是成本,最常见的解决办法是维护一个 Sprite 缓存。下面这段代码用静态字典做缓存,代码量不大但实用性很高。

public static class SpriteCache { private static Dictionary<string, Sprite> cache = new Dictionary<string, Sprite>(); public static bool TryGet(string url, out Sprite sprite) { return cache.TryGetValue(url, out sprite); } public static void Add(string url, Sprite sprite) { if (!cache.ContainsKey(url)) cache.Add(url, sprite); } public static void Clear() { cache.Clear(); } }

使用方式是在LoadOnePhoto的响应里,先查缓存,命中就直接用,没命中才发请求并写入缓存。这样同一个 URL 的照片只会被解码一次。需要注意的是,Sprite.Create每次创建的都是新对象,如果不缓存,同样的照片滚动三次就创建三份 Sprite,内存翻三倍,这就是很多照片墙「越滚越卡」的真相。

缓存放不进Resources.Load场景的原因也顺带说一句:Resources.Load 本身就有自己的资源管理机制,同一个资源多次加载返回的是同一份底层引用,不存在重复创建的问题。所以缓存主要是给远程加载场景准备的。

4. 点开看大图:照片墙交互闭环的遮罩层与点击事件

4.1 点击事件的两种挂法,以及它们的取舍

照片墙做出来后,最自然的交互是「点小图看大图」。UGUI 里响应点击事件有两个常见做法:一种是给每个格子挂Button组件,在 Inspector 里拖事件;另一种是写一个MonoBehaviour,实现IPointerClickHandler接口。

我倾向于用后一种,因为照片墙的格子是动态创建的,用Button每个都得动态绑定监听器,代码里做GetComponent<Button>().onClick.AddListener(() => ...),写起来绕且容易在闭包里捕获错误的循环变量。用接口实现的话,格子自己就知道被点了,逻辑内聚,排查问题也方便。下面是实现代码:

using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; public class PhotoItem : MonoBehaviour, IPointerClickHandler { private Sprite _photoSprite; public void SetPhoto(Sprite sprite) { _photoSprite = sprite; GetComponent<Image>().sprite = sprite; } public void OnPointerClick(PointerEventData eventData) { if (_photoSprite == null) return; PhotoViewer.Show(_photoSprite); } }

OnPointerClick是接口回调,Unity 在鼠标或手指点击到 UI 时调用。这里做了一个防御判断:_photoSprite == null说明图片没加载出来,点了也没意义,直接 return。PhotoViewer.Show是下一节要写的预览控制器,静态方法的好处是不需要在这个文件里拖引用,减少编辑器配置。

之前用 Button 方案时踩过一个典型的坑:在for循环里用AddListener,闭包捕获了循环变量i,结果每个格子的点击都是同一个索引。所以现在写点击事件一律不用 Button 挂闭包,直接用接口拿 Sprite 引用,干净利落。

4.2 大图预览:一个遮罩层 + 缩放动画

大图预览的本质是在 Canvas 顶层叠一个全屏遮罩,中间放一张大图,点击遮罩关闭。这里有一个设计细节:不能用格子里那个 Image 直接放大,必须新建一个专门的大图节点,否则缩略图的 GridLayout 布局会被影响。

下面是一个简化版但可用的PhotoViewer实现,挂在 Canvas 下的一个空物体上:

using System.Collections; using UnityEngine; using UnityEngine.UI; public class PhotoViewer : MonoBehaviour { public GameObject viewerRoot; // 遮罩+大图的根节点 public Image bigImage; // 大图 Image public Button closeButton; // 关闭按钮 private static PhotoViewer _instance; void Awake() { _instance = this; viewerRoot.SetActive(false); } public static void Show(Sprite sprite) { if (_instance == null) return; _instance.bigImage.sprite = sprite; _instance.viewerRoot.SetActive(true); } public void OnClose() { viewerRoot.SetActive(false); } }

viewerRoot的结构是 Canvas 下的一个全屏 Image(半透明黑色,alpha 大概设 0.6),它的子物体是大图 Image 和关闭按钮。大图 Image 的preserveAspect勾上,这样图片不会被拉伸变形,留下上下或左右的黑边,这是最常见的做法,丑但真实。点击半透明遮罩时有两种交互习惯:点哪都关闭,或者点遮罩关闭、点大图不关闭。后一种实现稍复杂,要给大图单独挂一个空的Image挡住点击事件,防止穿透。

OnClose是编辑器里绑定到 closeButton 的 onClick 事件上的,不需要代码额外注册。如果你喜欢更顺滑的动画,可以在 Show 和 OnClose 里加CanvasGroup的 alpha 渐变,这个是 polish 阶段的事,第一版不必做。

4.3 大图看不了:图片变形与显示区域裁剪

大图预览最常见的翻车现场是图片被拉伸成奇怪的宽高比,或者超出屏幕边缘。问题根源是 Image 组件的preserveAspect没勾,以及Image的type默认是Simple。preserveAspect是 UGUI 里最容易忽略的开关,它的语义是「保持图片宽高比」,勾选后 Image 会按原始比例缩放到矩形内,剩余空间透明。

另一个细节是bigImage的锚点。如果你把 bigImage 放在一个固定尺寸的 RectTransform 里,比如 90% 屏幕宽,那么不同分辨率的设备上表现会不一致。我一般让大图的anchorMin/anchorMax全屏,然后用fit逻辑手动计算显示区域。标准做法是写一个SafeFit脚本,用图片原始宽高比和屏幕宽高比比较,决定按宽度适配还是按高度适配。这一步属于体验细化的范畴,第一版用preserveAspect硬顶着就能跑通。

5. 照片墙常见翻车现场:五条避坑记录

5.1 紫红色图片:Texture 类型没设对

现象:动态加载的照片显示成一片紫红色,编辑器里看 Sprite 赋值是正常的,静态图显示没问题。

原因:图片资源的Texture Type不是Sprite (2D and UI)。Unity 对 UI 用的纹理有专门的导入管线,设为Default类型的图片不能赋给 Image.sprite,赋值时检查通过,运行时显示紫红。

解决:批量选中所有照片资源,在 Inspector 里把Texture Type改为Sprite (2D and UI),点击 Apply。如果是运行时从网上下载的图片,用Texture2D转 Sprite 前也要检查这个类型,但运行时下载的图不受导入设置限制,紫红色主要出在Resources.Load和编辑器拖拽的场景。

5.2 滚动时图片闪一下再显示:逐张加载导致的加载抖动

现象:照片墙快速滚动时,底下的空白区域露出,图片到可视区域内才开始加载,视觉上频繁闪烁。

原因:缩略图没有在滚动前全部加载完,或者说没有预加载。照片墙往往有几十张图,串行加载 URL 图时,用户滚动速度比加载速度快,自然能看到空白。

解决:两个方向。一是加载完所有图之前,不显示滚动区域,用一张 Loading 图顶着;二是按需加载——把可视区域周围的图片优先加载,远处的后加载,这需要和 ScrollRect 的滚动位置挂钩。第一版项目用方案一最简单,在 Start 协程结束后再激活 ScrollView 原物即可。如果你的需求是「无脑加载完才显示」,就等所有照片加载完成再SetActive(true);追求体验的再考虑切片加载。

5.3 Content 高度为 0 或布局错乱:锚点和 LayoutGroup 的顺序问题

现象:代码动态往 Content 里塞子物体,但 GridLayoutGroup 算出来的 Content 高度始终为 0,滚动不了。

原因:GridLayoutGroup 计算高度依赖子物体的 Grid 尺寸,但是子物体创建时,LayoutGroup 还没执行Rebuild,高度没刷新;更常见的是 Content 的锚点设置不对,pivot.y = 0.5时高度从上往下和从下往上长,视觉上表现为布局从中间开始。

解决:动态创建完成后,调用LayoutRebuilder.ForceRebuildLayoutImmediate(ContentRectTransform)强制刷新,然后检查 Content 的 pivot.y 是否为 1。第一版最容易处理的做法是在编辑器里把Content的pivot设为(0.5, 1),锚点的 anchorMin 和 anchorMax 都设为(0, 1),锁死在顶部,布局只会向下长。

5.4 内存只增不减:大图的 Texture 没有释放

现象:照片墙长时间使用后内存持续增长,切场景再回来更明显,用 Profiler 看 Texture2D 数量一直上涨。

原因:远程加载的照片 Texture 全部缓存在 SpriteCache 里,手动调用 Clear 的地方没写;Sprite.Create创建的 Sprite 引用 Texture,如果 Sprite 被丢弃而 Texture 没有被引用清理,GC 不会回收这些原生对象。

解决:确认 SpriteCache.Clear 在场景切换时或退出照片墙时调用,然后留意一个细节——Sprite.Create创建的 Sprite 需要显式Destroy才会立刻释放 Texture。在OnDestroy里遍历照片墙持有的 Sprite,逐个Destroy,再把缓存清空。Unity 的 Resources.UnloadUnusedAssets 也能帮上忙,但不要在频繁时刻调用,开销大。

5.5 网格每行的照片对不齐:Cell Size 与图片等比缩放冲突

现象:GridLayoutGroup 的 Cell Size 是 300×300,但照片内容有的显示黑边,有的被裁切,整面墙看起来参差不齐。

原因:Image 组件把 Sprite 拉伸到全矩形,但不同照片的原始宽高比不同,拉伸后比例失真。常见做法是 Image 的preserveAspect勾选,这样图片按比例缩放,矩形内出现透明边;或者用Image.type = Filled结合裁剪器做中心裁剪。

解决:想看到整齐的方形照片墙,就用preserveAspect = false配合Image的Pivot和RectTransform锚点做「中心裁剪」。方法是把 Image 尺寸设为 Cell Size,然后给 Sprite 设置Sprite.Create时,用原始图的中心裁剪出一个正方形区域。这个方案每次创建 Sprite 时都要算Rect,代码多一点,但视觉效果最整齐。没时间打磨的话,先接受黑边,跑通流程更重要。

6. 进阶玩法:把平面照片墙立起来,做成 3D 展厅效果

平面 UGUI 照片墙跑通以后,自然会想往「Unity 数字孪生」「Unity 仿真实验」这种场景延伸:把照片挂在 3D 墙面,用户用 WASD 走近看,或者鼠标拖拽旋转视角。实现思路是把内容从 UGUI 换到 World Space Canvas,本质不变,但有一个必须重新调的点——Canvas 的模式和缩放。

World Space Canvas 的缩放系数直接对应「照片墙在 3D 场景里的物理尺寸」。常见做法是CanvasScaler的Dynamic Pixels Per Unit设置为 10,然后手动把 Canvas 的 RectTransform 宽度设成墙面的期望宽度,比如 10 米。此时 ScrollView 的滚动逻辑完全复用,只是 Content 的宽度不再是像素而是世界单位。一个小技巧:如果想让相机用 WASD 走动而不是拖拽场景,给主相机挂一个简易的CharacterController脚本,重力设置为 0,用Input.GetAxis("Horizontal")做前后左右移动,交互手感就出来了。

进阶版的完整姿势是给照片墙增加一个「记录上次滚动位置」的功能。做法是在OnDestroy时把ScrollRect.normalizedPosition存进PlayerPrefs,下次打开时恢复。这也是 Unity 面试题里经常被追问的点——能否说出 ScrollRect 的normalizedPosition的语义。它的取值范围是 0 到 1,(0, 1)表示左上角,(1, 0)表示右下角,存的时候把 Vector2 的 xy 分量分别存下,读的时候new Vector2(x, y)一次性还回去。

最后分享一个我的血泪经验:别在照片墙的格子上放Button组件还要让它响应拖拽事件。ScrollRect 的滚动和 Button 的点击默认不会冲突(Unity 已经处理了拖拽阈值),但如果你在格子图片上叠了别的可拖拽 UI,冲突就会突然冒出来,表现为「滚动时偶尔触发了点击」。排查这类问题花了我一个下午,最后发现是子物体的RaycastTarget没关。照片墙里凡是纯显示用的 Image,RaycastTarget一律关掉,只保留真正要响应的那一个,能省不少事。

照片墙这个需求,从 UGUI 组合到远程加载、交互闭环、优化避坑,整体链路不算长,但每一步都有实打实的隐藏细节。你按这篇文章从前到后做一遍,从编辑器搭骨架到代码动态创建,再到加载真实照片,基本就能掌握「Unity UI 框架 + 资源管理 + 交互」这条主线。后续想往性能方向深入,可以做缩略图对象池;想往展示方向深入,可以把手势缩放、双击放大补全。希望这些思路帮你在自己的项目里少走几步弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询