
Скриншаринг — демонстрация экрана пользователя собеседникам во время видеозвонка.
В 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 — туда впоследствии будет выводиться захваченное видео:

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