本文基于大牛直播 SDK(SmartMediaKit)鸿蒙 NEXT 版本,结合真实 ArkTS 工程代码,完整介绍录像的参数配置、启停控制、文件切片事件处理,以及快照的异步回调机制和目录管理实践。
背景
在安防预览、工业巡检、远程直播等实时流媒体场景中,"边看边录"和"一键抓图"是使用频率极高的两个功能。鸿蒙 NEXT(纯血鸿蒙)版 SmartPlayer SDK 将这两个能力完整透出到 ArkTS 层,整个调用链如下:
TypeScript
ArkTS UI(SmartPlayerPage)
↓ doStartRecorder / captureImage
PlayerRecorderHelper / PlayerSnapshotHelper(业务封装)
↓ startRecorder / captureImage
SmartPlayerWrapper(ArkTS 封装层)
↓ NT 命名空间
SmartPlayerNative(libSmartPlayer.so NAPI 映射)
↓
C++ 原生层(文件 I/O / JPEG 编码)
录像和快照都是异步操作------启动命令立即返回,实际文件落盘的结果通过事件回调通知 ArkTS 层,必须正确处理事件才能拿到真实的文件路径。
一、录像功能

1.1 接口总览
Native 层(SmartPlayerNative.ets)
TypeScript
// 目录与文件管理
SmartPlayerCreateFileDirectory(path: string): number
SmartPlayerSetRecorderDirectory(handle, path: string): number
SmartPlayerSetRecorderFileMaxSize(handle, sizeMB: number): number
// 轨道开关
SmartPlayerSetRecorderVideo(handle, enable: number): number // 1=录视频
SmartPlayerSetRecorderAudio(handle, enable: number): number // 1=录音频
SmartPlayerSetRecorderAudioTranscodeAAC(handle, enable: number): number // 1=转码为AAC
// 录像控制
SmartPlayerStartRecorder(handle): number
SmartPlayerStopRecorder(handle): number
Wrapper 层(SmartPlayerWrapper.ets) ,在 Native 外包了 isOpened() 保护并同步 Session 缓存:
TypeScript
createFileDirectory(path: string): boolean
setRecorderDirectory(path: string): boolean
setRecorderFileMaxSize(sizeMB: number): boolean
setRecorderAudioTranscodeAAC(enable: boolean): boolean
setRecorderVideo(enable: boolean): boolean
setRecorderAudio(enable: boolean): boolean
startRecorder(): boolean
stopRecorder(): boolean
1.2 录像参数配置
录像参数通过 PlayerRecorderConfig 接口统一传递,在 Page 层构建:
TypeScript
export interface PlayerRecorderConfig {
recorderDir: string; // 录像保存目录(绝对路径)
recorderFileMaxSizeMB: number; // 单文件最大体积(MB),超出自动切片
recorderAudioTranscodeAAC: boolean; // 音频是否转码为 AAC
recorderVideoEnabled: boolean; // 是否录视频轨
recorderAudioEnabled: boolean; // 是否录音频轨
}
Page 层构建默认录像配置:
TypeScript
private buildRecorderConfig(): PlayerRecorderConfig {
return {
recorderDir: this.recordDir,
recorderFileMaxSizeMB: this.recordFileMaxSizeMB, // 默认 200MB
recorderAudioTranscodeAAC: this.recordAudioTranscodeAAC, // 默认 true
recorderVideoEnabled: this.recordVideoEnabled, // 默认 true
recorderAudioEnabled: this.recordAudioEnabled // 默认 true
};
}
Wrapper 中的 applyRecorderConfig() 负责将配置逐一下发到 Native:
TypeScript
applyRecorderConfig(config: PlayerRecorderConfig): boolean {
if (!this.isOpened()) return false;
if (!config.recorderDir || config.recorderDir.trim().length === 0) return false;
let ok = true;
ok = this.createFileDirectory(config.recorderDir) && ok; // 1. 确保目录存在
ok = this.setRecorderDirectory(config.recorderDir) && ok; // 2. 设置输出目录
ok = this.setRecorderFileMaxSize(config.recorderFileMaxSizeMB) && ok; // 3. 切片大小
ok = this.setRecorderAudioTranscodeAAC(config.recorderAudioTranscodeAAC) && ok; // 4. 音频转码
ok = this.setRecorderVideo(config.recorderVideoEnabled) && ok; // 5. 视频轨
ok = this.setRecorderAudio(config.recorderAudioEnabled) && ok; // 6. 音频轨
return ok;
}
1.3 获取录像目录
鸿蒙 NEXT 的沙箱路径在 aboutToAppear() 阶段通过 UIAbilityContext 获取:
TypeScript
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
// 录像目录挂在沙箱 filesDir 下
const dir = ctx.filesDir + '/records';
if (dir) {
this.recordDir = dir;
} else {
this.statusText = '获取录像目录失败';
}
}
ctx.filesDir是应用沙箱根目录,每个应用独享,无需申请额外权限。如果需要保存到相册或外部存储,需要申请ohos.permission.WRITE_MEDIA并通过photoAccessHelper操作。
1.4 开始录像
录像可以在两种场景下启动:纯录像模式 (不播放,只录)和边播边录模式 。Page 层的 doStartRecorder() 对两种场景都做了处理:
TypeScript
private doStartRecorder(): void {
// 防止重复启动
if (this.player.isRecordingState()) {
this.setLastEvent('已在录像中,忽略重复开始');
return;
}
const wasPlaying = this.player.isPlayingState();
// 如果播放器还没 open,先 open
if (!this.player.isOpened()) {
const openRet = this.player.open();
if (!openRet) {
this.statusText = '打开失败(native 未加载?)';
return;
}
this.bindPlayerCallbacks(); // 注册事件回调
}
// 如果当前没在播放,需要先下发基础网络配置
if (!wasPlaying) {
if (!this.player.applyBaseConfig(this.buildCommonConfig())) {
this.player.close();
return;
}
}
// 如果已在播放,跳过重复参数下发,直接追加录像配置
// 下发录像参数并启动
const recConfig = this.buildRecorderConfig();
this.player.applyRecorderConfig(recConfig);
const ok = this.player.startRecorder();
if (ok) {
this.isRecording = true;
this.setLastEvent('录像已启动');
} else {
this.statusText = '录像启动失败';
if (!this.player.isPlayingState()) {
this.player.close(); // 失败时释放资源
}
}
}
关键点:播放和录像共用同一个 handle,中间件层(SmartPlayerWrapper)的 Session 会同时维护 isPlaying 和 isRecording 两个状态,两者可以独立为 true。
1.5 停止录像
TypeScript
private doStopRecorder(): void {
if (!this.player.isOpened()) return;
const ok = this.player.stopRecorder();
if (ok) {
this.isRecording = false;
this.setLastEvent('录像已停止');
}
// 如果停录后既没在播放也没在录像,可以释放 handle
if (!this.player.isRunningState()) {
this.player.tryRelease();
}
this.syncUiFromPlayer();
}
tryRelease() 内部会检查 isRunningState()(播放或录像任一为 true 时不会释放),所以这里调用是安全的。
1.6 文件切片事件处理
当单文件体积超过 recorderFileMaxSizeMB 时,SDK 会自动切片并产生两个事件:
| 事件 | 触发时机 | strParam 含义 |
|---|---|---|
RECORDER_START_NEW_FILE |
新文件开始写入 | 新文件的完整路径 |
RECORDER_FILE_FINISHED |
旧文件写入完成并关闭 | 已完成文件的完整路径 |
在 onPlayerEvent() 中处理这两个事件:
TypeScript
case SmartPlayerEvent.RECORDER_START_NEW_FILE: {
// strParam 是新文件路径,更新当前录像文件记录
this.recorderCurrentFile = strParam ?? '';
this.statusText = this.recordingStatusText(); // 更新状态栏
this.setLastEvent(`新录像文件: ${strParam}`);
break;
}
case SmartPlayerEvent.RECORDER_FILE_FINISHED: {
// strParam 是已完成文件路径,备份后清空当前文件记录
this.recorderLastFile = (strParam && strParam.length > 0)
? strParam
: this.recorderCurrentFile;
this.recorderCurrentFile = '';
this.setLastEvent(`文件已完成: ${this.recorderLastFile}`);
break;
}
典型录像文件切片时序如下:
TypeScript
startRecorder()
↓
RECORDER_START_NEW_FILE → strParam = "/data/.../records/20240415_143201.mp4"
↓ (录满 200MB)
RECORDER_START_NEW_FILE → strParam = "/data/.../records/20240415_143501.mp4" ← 新文件
RECORDER_FILE_FINISHED → strParam = "/data/.../records/20240415_143201.mp4" ← 旧文件封包完成
↓
stopRecorder()
↓
RECORDER_FILE_FINISHED → strParam = "/data/.../records/20240415_143501.mp4" ← 最后一个文件封包
二、快照功能
2.1 接口总览
Native 层
TypeScript
SmartPlayerSaveImageFlag(handle, enable: number): number // enable=1 开启抓图功能
SmartPlayerSaveCurImage(handle, imagePath: string): number // 同步保存当前帧
SmartPlayerCaptureImage(
handle,
compressFormat: number, // 0=JPEG,1=PNG
quality: number, // 压缩质量 0~100
fileName: string, // 保存文件完整路径(含扩展名)
userData: string // 自定义标记,原样返回到回调
): number
Wrapper 层
TypeScript
setSaveImageFlag(enable: boolean): boolean
saveCurImage(imagePath: string): boolean
captureImage(compressFormat: number, quality: number, fileName: string, userData: string): number
2.2 快照前提:SaveImageFlag
setSaveImageFlag(true) 是抓图功能的总开关。SDK 在收到这个指令后才会在解码管线中预留抓图通道,如果漏掉这一步,captureImage() 会直接失败。
在 applyPlaybackConfig() 和 reapplySavedConfig() 中,这个开关都被无条件打开:
TypeScript
// applyPlaybackConfig 里
ok = this.setSaveImageFlag(true) && ok;
// reapplySavedConfig(Surface 重建后恢复)里
ok = this.setSaveImageFlag(true) && ok;
也就是说,只要走正常播放启动流程,这个开关就已经开好了,业务层不需要单独调用。
2.3 异步抓图:captureImage
captureImage() 是异步的,调用后立即返回,实际的文件写入结果通过 CAPTURE_IMAGE 事件回调通知:
TypeScript
// 触发快照
private captureImage(): void {
if (!this.player.isOpened()) return;
// 构造文件路径:目录 + 时间戳命名
const ts = new Date().getTime();
const filePath = `${this.recordDir}/snapshot_${ts}.jpg`;
// captureImage(格式, 质量, 路径, 自定义标记)
// compressFormat: 0=JPEG,1=PNG
const ret = this.player.captureImage(0, 90, filePath, 'snapshot');
if (ret !== SmartPlayerResult.OK) {
this.statusText = '快照请求失败';
this.setLastEvent('captureImage 返回错误');
} else {
this.setLastEvent('快照请求已发出,等待回调...');
}
}
canCaptureImage() 是 Wrapper 内部的守卫------只有播放中或录像中(视频帧有数据流入)才允许抓图:
TypeScript
canCaptureImage(): boolean {
return this.isPlayingState() || this.isRecordingState();
}
2.4 处理快照回调事件
快照结果通过 CAPTURE_IMAGE 事件异步返回:
TypeScript
case SmartPlayerEvent.CAPTURE_IMAGE: {
const captureOk = param1 === 0; // param1=0 表示成功,非0表示失败
// strParam 是实际保存的文件路径
if (captureOk) {
this.statusText = this.isRecording
? this.recordingStatusText()
: (this.isPlaying ? this.playingStatusText() : this.stoppedStatusText());
this.setLastEvent(`快照已保存: ${strParam}`);
// 如果需要展示预览,可以在这里通知 UI
// this.lastSnapshotPath = strParam;
} else {
this.statusText = '快照保存失败';
this.setLastEvent(`快照失败, code=${param1}`);
}
break;
}
注意事件处理后要把状态栏恢复到正确的播放/录像/停止文本,不要让"快照保存失败"这类提示长期停留在状态栏上。
三、录像与快照的目录管理
录像和快照共用同一个输出目录(实际项目中可以分开),目录在 aboutToAppear() 阶段初始化,确保在任何操作前已经就绪:
TypeScript
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
// 沙箱目录:/data/storage/el2/base/files/records
const dir = ctx.filesDir + '/records';
// 目录可能不存在,提前创建
this.player.open();
this.player.createFileDirectory(dir);
this.player.close(); // 仅用于创建目录,立即关闭
this.recordDir = dir;
}
更稳妥的做法是把目录创建独立出来,不依赖 player 实例。实际工程中可以直接调用
@ohos.file.fs的mkdir()方法,不必通过 SDK 创建目录。
四、完整调用时序

录像
TypeScript
player.open()
↓
player.applyBaseConfig(config) // 下发 URL 等网络参数
↓
player.applyRecorderConfig(recConfig) // 下发录像参数
├── createFileDirectory(dir)
├── setRecorderDirectory(dir)
├── setRecorderFileMaxSize(200)
├── setRecorderAudioTranscodeAAC(true)
├── setRecorderVideo(true)
└── setRecorderAudio(true)
↓
player.startRecorder()
↓
── 录像中 ─────────────────────────────────────────────
回调:RECORDER_START_NEW_FILE → 拿到新文件路径
回调:RECORDER_FILE_FINISHED → 拿到完整文件路径(可上传/入库)
── 文件切片自动循环 ───────────────────────────────────
↓
player.stopRecorder()
↓
回调:RECORDER_FILE_FINISHED → 最后一个文件封包完成
↓
player.tryRelease() // 不在播放则释放
快照
TypeScript
player.open()
↓
player.applyPlaybackConfig(config) // 内部已包含 setSaveImageFlag(true)
↓
player.startPlayback()
↓
── 播放中 ──────────────────────────────────────────────
player.captureImage(0, 90, filePath, 'snapshot')
↓
回调:CAPTURE_IMAGE
├── param1 === 0 → 成功,strParam = 文件路径
└── param1 !== 0 → 失败,记录错误码
── 可连续多次调用,每次异步通知 ──────────────────────
↓
player.stopPlayback()
player.tryRelease()
五、常见问题
Q:录像启动成功但文件始终是空的?
最常见原因是录像目录不存在。setRecorderDirectory() 不会自动创建目录,必须在调用前先通过 createFileDirectory() 或 @ohos.file.fs 确保目录存在。另一个原因是视频轨和音频轨都被关闭------setRecorderVideo(false) 且 setRecorderAudio(false) 的情况下,录出来的是空文件。
Q:RECORDER_FILE_FINISHED 一直没有回调?
这个事件只在文件被正常封包时触发。如果调用了 stopRecorder() 但没有收到这个回调,通常是因为 stopRecorder() 之前 player.close() 已经被调用,Native 层来不及触发封包事件。正确顺序是先 stopRecorder(),等到 RECORDER_FILE_FINISHED 回调后再做资源释放。
Q:captureImage() 返回 OK 但没有收到 CAPTURE_IMAGE 事件?
首先确认 setSaveImageFlag(true) 是否已调用(走正常的 applyPlaybackConfig() 流程会自动调用)。其次确认调用 captureImage() 时播放器处于 isPlayingState() 或 isRecordingState()------没有数据流的状态下即使方法返回 OK,底层也无法抓取到帧。
Q:录像和播放可以独立控制吗?
可以。isPlaying 和 isRecording 是 Session 里相互独立的两个布尔值。可以单独录像(不播放),也可以先播放再追加录像,还可以只停录像而继续播放,Wrapper 层对每种组合都有正确的状态维护。
Q:快照是否支持 PNG 格式?
支持,captureImage() 的第一个参数 compressFormat 传 1 即为 PNG,传 0 为 JPEG。PNG 无损但文件更大,在监控抓图场景一般用 JPEG足够。
HarmonyOS NEXT纯血鸿蒙RTSP|RTMP播放器
小结
录像和快照两个功能的核心差异在于:录像是状态驱动的(start/stop 切换,结果通过文件切片事件持续通知),而快照是请求驱动的(每次调用对应一个异步回调)。前者需要重点处理 RECORDER_FILE_FINISHED 来拿到可用的文件路径;后者需要确保 setSaveImageFlag 总开关已开,并在回调里检查 param1 来判断是否成功。
借助 SmartPlayerWrapper 的 Session 机制,录像文件路径(recorderCurrentFile / recorderLastFile)在页面重建后同样可以自动恢复,不会因为页面切换而丢失正在录制的状态。
大牛直播 SDK(SmartMediaKit) 的鸿蒙 NEXT 模块(libSmartPlayer.so)是目前业内少有的、针对纯血鸿蒙系统完整移植并持续维护的商用直播播放器 SDK。在架构设计上,它延续了 Android 端成熟的 Native C++ 内核,通过 NAPI 桥接层将音视频能力原生透出到 ArkTS,避免了 JS 跨层调用的性能损耗,在 RTSP/RTMP 低延迟播放场景下端到端时延可控制在 100-200ms。
纯血鸿蒙(HarmonyOS )RTSP直播播放器时延测试
录像和快照模块同样体现了这套架构的优势:文件 I/O 和 JPEG 编码全部在 C++ 层完成,不经过 ArkTS 的 GC 堆,大文件切片写入不会引发界面卡顿;RECORDER_FILE_FINISHED 的异步回调机制保证了每一段切片文件在封包完成后才通知上层,从根本上杜绝了"文件未写完就被读取"的数据损坏风险。
在行业覆盖上,大牛直播 SDK 已广泛应用于安防监控、工业巡检、远程医疗、智慧零售等对实时性和稳定性要求极高的场景。鸿蒙 NEXT 版本的推出,使这些行业的终端设备可以在不依赖 Android 兼容层的前提下,直接以原生鸿蒙应用的形式完成从拉流、解码、渲染到录像存档、关键帧抓图的完整闭环,为国产操作系统在专业音视频领域的落地提供了坚实的底层支撑。
📎 CSDN官方博客:音视频牛哥-CSDN博客