AVFoundation і UICollectionView — поєднання, створене в пеклі
Привіт! Мене звуть Сергій. Я — 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, тож часу вдвічі менше. Якщо підготовка або рендер займає більше часу (наприклад,
Наприклад, ми повідомляємо системі, що певна кнопка змінює свою позицію. Після цього рендер-сервер намагається за шарами відрендерити все, що є на екрані, враховуючи усі тіні, блюри, скруглення кутів тощо. Що складніша зміна, яку ви просите анімувати або показати на екрані, то більша ймовірність отримати хітч або хенг.
Проблема. Деталі
Як виглядав наш 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 файл розробнику = безцінно.
Корисні матеріали
- Scott Goodson, Effortless Responsiveness with AsyncDisplayKit — про витоки UIKit і мотивацію для створення AsyncDisplayKit
- Adlai Holler, ASCollectionNode Behind-the-Scenes — про проблеми UICollectionView і відмінність ASCollectionNode.
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів