ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Scroll View滚动列表长图截图:滑窗拼接原理与Unity实现

Scroll View滚动列表长图截图:滑窗拼接原理与Unity实现 简介Unity开发中将滚动视图内的列表内容连续截取为一张长图并保存到本地是界面导出场景里常见又棘手的问题。这份资源围绕这一需求为中等及以上Unity开发者整理了完整实现方案覆盖画布下滚动视图的滚动控制、协程逐帧截图、使用纹理类图像拼接以及长图边缘对齐与性能优化等关键环节也涉及导出为PDF文件时可选第三方库的扩展思路。压缩包为RAR格式共2000个文件大小约716.55MB内部以946个markdown文档、527个二进制文件、155个文本文件和99个json文件为主辅以少量asset、png、xml、cs等资源与脚本目录结构清晰便于按模块检索学习。目前已有162人学习下载。通过该资源读者可快速搭建滚动截图框架掌握滚动内容位置同步与屏幕捕捉接口的配合方法并借鉴作者在图像合成和内存占用方面的排错经验减少重复踩坑。1. 需求长啥样Scroll View 里连续截图本质上是一张滑窗拼贴图很多 Unity 项目做到后期都会冒出一个让人头疼的需求把 Scroll View 里滚动的列表一键保存成一张长图。功能清单、成就墙、商城商品列表、教程步骤甚至排行榜都可能要“截成长图”交付给运营或玩家保存。直接调CaptureScreenshot截一张全屏图会发现列表只拍到了当前可见的一小段其余内容被 Scroll View 的视口裁掉了滚到底再截也只会得到最后那一段中间的列表项全凭运气。问题出在 Scroll View 的渲染机制上它把整个 Content 当作一个超长画布再用 viewport 做矩形裁剪屏幕截图拿到的永远只是这个裁剪窗口。要做的是把 viewport 当成一个“滑窗”让 Content 在窗口里逐段移动每次截取当前可见区域最后按顺序拼成一张完整长图。这个方案不依赖真机分辨率也不要求列表项是等高的只要 Content 的高度测算准、窗口移动步长算对导出质量就能做到像素级对齐。2. 长图截图的底层逻辑一次截图为什么拿不到完整列表2.1 RectMask2D 与滚动裁剪Scroll View 里没滚到的地方根本没被渲染Scroll View 的层级结构一般是ScrollRect - Viewport - Content而 Viewport 上往往挂了RectMask2D或Mask。它的作用很直接把 Content 里超出矩形范围的子物体在渲染阶段直接裁掉。注意这里是“渲染阶段裁掉”不是“画出来再遮住”。GPU 在生成顶点数据时RectMask2D会把视口矩形之外的顶点剔除掉所以屏幕缓冲区里根本没有那些列表项的任何像素。很多人第一次做长图截图时会试着把RectMask2D临时禁用然后把 Content 拉长直接CaptureScreenshot。这个做法只在列表很短时好用比如 Content 高度只有两倍屏幕高度但 Content 一旦超过四五个屏幕高度渲染三角形数量和时间都会暴增而且 Unity UI 的可见性剔除依然会按屏幕边界处理最终截出来的长图尾部大概率是空的。真正可控的做法是保持 Mask 不动把捕捉区域限定在 viewport 对应的屏幕矩形里循环移动 Content 后再截取。还有一点容易忽略ScrollRect 的 Content 经常挂着VerticalLayoutGroup或ContentSizeFitter它的实际高度在布局计算完成前是 0 或旧值。直接读content.rect.height去算分段经常算出 0或者算出少于实际值的高度。这就是为什么连续截图脚本的第一步永远不该是截图而应该是强制刷新布局。2.2 滑窗循环和像素拼接连续截图的正确姿势把 viewport 想象成一台扫描仪上的扫描头Content 则是一张超长的纸。每次只扫描“扫描头”当前覆盖的那一小段纸向前移动一个扫描头的宽度再扫下一段。这个思路落到代码上就是viewport 的高度作为步进单位Content 的anchoredPosition.y每步增加一个 viewport 高度然后抓取 viewport 矩形区域内的画面拼到一张总纹理的对应纵坐标上。这里有个单位换算的细节。viewport 的rect.height是 Canvas 坐标系下的单位不是屏幕像素。Canvas 的Reference Resolution和实际屏幕分辨率之间有一个缩放比比如 Canvas 设计分辨率是 1080x1920实际屏幕是 1440x2560那么一个 Canvas 单位的实际像素大约是 1.333 倍。抓图时要用viewport.GetWorldCorners()拿四个角的世界坐标在ScreenSpaceOverlay模式下这些坐标就是屏幕像素坐标这样才能准确地从全屏截图里切出 viewport 区域。移动 Content 的方式也别直接改scrollRect.verticalNormalizedPosition它的值域是 0 到 1且经过 ScrollRect 内部对边界和像素取整的处理用它做等距步进很容易出现最后几像素偏差。更可控的是记住 Content 初始的anchoredPosition循环里直接写contentRT.anchoredPosition new Vector2(0, i * stepH)循环结束后恢复这样每步移动的距离完全一致。3. 用 ScrollRect 滑窗法实现长图核心脚本和三个参数3.1 先把 Content 高度和视口高度测准否则切段全是错的写连续截图脚本的第一件事不是截图而是等布局稳定。ScrollRect 的 Content 高度由ContentSizeFitter.preferredHeight或VerticalLayoutGroup的子项累加决定Unity 在 UI 树变化后的一帧内才会重算布局脚本刚启动立刻读rect.height拿到的通常是旧值。我一般会在协程开头先调LayoutRebuilder.ForceRebuildLayoutImmediate(contentRT)再执行过一次Canvas.ForceUpdateCanvases()双保险。下面是最小可运行脚本的骨架它可以挂在任意场景对象上通过按钮或游戏逻辑触发连续截图。先看完整代码再解释关键参数不要中途打断。using System.Collections; using System.IO; using UnityEngine; using UnityEngine.UI; public class ScrollLongCapture : MonoBehaviour { public ScrollRect scrollRect; // 要截图的滚动区域 public string savePath; // 本地保存路径末尾带 .png public void StartCapture() { StartCoroutine(CaptureRoutine()); } private IEnumerator CaptureRoutine() { RectTransform viewportRT scrollRect.viewport; RectTransform contentRT scrollRect.content; // 1. 强制刷新布局保证 Content 高度和子项位置是最终值 LayoutRebuilder.ForceRebuildLayoutImmediate(contentRT); Canvas.ForceUpdateCanvases(); yield return null; // 2. 读取两个核心参数内容总高度和窗口高度 float totalHeight contentRT.rect.height; float windowHeight viewportRT.rect.height; // 3. 计算需要截多少段向上取整最后一段可能不满窗 int segments Mathf.CeilToInt(totalHeight / windowHeight); Debug.Log($长图分段数{segments}总高度 {totalHeight}窗口高度 {windowHeight}); // 4. 把 Content 钉在最顶部并且暂时停掉用户拖动 Vector2 originalPos contentRT.anchoredPosition; contentRT.anchoredPosition new Vector2(0f, 0f); scrollRect.StopMovement(); yield return new WaitForEndOfFrame(); // 5. 后续的逐段截取和拼接逻辑会在下一个方法里完成 // 这一步只确保坐标体系正确所以先用空方法占位 } }这里对参数做几点说明。viewportRT.rect.height在 Canvas 单位下取值如果 Canvas 和屏幕的缩放比是 1:1它直接等于截图像素高度如果不是就需要把缩放比乘进去。totalHeight包含 Content 里所有子项加上 padding 和 spacing 后的总高度数值通常不是整像素数所以计算分段数时要Mathf.CeilToInt向上取整保证最后一段不会漏内容。scrollRect.StopMovement()这个调用不能省否则用户拖动产生的惯性会在协程执行期间把 Content 带跑位。3.2 切段循环anchoredPosition 怎么步进才不重不漏步进值是整个方案最核心的决策点。我的习惯是让 Content 每步移动的距离严格等于 viewport 的 Canvas 单位高度不要额外加 overlap也不要做“多截一像素再合并”的补偿。因为 Unity UI 的坐标是连续值viewport 的裁剪边缘和 Content 子项的边缘理论上都能对齐额外 overlap 反而会把上一段末尾的内容带进下一段造成长图里出现重影。如果担心 float 精度导致拼接处出现一条缝正确做法不是加 overlap而是把最后生成的图关闭 mipmap并且在拼接时用整数像素坐标取整。切段循环的写法很简单核心就是把之前占位的部分补上。private IEnumerator CaptureRoutine() { RectTransform viewportRT scrollRect.viewport; RectTransform contentRT scrollRect.content; LayoutRebuilder.ForceRebuildLayoutImmediate(contentRT); Canvas.ForceUpdateCanvases(); yield return null; float totalHeight contentRT.rect.height; float windowHeight viewportRT.rect.height; int segments Mathf.CeilToInt(totalHeight / windowHeight); Vector2 originalPos contentRT.anchoredPosition; contentRT.anchoredPosition new Vector2(0f, 0f); scrollRect.StopMovement(); yield return new WaitForEndOfFrame(); // 每一段截出来的画面都要贴到总纹理的对应位置上 Texture2D longTexture CreateBlankTexture(totalHeight, windowHeight, viewportRT); int pixelWindowHeight Mathf.RoundToInt(windowHeight * GetCanvasScaleFactor(viewportRT)); for (int i 0; i segments; i) { // Content 每次只移动一个窗口高度 contentRT.anchoredPosition new Vector2(0f, i * windowHeight); yield return new WaitForEndOfFrame(); // 当前这一段实际需要截取的高度最后一段可能不满窗 float currentSliceHeight Mathf.Min(windowHeight, totalHeight - i * windowHeight); Texture2D slice CaptureViewportSlice(viewportRT, currentSliceHeight); if (slice null) continue; int yStart pixelWindowHeight * segments - (i * pixelWindowHeight) - slice.height; longTexture.SetPixels(0, yStart, slice.width, slice.height, slice.GetPixels()); } contentRT.anchoredPosition originalPos; SaveTextureToFile(longTexture, savePath); }这段循环的顺序很关键先移动 Content再yield WaitForEndOfFrame最后截图。WaitForEndOfFrame保证渲染管线已经处理完 UI 的顶点更新不会出现移动和截取在同一帧导致画面滞后的情况。yStart的计算决定拼接方向Unity 的SetPixels原点在左下角所以第一段不是贴在最上边而是从总纹理的底部往上堆。如果你保存下来的长图上下颠倒多半就是这里把yStart写成了i * slice.height改正方向即可。3.3 抓取视口区域与保存从 Texture2D 到本地 PNG抓取 viewport 区域可以不用ReadPixels从显卡读全屏而是用ScreenCapture.CaptureScreenshotAsTexture()拿当前屏幕的 Texture2D再用GetPixels取出视口矩形。这样逻辑直观Window 和 Mac 编辑器下都能跑适合做原型验证。下面是抓取方法private Texture2D CaptureViewportSlice(RectTransform viewportRT, float sliceHeight) { Vector3[] corners new Vector3[4]; viewportRT.GetWorldCorners(corners); // ScreenSpaceOverlay 下世界坐标就是屏幕像素坐标 int screenX Mathf.RoundToInt(corners[0].x); int screenY Mathf.RoundToInt(corners[0].y); int screenW Mathf.RoundToInt(corners[2].x - corners[0].x); int screenH Mathf.RoundToInt(corners[2].y - corners[0].y); // Unity 的屏幕原点在左下角ReadPixels/GetPixels 的原点也在左下角 // 但 CaptureScreenshotAsTexture 的坐标体系需要反一下 Y int flipY Screen.height - screenY - screenH; Texture2D screenTex ScreenCapture.CaptureScreenshotAsTexture(); Color[] pixels screenTex.GetPixels(screenX, flipY, screenW, screenH); Texture2D slice new Texture2D(screenW, screenH, TextureFormat.RGBA32, false); slice.SetPixels(pixels); slice.Apply(); // 如果最后一段不满窗还需要按比例裁剪掉多余部分 Color[] trim slice.GetPixels(0, 0, screenW, Mathf.RoundToInt(screenH * sliceHeight / viewportRT.rect.height)); Texture2D result new Texture2D(screenW, trim.Length / screenW, TextureFormat.RGBA32, false); result.SetPixels(trim); result.Apply(); Object.Destroy(screenTex); Object.Destroy(slice); return result; }这段代码里最值得关注的是裁剪高度。假设总高度是 2350窗口高度是 720分段结果是 4前 3 段窗口高度都是 720最后一段应该只有 190 像素左右的内容。如果直接把屏幕视口整块截下来贴上去最后一段会混入 ScrollRect 滑到边界后露出的空白长图底部就多出一条无意义的背景色带。上面用sliceHeight / viewportRT.rect.height做比例裁剪把最后那段的多余像素先裁掉再拼进总纹理。注意裁剪后如果trim.Length / screenW算出来的高度是 0说明布局刷新还没完成需要回看 3.1 节的强制刷新步骤。保存到本地这一步很简单直接写 PNG 文件private void SaveTextureToFile(Texture2D texture, string path) { byte[] bytes texture.EncodeToPNG(); File.WriteAllBytes(path, bytes); Debug.Log($长图已保存{path}); }EncodeToPNG对 RGBA32 格式兼容最好其他格式可能需要先转格式再编码。保存路径在 PC 编辑器下可以随便填放到Application.persistentDataPath更稳妥因为真机上没有写任意路径的权限。4. 避坑Scroll View 连续截图最容易翻车的 5 个场景4.1 截图全是同一段内容滚动没生效现象循环跑了 10 次生成的 10 个切片在内容上完全一样拼出来是一张重复的图。原因最常见的是用了scrollRect.verticalNormalizedPosition做步进而 ScrollRect 在每一帧都会根据内部 velocity 修正 Content 位置导致你设置的值被覆盖或者你设置的 normalized 值本身就不对相邻两段移动距离过小截出来的画面自然没变化。解决直接操作contentRT.anchoredPosition并且在循环开始前调用scrollRect.StopMovement()。如果列表是水平滚动步进方向改成x坐标窗口宽度用viewportRT.rect.width逻辑相同。4.2 第一段是黑图或空图现象长图顶部有空白或者整张图只有最后一个 viewport 的内容。原因协程启动时 Content 的 layout 还没有刷新totalHeight读取的是 0分段数极多坐标计算全乱。另一种情况是移动 Content 后直接截图没有等WaitForEndOfFrame画面还停留在移动前的一帧。解决在测高度之前先LayoutRebuilder.ForceRebuildLayoutImmediate(contentRT)和Canvas.ForceUpdateCanvases()每次移动 Content 之后yield return new WaitForEndOfFrame()不要省这一帧。4.3 拼图边缘出现一条 1 像素的白线或黑线现象每个切片之间的接缝处显示出一根细线放大后能看到上下两段内容没有完全贴齐。原因viewport 的rect.height是 float转成屏幕像素时用Mathf.RoundToInt取整可能导致每段的实际抓取像素高度和yStart计算不吻合。尤其 Canvas 缩放比不是整数时float 取整误差会被累积。解决把 step 步进单位改成整数像素。先拿到 viewport 在屏幕上的像素高度screenH每步让 Content 移动的距离也转换成对应像素再除回 Canvas 缩放比让windowHeight的取值始终是整数像素在 Canvas 单位下的精确表示。如果嫌麻烦也可以每段在拼图前检查slice.height用实际高度反推yStart不依赖预先计算的pixelWindowHeight。4.4 在 Android 上截图结果是黑屏或者权限错误现象同一个脚本在编辑器里正常打包到 Android 后前几段正常后半段全是黑屏或者直接抛ReadPixels相关异常。原因ScreenCapture.CaptureScreenshotAsTexture在部分 Android 设备上依赖 SurfaceFlinger 的读取权限尤其当 Unity 的渲染线程和 UI 线程交错时读取屏幕缓冲区的时机不稳定。解决真机环境尽量改用下一章的 RenderTexture 方案用相机把 出面区域渲染到一张 RenderTexture 上再从 RenderTextureReadPixels不依赖系统屏幕截图能力。至少也要把yield WaitForEndOfFrame改成yield return new WaitForEndOfFrame()后加手动GL.InvalidateState()但更推荐直接迁移方案。4.5 列表太长导致内存暴涨直接闪退现象Content 高度超过 20000 像素、宽度 1080 时RGBA32 长图占用约 86MB 内存再加上切片、屏幕全图和 PNG 编码字节数组峰值轻松超过 300MB中端安卓机直接闪退。原因整个方案里所有 Texture2D 都留在 CPU 内存里SetPixels还会额外复制一份像素数组。解决把“拼成一张长图”改成“逐段保存切片再用非压缩方式合并成文件”。生成 PNG 时不要一次性EncodeToPNG()整张纹理可以每拼好几段就压缩写入磁盘或者把长图高度限制在 4000 像素内。如果业务必须超长图建议在真机上的最终落地使用 RenderTexture 叠加逐段保存而不是一次性堆内存。5. RenderTexture 方案真机上更稳的落地姿势5.1 改造思路把目标区域渲染进 RenderTexture再 ReadPixels如果只是做内部工具上一章的 ScreenCapture 方案完全够用。但发布到 Android / iOS 上或者 list 特别长的时候我一般会改成 RenderTexture 方案。整体思路是把要截图的 Canvas 从ScreenSpaceOverlay改成ScreenSpaceCamera渲染到一个独立 UI 相机这个相机只负责截图用的 Canvas并且把目标纹理设成 RenderTexture。需要注意这个改造会影响原项目的 UI 渲染方式不建议直接把业务里的主 Canvas 改成 ScreenSpaceCamera。常见做法是做一个独立的截图 Canvas把要捕获的内容复制到这个 Canvas 下再配一个专门相机在指定帧渲染。复制 UI 树听起来很重但如果只是临时启用Debug.Log其实开销可控。RenderTexture 的抓取循环和之前的切段逻辑完全一致只是把“抓屏幕”换成了“抓 RT”。private Texture2D CaptureFromRenderTexture(Camera captureCamera, int width, int height) { RenderTexture rt RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32); Texture2D result new Texture2D(width, height, TextureFormat.RGBA32, false); captureCamera.targetTexture rt; captureCamera.Render(); RenderTexture.active rt; result.ReadPixels(new Rect(0, 0, width, height), 0, 0); result.Apply(); captureCamera.targetTexture null; RenderTexture.active null; RenderTexture.ReleaseTemporary(rt); return result; }这段代码的核心优势是稳定它不依赖系统屏幕缓冲Camera 渲染到什么画面RT 里就有什么画面。缺点是 UI 相机渲染的内容需要提前单独配置好而且如果 Canvas 是动态生成的要在每一段截图前明确该相机只渲染当前这一帧的截图 Canvas避免把背景或其他 UI 也拍进去。5.2 真机保存目录PersistentDataPath 与系统相册的取舍保存到本地听起来简单但坑也不少。File.WriteAllBytes写到Application.persistentDataPath是最安全的这个目录在 Android 和 iOS 上都不需要额外权限应用卸载时会被系统清理。如果要把长图保存到系统相册Unity 本身不提供跨平台 API需要通过 Android 的 MediaStore 或 iOS 的UIImageWriteToSavedPhotosAlbum做原生桥接。很多团队在这一步选择直接引入现成的相册保存插件而不是自己写 AndroidJNI因为相册权限在不同版 Android 上要求不一样。一个更省事的做法是长图保留在persistentDataPath同时生成一个小尺寸预览图用户要分享时再通过分享插件拉起系统分享面板。这样既不触发相册权限又能满足“保存到本地”的交付需求。5.3 加一层长图校验用像素对比检查有没有漏行长图导出的最大风险是拼接处错位一整块内容肉眼在手机屏幕上很难发现。真机调试时我习惯在拼接完成后把所有切片的第 1 行像素和上一段切片的最后一行像素做一次相似度对比。正常的滑窗拼接中相邻切片之间不应该有内容重复如果出现两行高度相似说明有两个窗口重叠了。反过来如果像素值突然跳变且前后两段内容不连续说明漏了一段。对比的逻辑很简单把两个矩形的第一行和最后一行分别用GetPixels取出逐像素算差值超过阈值的像素数量占比大于 10% 就报警。private float CompareRows(Texture2D sliceA, Texture2D sliceB, bool compareLastOfA, bool compareFirstOfB) { int yA compareLastOfA ? sliceA.height - 1 : 0; int yB compareFirstOfB ? 0 : sliceB.height - 1; Color[] rowA sliceA.GetPixels(0, yA, sliceA.width, 1); Color[] rowB sliceB.GetPixels(0, yB, sliceB.width, 1); int mismatch 0; for (int i 0; i rowA.Length i rowB.Length; i) { if (rowA[i] ! rowB[i]) mismatch; } return (float)mismatch / Mathf.Max(rowA.Length, rowB.Length); }这个校验不是必选项但建议至少在产品交付前跑一次尤其是动态列表或异步加载图片的列表加载时机错位很容易让某一段内容为空。6. 验证长图三步做法以及一条能救场的兜底习惯长图生成后我会先做一次最基础的数字验证用脚本把图片读回来检查width / height是否和预期一致。预期高度 viewport 像素高度 × 分段数允许最后一小段不足一窗。如果高度对不上说明分段数或步进值有问题根本不需要打开图片看。确认尺寸后再取图中间某个列表项比如一个确定的按钮首尾各截一片对比是否和原 UI 一致这个可以暴露出 yStart 方向写反、坐标翻转这类低级错误。兜底习惯来自我的血泪经验只要是用像素滑窗方案拼长图就一定要保留一份“按子项截图”的备选逻辑。当 Content 里有大量不等高的 Item或者 Item 内部用了异步加载的远程图片时滑窗扫描很容易出现某段加载未完成、截图抓到空内容的情况。这时候我会暂停滑窗逻辑改成遍历 Content 的子物体把每个子物体按它的 RectTransform 底部对齐到 viewport 顶部再截取单个子项区域最后逐个拼接。这种按 item 的方式每段之间没有重叠也不会漏半个 Item只是需要额外处理子项跨窗口的情况但对“确保每个 Item 完整”这个需求来说比像素滑窗可靠得多。希望这条思路能帮你少走一次弯路有这层兜底连续截图这个功能在 Scroll View 里才算真正落地。本文还有配套的精品资源点击获取
返回列表