Unity Scroll View连续截图实战:逐帧拼接与避坑指南

发布时间:2026/9/23 13:07:22
Unity Scroll View连续截图实战:逐帧拼接与避坑指南 简介面向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配置先建立一个最简单的测试场景一个CanvasCanvas下挂Scroll ViewContent里放100个高度固定的List Item每个Item设置一个不同的背景色方便最后验证长图拼接顺序。Canvas采用默认的Screen Space - OverlayCanvasScaler的缩放模式用“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 ListTexture2D slices new ListTexture2D(); [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 GetComponentInParentCanvas(); if (canvas null) return 1f; return canvas.scaleFactor; } }这段代码里有三个地方要特别说明。第一个是movementType。ScrollRect默认有时是Elastic带弹性回弹Content在滚动结束后会被插值拉回导致你设定的位置对不上。截图期间必须临时改成Unrestricted截完再恢复原值。第二个是WaitForEndOfFrame。ReadPixels读的是当前屏幕缓冲如果你在帧中段调用屏幕缓冲可能还是上一帧的内容。协程里先yield WaitForEndOfFrame再调用CaptureViewportRegion才能保证这个等号成立。第三个是stepHeight取整。Viewport.rect.height是浮点像素值不一定是整数。如果直接拿浮点值改content.anchoredPositionUnity内部会做像素偏移相邻切片之间可能多出一个像素或者少一个像素拼接时就会出现斜纹或漏线。所以我在滚动时用Mathf.RoundToInt取了整把步长固定成整数像素。3.3 CanvasScaleFactor为什么不能省很多第一次写的人会在这一步卡住GetWorldCorners返回的坐标在Screen Space - Overlay下并不总是屏幕像素坐标。当CanvasScaler把Canvas整体缩放时Canvas节点的scaleFactor会大于1GetWorldCorners返回的值是“世界单位”需要除以scaleFactor才能换算成真正会被ReadPixels使用的像素坐标。比如参考分辨率1080x1920屏幕实际分辨率是2160x3840scaleFactor可能是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(ListTexture2D 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(长图高度超过4096Unity纹理最大尺寸限制自动切块保存); 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(ListTexture2D 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的计算。第一次循环i0offsetY0yPos totalHeight - sliceHeight也就是长图最顶部。第二次i1yPos 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或者ClampedContent在滚动到边界时会有一个反弹动画此外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()要写对。另外如果你的Canvas有父子嵌套比如多个Canvas组成的UI系统必须拿到实际渲染该UI的那个Canvas不能随手取一个。如果发现偏差就在CaptureViewportRegion里打印一下scaleFactor和屏幕分辨率对比。5.4 拼接出现一条条细缝或者内容重叠现象长图里每隔固定高度会有一条1像素宽的细缝或者相邻切片交界处出现同一行文字被切了一半又重复的情况。原因步长用的是浮点数的Viewport高度但纹理坐标和anchoredPosition都是整数精度。浮点累加会产生0.1像素的误差滚动到最后累积成1像素的错位反映到拼接处就是一条缝。反过来如果步长小于Viewport高度切片会有意外重叠同一内容就会被截两次。解决步长取整。循环里的scrolledY和stepHeight都转成intcontent.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这两个参数导致的异常很容易从首尾切片上看出来。这个习惯帮我省了很多次发版后才发现长图不对的尴尬。希望帮到你。本文还有配套的精品资源点击获取