跳到内容
Tony

Android 手机投屏到 Mac/iPhone 的架构实现

手机投屏是一项常见的需求:把 Android 手机的画面实时投射到电脑(Mac)或平板(iPad/iPhone)上,用于演示、远程协助或多媒体分享。实现一个低延迟、高流畅度的投屏方案,需要覆盖从手机端的屏幕采集编码、网络传输、到接收端的解码渲染全链路。

技术 , 音视频 4 分钟阅读

手机投屏是一项常见的需求:把 Android 手机的画面实时投射到电脑(Mac)或平板(iPad/iPhone)上,用于演示、远程协助或多媒体分享。实现一个低延迟、高流畅度的投屏方案,需要覆盖从手机端的屏幕采集编码、网络传输、到接收端的解码渲染全链路。

本文以小米手机的妙享桌面投屏方案为例,从架构视角分析两大核心能力的实现:镜像投屏(Screen Mirroring)反向控制(Reverse Control)

┌─ Source 端(手机)──────────────────────────────────────────────────────┐
│ │
│ ┌─ 平台层(Android)──────────────────────────────────────────────┐ │
│ │ 屏幕采集 (MediaProjection) │ │
│ │ ↓ │ │
│ │ 硬件编码器 (MediaCodec) │ │
│ │ H.264/H.265 │ 1080p@60fps │ 8Mbps │ IDR 响应 │ │
│ │ ↓ │ │
│ │ 编码帧回调 → NAL 分片 │ │
│ └───────┬─────────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌─ 投屏传输 SDK (Source 模式) ────────────────────────────────────┐ │
│ │ AES 加密 → RTP 打包(序列号 + timestamp)→ UDP/TCP 发送 │ │
│ │ ← 接收 Sink 反馈:丢包率 / IDR 请求 / 码率调整 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────┬───────────────────────────────────────┘
│ Wi-Fi 局域网
┌─ Sink 端(Mac / iPhone / Pad)───────────────────────────────────────────┐
│ ┌─ 投屏传输 SDK (Sink 模式) ─────────────────────────────────────────┐ │
│ │ UDP/TCP 接收 → RTP 重组 → AES 解密 → 按媒体类型回调分发 │ │
│ │ → 向 Source 反馈:丢包率统计 / 请求 IDR / 建议码率 │ │
│ └────────────────────────────────┬────────────────┬──────────────────┘ │
│ │ 视频数据回调 │ 音频数据回调 │
│ ▼ ▼ │
│ ┌─ 平台层(Mac/iPhone 各自实现)──────────────────────────────────────┐ │
│ │ VideoDecoder(VideoToolbox)→ RenderManager → Metal 渲染 │ │
│ │ AudioDecoder(PCM/AAC)→ AudioQueue 播放 │ │
│ │ 反向控制:事件采集 → 30Hz 节流 → 指令编码 → UDP → 手机端注入 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘

数据流包含两条通道:媒体通道(手机→接收端,视频/音频)和控制通道(接收端→手机,触控指令)。投屏传输 SDK 在两端对称运行——Source 端负责加密打包发送,Sink 端负责接收解密后回调裸音视频数据给平台层处理。同一套 SDK 支撑”手机→Mac”、“手机→iPhone”、“手机→Pad”等多种投屏组合。

整个投屏系统的底座是一套跨平台投屏传输 SDK(C++ 实现,Android / macOS / iOS / Windows 共用)。该 SDK 同时运行在 Source 和 Sink 端,封装了连接建立、协议解析、加解密和媒体数据传输,通过插件机制向上层暴露编解码回调。传输层基于 RTSP + RTP,自研的 MPT 传输模块支持 UDP/TCP 降级和多链路切换。第三章将详细展开其设计。


┌─────────────────────────────────────────────────────────────┐
│ Android 平台层 │
│ │
│ MediaProjection(屏幕采集) │
│ ↓ Surface │
│ MediaCodec(硬件编码器) │
│ │ H.264/H.265 │ 配置:分辨率/帧率/码率/Profile │
│ ↓ 编码帧回调 │
│ NAL 分片处理 │
│ ↓ │
├─────────────────────────────────────────────────────────────┤
│ 投屏传输 SDK │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │
│ │ AES 加密 │ → │ RTP 打包 │ → │ UDP/TCP 发送 │ │
│ └──────────┘ └──────────┘ └───────────────────────┘ │
│ ↑ │
│ 码率调整 / IDR 请求(来自 Sink 端反馈) │
└─────────────────────────────────────────────────────────────┘

struct EncoderConfig {
CodecType codec = CodecType::H265; // 默认 H.265,旧设备降级 H.264
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.264H.265
硬件兼容性几乎所有设备支持较新设备支持
同等画质码率基准降低约 30-50%
编码延迟较低略高
解码复杂度

策略:默认使用 H.265 以获得更低码率(同等画质下约降低 30-50%),仅对 iPad mini 4、iPad Air 2 等旧设备强制降级到 H.264 保证兼容。分辨率和帧率根据网络状况动态调整——网络良好时 1080p@60fps,网络波动时降级到 720p@30fps。

编码器输出的压缩帧经过以下流水线到达网络:

编码器输出 NAL 单元
NAL 分片与封装
RTP 打包(序列号 + timestamp)
AES 加密
┌────┴────┐
│ 网络状况 │
└────┬────┘
正常 ↙ ↘ 严重丢包
UDP 发送 TCP 发送

NAL 分片: 一帧 H.264 数据可能超过 UDP 的 MTU(1500 字节),需要分片。接收端根据 RTP 序列号重组完整帧。

RTP 协议: 序列号用于检测丢包和排序,timestamp 携带帧的采集时间,接收端据此计算播放时间戳和抖动缓冲。

AES 加密: 所有视频载荷加密,确保投屏内容在局域网内不被窃听。

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};
};

  • 关键帧请求(IDR Request): 接收端检测到丢包或解码异常时,请求手机发送关键帧恢复画面
  • 码率变化通知: 网络带宽变化时,通知编码器调整目标码率
  • 编码异常: 硬件故障或资源不足的异常处理

投屏系统最底层是一套跨平台的 C++ SDK,同时运行在 Android(Source)和 macOS/iOS(Sink)上。SDK 封装了协议解析、网络传输、加解密和会话管理,对上层暴露统一的插件接口和选项配置。

SDK 采用 Server-Client 模型,两端接口对称但职责不同:

┌─ Source 端 ─────────────────────────────┐ ┌─ Sink 端 ───────────────────────────────┐
│ │ │ │
│ IMiPlayCastMirrorServer (C++ 接口) │ │ IMiPlayCastMirrorClient (C++ 接口) │
│ │ │ │
│ • attachSurface(surface) 绑定采集 Surface│ │ • attachSurface(surface) 绑定渲染 Surface│
│ • setAttribute(type, val) 设置编码参数 │ │ • setAttribute(type, val) 设置解码参数 │
│ • registerVideoPlugin(p) 注册编码回调 │ │ • registerVideoPlugin(p) 注册解码回调 │
│ • registerAudioPlugin(p) 注册音频回调 │ │ • registerAudioPlugin(p) 注册音频回调 │
│ • registerStateCallback() 注册状态回调 │ │ • registerStateCallback() 注册状态回调 │
│ • write(type, data, len, pts) 发送编码帧 │ │ • start(uri) / stop() 控制会话 │
│ • start(uri) / stop() 控制会话 │ │ • pause(mediaType) / resume() 媒体控制 │
│ │ │ │
└──────────────────────────────────────────┘ └──────────────────────────────────────────┘

SDK 通过插件接口与上层解码/渲染逻辑解耦。上层只需实现 MediaPlugin 接口并注册:

class MediaPlugin {
public:
virtual int32_t onInit(MediaAttribute attr) = 0; // 传入协商后的媒体参数
virtual int32_t onStart() = 0;
virtual int32_t onStop() = 0;
virtual int32_t onPause() = 0;
virtual int32_t onResume() = 0;
virtual int32_t onChangeMediaAttribute(int32_t type, MediaAttribute attr) = 0;
};

编解码参数通过 setAttribute() 预设,SDK 在连接建立后与对端协商,协商结果通过 onInit()MediaAttribute 结构体回调给插件:

struct MediaAttribute {
int32_t width; // 视频宽
int32_t height; // 视频高
int32_t fps; // 帧率
int8_t format[50]; // 编码格式字符串 (video/avc 等)
int32_t profile; // Profile
int32_t level; // Level
int32_t bitrate; // 码率 (bps)
int32_t channels; // 音频通道数
int32_t sampleBits; // 音频采样位宽
int32_t sampleRate; // 音频采样率
};

SDK 通过 StateCallback 接口向外报告连接状态和数据到达:

class StateCallback {
public:
virtual void onStarted(int32_t localPort) = 0; // Server 端启动成功
virtual void onConnected() = 0; // 连接建立
virtual void onDisconnected() = 0; // 连接断开
virtual void onPlayed(int32_t status) = 0; // 媒体开始播放
virtual void onError(int32_t what, int32_t extra) = 0; // 错误
virtual void onInfo(int32_t what, int64_t extra) = 0; // 信息通知
virtual int32_t onReceiveData(int32_t mediaType, // 收到媒体数据
int8_t* data, int32_t len, int64_t pts) = 0;
};

一次完整的投屏会话从连接到播放:

Server.start(uri) Client.start(uri)
│ │
▼ ▼
连接建立 ─────────── TCP 握手 / RTSP 信令 ─────────────→ 连接建立
│ │
▼ ▼
onStarted(localPort) onConnected()
│ │
▼ ▼
加密协商 ──────────── AES key/iv 交换 ─────────────────→ 加密协商
│ │
▼ ▼
registerMediaPlugin() registerMediaPlugin()
│ │
▼ ▼
start 数据流 ──────── RTP over UDP ─────────────────→ onReceiveData()
│ → plugin.onInit(attr)
│ → plugin.onStart()
▼ ▼
onPlayed(0) onPlayed(0)

SDK 基于 RTSP + RTP 协议栈,在标准的 RTP 层之上做了扩展:

应用数据层:编码后的 H.264/H.265 视频帧 / PCM/AAC 音频帧
RTP 封装层:序列号 + timestamp + 负载类型标识
自定义传输层 (MPT):UDP 优先,支持 TCP 降级和天琴通道
物理链路:Wi-Fi / P2P / 蓝牙

RTP 序列号用于丢包检测和乱序重排,timestamp 将帧还原到采集时间线——这是 Jitter Buffer 的基础。

天琴通道(Lyra Channel): SDK 还支持通过”天琴”链路传输——手机和接收端之间可以通过蓝牙、自组网 WLAN 或远端转发通道交换 RTP/RTSP 数据,无需同在局域网。Option_UseLyraChannel 控制使用哪种底层链路。这一机制进一步扩展了投屏的使用场景。

编码参数不是由一端单方面决定,而是通过 setAttribute() 预设后,SDK 在连接建立阶段与对端协商:

Source 端 setAttribute() Sink 端 setAttribute()
VideoWidth = 1920 VideoWidth = 1920
VideoFps = 60 VideoFps = 60
VideoEncType = H265 VideoEncType = H264 ← Sink 侧偏好 H.264
VideoBitrate = 8M
│ │
└──────── 协商结果 ───────────────────────┘
VideoEncType = H264 (取交集,Sink 不支持降级)
其余参数取最小值
→ 通过 onInit(attr) 告知上层

SDK 内置码率自适应(Option_EnableAdptiveFun),运行时根据 RTP 丢包率反馈动态调整编码码率,无需上层干预核心逻辑,上层只需通过 onInfo() 感知调整事件。

SDK 的加密是分层的:

配置项说明
加密类型ENCRYPTION_TYPE_AES / SMS4选择加密算法
加密级别AESCBC128 / 192 / 256AES 密钥长度
加密范围FORMAT_VIDEO / AUDIO / CMD可选择只加密视频、音频或控制指令
传输加密ENCRYPTION_TRANSLEVEL_XOR密钥传输时额外 XOR 保护
完整性SHA256 / SHA128 / MD5数据完整性校验

加密密钥(key + iv)和鉴权密钥(authKey)由上层在 start() 前通过 setAttribute() 注入。加解密在 SDK 内部透明完成,上层拿到的音视频数据是解密后的明文。


Sink 端是整个系统最复杂的部分。内部架构:

┌─ SDK 协议层 ──────────────────────────────────────────────┐
│ UDP/TCP 接收 → AES 解密 → RTP 重组 → 媒体类型分发 │
└────────────────────────────┬─────────────┬────────────────┘
│ 视频 │ 音频
┌─ 编解码引擎层 ─────────────▼─────────────▼────────────────┐
│ │
│ VideoDecoder (VideoToolbox) AudioDecoder (PCM/AAC) │
│ ↓ ↓ │
│ RenderManager AudioPlayer │
│ (Jitter Buffer/帧调度) (AudioQueue 环形缓冲) │
│ ↓ │
├───────┼───────────────────────────────────────────────────┤
│ ↓ UI 渲染层 │
│ MetalRenderView(YUV→RGB / 零拷贝) │
└───────────────────────────────────────────────────────────┘

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 上报。

SDK 内置了第一级 Jitter Buffer(配置 JitterBufferSetEnable = 1,缓冲阈值 BufferingThreshold = 200ms),在网络接收层吸收抖动,回调给引擎层的帧已经按 PTS 重新排序。引擎层的 RenderManager 是第二级 Jitter Buffer,控制解码帧的渲染时机。

视频解码器基于 VideoToolbox 硬件加速,FFmpeg 软解作为降级备用:

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 decodeLoop();
RenderManager renderManager_;
std::unique_ptr<NativeVTDecoder> nativeVTDecoder_;
};
class NativeVTDecoder {
public:
int init(int width, int height, CodecType codec) {
// 从码流 SPS/PPS 构造 CMVideoFormatDescription
CMVideoFormatDescriptionCreateFromH264ParameterSets(
nullptr, paramCount, paramPointers, paramSizes, 4,
&formatDescription_);
// 创建 VTDecompressionSession
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_;
};

解码器初始化时从码流 SPS/PPS 构造 CMVideoFormatDescription,创建 VTDecompressionSession。接收到的编码数据需做 Annex-B → AVCC 格式转换(将 0x00000001 起始码替换为 4 字节长度前缀),封装为 CMSampleBuffer 后提交给 VideoToolbox 异步解码。解码完成的 CVPixelBuffer 通过回调推入 RenderManager 等待渲染调度。

解码流水线核心代码路径:

解码线程 receiveLoop():
├─ put(AVPacket) → 入待解码队列
├─ decodeLoop(): 从队列取包
│ └─ NativeVTDecoder.decode() → VTDecompressionSessionDecodeFrame()
└─ onFrameDecoded() 回调 → RenderManager.addFrame()

解码线程独立运行,与渲染线程通过帧队列解耦(详见踩坑记录)。

帧调度器是控制延迟与流畅度平衡的核心:

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};
};

核心调度逻辑:

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:SDK 层做第一级缓冲(200ms 阈值,吸收网络抖动、重排乱序帧),引擎层做第二级控制(根据渲染进度动态调整)。抖动大时增大 buffer(用延迟换流畅),抖动小时减小 buffer(降低延迟)
  • V-Sync 对齐:渲染时机对齐屏幕刷新信号,减少画面撕裂

解码器输出 CVPixelBuffer 后需要上屏显示。Apple 平台有两种方案:直接用 AVSampleBufferDisplayLayer(系统托管渲染),或用 Metal 自己控制渲染。

维度Metal 自渲染AVSampleBufferDisplayLayer
渲染时机完全自控(配合 VSync)系统内部缓冲队列,延迟不可控
丢帧策略自定义(丢旧保新)系统决定,无法干预
色彩空间自己写 YUV→RGB shader系统自动处理
代码复杂度低(几行 enqueue 代码)

投屏是延迟敏感场景,核心需求是自己决定”什么时候渲染哪一帧”。网络抖动导致帧积压时,需要丢掉旧帧只显示最新画面——这在 AVSampleBufferDisplayLayer 中无法实现(它按 PTS 顺序平滑播放,适合视频播放器,不适合实时投屏)。因此选择 Metal 自渲染。

Metal 渲染视图通过 CVMetalTextureCache 实现零拷贝上屏:

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:

#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);
}

音频路径默认使用 PCM 编码(低延迟,无需解压缩),同时支持 AAC 作为降级方案:

class AudioDecoder {
public:
int write(const uint8_t* data, size_t size, int64_t pts);
int readPCM(uint8_t* buffer, size_t size, int64_t* pts);
private:
PCMDecoder pcmDecoder_; // PCM 直通(默认)
AACDecoder aacDecoder_; // FFmpeg AAC → PCM(降级)
};

Source 端默认发送 PCM 数据(采样率 48000Hz,双声道,16bit),接收端直接透传给 AudioQueue。当对端不支持 PCM 时,降级使用 AAC 编码——通过 FFmpeg 的 avcodec_decode_audio4 解码后转为 PCM 播放。

AudioQueue 环形缓冲: 使用 5 个缓冲区的环形结构,AudioQueue 系统回调请求数据时从 ring buffer 填充:

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 上通过鼠标或触摸屏直接操控手机,是独立于视频流的通道。

Sink 端(Mac/iPhone) 网络 Source 端(手机)
┌───────────────────────┐ ┌──────────────┐ ┌─────────────────────┐
│ 鼠标/触摸事件采集 │ │ │ │ 指令解析 │
│ ↓ │ │ 独立 UDP │ │ ↓ │
│ 坐标归一化 │ ──►│ 通道 │──► │ 坐标映射 │
│ ↓ │ │ (HID 指令) │ │ ↓ │
│ 30Hz 节流 → 序列化 │ │ │ │ Android 输入系统注入 │
└───────────────────────┘ └──────────────┘ └─────────────────────┘

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 端事件采集:

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()
))
}
}

控制指令使用独立 UDP 通道,与视频流分离:

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);
}
};

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;
}
}

类型容忍延迟影响
视频帧100-300ms观感变差但可接受
控制指令< 50ms用户感知”不跟手”
连续滑动< 33ms轨迹断裂感

控制通道的设计原则:独立 UDP 通道、指令优先级高于视频帧、数据极小(单包可达)。


现象: Intel Mac 上随机花屏/绿屏,M 芯片机型完全正常。花屏不确定复现节奏,排查困难。

排查过程: 起初怀疑 FFmpeg 解码输出异常——为此绕过 FFmpeg 直接调 VTDecompressionSession 解码做对比,花屏依旧。又把解码后的视频帧写入本地文件播放,画面完全正常——彻底排除了解码层的嫌疑。进而拿同一解码输出换用 AVSampleBufferDisplayLayer 渲染,花屏消失——确认问题在 Metal 渲染层。最终定位到旧实现每帧都重新创建 CVMetalTextureCache 并在渲染后立即销毁。

CVMetalTextureCache 的正确用法是创建一次、持续复用(Apple 文档明确建议)。每帧重建虽然违反了最佳实践,但只有在 Intel GPU 的独立显存架构上才触发 GPU 资源竞态(GPU 还在引用纹理时 CPU 侧已释放)。M 芯片的统一内存架构对此容忍度更高,掩盖了问题。

修复本身不复杂:将 TextureCache 持久化到视图生命周期,加上 in-flight 帧数控制防止 GPU 积压。但教训值得记录——在 M 芯片 Mac 上能正常运行 ≠ 在 Intel Mac 上也能正常运行,充分覆盖两种架构的测试是必须的。

现象: Mac 端拖拽操作时,手机端出现明显的操作延迟和卡顿,与视频流畅度无关。

分析:

各设备的触摸/手势采样率:

设备触摸采样率说明
Mac 触控板 / 鼠标~60-80 HzmacOS 按显示刷新率节奏交付事件
iPhone(标准)~60-120 Hz
iPhone Pro~120 HzProMotion 设备
iPad Pro240 Hz配合 Apple Pencil 低延迟

Mac 触控板以 ~70Hz 产生 mouseDragged 事件。如果全量转发到手机端,Android 的 InputDispatcher 按 vsync(60Hz)节奏消费事件,多余事件会排队等待下一个 vsync,累积延迟逐帧增长。

解决方案: 在 Sink 端对触控事件做 30Hz 节流

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 的拖拽轨迹仍然平滑
  • touchDowntouchUp 不节流,保证点击响应的即时性

分辨率动态变化导致硬解闪退: 手机横竖屏切换时,码流的宽高突变。VideoToolbox 的 VTDecompressionSession 不支持动态分辨率变更——必须检测到 SPS 中的宽高变化后,销毁旧 Session 并以新参数重建。未处理时表现为 kVTInvalidSessionErr 后直接崩溃。

音频缓冲区溢出: AAC 解码后的 PCM 数据大小取决于采样率转换比例(如 44100→48000),不能硬编码固定值。修复:根据输入采样率和输出采样率动态计算缓冲区大小,同时统一由 AudioPlayer 管理 PCM buffer 生命周期,避免 double-free。

PTS 重复导致帧乱序: 编码端偶尔产生相同 PTS 的帧(B 帧参考或编码器 bug),渲染队列的 PTS 排序逻辑因此失效。修复:引入递增的 frameIndex 作为次级排序键——相同 PTS 时按到达顺序渲染,保证确定性。


维度本方案ScrcpyAirPlayGoogle Cast
传输协议RTSP/RTP + 自研 MPT(UDP/TCP/多链路)ADB Tunnel(USB/Wi-Fi)RTSP/RTP + UDPWebRTC
加密AESFairPlay DRMDTLS-SRTP
视频解码VideoToolbox 硬解FFmpeg 软解VideoToolbox 硬解硬解
音频编码PCM / AACPCM / OpusALAC / AACOpus
反向控制独立 UDP + HID 指令ADB HID 事件注入MFI 协议WebRTC DataChannel
延迟30-100ms(局域网)30-70ms(有线)50-200ms50-300ms+
跨平台 SinkmacOS + iOS全桌面平台Apple 生态全平台
开源自研✅ 开源部分开源部分开源

各方案适用场景:

  • Scrcpy:开发者调试 Android 应用的首选工具,延迟极低但依赖 ADB,无加密,不适合日常非开发场景
  • AirPlay:Apple 生态内最优,端到端延迟低且集成好,但封闭生态限制了 Android → iPhone 方向
  • Google Cast:跨平台能力最强(WebRTC),适合互联网跨网络投屏,但延迟较高(强依赖云端信令)
  • 本方案:取各家之长——传输层自研协议接近 AirPlay 的延迟水平,控制通道借鉴 Scrcpy 的反控思路,Sink 端覆盖 macOS/iOS 两大 Apple 平台

本文分析了 Android 手机投屏到 Mac/iPhone 的端到端架构:

  1. Source 端:Android 平台层(采集 + MediaCodec 编码)+ 投屏传输 SDK(加密 + RTP + 发送)

  2. Sink 端三层:投屏传输 SDK(接收/解密)→ 编解码引擎(VideoToolbox 硬解 + 帧调度)→ UI 渲染层(Metal)。SDK 和引擎层 C++ 跨平台,渲染层平台各自实现

  3. 网络传输:基于 RTSP + RTP,自研 MPT 传输模块支持 UDP/TCP/天琴多链路

  4. 反向控制:独立 UDP 通道 + 30Hz 节流 + 坐标归一化映射

  5. 跨架构兼容:M 芯片的统一内存会掩盖 GPU 资源生命周期问题,需在 Intel Mac 上充分覆盖测试