Android 手机投屏到 Mac/iPhone 的架构实现
前言
手机投屏是一项常见的需求:把 Android 手机的画面实时投射到电脑(Mac)或平板(iPad/iPhone)上,用于演示、远程协助或多媒体分享。实现一个低延迟、高流畅度的投屏方案,需要覆盖从手机端的屏幕采集编码、网络传输、到接收端的解码渲染全链路。
本文以小米手机的妙享桌面投屏方案为例,从架构视角分析两大核心能力的实现:镜像投屏(Screen Mirroring) 和反向控制(Reverse Control)。
一、整体架构
投屏传输 SDK
整个投屏系统的底座是一套跨平台投屏传输 SDK(C++ 实现)。该 SDK 同时运行在 Source 端和 Sink 端,负责连接建立、协议解析、加解密和媒体数据传输。它不关心具体平台——无论是小米手机投屏到 Mac,还是小米手机投屏到小米 Pad,底层走的是同一套 SDK,上层各平台只需对接解码和渲染。
端到端数据流
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─ Source 端(手机)────────────────────────────────────────────────┐
│ │
│ 屏幕采集 → H.264/H.265 编码 │
│ ↓ │
│ ┌─ 投屏传输 SDK (Source 模式) ─────────────────────────────────┐ │
│ │ AES 加密 → RTP 打包 → UDP/TCP 发送 │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────┬──────────────────────────────┘
│ Wi-Fi 局域网
▼
┌─ Sink 端(Mac / iPhone / Pad)────────────────────────────────────┐
│ ┌─ 投屏传输 SDK (Sink 模式) ──────────────────────────────────┐ │
│ │ UDP/TCP 接收 → RTP 重组 → AES 解密 → 回调分发音视频数据 │ │
│ └──────────────────────────────┬──────────────────────────────┘ │
│ ▼ │
│ ┌─ 平台层(Mac/iPhone 各自实现)───────────────────────────────┐ │
│ │ 视频解码 → 帧调度 → Metal 渲染 │ │
│ │ 音频解码 → AudioQueue 播放 │ │
│ │ 反向控制:事件采集 → 指令编码 → UDP 发送 → 手机端输入注入 │ │
│ └──────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
数据流包含两条通道:媒体通道(手机→接收端,承载视频/音频)和控制通道(接收端→手机,承载触控指令)。投屏传输 SDK 处理完网络和加解密后,将裸音视频数据通过回调交给平台层处理。
分层架构
以 Sink 端为例,从下到上三层职责明确:
1
2
3
4
5
6
7
8
9
10
11
12
13
┌───────────────────────────────────────────────────────────────┐
│ UI 渲染层 │
│ Metal 渲染视图 (macOS: NSView / iOS: UIView + CAMetalLayer) │
├───────────────────────────────────────────────────────────────┤
│ 编解码引擎层(C++) │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ │
│ │ VideoDecoder │ │ AudioDecoder │ │ RenderManager │ │
│ │ (双通道硬解) │ │ (AAC / PCM) │ │ (帧调度/丢帧/VSync)│ │
│ └──────────────┘ └──────────────┘ └────────────────────┘ │
├───────────────────────────────────────────────────────────────┤
│ 投屏传输 SDK(C++ 静态库,跨 Android/macOS/iOS/Pad) │
│ 网络收发 / RTP 解析 / AES 加解密 / 会话管理 / 状态回调 │
└───────────────────────────────────────────────────────────────┘
| 层 | 语言 | 跨平台能力 | 职责 |
|---|---|---|---|
| 投屏传输 SDK | C++ | Android / macOS / iOS 共用 | 网络 I/O、协议解析、加解密、连接生命周期 |
| 编解码引擎层 | C++ | macOS / iOS 共用 | 视频/音频解码、帧调度与渲染时机控制 |
| UI 渲染层 | Swift / ObjC | 平台各自实现 | Metal 纹理上屏、音频会话配置、事件采集 |
投屏传输 SDK 是最底层的跨端基础设施:手机端以 Source 模式运行(编码后交给 SDK 发送),接收端以 Sink 模式运行(SDK 接收后回调给上层解码渲染)。这使得同一套 SDK 可以支撑”手机→Mac”、”手机→iPhone”、”手机→Pad”等多种投屏组合。
二、Source 端:手机编码与发送
2.1 编码参数选型
1
2
3
4
5
6
7
8
9
struct EncoderConfig {
CodecType codec = CodecType::H264;
int width = 1920;
int height = 1080;
int fps = 60;
int bitrate = 8 * 1000 * 1000; // 8 Mbps
int profile;
int level;
};
H.264 vs H.265 的选择:
| 对比 | H.264 | H.265 |
|---|---|---|
| 硬件兼容性 | 几乎所有设备支持 | 较新设备支持 |
| 同等画质码率 | 基准 | 降低约 30-50% |
| 编码延迟 | 较低 | 略高 |
| 解码复杂度 | 低 | 高 |
策略:优先 H.264 保证兼容性,接收端声明支持 H.265 时切换以降低码率。分辨率和帧率根据网络状况动态调整——网络良好时 1080p@60fps,网络波动时降级到 720p@30fps。
2.2 数据打包与发送流程
编码器输出的压缩帧经过以下流水线到达网络:
1
2
3
4
5
6
7
8
9
10
11
12
13
编码器输出 NAL 单元
↓
NAL 分片与封装
↓
RTP 打包(序列号 + timestamp)
↓
AES 加密
↓
┌────┴────┐
│ 网络状况 │
└────┬────┘
正常 ↙ ↘ 严重丢包
UDP 发送 TCP 发送
NAL 分片: 一帧 H.264 数据可能超过 UDP 的 MTU(1500 字节),需要分片。接收端根据 RTP 序列号重组完整帧。
RTP 协议: 序列号用于检测丢包和排序,timestamp 携带帧的采集时间,接收端据此计算播放时间戳和抖动缓冲。
AES 加密: 所有视频载荷加密,确保投屏内容在局域网内不被窃听。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MediaSender {
public:
void sendEncodedFrame(const uint8_t* data, size_t size, int64_t pts) {
auto fragments = fragmentNAL(data, size);
for (auto& frag : fragments) {
RTPPacket packet;
packet.sequenceNumber = nextSequenceNum_++;
packet.timestamp = toRTPTimestamp(pts);
packet.payload = encrypt(frag.data, frag.size);
if (useTCP_) {
sendTCP(packet.data(), packet.size());
} else {
sendUDP(packet.data(), packet.size());
}
}
}
void onNetworkDegraded() {
useTCP_ = true;
encoder_.setBitrate(bitrate_ / 2);
}
private:
uint16_t nextSequenceNum_{0};
bool useTCP_{false};
};
2.3 编码器的事件回调
- 关键帧请求(IDR Request): 接收端检测到丢包或解码异常时,请求手机发送关键帧恢复画面
- 码率变化通知: 网络带宽变化时,通知编码器调整目标码率
- 编码异常: 硬件故障或资源不足的异常处理
三、网络传输
3.1 传输层设计
1
2
3
4
5
6
7
8
9
┌─────────────────────────────────────────────┐
│ 应用层:媒体数据(视频帧/音频帧/控制指令) │
├─────────────────────────────────────────────┤
│ 传输封装层:RTP(时序、序列号、丢包检测) │
├─────────────────────────────────────────────┤
│ 传输协议层:自定义投屏协议(UDP / TCP 降级) │
├─────────────────────────────────────────────┤
│ 网络层:IP 网络(局域网 Wi-Fi) │
└─────────────────────────────────────────────┘
UDP 优先 + TCP 降级: 投屏对延迟极度敏感,首选 UDP。当检测到严重丢包或 NAT 限制 UDP 不通时,自动降级到 TCP 保证可用性。切换条件:连续丢包率超过阈值,或 UDP 握手超时。
RTP 的作用: 序列号让接收端检测丢包并重排乱序包。timestamp 将帧从”网络到达时间”还原为”采集时间”,是帧调度(Jitter Buffer)的基础。
3.2 连接建立流程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Source 端(手机) Sink 端(Mac/iPhone)
│ │
│ 1. 设备发现广播 │
│ ────────────────────────────────────► │
│ │
│ 2. 握手请求 + 能力协商 │
│ ◄──────────────────────────────────── │
│ (编解码、分辨率、加密方式) │
│ │
│ 3. 加密参数协商(密钥交换) │
│ ◄────────────────────────────────────►│
│ │
│ 4. UDP 通道建立,开始传输 │
│ ════════════════════════════════════► │
│ 媒体流 │
3.3 码率自适应
接收端通过 RTP 序列号检测丢包率,定期反馈给手机端。手机端据此动态调整编码参数:
- 丢包率 < 1%:维持当前码率
- 丢包率 1-5%:降低码率 20%
- 丢包率 > 5%:大幅降低码率和分辨率,甚至降级到 TCP
四、Sink 端:接收、解码与渲染
Sink 端是整个系统最复杂的部分。内部架构:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─ SDK 协议层 ──────────────────────────────────────────────┐
│ UDP/TCP 接收 → AES 解密 → RTP 重组 → 媒体类型分发 │
└────────────────────────────┬─────────────┬────────────────┘
│ 视频 │ 音频
┌─ 编解码引擎层 ─────────────▼─────────────▼────────────────┐
│ │
│ VideoDecoder(双通道) AudioDecoder(AAC/PCM) │
│ ↓ ↓ │
│ RenderManager AudioPlayer │
│ (Jitter Buffer/帧调度) (AudioQueue 环形缓冲) │
│ ↓ │
├───────┼───────────────────────────────────────────────────┤
│ ↓ UI 渲染层 │
│ MetalRenderView(YUV→RGB / 零拷贝) │
└───────────────────────────────────────────────────────────┘
4.1 SDK 协议层
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class SessionClient {
public:
void registerStateCallback(StateCallback* cb);
void registerVideoPlugin(VideoPlugin* plugin);
void registerAudioPlugin(AudioPlugin* plugin);
int start(const char* uri, const SessionConfig& config);
int stop();
int pause();
int resume();
int setAttribute(AttributeType type, int value);
private:
void receiveLoop();
void decryptAndDispatch(const uint8_t* data, size_t size);
};
协议层通过回调模式分发数据:收到视频数据调用 VideoPlugin::write(),收到音频数据调用 AudioPlugin::write(),连接状态变化通过 StateCallback 上报。
4.2 视频解码引擎
视频解码器采用双通道设计——一条基于 FFmpeg + VideoToolbox 硬件加速,另一条直接调用原生 VideoToolbox API。
双通道设计的由来:最初使用 FFmpeg + VideoToolbox 路径,开发过程中在 Intel Mac 上遇到花屏问题。当时怀疑是 FFmpeg 的解码输出异常,因此开发了原生 VideoToolbox 解码路径进行对比验证。后来定位到花屏的根因在 Metal 渲染层(详见踩坑记录),但双通道架构保留下来,为不同场景提供了灵活的选择。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class VideoDecoder {
public:
int onInit(MediaAttribute attr);
int onStart();
int onStop();
int write(const uint8_t* data, size_t size, int64_t pts);
void setFrameCallback(FrameDecodedCallback cb);
private:
void decodeWithFFmpeg();
void decodeWithNativeVT();
void decodeLoop();
RenderManager renderManager_;
bool useFFmpegHardware_{false};
std::unique_ptr<NativeVTDecoder> nativeVTDecoder_;
};
FFmpeg + VideoToolbox 路径
利用 FFmpeg 的硬件加速框架,将 VideoToolbox 注册为硬件设备:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int FFmpegDecoder::initHardware(int width, int height) {
const AVCodec* codec = avcodec_find_decoder(AV_CODEC_ID_H264);
codecContext_ = avcodec_alloc_context3(codec);
// 创建 VideoToolbox 硬件设备上下文
AVBufferRef* hwDeviceCtx = nullptr;
av_hwdevice_ctx_create(&hwDeviceCtx, AV_HWDEVICE_TYPE_VIDEOTOOLBOX,
nullptr, nullptr, 0);
codecContext_->hw_device_ctx = av_buffer_ref(hwDeviceCtx);
codecContext_->get_format = [](AVCodecContext*, const AVPixelFormat*) {
return AV_PIX_FMT_VIDEOTOOLBOX;
};
return avcodec_open2(codecContext_, codec, nullptr);
}
FFmpeg 统一封装了 NAL 解析、解码和内存管理逻辑,代码量最少,但多一层抽象。
原生 VideoToolbox 路径
直接调用 VTDecompressionSession,自定义 NAL 单元解析和 CMSampleBuffer 构造:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class NativeVTDecoder {
public:
int init(int width, int height, CodecType codec) {
// 从码流中提取 SPS/PPS 构造 FormatDescription
CMVideoFormatDescriptionCreateFromH264ParameterSets(
nullptr, paramCount, paramPointers, paramSizes, 4,
&formatDescription_);
// 创建解码会话
VTDecompressionOutputCallbackRecord callback{&onFrameDecoded, this};
return VTDecompressionSessionCreate(
nullptr, formatDescription_, decoderSpec,
destImageBufferAttributes, &callback, &session_);
}
int decode(const uint8_t* data, size_t size, int64_t pts) {
// Annex-B startcode → AVCC 长度前缀格式转换
auto avccData = convertAnnexBToAVCC(data, size);
// 构造 CMSampleBuffer 并提交解码
CMBlockBufferRef blockBuffer;
CMBlockBufferCreateWithMemoryBlock(nullptr, avccData.data(),
avccData.size(), kCFAllocatorNull, nullptr, 0,
avccData.size(), 0, &blockBuffer);
CMSampleBufferRef sampleBuffer;
CMSampleBufferCreate(nullptr, blockBuffer, true, nullptr,
nullptr, formatDescription_, 1, 1, &timingInfo,
0, nullptr, &sampleBuffer);
VTDecompressionSessionDecodeFrame(session_, sampleBuffer,
kVTDecodeFrame_EnableAsynchronousDecompression, nullptr, nullptr);
CFRelease(sampleBuffer);
CFRelease(blockBuffer);
return 0;
}
private:
static void onFrameDecoded(void* refCon, void*,
OSStatus status, VTDecodeInfoFlags,
CVImageBufferRef imageBuffer,
CMTime pts, CMTime) {
auto* self = static_cast<NativeVTDecoder*>(refCon);
if (status == noErr && self->frameCallback_) {
self->frameCallback_(imageBuffer, CMTimeGetSeconds(pts));
}
}
VTDecompressionSessionRef session_{nullptr};
CMVideoFormatDescriptionRef formatDescription_{nullptr};
FrameCallback frameCallback_;
};
选型策略
| 场景 | 推荐路径 | 原因 |
|---|---|---|
| H.264 编码 | 原生 VT | 延迟最低,NAL 解析可控 |
| H.265 编码 | FFmpeg + VT | FFmpeg 的 hwaccel 对 H.265 支持更完整 |
| 异常降级 | FFmpeg 软解 | 两种硬解均失败时回退到 CPU 软解 |
| 分辨率动态变化 | 原生 VT | 可精确控制 Session 重建时机 |
4.3 帧调度与渲染管理
帧调度器是控制延迟与流畅度平衡的核心:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class RenderManager {
public:
void addFrame(int64_t index, int64_t pts);
int64_t render(); // 返回下次渲染的等待时间(μs)
uint32_t forceRender();
void updateJitterBuffer(int32_t bufferMs, int64_t currentTime);
void setRefreshRate(int rate);
using RenderCallback = std::function<void(int64_t pts, int64_t localTime, bool dropped)>;
void setRenderCallback(RenderCallback cb);
private:
struct FrameInfo {
int64_t index;
int64_t pts;
int64_t decodeClock;
int64_t renderClock;
};
std::deque<FrameInfo> pendingFrames_;
int64_t lastRenderTime_{0};
int64_t recommendMinus_{0}; // 推荐的渲染提前量
int64_t vsyncInterval_{16000}; // V-Sync 间隔(μs, ~60fps)
int64_t maxRenderDelayUs_{70000}; // 最大容忍渲染延迟
int maxRenderCache_{0};
};
核心调度逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int64_t RenderManager::render() {
if (pendingFrames_.empty()) return emptyWaitTime_;
auto& frame = pendingFrames_.front();
int64_t now = currentTimeMicros();
int64_t targetTime = frame.pts + recommendMinus_;
// 队列积压:丢弃旧帧,只保留最新
if (maxRenderCache_ > 0 && pendingFrames_.size() > maxRenderCache_) {
while (pendingFrames_.size() > 1) {
pendingFrames_.pop_front();
dropCount_++;
}
}
// 帧已过期太久:丢弃
if (now - targetTime > maxRenderDelayUs_) {
pendingFrames_.pop_front();
dropCount_++;
return 0; // 立即检查下一帧
}
// 未到渲染时间:等待
if (now < targetTime) return targetTime - now;
// 渲染
pendingFrames_.pop_front();
renderCallback_(frame.pts, now, false);
return vsyncInterval_;
}
关键设计决策:
- 丢帧策略:队列积压时优先丢弃旧帧保留新帧——投屏显示的是”当前画面”而非”流畅回放”
- Jitter Buffer:抖动大时增大 buffer(用延迟换流畅),抖动小时减小 buffer(降低延迟)
- V-Sync 对齐:渲染时机对齐屏幕刷新信号,减少画面撕裂
4.4 Metal 硬件渲染
解码器输出 CVPixelBuffer 后需要上屏显示。Apple 平台有两种方案:直接用 AVSampleBufferDisplayLayer(系统托管渲染),或用 Metal 自己控制渲染。
| 维度 | Metal 自渲染 | AVSampleBufferDisplayLayer |
|---|---|---|
| 渲染时机 | 完全自控(配合 VSync) | 系统内部缓冲队列,延迟不可控 |
| 丢帧策略 | 自定义(丢旧保新) | 系统决定,无法干预 |
| 色彩空间 | 自己写 YUV→RGB shader | 系统自动处理 |
| 代码复杂度 | 高 | 低(几行 enqueue 代码) |
投屏是延迟敏感场景,核心需求是自己决定”什么时候渲染哪一帧”。网络抖动导致帧积压时,需要丢掉旧帧只显示最新画面——这在 AVSampleBufferDisplayLayer 中无法实现(它按 PTS 顺序平滑播放,适合视频播放器,不适合实时投屏)。因此选择 Metal 自渲染。
Metal 渲染视图通过 CVMetalTextureCache 实现零拷贝上屏:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class MetalRenderView {
private var device: MTLDevice!
private var commandQueue: MTLCommandQueue!
private var pipelineState: MTLRenderPipelineState!
private var textureCache: CVMetalTextureCache!
private var metalLayer: CAMetalLayer!
func setupMetal() {
device = MTLCreateSystemDefaultDevice()
commandQueue = device.makeCommandQueue()
metalLayer = CAMetalLayer()
metalLayer.device = device
metalLayer.pixelFormat = .bgra8Unorm
// CVMetalTextureCache:零拷贝的关键
CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &textureCache)
setupRenderPipeline()
}
func render(pixelBuffer: CVPixelBuffer) {
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
// Y 平面纹理(零拷贝:直接映射 IOSurface)
var texY: CVMetalTexture?
CVMetalTextureCacheCreateTextureFromImage(
kCFAllocatorDefault, textureCache,
pixelBuffer, nil, .r8Unorm, width, height, 0, &texY)
// UV 平面纹理
var texUV: CVMetalTexture?
CVMetalTextureCacheCreateTextureFromImage(
kCFAllocatorDefault, textureCache,
pixelBuffer, nil, .rg8Unorm, width / 2, height / 2, 1, &texUV)
guard let mtlY = CVMetalTextureGetTexture(texY!),
let mtlUV = CVMetalTextureGetTexture(texUV!) else { return }
// 提交 GPU 绘制命令
guard let drawable = metalLayer.nextDrawable(),
let commandBuffer = commandQueue.makeCommandBuffer() else { return }
let descriptor = MTLRenderPassDescriptor()
descriptor.colorAttachments[0].texture = drawable.texture
descriptor.colorAttachments[0].loadAction = .clear
let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)!
encoder.setRenderPipelineState(pipelineState)
encoder.setFragmentTexture(mtlY, index: 0)
encoder.setFragmentTexture(mtlUV, index: 1)
encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
encoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
YUV→RGB Shader:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <metal_stdlib>
using namespace metal;
struct VertexOut {
float4 position [[position]];
float2 texCoord;
};
fragment float4 fragmentShader(VertexOut in [[stage_in]],
texture2d<float> texY [[texture(0)]],
texture2d<float> texUV [[texture(1)]]) {
constexpr sampler s(address::clamp_to_edge, filter::linear);
float y = texY.sample(s, in.texCoord).r;
float2 uv = texUV.sample(s, in.texCoord).rg;
// BT.709 色彩空间转换
float y_adj = y - 0.0625;
float u = uv.x - 0.5;
float v = uv.y - 0.5;
float r = y_adj + 1.5748 * v;
float g = y_adj - 0.1873 * u - 0.4681 * v;
float b = y_adj + 1.8556 * u;
return float4(r, g, b, 1.0);
}
4.5 音频同步
音频路径相对简单:
1
2
3
4
5
6
7
8
9
10
11
12
class AudioDecoder {
public:
enum class Format { AAC, PCM };
int init(Format format, int sampleRate, int channels);
int write(const uint8_t* data, size_t size, int64_t pts);
int readPCM(uint8_t* buffer, size_t size, int64_t* pts);
private:
AACDecoder aacDecoder_; // FFmpeg AAC → PCM
PCMDecoder pcmDecoder_; // PCM 直通
};
AudioQueue 环形缓冲: 使用 5 个缓冲区的环形结构,AudioQueue 系统回调请求数据时从 ring buffer 填充:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class AudioPlayer {
static constexpr int kBufferCount = 5;
AudioQueueRef queue_{nullptr};
AudioQueueBufferRef buffers_[kBufferCount];
static void onBufferRequest(void* userData, AudioQueueRef queue,
AudioQueueBufferRef buffer) {
auto* player = static_cast<AudioPlayer*>(userData);
player->fillBuffer(buffer);
}
void fillBuffer(AudioQueueBufferRef buffer) {
size_t available = ringBuffer_.readableSize();
size_t toRead = std::min(available, buffer->mAudioDataBytesCapacity);
ringBuffer_.read(buffer->mAudioData, toRead);
buffer->mAudioDataByteSize = toRead;
AudioQueueEnqueueBuffer(queue_, buffer, 0, nullptr);
}
};
音画同步: 音频时钟作为主时钟,视频帧渲染跟随音频播放进度。
五、反向控制的实现
反向控制让用户在 Mac/iPhone 上通过鼠标或触摸屏直接操控手机,是独立于视频流的通道。
5.1 架构
1
2
3
4
5
6
7
8
Sink 端(Mac/iPhone) 网络 Source 端(手机)
┌───────────────────────┐ ┌──────────────┐ ┌─────────────────────┐
│ 鼠标/触摸事件采集 │ │ │ │ 指令解析 │
│ ↓ │ │ 独立 UDP │ │ ↓ │
│ 坐标归一化 │ ──►│ 通道 │──► │ 坐标映射 │
│ ↓ │ │ (HID 指令) │ │ ↓ │
│ 30Hz 节流 → 序列化 │ │ │ │ Android 输入系统注入 │
└───────────────────────┘ └──────────────┘ └─────────────────────┘
5.2 事件采集与指令编码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct ControlCommand {
enum ActionType: UInt8 {
case touchDown = 0
case touchMove = 1
case touchUp = 2
case scroll = 5
case keyPress = 6
}
let action: ActionType
let x: Float // 归一化坐标 (0.0 - 1.0)
let y: Float
let pressure: Float
let timestamp: UInt64
}
macOS 端事件采集:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
extension MirrorPlayView {
override func mouseDown(with event: NSEvent) {
let location = convert(event.locationInWindow, from: nil)
let cmd = ControlCommand(
action: .touchDown,
x: Float(location.x / bounds.width),
y: Float(location.y / bounds.height),
pressure: Float(event.pressure),
timestamp: currentTimeMicros()
)
throttler.submit(cmd)
}
override func mouseDragged(with event: NSEvent) {
let location = convert(event.locationInWindow, from: nil)
throttler.submit(ControlCommand(
action: .touchMove,
x: Float(location.x / bounds.width),
y: Float(location.y / bounds.height),
pressure: Float(event.pressure),
timestamp: currentTimeMicros()
))
}
}
5.3 指令发送
控制指令使用独立 UDP 通道,与视频流分离:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ControlChannel {
UDPSocket socket_;
int send(const ControlCommand& cmd) {
uint8_t buffer[64];
size_t offset = 0;
buffer[offset++] = static_cast<uint8_t>(cmd.action);
memcpy(buffer + offset, &cmd.x, 4); offset += 4;
memcpy(buffer + offset, &cmd.y, 4); offset += 4;
memcpy(buffer + offset, &cmd.pressure, 4); offset += 4;
memcpy(buffer + offset, &cmd.timestamp, 8); offset += 8;
return socket_.send(buffer, offset);
}
};
5.4 手机端响应
1
2
3
4
5
6
7
8
9
10
11
12
13
void onCommandReceived(const uint8_t* data, size_t size) {
auto cmd = deserialize(data, size);
// 归一化坐标 → 手机实际像素坐标
int screenX = static_cast<int>(cmd.x * deviceWidth_);
int screenY = static_cast<int>(cmd.y * deviceHeight_);
switch (cmd.action) {
case TOUCH_DOWN: injectTouchEvent(ACTION_DOWN, screenX, screenY); break;
case TOUCH_MOVE: injectTouchEvent(ACTION_MOVE, screenX, screenY); break;
case TOUCH_UP: injectTouchEvent(ACTION_UP, screenX, screenY); break;
}
}
5.5 延迟敏感性
| 类型 | 容忍延迟 | 影响 |
|---|---|---|
| 视频帧 | 100-300ms | 观感变差但可接受 |
| 控制指令 | < 50ms | 用户感知”不跟手” |
| 连续滑动 | < 33ms | 轨迹断裂感 |
控制通道的设计原则:独立 UDP 通道、指令优先级高于视频帧、数据极小(单包可达)。
六、踩坑记录
难点 1:Metal 渲染花屏(Intel Mac)
现象: Intel Mac 上出现随机花屏/绿屏,Apple Silicon(M 芯片)机型完全正常。
排查过程:
- 初始怀疑 FFmpeg 解码问题——因此开发了原生 VideoToolbox 解码路径对比,但花屏依旧
- 缩小范围——同一解码输出换用
AVSampleBufferDisplayLayer渲染,花屏消失,确认问题在 Metal 渲染层 - 定位根因——旧实现每次
renderBuffer:调用都重新创建CVMetalTextureCache,用完立即销毁
1
2
3
4
5
6
7
8
// ❌ 问题代码:每帧都创建和销毁 TextureCache
void renderBuffer(CVPixelBufferRef buffer) {
CVMetalTextureCacheRef textureCache = NULL;
CVMetalTextureCacheCreate(NULL, NULL, device_, NULL, &textureCache);
// ... 创建纹理、渲染 ...
CVMetalTextureCacheFlush(textureCache, 0);
CFRelease(textureCache); // GPU 可能还在使用!
}
根因: Intel GPU 的 Metal 驱动在高频创建/销毁 TextureCache 时,存在 GPU 资源竞态——GPU 仍在引用纹理数据时,CPU 侧已经释放了 TextureCache。Apple Silicon 的统一内存架构对此有更好的容忍性,因此 M 芯片不复现。
修复方案:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ✅ 修复:持久化 TextureCache + in-flight 帧数控制
class MetalRenderer {
CVMetalTextureCacheRef textureCache_; // 持久化,init 时创建
CVMetalTextureRef cvTextures_[2]; // Y + UV 纹理引用
uint32_t presentFrameCount_{0}; // in-flight 帧计数
static constexpr uint32_t kMaxFramesInFlight = 3;
void renderBuffer(CVPixelBufferRef buffer) {
// 背压控制:GPU 消费不过来时跳帧
if (presentFrameCount_ > kMaxFramesInFlight) return;
releaseTextures(); // 释放上一帧的纹理引用
uploadTextures(buffer);
auto commandBuffer = commandQueue_.makeCommandBuffer();
presentFrameCount_++;
commandBuffer.addCompletedHandler([this](auto) {
presentFrameCount_--; // GPU 完成后减计数
});
// ... 渲染 + present ...
}
};
关键修复点:
CVMetalTextureCache在init时创建,dealloc时释放——生命周期与视图一致- 通过
presentFrameCount_实现 in-flight 帧数控制,防止 GPU 来不及消费时堆积 - 每帧渲染前先释放上一帧的纹理引用,再从 PixelBuffer 创建新纹理
难点 2:反向控制的触摸采样率节流
现象: Mac 端拖拽操作时,手机端出现明显的操作延迟和卡顿,与视频流畅度无关。
分析:
各设备的触摸/手势采样率:
| 设备 | 触摸采样率 | 说明 |
|---|---|---|
| Mac 触控板 / 鼠标 | ~60-80 Hz | macOS 按显示刷新率节奏交付事件 |
| iPhone(标准) | ~60-120 Hz | |
| iPhone Pro | ~120 Hz | ProMotion 设备 |
| iPad Pro | 240 Hz | 配合 Apple Pencil 低延迟 |
Mac 触控板以 ~70Hz 产生 mouseDragged 事件。如果全量转发到手机端,Android 的 InputDispatcher 按 vsync(60Hz)节奏消费事件,多余事件会排队等待下一个 vsync,累积延迟逐帧增长。
解决方案: 在 Sink 端对触控事件做 30Hz 节流:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class EventThrottler {
private let interval: TimeInterval = 1.0 / 30.0 // 33ms
private var lastSendTime: TimeInterval = 0
private var pendingCommand: ControlCommand?
func submit(_ command: ControlCommand) {
let now = CACurrentMediaTime()
// touchDown / touchUp 立即发送,不节流
if command.action == .touchDown || command.action == .touchUp {
send(command)
lastSendTime = now
return
}
// touchMove 做节流
if now - lastSendTime >= interval {
send(command)
lastSendTime = now
} else {
pendingCommand = command // 保留最新位置,下次发送
}
}
}
为什么是 30Hz:
- 30Hz 意味着每两个 vsync 周期(16.6ms × 2 = 33ms)最多一个事件到达,不会堆积
- 人眼对触控轨迹连续性的感知阈值约 20-30Hz,30Hz 的拖拽轨迹仍然平滑
touchDown和touchUp不节流,保证点击响应的即时性
其他典型问题
分辨率动态变化导致硬解闪退: 手机横竖屏切换时,码流的宽高突变。VideoToolbox 的 VTDecompressionSession 不支持动态分辨率变更——必须检测到 SPS 中的宽高变化后,销毁旧 Session 并以新参数重建。未处理时表现为 kVTInvalidSessionErr 后直接崩溃。
音频缓冲区溢出: AAC 解码后的 PCM 数据大小取决于采样率转换比例(如 44100→48000),不能硬编码固定值。修复:根据输入采样率和输出采样率动态计算缓冲区大小,同时统一由 AudioPlayer 管理 PCM buffer 生命周期,避免 double-free。
PTS 重复导致帧乱序: 编码端偶尔产生相同 PTS 的帧(B 帧参考或编码器 bug),渲染队列的 PTS 排序逻辑因此失效。修复:引入递增的 frameIndex 作为次级排序键——相同 PTS 时按到达顺序渲染,保证确定性。
七、总结
本文分析了 Android 手机投屏到 Mac/iPhone 的端到端架构:
- 三层架构:SDK 协议层 → 编解码引擎层 → UI 渲染层,各层 C++ 跨平台复用,只有渲染视图需平台适配
- Source 端:H.264/H.265 动态选型、RTP + AES 打包加密、UDP/TCP 传输降级
- Sink 端:双通道视频解码(FFmpeg + VT / 原生 VT)、Jitter Buffer 帧调度、Metal 零拷贝渲染
- 反向控制:独立 UDP 通道 + 30Hz 节流 + 坐标归一化映射
- 核心难点:Metal TextureCache 生命周期管理(Intel/M 芯片差异)、端到端采样率匹配