На первый взгляд, медиалента в мобильном приложении выглядит как стандартная задача: список карточек, автоплей, предзагрузка соседних роликов, несколько состояний загрузки. На практике это оказался один из самых сложных iOS‑компонентов, с которыми мне доводилось работать.

Проблема в том, что медиалента — это не один виджет и не просто плеер внутри ячейки. Это система, которая живёт на пересечении сразу нескольких тяжёлых доменов: динамически собираемый интерфейс, сетевые ограничения, декодирование медиа, менеджмент памяти, жизненный цикл вложенных контейнеров, UX‑требования к мгновенному старту, интеграция в чужие экраны и такие сложные системы, как BDUI, рекомендации, пагинации, и при этом — высокий трафик на массовом сценарии. 

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

Самое интересное в таких задачах начинается не на этапе «как добавить медиаленту», а на этапе ограничений и деградаций. В статье я разберу именно эту сторону задачи на примере приложения Яндекс Еды: как мы проектировали медиаленту, какие архитектурные решения не сработали, какие баги всплыли только на реальных данных, как мы строили observability для дебага и какие компромиссы в итоге оказались эффективнее красивой реализации.


Зачем нам понадобилась медиалента

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

С точки зрения интерфейса задача выглядела так: добавить в сценарий выбора ресторана не только обычные карточки, но и медийный контент, который даёт пользователю больше информации о месте. Это могли быть короткие видео, редакционные подборки, тематические рекомендации и другие форматы.

Из этого требования вырос отдельный формат медиаленты. По сути, это поток разнородного контента: видео, изображения, подборки, рекомендации, полноэкранные истории. Для пользователя это единый интерфейс, а для разработки — набор очень разных, но требовательных к ресурсам компонентов.

Именно здесь задача перестаёт быть «ещё одним экраном с видео». Нужно не просто встроить плеер, а собрать систему, в которой медиаконтент быстро открывается, предсказуемо ведёт себя в ленте, не перегружает сеть и память и нормально живёт внутри динамически собираемого UI.

Технические особенности задачи

С продуктовыми требованиями всё было более‑менее понятно. Но когда мы начали думать про реализацию, стало ясно, что медиалента — это не один UI‑компонент, а набор разных подсистем с разным поведением и разной стоимостью поддержки. 

Прежде всего мы собрали функциональные и нефункциональные требования:

Функциональные требования

Как я писал выше, тут довольно всё просто:

  • есть картинки, карусели, видео;

  • есть рубрикатор;

  • есть фильтрация;

  • есть экран для полноэкранного контента.

При этом важно учесть одну деталь: медиалента — это не отдельный экран, а набор виджетов внутри двух разных BDUI‑систем. А значит, она живёт внутри динамически собираемого UI. Она может находиться рядом с чем угодно: картами, подборками, каруселями, тяжёлыми карточками. Порядок и комбинации этих блоков задаются сервером и могут быть индивидуальными для юзера.

То есть мы делаем медиаленту внутри другой ленты. И наша задача — сделать так, чтобы она корректно встраивалась, не ломала соседние виджеты и не ломалась сама.

Нефункциональные требования

Тут начинается самое интересное. 

Производительность в чужом окружении. Медиалента встраивалась в уже существующий интерфейс, где рядом находятся тяжёлые изображения, сложные layout‑деревья, вложенные scroll‑view и другие ресурсоёмкие блоки.

Из‑за этого задача сводилась не просто к оптимизации собственного компонента, а к работе в чужой и непредсказуемой среде. То есть нужно было сделать высокопроизводительную систему в среде, которую мы не контролируем напрямую.

Вложенность и оверхед UI. Архитектурно всё тоже оказалось не так просто. В обеих BDUI‑системах, куда встраивалась лента, виджет был не просто UIView, а отдельным UIViewController. В ряде случаев внутри него дополнительно находился UIHostingController, если часть интерфейса была написана на SwiftUI.

В результате получалась глубокая вложенность со сложным жизненным циклом и лишним оверхедом на каждом уровне. Медиаленту нужно было встроить так, чтобы она могла спокойно жить внутри этой конструкции.

Легаси и компромиссы. Как и в любом большом продукте, у приложения Яндекс Еды уже была своя история: с легаси‑решениями, разными архитектурными подходами и кодом, который в разное время писали разные команды. Дополнительно задачу осложняло то, что интеграция требовалась сразу в два экрана, а они были построены на разных реализациях BDUI.

У нас не было возможности переписать всё заново. Поэтому задача сводилась к более приземлённому, но технически сложному сценарию: встроиться в существующую архитектуру, учесть её ограничения и при этом не сломать ничего вокруг.

Высокая нагрузка. Мы интегрируемся не в какой‑то редкий или второстепенный экран, а в главный пользовательский сценарий. Это значит, медиалента должна стабильно работать на большом количестве устройств, в разных сетевых условиях и при заметном разбросе по производительности железа. Любая проблема быстро перестаёт быть локальной. 

Продуктовые ограничения. Дополнительную сложность создавали и сами требования к поведению интерфейса. Например, воспроизводить все видео, которые попадают в область видимости. 

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

Таким образом, медиаленту нельзя было реализовать просто как набор независимых карточек с плеером внутри. Дальше я покажу, почему в итоге пришлось отдельно проектировать архитектуру, ресурсную политику и инструменты для дебага в продакшене.

Что мы не учли на старте и как это исправляли

На старте у нас было довольно оптимистичное настроение: «Ну, это же уже тысячу раз сделано. Значит, и у нас получится довольно быстро». И именно здесь мы сильно недооценили сложность.

Первое решение выглядело логично:

  • создаём ограниченное количество плееров; 

  • предзагружаем несколько следующих видео;

  • запускаем автоплей.

То есть перед нами — классическая декомпозиция: у каждой карточки свой жизненный цикл, плееры независимы, всё можно собрать на стандартных компонентах. Тем более визуально лента казалась простой: обычный список, без сложной навигации и тяжёлой бизнес‑логики. 

На первых тестах такой подход неплохо работал. Но по мере роста числа сценариев, устройств и объёма реального контента начали проявляться проблемы, которые не были видны на локальных тестах. В этот момент стало ясно, что нагрузка от видео сильно отличается от нагрузки обычного списка карточек.

Многие проблемы в подобных сложных системах нельзя увидеть сразу — они проявляются только на реальных данных и на большом наборе видео разных форматов.

При этом полноценно воспроизвести такую среду в тестовом контуре дорого и долго. На поведение ленты одновременно могут влиять сеть, состояние устройства, ответы CDN, кодеки, кеширование, ранжирование и действия пользователя. То есть это оказалась система с большим числом скрытых зависимостей, которые почти не проявляются в рамках обычного UI‑тестирования.

Ниже я сгруппировал основные проблемы по нескольким техническим категориям и рассказал, как и чем можно их дебажить.

Высокая нагрузка на сеть

Бывало так, что пользователь скроллит ленту, а видео не стартует, остаётся чёрный экран или воспроизведение зависает. При этом на одном устройстве проблема воспроизводится, а на другом — нет.

Причин у такого поведения может быть несколько: тяжёлые файлы, нестабильная связь, потери пакетов. Но одна из самых частых и недооценённых причин — конкуренция за сеть.

Мы привыкли думать про сеть как про что‑то бесконечное: можно просто добавить ещё один запрос, ещё одну предзагрузку, ещё один поток. На практике пропускная способность у пользователя ограничена, и все запросы внутри приложения делят один и тот же канал между собой. Особенно это критично, когда есть проблемы с сетью.

Хороший пример показан на скриншоте. Это экран последовательного просмотра историй, где хочется заранее предзагружать следующие страницы для UX. На симуляторах, stage‑окружении и тестовых устройствах такой сценарий может выглядеть стабильным. Но в реальности всё может сломаться о соединение с сетью.

Представим простую ситуацию. Если у пользователя есть канал около 3 Мбит/с и приложение загружает одно видео, то весь канал работает на него, поэтому старт обычно происходит быстро. Но если одновременно начать загружать пять видео, пропускная способность не увеличится — она просто разделится между потоками, и каждый получит лишь по 0,6 Мбит/с.

В этот момент запросы начинают конкурировать не только за сеть, но и за ресурсы устройства. В результате вместо быстрого старта получаем медленную загрузку, нестабильное воспроизведение и фризы при переключении между элементами.

Распределение ресурсов

Отсюда следует важный вывод: даже при нормальной сети параллелизм легко начинает вредить. Для медиаленты выгоднее сосредоточить ресурсы на том видео, которое сейчас просматривает пользователь, а всё остальное загружать последовательно или с пониженным приоритетом.

Для нас это свелось к нескольким правилам:

  • контролировать лишние сетевые запросы, потому что каждый из них отнимает часть пропускной способности;

  • помнить о том, что больше параллельных загрузок ≠ быстрее;

  • текущее видео всегда важнее предзагрузки;

  • воспринимать префетч не как «скачать всё заранее», а как ограниченный и управляемый механизм.

Видеолента довольно активно использует сеть: текущее видео догружает сегменты, соседние элементы могут предзагружаться, параллельно приложение выполняет и другие запросы. Если этим не управлять, будущий контент начинает мешать текущему воспроизведению. Поэтому отдельной частью реализации стало управление сетевыми приоритетами: максимальный приоритет — для видео в фокусе, префетч — только в те моменты, когда сеть свободна.

Рекомендации по инструментам

Для тестирования в плохой или нестабильной сети нам пригодились несколько инструментов: Network Lint Conditioner, Xcode Network и Proxyman. На последнем остановлюсь немного подробнее.

Proxyman — мой любимый инструмент для профилирования запросов. Для отладки в нём особенно полезна функция Network Conditions, которая позволяет искусственно замедлять сеть, в том числе для конкретных URL.

Но у такого подхода есть важные ограничения, и их нужно учитывать при интерпретации результатов. 

  • Proxyman работает как MITM‑прокси, поэтому трафик нередко деградирует с HTTP/2 или HTTP/3 до HTTP/1.1, из‑за чего теряется реальная картина мультиплексирования;

  • из‑за двойного терминирования SSL/TLS (Клиент ↔ Прокси ↔ Сервер) появляются дополнительные задержки, поэтому метрики вроде Connection Time и TTFB искажаются;

  • часть сетевого поведения берёт на себя прокси: он может буферизовать полезную нагрузку перед отправкой клиенту и сглаживать медленный старт TCP‑соединения — так что в итоге мы измеряем скорость работы локального прокси, а не сети;

  • в некоторых случаях прокси переписывает заголовки Accept‑Encoding для удобства отображения в UI, что тоже влияет на достоверность замеров.

Поэтому Proxyman полезен не как источник точных сетевых метрик, а как инструмент для структурной диагностики. С его помощью удобно:

  • искать проблему запросов «N + 1»;

  • проводить аудит размеров (например, улетает ли на клиент JSON на 5 МБ вместо 50 КБ);

  • проверять работу кеширования (Cache‑Control, ETag);

  • проверять наличие сжатия (уходит ли GZIP или Brotli).

Нестабильный перформанс

С проблемами мы столкнулись и во время скролла: мигания, пропуски кадров, задержки и заметный нагрев устройства. Дополнительная сложность была в том, что такие дефекты воспроизводились далеко не на всех устройствах.

Дебаг перформанса — довольно тяжёлая история. Его профайлинг усложняется многими факторами:

  • Профилирование на симуляторе даёт искажённую метрику, потому что симулятор использует ресурсы хоста: CPU, память и сеть фактически будут характеристиками вашего ноутбука, а не целевого устройства. 

  • Профилирование на реальных девайсах ближе к реальности, но, увы, тестировщикам не всегда доступен нужный парк устройств.

  • Даже если данные собраны идеально, их ещё нужно корректно интерпретировать. Мы банально можем неправильно понять метрику и начать лечить не причину, а симптом.

Чтобы получать полезные результаты, такие сценарии нужно тестировать на реальных данных, а не на ограниченном наборе. Но собрать окружение с сотнями видео разного формата, размера и длительности — отдельная трудоёмкая задача. На ней мы останавливаться не будем, но поговорим про две интересные сущности — hitches (фризы) и hangs (зависания).

Hitches

Hitch — это опоздание кадра относительно VSYNC, из‑за которого видео теряет плавность. Такое происходит, когда система не успевает вовремя подготовить следующий кадр в render loop.

У этой модели есть жёсткий временной лимит:

  • при 60 FPS на кадр доступно примерно 16,67 мс;

  • при 120 FPS — уже около 8,33 мс.

Если приложение не укладывается в этот интервал, пользователь видит микрофриз: скролл или анимация на долю секунды «цепляются» и перестают быть ровными.

Упрощённо hitches можно разделить на два типа.

Commit hitch. Возникает, когда CPU или main thread не успевают подготовить кадр. Типичные причины — тяжёлый layout, пересчёт размеров, сложная иерархия, синхронная работа на главном потоке.

В нашем случае заметный вклад давала легаси BDUI с глубокой вложенностью UIView‑элементов. CPU тратил слишком много времени на подготовку дерева view, особенно при пагинациях и перерасчёте лейаута.

Render hitch. Здесь узким местом становится уже рендеринг: GPU или Render server не успевают обработать слишком тяжёлую графику. Обычно это связано с большим количеством визуальных эффектов — прозрачностей, скруглений, блюра, наложений.

Hitches логичней всего воспринимать как метрику качества UX. Глубокая иерархия view увеличивает стоимость layout и построения слоёв, что приводит к commit hitches, а перегруженная графика дополнительно провоцирует render hitches.

Hangs

Hang — это состояние, при котором приложение перестаёт вовремя реагировать на действия пользователя, потому что main thread занят работой или заблокирован ожиданием другого потока или ресурса. 

Если следовать документации Apple, hang связан не просто с медленной отрисовкой, а с нарушением отзывчивости интерфейса: главный поток не успевает обработать новые события и обновить UI. В инструментах Apple такие состояния обычно начинают фиксироваться примерно от 250 мс, а задержки свыше 500 мс уже считаются полноценным зависанием. Для действий, которые должны восприниматься как мгновенные, ориентир намного жёстче — порядка 100 мс и меньше.

Простыми словами, hang — это ситуация, когда пользователь нажал, свайпнул или начал скроллить, а интерфейс не реагирует вовремя и выглядит зависшим. В отличие от обычной просадки FPS, здесь ломается не только плавность, но и сама отзывчивость приложения.

Типовые причины hang обычно сводятся к следующим:

  • Главный поток может быть просто перегружен работой: тяжёлый layout, синхронный парсинг, декодирование изображений, вычисления, обращение к базе или подготовка данных для UI. 

  • Главный поток может не выполнять полезную работу, а ждать: lock, semaphore, dispatch sync, завершение фонового потока, доступ к системному ресурсу или I/O. 

  • Hang возможен и в коде, который формально написан асинхронно. Это происходит, если выполнение слишком рано или слишком часто возвращается на MainActor или главный поток либо если в критичном месте появляется синхронный участок.

Избавляемся от hitch и hang

Итак, одна из ключевых причин просадок — глубокая вложенность view и высокая стоимость auto layout. Переписывать целиком оба легаси BDUI‑механизма было слишком дорого, поэтому самым реалистичным вариантом было упростить наш компонент и снять часть нагрузки с главного потока.

Для этого мы перевели layout медиаленты на frame‑based‑подход. Сегодня такой выбор может показаться устаревшим и хардкорным, но тем не менее это по‑прежнему рабочий инструмент. Особенно если компонент встраивается в непредсказуемую и уже и так перегруженную UI‑среду. В нашем случае это был как раз такой сценарий: сложный BDUI‑экран, глубокая вложенность контейнеров и большой разброс по производительности устройств пользователей.

Идея была простой: расчёт layout всех элементов переписать на frame и вывести в фоновую очередь, добавив кеширование и асинхронность расчётам. Это заметно уменьшило число commit hitches и снизило вероятность hang. Также у нас получилось уйти от расчёта сложного auto layout на главном потоке и считать глубокое дерево view в фоне.

Такой подход не уникален: многие разработчики часто сознательно уходят от auto layout в пользу ручного расчёта или его гибридных форм. Причина обычно одна и та же — в критичных местах важнее предсказуемая стоимость layout, чем удобство декларативной вёрстки. Вот открытый бенчмарк производительности различных фреймворков для создания макетов на Swift:

В качестве простого примера такого подхода можно посмотреть на FrameLayoutKit. Мы же не хотели добавлять ещё одну внешнюю зависимость в и без того сложный стек, поэтому использовали свой небольшой DSL для frame‑based layout: по смыслу он был близок к PinLayout.

  view.layout(left: 16, right: 16, top: 8, bottom: 8)                                                                                                                                                                                                 
  view.layout(left: 16, right: 16, top: 8, fitHeight: maxHeight)                                                                                                                                                                                                                                                  
  view.layout(right: 16, bottom: 16, width: 44, height: 44)                                                                                                                           
  view.layout(centerX: bounds.midX, centerY: bounds.midY, width: 100, height: 100)    

Вот результат с пагинацией на 100 видеоячеек до: 

И результат после перехода на frame‑based‑подход:

Если вы ещё считаете что фреймы в 2к26 — это хардкорный аскетизм, то это не так:) 

Улучшение архитектуры

Одним из основных источников нагрузки была сама вложенность ленты. Проблема проявлялась не только в обычном скролле, но и в сценариях с пагинацией и фильтрацией, когда системе приходилось заново пересчитывать большую часть иерархии из‑за вложенности O(n*k). Из‑за этого пересчёт layout, обновление состояний и перестройка view‑дерева не укладывались в доступный тайм‑аут.

Такой подход оказался неэффективным по двум причинам. 

  • Во‑первых, системе приходилось постоянно пересчитывать высоты элементов, и даже при наличии кеша это всё равно добавляло лишнюю вычислительную работу. 

  • Во‑вторых, из‑за вложенной структуры стоимость операций росла нелинейно: фактически нагрузка множилась на каждом уровне иерархии, и даже на современных устройствах это уже приводило к заметным hitches.

Поэтому с самого начала у нас были сомнения в самой идее хранить одну ленту внутри другой view‑структуры. Модель, в которой элементы добавляются во вложенный контейнер, а затем вся конструкция заново пересчитывается, — спорное решение. Более устойчивым вариантом было сделать структуру как можно более плоской, чтобы обновления и расчёты вели себя ближе к линейной сложности O(n).

В итоге архитектура стала более простой и предсказуемой для интеграции. Управление событиями и состояниями мы вынесли в отдельные сервисы и менеджеры, чтобы UI перестал быть точкой, где одновременно живут и отображение, и координация поведения.

С помощью такой архитектуры мы смогли не только уменьшить влияние вложенности, но и лучше контролировать ресурсную политику. Нагрузка стала масштабироваться более предсказуемо, без лишнего каскадного роста. Дополнительно это упростило синхронизацию состояний между независимыми контейнерами. Консистентность теперь обеспечивалась через presentation‑слой, а не через прямую связность между view.

Общая оценка в Xcode Instruments до всех оптимизаций и после:

Рекомендации по инструментам

В Xcode есть много инструментов, чтобы локально искать причины проблем: смотреть зависания, просадки FPS, нагрузку на CPU, память и сетевое поведение. Но этого недостаточно, если нужно понимать, что происходит не у разработчика на устройстве, а у реальных пользователей.

Для такой задачи обычно используют MetricKit, который позволяет собирать системные метрики уже в продакшене. В нашем случае вместо него использовались собственные метрики и внутренние механизмы наблюдаемости — MVI и RUM. 

Один из инструментов, который помог в поиске причин нестабильного перформанса, — самописный Watchdog. Его задача — отслеживать перегрузку главном потоке и фиксировать такие события не только локально во время разработки, но и на реальных устройствах.

На практике это сильно упростило диагностику. Вместо долгих итераций с ручным воспроизведением проблемы достаточно было запустить сборку на устройстве, включить флаг в дебаг‑меню и получить готовые логи для анализа. Это снимало часть нагрузки с тестировщиков и позволяло быстрее собирать данные о проблемах, которые проявлялись нестабильно.

За основу я взял популярное решение, но адаптировал его под наши ограничения и современный стек.

Упрощённый код WatchDog (в проде работает другая реализация)
 actor PingState {                                                                                                        
    private var pendingPing: ContinuousClock.Instant?
                                                                                                                         
    func recordPing(_ time: ContinuousClock.Instant)  { pendingPing = time }
    func clearPing()                                  { pendingPing = nil  }                                             
    func activePing() -> ContinuousClock.Instant?     { pendingPing       }                                              
}
                                                                                                                         
final class MainThreadWatchdog: Sendable {
    private let state = PingState()                                                                                      
    private let threshold: Duration
    private var monitorTask: Task<Void, Never>?

    init(threshold: Duration = .milliseconds(250)) {                                                                     
        self.threshold = threshold
    }                                                                                                                    
                
    func start() {
        // detached — не наследуем actor-контекст вызывающего
        monitorTask = Task.detached(priority: .userInitiated) { [weak self] in
            await self?.monitorLoop()                                                                                    
        }
    }                                                                                                                    
                
    func stop() {
        monitorTask?.cancel()
    }

    // MARK: Цикл мониторинга                                                                                          

    private func monitorLoop() async {                                                                                   
        while !Task.isCancelled {
            let pingTime = ContinuousClock.now

            // 1. Ping: фиксируем момент                                                                                 
            await state.recordPing(pingTime)
                                                                                                                         
            // 2. Просим main thread сбросить его (pong)
            Task { @MainActor [weak self] in
                await self?.state.clearPing()                                                                            
            }
                                                                                                                         
            // 3. Ждём threshold (suspends task, не блокирует поток)                                                     
            try? await Task.sleep(for: threshold)
                                                                                                                         
            // 4. Проверяем результат
            guard let stored = await state.activePing(), stored == pingTime else {
                continue // main thread ответил вовремя                                                                  
            }
                                                                                                                                                                                            
            let blockDuration = ContinuousClock.now - pingTime
            await reportBlock(duration: blockDuration)                                                                   
            await state.clearPing()
        }
    }
                                                                                                                         
    // MARK: Обработка блокировки
                                                                                                                         
    private func reportBlock(duration: Duration) async {
        // Стек-трейс снимаем через Mach API — не нужен main thread
        let stackTrace = WatchdogStackTraceCapture.captureMainThreadStackTrace()                                         
        let metrics    = WatchdogSystemMetrics.capture()
                                                                                                                         
        // Имя экрана — требует main thread                                                                             
        .....                                                                                                              
 
        let entry = WatchdogLogEntry(                                                                                    
            timestamp:      Date(),
            duration:       duration.timeInterval,
            threshold:      threshold.timeInterval,
            screenName:     screenName,                                                                                  
            stackTrace:     stackTrace,
            cpuUsage:       metrics.cpuUsage,                                                                            
            memoryUsageMB:  metrics.memoryUsageMB,
            totalMemoryMB:  metrics.totalMemoryMB                                                                        
        )
                                                                                                                         
        fileLogger.logEntry(entry)

        await MainActor.run {
            NotificationCenter.default.post(
                name: .watchdogDidDetectBlock,                                                                           
                object: nil,
                userInfo: ["entry": entry]                                                                               
            )   
        }
    }                                                                                                                    
}

А так инструмент выглядит в UI для тестировщика в дебаг‑меню приложения Яндекс Еды:

Тестировщик сразу может запустить приложения на устройстве и получить все необходимые логи со стек‑трейсом.

Краши и утечки памяти

Пока объём контента оставался небольшим, лента вела себя относительно стабильно. Но с ростом длины списка и появлением пагинации начали возникать проблемы с памятью: утечки, резкий рост потребления ресурсов и, как следствие, краши. Как и в случае с перформансом, воспроизводилось это нестабильно и только на части устройств.

Изначально расчёт был довольно скромный: до 50 публикаций в ленте, из них не более 10 видео. В таком режиме система жила в предсказуемых рамках. Но затем входные условия изменились: горизонтальных каруселей стало заметно больше, а лимит вертикальной ленты вырос до 100 элементов, причём значительную часть этого списка могли составлять именно видео.

После этого стало понятно, что система плохо масштабируется по памяти. При просмотре каждой следующей пачки элементов потребление могло расти на 200 МБ, а на длинных сценариях с сотней элементов прирост доходил уже до 2–4 ГБ в зависимости от размера файлов и состава ленты.

Разбор проблемы показал, что я преждевременно оптимизировал кеши. Пока контента было мало, это выглядело полезно. Но при росте объёма ленты те же решения начали сохранять слишком много данных в память, на диск и в другие хранилища. 

Как это выглядело на замерах до оптимизаций для ленты из 100 видео:

Пул плееров

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

Вместо модели «одна ячейка — один плеер» мы перешли к паттерну Object Pool. Это значит, что мы заранее держим ограниченное число переиспользуемых плееров и выдаём их только тем элементам, которые находятся в активной зоне просмотра. Для медиаленты это естественный компромисс, потому что пользователю редко нужно большое число одновременно работающих плееров.

Причина такой оптимизации — не только в самом AVPlayer: дело в том, что каждый новый экземпляр тянет за собой AVPlayerItem, внутренние буферы, декодирование, рендеринги. Когда таких объектов становится много, потребление памяти начинает расти на глазах. Поэтому мы стали держать ограниченный пул из пяти плееров. Когда ячейка с видео уходила за пределы экрана, плеер не уничтожался, а просто ставился на паузу, текущий AVPlayerItem отвязывался и возвращался в пул, чтобы перейти в пользование следующему элементу.

Дополнительно мы жёстко управляем иерархией view: VideoPlayerManager не просто останавливает воспроизведение у невидимых ячеек, а полностью удаляет playerView из view tree через removePlayerFromHierarchy(), тем самым освобождая GPU‑ресурсы, связанные с рендерингом видеослоя. 

Упрощённый код
import AVFoundation
                                                                                                                           
  final class VideoPlayer {
      let avPlayer = AVPlayer()
      func load(_ url: URL) {                                                                                              
          avPlayer.replaceCurrentItem(with: AVPlayerItem(url: url))
      }                                                                                                                    
                  
      func play()  { avPlayer.play()  }                                                                                    
      func pause() { avPlayer.pause() }
                                                                                                                           
      // Сброс перед возвратом в пул
      func reset() {
          avPlayer.replaceCurrentItem(with: nil)                                                                           
          avPlayer.pause()
      }                                                                                                                    
  }               
  // MARK: пул
                                                                                                                           
  actor PlayerPool {
      private var available: [VideoPlayer]                                                                                 
      private var waiters: [CheckedContinuation<VideoPlayer, Never>] = []
      init(size: Int) {
          // Прогреваем пул при старте, не во время скролла
          available = (0..<size).map { _ in VideoPlayer() }                                                                
      }                                                                                                                    
                                                                                                                           
      // Взять плеер. Если пул пуст — suspension до release()                                                              
      func acquire() async -> VideoPlayer {
          if let player = available.popLast() {                                                                            
              return player
          }
          return await withCheckedContinuation { continuation in
              waiters.append(continuation)                                                                                 
          }
      }                                                                                                                    
                  
      // Вернуть плеер. Если кто-то ждёт — отдать напрямую, минуя пул                                                      
      func release(_ player: VideoPlayer) {
          player.reset()                                                                                                   
                  
          if waiters.isEmpty {                                                                                             
              available.append(player)
          } else {
              let next = waiters.removeFirst()
              next.resume(returning: player)                                                                               
          }
      }                                                                                                                    
  }    

Важно помнить: если ячейка ушла из области видимости, то плеер нужно удалять из иерархии, так как он занимает ценные ресурсы.

Ранжирование

Однако наша задача усложнялась наличием смешанного контента: вместе с вертикальными видео в медиаленте встречались и горизонтальные карусели. Проще говоря, такой сценарий предусматривал одновременное воспроизведение нескольких видеопотоков. 

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

Прежде чем пытаться исправить проблему кодом, мы задали себе вопрос: «А зачем мы вообще показываем сразу пять видео на одном экране? Может, будем специально чередовать контент?» Так мы пришли к добавлению алгоритмов ранжирования: теперь в медиаленте видео гармонично миксуется с другим контентом — например, текстовыми публикациями.

Кеширование

К слову про статический контент. Помимо видео и текстов, в медиаленте встречается очень много изображений: сниппеты, заглушки перед видео, иконки и так далее. Стандартное архитектурное решение в данном случае — многоуровневое кеширование.

Можно написать свой механизм кеширования, но мы выбрали готовое решение — библиотеку Nuke. Она отвечает за оптимизированную загрузку, хранение и выгрузку графических ресурсов из памяти.

А теперь вспомним график из начала раздела. Тогда потребление памяти на 100 видео превышало 2 ГБ, а теперь — около 500 МБ. 

Проблемы уровня UX/UI 

Уже по ходу разработки стало видно, что часть проблем лежит не только в области производительности, но и в восприятии интерфейса. Некоторые решения, которые формально работали корректно, на практике создавали ощущение медленного или нестабильного UI. В медиасценариях это особенно заметно: пользователь оценивает не только фактическое время старта, но и то, что он видит в первые сотни миллисекунд.

Критичными здесь оказались довольно базовые вещи:

  • наличие первого кадра или его адекватной замены;

  • воспринимаемая скорость, а не только измеримое время запуска;

  • понятное состояние загрузки, чтобы интерфейс не выглядел сломанным.

То есть часть задач решается не только оптимизацией, но и правильной организацией состояний отображения. Если видео стартует не мгновенно, интерфейс всё равно должен вести себя предсказуемо и не оставлять пользователя перед чёрным или пустым блоком.

Сложность снова упиралась в отладку. На поведение ленты влияли комбинации layout‑конфигураций, A/B‑экспериментов, разных типов контента и ограничений конкретного устройства. Значительная часть таких сценариев либо плохо воспроизводится локально, либо становится заметна только на слабых девайсах или ограниченных ресурсах.

В разборе части проблем с воспроизведением нам помог опыт команды Яндекс Плеера. Не все их рекомендации можно было напрямую применить в нашей конфигурации, но часть подходов оказалась полезной. В частности, на одном из этапов мы отказались от системного плеера в пользу Яндекс Плеера, потому что он лучше соответствовал требованиям к управляемости и диагностике.

Ещё несколько практических советов

Некоторые методы оптимизаций находятся на стыке вышеперечисленных аспектов, поэтому я решил вынести их в отдельный раздел.

Буферизация коротких видео

Стандартные стратегии буферизации обычно рассчитаны на длинное воспроизведение: плеер старается заранее накопить заметный объём данных, чтобы снизить риск остановок. Для коротких видео такая модель часто неэффективна.

В ленте пользователь нередко смотрит ролик всего несколько секунд и сразу переходит к следующему. Если к этому моменту уже загружена значительная часть файла, это означает лишний расход сети без реальной пользы. Поэтому для короткого контента стандартную политику буферизации пришлось пересматривать и делать более агрессивной в сторону экономии ресурса.

Выбор качества видео

Одна из наивных идей — попробовать оптимизацию за счёт замены форматов. В нашем случае — замены коротких видео на GIF. Со стороны это может показаться более лёгким вариантом: меньше логики, не нужен «настоящий» видеоплеер, проще встроить в интерфейс. На практике — это распространённое заблуждение. 

Для медиасценариев GIF почти всегда оказывается не упрощением, а шагом назад по эффективности. Многие приложения, которые внешне выглядят как работающие с «гифками», под капотом используют обычное mp4-видео, чаще всего mp4.

Причина простая: GIF — очень тяжёлый формат с точки зрения размера и доставки. В зависимости от параметров контента он может быть больше эквивалентного видео в 5, а то и в 20 раз. Поэтому с точки зрения сети, памяти и общей стоимости показа короткого ролика видеоформат обычно намного выгоднее.

Восприятие скорости пользователем

Не стоит смотреть только на фактическое время старта видео. Даже если технически запуск происходит быстро, пользователь может увидеть чёрный экран, пустую карточку или паузу перед первым кадром. На уровне восприятия это выглядит как медленная и нестабильная работа интерфейса.

Поэтому отдельной задачей было не только ускорить воспроизведение, но и сделать состояние загрузки визуально предсказуемым. В медиасценариях часть проблем можно не только исправлять на уровне производительности, но и сглаживать за счёт корректных промежуточных состояний, анимаций и более аккуратного поведения UI до появления первого кадра.

Настройки, зависящие от типа сети

Полезно разделять политику загрузки медиа по типу сети: Wi‑Fi и мобильный интернет дают разные ограничения. Если не настроить оба режима осознанно, приложение либо будет тратить много мобильного трафика, либо, наоборот, не будет подгружать видео там, где вам это удобно.

На мобильной сети разумно включать более консервативный режим: снижать допустимый размер видео, ограничивать автозагрузку тяжёлого контента и осторожнее использовать префетч. В Wi‑Fi эти ограничения можно ослаблять. Такой подход позволяет лучше контролировать и сетевую нагрузку, и предсказуемость поведения медиаленты в разных условиях.

При плохом качестве сети, например в 3G, полезно адаптировать не только загрузку самих медиа, но и объём данных, который приложение запрашивает у сервера. Один из рабочих приёмов — уменьшать окно пагинации и ограничивать число сопутствующих элементов в ответе.

Прогрев сети

Также полезно задуматься о network warmup и preconnect. Холодный доступ к CDN или видеофайлу может заметно тормозить из‑за DNS, установки TCP/TLS‑соединения и прогрева самого канала. Для коротких видео такая задержка особенно чувствительна, потому что она съедает значимую часть времени до первого кадра. Проблема становится ещё заметнее, если в проекте используется несколько URLSession и разные запросы не переиспользуют уже открытые соединения. 

Поэтому сетевой слой лучше по возможности централизовать и не дробить без необходимости: одна хорошо настроенная URLSession обычно эффективнее, чем несколько изолированных.

Сжатие данных

Ещё один базовый, но важный слой оптимизации — сжатие ответов. Если сервер отдаёт метаданные, конфиги или другой сопутствующий контент без компрессии, приложение тратит больше времени и трафика на доставку того, что можно было передать заметно компактнее.

Изначально у нас использовались стандартные подходы вроде GZIP, а позже мы начали переходить на Brotli. Для текстовых ответов это обычно даёт более эффективное сжатие и помогает уменьшить объём передаваемых данных, что особенно полезно в сетевых сценариях с ограниченной пропускной способностью.

Подробнее о методах сжатия можно почитать в этой статье

Нагрев батареи

Ещё один важный показатель качества — нагрев устройства. Даже если приложение формально работает без лагов и падений, длительная высокая нагрузка на CPU, GPU, декодеры и сеть быстро приводит к перегреву. Это вызывало у пользователей дискомфорт.

Поэтому стоит смотреть не только на время старта видео, но и на косвенные признаки перерасхода ресурсов: какие операции лишний раз нагружают CPU, где можно уменьшить частоту обновлений, сократить число одновременно активных эффектов или ослабить фоновую работу.

Полезный приём здесь — activation Threshold. Некоторые приложения снижают нагрузку при определённых условиях, например при низком заряде батареи: отключают автоплей, уменьшают качество или агрессивность предзагрузки, убирают дорогие визуальные эффекты вроде blur, прозрачностей и теней. Для медиасценариев это часто даёт заметный выигрыш без серьёзной потери функциональности.

Итог оптимизаций

Если свести результат к цифрам, изменения были такими:

  • потребление памяти на ленте из 100 видео снизилось примерно с 2 ГБ до 500 МБ;

  • hitches и hangs при пагинации ушли из категории заметных фризов на длинных списках в редкие фоновые просадки;

  • старт видео на слабой сети перестал деградировать из‑за конкуренции с предзагрузкой;

  • отказ от вложенной ленты внутри BDUI‑ленты улучшил плавность интерфейса и ускорил срабатывание пагинации.

Что я унёс из этого проекта

Медиалента — это не видеоплеер. Здесь одновременно сходятся ограничения по сети, памяти, декодированию, жизненному циклу UI и чужой контейнерной среде. Если проектировать такую фичу как обычный список карточек с видео внутри, проблемы почти гарантированы.

Преждевременная оптимизация кеша опаснее её отсутствия. Оптимизация «на всякий случай» легко даёт обратный эффект: в нашем случае это вылилось в гигабайты занятой памяти. Кеш должен учитывать давление на ресурсы и уметь самоограничиваться, иначе это не оптимизация, а медленная утечка.

Auto layout и глубокая вложенность плохо масштабируются в тяжёлых списках. Основная часть hitches у нас была связана именно с этим. В таких местах frame‑based layout — не архаика, а вполне рабочий способ сделать стоимость layout более предсказуемой и снять лишнюю нагрузку с главного потока.

Часть проблем решается не кодом. Например, в случае сценария с одновременным воспроизведением всех видео в зоне видимости нам было выгоднее изменить саму конфигурацию отображения контента, чем бесконечно пытаться «дотюнить» инфраструктуру.

Observability нужно закладывать заранее. Без Watchdog, рантайм‑метрик и дебаг‑меню многие проблемы мы бы ловили слишком долго. Для сложных систем инструменты диагностики — это не вспомогательная часть, а полноценный слой архитектуры.

Комментарии (6)


  1. Sazonov
    19.06.2026 07:53

    Пользователь приходит сюда заказать еду или выбрать ресторан.

    Как пользователь, когда я хочу сходить в ресторан, я открываю интересующий меня район в картах и уже там перебираю рестораны.

    Но, к сожалению, это уже не новая тенденция Яндекса - делать «супер приложения» для втюхивания большего количества рекламы. Когда основному функционалу приложения отводится менее 50% а то и 80% места на экране.

    Вот и приходится продираться сквозь все эти высоко оптимизированные слои спама, чтобы добраться до основной функциональности - доставки еды.

    P.S. с технической точки зрения было интересно почитать, спасибо.


    1. levbond Автор
      19.06.2026 07:53

      да, карты — это тоже один из сценариев выбора. Лента же более традиционный механизм для другой группы по разным фильтрам.


  1. Mishootk
    19.06.2026 07:53

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

    А можно мне в настройках поставить галочку "я знаю куда сходить на выходных, я уже прочитал все новости, я уже насмотрелся котиков, я просто хочу заказать ровно то, что кончилось у меня в холодильнике" и оставить только интерфейс заказа еды?

    У многих похожих супераппов заходишь с одной конкретной целью, а в условиях слабого интернета все полностью висит, пока не прогрузятся подобные "медиаленты". Хотя я захожу за информацией которой интернет вообще не нужен.

    Яндекс - в ваш камень огород: блокирнули тут интернет в очередной раз по известной причине, ЯМузыка играет. А вот при открытии приложения интерфейс висит на прогрузке - ничего не сделать. А моя волна играет (долго играет, новые треки включает - т.е. качает стрим). А ничего не сделать в интерфейсе - только плейсхолдеры мертвые.


    1. levbond Автор
      19.06.2026 07:53

      медиалента и лишняя информация не показывается агрессивно. Раздел "заказать" изолирован и не мешает привычным флоу


      1. Mishootk
        19.06.2026 07:53

        Это хорошо. Надеюсь разработчики остальных ЯПриложений находятся не на другой планете.


  1. turlir
    19.06.2026 07:53

    Удивительно как много усилий уходит на создание бесполезной и даже вредной функциональности. Следующая статья будет про оптимизацию компиляции. Как заставить все это собираться с приемлемой скоростью. Подобные медиа-ленты и блокировка по VPN на одном уровне управленческой экспертизы. Бизнес хочет чтобы хомячки проводили побольше времени в приложении. Любые разработки во имя продолжительности сессии. Лучше бы сделали что-нибудь полезное.