
最近在做一款相册类App用户需要把选中的十来张照片一键合成一段短视频方便分享到社交平台。这个需求在鸿蒙6.0上怎么做网上的资料比较零散我踩了一圈坑之后觉得值得把整个实现过程完整写下来包括技术选型、核心代码、性能优化和各类异常排查。这篇文章会集中讲解鸿蒙6.0应用开发里“图片合成视频”这条链路重点聚焦MediaKit媒体框架以及AVRecorder加Surface采集这套最值得推荐的方案。适合正在做鸿蒙应用开发尤其是相册、短视频、社交分享类场景的开发者参考不管你是刚接触ArkTS的新手还是已经做过一些媒体功能的老手应该都能从这里找到可以直接落地的思路。1. 图片转视频的需求场景与研发前先想清楚的三件事1.1 这不是“逐张截图拼接”底层的编码链路是什么很多第一次接这个需求的同学会误以为图片合成视频就是把图片按顺序贴到时间线上导出的时候“拼”一下就行。实际上视频的本质是一连串带时间戳的帧而“合成”要做的事情是把一个图片序列按照设定好的帧率连续送进视频编码器编码器输出H.264/H.265压缩码流再封装成MP4文件。这里有几个关键参数是绕不开的分辨率比如720x1280、帧率比如25fps或30fps、每张图的停留时长比如2秒以及是否要转场。换算一下就明白2秒一张图、25fps意味着同一张图片要被作为25帧送入编码器只是每帧画面内容相同。所以“图片合成视频”本质上是一个时间线渲染问题而不是一个简单的文件合并问题。我习惯在动手前先问自己三个问题视频是给用户本地看还是要分享到社交平台是实时预览导出还是纯后台任务有没有音频需求这三个答案直接决定了技术路线和参数配置。如果这三点没想清楚后面大概率要推翻重来。1.2 鸿蒙上可行的三条路径AVRecorder、FFmpeg、屏幕录制在鸿蒙6.0上做图片合成视频我接触过的方案大致有三条。第一原生MediaKit的AVRecorder配合Surface采集。AVRecorder支持将Surface作为视频输入源我们只要把每一帧图片内容绘制到这个Surface上编码器就会自动把Surface上的内容录制下来。优点是走系统硬件编码性能好、功耗低而且产出的MP4兼容性好。缺点是写绘制和渲染的代码有一定门槛后续如果要做转场、字幕都需要自己处理。第二集成FFmpeg。在Native层把图片解码成原始RGBA数据喂给FFmpeg的libavcodec做编码最后封装成MP4。优点是跨平台逻辑可以复用控制粒度最细缺点是在鸿蒙工程里编译FFmpeg、适配NDK接口需要额外工作量而且纯软编在高分辨率下的耗时和发热都比较明显要做硬编还得再对接系统媒体编码能力。第三屏幕录制。利用AVScreenCapture把播放图片的页面录下来。这方案看着简单但它录制的是屏幕上的可见区域应用退到后台、弹窗、状态栏变化都可能污染视频内容一般只适合录操作教学类内容不太适合作为正经的导出功能交付给用户。对比下来我最后选择了第一套方案。原因很直接HarmonyOS的应用生态决定了我们要优先使用系统框架能力AVRecorder是官方推荐的录制API而且能够直接拿到编码后的MP4文件开发和维护成本最可控。1.3 我为什么选了Surface采集方案补充说一下为什么不是直接用AVCodec去硬编码。AVCodec更偏底层你可以自己管理输入buffer把图片数据包装成MediaFormat塞进去。它的灵活性更高但你要处理的事情也多解码PixelMap、转换颜色格式、按帧率排队输入buffer、处理编码器反馈的格式变化……一套搞下来会消耗大量调试时间。而AVRecorder的Surface采集模式把“输入数据”这件事抽象成了一个Surface外部逻辑只需要保证“这个Surface上有画面”即可。等于说编码器自己在Surface后面等着取帧我们不用管buffer排队和时序。这正好契合“图片合成视频”这种周期性提交画面的场景。后面我会详细讲这套链路里最重要的几个细节尤其是surfaceId的绑定方式。很多人就是在这块踩了坑视频录出来全黑半天找不到原因。2. AVRecorder采集链路的核心机制与初始化要点2.1 AVRecorder状态机不要一上来就startAVRecorder不是new完就能用的它有一套明确的状态机。初次创建后是idle状态需要依次走prepare、start、stop、release这几个关键节点而且每个节点都有对应的异步API。我在代码里吃过教训第一次写就直接调start结果抛了状态异常后来仔细看文档才发现必须先prepare。一个标准的初始化流程是这样import { media } from kit.MediaKit; import { BusinessError } from kit.BasicServicesKit; import { common } from kit.AbilityKit; let avRecorder: media.AVRecorder | undefined undefined; let surfaceId: string ; async function initVideoRecorder(context: common.Context, fileName: string): Promisestring { avRecorder await media.createAVRecorder(); const config: media.AVRecorderConfig { videoCaptureConfig: { videoSourceType: media.VideoSourceType.SURFACE_RENDER, videoFrameWidth: 720, videoFrameHeight: 1280, videoFrameRate: 30 }, fileGenerationConfig: { fileGenerateMode: media.FileGenerateMode.GENERATE_MODE_FILE, url: file:// context.filesDir / fileName .mp4, containerFormat: media.ContainerFormatType.CF_MPEG_4 } }; await avRecorder.prepare(config); surfaceId await avRecorder.getInputSurface(); return surfaceId; }注意videoSourceType填SURFACE_RENDER这表示视频数据来自Surface。fileGenerateMode用GENERATE_MODE_FILE并指定url最后就能直接得到一个MP4文件。如果后续想上传云端或做二次处理也可以考虑GENERATE_MODE_APP_DATA这种更灵活的模式不过一般情况用文件模式就够。prepare成功的前提下getInputSurface()返回的surfaceId才是我们后续要和渲染侧对接的钥匙。2.2 视频参数如何定分辨率、帧率、码率的关系这个环节我建议在拿到产品需求后先算一笔账而不是直接拍脑袋设1080p。视频码率的经验公式大致是码率 ≈ 分辨率宽度 * 高度 * 帧率 * 系数系数取决于内容复杂度和编码器效率。同分辨率下画面里如果全是静态图片运动量小码率可以适当降低文件体积就小如果加了滚动、缩放、粒子特效这些高频变化码率就得往上抬不然会出现块状模糊。以我这次做相册场景为例用户手机大多是竖屏输出选720x1280、25fps、目标码率4Mbps左右测试下来清晰度完全够用而且编码速度比1080p理想得多。这里先记住一个原则输出分辨率不一定等于所有图片的原生分辨率也不是越高越好要根据播放场景和设备硬件合理取舍。如果产品那边一定要支持1080p那额外要做的是控制图片解码后的像素尺寸别把一张4000x3000的原图直接丢给渲染管线。至于原因后面讲内存问题时你会看到血泪教训。2.3 getInputSurface返回的到底是什么这是这套方案里最容易让人困惑的一环。getInputSurface()返回的不是一个文件路径也不是一个view控件而是一个图形生产者和编码器之间的桥接SurfaceId。可以简单地把这扇Surface理解为编码器的“收件口”任何把图形内容绘制到这个Surface上的操作都会被编码器捕获并编码成视频帧。所以在ArkUI侧合适的做法是把这个surfaceId交给XComponent或其他能够真正渲染到该Surface的组件或引擎。很多同学卡住是因为不知道surfaceId到底给谁用或者误以为把图片转化成PixelMap后直接“传”给AVRecorder就行。实际上PixelMap不会自己飞进编码器必须通过渲染管线把它画到一个和AVRecorder绑定的Surface上。理解了这一点后面的渲染循环代码就有了方向我们要做的不是“往AVRecorder里塞图片”而是“把图片绘制到与AVRecorder关联的Surface上”。3. 图片解码与渲染循环的工程实现3.1 图片预解码DecodingOptions里最容易忽略的sampleSize用户选中的照片很多是几千万像素的JPG直接解码成PixelMap一张图可能占几十MB内存。如果用户一次性选了20张内存基本就爆了。所以第一步解码就应该做采样缩小。在鸿蒙里用ImageSource的DecodingOptions控制采样import { image } from kit.ImageKit; import { fileIo as fs } from kit.CoreFileKit; async function loadPixelMap(filePath: string, targetWidth: number, targetHeight: number): Promiseimage.PixelMap { const file fs.openSync(filePath, fs.OpenMode.READ_ONLY); const imageSource image.createImageSource(file.fd); const decodingOptions: image.DecodingOptions { desiredPixelFormat: image.PixelMapFormat.RGBA_8888, // 根据目标尺寸估算采样倍数这里先给一个固定值 sampleSize: 2 }; const pixelMap await imageSource.createPixelMap(decodingOptions); imageSource.release(); file.close(); // 如果采样后仍比目标尺寸大再显式缩放到期望尺寸 if (pixelMap.getPixelMapWidth() targetWidth || pixelMap.getPixelMapHeight() targetHeight) { await pixelMap.scale(targetWidth / pixelMap.getPixelMapWidth()); } return pixelMap; }这里的sampleSize不是简单除以2而是指解码时每几个像素采样一个能够显著降低解码后PixelMap的内存占用。需要强调一点createPixelMap之后imageSource要及时release不然后续反复打开图片会累积大量文件句柄导致文件访问异常。实际项目里我通常会提前把“视频需要的图片路径列表”变成“已经降采样好的PixelMap列表”并且只保留当前帧、下一帧再配合一个预加载计数器避免一次性把所有PixelMap都驻留在内存里。这样既能保证渲染不卡也能控制内存峰值。3.2 Surface上绘制PixelMap的两种姿势拿到PixelMap之后怎么把它渲染到Surface上我实际尝试过两种姿势各有适用场景。第一种用ArkUI的XComponent承载surfaceId然后用Canvas或原生绘制能力在组件上绘制。思路是在页面上放置一个XComponent把AVRecorder返回的surfaceId作为这个XComponent关联的Surface然后在这个组件的上下文里把PixelMap绘制出来。这种方式代码层面最贴近UI开发习惯但在连续快速绘制时容易受UI线程调度影响而且如果页面有其他组件覆盖有概率把无关内容录制进去。第二种用Native侧EGL离屏渲染。把AVRecorder的surfaceId作为EGLSurface在Native循环里通过OpenGL ES将PixelMap转成纹理一帧一帧地绘制并执行缓冲交换。这是当前短视频类应用比较成熟的做法画面完全不受ArkUI布局影响后台也能稳定录制。代价就是需要写C代码并处理ArkTS到Native的Bridge工程复杂度高一些。如果你只是做一个MVP验证用第一种完全够如果要交付稳定性要求高的功能建议走第二种。我的做法是折中先用XComponent快速验证流程验证通过后再把渲染部分下沉到Native侧重写。这样既能快速出Demo又能在正式版本里获得稳定帧率。3.3 时间线控制让每张图“踩准”节拍渲染循环的核心是时间线。简单来说是这样async function runTimeline(pixelMaps: Arrayimage.PixelMap, durationMs: number) { const frameIntervalMs 1000 / fps; for (let i 0; i pixelMaps.length; i) { const startTime Date.now(); while (Date.now() - startTime durationMs) { await drawFrame(pixelMaps[i]); await sleep(frameIntervalMs); } } await stopRecorder(); }这里的sleep必须足够精准实际跑下来会发现系统定时器会有几毫秒误差短时间看不出来但录40秒之后可能累积出好几帧偏移。我的做法是使用系统时间戳校准而不是简单累加帧数。另外渲染侧要避免一次性把多张图同时推进Surface否则编码器会认为你在跳帧画面变化会变得突兀。有个容易被忽视的点每一张图停留的时间不应该用“图片数量 × 固定时长”来算最好由产品侧明确“片头、片尾、正片”的不同时长规则。我在早期版本里统一让所有图片停留2秒结果用户反馈片尾的黑场总是不自然后来改成最后一张图多停留0.5秒观感立刻好很多。3.4 结束录制与文件落盘录制收尾阶段一定要按照正确的顺序释放资源async function stopRecorder() { await avRecorder?.stop(); await avRecorder?.release(); }stop之后再release顺序反了会拿不到完整文件。stop成功之后之前配置的url路径下就会生成MP4文件。此时如果应用方需要展示给用户可以直接用文件路径创建播放源。还有一个细节开始录制前尽量先把Surface的第一帧画出来不要让编码器在纯黑状态下启动。虽然AVRecorder会等待Surface首帧到来才开始有效编码但若首帧来得太晚文件时长可能会比预期短。我在项目里会在start之后立即绘制一次首帧背景保证编码器第一时间收到有效画面。4. 踩坑实录黑屏、花屏、卡顿、内存暴涨的完整排查链路4.1 黑屏surfaceId绑定时机与XComponent生命周期第一次跑通流程后我最常遇到的问题就是生成的视频黑屏。排查下来有两个主要原因。第一个是surfaceId绑定时机太晚。如果我在AVRecorder.prepare之后、XComponent的onLoad之前就把surfaceId赋给了组件而XComponent还没创建完成这个id可能没法正确绑定到底层Surface上。正确的做法是先让AVRecorder创建出surfaceId再通过XComponentController的动态能力将surfaceId设置进去或者等待XComponent的onLoad回调后再启动同步。第二个原因是页面进入后台或最小化时XComponent对应的Surface被系统回收。如果录制过程中App被切到后台Surface内容中断视频后半段就会是黑屏。这个在纯后台导出场景尤其危险所以我最终把渲染逻辑迁到了Native侧把Surface生命周期握在自己手里而不是依赖页面组件。如果你排查黑屏我建议先加日志确认onLoad回调确实触发、确认surfaceId不为空、确认渲染循环确实有调用buffer交换。这三个点任何一个不满足它必然黑屏。4.2 花屏RGBA_8888与YUV的色彩空间错位花屏比黑屏更隐蔽。我遇到过一次视频能录出来但画面颜色特别奇怪偏绿偏紫看着像监控摄像头夜视模式。追根溯源问题出在颜色格式上。Surface采集模式下编码器内部接收的其实是YUV格式的帧数据而我们从图片解码出来的是RGBA_8888。如果使用EGL直接把RGBA纹理渲染到Surface系统内部会做一次颜色格式转换但有些情况下色彩范围或色度采样信息没有对齐就会出现颜色错乱。这事的排查思路是先确认解码时desiredPixelFormat是不是RGBA_8888再确认EGL配置的surface属性里是否使用了合适的颜色位深并确保各颜色分量配置一致。还要检查图片资源本身是否带有广色域色彩管理如果项目里有ICC配置或广色域资源建议先统一到sRGB避免颜色编码不一致。4.3 卡顿主线程渲染引发的掉帧某次测试我发现帧率标称25fps但导出视频里明显卡顿比如某个画面停了一拍。打点后发现卡顿点都落在drawFrame的耗时上。究其原因是我在ArkTS侧用XComponent时渲染逻辑跑在了UI主线程图片纹理上传和同步绘制操作阻塞了主线程导致帧率波动。解决思路很简单把纹理上传、绘制、缓冲交换放到独立渲染线程里通过消息队列来控制每一帧的时序。如果是Native侧EGL方案线程模型本来就和ArkUI解耦不容易卡主线程。另外有一点要留意decode PixelMap也尽量避免在渲染循环内做应该在预加载阶段先完成渲染循环里只做纹理上传和绘制。4.4 内存暴涨原图分辨率没有做降采样这个问题在3.1小节已经提到。补充一个真实的崩溃现场用户选择6张约4000x3000的JPG没有降采样每张解码成RGBA_8888后内存占用约4000乘3000乘4也就是接近48MB六张就要接近300MB加上渲染管线里的纹理缓冲App直接稳定崩溃。降采样后如果输出目标视频是720x1280图片最大边可以限制到1000px左右一张图内存占用降到约4MB六张也只有24MB加上纹理缓存完全可接受。所以强烈建议在业务层面对图片列表做统一的尺寸约束而不是让底层渲染去兜底。兜底的代价往往是性能灾难。5. 性能优化与转场效果打磨5.1 帧率跑不满时的四板斧如果录出来的视频实际帧率上不去我会按这个顺序排查第一确认图片解码是否占用过高延时如果是把图片预先处理成RGBA数据缓存起来第二确认渲染线程是否存在锁等待或sleep不精确用更精确的时钟源来校准第三检查EGL是否开启了垂直同步如果录制不需要和屏幕刷新对齐可以考虑关闭相关配置来避免阻塞第四适当下调输出分辨率或帧率比如从30fps降到25fps肉眼几乎无感但编码压力和发热会明显下降。5.2 简单转场淡入淡出与缩放平移的实现思路纯图片硬切虽然能用但用户观感一般。想加转场核心是两帧之间进行插值。淡入淡出就是在前一张图和后一张图之间按进度混合透明度缩放平移则是通过矩阵变化控制画布。实现上在EGL渲染循环里可以维护一个全局时间偏移量。比如每张图停留2秒转场0.5秒那么在最后0.5秒内将当前贴图透明度从1降到0同时后一张图从0升到1中间用线性或者正弦插值。旋转、推入等效果本质就是顶点坐标和纹理坐标的变换在GLSL里几行shader就能完成。如果不想写GLSL也可以把两张PixelMap先在离屏状态下合成一张再当作单帧渲染到Surface。但这种方式要注意离屏合成的尺寸不要超过视频分辨率太多否则费性能。5.3 输出配置的权衡720p还是1080p这部分我不建议拍脑袋。先看用户使用场景如果视频主要用于社交平台分享720p其实已经非常够用文件小、上传快、播放流畅如果用户要求高清保存到本地可以给1080p选项但需要在UI上提示“高清导出耗时更长、更耗电”。码率方面720p静态相册场景我一般给3到5Mbps1080p给6到8Mbps。如果加了转场、缩放等动态效果码率可以上调一档否则快速变化的区域容易出现马赛克。经验值不需要特别精确最终确认以真机目测为准。5.4 实测数据25fps、40秒视频的资源占用我在真机上做了几次对比输出720x1280、25fps、40秒视频、图片数量20张、每张2秒、带淡入淡出转场。图片加载阶段预解码20张缩略图内存峰值约80MB。渲染录制阶段OpenGL纹理约7MBEGL缓冲约20MBAVRecorder编码器自身占用约50到60MB总体内存峰值能控制在200MB内。导出耗时中端设备上约30秒完成40秒视频基本能做到接近实时对比纯软编有明显优势。文件体积静态图为主码率4Mbps下最终文件约20MB播放端反馈流畅。这个数据没有做极端优化但对大多数相册类App已经算比较舒服的基线。如果你要更强的实时性还可以在上传纹理时使用更高效的内存共享机制进一步减少拷贝。6. 进阶扩展与做相册类应用的一点心得6.1 给视频配背景音乐很多产品需求会要求给合成的视频加背景音乐。方案并不复杂在AVRecorderConfig里同时配置音频采集源audioCaptureConfig: { audioSourceType: media.AudioSourceType.SOURCE_TYPE_MIC, audioSampleRate: 48000, audioChannels: 2, audioEncodingBitRate: 128000, audioContentType: media.AudioContentType.CONTENT_TYPE_MUSIC, audioStreamUsage: media.AudioStreamUsage.USAGE_MEDIA }但要注意如果直接用麦克风采集它录的是环境音并不是我们想要的“把本地音乐合成到视频里”。正确做法是使用音频播放器播放音乐同时把音频内容捕获进来或者在Native侧做混音。这部分链路相对独立我建议不要和第一版视频合成一起做先把视频主线搞稳定再扩展。6.2 图片上叠加文字和贴纸有了GL渲染管线后叠加文字和贴纸反而很简单。将文字生成一张带透明通道的纹理贴图在渲染每一帧时按照设计稿的坐标、透明度叠加到主画面上即可。时间线方面可以给每个元素定义起始帧和结束帧例如“第3秒到第5秒显示标题”渲染循环里判断当前帧时间是否在展示区间内。如果不想用GL做也可以直接在PixelMap层面用离屏绘制文字后再作为纹理上传。区别就是多一次离屏合成性能稍微差一些。6.3 从离线导出到实时预览很多用户希望生成前先看到效果而不是等文件生成后再播放。实时预览的思路是让渲染循环同时输出到两个Surface一个给AVRecorder编码一个给预览组件显示。这需要在Native侧管理多条输出复杂度会上升一个台阶。我的建议是MVP阶段先用“先导出再播放”的方式等视频合成链路稳定了再考虑实时预览。因为实时预览涉及硬件编解码并行、画面同步等问题开发成本不低一次性全上容易顾此失彼。6.4 几十个项目做下来我给新手的四个建议第一图片在画面中的位置和尺寸别在UI层硬编码用“视频宽高 / 基准分辨率”去换算不然换设备就错位。第二把AVRecorder的状态回调完整打点记录下来调试时能省一半时间。第三每次release之后把avRecorder置空避免二次释放导致崩溃。第四遇到问题时先确认Surface是否真的有内容在渲染再怀疑编码参数。很多“视频异常”问题其实是“渲染侧异常”问题编码器往往只是背锅侠。这些经验看着零碎但在实际项目里每一条都能帮你少折腾半天。尤其第四个建议可以说是所有媒体类功能调试时的通用原则。按这个思路去定位问题绝大多数黑屏、卡顿、花屏的案子都能在十分钟内找到真正的病灶。