iOS端实现屏幕共享

本文将为您介绍iOS端如何实现屏幕共享。

功能介绍

屏幕共享功能允许用户在视频通话、直播过程中将自己的屏幕内容实时分享给频道内的其他用户,实现信息的即时共享与可视化交流。

技术原理

iOS 系统不支持在主进程进行屏幕采集,因此实现屏幕共享功能需要增加Broadcast Upload Extension录屏进程以配合主进程进行推流,录屏进程由系统在需要录屏的时候创建,使用 iOS 原生的 ReplayKit 框架实现屏幕录制,然后将屏幕共享流传递给主进程进行推流。

说明

由于苹果隐私设置,不同 App、不同进程之间数据无法互通,因此需要使用苹果提供的 App Group 机制,并为主进程和录屏进程配置相同的 App Group ID。

image

示例项目

ARTC 提供了开源示例项目供您参考,您可以前往下载或查看其中的代码示例代码:iOS实现屏幕共享

前提条件

在实现屏幕共享前,请确保达成以下条件:

  • 一个有效的阿里云账号并创建实时音视频应用,请参考创建应用。在应用管理控制台获取App IDApp Key。

  • 已经在项目中集成了 ARTC SDK,并实现了基础的实时音视频功能。SDK 集成请参考SDK下载/集成,实现音视频请参考:实现音视频通话

  • 受系统限制,屏幕共享只支持 iOS 12.0 及以上版本,请确保在符合要求的设备上运行。

注意事项

  1. 该功能对设备性能要求较高,我们推荐你使用 iPhone X 及以上设备。

  2. 用户在 iOS 设备上开启屏幕共享后,因系统限制,音频路由会自动切换为听筒。

项目配置

(可选)创建 App Group

虽然在未配置 App Group(使用未注册的 ID)的情况下,屏幕共享功能在部分场景下仍可运行,但这种做法缺乏稳定性保障,可能导致扩展进程无法访问共享数据或出现不可预期的行为。

为确保主 App 与录屏扩展之间的数据同步与通信可靠,建议始终配置并使用一个合法、注册在 Apple Developer Portal 中的 App Group ID。

注册 App Group 请参考https://developer.apple.com/cn/help/account/identifiers/register-an-app-group

创建 Broadcast Upload Extension

  1. 使用 Xcode 打开您的项目,依次点击File -> New -> Target... ,在弹出的窗口中选择 iOS 平台,然后选择Broadcast Upload Extension,之后点击 Next

image.png image.png

  1. 在弹出的界面设置 Product Name 等信息,语言选择 Objective-C,取消勾选 Include UI Extension。之后,点击 Finish,Xcode 会自动创建对应 Extension 的文件夹,其中包含 SampleHandler 等文件。

image.png

  1. 集成 AliScreenShare.framework:

    使用 CocoaPods 工具自动集成

    1. 打开终端,在您的开发设备上安装 CocoaPods 工具,如果您已经完成安装,可以跳过此步骤。

    sudo gem install cocoapods
    1. 打开终端,进入项目根目录,在终端窗口中输入以下命令,创建 Podfile 文件。

    pod init
    1. 打开并编辑生成的 Podfile文件,为上面创建的 ScreenShareExtension 录屏进程添加 AliScreenShare 依赖。

    # Uncomment the next line to define a global platform for your project
    platform :ios, '11.0'
    
    target 'ARTCExample' do
    
      # Pods for ARTCExample
      
      pod 'AliVCSDK_ARTC', '~> 7.5.0'
    end
    
    # 为ScreenShareExtension录屏进程添加AliScreenShare库依赖
    target 'ScreenShareExtension' do
    
      # Pods for ScreenShareExtension
      pod "AliScreenShare", '7.5.0'
    end
    1. 在终端窗口中输入以下命令更新项目中的CocoaPods依赖库。

    pod install

    成功执行后,项目文件夹下将生成一个后缀为.xcworkspace后缀的工程文件,双击该文件通过 Xcode打开项目即可加载工作区自动集成 CocoaPods 依赖。

    手动集成

    点击下载SDK,将下载的AliScreenShare.framework 文件拷贝到您的项目目录。选择刚才创建的 Extension 工程。选择录屏进程的 Extension Target,依次选择 General -> Frameworks and Libraries,点击加号,然后点击 Add Files,选择 AliScreenShare.framework 导入。

    image.png

    image.png

  2. 为主项目 Tartget 与 Extension Target 配置相同的 App Groups:依次选择 Target -> Signing & Capabilities,设置 App Groups,如果没有则创建。录屏扩展应用和主应用位于不同的进程中,并且拥有不同的沙盒,为了实现跨进程通信,需要使用苹果的 App Group 功能。

image.png

如下图需要为两个 Target 配置相同的 App Group ID。

image.png

image.png

说明
  • 选中 Extension 的 Target,点击 General,设置支持的 iOS 版本(Minimum Deployments)为 12.0 以上。

  • 主 App 和 App Extension 需要保持相同的 Bundle Id 前缀, 并使用同样的开发证书进行签名。

  1. 在新创建的 Target 中,Xcode 会自动创建一个名为 "SampleHandler" 的类,用如下代码进行替换其中的.m文件。

    说明

    需将代码中的 APP GROUP ID(kAppGroup)改为上文中的创建的 App Group Identifier。

#import "SampleHandler.h"
#import <AliScreenShare/AliScreenShareExt.h>

static NSString * _Nonnull kAppGroup = @"group.com.aliyun.video"; // 屏幕共享主app和插件的AppGroup

@interface SampleHandler() <AliScreenShareExtDelegate>

@property (nonatomic, assign) int32_t frameNum;

@end

@implementation SampleHandler

- (void)broadcastStartedWithSetupInfo:(NSDictionary<NSString *,NSObject *> *)setupInfo {
    // User has requested to start the broadcast. Setup info from the UI extension can be supplied but optional.
    NSLog(@"SampleHandler SEND broadcastStartedWithSetupInfo");
    [[AliScreenShareExt sharedInstance] setupWithAppGroup:kAppGroup delegate:self];
}

- (void)broadcastPaused {
    // User has requested to pause the broadcast. Samples will stop being delivered.
    NSLog(@"SampleHandler SEND broadcastPaused");
}

- (void)broadcastResumed {
    // User has requested to resume the broadcast. Samples delivery will resume.
    NSLog(@"SampleHandler SEND broadcastResumed");
}

- (void)broadcastFinished {
    // User has requested to finish the broadcast.
    NSLog(@"SampleHandler SEND broadcastFinished");
}

- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType {
    @autoreleasepool {
        [[AliScreenShareExt sharedInstance] sendSampleBuffer:sampleBuffer type:sampleBufferType];
    }
}

#pragma mark - AliScreenShareExtDelegate
- (void)finishBroadcastWithError:(AliScreenShareExt *)broadcast error:(NSError *)error
{
  [self finishBroadcastWithError:error];
}


@end
  1. 修改ARTCExample/BasicUsage/ScreenShare/ScreenShareVC.swift文件中的 kAppGroup 参数为您设置的。

    image.png

功能实现

image

1.相机流和屏幕共享流配置

ARTC 支持推送相机流和屏幕共享流,请根据业务场景灵活配置。

1.1.仅需推送屏幕共享流

如果您的场景中如果仅需要推送屏幕流,由于 SDK 默认会默认推送相机流,因此需要您主动调用publishLocalVideoStream关闭相机流推送,流程如下:

  • 加入频道前调用publishLocalVideoStream(false)来关闭相机流推送。

  • 加入频道后调用startScreenShare开启屏幕录制并推送屏幕共享流。

engine.publishLocalVideoStream(false)

1.2.需要同时推送屏幕共享流和相机流

如果您的场景中需要同时推送相机流和屏幕共享流,需要:

  • 调用publishLocalVideoStream(true)开启相机流推送。(默认行为,可省略)。

  • 加入频道后调用startScreenShare开启屏幕录制并推送屏幕共享流。

engine.publishLocalVideoStream(true)

2.(可选)配置屏幕采集编码参数

如果需要自定义屏幕共享视频流的编码属性,可以调用setScreenShareEncoderConfiguration接口进行配置,包含分辨率、帧率、码率、GOP、视频旋转方向。

说明
  • 该接口在加入频道前后均可配置,如果每次入会只需要设置一次屏幕流视频编码属性,建议在入会前调用。

  • 如果需要更新配置,可以多次调用该接口。

相关配置如下:

参数名

参数说明

默认值

dimensions

视频分辨率。

0x0,表示推流分辨率跟随屏幕采集的分辨率。最大取值为3840x2160。

frameRate

视频帧率。

默认帧率为 5,最大取值为 30。

bitrate

视频编码码率(Kbps)。

注意:码率设置根据分辨率和帧率有对应的合理范围,该值设置在合理范围内有效,否则SDK会自动调节码率到有效值。

512

keyFrameInterval

关键帧间隔,GOP。单位为毫秒(ms)。

默认值0,表示SDK内部控制关键帧间隔。

forceStrictKeyFrameInterval

是否强制编码器严格按照设置的关键帧间隔产生关键帧。

默认值为 false。

  • false表示编码器会响应他人入会等关键帧请求,关键帧间隔和设置的值不严格匹配。

  • true表示编码器不响应其他关键帧请求,严格按照设置的值产生关键帧。可能会造成订阅者首帧变慢。

rotationMode

推流旋转。

默认值为AliRtcRotationMode_0。可选择 0、90、180、270 四个角度。

示例代码如下:

var screenShareConfig: AliRtcScreenShareEncoderConfiguration = AliRtcScreenShareEncoderConfiguration()
screenShareConfig.dimensions = CGSize(width: Int(720), height: Int(1280))
screenShareConfig.frameRate = 15
screenShareConfig.bitrate = 512
screenShareConfig.keyFrameInterval = 2000
alirtcEngine.setScreenShareEncoderConfiguration(screenShareConfig)

3.开启屏幕采集

  1. 调用startScreenShare开启屏幕采集,并根据你的应用场景进行参数设置:

    1. kAppGroup: 屏幕采集插件和主app约定使用相同的 App Group。

    2. mode:屏幕共享模式,包含不共享、仅共享屏幕视频、仅共享系统音频、共享系统音频和屏幕视频。

// 主App需要与Extension进程使用相同的App Group
let kAppGroup = "group.com.aliyun.video";

@IBAction func onStartScreenShareBtnClicked(_ sender: UIButton) {
    guard let alirtcEngine = self.rtcEngine else {return}
    // 配置屏幕共享编码参数
    alirtcEngine.setScreenShareEncoderConfiguration(screenShareConfig)
    // 启动弹窗
    startBroadcastPicker()
    // 开始屏幕共享
    alirtcEngine.startScreenShare(kAppGroup, mode: screenShareMode)
}
  1. 启用 Extension 扩展进程。

Apple 需要用户主动操作才能开启屏幕采集,下面的startBoardcastPicker是利用 Apple 在 iOS 12.0 引入的RPSystemBroadcastPickerView,主要用于弹出“直播屏幕”的弹窗,提示用户操作开启屏幕录制。

// 创建并配置一个系统广播选择器视图,苹果要求屏幕共享需要用户显示触发
func startBroadcastPicker() {
    if #available(iOS 12.0, *) {
        let broadcastPickerView = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
        
        guard let bundlePath = Bundle.main.path(forResource: "ScreenShareExtension", ofType: "appex", inDirectory: "PlugIns") else {
            self.showErrorAlertView("Can not find bundle at path", code: 0, forceShow: false)
            return
        }
        
        guard let bundle = Bundle(path: bundlePath) else {
            self.showErrorAlertView("Can not find bundle at path", code: 0, forceShow: false)
            return
        }
        
        broadcastPickerView.preferredExtension = bundle.bundleIdentifier
        
        // Traverse the subviews to find the button to skip the step of clicking the system view.
        // This solution is not officially recommended by Apple, and may be invalid in future system updates.
        for subView in broadcastPickerView.subviews {
            if let button = subView as? UIButton {
                button.sendActions(for: .allEvents)
            }
        }
        
    } else {
        self.showErrorAlertView("This feature only supports iOS 12 or above", code: 0, forceShow: false)
        return
    }
}

4.停止屏幕共享

调用stopScreenShare,在频道内停止屏幕共享,示例如下:

@IBAction func onStopScreenShareBtnClicked(_ sender: UIButton) {
    guard let alirtcEnging = self.rtcEngine else {return}
    if(alirtcEnging.isScreenSharePublished()) {
        alirtcEnging.stopScreenShare()
    }
}

5.(远端)观看屏幕共享画面

ARTC 允许同时推拉屏幕流和相机流,当远端用户开始推送音视频流时,本端会触发onRemoteTrackAvailableNotify回调,通过 videoTrack 的状态变化来表示远端用户视频流信息,可以根据状态变化来部署用户的画面。

// 保存所有视图
var videoSeatViewMap: [String: SeatView] = [:]

// 根据回调部署画面
func onRemoteTrackAvailableNotify(_ uid: String, audioTrack: AliRtcAudioTrack, videoTrack: AliRtcVideoTrack) {
    "onRemoteTrackAvailableNotify uid: \(uid) audioTrack: \(audioTrack)  videoTrack: \(videoTrack)".printLog()
    // 远端用户的流状态
    DispatchQueue.main.async {
        switch videoTrack {
        case .no:
            // 移除所有该用户的视图
            self.removeSeatView(uid: uid, streamType: .camera)
            self.removeSeatView(uid: uid, streamType: .screen)
        case .camera:
            // 添加相机视图
            self.createOrUpdateSeatView(uid: uid, streamType: .camera)
        case .screen:
            // 添加屏幕共享视图
            self.createOrUpdateSeatView(uid: uid, streamType: .screen)
        case .both:
            // 添加相机视图和屏幕共享视图
            self.createOrUpdateSeatView(uid: uid, streamType: .camera)
            self.createOrUpdateSeatView(uid: uid, streamType: .screen)
        @unknown default:
            break
        }
    }
}
// 移除指定用户的指定画面
func removeSeatView(uid: String, streamType: StreamType) {
    let key = "\(uid)_\(streamType)"
    
    guard let seatView = videoSeatViewMap.removeValue(forKey: key) else { return }
    
    // 1. 从UI中移除
    seatView.removeFromSuperview()
    
    // 2. 清理视频资源
    rtcEngine?.setRemoteViewConfig(nil, uid: uid, for: streamType == .camera ? .camera : .screen)
    
    // 3. 检查是否还有该用户的其他视图
    let hasOtherViews = videoSeatViewMap.keys.contains { $0.hasPrefix("\(uid)_") }
    
    if !hasOtherViews {
        // 移除用户容器
        if let container = findUserContainer(for: uid) {
            container.removeFromSuperview()
        }
    } else {
        // 重新布局剩余视图
        updateLayoutForUser(uid: uid)
    }
}

// 创建或更新一个视频通话渲染视图,并加入到contentScrollView中
func createOrUpdateSeatView(uid: String, streamType: StreamType) ->SeatView {
    let key = "\(uid)_\(streamType)"
    
    // 1. 如果已有视图,直接返回
    if let existingView = videoSeatViewMap[key] {
        return existingView
    }
    
    // 2. 创建新视图
    let seatView = SeatView(frame: .zero)
    seatView.seatInfo = SeatInfo(uid: uid, streamType: streamType)
    
    if uid != self.userId {
        // 3. 配置视频画布
        let canvas = AliVideoCanvas()
        canvas.view = seatView.canvasView
        canvas.renderMode = .fill
        canvas.mirrorMode = streamType == .screen ? .allDisabled : .allEnabled
        canvas.rotationMode = ._0
        
        rtcEngine?.setRemoteViewConfig(canvas, uid: uid, for: streamType == .camera ? .camera : .screen)
    }
    
    // 4. 添加到管理字典
    videoSeatViewMap[key] = seatView
    
    // 6. 更新布局
    updateLayoutForUser(uid: uid)
    return seatView
}

6.(可选)配置屏幕共享音频音量

如果需要共享系统声音,可以调用setAudioShareAppVolume接口控制屏幕共享音频流的音量。

@IBAction func onShareAudioVolumeSliderChanged(_ sender: UISlider) {
    let volume = Int32(sender.value)
    rtcEngine?.setAudioShareAppVolume(volume)
}