Как реализовать совместное использование экрана в приложении для iOS с помощью ReplayKit и расширения приложения

Алексей Т.

Вступление

Совместное использование экрана — захват экрана пользователя и демонстрация его коллегам во время видеозвонка.
Есть два способа, как вы можете реализовать совместное использование экрана в вашем приложении для iOS:

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

В этой статье мы поделимся руководствами по обоим вариантам.

Совместное использование экрана в приложении

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

Для захвата экрана мы обращаемся к классу 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)
        }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Затем приложение запрашивает разрешение на захват экрана:

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)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вот как это выглядит:

Захваченный экран, транслируемый в WebRTC

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

WebRTC соединяет 2 клиента для передачи видеоданных без дополнительных серверов — это одноранговое соединение (p2p). Ознакомьтесь с этой статьей, чтобы узнать о нем подробнее.

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

Чтобы успешно установить p2p-соединение, настройте локальный медиапоток, который в дальнейшем будет передаваться в дескриптор сессии. Для этого получите объект класса 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)
        }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Совместное использование экрана с помощью расширения приложения

Поскольку iOS — довольно закрытая и сильно защищенная ОС, не так-то просто обратиться к пространству хранения данных вне приложения. Чтобы дать разработчикам доступ к определенным функциям вне приложения, Apple создала App Extensions — внешние приложения с доступом к определенным связям в iOS. Они работают в соответствии со своими типами. App Extensions и основное приложение (назовем его Containing app) не взаимодействуют друг с другом напрямую, но могут совместно использовать контейнер для хранения данных. Чтобы убедиться в этом, создайте AppGroup на сайте разработчиков Apple, затем свяжите эту группу с содержащим приложением и расширением приложения.

Теперь перейдем к разработке расширения приложения. Создайте новую цель и выберите Broadcast Upload Extension. Оно имеет доступ к потоку записи и его дальнейшей обработке. Создайте и настройте группу приложений между целями. Теперь вы можете видеть созданную папку с App Extension. Там есть Info.plist, файл расширения, и файл 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 и установим расширение для вызова.

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: "<OurName>BroadcastExtension", withExtension: "appex", subdirectory: "PlugIns") {
    if let bundle = Bundle(url: url) {
           systemBroadcastPicker.preferredExtension = bundle.bundleIdentifier
     }
}
view.addSubview(systemBroadcastPicker)

Вход в полноэкранный режим Выход из полноэкранного режима

Как только пользователь нажмет кнопку «Начать трансляцию», трансляция начнется, и выбранное расширение будет обрабатывать состояние и сам поток. Но как содержащее приложение узнает об этом? Поскольку контейнер хранения является общим, мы можем обмениваться данными через файловую систему — например, UserDefaults(suiteName) и FileManager. С его помощью мы можем установить таймер, проверять состояния через определенные промежутки времени, записывать и считывать данные по определенному пути. Альтернативой этому является запуск локального web-socket сервера и обращение к нему. Но в этой статье мы рассмотрим только обмен через файлы.

Напишите класс 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.<YourOrganizationName>.<>"
    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 для расширения приложения и содержащего приложения, чтобы они знали состояние трансляции и записывали его. Содержащее приложение не может остановить трансляцию напрямую. Поэтому мы подписываемся на состояние — таким образом, когда оно сообщит false, App Extension завершит вещание, используя метод finishBroadcastWithError. Даже если на самом деле мы завершаем его без ошибки, это единственный метод, который Apple SDK предоставляет для завершения трансляции программы.

extension SampleHandler: BroadcastStatusSubscriber {
    func onChange(status: Bool) {
        if status == false {
            finishBroadcastWithError(NSError(domain: "<YourName>BroadcastExtension", code: 1, userInfo: [
                NSLocalizedDescriptionKey: "Broadcast completed"
            ]))
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

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

Затем создайте класс 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.

Оцените статью
devanswers.ru
Добавить комментарий