Как реализовать скриншаринг в iOS-приложении? — обложка

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

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

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

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

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

Начнём с простого — если нужно делиться только экраном внутри приложения. Для этого используем фреймворк от 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 соединяет двух клиентов для передачи видео без использования дополнительных серверов — соединение 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) 
        } 
} 
 

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

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

App Extension и основное приложение (назовём его Containing app) не могут напрямую обмениваться данными, но могут использовать общее хранилище. Чтобы его настроить, создайте AppGroup на сайте разработчика Apple и привяжите эту группу к основному приложению и 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” начнётся трансляция, и выбранное расширение будет управлять состоянием и самим потоком. Как приложение-контейнер узнаёт об этом? Контейнер хранилища общий, поэтому обмен данными можно организовать через файловую систему — например, с помощью 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 кадров в секунду, но рекомендуется читать с частотой 30 кадров в секунду, так как у системы недостаточно ресурсов для обработки данных в 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». Доступно в App Store.

  • Разработка