AVFoundation і UICollectionView — поєднання, створене в пеклі

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Привіт! Мене звуть Сергій. Я — iOS Developer у компанії Lift з екосистеми Genesis. Наша команда створює інструмент для просування сторінок у соціальних мережах, і однією з головних фіч є редагування коротких відео для Instagram Stories, Reels, TikTok. У зв‘язку з цим нам доводиться досить часто працювати з AVFoundation. Оскільки у нас є близько 1000 готових темплейтів для відео, які треба презентувати користувачу, з UICollectionView ми теж часто зустрічаємось.

Тож коли в межах невеликого (на перший погляд) багфікса у нас виникла ідея поєднати ці два інструменти й зробити двовимірну колекцію медіаплеєрів, це не виглядало поганою ідеєю.

У цьому тексті хочу поділитись історією цього багфікса, який затягнувся більше ніж на місяць. Розповім, як ми шукали проблему, дізнавались історичні причини певних труднощів з перформансом колекцій на iOS, затягували у проєкт страшне легасі та оверрайдили завантаження remote AVAsset.

Проблема. Overview

Нічого не віщувало біди. На головному екрані нашого застосунку є велика колекція темплейтів, створених дизайнерами. Деякі з них анімовані, деякі — ні. До нас звернулася продуктова команда з проханням оптимізувати скрол preview цих темплейтів.

Приклад того, як це виглядало:

Озброївшись Instruments і парою відео з WWDC (1, 2), ми почали шукати проблему.

Короткий відступ: hangs & hitches

Чому виникають підвисання UI на iOS? 16,6 ms — час, протягом якого в ідеалі потрібно підготувати все для одного render pass при частоті оновлення екрана в 60Hz. На ProMotion-дисплеях частота може сягати 120Hz, тож часу вдвічі менше. Якщо підготовка або рендер займає більше часу (наприклад, 20-30 мс), то зʼявляються хітчі (hitches) — короткочасні затримки. Якщо підготовка зайняла 500ms і більше — маємо так званий hang, помітне зависання всього інтерфейсу.

Наприклад, ми повідомляємо системі, що певна кнопка змінює свою позицію. Після цього рендер-сервер намагається за шарами відрендерити все, що є на екрані, враховуючи усі тіні, блюри, скруглення кутів тощо. Що складніша зміна, яку ви просите анімувати або показати на екрані, то більша ймовірність отримати хітч або хенг.

Проблема. Деталі

Як виглядав наш UI:

Кожен з умовних TemplatePreviewView складався з великої кількості subview, які анімувалися з різною швидкістю. Якщо уявити кількість layout-операцій при швидкому скролі екрану з тисячею таких UIView і додати складність рендеру об‘єктів з тінями, блюрами тощо, стане дивним, що проблема не проявилася раніше. Це рішення не виглядало таким, яке можна трохи оптимізувати, тож ми почали шукати альтернативи.

Рішення № 1. Відео замість UIView

Ми уявили, як було б зручно не анімувати вʼюшки чи тримати картинки в бандлі, а просто показувати користувачу темплейти через окремі AVPlayerLayer (точніше тисячі таких плеєрів). Зробити прототип з десятьма відео, щоб подивитися на перформанс, було досить просто, тож ми взялися до справи. Перформанс виявився жахливим 🙂

Time Profiler у зв‘язці з Thread State Trace показали, що main thread не перевантажений, а заблокований синхронною операцією і робить це AVPlayerLooper (клас з AVFoundation, який дозволяє зациклити відображення відео).

Його ініціалізація блокувала main thread абсолютно нещадно та надовго. Виявилося, що AVPlayerLooper створює два додаткових AVPlayerItem під капотом, щоби зациклити програвання. Створення AVPlayerItem і підготовка до програвання — синхронна операція, і здійснювати її на головному потоці — погана ідея.

На жаль, деякі операції з класами AVFoundation (наприклад, взаємодіяти з AVPlayerLayer) можна робити лише з main thread. Але не всі 😏

Рішення № 2. DispatchQueue.global().async

Якщо ми не хочемо, щоби щось виконувалося на головному потоці, у нас завжди є простий спосіб цього досягти, вірно?

DispatchQueue.global().async {
    let asset = AVURLAsset(url: url)
    let key = "playable"
    asset.loadValuesAsynchronously(forKeys: [key]) { [weak self] in
        let playerItem = AVPlayerItem(asset: asset)
        let player = AVPlayer(playerItem: playerItem)
        self?.cancellable = player.publisher(for: \.status)
            .filter { $0 == .readyToPlay }
            .receive(on: DispatchQueueu.main)
            .sink { _ in
                self?.playerLayer.player = player
                player.play()
            }
    }
}

Створюємо AVURLAsset на глобальній черзі, готуємо до програвання. Коли його статус «readyToPlay», підключаємо до AVPlayerLayer уже на main thread.

Це трохи покращило ситуацію, але остання частина на головному потоці все ще трохи блокувала UI. Також залишилися проблеми, пов‘язані з лейаутом великої колекції. Саме тут, у пошуках натхнення і шляхів подальшої оптимізації нам трапився AsyncDisplayKit.

Рішення № 3. AsyncDisplayKit

AsyncDisplayKit (він же Texture) — фреймворк, мета якого — перенести розрахунок лейаута на бекграунд, закешувати розрахунки та позиціонувати UIView уже на main thread, користуючись розрахованими значеннями. Створений в Meta 2014 року, після чого команда, яка його підтримувала, перевелася в Pinterest і вже там його допрацювала.

В ASDK є своя абстракція над UICollectionView, яка обіцяє кращий перформанс, і навіть своя абстракція над AVPlayerLayer, яка кешує remote-асети, зациклює програвання і показує placeholder, поки відео не доступне.

Рішення мало свої плюси, але деякі нюанси насторожували: останнє оновлення 2021 року, 548 відкритих issues на GitHub і, найголовніше, — зав‘язування core-частини бізнесу на цій залежності. Проте, спробувати було варто.

Короткий відступ: AsyncDisplayKit

Ключовим концептом ASDK є ASDisplayNode — тонка абстракція поверх UIView з власним життєвим циклом, яка ініціалізується і проводить розрахунки лейаута або декодування зображень на бекграундному потоці, а потім, наприклад, змінює contents у CALayer вже на головному.

У ньому є відповідники багатьом стандартним елементам UIKit, які додають quality of life improvements. Наприклад, можливість інвертнути колекцію, щоби нові елементи з‘являлися знизу вверх. Або розширений lifecycle комірки в колекції залежно від того, як далеко вона знаходиться від екрана, і в який бік користувач скролить.

Код в AsyncDisplayKit досить схожий на UIKit, але оскільки написаний він на Objective C++, взаємодія з ним зі Swift іноді виглядає так:

Важлива відмінність — декларативний API для опису лейаута, натхненний CSS Flexbox. Схожий на SwiftUI, але достатньо відмінний, щоби ми витратили декілька днів на його вивчення.

У проєкті є декілька preprocessing flags, які вмикають або вимикають певні частини функціоналу, тож для додавання залежності ми вирішили форкнути його, встановити потрібні флаги, видалити непотрібну нам логіку (щоби зробити власний placeholder, поки відео вантажиться, але про це далі), залити static binary на наш GitHub як реліз і лінкнути через SPM у проєкті.

Скрол став трошки кращим, але зупинятися зарано.

Рішення № 4. Завантаження remote AVAsset

Переходимо до взаємодії з AVFoundation. AVAsset — абстракція над відео, яке ми плануємо програвати. Коли ми створюємо AVURLAsset з remote URL, AVFoundation відправляє запит на отримання одного байта цього ресурсу. Якщо у відповідь отримує код 206, відправляє запит на всі інші дані.

У цій імплементації є дві речі, які не підходять нам:

1. Очікуємо на повну відповідь ми синхронно.

2. HTTP 206 Partial Content респонси URLSession не кешує.

Вплинути на обидві проблеми можна, створивши власний клас, який відповідає протоколу AVAssetResourceLoaderDelegate, і передавши його в AVAsset:

asset.resourceLoader.setDelegate(videoLoader, queue: queue)

AVFoundation звертатиметься до нього, якщо виникнуть проблеми з завантаженням ресурсу. Ніяких проблем з доступом до наших відео-preview наразі немає, але ми можемо легко їх створити:

1. Змінюємо схему в URL наших відео на кастомну, наприклад https-lift://.

2. Створюємо AVURLAsset з новою URL.

3. Проставляємо кастомний делегат в новостворений асет.

4. AVFoundation не може завантажити асет і питає, чи може делегат допомогти виконати цей реквест, отримує позитивну відповідь.

5. Делегат створює URLSession і починає викачувати ресурс.

6. Коли AVFoundation звертається до делегата за діапазоном байт для програвання, повертаємо те, що накачали.

7. Коли викачали все, кешуємо ресурс на майбутнє.

Фінальний результат:

Висновки

Великі open-source залежності несуть певні ризики порівняно з рішеннями від Apple, але можливість самостійно прочитати код, переробити його під свою проблему або видалити з нього щось непотрібне — неоціненна. Коли не розумієш, чому ASDK не показав placeholder, можеш знайти це місце в коді та дізнатися. Коли не розумієш, чому AVPlayerLayer показує два різних кадри при перемотуванні на один і той самий час, маєш створювати Technical Support Incident.

З імовірністю 95% AsyncDisplayKit не знадобиться вам в day-to-day роботі, але почитати його код, example-проєкти та дискусії на GitHub — дуже цікаво і корисно.

Запитувати фідбек колег про суб‘єктивні враження (підлагує чи ні) в усній формі — погана ідея. Від відповідей у вигляді «іноді підлагує» немає ніякої користі. Instruments + Hangs + відправити .trace файл розробнику = безцінно.

Корисні матеріали

👍ПодобаєтьсяСподобалось17
До обраногоВ обраному8
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

чудова стаття, дякую! отримав задоволення від прочитання. а результат — бімба!

Недавно працював над одним проектом, де був схожий набір бібліотек. Тільки задача була відмовитись від AVFoundation з їхнім AVPlayer і зробити плеєр для програвання HLS. Як підкладка для рендеру також використовувався фейсбучний Texture. Вибір зразу впав на VLC так як той побудований поверх ffmpeg, уміє грати будь-які стріми і має з коробки hardware acceleration і підтримку адаптивного бітрейту — все, що було потрібно.

Саме в цьому місці почався треш. MobileVLCKit, байндінги влц для іос, не білдяться в останній xcode. В репо проекта сотні відкритих issues і декілька по проблемам білду і ніхто на них не відповідає. Навіть найтлі білди проекту померли ще пів року тому. Ок, не страшно, візьму пребілджений фреймворк і встановлю його як кокоапод. Бац, останні декілька версій пода мають биті посилання на архів з модулем. Що ж, візьмем ще старішу версію, яка працює. Але проект використовує базель, тому потрібен плагін для роботи з кокоаподами. Такий є, від пінтерест, але він не білдиться на інтел мак і сам проект виглядає, як мертвий. Давай розбиратися, як в самому базелі це зробити руками. Наче просто але і базель працює через раз, а кеші на 70гб просто убивають систему. Навіть xcode — це повний треш. Ide в якій немає статичного аналізатора коду для флагманської мови програмування? Треш.

Я не мобільщик, на свіфті за все життя написав 10 стрічки коду для пропоузала в ECMA-262. Колись писав на ObjC і навіть цілу книжку по ньому прочитав. Тому я був дуже здивований, коли побачив, який бардак твориться в царстві яблучному.

Судячи з усього, люди не доходять до Texture від хорошого життя, а це одразу готова історія 😁

Я свого часу напряму ffmpeg і рендер через OpenGL використовував для такої задачі. Але мені треба був rtmp.

Класна стаття, порадів за результат з вами :)

Підписатись на коментарі