Всем привет! Меня зовут Киселев Илья, я iOS-разработчик в продуктовой команде Видео в Иви.
С системной функцией Picture-in-Picture (PiP) сталкивались, пожалуй, все владельцы iPhone: кто-то смотрит видео на фоне переписки, кто-то общается по видеосвязи в любимых мессенджерах. Для пользователя это просто удобство, а для разработчика видеосервиса - отдельный механизм со своей инфраструктурой, жизненным циклом и набором ограничений. В этой статье я разберу интеграцию PiP как встроенного плеера: от первого запуска до переключения контента. Поделюсь нюансами, с которыми вы точно столкнётесь на практике, и покажу, как обойти типовые грабли.
Оглавление
Что такое Picture-in-Picture
Picture-in-Picture (PiP) - это системная функция iOS, которая позволяет воспроизводить видео в отдельном плавающем окне поверх других приложений и домашнего экрана. Пользователь может свернуть приложение, продолжив при этом смотреть контент в компактном окошке, которое можно перетаскивать по экрану, изменять в размере или временно «прятать» за грань дисплея.
Технически это гораздо больше, чем просто «маленькое видео в углу». Ключевая особенность PiP заключается в том, что это сквозной системный механизм. Окно PiP не принадлежит вашему приложению в привычном смысле - оно управляется системой и продолжает жить, даже когда пользователь ушёл на рабочий стол или открыл другое приложение.
Функция появилась достаточно давно (изначально на iPad в iOS 9), но для iPhone стала общедоступной начиная с iOS 14. Именно с этого момента фоновое воспроизведение видео в маленьком окошке стало стандартом де-факто для всех серьёзных видеосервисов.
Подготовка и запуск AVPictureInPictureController
Мы сосредоточимся на связке PiP с AVPlayer. Начнём с главного инструмента - класса AVPictureInPictureController.
Сначала нам нужно создать контроллер. Самый простой способ - передать ссылку на AVPlayerLayer вашего плеера:
let pipController = AVPictureInPictureController(playerLayer: playerLayer)
Либо можно воспользоваться инициализатором через contentSource:
let contentSource = AVPictureInPictureController.ContentSource(playerLayer: playerLayer) let pipController = AVPictureInPictureController(contentSource: contentSource)
Настройка AVAudioSession
Важный подготовительный шаг. Чтобы система вообще разрешила запускать видео в фоне, необходимо правильно настроить
AVAudioSession. Если этого не сделать,startPictureInPicture()просто не запустит Picture-In-Picture.
do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .moviePlayback) try session.setActive(true) } catch { print("Failed to configure AVAudioSession: \(error)") }
Особенность системного окна
Система отображает контент в отдельном окне. Стоит отметить инженерный факт: PiP живёт в собственном UIWindow. При желании его можно найти в иерархии окон приложения и даже добавить туда свои UI-элементы. Однако взаимодействовать с ними не получится, так как у окна PiP своя собственная Responder Chain.
Подводные камни: почему PiP может не запуститься
На практике реализация PiP часто сталкивается с двумя проблемами на старте. Если вы всё сделали по документации, а окошко не появляется, проверьте следующие моменты.
1. Удержание контроллера в памяти (Memory Management)
Это самая частая ошибка, если вы создали AVPictureInPictureController локально внутри функции (например, по тапу на кнопку), ARC (счётчик ссылок) сразу удалит его из памяти, как только функция отработает. Контроллер просто не доживёт до момента отрисовки окошка.
class PlayerViewController: UIViewController { // Strong reference обязательно! var pipController: AVPictureInPictureController? func setupPiP(playerLayer: AVPlayerLayer) { pipController = AVPictureInPictureController(playerLayer: playerLayer) } }
2. Настройка Background Modes (Capabilities)
Даже если аудиосессия настроена верно, iOS не даст запустить фоновый процесс, если вы явно не запросили на это права.
Вам нужно зайти в настройки проекта в Xcode → вкладка Signing & Capabilities → добавить Background Modes и обязательно поставить галочку напротив Audio, AirPlay, and Picture in Picture.
Отслеживание состояний (Delegate)
Чтобы понимать, что происходит с PiP, необходимо реализовать протокол AVPictureInPictureControllerDelegate. Интерфейс делегата отражает жизненный цикл:
WillStart → DidStart → FailedToStart → WillStop → Restore? → DidStop
Особый интерес вызывает метод Restore. Он вызывается только в том случае, если пользователь решит вернуться из режима PiP обратно в приложение. В этом методе мы должны восстановить UI плеера (например, показать скрытые вью) и вызвать completionHandler(true).
Пример реализации делегата:
extension PlayerViewController: AVPictureInPictureControllerDelegate { // Пользователь нажал кнопку «развернуть» в окне PiP, // чтобы вернуться в приложение func pictureInPictureController( _ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void ) { // Показываем UI плеера, который был скрыт при переходе в PiP self.playerView.isHidden = false // Сообщаем системе, что мы готовы принять пользователя обратно completionHandler(true) } // PiP полностью закрылся (пользователь смахнул окошко) func pictureInPictureControllerDidStopPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { // Очищаем ресурсы, если нужно, или ставим плеер на паузу } }
Для автоматического старта PiP при свайпе на главный экран можно использовать флаг:
pipController.canStartPictureInPictureAutomaticallyFromInline = true
Из чего состоит Picture-in-Picture (подкапотный разбор)
Настроив механизм в приложении и запустив его, мы увидим то самое окно с видео внутри. При этом механизм этот сквозной для всей системы. Было бы интересно узнать о нём подробнее: из чего он состоит и всё ли ограничивается одним AVKit? Давайте разбираться.
Поможет нам в этом утилита Logging в Xcode Instruments. Запустим приложение с настроенным PiP, провзаимодействуем с ним и сопоставим эти события на таймлайне нашего запуска.

Сравнив таймлайн и сообщения, видно, что больше всего в моменты активности PiP событий приходится на процессы com.apple.avkit и com.apple.pegasus, и если с AVKit всё понятно, то что же такое Pegasus?

Сразу скажу, бояться не стоит: это не шпионское ПО, которое вам установили на айфон, а всего лишь системный фреймворк, используемый для работы с Picture-in-Picture. Вы можете ознакомиться с его заголовками подробнее на GitHub.
Если взглянуть подробнее на события, расставленные по таймлайну, то мы увидим, кто их отправляет: у AVKit это AVPictureInPicturePlatformAdapter, а у Pegasus - PGPictureInPictureProxy.


Как именно AVKit взаимодействует с Pegasus?
Если мы проанализируем заголовки (class-dump), то увидим архитектуру того, как iOS развязывает ваш плеер и системный UI. Вся магия Picture-in-Picture кроется именно в них - мы увидим архитектурную цепочку:
AVPictureInPictureController ➜ AVPictureInPicturePlatformAdapter ➜ PGPictureInPictureProxy
Вот как это работает на практике:
Публичный фасад. Когда вызывается startPictureInPicture(), AVPictureInPictureController не пытается сам рисовать системное окно. Он передаёт команду внутреннему классу AVPictureInPicturePlatformAdapter. Адаптер нужен Apple, чтобы развязать публичный API от тяжёлой приватной реализации.
Прокси-менеджер (Pegasus). Адаптер обращается к классу PGPictureInPictureProxy внутри Pegasus.framework, и именно здесь кроется самое интересное: если посмотреть заголовок PGPictureInPictureProxy.h, мы увидим, что этот класс напрямую владеет свойством UIWindow *_contentWindow.
Отслеживание действий пользователя внутри PiP
Когда пользователь смотрит видео в PiP, он может поставить видео на паузу или перемотать его с помощью системных контролов. Как узнать об этом? Сам Delegate не предоставляет для этого методов.
iOS 18+: AVMetrics
Начиная с iOS 18, Apple представила механизм AVMetrics. Теперь мы можем подписаться на асинхронный поток событий перемотки у нашего AVPlayerItem:
if let stream = item.metrics(forType: AVMetricPlayerItemSeekEvent.self) { Task { for try await event in stream { // Реагируем на перемотку контента print("Пользователь перемотал видео") } } }
До iOS 18: KVO / Combine
Но что делать на версиях iOS ниже 18?
Здесь на помощь приходит KVO (или современный Combine). Необходимо наблюдать за свойством timeControlStatus у экземпляра AVPlayer, когда пользователь нажмёт паузу в окне PiP, статус изменится на .paused, и ваше приложение сможет синхронизировать свой UI.
Пример использования:
private var cancellables = Set<AnyCancellable>() func observePlayerState(player: AVPlayer) { player.publisher(for: \.timeControlStatus) .sink { [weak self] status in switch status { case .playing: print("Видео воспроизводится") case .paused: print("Видео встало на паузу (возможно, из PiP)") case .waitingToPlay: print("Ожидание (например, буферизация)") @unknown default: break } } .store(in: &cancellables) }
Переключение контента в Picture-in-Picture
При разработке видеосервисов часто возникает необходимость бесшовно переключить видео (например, следующую серию), не прерывая режим PiP. Здесь есть два возможных сценария.
Сценарий 1. Смена текущего AVPlayerItem
Если у вас меняется только контент, достаточно просто заменить AVPlayerItem у текущего AVPlayer. PiP продолжит работать плавно, и от разработчика не требуется никаких дополнительных действий.
let nextItem = AVPlayerItem(url: nextEpisodeURL) player.replaceCurrentItem(with: nextItem)
Сценарий 2. Пересоздание плеера
Иногда архитектура приложения требует создания нового инстанса AVPlayer (и, соответственно, нового AVPlayerLayer). На помощь приходит тот самый contentSource, нам не нужно уничтожать AVPictureInPictureController - мы просто меняем contentSource, передавая в него новый слой, и видео в окошке PiP бесшовно переключается.
func switchToNewPlayer(newPlayerLayer: AVPlayerLayer) { // Бесшовно обновляем PiP без пересоздания контроллера if let pipController = self.pipController { pipController.contentSource = .init(playerLayer: newPlayerLayer) } }
Как система реагирует на смену contentSource?
Мы уже выяснили, что Picture-in-Picture устроен гораздо сложнее, чем кажется на первый взгляд. Давайте запустим Logging в Xcode Instruments и посмотрим, есть ли какие-то интересные системные сообщения в момент смены contentSource «на лету».
Интересно, что явных логов вроде contentSource система не выдаёт. Однако в моменты переключения возникает всплеск сообщений от метода PGPictureInPictureProxy _updatePlaybackStateIfNeeded. По ним мы можем отследить всё, что происходит под капотом, давайте проанализируем таймлайн.
Шаг 1. Ожидание воспроизведения
В начале мы видим лог, где PGPictureInPictureProxy запрашивает текущее состояние у нашего приложения:

В этом сообщении мы видим текущие метаданные контента и статус воспроизведения. Обратите внимание на поле TimeControlStatus = WaitingToPlayAtSpecifiedRate. Это означает, что плеер буферизует данные.
Шаг 2. Начало воспроизведения
В следующем сообщении мы видим, что состояние изменилось на .playing, и видео начало играть. Обратите внимание на адреса объектов: PGPictureInPictureProxy имеет адрес 0x1301c2880, а объект состояния PGPlaybackState - 0x126ffa7a0.

Шаг 3. Момент смены контента
Теперь смотрим на следующее сообщение на таймлайне. Именно в этот момент в нашем Swift-коде сработало присвоение нового contentSource.

Что здесь происходит?
Мы видим, что метаданные контента резко изменились. Длительность видео (ContentDuration) сменилась с 70.96 на 37.88, а текущее время просмотра (Elapsed Time) сбросилось в 0. Это однозначно доказывает, что источник видео сменился.
Но самое главное - адреса памяти объектов не изменились. PGPictureInPictureProxy всё ещё имеет адрес 0x1301c2880, и он использует всё тот же экземпляр PGPlaybackState по адресу 0x126ffa7a0.
Жизненный цикл приложения и завершение работы
Важно понимать, как PiP взаимодействует с жизненным циклом приложения. Окошко PiP не является полностью независимым - оно жёстко привязано к процессу вашего приложения.
Блокировка экрана. Если пользователь смотрит видео в PiP и блокирует телефон, видео продолжит играть только в том случае, если у вас корректно настроена аудиосессия. Экран погаснет, но звук пойдёт дальше.
Убийство приложения. Если пользователь смахнёт ваше приложение из многозадачности, пока активно окно PiP, процесс приложения будет уничтожен. Вместе с ним мгновенно закроется и окно Picture-in-Picture, а воспроизведение прекратится. Apple не позволяет PiP продолжать работать без хост-приложения.
Системные ограничения. iOS может принудительно приостановить фоновый плеер, если приложение долгое время не выводит изображение на экран или если произошёл сбой в стриме. В таком случае делегат уведомит вас через метод DidStop, и вам нужно будет корректно очистить ресурсы плеера, чтобы не разряжать батарею в фоне.
Итоги
Интеграция Picture-in-Picture значительно улучшает пользовательский опыт в видеоприложениях, но требует глубокого понимания подкапотных механизмов Apple. Как мы выяснили, для стабильной работы критически важно:
правильно настроить фоновую аудиосессию и capabilities проекта;
грамотно управлять памятью контроллера и его жизненным циклом (особенно при возврате в приложение);
использовать свойство
contentSourceдля бесшовного переключения видео, позволяя менять инстансы плеера на лету.
Надеюсь, этот опыт и практические примеры помогут вам быстро и без костылей внедрить или улучшить PiP в ваших проектах.
Полезные материалы
WWDC 2021: AVKit - сессия Apple с примерами настройки PiP.
AVFoundation Framework - официальный фреймворк для работы с медиа в iOS.
AVKit - фреймворк для внедрения стандартного медиаплеера с готовым интерфейсом управления
Xcode Instruments Logging - инструмент анализа системных событий, которым мы пользовались для разбора Pegasus.
Дампы хедеров - репозиторий извлеченных заголовков приватных фреймворков iOS.
Ссылки и примечания
Picture in Picture - Apple Developer Documentation - официальная документация по PiP в AVKit.
AVPictureInPictureController - Apple Developer - класс контроллера PiP.
AVAudioSession - Apple Developer - управление аудиосессией.
iOS-Runtime-Headers: Pegasus.framework - заголовки приватного фреймворка Pegasus.
AVMetrics - Apple Developer - механизм метрик AVFoundation (iOS 18+).