14、屏幕共享:Android屏幕采集、iOS屏幕广播、跨平台实现

屏幕共享这个功能,说实话,是WebRTC里最容易被低估的硬骨头。很多人觉得不就是录个屏嘛,但真正做起来,Android和iOS两边的路子完全不同。我最早做这块时,差点被系统限制搞到怀疑人生。

今天咱们就把它拆开揉碎,从Android的MediaProjection,到iOS的ReplayKit,再到怎么用一套代码搞定两端。嗯,开始吧。

Android屏幕采集:MediaProjection的正确姿势

Android从5.0开始提供了MediaProjection API。但注意,它不像摄像头那样直接拿权限就行——你得先弹个系统对话框让用户确认。

核心流程:

  1. 通过MediaProjectionManager获取Intent
  2. 用startActivityForResult启动授权
  3. 在onActivityResult里拿到data,创建MediaProjection实例
  4. 用VirtualDisplay把屏幕内容投到SurfaceTexture或Surface

代码大概长这样:

// 1. 获取管理器
MediaProjectionManager manager = (MediaProjectionManager) 
    getSystemService(Context.MEDIA_PROJECTION_SERVICE);

// 2. 发起授权
Intent intent = manager.createScreenCaptureIntent();
startActivityForResult(intent, REQUEST_CODE);

// 3. 回调中创建实例
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == REQUEST_CODE && resultCode == RESULT_OK) {
        MediaProjection projection = manager.getMediaProjection(resultCode, data);
        // 开始采集
        startScreenCapture(projection);
    }
}

// 4. 创建VirtualDisplay
private void startScreenCapture(MediaProjection projection) {
    Surface surface = /* 从EGLSurface或SurfaceTexture获取 */;
    projection.createVirtualDisplay(
        "ScreenShare",
        width, height, dpi,
        DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
        surface,
        null, null
    );
}

这里有个坑,我踩过好几次——VirtualDisplay的宽高必须和屏幕实际分辨率匹配,否则画面会拉伸或裁剪。我曾经图省事直接写死了1080p,结果在平板上一跑,画面歪得没法看。

我的习惯:用WindowManager获取当前屏幕的真实尺寸,再根据编码器的能力做缩放。别直接用DisplayMetrics的宽高,那可能包含导航栏。

iOS屏幕广播:ReplayKit的坑与解

iOS这边就麻烦多了。从iOS 12开始,苹果推荐用ReplayKit 2来做屏幕共享。但它的设计思路和Android完全不同——它走的是Extension(扩展)机制。

说白了,你的主App和屏幕采集不在同一个进程里。采集是在一个叫"Broadcast Upload Extension"的独立进程中跑的。这就带来两个问题:

  • Extension和主App之间没法直接共享内存
  • Extension的资源受限,不能做太多计算

我刚开始做时,傻乎乎地在Extension里直接做视频编码,结果跑几分钟就被系统杀掉。后来才明白,Extension只负责把原始数据传给主App,编码和推流应该交给主进程。

实现步骤:

  1. 新建一个Broadcast Upload Extension Target
  2. 在SampleHandler里实现processSampleBuffer
  3. 用Socket或CFMessagePort把数据传给主App
  4. 主App收到后,送入WebRTC的VideoSource

关键代码:

// Extension端 - SampleHandler.m
- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer 
                   withType:(RPSampleBufferType)sampleBufferType {
    if (sampleBufferType == RPSampleBufferTypeVideo) {
        // 通过Socket发送到主App
        [self.socket sendData:[self convertSampleBufferToData:sampleBuffer]];
    }
}

// 主App端 - 接收并送入WebRTC
- (void)didReceiveVideoFrame:(NSData *)frameData {
    RTCMutableI420Buffer *buffer = [self decodeFrameData:frameData];
    RTCVideoFrame *videoFrame = [[RTCVideoFrame alloc] initWithBuffer:buffer
                                                             rotation:0
                                                          timeStampNs:0];
    [self.videoSource capturer:nil didCaptureVideoFrame:videoFrame];
}

注意:Extension的启动有延迟,用户点击"开始广播"后,可能要等1-2秒才能收到第一帧。我建议在UI上做个loading动画,别让用户干等。

跨平台实现:抽象一层接口

既然Android和iOS的实现差异这么大,怎么用一套代码搞定?我的做法是——抽象一个ScreenCapturer接口

接口定义:

public interface ScreenCapturer {
    void startCapture(ScreenCaptureCallback callback);
    void stopCapture();
    boolean isCapturing();
    void setVideoSource(Object source); // 对应WebRTC的VideoSource
}

// 回调
public interface ScreenCaptureCallback {
    void onFrameCaptured(byte[] data, int width, int height, int rotation);
    void onError(String message);
}

Android实现:

public class AndroidScreenCapturer implements ScreenCapturer {
    private MediaProjection projection;
    private VirtualDisplay display;
    private EGLBase eglBase;
    
    @Override
    public void startCapture(ScreenCaptureCallback callback) {
        // 用MediaProjection + VirtualDisplay采集
        // 通过EGL读取帧数据
    }
}

iOS实现:

public class iOSScreenCapturer implements ScreenCapturer {
    private SocketServer socketServer;
    private RTCVideoSource videoSource;
    
    @Override
    public void startCapture(ScreenCaptureCallback callback) {
        // 启动Socket监听,等待Extension发数据
        // 收到后回调callback
    }
}

这样,上层的业务代码完全不用关心底层是Android还是iOS。调用方只需要:

ScreenCapturer capturer = PlatformFactory.createScreenCapturer();
capturer.setVideoSource(peerConnectionFactory.createVideoSource(false));
capturer.startCapture(frame -> {
    // 帧数据直接送入WebRTC pipeline
});

我的建议:在跨平台层,把帧的格式统一成I420。Android端从ImageReader读出来是YUV,iOS端从CMSampleBuffer读出来是NV12,统一转成I420后,上层处理就简单多了。

知识体系总览

下面这张图,是我自己整理的核心逻辑。你看一眼,基本就明白整个屏幕共享的脉络了:

屏幕共享跨平台架构 Android 端 MediaProjectionManager → VirtualDisplay → Surface → ImageReader 帧数据 (YUV/NV12) iOS 端 Broadcast Upload Extension → Socket/CFMessagePort → 主App接收 帧数据 (CMSampleBuffer) 抽象接口层 (ScreenCapturer) startCapture() / stopCapture() / setVideoSource() 统一回调:onFrameCaptured(byte[], width, height, rotation) 格式统一 → I420 Android: YUV → I420 | iOS: NV12 → I420

性能优化与避坑

屏幕共享比摄像头采集更吃性能,因为分辨率通常更高(1080p甚至2K)。我总结了几条经验:

  • 降分辨率:别直接采集原始分辨率。Android端用VirtualDisplay时指定一个较小的尺寸,比如720p。iOS端在Extension里用CVPixelBuffer的裁剪功能。
  • 帧率控制:屏幕内容变化不频繁时,没必要跑30fps。我习惯动态调整——检测到画面变化大时提帧率,静止时降到5fps。
  • 内存管理:Android的ImageReader如果不及时释放,会积压几十帧,内存暴涨。记得每帧处理完就调用image.close()。

我曾经踩过的坑:在Android上,如果用户旋转屏幕,VirtualDisplay不会自动跟随。你得监听ConfigurationChanged,销毁旧的VirtualDisplay,用新尺寸重新创建。否则画面方向就错了。

嗯,屏幕共享这块,说白了就是两套系统API + 一层抽象。Android那边直接,但要注意权限和尺寸;iOS那边绕,但Extension机制其实更安全。把接口统一好,上层代码几乎不用改。这就是跨平台的价值。


公众号:蓝海资料掘金营,微信deep3321