SmartMediaKit 在鸿蒙 NEXT 下的同屏推流实践:从功能打通到工程化落地
本文系统探讨了鸿蒙NEXT平台下同屏推流技术的实现方案。文章指出,同屏推流远非简单的屏幕录制延伸,而是涉及权限管理、音视频采集、编码推流、本地录像、RTSP服务等多模块协同的完整链路。作者提出分层架构设计思路:将用户交互、业务调度、采集适配和发布控制分离,通过PublisherScreenEngine作为总控层协调各模块。特别强调了音频处理的复杂性,以及轻量级RTSP服务在本地分发场景中的价值。该
在移动端音视频开发场景里,“同屏推流”看起来像是一个相对具体的小功能,但真正做起来,往往比普通摄像头推流更复杂。原因并不难理解:它不是单纯采集一段视频数据,而是要围绕系统级屏幕内容采集,把权限管理、后台运行、系统音频采集、麦克风采集、实时编码、录像、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博客
更多推荐



所有评论(0)