
简介面向需要实现界面长图导出功能的Unity开发者这份资源围绕滚动视图内连续截图并合成保存为本地长图的核心需求系统梳理了从控制内容滚动、逐帧调用屏幕捕捉接口到利用纹理对象进行图像拼接与边缘对齐的完整流程也讨论了移动端和电脑端的性能优化与导出交互设计。压缩包共2000个文件以Markdown说明文档、二进制数据、配置文件、资源文件及C#脚本等为主整体体积约716.55MB目录结构清晰便于按脚本、文档、配置等模块检索。目前已有163人学习下载。资源内提供了可落地的协程滚动截图思路、长图拼接注意事项、导出进度与保存位置提示等实现细节同时涉及PDF导出时第三方库选型与页面合并方法能帮助开发者绕过常见坑点快速将截图保存功能集成到自己的项目中。1. 先看场景为什么大家都说Scroll View不能直接截长图有一次需求是做赛季排行榜导出分享图200 个玩家要截成一整张长图。第一反应是调ScreenCapture.CaptureScreenshot结果截出来的只有当前屏幕那一点点滚动区域全是空白。原因很简单Scroll View 的 Viewport 上挂了 Mask可视区之外的内容被裁剪掉了屏幕截图 API 只能截屏幕截不到画布外的东西。这篇笔记把“Unity 在 Scroll View 内连续截图并保存本地”这件事拆开讲核心是两条实现路径第一条用协程模拟滚动画布每滚一屏截一次屏最后把多张图拼成长图第二条临时把 Viewport 的 Mask 关掉把整个 Content 一次性渲染到 RenderTexture。前者兼容性好后者速度快、质量高。适合正在做排行榜导出、聊天记录存图、UI 内容分享图或者长截图工具的人新手能照着跑通老手重点看参数和坑。这个功能真正的难点不在“截图”API而在三步坐标换算逻辑像素和屏幕像素、拼接对齐步长必须整除、以及 Mask 裁剪截到的不一定是滚动区内容。2. Scroll View为什么截不出长图Mask裁剪与两条截图路径2.1 三层结构ScrollView、Viewport 与 Content先明确 Unity 里Scroll View的标准结构Scroll View节点下面套一个ViewportViewport下面才是Content。Viewport上挂着RectMask2D新版本默认或者Image Mask旧版本这个 Mask 就是让你看不到 Content 超出视口范围的元凶。Content 本身往往挂着Content Size Fitter和Vertical Layout Group高度由子物体撑开子物体数量决定截图内容的总高度。这里有一个容易忽略的事实Content 下的大量子物体不一定是同时存在的。如果用ObjectPool或者常见的列表优化方案Unity 只会实例化可视区域附近的 item滚到哪里才实例化到哪里。这意味着什么意味着你截图前得先确认内容都“活”了否则长图中间会出现大段空白。后面避坑部分专门有一条讲这个。从渲染路径看UI 有两种绘制方式Screen Space - Overlay模式直接画在屏幕上不经过相机Screen Space - Camera模式则会把 UI 交给指定的相机渲染。这条差异直接决定了截图代码怎么写——Overlay 下你能用ReadPixels读屏幕Camera 模式下你可以借 RenderTexture 把画面导出来。两条路背后其实是同一件事的两种做法要么把屏幕当目标要么把纹理当目标。2.2 两条截图路径逐帧滚动拼接 vs 解遮罩整体渲染第一种思路是“模拟滚动 逐帧采集”。用协程把 Content 的位置往下挪一个视口高度等一帧让 UI 刷出来然后用ReadPixels把当前视口区域截下来最后把每张截图按顺序拼成一张大图。这个方案最贴近用户手动滚动的观感对 Scroll View 的结构几乎没要求缺点是逐帧采集受屏幕分辨率限制而且拼接时如果步长算错就会出现缝隙。第二种思路是“解遮罩 一次渲染”。既然桃子里面的桃核是 Mask 裁剪那直接临时把 Viewport 上的 Mask 关掉把 Canvas 渲染模式切到Screen Space - Camera用一个正交相机把整个 Content 画进 RenderTexture再读出来。这个方案理论上只渲染一帧速度快得多出图分辨率不受屏幕限制可以按 Content 实际像素尺寸导出。风险在于切换 Canvas 渲染模式会触发 UI 重建嵌套 Scroll View 的 Mask 可能关不干净。两条路并不互斥很多项目是两套都写小列表走方案 A大列表或者需要超高清导出时走方案 B。下面两章分别给出完整可跑的代码。2.3 方案选型对照对比维度方案A逐帧滚动拼接方案B解遮罩整体渲染实现难度低纯协程ReadPixels中需要临时切换 Canvas 渲染模式截图分辨率受屏幕/视口尺寸限制可按 Content 实际尺寸设置可更大速度慢滚几屏截几张快一帧渲染出图动态加载列表需要预热或等待实例化必须先强制实例化所有 item最大风险拼接错位、白缝Mask 关不干净、UI 重建副作用推荐场景普通排行榜、消息记录长图文案、高分辨率分享图这里还要补一个常识Texture2D 在绝大多数平台有最大尺寸限制常见是 4096 或 8192。方案 B 导出超高长图时Content 高度超过上限就会出现黑图或者保存失败。这个我在第 6 章给出分块保存的处理方式。3. 方案A逐帧滚动拼接协程采集视口像素的完整代码3.1 滚动步长怎么定不能随便写死100像素拼接长图的前提是每一张截图覆盖的区域首尾相接不重叠也不遗漏。要做到这一点滚动步长必须严格等于截图区域的实际像素高度。常见做法是用 Viewport 的 RectTransform 在世界坐标中的四个角算出宽高再取整作为步长。public RectTransform viewport; public RectTransform content; public ScrollRect scrollRect; // 计算视口的实际屏幕像素尺寸 public Vector2 GetViewportScreenSize() { Vector3[] corners new Vector3[4]; viewport.GetWorldCorners(corners); int w Mathf.RoundToInt(corners[2].x - corners[0].x); int h Mathf.RoundToInt(corners[2].y - corners[0].y); return new Vector2(w, h); }这里的逻辑是GetWorldCorners在Screen Space - Overlay模式下返回的已经是屏幕像素坐标corner[0]是左下角corner[2]是右上角两者相减得到的就是截图像素宽高。为什么要取整而不是直接强转因为 DPI 缩放或者 Canvas Scaler 计算后这个数值可能是带小数的浮点拼接时差 1 个像素都会造成错位或白缝。取整后后面所有滚动偏移、ReadPixels 的 Rect 都基于同一个整数才能保证对齐。我一般还会顺带把scrollRect.movementType临时设成Clamped并关掉inertia。否则滚到边缘时 Elastic 回弹会让截图内容不在预期位置。3.2 采集协程滚一屏截一张核心协程写法如下public float scrollStep 0; // 会在 Start 里自动算 IEnumerator ExportScrollViewLongImage() { // 记录初始位置结束后恢复 Vector2 initPos content.anchoredPosition; Vector2 viewportSize GetViewportScreenSize(); float step scrollStep 0 ? scrollStep : viewportSize.y; int totalHeight Mathf.CeilToInt(content.rect.height); int piecesCount Mathf.CeilToInt(totalHeight / step); // 临时关闭惯性避免滚动位置漂移 bool lastInertia scrollRect.inertia; scrollRect.inertia false; ListTexture2D pieces new ListTexture2D(); for (int i 0; i piecesCount; i) { // 每步向下滚动一屏 float y initPos.y - step * i; content.anchoredPosition new Vector2(initPos.x, y); // 等一帧让 ScrollRect 完成布局刷新 yield return new WaitForEndOfFrame(); // 截取当前视口区域 Texture2D shot CaptureViewport(); pieces.Add(shot); } // 恢复滚动状态 scrollRect.inertia lastInertia; content.anchoredPosition initPos; // 拼接并保存 Texture2D final StitchPieces(pieces, (int)step); byte[] png final.EncodeToPNG(); File.WriteAllBytes(Path.Combine(Application.persistentDataPath, long_capture.png), png); }这里的参数和顺序都值得注意。step必须是视口像素高度不能取 Content 高度均分因为最后一张可能不满一屏。content.rect.height是 Content 的总高度除以 step 向上取整得到截图张数。向下滚动用的是initPos.y - step * i方向取决于 Content 的 pivot如果 pivot 在顶部这个方向就是正确的如果 pivot 在底部需要改成initPos.y step * i。稳妥的做法是在计算前打一条 Debug 日志确认滚动方向。还有个细节协程里每一步之后都要yield return new WaitForEndOfFrame()。原因有两个一是 UI 布局在下一帧才真正刷新二是在 Overlay 模式下ReadPixels必须等帧末屏幕缓冲写完才能读到正确画面。跳过这一步截出来的图很可能是旧画面或者黑图。3.3 截取视口画面ReadPixels 的坐标换算Texture2D CaptureViewport() { Vector3[] corners new Vector3[4]; viewport.GetWorldCorners(corners); int x Mathf.RoundToInt(corners[0].x); int y Mathf.RoundToInt(corners[0].y); int w Mathf.RoundToInt(corners[2].x - corners[0].x); int h Mathf.RoundToInt(corners[2].y - corners[0].y); Texture2D tex new Texture2D(w, h, TextureFormat.RGBA32, false); tex.ReadPixels(new Rect(x, y, w, h), 0, 0); tex.Apply(); return tex; }这个方法的参数含义是屏幕坐标原点在左下角x和y是视口矩形左下角在屏幕上的位置w和h是矩形宽高。ReadPixels支持负坐标吗如果视口有一部分在屏幕外会读到 0 像素这种情况下要么截屏前强制 Scroll View 完全可见要么用 Clamp 限制坐标范围。另外如果你 Canvas 用的是Screen Space - Camera模式那GetWorldCorners返回的是世界坐标必须先转屏幕坐标否则截出来是错位的。常见做法是Canvas canvas GetComponentInParentCanvas(); if (canvas.renderMode ! RenderMode.ScreenSpaceOverlay) { Vector2 screenPoint RectTransformUtility.WorldToScreenPoint(canvas.worldCamera, corners[0]); }这个细节在新手手上基本必踩因为编辑器默认是 Overlay手机上很多项目为了 UI 特效会切成 Camera 模式一换模式代码就全错。3.4 拼接长图处理最后一张不满屏的情况拼接的原理是把每张截图按顺序贴到一张大图上但要注意 Unity 的 Texture2D 像素坐标系是从下往上的。第一张截图是页面顶部它应该贴在长图的最上方也就是fullH - shot.height的位置。Texture2D StitchPieces(ListTexture2D pieces, int pieceStep) { int w pieces[0].width; int fullH pieceStep * (pieces.Count - 1) pieces[pieces.Count - 1].height; Texture2D result new Texture2D(w, fullH, TextureFormat.RGBA32, false); int cursorY fullH; foreach (Texture2D piece in pieces) { cursorY - piece.height; // 把当前截图标到长图的指定 y 位置 result.SetPixels(0, cursorY, piece.width, piece.height, piece.GetPixels()); } result.Apply(); return result; }fullH的计算是关键前面每一张高度都是完整的pieceStep只有最后一张可能不满所以总高度 步长 × (张数 - 1) 最后一张实际高度。SetPixels的第一个参数和第二个参数是目标位置的左下角坐标cursorY从长图顶部往下减保证第一章截的图在最上面。要注意的是如果手机 DPI 缩放导致实际截屏宽高和逻辑像素不一致pieceStep必须在计算时和CaptureViewport的宽高用同一个来源。这里最容易翻车的场景是步长用viewport.rect.height算截图尺寸用GetWorldCorners算两边差了几像素拼接处就出现细缝。4. 方案B解遮罩一次渲染关掉Mask把整个Content画进RenderTexture4.1 递归关闭 Mask 链只关Viewport上那一个是远远不够的方案 B 的核心是把裁剪去掉让 Content 完整地画出来。第一步就是关 Mask。但我第一次写的时候只关了 Viewport 上的RectMask2D结果图片边缘还是被裁了——因为父级 Scroll View 外面还套了一层 Mask。所以正确做法是从 Canvas 往下递归把路径上所有Mask和RectMask2D都临时禁用。void SetMaskEnabled(Transform node, bool enabled) { var rectMask node.GetComponentRectMask2D(); if (rectMask ! null) rectMask.enabled enabled; var mask node.GetComponentMask(); if (mask ! null) { mask.enabled enabled; // Mask 一般挂 Image关闭 Mask 后 Image 的 sprite 会暴露出来需要一并处理 if (!enabled) { var img node.GetComponentUnityEngine.UI.Image(); if (img ! null) img.enabled false; } } foreach (Transform child in node) SetMaskEnabled(child, enabled); }这里有个坑Mask组件依赖Image来画遮罩图形只关 Mask 不关 Image裁剪虽然没了但 Image 本身还会渲染一层带 alpha 的色块盖在上面。我一般把 Image 也一起关掉导出完再恢复。RectMask2D没有这个依赖可以直接关。递归禁用会动到整个 Canvas 下的所有子节点所以调用前必须做好状态备份导完立刻恢复。如果中间有 Tween 或者 LayoutGroup 在跑改状态可能引起重排建议先停止相关动画。4.2 把 Canvas 临时切到 Screen Space - Camera用正交相机渲染到 RT关键代码在下面。这条路径上最容易犯的错误是Canvas 还是 Overlay 模式就开始渲染相机结果得到的是一张黑图。Overlay 模式不经过相机RenderTexture 里什么都画不出来。public Texture2D CaptureContentToTexture(RectTransform content, Canvas canvas) { // 1. 先解遮罩 SetMaskEnabled(canvas.transform, false); // 2. 记录原状态 RenderMode lastMode canvas.renderMode; Camera lastCam canvas.worldCamera; // 3. 建一个只渲染 UI 层级的正交相机 GameObject camGo new GameObject(ExportCam); Camera cam camGo.AddComponentCamera(); cam.orthographic true; cam.cullingMask 1 5; // UI Layer cam.clearFlags CameraClearFlags.SolidColor; cam.backgroundColor new Color(0, 0, 0, 0); cam.nearClipPlane -1000; cam.farClipPlane 1000; camGo.transform.position new Vector3(0, 0, -100); // 4. 设置 Canvas 渲染模式 canvas.renderMode RenderMode.ScreenSpaceCamera; canvas.worldCamera cam; // 5. 设置 RenderTexture尺寸按 Content 实际像素 int w Mathf.CeilToInt(content.rect.width); int h Mathf.CeilToInt(content.rect.height); RenderTexture rt RenderTexture.GetTemporary(w, h, 24, RenderTextureFormat.ARGB32); cam.targetTexture rt; // 6. 渲染一帧 cam.Render(); // 7. 读取像素 Texture2D tex new Texture2D(w, h, TextureFormat.RGBA32, false); RenderTexture.active rt; tex.ReadPixels(new Rect(0, 0, w, h), 0, 0); tex.Apply(); RenderTexture.active null; // 8. 还原现场 cam.targetTexture null; canvas.worldCamera lastCam; canvas.renderMode lastMode; SetMaskEnabled(canvas.transform, true); RenderTexture.ReleaseTemporary(rt); DestroyImmediate(camGo); return tex; }这里最重要的参数是相机的orthographicSize。上面代码里我没设置它默认是 5只能渲染高度为 10 的世界单位范围。Content 如果高度是 3000 像素相机根本拍不全。正确做法是动态设置cam.orthographicSize h / 2f 20f; // 留一点边距以免边缘被裁剪另外相机位置要放在 Canvas 的正前方也就是把相机 z 轴推到 Canvas 的背面。Canvas 的 RectTransform 在世界空间的原点通常是场景原点相机放(0, 0, -100)然后正交模式下远近不会影响大小只要 z 在裁剪面范围之内就行。nearClipPlane设成负值是为了把 Canvas 画在相机后面也能被拍到常见做法是把相机设nearClipPlane -1000让 Canvas 落在裁剪空间内部。还有一个容易被忽略的点内容尺寸的获取时机。Content 的rect.height如果依赖VerticalLayoutGroup重建直接读可能在布局还没刷新时拿到旧值。切渲染模式本身会触发一次重建所以最好先Canvas.ForceUpdateCanvases()再读尺寸。4.3 方案B的适用边界动态列表与嵌套Scroll View方案 B 在“内容全部实例化”这个前提下非常好用但如果 Content 下的 item 是按需加载的那关掉 Mask 后渲染出来的就是一堆空的占位符。解决办法是在导出前强制把所有模板节点激活foreach (Transform child in content) child.gameObject.SetActive(true);如果 item 是由对象池管理的还得先滚到底再滚回顶让池子把所有 item 都创建出来。这个预热过程会触发大量实例化耗时明显所以方案 B 适合内容长度固定、渲染量可控的场景比如聊天记录、商品列表导出不适合无限下拉流。嵌套 Scroll View 是另一个坑。如果 Content 里还有子 Scroll View递归关闭 Mask 会把它们一起关掉子列表的裁剪全部失效截图里可能会出现混乱的重叠画面。处理方式是在递归接口里加白名单参数只关指定路径上的节点或者干脆把子 Scroll View 临时设为不活动。5. 避坑记录五条高频翻车现场与排查路径5.1 拼出来的长图每隔一屏有一条白缝现象长图拼接处出现横向白线或细黑线每屏一条。原因滚动步长和截图尺寸不一致。最常见的是步长用了viewport.rect.height逻辑像素截图用了GetWorldCorners算出的屏幕像素两者之间差了 1~2 像素。DPI 缩放越高这个误差越明显。解决统一像素来源。定义步长时直接复用GetViewportScreenSize().y的返回值也就是同一个取整后的高度同时用于滚动偏移和拼接总高度。不要在两处分别写计算。5.2 每张截图的内容都没变滚动完全没生效现象协程跑了截图数量也对但每张图内容一模一样。原因改content.anchoredPosition之后没有等布局刷新直接ReadPixels读到的还是上一帧的画面或者ScrollRect.movementType是 Elastic滚动到边缘被弹回原位。解决滚动后先yield return null再yield return new WaitForEndOfFrame()两步都要。前者让 ScrollRect 内部布局重新计算后者保证屏幕缓冲写完。滚动前临时把movementType MovementType.Clamped、inertia false导出完成再恢复。5.3 方案B导出全是黑图或者只有视口那一小块有内容现象用相机渲染 RenderTexture 得到纯黑图偶尔能看到视口区域的 UI其它全黑。原因Canvas 还是Screen Space - Overlay模式UI 不经过相机RT 里什么都没有或者orthographicSize太小相机只覆盖了视口范围。解决把 Canvas 切到Screen Space - Camera并指定导出相机同时把orthographicSize设成h / 2 边距。这两个条件缺一个都出不来完整画面。5.4 长图中间有大段空白item 位置是空的现象拼接后的长图中间有连续几屏空白或者干脆只有第一屏和最后一屏有内容。原因列表用了对象池只有可视区域内 item 实例化了。滚动协程跑太快item 还没实例化完就截了图。解决滚动截图前先做预热——在协程里先快速滚到底再滚回顶部让对象池把所有 item 创建出来。预热结束重置到初始位置后再开始正式截图。或者把步进改为每次滚动后等待 0.1 秒再截图代价是导出时间变长。5.5 长图超过 4096 或 8192保存出来的图片是黑的现象小尺寸测试正常Content 高度超过 4000 后最终 PNG 打开是黑的或保存直接报错。原因Unity 的Texture2D有平台最大尺寸限制超过后EncodeToPNG得不到有效数据。解决用分块保存代替单张大图——把长图按 2000 像素高度切成多段分别编码保存为多个 PNG 文件。展示的时候再用 UIImageView 连续加载。分块代码我放在下一章。6. 长图导出的最后一公里内存控制、进度反馈与批量技巧6.1 分块写入避免单张 Texture 超过平台上限内容高度很容易超过 4000 像素。与其挑战平台上限不如主动分块。做法是把最终长图按固定高度切成若干块每块单独编码保存void SplitAndSave(Texture2D source, int blockHeight, string saveDir) { Directory.CreateDirectory(saveDir); int totalBlocks Mathf.CeilToInt(source.height / (float)blockHeight); for (int i 0; i totalBlocks; i) { int y i * blockHeight; int h Mathf.Min(blockHeight, source.height - y); Color[] pixels source.GetPixels(0, y, source.width, h); Texture2D block new Texture2D(source.width, h, TextureFormat.RGBA32, false); block.SetPixels(pixels); block.Apply(); string path Path.Combine(saveDir, $long_{i:D3}.png); File.WriteAllBytes(path, block.EncodeToPNG()); DestroyImmediate(block); } }GetPixels带四个参数是从指定矩形区域读取像素最后一块的h要取Mathf.Min。这样每一块的尺寸都安全内存压力也小。想再省内存可以在方案 A 里改成“截一屏、写一块”不攒全部再拼接但那样对齐难度更高一般只在超大列表时才用。6.2 导出进度与预热时机连续截图在低端机上可能要跑十几秒没有进度反馈用户会以为卡死。做法是在协程循环里维护一个progress变量更新到 UI 文本上。预热阶段显示“准备内容”正式截图阶段显示“第 x / y 屏”。我一般把预热和截图合并成一个流程预热时也读一下content.rect.height因为只有所有 item 实例化后高度才是准确的。6.3 批量导出与命名规范批量处理多个 Scroll View 时要注意串行执行不要开多条协程同时截图否则 Canvas 渲染模式切换会互相打架。常见做法是把要导出的 Scroll View 放进数组逐个遍历。文件名用DateTime.Now.ToString(yyyyMMdd_HHmmss)加业务 ID 组合避免覆盖。从那以后我每次做 UI 导出功能都强制走一遍这三件事先理清 Mask 链哪些节点会裁剪再固定步长和截图尺寸的像素来源最后测一次长图尺寸上限。这三步能挡住 90% 的翻车问题。希望帮到你。本文还有配套的精品资源点击获取