Android投屏MirrorCast全链路

Android 投屏全链路:从应用层到 BSP 的一次彻底拆解

当你把手机画面投到平板上,背后到底发生了什么?这篇从上往下走一遍,覆盖应用 API、framework、AudioFlinger / SurfaceFlinger、HAL,到驱动层。基于 AOSP 标准实现。


一、整体架构:双通道并行的镜像投屏

镜像投屏不是单向"推流",而是手机和接收端之间一直保持双向控制 + 数据通道。手机端做的事:把屏幕画面和音频"复制"一份送进编码器,然后通过网络发到接收端。接收端做的事:解码 + 显示 + 播放。

视频和音频是两条完全独立的通道。这一点很关键------它解释了为什么常见 bug 会出现"画面正常但没声音",反过来基本不存在"有声音但画面卡死"。


二、应用层 API:MediaProjection 体系

Android 应用想要做投屏,标准入口是 MediaProjection(API 21 起)。简化的代码骨架:

java 复制代码
// 1. 申请投屏权限(弹窗"是否允许录屏")
MediaProjectionManager mpm =
    (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
Intent intent = mpm.createScreenCaptureIntent();
startActivityForResult(intent, REQUEST_CODE);

// 2. 用户同意后拿到 MediaProjection
MediaProjection mp = mpm.getMediaProjection(resultCode, data);

// 3. 创建编码器,拿到 Surface
MediaCodec videoEncoder = MediaCodec.createEncoderByType("video/avc");
videoEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
Surface inputSurface = videoEncoder.createInputSurface();
videoEncoder.start();

// 4. 创建虚拟显示器, 输出接到编码器 Surface
VirtualDisplay vd = mp.createVirtualDisplay(
    "screen-mirror",         // 名字, 后续在 dumpsys 中能看到
    width, height, density,
    flags,
    inputSurface,            // ★ 关键: surface 接到编码器
    null, null);

// 5. 拉取编码后的 NAL units
while (mirroring) {
    int idx = videoEncoder.dequeueOutputBuffer(bufferInfo, timeout);
    ByteBuffer nal = videoEncoder.getOutputBuffer(idx);
    networkSend(nal);
}

// 6. 音频侧 (Android 10+ 提供应用音频捕获)
AudioPlaybackCaptureConfiguration config =
    new AudioPlaybackCaptureConfiguration.Builder(mp)
        .addMatchingUsage(AudioAttributes.USAGE_MEDIA)
        .build();

AudioRecord audioRecord = new AudioRecord.Builder()
    .setAudioPlaybackCaptureConfig(config)
    .setAudioFormat(...)
    .build();
audioRecord.startRecording();

MediaProjection 本质是 framework 给应用的一张"通行证",凭这张证可以创建 VirtualDisplay(视频)和注册 AudioPolicyMix(音频)。

AudioPlaybackCaptureConfiguration 是 Android 10 新增的应用音频捕获 API。它会在 framework 内部翻译为一个 AudioPolicyMix 规则注册到 AudioPolicyManager。


三、视频通道:从 SurfaceFlinger 到编码器

3.1 VirtualDisplay 的本质

VirtualDisplay 不是真显示器,是 SurfaceFlinger 里的一块"虚拟显示层",跟主屏幕一起接收合成结果。

每帧合成时,SurfaceFlinger 会把同一个 layer stack 的内容画到所有目标 display 上。源码位于 frameworks/native/services/surfaceflinger/。

3.2 数据流

![外

Surface 直接接编码器这一点很关键。它不是 CPU 拷贝像素,而是 GPU buffer 直接喂给硬件编码器,零拷贝,所以即使 4K 60fps 也吃得消。

3.3 编码器:MediaCodec 与 Codec2

应用层调的是 MediaCodec,Android 10 起底下走 Codec2 framework:

接收端做的事是反过来:拿 NAL → MediaCodec 解码 → 输出到 Surface(设备屏幕)。

3.4 视频通道为什么很少出问题

视频通道的特点:

  • 无状态推流:每一帧画面都是独立的,没有跨帧的"客户端状态"需要和 server 同步
  • 断流就是黑帧:编码失败/网络丢包顶多"画面卡几帧",不会出现"画面长时间错乱"
  • 驱动稳定:硬件编码器 IP 经过多年迭代,bug 大多被修了

实际中视频通道出问题大都是「黑屏」或「掉帧」,往 MediaCodec stats 和 SurfaceFlinger dumpsys 一看就清楚。


四、音频通道:r_submix loopback 机制

音频投屏比视频复杂。手机的音频体系本来是设计给"播放到喇叭"的,要把它"loopback 出来"喂给镜像编码器,需要走一条特殊的虚拟设备路径。

4.1 整体音频路径

4.2 r_submix(remote_submix)是什么

r_submix 是 AOSP 提供的虚拟音频设备。源码:

  • hardware/interfaces/audio/aidl/default/r_submix/StreamRemoteSubmix.cpp
  • 在 audio_policy_configuration.xml 里配置成一个 module

它的特殊性:有 sink(写)和 source(读)两端,两端共享一个内存 FIFO。

![外链图片转存失败

普通耳机/喇叭的 HAL 只有 sink,PCM 写过去就喂给硬件。r_submix 的 HAL 把 PCM 写到内存 FIFO,让另一个 AudioRecord 从同样的 FIFO 读出来------等价于在内核里把"扬声器"和"麦克风"用一根虚拟线接起来。

4.3 AudioPolicyMix:让 AudioPolicy 知道该把谁的音频送到 r_submix

镜像 App 通过 MediaProjection.createAudioRecord() 实际做的事是:注册一个 AudioPolicyMix 规则到 AudioPolicyManager:

复制代码
"uid=10295 (player app) 发出的 USAGE_MEDIA 音频,
 请帮我 loopback 到 mix `1646668027:ap:41mixp:0`,
 我要在那边读"

底层 API:

  • Java:AudioPolicy / AudioMixingRule
  • Native:AudioPolicyManager::registerPolicyMixes
  • 配置可见 frameworks/av/services/audiopolicy/

注册后,AudioPolicyManager 在 getOutputForAttr()(决定一个 AudioTrack 该走哪条 output)时就会把这个 uid 的 MEDIA 音频路由到一个新建的 r_submix output。

4.4 为什么音频通道有"客户端状态"问题

这是和视频最大的区别:音频客户端有持续的状态需要和 server 同步:

  • 已写入的 sample 数(samples_written)
  • 当前 server 端播放位置(frame_pos,通过 getTimestamp() 获取)
  • 客户端音量(volume)通过共享内存的 cblk 字段传给 server mixer
  • 设备路由(routedDeviceIds)------ 当前这个 AudioTrack 输出到哪个 device

当路由变化(比如从 SPEAKER 切到 r_submix),底层会触发 restoreTrack_l:销毁旧的 server 端 IAudioTrack,创建新的;客户端还在持有同一个 android::AudioTrack 对象,但底下的 server 端已经换了。

如果客户端对路由变化没有正确响应(不重发音量、不更新状态、不通知 listener),就会出现各种诡异问题------这是音频投屏 bug 的高发区。


五、AudioFlinger / AudioPolicyManager / AudioService 三剑客

Android 音频架构里这三个角色经常被混淆,澄清一下:

组件 进程 职责
AudioFlinger audioserver 实际混音器。所有 AudioTrack 写来的 PCM,都在这里被混合后送给 HAL
AudioPolicyManager audioserver 路由决策。决定一个 AudioTrack 该走哪个 output(speaker/HDMI/r_submix),音量怎么算
AudioService system_server (Java) 上层管理:音量按键、焦点、播放配置追踪、AudioPlaybackCallback 分发

它们的协作:

![外链图片

5.1 PlayerBase:让 AudioService 看见你的 player

任何 AudioTrack(Java 或 native)都应该通过 PlayerBase 注册到 AudioService,否则 AudioService 不知道有这个 player 在播放,相关功能(音量管控、焦点、镜像 captures、电池统计)都会缺失。

Java AudioTrack 默认就调了:

java 复制代码
// frameworks/base/media/java/android/media/AudioTrack.java
private AudioTrack(...) {
    super(attributes, AudioPlaybackConfiguration.PLAYER_TYPE_JAM_AUDIOTRACK);
    ...
    baseRegisterPlayer(mSessionId);
    // 内部 trackPlayer(new PlayerIdCard(...)) 通过 binder 注册
}

native 路径需要主动通过 PlayerBase::init() 注册(frameworks/av/media/libaudioclient/PlayerBase.cpp)。

5.2 路由变化的事件链

这是音频投屏中最关键、也是最容易出 bug 的部分。

!

任何一步出问题,下游都会"看不到 player 状态变化"。


六、HAL 层:硬件抽象的边界

Android Audio HAL 经历了几代演进:

  • HAL 1.x:本地共享对象
  • HAL 2.x:HIDL
  • HAL 3.x(Android 13+):AIDL(当前推荐)

新设备走 AIDL HAL,源码在 hardware/interfaces/audio/aidl/。

6.1 一个 output 的完整通路

!

6.2 音量在哪些层级处理

音量在多层都有处理,理解这个对调音很关键:

层级 控制范围 实现
App 端 单个 AudioTrack 的 left/right gain AudioTrack::setVolume(float, float) → 写入 cblk 共享内存
Server cblk 单个 track 的最终增益 mixer 读取 cblk 的 mVolumeLR 字段做混音
Stream type 系统音量 (媒体/通话/铃声) AudioPolicyManager::checkAndSetVolume,按 stream 全局调
HAL 硬件音量 (DSP/codec gain) IDevice::setMasterVolume() 或 effect chain
Audio Effect 各种 effect (EQ/虚拟环绕/降噪) frameworks/av/media/libeffects

实际生效的是各层 gain 的乘积。比如 system_volume × app_track_volume × dsp_gain,任何一个为 0 都会导致"无声"。

6.3 r_submix HAL 的特殊性

普通 HAL 实现 IStreamOut::write(buffer, size) 是写到硬件。r_submix HAL 实现的 write 是写到一个内核环形 buffer(pipe),同时另一个 IStreamIn::read 读取这个 buffer。

代码(简化):

cpp 复制代码
// hardware/interfaces/audio/aidl/default/r_submix/StreamRemoteSubmix.cpp
ndk::ScopedAStatus StreamRemoteSubmix::transfer(...) {
    if (mIsInput) {
        // 从 sink pipe 读
        return mPipeReader->read(buffer, size);
    } else {
        // 写到 sink pipe
        return mPipeWriter->write(buffer, size);
    }
}

注意 r_submix HAL 没有自己的音量控制 ------ mixer 写来的 PCM 是什么增益,FIFO 里就是什么增益。这意味着任何"AudioTrack 客户端音量未刷新"的问题会直接表现为"FIFO 里全是静音",比真喇叭路径更容易暴露 bug。


七、BSP / Vendor 层:DSP 与 Codec

到了真硬件这一层,各家 SoC 厂商(高通 / 联发科 / Exynos / 紫光展锐)实现差别就很大了。共通点:

7.1 调试这一层的工具

工具 用途
dumpsys media.audio_flinger AudioFlinger 内部状态
dumpsys media.audio_policy 路由决策 / 当前所有 output / mix 列表
dumpsys audio AudioService 状态 / focus / 全部 player
tinyplay / tinycap 直接走 ALSA 测播放/录音
cat /proc/asound/cards 看 ALSA 注册的声卡
cat /sys/kernel/debug/asoc/* ASoC 路径 / DAPM widget 状态

ASoC(ALSA System on Chip)是 ALSA 在嵌入式平台的扩展,连接 codec 驱动和 platform driver。


八、几个常见投屏问题的诊断思路

8.1 接收端"无声音"

定位完通常会发现是某一层"通知没传到"。

8.2 接收端"音画不同步"

视频和音频是两条独立通道,时间戳本来就不严格对齐。常见原因:

  • 编码器 latency 不一致(视频比音频晚 50~200ms 是正常的)
  • 网络抖动让两条流分开传
  • 接收端的渲染线程没用 AVSync

接收端要做的事:用 PTS 对齐 + 一个时钟参考做 drop frame / 静音填充。

8.3 接收端"卡顿"

视频卡:先看 MediaCodec stats(内置的统计:DQin/DQout 比例,编码耗时),再看 dumpsys SurfaceFlinger 的 fps。

音频卡:看 dumpsys media.audio_flinger 的 underrun count。

8.4 切到蓝牙耳机后投屏端无声

通常是音频路由优先级问题。蓝牙优先级高于 r_submix,发生抢路由。解决方法:

  • 修改 audio_policy_configuration.xml,调整 priority
  • 或镜像 App 在注册 PolicyMix 时设置 MIX_RULE_FLAG_EXCLUSIVE

九、安全与隐私边界

镜像投屏涉及屏幕和音频内容,Android 在多个层级做了限制:

层级 限制
应用层 必须用 MediaProjection API,每次启动弹窗确认
Window 层 DRM 内容、银行 App 等带 FLAG_SECURE 的窗口在 VirtualDisplay 上会变黑
AudioPolicy 层 只能 capture USAGE_MEDIA / GAME / UNKNOWN 等开放 usage,VOICE_COMMUNICATION 等被禁
应用清单 App 自己可以 android:allowAudioPlaybackCapture="false" 拒绝被捕获

这些限制不是 vendor 自定义,是 AOSP 标准行为,源码在 services/core/java/com/android/server/audio/ 和 frameworks/native/services/surfaceflinger/。


十、给应用开发者的几条经验

10.1 视频通道

  • 编码器优先用 H.265,码率比 H.264 省 30%~50%。但有些老接收端不支持,做协商
  • 用 Surface input 方式给编码器喂数据,不要走 ByteBuffer 拷贝
  • VirtualDisplay 销毁后再重建会很慢,尽量复用

10.2 音频通道

  • AudioPlaybackCaptureConfiguration 的 usage 列表要全:MEDIA / GAME / UNKNOWN / ASSISTANCE_SONIFICATION 至少加上
  • 接收端 AudioRecord 的 buffer size 要算好:太小容易 underrun,太大延迟高
  • 订阅 AudioManager.registerAudioPlaybackCallback 来动态感知 player 变化,比定时 poll 强很多

10.3 控制通道

  • TCP(命令) + UDP(数据)的组合比纯 TCP 强很多
  • 心跳要做,断网检测要做
  • 输入事件反向发送时注意坐标系换算(手机和接收端分辨率/方向可能不同)

一些参考阅读

希望这篇能让你对 Android 投屏的「应用 → framework → AudioFlinger/SurfaceFlinger → HAL → BSP」整条链路有个完整的图。下次遇到问题时,知道该往哪一层看。

相关推荐
千里马学框架5 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台5 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone5 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc5 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo5 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077005 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼5 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone5 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen5 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone5 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui