Скриншаринг — показ экрана пользователя собеседникам во время видеозвонка.
В iOS - 2 способа реализации скриншаринга:
- Скриншаринг в приложении. Этот способ поддерживает только скриншаринг в пределах конкретного приложения. При сворачивании передача видео остановится. Реализовать просто.
- Скриншаринг c использованием расширений. Такой способ дает возможность шарить экран почти из любой точки операционной системы: например, домашний экран, сторонние приложения или системные настройки. Но его реализация довольно трудоемкая.
В этой статье расскажем, как реализовать оба способа.
Скриншаринг в приложении
Начнем с простого — если нужно шарить только экран внутри приложения. Для этого используем фреймворк от Apple — ReplayKit.
В примере для наглядности добавили его в ViewController. На нем размещены кнопки управления записью, изменения фона и imageView — туда впоследствии будет выводиться захваченное видео:
Для начала захвата экрана обращаемся к классу RPScreenRecorder.shared() и вызываем startCapture(handler: completionHandler:)
Приложение запросит у пользователя разрешение на захват экрана:
Затем ReplayKit генерирует поток CMSampleBuffer для каждого типа медиа — аудио и видео. Он содержит сам медиафрагмент — изображение экрана пользователя — и всю необходимую информацию о нем.
Каждый сгенерированный видеокадр конвертируем в тип UIImage и выводим на экран:
Результат:
Трансляция захвата экрана в WebRTC
Обычная ситуация: во время видеозвонка один из собеседников хочет показать другому, что происходит на экране. Для этого отлично подойдет WebRTC.
WebRTC соединяет 2 клиента для передачи видеоданных без использования дополнительных серверов - соединение peer-to-peer (p2p). Подробнее о WebRTC — в статье в нашем блоге.
Потоки данных, которыми обмениваются между собой клиенты — это медиастримы, которые состоят из видео- и/или аудиопотока. Видеопотоком может быть как изображение с камеры, так и изображение экрана.
Для успешной установки соединения peer-to-peer конфигурируем локальный медиапоток, который затем передастся дескриптору сессии. Для этого получаем объект класса RTCPeerConnectionFactory, в который добавляем медиапоток, упакованный аудио- и видеодорожкой.
Стоит уделить отдельное внимание и конфигурации видеодорожки:
Скриншаринг 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 написан класс с таким же названием. Экземпляр именно этого класса будет обрабатывать записываемый поток.
В нем уже прописаны доступные нам методы:
По названиям функций понимаем, за что они отвечают. Исключение — последний метод, в него как раз приходит последний CMSampleBuffer и его тип. В случае, если тип буфера .video, там находится последний кадр.
Теперь реализуем скриншаринг с запуском iOS Broadcast. Для начала показываем сам RPSystemBroadcastPickerView и выставляем расширение — Extension, — которое необходимо вызывать.
После нажатия на “Start broadcast” начнется броадкаст и выбранное расширение будет обрабатывать состояние и сам поток. Как Containing App узнает об этом? Контейнер хранилища общий, поэтому можем обмениваться данными через файловую систему — например, UserDefaults(suiteName) и FileManager. С их помощью можем настроить таймер, проверять состояние в определенные промежутки времени, записывать и читать данные по определенному пути. Альтернатива — запустить локальный веб-сокет сервер и общаться по нему. Но в этой статье рассмотрим только обмен через файлы.
Напишем класс BroadcastStatusManagerImpl, который будет заносить текущий статус броадкаста и сообщать делегату об изменении статуса. Проверять актуальные данные будем через таймер с частотой 0.5 секунд.
Создадим экземпляры BroadcastStatusManagerImpl у App Extension и у Containing App, чтобы они знали состояние броадкаста и записывали его. Containing App не может напрямую остановить броадкаст. Поэтому в App Extension подписываемся на состояние. Так, когда придет статус false, App Extension сможет завершить броадкаст, используя метод finishBroadcastWithError. Хоть мы и завершаем броадкаст без ошибки, это единственный способ программного завершения броадкаста, который предоставляет Apple SDK.
Теперь оба приложения знают, когда начался или закончился броадкаст. Дальше передаем данные последнего кадра. Для этого создаем класс PixelBufferSerializer, где объявим методы serialize и deserialize. В методе processSampleBuffer SampleHandler’а переводим CMSampleBuffer в CVPixelBuffer, затем сериализуем в Data. При сериализации в Data записываем в заголовок тип формата, высоту, ширину, шаг обработки для каждой плоскости. В данном случае плоскости 2: luminance (яркость) и chrominance (цветность) и их данные. Чтобы получить данные из буфера, используем функции семейства CVPixelBuffer.
При тестировании с iOS на Android мы столкнулись с проблемой: на Android скриншаринг не отображался. Дело в том, что эта ОС не поддерживает нестандартное разрешение кадра. Проблему решили — сменили разрешение на стандартные 1080х720.
После того, как сериализовали в Data, записываем ссылку на полученные байты в файл.
В Containing App создаем класс BroadcastBufferContext. Логика его поведения похожа на BroadcastStatusManagerImpl: файл считывает каждую итерацию таймера и отдает данные на дальнейшую обработку. Сам поток приходит в 60 FPS, но желательно читать с 30 FPS, т.к у системы нет достаточных ресурсов для обработки в 60 FPS.
Затем десериализуем обратно в CVPixelBuffer - по такому же принципу, как и сериализовали, только в обратном порядке. Затем конфигурируем видеодорожку — устанавливаем разрешение и FPS.
И, наконец, добавляем фрейм RTCVideoFrame(buffer: rtcpixelBuffer, rotation: RTCVideoRotation._0, timeStampNs: Int64(timestamp)). Эту видеодорожку добавляем к локальному стриму.
Заключение
Реализация скриншаринга на iOS не такая простая, как могло бы показаться на первый взгляд. Закрытость и безопасность операционной системы заставляют придумывать обходные пути для решения таких задач. Мы их нашли - посмотрите результат на примере “Fora Soft Video Calls”. Доступно в AppStore.
Комментарии