在移动端音视频开发场景里,“同屏推流”看起来像是一个相对具体的小功能,但真正做起来,往往比普通摄像头推流更复杂。原因并不难理解:它不是单纯采集一段视频数据,而是要围绕系统级屏幕内容采集,把权限管理、后台运行、系统音频采集、麦克风采集、实时编码、录像、RTMP 推流、轻量级 RTSP 服务和 RTSP 流发布这些能力串成一条完整链路。

尤其在鸿蒙 NEXT 这样的新平台上,这件事更不能简单理解成“拿到屏幕帧后推上去就行”。真正决定项目能不能落地的,往往不是某一个接口是否可用,而是整套实现是否具备清晰的模块划分、稳定的状态控制能力以及可持续演进的工程结构。本文结合大牛直播SDK在鸿蒙 NEXT 下的同屏推流实现思路,从系统设计、模块职责、音视频协同、横竖屏处理以及工程化落地几个方面,系统梳理这项能力更合理的实现方式。

一、为什么鸿蒙 NEXT 下的同屏推流值得单独打磨

很多开发者第一次接触同屏推流,会把它理解为“屏幕录制”的延伸能力。但从实际业务场景来看,它的价值远不止如此。在线培训、远程演示、设备巡检、工业终端界面回传、移动办公协作、远程指导、教育录播等场景,往往并不依赖摄像头,反而更依赖“把当前设备屏幕内容实时发出去”的能力。

只要屏幕采集能力建立起来,再结合 RTMP 推流、本地录像和局域网 RTSP 分发,一条完整的实时内容输出链路也就成型了。对于鸿蒙 NEXT 来说,这类能力的意义还要更大一些。它面向的并不仅仅是传统手机应用,还会逐步进入更多跨终端协同、行业专用终端和系统级场景。在这些场景中,稳定的同屏推流并不是一个附加功能,而可能是整个业务方案的基础能力。

因此,从产品能力建设和技术沉淀两个角度看,把鸿蒙 NEXT 下的同屏推流单独做好,都是非常有价值的事情。

二、同屏推流真正复杂的地方,不在“推”,而在“链路管理”

表面上看,同屏推流无非就是“屏幕采集 + 编码 + 上行”。但一旦放到真实项目中,事情很快就会复杂起来。因为它背后同时牵涉多个模块之间的协同,任何一个环节处理不当,都会影响最终体验。

一条真正可用的同屏推流链路,通常至少包括这些能力:屏幕采集权限获取、系统屏幕内容采集、系统播放音频采集、麦克风采集、原始音视频数据投递到推流引擎、RTMP 推流、本地录像、轻量级 RTSP 服务启动、RTSP 流发布、后台运行维持,以及横竖屏切换时采集尺寸与编码链路的一致性维护。

也就是说,同屏推流并不是“一个按钮触发一个接口”的问题,而是一个完整的状态机问题。Android 侧的参考实现之所以更成熟,本质上也是因为它没有把这些能力硬绑在一个按钮里,而是将媒体投影权限获取、音频播放采集、麦克风采集、RTMP 推流、RTSP 服务、RTSP 流发布等动作拆开控制,再由统一的媒体引擎进行协调。大牛直播SDK在鸿蒙 NEXT 下的同屏推流实现,同样遵循这一思路。

三、鸿蒙 NEXT 下更合理的工程拆分方式

如果只是做一个简单 Demo,很多逻辑当然可以堆在一个页面里。但只要开始处理后台运行、音频采集、录像控制、RTSP 服务和横竖屏适配,代码很快就会变得难以维护。更合理的方式,是从一开始就把这套能力按职责拆开。

页面层的职责应该尽量单纯,聚焦在用户交互、参数输入、状态展示和日志输出上。比如输入推流地址、设置 RTSP 端口和流名、控制是否录像、是否启用 RTSP 服务、查看运行事件等,这些都属于页面层应该承担的事情。但页面层不应该直接决定媒体链路如何切换、采集何时重建、编码参数何时生效,这些逻辑应该下沉到引擎层。

业务调度层,也就是整个同屏推流方案里的总控层,负责统一管理当前是否已经准备好 publisher、是否已经开始屏幕采集、是否正在 RTMP 推流、是否开启录像、是否启动 RTSP 服务、是否已经发布 RTSP 流,以及后台切换和横竖屏变化时应该如何调整整条链路。页面只负责发出操作意图,真正让系统进入什么状态,应该由这一层来决定。

再往下,是采集适配层。它主要负责把鸿蒙系统的屏幕采集能力、系统播放音频采集能力和麦克风采集能力,统一转换成上层可消费的音视频输入。它的重点不在业务判断,而在于采集本身的封装,比如启动与停止屏幕采集、运行时调整画布尺寸、转发真实视频帧、转发 PCM 音频数据,以及把关键采集事件抛给调度层。

最后是发布控制层,也就是围绕大牛直播SDK构建的统一发布包装层。这一层的价值在于,把 RTMP 推流、本地录像、内置轻量级 RTSP 服务、RTSP 流发布这些能力放进同一个控制器里,而不是各自单独管理。这样一来,用户看到的是多个独立按钮,底层却共用的是同一个 publisher 实例和同一条音视频输入链路。这种统一收口的设计,对项目落地非常重要。

四、页面层应该怎么设计:权限获取和媒体控制真正解耦

在同屏推流场景里,一个很容易被忽视、但实际很重要的原则是:屏幕采集权限获取,必须和具体媒体能力启动解耦。

权限本身是系统能力,RTMP 推流是媒体输出能力,录像和 RTSP 也是另外两类输出能力。如果一开始就把“申请屏幕权限”和“开始 RTMP 推流”绑在一个按钮上,后面一旦业务想改成“只录像不推流”“只开 RTSP 服务不推 RTMP”“先申请权限后续再启动媒体链路”,整个结构就会变得很别扭。

因此,更合理的页面层设计,是把权限单独做一个按钮,然后 RTMP、录像、RTSP 服务、RTSP 流分别控制。页面层示例可以写成下面这种风格:

import { PublisherScreenEngine, ScreenPublishConfig } from '../media/PublisherScreenEngine'

@Entry
@Component
struct SmartScreenPublisherPage {
  @State pushUrl: string = 'rtmp://192.168.0.104:1935/hls/stream2'
  @State rtspPort: number = 38554
  @State streamName: string = 'stream1'
  @State fps: number = 25

  @State isPrepared: boolean = false
  @State isRtmpRunning: boolean = false
  @State isRecording: boolean = false
  @State isRtspServiceRunning: boolean = false
  @State isRtspStreamRunning: boolean = false
  @State logText: string = ''

  private engine: PublisherScreenEngine = new PublisherScreenEngine()

  aboutToAppear(): void {
    this.engine.setLogCallback((msg: string) => {
      this.logText = `${this.logText}\n${msg}`
    })
  }

  private buildConfig(): ScreenPublishConfig {
    return {
      pushUrl: this.pushUrl,
      width: 816,
      height: 1852,
      fps: this.fps,
      gop: this.fps * 2,
      enableRecorder: true,
      recordDir: '/data/storage/el2/base/files/smartpublisher_record',
      recordFileMaxSizeMB: 200,
      enableInnerAudio: true,
      enableMic: false,
      rtspPort: this.rtspPort,
      rtspStreamName: this.streamName
    } as ScreenPublishConfig
  }

  private async onPrepareCapture(): Promise<void> {
    const ok = await this.engine.requestScreenCapturePermission()
    if (!ok) {
      this.logText += '\n申请屏幕采集权限失败'
      return
    }

    const prepared = await this.engine.prepare(this.buildConfig())
    this.isPrepared = prepared
  }

  private async onToggleRtmp(): Promise<void> {
    if (!this.isPrepared) {
      const prepared = await this.engine.prepare(this.buildConfig())
      if (!prepared) {
        return
      }
      this.isPrepared = true
    }

    if (this.isRtmpRunning) {
      await this.engine.stopPublish()
      this.isRtmpRunning = false
    } else {
      const ok = await this.engine.startPublish()
      this.isRtmpRunning = ok
    }
  }

  build() {
    Column({ space: 12 }) {
      Button('申请屏幕采集权限').onClick(() => this.onPrepareCapture())
      Button(this.isRtmpRunning ? '停止RTMP推流' : '开始RTMP推流').onClick(() => this.onToggleRtmp())
      Text(this.logText).fontSize(12)
    }
    .padding(16)
  }
}

这段代码的重点不是 UI 长什么样,而是体现一个结构原则:权限按钮单独存在,媒体能力按钮各自独立,而真正的媒体状态由引擎层统一控制。

HarmonyOS 鸿蒙NEXT无纸化同屏时延测试

五、为什么 PublisherScreenEngine 应该成为整条链路的总控

如果说页面层只是交互入口,那整个同屏推流链路的核心,其实就在 PublisherScreenEngine 这一层。

对于这类多能力叠加的媒体场景,独立的引擎层几乎是必须的。因为它不仅要负责 prepare publisher、启动屏幕采集、接收真实视频帧、接收系统音频和麦克风 PCM、把音视频继续投递给发布层,还要控制 RTMP、录像、RTSP 服务和 RTSP 流,同时还要处理横竖屏变化时的链路同步。

换句话说,它不是“帮页面多转几次接口”,而是真正把整条媒体链路串起来。页面只负责发出操作意图,真正决定系统进入什么状态的是引擎层。

一个更适合放在博客里的简化示例,可以写成下面这样:

import { ArktsScreenCaptureAdapter } from './ArktsScreenCaptureAdapter'
import { SmartPublisherScreenWrapper } from './SmartPublisherScreenWrapper'

export interface ScreenPublishConfig {
  pushUrl: string
  width: number
  height: number
  fps: number
  gop: number
  enableRecorder: boolean
  recordDir: string
  recordFileMaxSizeMB: number
  enableInnerAudio: boolean
  enableMic: boolean
  rtspPort: number
  rtspStreamName: string
}

export class PublisherScreenEngine {
  private capturer: ArktsScreenCaptureAdapter = new ArktsScreenCaptureAdapter()
  private publisher: SmartPublisherScreenWrapper = new SmartPublisherScreenWrapper()
  private config?: ScreenPublishConfig
  private prepared: boolean = false
  private logCallback?: (msg: string) => void

  setLogCallback(cb: (msg: string) => void): void {
    this.logCallback = cb
  }

  private log(msg: string): void {
    if (this.logCallback) {
      this.logCallback(`[ScreenPage] engine: ${msg}`)
    }
  }

  async requestScreenCapturePermission(): Promise<boolean> {
    return await this.capturer.requestPermission()
  }

  async prepare(config: ScreenPublishConfig): Promise<boolean> {
    if (this.prepared) {
      this.log(`prepare skipped, already prepared -> ${config.width}x${config.height}`)
      return true
    }

    this.log(`prepare begin url=${config.pushUrl}`)
    this.config = config

    const applied = this.publisher.applyConfig(config)
    if (!applied) {
      this.log('publisher.applyConfig failed')
      return false
    }

    const started = await this.capturer.start({
      width: config.width,
      height: config.height,
      fps: config.fps,
      enableInnerAudio: config.enableInnerAudio,
      enableMic: config.enableMic,
      onVideoFrame: (frame) => {
        this.publisher.postScreenVideoRGBA(frame.buffer, frame.width, frame.height, frame.stride)
      },
      onInnerAudioPCM: (pcm) => {
        this.publisher.postSystemAudioPCM(
          pcm.buffer,
          pcm.sampleRate,
          pcm.channels,
          pcm.perChannelSamples
        )
      },
      onMicAudioPCM: (pcm) => {
        this.publisher.postMicAudioPCM(
          pcm.buffer,
          pcm.sampleRate,
          pcm.channels,
          pcm.perChannelSamples
        )
      }
    })

    if (!started) {
      this.log('capturer.start failed')
      return false
    }

    this.prepared = true
    this.log('capture pipeline prepared')
    return true
  }

  async startPublish(): Promise<boolean> {
    this.log('startPublish begin')
    return this.publisher.startPublish()
  }

  async stopPublish(): Promise<void> {
    this.publisher.stopPublish()
    this.log('stopPublish done')
  }

  async syncOrientationHot(width: number, height: number): Promise<void> {
    this.log(`syncOrientationHot begin -> ${width}x${height}`)
    await this.capturer.resizeCanvas(width, height)
  }
}

这段代码最关键的地方,不是它写得多复杂,而是它把链路收拢得足够清楚:
页面层不直接碰 native,不直接碰原始音视频输入,也不直接碰 RTSP 服务细节。所有事情先经过引擎层,再由引擎层决定怎么调度。

六、一个 Publisher 承担推流、录像、RTSP 输出

同屏推流一旦开始叠加功能,最容易失控的地方就是状态越来越乱。比如 RTMP 推流在跑,录像也在跑,RTSP 服务已经起来了,但 RTSP 流还没发布;或者录像停了,但推流没停;或者 RTSP 服务停了,但 RTSP 流状态没有同步清掉。只要没有一个清晰的包装层,页面很快就会被这些判断拖垮。

因此,大牛直播SDK在这类场景里更推荐一个思路:一个 Publisher 尽量承载多路输出能力。

只要把 RTMP 推流、本地录像、轻量级 RTSP 服务、RTSP 流发布统一收进一个包装层里,很多事情就会自然得多:

  • 页面层不再需要分别维护多套复杂状态
  • 调度层更容易判断当前链路是否还活着
  • 音视频输入只需要维护一条
  • 后续扩展和问题排查都会更轻松

因此,上层 ETS 里很适合加一层 SmartPublisherScreenWrapper,把这些能力统一收口。示例代码可以写成下面这样:

import { SmartPublisherNative } from './SmartPublisherNative'

export class SmartPublisherScreenWrapper {
  private nativePublisher: SmartPublisherNative = new SmartPublisherNative()
  private opened: boolean = false
  private rtspServiceRunning: boolean = false
  private rtspStreamRunning: boolean = false

  applyConfig(config: {
    pushUrl: string
    width: number
    height: number
    fps: number
    gop: number
    recordDir: string
    recordFileMaxSizeMB: number
  }): boolean {
    if (!this.opened) {
      const openOk = this.nativePublisher.open(config.width, config.height)
      if (!openOk) {
        return false
      }
      this.opened = true
    }

    this.nativePublisher.setURL(config.pushUrl)
    this.nativePublisher.setFPS(config.fps)
    this.nativePublisher.setGopInterval(config.gop)
    this.nativePublisher.setRecorderDirectory(config.recordDir)
    this.nativePublisher.setRecorderFileMaxSize(config.recordFileMaxSizeMB)
    return true
  }

  startPublish(): boolean {
    return this.nativePublisher.startPublisher()
  }

  stopPublish(): void {
    this.nativePublisher.stopPublisher()
  }

  startRecorder(): boolean {
    return this.nativePublisher.startRecorder()
  }

  stopRecorder(): void {
    this.nativePublisher.stopRecorder()
  }

  startRtspService(port: number): boolean {
    const ok = this.nativePublisher.openRtspServer(port)
    this.rtspServiceRunning = ok
    return ok
  }

  stopRtspService(): void {
    this.nativePublisher.stopRtspServer()
    this.rtspServiceRunning = false
  }

  startRtspStream(streamName: string): boolean {
    if (!this.rtspServiceRunning) {
      return false
    }
    this.nativePublisher.setRtspStreamName(streamName)
    const ok = this.nativePublisher.startRtspStream()
    this.rtspStreamRunning = ok
    return ok
  }

  stopRtspStream(): void {
    this.nativePublisher.stopRtspStream()
    this.rtspStreamRunning = false
  }

  postScreenVideoRGBA(
    buffer: ArrayBuffer,
    width: number,
    height: number,
    stride: number
  ): boolean {
    return this.nativePublisher.onScreenVideoRGBA(buffer, width, height, stride)
  }

  postSystemAudioPCM(
    buffer: ArrayBuffer,
    sampleRate: number,
    channels: number,
    perChannelSamples: number
  ): boolean {
    return this.nativePublisher.onPCMData(buffer, sampleRate, channels, perChannelSamples)
  }
}

这段代码的价值不在“多封一层”,而在于让上层结构变得非常清楚:页面层不直接碰 native,调度层不直接管 RTSP 细节,真正和 SDK 对接的是包装层。

HarmonyOS鸿蒙NEXT下RTMP播放器时延测试

七、音频在同屏推流里,比很多人想象得更重要

做同屏推流时,很多人的注意力都放在画面上,但到了真正落地阶段,音频往往才是最影响体验的部分。

因为同屏推流里的音频来源并不单一。有的场景只需要系统播放音频,有的场景只需要麦克风,有的场景系统音频和麦克风都要,而且它们是否参与推流、是否参与录像、是否参与 RTSP 输出,也不一定完全一样。

所以在这类方案里,音频不应该“顺带处理”,而应该单独设计。更合理的方式,是让系统播放音频和麦克风采集在采集层面保持独立,再统一进入 publisher 的音频输入链路,由发布层决定哪些输出能力使用这些 PCM 数据。这样做的好处很直接:页面层不用知道太多音频细节,调度层也只需要控制状态,真正音频如何进入 SDK,统一交给采集层和发布层处理。

八、轻量级 RTSP 服务的意义

很多人一说推流,第一反应就是 RTMP,因为 RTMP 适合往直播服务器或者 CDN 发。但如果只停留在这个层面,其实会低估同屏推流的扩展空间。

轻量级 RTSP 服务在这类场景里有非常现实的价值。因为它意味着当前设备不只是一个采集端,还是一个可以对局域网做实时分发的节点。这样一来,同一条同屏采集链路除了能向云端 RTMP 推流之外,还可以同时做本地录像、启动本地 RTSP 服务,并发布 RTSP 流给局域网客户端拉取。

这类能力在教学演示、设备调试、专网分发、远程协作等场景下都非常实用。对于很多行业项目来说,这种“本地分发 + 云端上行”的组合能力,往往比单一 RTMP 推流更有价值。

结语

做到这一步以后,对鸿蒙 NEXT 同屏推流这件事的判断其实已经很明确了:它绝对不是一个“做个 Demo 看看能不能跑”的功能,而是一项非常值得持续沉淀的实时媒体基础能力。

因为一旦权限获取、屏幕采集、系统音频、麦克风、推流、录像、RTSP 服务、RTSP 流发布这些模块都理顺了,后面不管是做在线培训、远程演示、设备协作、工业终端界面分发,还是更多行业项目,都会非常自然。

对大牛直播SDK来说,这项能力真正重要的地方也不只是“支持 RTMP 推流”或者“支持 RTSP 服务”,而是希望它最终形成一套结构清晰、职责明确、便于集成、适合工程落地的完整方案。


📎 CSDN官方博客:音视频牛哥-CSDN博客 

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐