Post

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 加解密 / 会话管理 / 状态回调         │
└───────────────────────────────────────────────────────────────┘
语言跨平台能力职责
投屏传输 SDKC++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.264H.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 + VTFFmpeg 的 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 芯片)机型完全正常。

排查过程:

  1. 初始怀疑 FFmpeg 解码问题——因此开发了原生 VideoToolbox 解码路径对比,但花屏依旧
  2. 缩小范围——同一解码输出换用 AVSampleBufferDisplayLayer 渲染,花屏消失,确认问题在 Metal 渲染层
  3. 定位根因——旧实现每次 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 ...
    }
};

关键修复点:

  • CVMetalTextureCacheinit 时创建,dealloc 时释放——生命周期与视图一致
  • 通过 presentFrameCount_ 实现 in-flight 帧数控制,防止 GPU 来不及消费时堆积
  • 每帧渲染前先释放上一帧的纹理引用,再从 PixelBuffer 创建新纹理

难点 2:反向控制的触摸采样率节流

现象: 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 节流

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

其他典型问题

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

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

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


七、总结

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

  1. 三层架构:SDK 协议层 → 编解码引擎层 → UI 渲染层,各层 C++ 跨平台复用,只有渲染视图需平台适配
  2. Source 端:H.264/H.265 动态选型、RTP + AES 打包加密、UDP/TCP 传输降级
  3. Sink 端:双通道视频解码(FFmpeg + VT / 原生 VT)、Jitter Buffer 帧调度、Metal 零拷贝渲染
  4. 反向控制:独立 UDP 通道 + 30Hz 节流 + 坐标归一化映射
  5. 核心难点:Metal TextureCache 生命周期管理(Intel/M 芯片差异)、端到端采样率匹配
This post is licensed under CC BY 4.0 by the author.