Разработка
Влияние AnyView на производительность SwiftUI
SwiftUI будет сложно определить идентичность представления и его структуру, и он просто всегда будет перерисовывать все представления, что не очень эффективно.
AnyView — это представление без типа (type-erased), которое может пригодиться в контейнерах SwiftUI, состоящих из разнородных представлений. В таких случаях вам не нужно указывать конкретный тип всех представлений, которые могут находиться в иерархии. При таком подходе можно избежать использования дженериков, что упрощает код.
Однако его использование может сопровождаться снижением производительности. Как известно, SwiftUI полагается на тип представлений для вычисления разниц (диффинга). Если это AnyView (который, по сути, является оберткой), SwiftUI будет сложно определить идентичность представления и его структуру, и он просто всегда будет перерисовывать все представления, что не очень эффективно. Более подробно о механизме диффинга SwiftUI можно узнать из этого замечательного доклада на WWDC.
Apple также несколько раз упоминала, что нам следует избегать использования AnyView внутри ForEach, говоря, что это может вызвать проблемы с производительностью. Возможный случай, когда это может быть измерено — бесконечная лента различных представлений, отрисовывающая разные данные (например, в чате, социальном фиде и т.д.). В этом посте я проведу некоторые измерения с помощью SDK для чатов от Stream, используя его стандартную реализацию, основанную на дженериках, и сравнивая ее с модифицированной реализацией, использующей AnyView.
Тестовая среда
Несколько замечаний о системе тестирования:
- Все тесты и измерения проводились на iPhone 11 Pro Max.
- Для единообразия во всех тестах используется один и тот же набор данных и один и тот же пользователь.
- Тесты выполняются несколько раз.
- Тестируемые списки содержат различные типы данных (например, изображения, видео, гифки, тексты и т. д.).
- При тестировании различных реализаций выполняются одни и те же действия (например, прокрутка контента три раза).
- Данные собираются на страницах по 25 элементов в каждой.
- Мы будем использовать профилировщик для отлова сбоев в анимациях, а также этот счетчик FPS с открытым исходным кодом.
Заминки при анимации
Apple рекомендует использовать заминки в анимации в качестве метрики для оценки производительности приложения. По сути, заминка — это кадр, который отображается на экране позже, чем должен был отображаться. Чем больше время заминки, тем заметнее глюки и зависания, которые ухудшают пользовательский опыт. Например, если время заминки составляет 100 миллисекунд, это означает, что данный кадр отображается на 100 миллисекунд позже, чем ожидалось, что делает зависание заметным для пользователей. Заминки могут появляться как на этапе коммита, так и на этапе рендеринга.
Отличной отправной точкой для получения дополнительной информации о заминах в анимации являются следующие выступления Apple Talks:
- Демистификация и устранение заминок на этапе рендеринга
- Изучение заминок в анимации пользовательского интерфейса и цикла рендеринга
- Поиск и устранение ошибок на этапе коммита
Чтобы повысить производительность нашего приложения, нам нужно свести эти заминки анимации к минимуму (а еще лучше — избавиться от них совсем).
Я также покажу сравнение с FPS (кадры в секунду), поскольку эта метрика, как правило, более известна среди разработчиков. При использовании FPS в качестве метрики важно указывать максимальную частоту кадров (в данном случае 60), а также отбрасывать значения, когда в приложении нет активности.
Просмотр данных
Сначала посмотрим, как различные реализации будут работать при прокрутке содержимого. В этом тесте мы прокрутим весь список сообщений три раза.
Без AnyView
Ниже приведена запись анимационных заминок в реализации с дженериками.
Как видите, заминок анимации немного, две из них оранжевые, что означает, что длительность заминки превышает допустимую задержку в 33 мс. Поэтому в этих двух случаях кадр будет потерян. Эти две заминки происходят при загрузке новых сообщений и добавлении их в список. Любая последующая прокрутка списка во время загрузки сообщений не влияет на производительность.
Среднее значение FPS во время этого теста составляет около 59 кадров в секунду. Прокрутка плавная и отзывчивая.
С AnyView
Далее проведем тот же тест с использованием обертки AnyView. Ниже приведены результаты, полученные с помощью профилировщика анимационных заминок:
В этом примере можно увидеть немного больше оранжевого цвета. Здесь больше заминок, которые превышают допустимую задержку в 33 мс. Это приводит к заметным зависаниям — это видно как в Инструментах, так и визуально при выполнении тестов.
Кроме того, при повторной прокрутке списка производительность не улучшается (даже ухудшается). Это понятно, так как SwiftUI не знает, что уже отображал это представление один раз (потому что оно скрыто под зонтиком AnyView). Поэтому он отрисовывает его еще раз, при этом потенциально кэшируя (но не используя) старую версию этого же представления.
Средний FPS в этом тесте составил около 55, при этом можно было заметить некоторые видимые глюки при прокрутке, хотя все не так уж плохо.
Изменение данных во время просмотра
Еще один тест, который мы можем провести, это тест производительности — отправка большого количества контента в список и принудительное обновление представлений (это, например, реакции на сообщения), в то время как мы также просматриваем данные. Это вызовет несколько перерисовок представлений за короткий промежуток времени.
Без AnyView
Проведение тестов без обертки AnyView дает схожие с обычным тестом на прокрутку результаты (58-59 FPS). Это также вполне ожидаемо, поскольку SwiftUI знает об идентичности представлений и их структуре. Когда представление необходимо обновить, в нем применяются только необходимые изменения (например, добавление реакции в само представление).
С AnyView
Когда мы используем AnyView в этом контексте, все становится более интересным.
В этом сценарии есть несколько видимых заминок и зависаний, а FPS падает ниже 50, когда мы часто реагируем на сообщения. Выпадение кадров здесь более заметно, потому что мы принудительно перерисовываем представления много раз в течение нескольких секунд. Поскольку SwiftUI не знает, что это за представления, я предполагаю, что он перерисовывает их каждый раз с нуля. Некоторые из этих представлений довольно дорогие (например, gif), поэтому перерисовка может быть довольно дорогой операцией.
Использование AnyView позволяет добиться эффекта, аналогичного применению модификатора id со значением UUID(), который всегда будет обновлять элементы в представлении при их изменении.
Анализ результатов
Эти цифры зависят от настроек, поэтому их не стоит воспринимать как должное, а лишь как пример.
Без AnyView (FPS) | С AnyView (FPS) | Регрессия | |
Прокрутка | 59 | 55 | 10% |
Изменение данных | 59 | 50 | 16.5% |
Простой просмотр данных работает примерно на 10% медленнее, если вы обернули свои представления в AnyView. Если вы меняете данные во время просмотра, разница увеличивается примерно до 17%, и глюки здесь более заметны.
Чтобы лучше понять результаты, нам нужно глубоко погрузиться в то, как работает SwiftUI. На сессии WWDC, посвященной производительности SwiftUI, Радж из команды SwiftUI рассказывал о том, что список или таблица должны знать все свои идентификаторы заранее. SwiftUI может эффективно собирать их, не посещая все содержимое, только если содержимое разрешается в постоянное количество строк. Если вы используете условные проверки или AnyView, количество строк не может быть определено, и все представления должны быть созданы заранее, что влияет на производительность.
Поэтому старайтесь избегать подобного кода:
ForEach(someData) { someElement in if someCondition { SomeView(data: someElement) } }
А также кода, подобного этому:
ForEach(someData) { someElement in AnyView(SomeView(data: someElement)) }
Последняя часть кода похожа на то, как мы выполняли тесты с AnyView. Это означает, что мы фактически пересоздавали весь список, когда в нем происходили изменения. Это также объясняет, почему реализация AnyView со временем стала медленнее — при каждом перерисовывании приходится создавать больше контента с нуля.
Заключение
В качестве вывода можно сказать, что в подобных сценариях (прокручиваемые списки разнородных представлений) лучше использовать конкретные типы для различных представлений в контейнерах. Это может показаться более сложным в реализации, но на самом деле вы можете сделать это проще, не имея много проблем с дженериками.
Однако это не означает, что использование AnyView всегда влияет на производительность таким образом. Например, если у вас есть меню в виде списка из нескольких разнородных элементов, которые при нажатии показывают различные направления навигации, и вы решили обернуть эти представления в AnyView, измерения не покажут разницы в производительности по сравнению с другими подходами.
В этой статье был проведен другой пример теста с использованием AnyView против Group с операторами if-else, который не показал заметных различий между ними. Использование if-else приводит к потере идентичности представления, как и в AnyView, поэтому отсутствие разницы в производительности здесь вполне ожидаемо.
Это также зависит от того, как выглядит реализация — ваша модель данных, какое состояние куда передается, какие обновления могут вызвать перерисовку представления и т.д.
Каков ваш опыт работы с AnyView? Использовали ли вы его в широких масштабах и заметили ли вы какие-либо ухудшения производительности? Ждем ваших мнений в комментариях или в Twitter.