简介:面向Unity开发者的Scroll View长图截取与本地保存资源包,解决滚动列表内容超出屏幕后难以完整导出长图的痛点。资源围绕连续截图、图像合成与文件导出三个关键链路展开,提供了基于协程逐帧移动Content并抓取屏幕内容的完整思路,包含Texture2D拼接时的边缘重叠处理、滚动位置与尺寸校准、图片内存释放等实现细节,同时给出导出PDF时面对Unity原生不支持格式的替代方案与第三方库对比。RAR压缩包内共2000个文件,核心代码以md说明文档和cs脚本为主,另有bin资源、txt配置、json数据、asset与meta工程文件,以及少量png缩略图可辅助预览;整体包体716.55MB,目录层级完整,适合在Unity工程中对照学习。当前已有162人浏览学习。通过它可快速理解滚动截图拼接原理,得到现成的坐标控制、边缘对齐和性能优化代码参考,能有效减少自行调试图像错位与耗时资源回收的负担,适合需要做界面长图导出的初中级开发者。
1. Scroll View连续截图:需求一句话,落地却总差一口气
玩家想把整个排行榜截成一张长图发到群里,运营想导出全部聊天记录做留档,产品想让背包列表一键变成宣传图——需求本质都是“把滚动列表内的内容截成一个长图”。可你动手会发现,Unity的ScreenCapture只能截当前屏幕,Scroll View的视口又像一扇固定大小的窗户,窗外的东西根本不会被渲染出来。于是你只能换着法子骗过Unity:搬动Content、拆掉Mask、或者另起炉灶做离屏渲染。这篇文章把三种路线的边界、参数和坑都拆开讲,最后的连续截图加本地保存方案可以直接抄进项目。
2. 为什么不能直接截屏:Scroll View的裁剪机制与三种方案的取舍
2.1 名字叫Scroll View,本质是个裁剪窗口
Scroll View在Unity UI里看起来是个可滚动的列表,但底层实现其实极其朴素:一个Content RectTransform承载所有子物体,一个Viewport RectTransform提供可视范围,再由RectMask2D或Mask把超出范围的UI挡住。绝大多数项目的Scroll View子物体根本不会被摄像机“看见”,因为裁剪发生在UI网格生成阶段,视口外的元素会被剔除或者直接跳过合批,不会进入渲染队列。
这才是直接截屏失败的根源。ScreenCapture.CaptureScreenshot截的是摄像机最终输出的整张画面,而Scroll View窗口之外的内容压根不存在于画面里。就算你把摄像机拉远强行包住整个Content,又会遇到另一个问题:UI的CanvasScaler会按屏幕分辨率缩放,Content高度一旦超过屏幕高度几千像素,把它强行塞进一屏的结果是文字缩成一团,尺寸完全失真。我见过不少同事一开始就在这里绕了远路,改了半天摄像机参数,最后截出来还是残缺的。
要解决这个问题,核心不是“怎么截图”,而是“怎么让Unity把Content完整渲染出来”。常见的路子有三条:禁用Mask一次性截、用RenderTexture离屏渲染、逐帧滚动拼接。每条路都有代价,下面一个个说。
2.2 三种方案横向对比:快、脏、稳之间的选择
经常有人问:能不能把Viewport拉大,或者临时把Content复制一份到屏幕外,然后截全屏?先说结论:这类思路都归到“禁用Mask一次性截”,做原型可以,交付不行。
把Viewport拉大到等于Content大小,等于让Scroll View直接失去滚动逻辑,Content瞬间缩回顶部或按锚点重新排列,布局大概率崩。就算你小心保存了原位置,再把Viewport尺寸改回去,过程中如果有LayoutGroup、ContentSizeFitter参与,子项尺寸会重新计算,滚动位置对不齐。临时复制Content到屏幕外更糟,因为Scroll View里的子项多半用了循环复用,复制出来的是一份已经“滚动到当前位置”的脏数据,不是从头开始的完整内容。
RenderTexture离屏渲染是另一条路:创建一个和Content等高等宽的RT,把UI再画一遍。听起来最干净,但工程量不小。Screen Space - Overlay的Canvas不能直接进RT,你得先复制Canvas或临时改它的Render Mode,再配一个专用相机,还要处理RT最大尺寸限制(多数移动端上限4096)。更麻烦的是项目里往往有大量的Tween、动画、定时器绑在原有Canvas上,一旦临时换Render Mode,很多效果会闪烁或者丢失。
所以我把默认方案定成“逐帧滚动拼接”。一句话版本:把Content按Viewport高度切成N段,每次滚到对应位置截一小张,然后拼接成整张长图。这方案不破坏Scroll View结构,不换Canvas模式,不需要额外相机,代码量也最少。缺点是慢,但用户本来就要等,加个Loading即可。
2.3 逐帧拼接的本质是把“空间问题”转成“时间问题”
逐帧滚动拼接听起来笨,但它有一个其他方案不具备的优势:你截下来的每一帧,都是Scroll View真实渲染出来的结果。这意味着Content里任何Image、Text、自定义Shader、特效,都保留着和用户实际看到时一致的观感,不用额外还原。
它的工程化难点只剩两个:一是如何保证每次滚动距离精确等于Viewport高度,二是如何保证滚动后UI已经完成渲染再截图。前者靠整数像素对齐,后者靠WaitForEndOfFrame加等待延时。
还有一个需要提前想清楚的参数:步长。步长等于Viewport高度时,切片之间没有任何重叠,拼接最简单。但代价是如果Content高度不是Viewport高度的整数倍,最后一段可能不满一屏。处理方式很简单:循环结束条件用“当前位置 >= 最大可滚动距离”,最后一步直接clamp到底部,截出来的最后一张仍然填满Viewport,只是显示的是Content最底部的内容,这样每张切片尺寸一致,拼接时不会错位。
我一般把整个截图流程封装成一个协程,方便挂Loading动画,也能随时取消。下面一章给出可以直接用的脚本框架。
3. 核心实现:用协程控制Content滚动,按Viewport高度逐屏取图
3.1 环境准备与UI配置
先建立一个最简单的测试场景:一个Canvas,Canvas下挂Scroll View,Content里放100个高度固定的List Item,每个Item设置一个不同的背景色,方便最后验证长图拼接顺序。Canvas采用默认的Screen Space - Overlay,CanvasScaler的缩放模式用“Scale With Screen Size”,参考分辨率1080x1920。实际上不同参考分辨率也能跑,只是后面的像素换算要注意。
把下面的脚本挂到Canvas或任意常驻节点上,然后在Inspector里把ScrollRect、Viewport、Content三个引用拖进去。需要说明的是,Viewport和Content必须是ScrollView内部的那个RectTransform,很多项目把ScrollRect挂在外层Root上,Content挂在内层,拖的时候别拖错。
[Header("引用")] public ScrollRect scrollRect; public RectTransform viewport; public RectTransform content;如果你用的是ListView、LoopScrollRect这类第三方组件,原理也一样,只要它们最终通过修改Content的anchoredPosition实现滚动就能用。但如果组件内部用了虚拟化单元格复用,滚动后Item内容会异步更新,那就必须把frameDelay调大,或者等它们的刷新回调触发后再截图。
3.2 滚动的主体协程:滚一屏、等渲染、截一张
下面这段是核心协程,先从顶部开始,逐屏向下滚动,每屏从屏幕上截取Viewport区域对应的像素,存进一个List,最后调用拼接保存函数(下一章实现)。
using System.Collections; using System.Collections.Generic; using System.IO; using UnityEngine; using UnityEngine.UI; public class ScrollViewLongCapture : MonoBehaviour { [Header("引用")] public ScrollRect scrollRect; public RectTransform viewport; public RectTransform content; [Header("截图参数")] [Tooltip("滚动后等待的秒数,列表内有异步加载时调大")] public float frameDelay = 0.1f; [Tooltip("Canvas的缩放因子,通常自动获取")] public float manualScaleFactor = -1f; private List<Texture2D> slices = new List<Texture2D>(); [ContextMenu("开始连续截图")] public void StartCapture() { StartCoroutine(CaptureRoutine()); } private IEnumerator CaptureRoutine() { // 记录初始状态 scrollRect.StopMovement(); scrollRect.velocity = Vector2.zero; scrollRect.movementType = ScrollRect.MovementType.Unrestricted; // 强制回到顶部 content.anchoredPosition = Vector2.zero; yield return null; yield return new WaitForEndOfFrame(); // 如果Content有LayoutGroup或ContentSizeFitter,等一帧让高度更新 yield return new WaitForEndOfFrame(); float contentHeight = content.sizeDelta.y; float viewportHeight = viewport.rect.height; float maxScrollY = Mathf.Max(0f, contentHeight - viewportHeight); // 步长取整数,保证相邻两张切片在像素上严格对齐 int stepHeight = Mathf.RoundToInt(viewportHeight); slices.Clear(); float scrolledY = 0f; while (true) { // 先等当前帧UI彻底渲染完,再读取像素 yield return new WaitForEndOfFrame(); Texture2D slice = CaptureViewportRegion(); slices.Add(slice); // 到达底部则结束 if (scrolledY >= maxScrollY - 0.5f) { break; } // 向前滚动一个步长,同时防止越界 scrolledY += stepHeight; if (scrolledY > maxScrollY) { scrolledY = maxScrollY; } // 垂直列表:content上移,anchoredPosition.y为正 Vector2 pos = content.anchoredPosition; pos.y = scrolledY; content.anchoredPosition = pos; // 等UI完成滚动后的重新布局 yield return new WaitForSecondsRealtime(frameDelay); yield return new WaitForEndOfFrame(); } SaveLongTexture(slices); } private Texture2D CaptureViewportRegion() { // 获取Viewport四个角的世界坐标,顺序:左下、左上、右上、右下 Vector3[] corners = new Vector3[4]; viewport.GetWorldCorners(corners); float scaleFactor = manualScaleFactor > 0f ? manualScaleFactor : CanvasScaleFactor(); int x = (int)(corners[0].x / scaleFactor); int y = (int)(corners[0].y / scaleFactor); int w = (int)((corners[3].x - corners[0].x) / scaleFactor); int h = (int)((corners[1].y - corners[0].y) / scaleFactor); // 防御性检查:避免分辨率/缩放异常导致尺寸为0 if (w <= 0 || h <= 0) { Debug.LogError("Viewport区域计算异常,检查scaleFactor设置"); return new Texture2D(2, 2); } Texture2D tex = new Texture2D(w, h, TextureFormat.RGB24, false); tex.ReadPixels(new Rect(x, y, w, h), 0, 0); tex.Apply(); return tex; } private float CanvasScaleFactor() { Canvas canvas = GetComponentInParent<Canvas>(); if (canvas == null) return 1f; return canvas.scaleFactor; } }这段代码里有三个地方要特别说明。
第一个是movementType。ScrollRect默认有时是Elastic,带弹性回弹,Content在滚动结束后会被插值拉回,导致你设定的位置对不上。截图期间必须临时改成Unrestricted,截完再恢复原值。
第二个是WaitForEndOfFrame。ReadPixels读的是当前屏幕缓冲,如果你在帧中段调用,屏幕缓冲可能还是上一帧的内容。协程里先yield WaitForEndOfFrame,再调用CaptureViewportRegion,才能保证这个等号成立。
第三个是stepHeight取整。Viewport.rect.height是浮点像素值,不一定是整数。如果直接拿浮点值改content.anchoredPosition,Unity内部会做像素偏移,相邻切片之间可能多出一个像素或者少一个像素,拼接时就会出现斜纹或漏线。所以我在滚动时用Mathf.RoundToInt取了整,把步长固定成整数像素。
3.3 CanvasScaleFactor为什么不能省
很多第一次写的人会在这一步卡住:GetWorldCorners返回的坐标在Screen Space - Overlay下并不总是屏幕像素坐标。当CanvasScaler把Canvas整体缩放时,Canvas节点的scaleFactor会大于1,GetWorldCorners返回的值是“世界单位”,需要除以scaleFactor才能换算成真正会被ReadPixels使用的像素坐标。
比如参考分辨率1080x1920,屏幕实际分辨率是2160x3840,scaleFactor可能是2,那么Viewport的宽在所有情况下都是屏幕像素的1/2。不除以scaleFactor,截出来的图会放大两倍,并且位置偏掉。脚本里的manualScaleFactor就是留给你手动校准的调试口,如果发现长图宽度不对,先看看这个值是不是等于Canvas的scaleFactor。
连续截图的循环逻辑还有一个小技巧:我把最后一张的截取时机放在break之前。也就是说,即使scrolledY已经到达maxScrollY,也先截一张再退出,保证最后一片覆盖到底部内容。如果你的Content高度正好是Viewport高度的整数倍,会出现最后一张和倒数第二张完全重复。判断条件里scrolledY >= maxScrollY - 0.5f能规避大部分情况,但整数倍时还是可能多截一张。想彻底去掉重复,就改成先滚动再截图的顺序,但那样需要处理第一步的复位,我嫌麻烦。多一张的代价只是拼接时多一段重复画面,可接受,要彻底去重见第5章。
4. 拼接与保存:把多个切片合成一张长图并落盘
4.1 有顺序意识的合并:从屏幕坐标到纹理坐标
切片是逐张截出来的,顺序都保存在List里,第一张在顶部,最后一张在底部。Unity的Texture2D坐标系原点在左下角,x向右,y向上。所以把切片写入长图时,第一张应该放在长图的“最高处”,也就是y坐标最接近totalHeight的位置。
以下是拼接和保存的完整代码,可以接到第3章协程末尾的SaveLongTexture调用:
private void SaveLongTexture(List<Texture2D> slices) { if (slices == null || slices.Count == 0) return; int sliceWidth = slices[0].width; int sliceHeight = slices[0].height; int totalHeight = 0; foreach (var tex in slices) { totalHeight += tex.height; } // 输出路径 string outputDir = Path.Combine(Application.persistentDataPath, "LongCapture"); Directory.CreateDirectory(outputDir); string outputPath = Path.Combine(outputDir, "longshot_" + System.DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".png"); // 单个后备:超过4096就切块保存 const int maxTextureSize = 4096; if (totalHeight > maxTextureSize) { Debug.LogWarning("长图高度超过4096,Unity纹理最大尺寸限制,自动切块保存"); SaveSlicesAsSeparateFiles(slices, outputDir); return; } Texture2D longTexture = new Texture2D(sliceWidth, totalHeight, TextureFormat.RGB24, false); int offsetY = 0; for (int i = 0; i < slices.Count; i++) { Color[] pixels = slices[i].GetPixels(); int yPos = totalHeight - offsetY - slices[i].height; longTexture.SetPixels(0, yPos, sliceWidth, slices[i].height, pixels); offsetY += slices[i].height; } longTexture.Apply(); byte[] pngData = longTexture.EncodeToPNG(); File.WriteAllBytes(outputPath, pngData); // 释放内存 Destroy(longTexture); foreach (var tex in slices) Destroy(tex); slices.Clear(); Debug.Log("长图已保存到: " + outputPath); } private void SaveSlicesAsSeparateFiles(List<Texture2D> slices, string outputDir) { for (int i = 0; i < slices.Count; i++) { byte[] pngData = slices[i].EncodeToPNG(); File.WriteAllBytes(Path.Combine(outputDir, "slice_" + i.ToString("D3") + ".png"), pngData); } }这段拼接代码的横跳点是yPos的计算。第一次循环i=0,offsetY=0,yPos = totalHeight - sliceHeight,也就是长图最顶部。第二次i=1,yPos = totalHeight - sliceHeight * 2,排在第一次下面。GetPixels返回的数组是从纹理左下角开始一行一行往上读的,SetPixels也是从左下角写入,所以直接把GetPixels拿到的数组原样塞到对应yPos即可,不需要反转。
4.2 超过4096怎么办:切块比缩放更靠谱
需要解释一下为什么要检查totalHeight。Unity在移动端和许多PC平台,Texture2D的最大尺寸都有上限,常见的是4096或8192。如果你截图排行榜,Content高度轻松突破一万像素,直接new一个10000高的Texture2D,在真机上很可能会得到一个空白纹理,或者直接报Out of Memory。
处理方式有两种:等比例缩小长图,或者切块保存。缩小长图要用高质量缩放,Unity自带的Texture2D.Resize不做抗锯齿,放大缩小的画质很糊,要自己用RenderTexture做Blit,代码反而更长。所以我默认走切块路线:超过4096就按每片4000的高度拆成多张PNG保存,文件名按序排好,哪怕不在Unity里,外面用Photoshop或者Python脚本也能合并。这个方案不用额外引库,也不怕设备纹理上限。
如果你要求输出单张长图怎么办?那就把maxTextureSize调成设备支持的上限,然后用RenderTexture做一次缩放。但我不建议这样做,因为玩家分享场景里,一张超过4096的长图传到聊天工具里会被二次压缩,清晰度损失严重,反而不如切块保存更实用。
4.3 保存到本地:persistentDataPath与相册的区别
脚本里用的是Application.persistentDataPath,这个路径在PC上位于用户目录下的AppData/LocalLow/公司名/项目名,在Android上位于应用的内部存储目录,在iOS上位于沙盒Documents。这个路径适合开发者调试,但从玩家视角看,这里不“本地”。
如果需求是“截图保存后用户能直接在手机相册看到”,那就不能只用File.WriteAllBytes。Android平台需要走MediaStore接口,iOS需要调用原生相册方法,一般要写AndroidJavaObject或者接一个相册插件。我通常在编辑器阶段统一存persistentDataPath,等真机发布时再在SaveLongTexture里加一个平台分支,接入已经存在的相册SDK。底层逻辑不变,只是把byte[]交给不同平台的接口。
保存成功后,最好在UI上显示一下输出路径,这句话不仅方便你调试,也方便玩家自己找文件。Debug.Log只在编辑器看得见,真机上没人看。
5. 避坑指南:Scroll View长图截图的五个翻车现场
5.1 滚动回弹和惯性让你永远截不对位置
现象:脚本跑完,长图里某些切片内容重复,或者滚动位置忽上忽下,最后一张总是离底部差一截。
原因:ScrollRect默认的MovementType如果是Elastic或者Clamped,Content在滚动到边界时会有一个反弹动画;此外ScrollRect自带的惯性会在你设完anchoredPosition以后继续滑动一小段,导致实际截图位置和你设定的position不一致。
解决:在开始截图前必须先调用scrollRect.StopMovement(),并且把speed和velocity都清零。然后强制设置movementType为Unrestricted,这样ScrollRect不会再干预Content的坐标。截图结束后再恢复原movementType。注意Unrestricted只是放开边界限制,ScrollRect内部仍然有velocity插值逻辑,所以停用惯性那一行不能漏。
scrollRect.StopMovement(); scrollRect.velocity = Vector2.zero; scrollRect.movementType = ScrollRect.MovementType.Unrestricted;5.2 Scroll View里的Item是复用的:你截到的可能是上一秒的内容
现象:每一张切片单独看内容都正常,但两张切片拼接后,同一个Item看起来变了,或者出现相同内容被截进两个位置。
原因:现在很多列表框架为了性能都做“单元格循环复用”,Item滑动出视口后会被回收到顶部继续使用。截图时如果复用逻辑在WaitForEndOfFrame之后才执行数据填充,那么ReadPixels读取到的像素是复用的旧数据。
解决:在滚动到新位置后不要立刻截,先等一帧,再WaitForEndOfFrame。最保险的是等待两帧:第一帧让ScrollRect完成Content位置更新,第二帧让列表框架检测到复用并刷新Item。如果列表里还有异步加载的图片,必须等所有图片加载完成,或者在刷新回调里发送一个事件,截图协程收到事件后再继续。我在实际项目里的做法是加一个回调参数,frameDelay默认0,但列表刷新完成后会调用一个接口来解除等待。
5.3 CanvasScaler缩放把你绕晕:宽度一会对一会错
现象:长图宽度不是预期的1080,有时是1920,有时是540,而且越往下截,位置偏移越明显。
原因:GetWorldCorners返回的是UI世界坐标,CanvasScaler会把Canvas整体缩放,导致Viewport的世界宽高和屏幕像素宽高之间存在一个scaleFactor的换算关系。如果不除以scaleFactor,截图区域就是按放大后的坐标去裁,裁出来的内容就会偏移,宽度也跟着错。
解决:使用Canvas.scaleFactor做换算。注意Canvas挂在父节点上,GetComponentInParent
5.4 拼接出现一条条细缝或者内容重叠
现象:长图里每隔固定高度会有一条1像素宽的细缝,或者相邻切片交界处出现同一行文字被切了一半又重复的情况。
原因:步长用的是浮点数的Viewport高度,但纹理坐标和anchoredPosition都是整数精度。浮点累加会产生0.1像素的误差,滚动到最后累积成1像素的错位,反映到拼接处就是一条缝。反过来,如果步长小于Viewport高度,切片会有意外重叠,同一内容就会被截两次。
解决:步长取整。循环里的scrolledY和stepHeight都转成int,content.anchoredPosition只赋整数值。另外,截取区域时的x、y、w、h也要取整,避免ReadPixels对区域做四舍五入。对于已经产生细缝的旧图,可以把切片横向重叠一个像素,拼接时在左右各裁掉1像素再做合并,但治标不治本,不如从源头改整数。
5.5 截图时有其它UI遮挡:Loading、弹窗、特效全被截进去
现象:跑截图时屏幕上有个“加载中”的转圈,或者一个未关闭的弹窗,这些元素被完整截进长图底部。
原因:逐帧滚动拼接本质是截屏,屏幕上任何可见物体都会进图。Scroll View之外的UI如果没有隐藏,就会出现在对应位置的切片里。
解决:开始截图前,把非ScrollView的UI全部隐藏。我一般会维护一个“截图模式”开关:记录所有CanvasGroup或GameObject的Active状态,截图开始时全部关掉,结束后恢复。要注意那些全屏特效、角色立绘、粒子系统,它们也存在屏幕空间,隐藏列表时很容易漏掉。最稳妥的做法是截图期间用一个专用的空Canvas盖住整个屏幕,同时把原Canvas的SortingOrder降下来,不过这会影响后续截图区域,需要设计好层级。工程实践上,给所有非目标UI挂一个标识组件,截图前按标识禁用,比逐个手拖引用更省事。
6. 进阶:用离屏RenderTexture一次性烘出完整长图
逐帧拼接适合通用场景,但如果你要截的Content高度只有一千多像素,或者项目里根本没有Scroll View,只是想把一张超长的图渲染出来,离屏渲染就快得多。思路是临时把Canvas改成Screen Space - Camera,让一个专用相机把Content完整渲染到一张比视口大的RenderTexture上,然后ReadPixels整张RT。
关键参数有三个。第一,RT的高度必须等长于Content的实际像素高度,可以用content.sizeDelta.y乘以canvas.scaleFactor得到,但记得限制在设备纹理上限内。第二,相机必须正对Canvas,如果Canvas在场景里带有旋转,相机的变换要和Canvas的平面完全对齐,否则最终渲染出来的图会透视变形。第三,Canvas临时改成Camera模式后,原来的Overlay层级会变化,所有其它UI和特效的显示顺序都会受影响,所以这个方法适合内容较少、场景干净的模块,不适合塞满弹窗和特效的复杂UI。
离屏渲染的好处是速度快,一次搞定,没有切片拼接缝;缺点是改Canvas状态带来的副作用很多。我自己的习惯是:优先用第3、4章那种逐帧拼接,除非性能测试真的证明它慢到不可接受,才切换到离屏方案。曾经有个项目因为Content里嵌套了好几层ScrollView,逐帧拼接滚动了三十多次,被测试报告判定耗时太长,我换成离屏渲染后,总时长控制在两秒以内,代价是花了大半天处理Canvas层级问题。
如果你已经在用逐帧拼接,一个很实用的验证习惯是:把第一张和最后一张切片在Unity里用RawImage预览出来检查。正常情况下第一张应该能看到Content最顶部,最后一张能看到最底部。如果最后一张出现了空白或者重复内容,优先检查步长取整和movementType,这两个参数导致的异常很容易从首尾切片上看出来。这个习惯帮我省了很多次发版后才发现长图不对的尴尬。
希望帮到你。
本文还有配套的精品资源,点击获取