做移动端Unity开发的人,迟早会遇到录屏、截图、导Gif这类需求。不管是做社交分享、游戏高光时刻回放,还是AR试戴后保存一段小视频,都属于“看起来很基础,真做起来一堆坑”的活。如果你还停留在调系统原生API或者硬写RenderTexture去拼帧,那效率确实有点低。
我这边长期在移动端项目里做录制相关功能,市面上常见的方案基本都摸过一遍,从原生Android MediaRecorder/iOS ReplayKit,到Unity自带的Recorder窗口,再到RenderTexture自研方案,最后稳定下来用的是NatCorder这套插件。这篇文章就围绕NatCorder,从选型逻辑讲到三个核心功能——视频录制、JPG拍照、Gif动图生成,完整拆解一遍,里面有可直接抄作业的代码,也有我在真机上踩出来的坑。
1. 插件选型与整体设计:为什么最终落在NatCorder上
1.1 三个主流方案的真实对比
先说说市面上能选的路子。
第一类是用Unity自带的Recorder,这个玩意儿编辑器里用起来确实爽,录制Game视图,一键出片。但它是为编辑器离线录制设计的,打着包跑到手机上根本没接口调用。移动端玩家要录像,这条路直接堵死,唯一能做的是在编辑器里产出一些宣传素材。
第二类是自己调用Android和iOS的原生接口。Android那边用MediaRecorder,iOS用ReplayKit。优点是完全可控、性能好、代码不依赖任何第三方库。但代价极其惨烈:你需要写Android的Java/Kotlin层、iOS的Objective-C/Swift层,再通过UnitySendMessage或者传到C++层回调C#,两边权限管理、生命周期处理、前后台切换全得自己扛。最恶心的是,一旦设备厂商在某个系统版本改了行为,你的原生层就得跟着改,维护成本非常高。
第三类是封装插件。NatCorder就是这一类,它把各家平台的底层录屏能力统一封装成一套C#接口。你在C#里写一套代码,它自动帮你适配Android的MediaCodec、iOS的VideoToolbox,甚至是WebGL。这样上层业务逻辑完全跨平台,这是它最大的价值。
从成本角度看,原生方案表面上省了一笔插件钱,但算上开发和维护时间,反而更贵。尤其团队里没有人精通双端原生开发时,用NatCorder换来的时间收益是实打实的。
1.2 NatCorder的核心设计逻辑
NatCorder的API设计很有意思,核心概念就是三个东西:Recorder、Clock、Input。
Recorder负责编码和写入文件。你要MP4就用MP4Recorder,要Gif就用GIFRecorder,抽象得非常干净。Clock负责帧节奏,内置了RealtimeClock实时时钟,也有FixedIntervalClock给固定帧率录制用。Input负责把图像和音频数据送进Recorder,CameraInput可以一条龙抓相机画面和麦克风音频,也可以手动用Frame.FromCamera从相机抓单帧,传给Recorder的CommitFrame方法。
这套设计的好处是分层清晰。如果你想在录视频的时候把UI也录进去,或者想要分屏效果,只要自己拼好RenderTexture,再通过Frame送入Recorder就行,不需要改任何底层逻辑。
1.3 适用场景与功能边界
NatCorder适合的典型场景有这么几类:游戏内精彩时刻回放、用户生成内容的录制分享、试穿试戴类App的结果保存、教育类App需要把操作过程录下来、以及简单的拍照和动图导出。
需要注意它的边界。它负责的是“录制并编码”这一段,不是“视频编辑”。剪辑、加滤镜、加特效这些不需要指望它。另外Gif录制虽然可以做,但它本质上是对每一帧编码成Gif格式,文件大小和帧率关联非常紧密,后面我会讲怎么控体积。
2. 环境搭建与基础配置:一步步把插件跑起来
2.1 导入插件与版本选择
NatCorder在Unity Asset Store里可以直接拿到,搜索NatCorder就能找到。下载后通过Package Manager导入或者直接把整个目录拖进项目的Assets文件夹都行。
需要注意的是版本兼容性。Unity 2019.4以上基本没问题,我用的Unity 2021.3 LTS跑起来非常稳。如果你项目还在用老版本Unity,最好先看一眼插件文档里的最低版本要求。
导入之后,确认Scripting Runtime Version设置成.NET 4.x Equivalent。NatCorder内部大量使用了C#的async/await,如果项目还在.NET 3.5会让部分API不可用。设置路径在Player Settings -> Other Settings -> Configuration -> Scripting Runtime Version里面。
这里有一个实战提示:最好把NatCorder相关脚本放在Assets目录下专门的文件夹里,同时开启Assembly Definition。这样编译的时候可以单独管理依赖,后面如果要做热更新或者分包,会省很多事。不过新手阶段可以不用搞这么复杂,默认放Assets就行。
2.2 移动端平台设置与权限处理
Android平台需要注意Target API Level,建议在Android 10以上。插件本身会在内部申请存储权限,但实测下来,如果项目里的主场景在Android 6.0以上跑,最好还是弹一次运行时权限申请,避免出现录制结束但写入失败的情况。
iOS平台的部署目标建议设在iOS 11以上,NatCorder底层用的是VideoToolbox,旧版本系统上容易出兼容性问题。
还有一个容易踩的坑是Audio Capture。如果你在录视频时需要同时录麦克风声音,iOS上必须在Info.plist里添加NSMicrophoneUsageDescription,否则系统会直接杀掉App。Android那边,如果只是录游戏内部AudioSource的声音,不需要额外权限;如果要录麦克风,也需要在Manifest里声明RECORD_AUDIO并动态申请。
我实际项目里通常的做法是录制前统一检查一下权限状态,缺失就弹出申请框,用户同意之后再开始。下面这段代码可以直接放在按钮点击的处理里。
#if UNITY_IOS // iOS上没有动态权限判断,只要plist配置好了,首次使用会弹窗 #elif UNITY_ANDROID if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.Microphone)) { UnityEngine.Android.Permission.RequestUserPermission(UnityEngine.Android.Permission.Microphone); } #endif3. 三大核心功能实操:录制、拍照、Gif全流程
3.1 场景搭建与UI按钮布局
动手写代码之前,先在场景里准备一个主角。随便创建一个Cube或者放一个实际要录制的模型,再放一个用来录制画面的相机。
界面我习惯用UGAI来做,Canvas下放三个按钮,分别绑上开始录制视频、停止录制视频、拍照三个事件,如果需要Gif录制再加两个按钮。录制的过程中建议在屏幕上显示一个红点或者计时文本,让用户知道当前处于录制状态。后面代码里会用Debug.Log输出录制状态,真机上跑的时候可以挂一个Text组件来展示提示信息。
3.2 视频录制完整实现
视频录制是NatCorder最核心也是最常用的功能。完整实现分为三步:创建MP4Recorder和CameraInput、开始录制、停止并保存文件。
先看完整的脚本结构。我按“一个Manager类管理所有媒体操作”的方式来组织,方便以后扩展。
using UnityEngine; using UnityEngine.UI; using System.IO; using System.Threading.Tasks; using NatSuite.Recorders; using NatSuite.Recorders.Clocks; using NatSuite.Recorders.Inputs; public class MediaCaptureManager : MonoBehaviour { [Header("相机引用")] public Camera captureCamera; [Header("视频参数")] public int videoWidth = 1280; public int videoHeight = 720; public int frameRate = 30; public int audioSampleRate = 44100; public int audioChannelCount = 2; [Header("Gif参数")] public int gifWidth = 480; public int gifHeight = 270; public float gifFrameDuration = 0.1f; [Header("UI提示")] public Text statusText; private MP4Recorder mp4Recorder; private CameraInput cameraInput; private RealtimeClock realtimeClock; private GIFRecorder gifRecorder; private bool isRecordingVideo = false; private bool isRecordingGif = false; private void SetStatus(string message) { Debug.Log(message); if (statusText != null) { statusText.text = message; } } public void StartVideoRecording() { if (isRecordingVideo) { SetStatus("已经在录制视频了"); return; } isRecordingVideo = true; realtimeClock = new RealtimeClock(); mp4Recorder = new MP4Recorder( videoWidth, videoHeight, frameRate, audioSampleRate, audioChannelCount ); cameraInput = new CameraInput(mp4Recorder, realtimeClock, captureCamera); cameraInput.StartRecording(); SetStatus("开始录制视频..."); } public async void StopVideoRecording() { if (!isRecordingVideo) { SetStatus("当前没有在录制视频"); return; } isRecordingVideo = false; var videoPath = await cameraInput.FinishRecording(); SetStatus($"视频保存路径: {videoPath}"); } }这里有一个关键点:为什么不直接调用mp4Recorder.FinishWriting(),而是用cameraInput.FinishRecording()?
因为CameraInput内部既管视频帧,又管音频数据。直接调mp4Recorder.FinishWriting()会把录制的封装逻辑打断,正确做法是让CameraInput通知Recorder结束编码。返回的videoPath是字符串,里面是实际文件路径,可以直接拿来播放、分享或者上传。
关于分辨率选择,我默认给了1280x720,这个分辨率在移动端是性能和画质的最佳平衡点。你如果要录制完整的竖屏UI界面,可能需要换成1080x1920这样的竖屏分辨率,但那样会明显增加内存占用和编码压力,待会儿会单独说明。
3.3 拍照JPG功能的实现
拍照这个需求其实分两种:一种是直接把当前屏幕截下来,另一种是只把某个相机画面截下来。NatCorder官方主打的是录视频,但我们可以用它提供的Frame机制,也可以用Unity原生RenderTexture方案。
我比较推荐后者,因为拍照本质上是一次性的操作,不需要持续编码,用RenderTexture更轻量、更好控制画质。
public void CapturePhoto() { int width = Screen.width; int height = Screen.height; var rt = RenderTexture.GetTemporary(width, height, 24); var oldTargetTexture = captureCamera.targetTexture; captureCamera.targetTexture = rt; captureCamera.Render(); var oldActiveTexture = RenderTexture.active; RenderTexture.active = rt; var texture = new Texture2D(width, height, TextureFormat.RGB24, false); texture.ReadPixels(new Rect(0, 0, width, height), 0, 0); texture.Apply(); RenderTexture.active = oldActiveTexture; captureCamera.targetTexture = oldTargetTexture; RenderTexture.ReleaseTemporary(rt); var jpgBytes = texture.EncodeToJPG(90); var path = Path.Combine(Application.persistentDataPath, $"photo_{System.DateTime.Now:yyyyMMdd_HHmmss}.jpg"); File.WriteAllBytes(path, jpgBytes); Destroy(texture); SetStatus($"照片保存路径: {path}"); }这段代码有三个注意点。
第一,RenderTexture.GetTemporary比new一个RenderTexture更高效,系统会从池子里复用显存,用完一定要ReleaseTemporary还回去,否则内存只增不减。
第二,ReadPixels之前必须把RenderTexture.active设置成目标RT,否则读到的永远是屏幕当前激活的缓冲区内容。读完之后要记得恢复原来的active目标,我代码里已经写了保存和恢复的逻辑。
第三,EncodeToJPG的第二个参数是画质,范围0到100。实测下来85到95之间视觉差异非常小,但文件体积差距明显,一般90比较好用。
如果遇到场景里还有UI需要截取的情况,可以把Canvas的RenderMode设置成ScreenSpaceCamera,并把renderCamera指向captureCamera,这样UI和3D内容会一起渲染到同一个RT里,一张图就搞定了。
3.4 GIF动图生成实现
Gif录制是NatCorder里相对特殊的一个分支。它不需要音频,只需要持续地把帧提交给GIFRecorder。
先看完整实现。
public void StartGifRecording() { if (isRecordingGif) { SetStatus("已经在录制Gif了"); return; } isRecordingGif = true; gifRecorder = new GIFRecorder(gifWidth, gifHeight, gifFrameDuration); SetStatus("开始录制Gif..."); } public void RecordGifFrame() { if (!isRecordingGif || gifRecorder == null) return; var frame = Frame.FromCamera(captureCamera, gifFrameDuration, null, gifWidth, gifHeight); frame.Upload(); gifRecorder.CommitFrame(frame); frame.Dispose(); } public async void StopGifRecording() { if (!isRecordingGif) { SetStatus("当前没有在录制Gif"); return; } isRecordingGif = false; var gifPath = await gifRecorder.FinishWriting(); gifRecorder = null; SetStatus($"Gif保存路径: {gifPath}"); }GIFRecorder的构造函数参数含义很明确:第一个和第二个是输出图像的宽高,第三个是每帧之间的间隔时间,单位是秒。如果你要10fps的Gif,那frameDuration就填0.1;要15fps就填0.0667。
RecordGifFrame必须在每一帧或按固定频率手动调用。通常情况下放在Update里,每次调用都从相机抓一帧提交给Gif Recorder。但要注意,如果Update本身跑得很快,比如60fps,而Gif目标只有10fps,会产生很多重复帧,文件明显变大。
所以更合理的做法是控制调用频率,我项目的做法是用一个计时器累加时间,到了设定间隔才抓帧。
private float gifTimer = 0f; void Update() { if (isRecordingGif) { gifTimer += Time.deltaTime; if (gifTimer >= gifFrameDuration) { gifTimer -= gifFrameDuration; RecordGifFrame(); } } }用FixedIntervalClock理论上也可以,但GifRecorder配合手动的提交节奏更直观,录制时长也更容易控制。从实测结果看,NatCorder的Gif输出质量相当不错,颜色还原度够,但体积确实会比MP4大不少,后面会说怎么压低体积。
4. 移动端性能优化与内存管理
4.1 分辨率和帧率怎么选
移动端录制最大的敌人是发热和卡顿。如果把录制分辨率和游戏渲染分辨率设成一样,比如同时是2K,那GPU的压力会翻倍。我自己用的策略是:录屏分辨率走独立档位,不跟Screen.width走。
视频录制的话,游戏如果跑60fps,录制帧率不用跟着60,30就非常够看了。Gif录制更夸张,10到15fps足够,因为Gif的动态效果本身就带有大量的时间冗余,帧率太高纯粹是浪费体积。
分辨率方面,竖屏真人出镜类内容用720x1280,横屏游戏画面用1280x720,方形贴纸类动图用512x512。如果纯粹是论坛或者聊天工具里的分享,甚至可以压到480x270,因为小尺寸Gif在手机端放大看会糊,但胜在文件小、生成快。
4.2 内存与纹理释放要点
NatCorder录制过程中,每帧都会从GPU拷贝一份纹理数据交给编码器。如果录制分辨率很高,这个拷贝的开销非常可观。我实测录1080P的时候,每帧拷贝和编码大概要消耗4到8毫秒,如果主线程Update里有其他耗时逻辑,很容易出现顿卡。
所以优化手段就是降低录制分辨率,尽量使用RenderTexture作为中间缓冲,避免在C#层反复new Texture2D。
还有一点是Frame的Dispose。每次Frame.FromCamera拿到的是一个句柄,用完了不Dispose,帧数据会堆积在内存里。轻则内存在录制过程中持续涨,重则直接触发低内存崩溃。
音频方面,如果游戏不需要录麦克风,建议把MP4Recorder构造参数里的采样率和声道数传低一些。44100和2声道是CD音质,在移动端游戏录制场景里其实有点奢侈,换成22050和1声道能明显降低音频处理的负担。视觉和听觉重点不同,音频降采样后几乎听不出来差别。
4.3 不同平台的细微差异
Android上NatCorder使用的是MediaCodec,播放器和系统编码器对H.264的支持都很好,录出来的MP4可以直接在系统相册里播放。但有些国产ROM对写入公共目录的权限卡得非常严格,我一般会直接把文件写到Application.persistentDataPath,需要分享的时候通过系统分享面板传出去,这样绕开了直接访问公共存储的问题。
iOS上VideoToolbox的编码效率非常高,录1080P 60帧的发热控制也比Android好很多。但iOS上的文件路径管理更严格,不能用File.WriteAllBytes往相册里写,正确做法是把文件写到沙盒,再调用保存到相册的面板。
WebGL平台我用过一两次,NatCorder有对应的后端实现,但因为浏览器环境限制,文件只会在内存里生成,然后通过URL.createObjectURL让用户手动下载,体验跟移动端原生保存是两回事。
5. 常见问题排查与避坑指南
5.1 录制出来是黑屏
这个问题我遇到过无数次,大部分情况下不是插件的问题,而是相机配置的问题。
检查顺序是这样的:先确认captureCamera在Inspector里确实有赋值,不是空引用;再确认相机没有开启遮挡剔除或者Culling Mask把主场景的层给剔除了;最后检查Frame.FromCamera或者CameraInput内部的相机参数是否跟实际相机一致。
有一个很隐蔽的坑:如果场景里存在多个相机,其中一个负责渲染UI且Depth值较高,那么录制的相机有时候拍不到UI层。解决方案是把UI相机的Depth调低,或者把UI相机也切成ScreenSpaceCamera模式并挂到录制的相机身上。
5.2 音画不同步
音画不同步是录制类接口的经典问题。NatCorder用了时钟机制来保证同步,所以问题往往出在你手动提交帧的时候没有遵循统一的时间节奏。
如果你用的是RealtimeClock,那系统会根据真实时间自动管理视频和音频的对应关系。如果你自己在Update里固定间隔提交帧,又没设置正确的timeScale,那么录制时间轴就会和真实时间错位。解决办法是,除非有特殊需求,否则视频录制尽量用CameraInput配合RealtimeClock。
Gif没有音轨,自然不存在音画同步问题,但你提交帧的时间间隔必须和你设置的frameDuration一致,否则Gif播放速度会失真。
5.3 Gif文件体积过大怎么办
Gif格式本身就不适合承载大动态、高分辨率的视频内容,一张480x270、10fps、录5秒的动图,轻松能到5MB以上。
压体积从三个方向入手:降分辨率、降帧率、减少录制时长。我现在项目里的Gif分享就固定用480x270、8fps、最长5秒,一张图控制在2MB以内。
还有一个技巧是录制过程中尽量保持画面简单,不要有大幅度的摄像机晃动和复杂噪点纹理。Gif压缩对大面积纯色非常友好,纹理越乱体积越大,这一点和MP4的编码特性不太一样。
5.4 Android和iOS上偶发崩溃
录制过程中崩溃,最常见的原因是FinishWriting还没执行完,游戏App就退到后台了。移动系统在后台会迅速回收资源,编码过程直接被砍掉,文件就坏了甚至崩溃。
解决方法是在OnApplicationPause和OnApplicationFocus里检测录制状态,一旦发现暂停就主动结束录制。
private void OnApplicationPause(bool pauseStatus) { if (pauseStatus && isRecordingVideo) { StopVideoRecording(); } if (pauseStatus && isRecordingGif) { StopGifRecording(); } }还有一个容易忽略的问题:某些低端Android机型在录制过程中同时加载场景资源,会产生明显卡顿甚至ANR。项目里如果要做这个功能,尽量在录制开始前把所有资源加载好,录制过程中不要频繁Instantiate和Destroy大物体。
5.5 文件路径记不住怎么办
我用NatCorder这么多次,最花时间的不是功能实现,而是文件管理。录制结束之后返回一个路径,这个路径可能很长,而且不同平台格式不同。
建议封装一个小工具,在拿到路径后自动做三件事:打印日志、把路径存到PlayerPrefs或内存列表、用系统分享面板把文件传出去或直接在相册里展示。下面是一段非常实用的保存与分享函数。
private void SaveAndShare(string filePath) { #if UNITY_ANDROID using (var intent = new AndroidJavaObject("android.content.Intent", "android.intent.action.SEND")) { var uri = new AndroidJavaObject("android.net.Uri"); var file = new AndroidJavaObject("java.io.File", filePath); intent.Call<AndroidJavaObject>("setType", "image/*"); intent.Call<AndroidJavaObject>("putExtra", "android.intent.extra.STREAM", uri.CallStatic<AndroidJavaObject>("fromFile", file)); // 启动系统分享面板的逻辑略 } #elif UNITY_IOS // iOS相册保存逻辑略 #endif }原生层调起来比较繁琐,按需封装就行。
6. 扩展思路:录制视频后的二次处理和分享链路
功能做到这里,已经满足日常录制需求了。但如果想让功能更实用,还可以做一个分享按钮,把录制完成的文件直接丢给微信、钉钉、系统相册等第三方App。NatCorder本身不含分享逻辑,需要自己调原生分享接口。
思路是录制结束后拿到文件路径,Android上用Intent触发分享面板,iOS上用UIActivityViewController。两个平台的代码都可以封装在Unity的Native层或直接用AndroidJavaObject调Java层。好在只需要传一个文件路径,交互层不复杂。
如果要更进一步的视频剪辑,比如从一段录好的MP4里截取几秒再加个过渡效果,那就需要接FFmpeg之类的方案了。NatCorder专注在录制这一段,没必要硬扩展成剪辑工具。我目前的处理方式是录制完成直接分享,用户需要的简单裁剪交给系统相册的能力去完成。
另外,如果你的项目里有热更新需求,NatCorder因为是纯C#接口加少量原生桥接,可以很容易地放到热更资源里动态加载。不过需要注意,Android上部分原生库是编译进aar的,热更新包只能更新C#层逻辑,不能替换底层库文件。
最后分享一点自己的习惯
用过一段时间NatCorder之后,我现在对移动端录制类功能的处理流程已经固定下来了:进游戏先判断设备性能档位,中低端机强制720P录制,高端机可以上1080P;录制时间超过30秒自动分段,避免单文件过大导致编码时间太长;每次录制结束立刻释放Recorder和Input,绝不让多个录制实例同时存活。
还有个小技巧:录制视频时给mainCamera多加一个轻微的抗锯齿效果,录出来的画面观感提升明显,而且编码体积会稍微小一点。这是因为编码器对平滑的边缘处理效率更高,边缘锯齿越多,码率浪费越严重。
NatCorder这套方案整体上是稳的,感兴趣的可以拿我的代码先跑一遍Demo,再根据你自己的业务场景去调参数。真正上线前,建议至少准备3台不同品牌的真机做一轮验证,把性能和兼容性问题提前揪出来。