一个教学录屏功能,用户在竖屏下点录制,AVScreenCaptureRecorder 初始化、授权、startRecording 一气呵成,录出来的 MP4 正常。然后用户把手机横过来继续看视频,画面在屏幕上是转正的,录出来的文件后半段变成了一条绿边加拉伸的鬼影。你检查 init 参数,videoWidth 和 videoHeight 是按竖屏分辨率填的;检查编码器,H.264 支持这个分辨率;检查文件,前半段正常,后半段花屏。问题不在编码器,也不在播放器,在于屏幕旋转之后,窗口的分辨率变了,但 AVScreenCaptureRecorder 的编码参数是初始化时锁死的,它不会跟着窗口一起转。

AVScreenCapture 的录制画布在 init 那一刻就按你给的分辨率确定了。旋转屏幕改变的是显示方向,不是录制器的编码规格。编码器仍然按 1080×1920 接收帧,系统送过来的画面已经变成了 1920×1080 的横屏内容,两者的 stride 和宽高对不上,编码器按错误的跨度去读缓冲区,后半段就花了。

录制规格在 init 时锁定,旋转不会触发重配

先把这条边界钉死。AVScreenCaptureRecorder.init 接收的 ScreenCaptureParam 里,videoWidth、videoHeight、videoFrameRate 和 audioSampleRate 等参数在初始化时确定,之后 startRecording 到 stopRecording 之间不会重新协商。屏幕旋转改变的是窗口的显示方向,AVScreenCaptureRecorder 不会收到“分辨率变了”的通知,也不会主动重新初始化编码器。

这意味着你需要在旋转发生时做显式处理。一个务实的选择是锁定录制期间的方向:在 startRecording 之前记录当前方向,录制期间监听 display.on('change'),一旦检测到方向变化,先 stopRecording 收尾当前文件,再用新的分辨率 init 一个新的录制器,startRecording 继续录到新文件里。代价是旋转点会把文件切成两段,收益是两段都是可播放的。

typescript

import { screenCapture } from '@kit.MediaKit';
import { display } from '@kit.ArkUI';

class RotationAwareRecorder {
  private recorder: screenCapture.AVScreenCaptureRecorder | null = null;
  private segmentIndex: number = 0;

  private async startSegment(width: number, height: number): Promise<void> {
    const param: screenCapture.ScreenCaptureParam = {
      videoWidth: width,
      videoHeight: height,
      videoFrameRate: 30,
      audioSampleRate: 48000,
      audioChannelCount: 2,
      audioBitrate: 128000,
      videoBitrate: 4000000,
      videoCodec: screenCapture.VideoCodecType.VIDEO_CODEC_TYPE_H264,
      audioCodec: screenCapture.AudioCodecType.AUDIO_CODEC_TYPE_AAC,
      fileFormat: screenCapture.ContainerFormatType.CFT_MPEG_4,
      path: `/data/storage/el2/base/haps/entry/files/rec_${this.segmentIndex}.mp4`
    };
    this.recorder = await screenCapture.createAVScreenCaptureRecorder();
    await this.recorder.init(param);
    await this.recorder.startRecording();
  }

  async onOrientationChanged(width: number, height: number): Promise<void> {
    if (!this.recorder) return;
    await this.recorder.stopRecording();
    await this.recorder.release();
    this.recorder = null;
    this.segmentIndex++;
    await this.startSegment(width, height);   // 用新分辨率重开一段
  }
}

onOrientationChanged 里的 stopRecording → release → 新建 → init → startRecording 是完整的一套。只调 stopRecording 不 release,旧录制器占着编码器资源,新的 createAVScreenCaptureRecorder 可能拿不到资源。release 之后再 init,参数才是全新的。

如果业务不能接受文件被切成多段,另一个方向是在录制启动时就把分辨率固定为正方形或者按最长边裁切,让横竖屏都落在同一个编码画布内。代价是画面有黑边或者裁切,收益是文件始终是单段、始终可播。这个取舍应该在需求阶段就决定,而不是等用户录出花屏文件之后再回头改。

音频源不是“系统声音和麦克风二选一”,选错就是无声或双轨

ScreenCaptureParam 里音频源的配置有一个容易踩的边界。audioSource 决定录制时采集哪些声音:SOURCE_DEFAULT 走系统默认,通常是系统播放声音;SOURCE_MIC 采集麦克风;SOURCE_ALL_PLAYBACK 采集所有播放声音;SOURCE_PLAYBACK_CAPTURE 采集应用内播放声音。

选 SOURCE_MIC 录教学视频,用户说话的声音能进去,但视频本身的声音进不去;选 SOURCE_DEFAULT 录系统声音,讲解的声音进不去。如果你的场景是“一边放视频一边讲解”,需要的是两个音频源同时采集,而 AVScreenCaptureRecorder 在单次 init 里只接受一个音频源。解决办法不是把两个源“混合”进一个参数,而是在录制结束后用音频编辑能力把两轨合到一起,或者接受“只录系统声音、讲解另外配”的产品方案。

audioSampleRate 和 audioChannelCount 要和音频源的实际输出对齐。麦克风通常是 48000Hz 单声道或双声道,系统播放声音可能是 44100Hz 立体声。参数和源不匹配时,音频不会报错,但听起来会变调或者只有一边声道有声。

切后台录制被暂停,长时任务不是申请了就生效

录屏应用退到后台,AVScreenCaptureRecorder 的录制不会自动继续。系统在应用进入后台之后会限制屏幕采集,录制状态可能变成暂停,on('stateChange') 回调里能看到状态变化,但如果你没有监听这个回调,表现就是“录着录着文件不长了”。

要让录屏在后台持续,需要申请 AUDIO_RECORDING 或者 SCREEN_CAPTURE 类型的长时任务,并在 module.json5 的 backgroundModes 里声明。但长时任务只解决“进程不被冻结”,它不解决“屏幕采集在后台被限制”。AVScreenCaptureRecorder 的前台约束和长时任务是两个独立的门,长时任务开了,屏幕采集在后台仍然可能被系统暂停,尤其是在低电量或者隐私策略收紧的场景下。

更稳妥的产品设计是:录屏期间保持一个可见的前台通知,用户知道录制在进行,系统也知道这是一个用户主动发起的前台行为。应用切到后台时,主动 stopRecording 收尾当前文件,回到前台再重新开始,而不是赌长时任务能扛住所有后台限制。

授权弹窗只弹一次,拒绝之后 request 不再出现

AVScreenCaptureRecorder 的授权模型和相机、麦克风不同。它没有对应的 requestPermissionsFromUser 权限名,授权是通过 init 或 startRecording 时系统弹出的“是否允许录制屏幕”对话框完成的。用户点“拒绝”之后,同一个应用再次调用录屏接口,系统不会再次弹窗,而是直接返回授权失败。

这和相机权限的 dialogShownResults 是同一类问题,但录屏的授权状态没有 checkAccessToken 那样的查询接口可以提前判断。你只能在 init 或 startRecording 的失败回调里识别“授权被拒”,然后引导用户去系统设置页手动打开屏幕录制权限。不要把“再次调用接口”当成“再次请求授权”,拒绝之后的重复调用只会拿到同一个失败结果。

typescript

try {
  await recorder.init(param);
} catch (err) {
  const e = err as BusinessError;
  // 授权被拒时引导去设置页,不要原地重试
  if (isScreenCaptureDenied(e)) {
    this.showGuideToSettings();
    return;
  }
  throw err;
}

文件路径不在沙箱里,写完也读不到

ScreenCaptureParam.path 必须是应用沙箱内的可写路径。用 context.filesDir 或者 context.cacheDir 派生,不要自己拼 /data/storage/el2/... 的深层路径。路径不可写时,init 可能成功,startRecording 也可能成功,但 stopRecording 之后文件不存在或者大小为 0。这和下载代理的 saveas 是同一类问题:录制器在初始化时不校验路径的写权限,它把失败留到了写文件的最后一刻。

typescript

const path = `${ctx.filesDir}/screen_record_${Date.now()}.mp4`;

路径里带时间戳或者 segmentIndex,避免多次录制覆盖同一个文件。stopRecording 返回之后,用 fs.statSync 检查文件存在和大小,再交给业务侧使用。空文件和路径重定向是录制完成之后最隐蔽的两个坑。

排查顺序:先看旋转,再看音频源,最后看后台和授权

下次录屏文件花屏、无声、或者录到一半停了的时候,按这个顺序走。

第一步,如果文件前半段正常后半段花,检查录制期间屏幕有没有旋转,init 的分辨率和旋转后的窗口分辨率是否一致。不一致时,在旋转点分段收尾重开,或者启动时固定画布。

第二步,如果文件完全没有声音,检查 audioSource 和业务场景是否匹配。讲解加系统声音需要两个源,单次 init 只接受一个,要么后期合轨,要么改产品方案。

第三步,如果录制在切后台之后停了,检查长时任务有没有申请、backgroundModes 有没有声明。长时任务开了但采集仍被暂停的,改成前台录制、切后台主动收尾。

第四步,如果授权弹窗只出现过一次、之后调用直接失败,检查是不是被用户拒绝过。录屏授权没有查询接口,失败即引导去设置页,不要原地重试。

第五步,如果 stopRecording 之后文件不存在或为 0,检查 path 是不是沙箱内可写目录,stopRecording 之后有没有做 fs.statSync 校验。

AVScreenCaptureRecorder 的录制规格在 init 时锁定,屏幕旋转不会触发重配,横竖屏切换会把编码跨度打乱;音频源在单次录制里只能选一个,讲解和系统声音不能靠一个参数同时采集;后台录制受前台约束,长时任务不是万能通行证;授权拒绝之后不会再次弹窗,只能引导设置页。下一次录屏文件横过来花一半的时候,先别怀疑编码器——打开 init 的参数看一眼,那个 videoWidth 和 videoHeight,到底是不是录制期间一直没变过。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐