
简介这是一份面向 Unity 开发者的完整工程资源适合有一定 GUI 与脚本编程基础、希望实现 Scroll View 滚动列表内容连续截图并合成为长图保存到本地的开发者。资源围绕滚动列表长图导出的实际需求覆盖协程驱动滚动位置、逐帧调用屏幕捕捉 API、利用 Texture2D 拼接合成完整长图、处理图片边缘对齐等关键环节同时给出导出 PDF 的扩展思路和性能优化建议能帮助开发者理解连续截图与图像处理的核心原理。包内包含 Unity 工程文件、C# 源码、场景配置、文本说明等资料便于对照学习。资源共 2000 个文件以 md 文档、bin 二进制资源、txt 说明和 cs 脚本为主并含 asset、meta、png 等工程文件压缩包整体约 716.55MB文件类型较为完整。目前已有 162 人学习适合用作功能参考或进一步改造为项目内的截图导出工具。1. Unity Scroll View 连续截图滚动列表变长图先搞清四个字“裁掉的内容”做 Unity 项目时最常被产品追着要的功能之一就是把排行榜、聊天记录、订单流水这类 Scroll View 列表一键导出成长图。很多人第一反应是调ScreenCapture.CaptureScreenshot按下按钮截一张图就完事——但截出来的只有当前视口里那一屏列表下面滚不到的内容全没了产品看一眼就会打回来说“榜单有 120 行你这图里只有 8 行”。Scroll View 连续截图的难点本质上不是截图 API 怎么调而是 Scroll View 的 viewport 把超出可视区域的 UI 全部裁掉了而截图工具只能截“屏幕上看得到的像素”。所以这篇要解决的核心问题就一个:怎么把被 viewport 裁掉的内容一段一段捞回来再拼成一张完整长图保存到本地。适合在 Canvas 上做界面导出、生成分享图的 Unity 开发内容不依赖具体业务 UI排行榜、背包、聊天列表都能直接套。2. 截图方案选型分段拼接 vs 离屏渲染差别在透明通道和全屏闪烁2.1 分段拼接代码量少坑全在对齐最常见、也最容易跑通的做法是写一个协程把 ScrollRect 的 Content 按固定步长往下滚一段、截一张图滚到底后把若干张截图拼成一张长图。这个方案的核心依据是:Unity 的 UI 只要在 Canvas 下且没有被 viewport 裁剪就会正常渲染到屏幕上所以当 Content 滚动到某个位置时当前可视区域的内容是可截取的。分段拼接的好处是代码不依赖任何平台 APIEditor 下和真机都能跑也不需要额外相机。缺点是拼接时像素对齐要自己做遇到 ScrollRect 自带弹性回弹、惯性滑动时Content 位置不稳定会直接截出重复行或丢行。另外ScreenCapture.CaptureScreenshotAsTexture()截的是整个屏幕如果 Canvas 是 Screen Space - Camera 模式会把世界相机渲染的东西也截进来后面裁剪逻辑就得多处理一层。2.2 离屏渲染适合透明背景但解决不了 viewport 裁剪离屏渲染的做法是做一个单独的相机把要截的 UI 放到指定 Layer相机 TargetTexture 指向一张 RenderTexture最后ReadPixels读出像素。很多人以为离屏渲染可以“绕过屏幕”直接截到 Scroll View 全部内容——这是误解。Scroll View 的 viewport 裁剪是靠RectMask2D或者Mask在 Shader 层面做裁剪的这个裁剪和屏幕还是离屏没有关系裁剪区域外的 UI 根本不会提交像素。所以离屏渲染也得先处理 viewport 问题要么临时把 viewport 拉大到 Content 大小要么把 Mask 禁用掉。那离屏渲染的优势在哪?一是能截透明背景的 PNG分段拼接截的是屏幕屏幕背景色会带进图里透明通道基本拿不到;二是截图分辨率可以自定义不依赖设备屏幕尺寸;三是不会像CaptureScreenshot在部分 Android 机型上出现“闪烁一帧”的现象因为离屏渲染发生在 RenderTexture 上不经过屏幕呈现。2.3 选型条件按导出场景决定不按喜好决定我一般这样判断:如果导出图最终要贴到聊天、社交平台或者要覆盖在别的背景上必须要有透明通道走离屏渲染;如果只是保存到本地、发朋友圈、打印分段拼接更快性能也更容易控。还有第三个维度的参考——列表内容是不是动态加载的。如果 Scroll View 用的是按需实例化 Item比如只实例化可视区附近的数据分段拼接会因为滚动触发数据重排截出来的内容可能前一张和后一张之间少了几个 Item。这种情况必须先确认数据源是否一次性全量加载是虚列表就得先把实例化范围放开或者走离屏渲染配合禁用 Mask 的方式。选型时还要看 Editor 还是真机调试。Editor 下CaptureScreenshotAsTexture截的像素分辨率跟随 Game 视图如果 Game 视图和 Build 分辨率不一致导出图上字会发虚。真机上这个问题不明显但 Android 上连续调用截图 API 确实有概率触发 Surface 重绘表现为导出过程中屏幕闪一下。我自己做的时候Editor 调试用分段拼接发版给用户的功能走离屏渲染两套代码共用拼接和保存逻辑。3. 协程截图脚本落地滚动步进、重叠像素与长图拼接参数3.1 初始化参数先把关键尺寸算清楚先强调一个前提:下面的脚本适用于 Content 在 Scroll View 内一次性完整实例化的场景Content 的 pivot 建议保持 (0.5, 0.5)anchor 保持 stretch。脚本做的事是:记录起始位置 - 按步长滚动 - 截屏 - 继续滚动 - 截到底 - 拼接 - 保存。核心是步长和帧数的计算步长决定了截多少张图也决定了拼接时是否会有遗漏。using System.Collections; using System.IO; using UnityEngine; using UnityEngine.UI; public class ScrollViewLongCapture : MonoBehaviour { [Header(绑定)] public ScrollRect scrollRect; public Canvas rootCanvas; [Header(截图参数)] public float startDelay 0.2f; public int overlapPixels 8; // 相邻两张图重叠的像素行数 public float stepFactor 0.9f; // 每次滚动 viewport 高度的 90% public bool restorePosition true; // 截完恢复原滚动位置 [Header(输出)] public string fileName scroll_capture.png; private Texture2D[] slices; private Vector2 originalPos; public void StartCapture() { StartCoroutine(CaptureSequence()); } private IEnumerator CaptureSequence() { yield return new WaitForEndOfFrame(); RectTransform content scrollRect.content; RectTransform viewport scrollRect.viewport; originalPos content.anchoredPosition; // 内容总高度和可视区高度单位是 Canvas 像素 float contentHeight content.rect.height; float viewportHeight viewport.rect.height; if (contentHeight viewportHeight) { Debug.LogWarning(内容没有超出可视区不需要分段截图); yield break; } // 实际能滚动的距离 float maxScroll Mathf.Max(0f, contentHeight - viewportHeight); // 每次滚动多少像素控制截图张数 float stepPx viewportHeight * stepFactor; // 需要截多少张最后一张可能不满一屏 int frameCount Mathf.CeilToInt(maxScroll / stepPx) 1; slices new Texture2D[frameCount]; // 每张截图之间让 UI 稳定一帧避免半渲染状态 for (int i 0; i frameCount; i) { float targetY Mathf.Min(maxScroll, i * stepPx); ApplyScroll(targetY); scrollRect.StopMovement(); yield return new WaitForEndOfFrame(); slices[i] CaptureCurrentFrame(); } if (restorePosition) { ApplyScroll(0f); scrollRect.StopMovement(); } Texture2D combined CombineSlices(slices, overlapPixels); SaveTexture(combined, fileName); Debug.Log($保存完成, 尺寸 {combined.width} x {combined.height}); } private void ApplyScroll(float y) { if (scrollRect.verticalNormalizedPosition 0f) { // 防止 elastic 模式下越界 scrollRect.velocity Vector2.zero; } Vector2 pos scrollRect.content.anchoredPosition; pos.y y; scrollRect.content.anchoredPosition pos; } }stepFactor是控制截图像素重叠量的关键参数0.9 意味着每次滚动可视区高度的 90%相邻两张图之间自然有 10% 的内容重复。这样即使滚动定位有 1~2 像素误差拼接时也能通过重叠区域对齐避免丢行。overlapPixels是拼接时真正裁剪掉的像素行数和stepFactor是两个维度:step 控制“拍多少张”overlap 控制“拼的时候裁掉多少重复行”。如果 overlap 设 0两张图边界会紧贴浮点取整误差会造成黑线或内容错位。overlap 设 8~16 像素拼接逻辑会从下一张图第 overlap 行开始取留出误差缓冲。3.2 截图与拼接CaptureScreenshotAsTexture 和 SetPixels 的配合截图和拼接是这套方案里最容易出幺蛾子的两个环节。截屏这里我用ScreenCapture.CaptureScreenshotAsTexture()它返回一张整个屏幕的 Texture2D直接同步可用不像CaptureScreenshot(path)还要等文件系统异步写完适合连续多次调用。但要注意它截的是整个屏幕如果你的 Canvas 是 Overlay 模式且是全屏 UI不需要裁剪;如果不是全屏需要按 viewport 的屏幕坐标裁一刀。private Texture2D CaptureCurrentFrame() { Texture2D full ScreenCapture.CaptureScreenshotAsTexture(); Rect viewRect GetViewportScreenRect(); int w Mathf.FloorToInt(viewRect.width); int h Mathf.FloorToInt(viewRect.height); Texture2D crop new Texture2D(w, h, TextureFormat.RGBA32, false); Color[] pixels full.GetPixels( Mathf.FloorToInt(viewRect.x), Mathf.FloorToInt(viewRect.y), w, h); crop.SetPixels(pixels); crop.Apply(); Destroy(full); return crop; } private Rect GetViewportScreenRect() { // 把 viewport 的世界坐标转屏幕坐标Overlay 模式下中心点即屏幕坐标 Vector3[] corners new Vector3[4]; scrollRect.viewport.GetWorldCorners(corners); if (rootCanvas.renderMode RenderMode.ScreenSpaceOverlay) { return new Rect(corners[0].x, corners[0].y, corners[2].x - corners[0].x, corners[2].y - corners[0].y); } // ScreenSpaceCamera 模式需要转换到屏幕坐标 Vector2 min RectTransformUtility.WorldToScreenPoint(rootCanvas.worldCamera, corners[0]); Vector2 max RectTransformUtility.WorldToScreenPoint(rootCanvas.worldCamera, corners[2]); // WorldToScreenPoint 以左下角为原点和 Texture2D.GetPixels 的 y 方向一致 return Rect.MinMaxRect(min.x, min.y, max.x, max.y); }这里最容易被忽略的是坐标原点方向。WorldToScreenPoint返回的坐标以屏幕左下角为原点y 向上;Texture2D.GetPixels的 y 参数也是从图片底部开始算。两个方向一致直接传就行。如果你习惯了 UI 坐标从左上角往下到这里会翻车——我之前就是没换算裁出来的图整体上下颠倒。坐标换算完拼接就是纯像素操作:第一张图整段拿来之后每张图从overlapPixels行开始取因为重叠区在上一张图里已经截过了。private Texture2D CombineSlices(Texture2D[] slices, int overlap) { int width slices[0].width; int height slices[0].height; int totalHeight height; for (int i 1; i slices.Length; i) { totalHeight slices[i].height - overlap; } Texture2D result new Texture2D(width, totalHeight, TextureFormat.RGBA32, false); int yOffset 0; for (int i 0; i slices.Length; i) { Texture2D s slices[i]; int startY (i 0) ? 0 : overlap; int copyHeight s.height - startY; Color[] block s.GetPixels(0, startY, width, copyHeight); result.SetPixels(0, yOffset, width, copyHeight, block); yOffset copyHeight; // 每拼一张就释放不然内存峰值会很高 Destroy(s); } result.Apply(); return result; }拼接逻辑的overlap不是简单地把两张图重叠部分“盖在一起”而是从上一张的尾部开始下一张从overlap行往后取。这样即使滚动定位有误差只要误差小于 overlap 行最终长图里就不会出现内容缺失或重复。拼接完的result是一张超长图宽度等于 viewport 宽度高度是内容区总高。这里要提醒一点:SetPixels是按行从下往上排的所以 result 的底部是截图序列的第 0 张对应滚动起始位置符合直觉不用额外翻转。3.3 保存 PNG 与关键参数速查保存用EncodeToPNG()再写文件这一步在 PC、Android、iOS 上通用。我习惯把保存路径暴露成参数Editor 下用Application.dataPath同级目录真机用Application.persistentDataPath避免用户没有权限写外部存储。private void SaveTexture(Texture2D tex, string name) { string dirPath Application.persistentDataPath /CaptureOutput/; if (!Directory.Exists(dirPath)) { Directory.CreateDirectory(dirPath); } string filePath Path.Combine(dirPath, name); File.WriteAllBytes(filePath, tex.EncodeToPNG()); #if UNITY_EDITOR Debug.Log(截图已保存到: filePath); #endif }几个参数的经验值:列表项高度如果很小比如 50 像素以内步长建议设0.85foverlap 设16f因为行高小的时候 1 像素误差就可能导致文字被切半;普通排行榜 Item 高度 80~150 像素stepFactor 0.9f, overlap 8f足够;内容高度超过 5000 像素时建议把 step 调大到 0.95减少截图张数否则拼接循环里GetPixels的拷贝量会吃掉大量内存。帧率方面一次 8000 像素高的列表步长 0.9、viewport 高度 800大约要截 12 张左右协程逐帧执行2 秒内能跑完用户不会觉得卡。4. 画质与文件细节DPI 写入、透明背景、内存峰值控制4.1 重叠像素的选择和边界情况overlap 选 8 还是 16核心看两步之间实际位移的误差。ScrollRect 的anchoredPosition是 float设置值会被赋值到 Transform但在不同机型上UI 重建和 Canvas 顶点数据更新可能差一帧。WaitForEndOfFrame保证的是“这一帧渲染已经完成”但 Canvas 的脏标记刷新有时会延迟到下一帧。如果你的 Item 里带富文本、图集多、或者有ContentSizeFitter强烈建议 overlap 设 16 以上反正多截的像素会在拼接时裁掉不会造成内容重复。只有一种情况 overlap 不能覆盖——滚动一步跨越了超过一个 Item 的高度也就是 Item 高度大于viewportHeight * (1 - stepFactor)。这种时候截图之间会整个丢掉一个 Item改 overlap 没用必须调小 stepFactor。4.2 把 DPI 写进 PNGEncodeToPNG 的隐藏短板Texture2D.EncodeToPNG()生成的 PNG 文件默认不写 pHYs 块也就是没有 DPI 信息。Windows 照片查看器和微信发送原图一般没关系但你要把长图交给打印店、或者让用户在 Word/PDF 里插入图片DPI 缺失会导致图片被按 96dpi 解读打印出来尺寸完全不对。Unity 没有官方 API 写 DPI但 PNG 格式允许在 IHDR 之后插入一个 pHYs chunk 来声明像素密度。常见的做法是保存后用二进制方式往 PNG 文件里插入这个 chunk。public static void WritePngWithDpi(Texture2D tex, string path, int dpi 300) { byte[] png tex.EncodeToPNG(); byte[] result InsertPhysChunk(png, dpi); File.WriteAllBytes(path, result); } private static byte[] InsertPhysChunk(byte[] png, int dpi) { int idatIndex FindChunk(png, IDAT); uint ppm (uint)Mathf.RoundToInt(dpi / 0.0254f); // 像素/米 byte[] chunkData new byte[9]; WriteUIntBE(chunkData, 0, ppm); WriteUIntBE(chunkData, 4, ppm); chunkData[8] 1; // 单位是米 using var ms new MemoryStream(); ms.Write(png, 0, idatIndex); WriteUIntBE(ms, 9); // pHYs 数据长度 9 ms.Write(new byte[] { (byte)p, (byte)H, (byte)Y, (byte)s }, 0, 4); ms.Write(chunkData, 0, 9); byte[] crcInput new byte[13]; System.Buffer.BlockCopy(new byte[] { (byte)p, (byte)H, (byte)Y, (byte)s }, 0, crcInput, 0, 4); System.Buffer.BlockCopy(chunkData, 0, crcInput, 4, 9); WriteUIntBE(ms, Crc32(crcInput)); ms.Write(png, idatIndex, png.Length - idatIndex); return ms.ToArray(); } private static int FindChunk(byte[] png, string name) { // PNG 签名 8 字节, 之后是 IHDR 块, 从第 8 字节开始遍历块 int offset 8; byte[] typeName new byte[4]; while (offset 8 png.Length) { int len (png[offset] 24) | (png[offset 1] 16) | (png[offset 2] 8) | png[offset 3]; System.Array.Copy(png, offset 4, typeName, 0, 4); string s System.Text.Encoding.ASCII.GetString(typeName); if (s name) { return offset; } offset 12 len; // 跳到下一个 chunk } return -1; } private static uint Crc32(byte[] data) { uint crc 0xFFFFFFFF; foreach (byte b in data) { crc ^ b; for (int i 0; i 8; i) { uint mask 0u - (crc 1u); crc (crc 1) ^ (0xEDB88320u mask); } } return ~crc; }ppm的计算是dpi / 0.0254f因为 1 英寸等于 0.0254 米300dpi 对应的像素密度是 11811。这个值和 Windows 图片属性里显示的 300dpi 是一致的。Crc32是 PNG 规范里指定的 CRC-32 算法多项式 0xEDB88320 是反射形式uint 运算在 C# 里要用0u - (crc 1u)来构造掩码直接写- (crc 1)会溢出报错。这段代码抄下来就能用但注意它只适用于标准 PNG,Unity 的 EncodeToPNG 输出都是标准 8 位 RGBA PNG没有遇到兼容性问题。4.3 透明背景和内存峰值控制透明背景需求必须走离屏渲染方案分段拼接截屏幕拿不到透明通道。离屏渲染时注意 Canvas 的 Sorting Layer 和相机 culling mask 要配对相机只渲染目标 UI 的 Layer。渲染到 RenderTexture 后ReadPixels的 Rect 必须从 (0,0) 开始读整个 RenderTexture读出来就是透明背景。然后你仍然要处理 viewport 裁剪的问题:一个比较省事的做法是截图前把 viewport 的 sizeDelta 临时设置成 content 的大小并关掉RectMask2D组件等截完再恢复。这样 RenderTexture 里就是完整的列表内容不需要滚动直接一次截完。代价是 UI 会短暂地不裁剪用户能看到列表“溢出”一帧。由于相机渲染不经过屏幕实际上用户看不到这个瞬间只是内存峰值会比较高——一张 1080x8000 的透明 PNG运行时内存至少 35MB 起步大列表要做好分批处理或临时降低分辨率。5. 避坑Scroll View 连续截图踩过的五个坑5.1 截图之间出现明显的黑线或白缝现象:拼出来的长图每隔一段就有一条横贯的黑线位置正好是两张截图的交界处。原因:GetPixels的 y 参数取整误差。viewport 高度换算成像素后如果出现小数比如 735.6Unity 会四舍五入截取而滚动位移用的是 Canvas 坐标系的 float和像素坐标系对不上交界处就有一条没有内容覆盖的缝隙。解决:拼接时不按整张图高度对齐而是按上一张图的底部行号对齐。给overlapPixels留足余量后从startY overlap开始取下一段;同时在拼接循环里把上一张图的最后 1 行丢弃强制从下一张图的overlap行接上缝隙就消失了。5.2 ScrollView 弹性回弹导致内容错位现象:连续滚动截图时截到一半列表突然自己弹回去了后面的截图内容和前面重复长图里同一段数据出现了两次。原因:ScrollRect开了movementType Elastic当 Content 滚到底部时回弹动画会改变anchoredPosition。协程里设置 targetY 后没有立刻停住回弹把位置拉走了。解决:每次设置滚动位置后调用scrollRect.StopMovement()把velocity清零;如果还不行截图期间临时把movementType改成Clamped截完恢复。回弹动画本质上是带动量的运动anchoredPosition赋值后一帧内可能被回弹逻辑覆盖StopMovement要在赋值后同帧调用。5.3 截到半渲染状态的 UI文字发虚或图标缺一角现象:长图上某些 Item 的文字比实际模糊或者 icon 只截了一半。原因:WaitForEndOfFrame保证的是渲染管线结束但 Canvas 的脏标记刷新有自己的调度。如果截图帧刚好在 Canvas 重建之后、GPU 提交之前屏幕上的内容可能是上一帧的。解决:在滚动位置设置之后额外等一个yield return null再WaitForEndOfFrame让 Canvas 有时间处理布局变更。对于带ContentSizeFitter的 Item,要等两帧因为 SizeFitter 的布局计算是异步的。5.4 ScrollRect 的 OnValueChanged 回调在截图期间被反复触发现象:列表项里如果监听了ScrollRect.onValueChanged做加载更多、隐藏悬浮按钮等操作截图期间这些回调大量触发有的还会打断 UI 状态。原因:每截一张图就要滚动一次每次滚动位置变化都会触发一次onValueChanged事件。如果你的业务逻辑在回调里做了耗时操作截图协程会被卡顿甚至会因为重入而中断。解决:截图开始前用一个 bool 标志位屏蔽业务回调截完再恢复。比移除监听更安全的是在回调函数最前面判断_captureInProgress这样业务逻辑不感知截图这个过程只跳过自己。注意恢复时不要手动触发一个假的回调来刷新 UI直接让按钮状态自然刷新即可。5.5 长图过大Android 上打开照片应用一片黑现象:拼接完的 PNG 在电脑上看没问题但传到手机相册打开图片显示全黑或者提示图片损坏。原因:部分 Android 机型的图片解码器对超大图片尤其高度超过 8000 像素以上解码内存峰值会超过应用可用内存系统直接放弃解码返回黑色或者解析失败。解决:导出长图前先按目标用途决定尺寸——发微信群聊的长图建议高度控制在 8000 像素以内宽度保持原样;如果内容确实很长分段导出多张图或者在拼接时做一次缩放。Unity 里缩放Texture2D用ScaleTexture逐像素采样性能较差可以把 Texture 画到 RenderTexture 上再ReadPixels,GPU 扛得住代码也简单。6. 从长图到 PDF 与验证两条转换路和一个像素级自查习惯导出的长图往往不是终点不少项目要求“保存成 PDF方便用户打印或归档”。Unity 本身没有 PDF 输出能力需要走平台能力或者第三方库。我这边实践过两条路:一条是 iOS 上直接调用原生UIGraphicsPDFRenderer把长图按 A4 比例切割后每页渲染一张图全程不需要额外库但只在 iOS 端有效而且要在 Xcode 工程里加原生桥接文件。另一条是导出 PNG 后接入 PDFsharp 之类的 .NET 库在 Windows 或服务器端合成 PDF适合做多平台导出中心。从 Unity 侧看最省事的做法是先把长图按 A4 竖版比例切成多段每段生成一个 PDF 页面。切割算法其实就是复用前面拼接的部分逻辑把长图按固定高度分页分页之间留一点重叠避免表格线被切断。验证工作往往是这部分最容易跳过的。导出完成后我会在代码里加一个自检:按固定行号取原列表里的数据项在长图对应坐标读取像素颜色判断是否和期望的 Item 背景色一致。这个验证不能替人眼检查排版但能快速发现拼接错位、丢行这类严重问题。我自己的习惯是每调整一次滚动步长或者 overlap 参数就导出一次小图用 PS 或者看图工具把相邻两张截图的拼接边界放大到 400% 检查一遍文字是否连续。这个动作看起来繁琐但确实救过我几次——有一次就是 Item 高度取了带小数的值步进累积误差到了第 5 张图直接错开 3 个像素文字被截断光看缩略图完全看不出来。从那以后我每次改 UI 尺寸都会强制重新跑一遍快速导出 放大验证的流程。这套坑踩完之后再看这类需求其实就一句话:Scroll View 连续截图不是截不截得出来的问题而是滚动位置控制、重叠像素策略、裁剪边界处理三者之间能不能对齐的问题参数调稳了剩下的就是按业务场景选分段拼接还是离屏渲染。希望这篇能帮你少走这一段弯路。本文还有配套的精品资源点击获取