Разработка
Быстродействие прокрутки в SwiftUI — в погоне за 120 кадрами в секунду
Бесконечные ленты — самое сложное, что можно сделать в SwiftUI.
SwiftUI подобен волшебству.
Но, как и все хорошо продуманные волшебные системы, он имеет свою цену.
Вы что, думали, что красивый декларативный синтаксис и автоматическое связывание данных достаются бесплатно? Подумайте еще раз.
Досадные механизмы безопасности памяти в Swift ставят его в невыгодное положение по производительности по сравнению с такими небезопасными языками, как C и C++. Точно так же красивый реактивный поток данных SwiftUI пожирает циклы процессора, как пациент Оземпика еду на конкурсе по поеданию хот-догов.
Чтобы достичь максимальной частоты обновления 120 Гц, главный поток должен выполнить все вычисления компоновки и рендеринг за 8,3 мс, чтобы избежать падения кадров — страшной неприятности. С учетом системных накладных расходов вам повезет получить 5 мс на эту работу.
SwiftUI может столкнуться с очень болезненными проблемами производительности при работе с бесконечно прокручиваемой лентой. Сегодня мы изучим эти проблемы производительности, поймем, почему они возникают, и рассмотрим методы их устранения, чтобы сделать производительность SwiftUI гладкой как масло Аннушки.
Архитектура прокрутки
Мы сосредоточимся на представлениях с прокруткой — самом популярном мешке для битья среди недоброжелателей SwiftUI. Давайте попробуем применить несколько подходов, чтобы получить бесконечно прокручиваемое представление, работающее с частотой 120 кадров в секунду.
У нас есть простая лента SwiftUI с множеством одинаковых ячеек, содержащих пару фотографий (любезно предоставленных Lorem Picsum), текст, анимацию спиннера и крутой эффект градиента под именем пользователя и его фотографией в профиле.
В душе я ученый. Чтобы тест был честным, я запускаю все эти профилировщики на своем iPhone 15 Pro под управлением iOS 18.3.2.
Поскольку система сама решает, когда работать со скоростью 120 кадров в секунду, а когда с обычной 60, я не смог заставить CADisplayLink постоянно вызывать 120 Гц. Поэтому на самом деле мы будем выполнять задачу с частотой 60 кадров в секунду.
Чтобы извиниться за этот обман, я усложню себе задачу и проведу все испытания в режиме Low Power Mode, при котором система дросселируется. В моем стартапе это одна из наших любимых техник проверки приложения на высокую производительность: если ваш экран плавно прокручивается в режиме Low Power Mode, значит, все в порядке.
VStack
Есть несколько способов структурировать прокручиваемую ленту. Первым из них может быть VStack.
Если это не первое ваше родео, вы можете предсказать, что произойдет, когда мы спрофилируем его с помощью инструмента Animation Hitches.
При загрузке приложения экран сразу же застыл. Это огромное начальное зависание связано с тем, что VStacks загружает и отображает всю иерархию представлений при отображении. Это означало, что все 1000 ячеек загружались и отображались одновременно.
Прикосновение к экрану ничего не давало. Все взаимодействия были заблокированы.
Когда загрузка закончилась, я смог взаимодействовать с представлением прокрутки, но при этом наблюдалось множество пропуска кадров.
Как видно из трассировки VSync, в то время как некоторые кадры рендерились за ожидаемые 16.67 мс (60 кадров в секунду), многие занимали гораздо больше времени, что проявлялось в виде большого количества пропущенных кадров в секунду.
При одновременной загрузке 1000 представлений возникал огромный всплеск потребления памяти, сопровождавший зависание, и это потребление так и не прошло, поскольку все представления хранились в памяти.
Очевидно, что VStack не подходит для работы с прокручиваемыми лентами.
LazyVStack
Давайте перейдем к следующему подходу, рекомендованному Apple для прокручиваемых лент — LazyVStack.
Ленивые стеки загружают и визуализируют свои вложенные представления по требованию, по мере прокрутки вблизи области просмотра (видимой на экране части вашего макета).
Поскольку эти вычисления размеров и компоновки выполняются по требованию, для получения такой производительности мы вынуждены пожертвовать некоторой точностью. LazyVStack динамически оценивает геометрию в зависимости от количества представлений.
Что это значит на практике? Представим, что все ваши представления имеют динамический размер, или ваш макет содержит ячейки с разной высотой. Если вы программно прокрутите страницу до определенной ячейки, находящейся далеко за пределами экрана, SwiftUI внезапно придется выполнять все эти макетные вычисления, что приведет к большому снижению производительности (сейчас мы увидим, как это может быть).
Трассировка Instruments при загрузке и прокрутке гораздо более здоровая. На трассе видно, что VSync комфортно готовит каждый кадр с интервалом 16,67 мс, необходимым для 60 кадров в секунду, даже в режиме пониженного энергопотребления.
Прокручивая вниз сотни элементов, мы получаем несколько очень незначительных падений кадров, но никаких зависаний. С точки зрения SwiftUI, на экране одновременно находится всего несколько элементов, поэтому все это можно комфортно вычислить и отрендерить за миллисекунды между каждым кадром.
Это отличный базовый вариант, но теперь давайте рассмотрим динамически изменяемые размеры ячеек:
Теперь мы увидим заметное снижение производительности, с несколькими пропавшими кадрами. Вот трассировка, показывающая эти выпавшие кадры и микрозависания. В целом, это было не так уж и плохо, учитывая нечестный пример, который я использовал (со случайной высотой ячеек).
Использование памяти в LazyVStack довольно стабильно. Оно немного подскочило при очень быстрой прокрутке, но не резко, вытесняя данные представления из памяти по мере их удаления из области просмотра.
Однако мне удалось сломать LazyVStack при использовании ячеек динамического размера. Когда я перетаскивал индикатор прокрутки, чтобы бешено прокручивать его вверх и вниз, динамическая оценка высоты LazyVStack дала сбой. Он начал глючить, заставляя наш скроллинг дико прыгать сам по себе, и увеличил потребление памяти
Эти данные значительно превзошли мои ожидания. Я уже читал, что LazyVStack ленится только в одну сторону — ячейки после появления остаются в памяти неопределенное время, из-за чего прокрутка постепенно становится все медленнее и медленнее.
Чтобы удовлетворить свое любопытство, я установил iOS 16.0 SDK и повторно выполнил профилирование. Как и ожидалось, я наблюдал рост использования памяти (и падение производительности), чем дальше мы прокручивали страницу.
LazyVStack, очевидно, был модернизирован под капотом с iOS 16, чтобы стать ленивым в обоих направлениях.
List
Теперь перейдем к Элвису из мира контейнеров SwiftUI — списку.
Он настолько хорош, что это почти то же самое, что использовать UIKit.
Потому что… это полностью UIKit.
List использует UICollectionView под капотом, включая всю переработку ячеек, которую мы знаем и любим.
Форматировать список немного сложнее, поскольку мы сидим поверх базовых системных настроек UIKit, а не пользуемся полной свободой SwiftUI.
Давайте запустим его… Ощущения более плавные. Все идет почти как по маслу.
Хотя это ненаучное ощущение трудно подчеркнуть, оно было подтверждено этим глубоко научным следом в Instruments.
Никаких зависаний. Никаких заминок. Ничего. Эффективные 60 кадров в секунду в режиме пониженного энергопотребления.
При динамическом изменении размера содержимого ячеек я обнаружил несколько заминок на один-два кадра при очень быстрой прокрутке, но, конечно, меньше, чем при использовании LazyVStack.
Что касается памяти, то при первой загрузке List немного потребил ее, а затем стал очень последовательным и плавным; все 1000 элементов в коллекции были сохранены.
Я также не смог его сломать.
Дальнейшее исследование производительности
Мы остановились на List как на самом эффективном способе отображения бесконечной ленты.
Но при прокрутке вверх и вниз он все еще кажется немного неуклюжим. Давайте рассмотрим некоторые подходы к оптимизации производительности прокрутки.
Кэширование изображений
Самый очевидный недостаток нашего примера — это изображения: они загружаются каждый раз, когда на экране появляется ячейка. Это слабое место компонента SwiftUI AsyncImage, который тратит тонну сетевого ввода-вывода при повторном рендеринге изображения, ушедшего за пределы экрана.
Существуют десятки библиотек, решающих эту проблему, например Nuke, Kingfisher или CachedAsyncImage. Когда изображения получаются мгновенно (по крайней мере, на втором проходе), все ваше приложение работает значительно быстрее.
Пагинация
Когда вы реализуете пагинацию, вместо того, чтобы выделять каждый элемент в начале, мы загружаем на экран только первые несколько (часто 20, 50 или 100). Когда вы прокручиваете страницу до самого низа, извлекается и отображается больше данных.
Это очень распространенная схема, особенно при бесконечной прокрутке ленты — вы же не загружаете бесконечное количество данных, это было бы невозможно. И дорого.
С помощью LazyVStack и List вы сможете гораздо эффективнее выделять начальную память и выполнять сетевой ввод-вывод. Меньший источник данных также может повысить скорость диффинга при изменении данных.
Минимизация перерисовки
Движок рендеринга SwiftUI перерисовывает представления при изменении их зависимостей, например свойств @State
или свойств модели представления @Observable
. SwiftUI запускает алгоритм диффинга, который сравнивает новое тело представления со старой структурой и идентичностью и выстраивает и перерисовывает представление, если обнаруживает разницу.
Чтобы сделать высокопроизводительные представления SwiftUI, нам нужно сделать две вещи:
- Минимизировать зависимости в ваших представлениях. Ваши представления SwiftUI должны иметь как можно меньше состояний, что означает меньше повторных вычислений и меньше перерисовки.
- Сделать поиск различий максимально простым и быстрым. Алгоритм диффинга SwiftUI по умолчанию может быть медленным для сложных представлений, но мы можем помочь ему, сделав наши представления равнозначными и применив модификатор
equatable()
. Если ваше представление имеет только примитивные типы для зависимостей, оно может выполнять (недокументированные!) сравнения на уровне байтов в стиле memcmp.
В процессе разработки вы можете использовать вспомогательные функции, такие как Self._printChanges
, чтобы помочь отладить повторные вычисления представления и отследить проблемы с производительностью потока данных.
Фоновая обработка
Мы хотим выполнять как можно меньше работы в главном потоке, чтобы он мог свободно реагировать на действия пользователя. Поэтому мы должны с осторожностью передавать работу фоновым потокам, включая любые виды длительной обработки, преобразования изображений и парсинг данных.
Тела представлений SwiftUI по умолчанию являются @MainActor
, что означает, что любая работа по вычислению представления, например интерполяция строк и фильтрация данных, происходит на главном агенте, запускаясь при каждом вычислении представления и делая ваше приложение менее отзывчивым.
Вызывая функции в моделях представления, вы также должны убедиться, что асинхронная работа не будет неожиданно выполняться в главном потоке. Для ознакомления с этой темой прочитайте мою небольшую статью.
Существуют библиотеки UIKit, такие как Texture, которые переносят тонну работы по рендерингу и компоновке в фоновые потоки, но, насколько я знаю, для SwiftUI эквивалента нет. Кто-нибудь хочет его создать?
Использование Metal
SwiftUI включает модификатор drawingGroup()
, который использует Metal для рендеринга вида как единой предварительно отрендеренной текстуры перед отрисовкой на экране. Это позволяет задействовать GPU для сглаживания сложных иерархий представлений в простые изображения.
Это отличный вариант, если у вас сложный набор анимаций, глубокая иерархия вложенных представлений или вы активно используете режимы наложения CoreImage.
Перегрузка представлений на GPU сопряжена с накладными расходами. Хотя этот модификатор может волшебным образом устранить некоторые проблемы с производительностью, в некоторых случаях он может замедлить работу View. Учитывайте это, когда реагируете на проблему с производительностью, а не применяйте его заранее ко всем видам.
Более детальное профилирование
Для вашего приложения я рекомендую профилировать его, чтобы найти узкие места в вычислениях, которые могут мешать производительности. Вы можете использовать такие инструменты, как View Body, View Properties, Core Animation Commits, Hangs и, конечно же, Time Profiler.
Заключение
SwiftUI — коварный зверь.
Магия его декларативного синтаксиса и мощного потока данных делает создание приложений быстрее и проще, чем когда-либо. Но, с другой стороны, его возможности могут показаться ограниченными, когда вы сталкиваетесь с холодной, суровой реальностью сбоев и проблем с производительностью.
Бесконечные ленты — самое сложное, что можно сделать в SwiftUI.
По состоянию на iOS 18, и List, и LazyVStack имеют довольно хорошую производительность, использование памяти и переработку ячеек, при этом List на основе UICollectionView опережает его по стабильности при работе с динамически изменяемым размером контента.
Как только вы настроите представление с прокруткой, вы сможете применить множество трюков, чтобы увеличить производительность до максимума: кэширование, пагинация, аппаратное ускорение и фоновая обработка. Также следует продуманно структурировать представления и поток данных, предотвращая ненужные повторные вычисления и рендеринг представлений. Этого можно добиться, минимизировав состояние каждого представления и сделав представления по возможности равнозначными.
Каждое приложение отличается от другого. Как я люблю говорить, изучите свое приложение, прежде чем слушать какого-то парня в интернете.
Не стесняйтесь проверить репозиторий с открытым кодом, который я использовал, если вы хотите провести свой собственный анализ.
Я стою на плечах гигантов. На этот пост сильно повлияли работы Томаса Рикуарда и Фэтбобмана. Привет вам обоих.
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.22
-
Новости2 недели назад
Видео и подкасты о мобильной разработке 2025.24
-
Вовлечение пользователей4 недели назад
Небольшое изменение в интерфейсе Duolingo, которое меняет все
-
Маркетинг и монетизация4 недели назад
Институциональные покупки: понимание и обнаружение