Скриншаринг — показ экрана пользователя собеседникам во время видеозвонка.

В iOS - 2 способа реализации скриншаринга:

  • Скриншаринг в приложении. Этот способ поддерживает только скриншаринг в пределах конкретного приложения. При сворачивании передача видео остановится. Реализовать просто.
  • Скриншаринг c использованием расширений. Такой способ дает возможность шарить экран почти из любой точки операционной системы: например, домашний экран, сторонние приложения или системные настройки. Но его реализация довольно трудоемкая.

В этой статье расскажем, как реализовать оба способа.

Скриншаринг в приложении

Начнем с простого — если нужно шарить только экран внутри приложения. Для этого используем фреймворк от Apple — ReplayKit.

 
 import ReplayKit 
class ScreenShareViewController: UIViewController { 
		lazy var startScreenShareButton: UIButton = { 
        let button = UIButton() 
        button.setTitle("Start screen share", for: .normal) 
        button.setTitleColor(.systemGreen, for: .normal) 
        return button 
    }() 
    lazy var stopScreenShareButton: UIButton = { 
        let button = UIButton() 
        button.setTitle("Stop screen share", for: .normal) 
        button.setTitleColor(.systemRed, for: .normal) 
        return button 
    }() 
		lazy var changeBgColorButton: UIButton = { 
        let button = UIButton() 
        button.setTitle("Change background color", for: .normal) 
        button.setTitleColor(.gray, for: .normal) 
        return button 
    }() 
    lazy var videoImageView: UIImageView = { 
        let imageView = UIImageView() 
        imageView.image = UIImage(systemName: "rectangle.slash") 
        imageView.contentMode = .scaleAspectFit 
        return imageView 
    }() 
} 
 

В примере для наглядности добавили его в ViewController. На нем размещены кнопки управления записью, изменения фона и imageView — туда впоследствии будет выводиться захваченное видео:

Как реализовать скриншаринг в iOS-приложении?, image #1

Для начала захвата экрана обращаемся к классу RPScreenRecorder.shared() и вызываем startCapture(handler: completionHandler:)

 
 @objc func startScreenShareButtonTapped() { 
		RPScreenRecorder.shared().startCapture { sampleBuffer, sampleBufferType, error in 
				self.handleSampleBuffer(sampleBuffer, sampleType: sampleBufferType) 
            if let error = error { 
                print(error.localizedDescription) 
            } 
        } completionHandler: { error in 
            print(error?.localizedDescription) 
        } 
}
 

Приложение запросит у пользователя разрешение на захват экрана:

Как реализовать скриншаринг в iOS-приложении?, image #2

Затем ReplayKit генерирует поток CMSampleBuffer для каждого типа медиа — аудио и видео. Он содержит сам медиафрагмент — изображение экрана пользователя — и всю необходимую информацию о нем.

 
 func handleSampleBuffer(_ sampleBuffer: CMSampleBuffer, sampleType: RPSampleBufferType ) { 
        switch sampleType { 
        case .video: 
            handleVideoFrame(sampleBuffer: sampleBuffer) 
        case .audioApp: 
//             handle audio app 
            break 
        case .audioMic: 
//             handle audio mic 
            break 
        } 
    } 
 

Каждый сгенерированный видеокадр конвертируем в тип UIImage и выводим на экран:

 
 func handleVideoFrame(sampleBuffer: CMSampleBuffer) { 
        let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)! 
        let ciimage = CIImage(cvPixelBuffer: imageBuffer) 
        let context = CIContext(options: nil) 
        var cgImage = context.createCGImage(ciimage, from: ciimage.extent)! 
        let image = UIImage(cgImage: cgImage) 
        render(image: image) 
} 
 

Результат:

Как реализовать скриншаринг в iOS-приложении?, image #3
Как реализовать скриншаринг в iOS-приложении?, image #4

Трансляция захвата экрана в WebRTC

Обычная ситуация: во время видеозвонка один из собеседников хочет показать другому, что происходит на экране. Для этого отлично подойдет WebRTC.

WebRTC соединяет 2 клиента для передачи видеоданных без использования дополнительных серверов - соединение peer-to-peer (p2p). Подробнее о WebRTC — в статье в нашем блоге.

Потоки данных, которыми обмениваются между собой клиенты — это медиастримы, которые состоят из видео- и/или аудиопотока. Видеопотоком может быть как изображение с камеры, так и изображение экрана.

Для успешной установки соединения peer-to-peer конфигурируем локальный медиапоток, который затем передастся дескриптору сессии. Для этого получаем объект класса RTCPeerConnectionFactory, в который добавляем медиапоток, упакованный аудио- и видеодорожкой.

 
 func start(peerConnectionFactory: RTCPeerConnectionFactory) { 
        self.peerConnectionFactory = peerConnectionFactory 
        if self.localMediaStream != nil { 
            self.startBroadcast() 
        } else { 
            let streamLabel = UUID().uuidString.replacingOccurrences(of: "-", with: "") 
            self.localMediaStream = peerConnectionFactory.mediaStream(withStreamId: "\\(streamLabel)") 
            let audioTrack = peerConnectionFactory.audioTrack(withTrackId: "\\(streamLabel)a0") 
            self.localMediaStream?.addAudioTrack(audioTrack) 
            self.videoSource = peerConnectionFactory.videoSource() 
            self.screenVideoCapturer = RTCVideoCapturer(delegate: videoSource!) 
            self.startBroadcast()
            self.localVideoTrack = peerConnectionFactory.videoTrack(with: videoSource!, trackId: "\\(streamLabel)v0") 
            if let videoTrack = self.localVideoTrack  { 
                self.localMediaStream?.addVideoTrack(videoTrack) 
            } 
            self.configureScreenCapturerPreview() 
        } 
    } 
 

Стоит уделить отдельное внимание и конфигурации видеодорожки:

 
 func handleSampleBuffer(sampleBuffer: CMSampleBuffer, type: RPSampleBufferType) { 
        if type == .video { 
            guard let videoSource = videoSource, 
                  let screenVideoCapturer = screenVideoCapturer, 
                  let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } 
            let width = CVPixelBufferGetWidth(pixelBuffer) 
            let height = CVPixelBufferGetHeight(pixelBuffer) 
            videoSource.adaptOutputFormat(toWidth: Int32(width), height: Int32(height), fps: 24) 
            let rtcpixelBuffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer) 
            let timestamp = NSDate().timeIntervalSince1970 * 1000 * 1000 
            let videoFrame = RTCVideoFrame(buffer: rtcpixelBuffer, rotation: RTCVideoRotation._0, timeStampNs: Int64(timestamp)) 
            videoSource.capturer(screenVideoCapturer, didCapture: videoFrame) 
        } 
} 
 

Скриншаринг c использованием App Extension

Операционная система iOS довольно закрытая и защищенная, поэтому обратиться к пространству памяти вне приложения сложно. Чтобы дать разработчикам возможность получить доступ к экранам вне приложения, Apple сделали специальные App Extensions — отдельные программы с доступом к той или иной зависимости iOS. Они выполняют конкретную задачу в соответствии со своим типом.

App Extension и главное приложение (назовем его Containing app) не могут напрямую взаимодействовать друг с другом, но у них может быть общий контейнер хранилища данных. Чтобы его сформировать, создаем AppGroup на сайте разработчика Apple и связываем группу с Containing App и App Extension.

Схема обмена данными между сущностями
Схема обмена данными между сущностями

Приступим к созданию нужного App Extension. В проекте создаем новый Таргет и выбираем Broadcast Upload Extension. Именно у него есть доступ к потоку записи и дальнейшей его обработке. Также создаем и настраиваем App Group между таргетами.

В проекте появилась папка с App Extension. В ней - Info.plist, файл с расширением .entitlements и swift-файл SampleHandler. В SampleHandler написан класс с таким же названием. Экземпляр именно этого класса будет обрабатывать записываемый поток.

В нем уже прописаны доступные нам методы:

 
 override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) 
override func broadcastPaused()  
override func broadcastResumed()  
override func broadcastFinished() 
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) 
 

По названиям функций понимаем, за что они отвечают. Исключение — последний метод, в него как раз приходит последний CMSampleBuffer и его тип. В случае, если тип буфера .video, там находится последний кадр.

Теперь реализуем скриншаринг с запуском iOS Broadcast. Для начала показываем сам RPSystemBroadcastPickerView и выставляем расширение — Extension, — которое необходимо вызывать.

 
 let frame = CGRect(x: 0, y: 0, width: 60, height: 60) 
let systemBroadcastPicker = RPSystemBroadcastPickerView(frame: frame) 
systemBroadcastPicker.autoresizingMask = [.flexibleTopMargin, .flexibleRightMargin] 
if let url = Bundle.main.url(forResource: "BroadcastExtension", withExtension: "appex", subdirectory: "PlugIns") { 
    if let bundle = Bundle(url: url) { 
           systemBroadcastPicker.preferredExtension = bundle.bundleIdentifier 
     } 
} 
view.addSubview(systemBroadcastPicker) 
 

После нажатия на “Start broadcast” начнется броадкаст и выбранное расширение будет обрабатывать состояние и сам поток. Как Containing App узнает об этом? Контейнер хранилища общий, поэтому можем обмениваться данными через файловую систему — например, UserDefaults(suiteName) и FileManager. С их помощью можем настроить таймер, проверять состояние в определенные промежутки времени, записывать и читать данные по определенному пути. Альтернатива — запустить локальный веб-сокет сервер и общаться по нему. Но в этой статье рассмотрим только обмен через файлы.

Напишем класс BroadcastStatusManagerImpl, который будет заносить текущий статус броадкаста и сообщать делегату об изменении статуса. Проверять актуальные данные будем через таймер с частотой 0.5 секунд.

 
 protocol BroadcastStatusSubscriber: AnyObject { 
    func onChange(status: Bool) 
} 
protocol BroadcastStatusManager: AnyObject { 
    func start() 
    func stop() 
    func subscribe(_ subscriber: BroadcastStatusSubscriber) 
} 
final class BroadcastStatusManagerImpl: BroadcastStatusManager { 
    // MARK: Private properties 
    private let suiteName = "group.com..<>" 
    private let forKey = "broadcastIsActive" 
    private weak var subscriber: BroadcastStatusSubscriber? 
    private var isActiveTimer: DispatchTimer? 
    private var isActive = false 
    deinit { 
        isActiveTimer = nil 
    } 
    // MARK: Public methods 
    func start() { 
        setStatus(true) 
    } 
    func stop() { 
        setStatus(false) 
    } 
    func subscribe(_ subscriber: BroadcastStatusSubscriber) { 
        self.subscriber = subscriber 
        isActive = getStatus() 
        isActiveTimer = DispatchTimer(timeout: 0.5, repeat: true, completion: { [weak self] in 
            guard let self = self else { return } 
            let newStatus = self.getStatus() 
            guard self.isActive != newStatus else { return } 
            self.isActive = newStatus 
            self.subscriber?.onChange(status: newStatus) 
        }, queue: DispatchQueue.main) 
        isActiveTimer?.start() 
    } 
    // MARK: Private methods 
    private func setStatus(_ status: Bool) { 
        UserDefaults(suiteName: suiteName)?.set(status, forKey: forKey) 
    } 
    private func getStatus() -> Bool { 
        UserDefaults(suiteName: suiteName)?.bool(forKey: forKey) ?? false 
    } 
} 
 

Создадим экземпляры BroadcastStatusManagerImpl у App Extension и у Containing App, чтобы они знали состояние броадкаста и записывали его. Containing App не может напрямую остановить броадкаст. Поэтому в App Extension подписываемся на состояние. Так, когда придет статус false, App Extension сможет завершить броадкаст, используя метод finishBroadcastWithError. Хоть мы и завершаем броадкаст без ошибки, это единственный способ программного завершения броадкаста, который предоставляет Apple SDK.

 
 extension SampleHandler: BroadcastStatusSubscriber { 
    func onChange(status: Bool) { 
        if status == false { 
            finishBroadcastWithError(NSError(domain: "BroadcastExtension", code: 1, userInfo: [ 
                NSLocalizedDescriptionKey: "Broadcast completed" 
            ])) 
        } 
    } 
} 
 

Теперь оба приложения знают, когда начался или закончился броадкаст. Дальше передаем данные последнего кадра. Для этого создаем класс PixelBufferSerializer, где объявим методы serialize и deserialize. В методе processSampleBuffer SampleHandler’а переводим CMSampleBuffer в CVPixelBuffer, затем сериализуем в Data. При сериализации в Data записываем в заголовок тип формата, высоту, ширину, шаг обработки для каждой плоскости. В данном случае плоскости 2: luminance (яркость) и chrominance (цветность) и их данные. Чтобы получить данные из буфера, используем функции семейства CVPixelBuffer.

При тестировании с iOS на Android мы столкнулись с проблемой: на Android скриншаринг не отображался. Дело в том, что эта ОС не поддерживает нестандартное разрешение кадра. Проблему решили — сменили разрешение на стандартные 1080х720.

После того, как сериализовали в Data, записываем ссылку на полученные байты в файл.

 
 memcpy(mappedFile.memory, baseAddress, data.count)
 

В Containing App создаем класс BroadcastBufferContext. Логика его поведения похожа на BroadcastStatusManagerImpl: файл считывает каждую итерацию таймера и отдает данные на дальнейшую обработку. Сам поток приходит в 60 FPS, но желательно читать с 30 FPS, т.к у системы нет достаточных ресурсов для обработки в 60 FPS.

 
 func subscribe(_ subscriber: BroadcastBufferContextSubscriber) { 
        self.subscriber = subscriber 
        framePollTimer = DispatchTimer(timeout: 1.0 / 30.0, repeat: true, completion: { [weak self] in 
            guard let mappedFile = self?.mappedFile else { 
                return
            } 
            var orientationValue: Int32 = 0 
            mappedFile.read(at: 0 ..< 4, to: &orientationValue) 
            self?.subscriber?.newFrame(Data( 
                bytesNoCopy: mappedFile.memory.advanced(by: 4), 
                count: mappedFile.size - 4, 
                deallocator: .none 
            )) 
        }, queue: DispatchQueue.main) 
        framePollTimer?.start() 
    } 
 

Затем десериализуем обратно в CVPixelBuffer - по такому же принципу, как и сериализовали, только в обратном порядке. Затем конфигурируем видеодорожку — устанавливаем разрешение и FPS.

 
 videoSource.adaptOutputFormat(toWidth: Int32(width), height: Int32(height), fps: 60) 
 

И, наконец, добавляем фрейм RTCVideoFrame(buffer: rtcpixelBuffer, rotation: RTCVideoRotation._0, timeStampNs: Int64(timestamp)). Эту видеодорожку добавляем к локальному стриму.

 
 localMediaStream.addVideoTrack(videoTrack) 
 

Заключение

Реализация скриншаринга на iOS не такая простая, как могло бы показаться на первый взгляд. Закрытость и безопасность операционной системы заставляют придумывать обходные пути для решения таких задач. Мы их нашли - посмотрите результат на примере “Fora Soft Video Calls”. Доступно в AppStore.

  • Разработка