Разработка
Понимаем и улучшаем производительность SwiftUI
Применение этих трех методов к представлениям SwiftUI в нашем приложении привело к значительному сокращению ненужных переоценок и повторных рендерингов.
Airbnb впервые внедрил SwiftUI в 2022 году, начав с отдельных компонентов, а затем расширив его до целых экранов и функций. Мы увидели значительные улучшения производительности инженеров благодаря его декларативной, гибкой и компонуемой архитектуре. Однако внедрение SwiftUI принесло новые проблемы, связанные с производительностью. Например, в SwiftUI есть много общих шаблонов кода, которые могут быть неэффективными, и множество небольших проблем могут в совокупности привести к большому кумулятивному снижению производительности. Чтобы начать решать некоторые из этих проблем в масштабе, мы создали новый инструментарий для упреждающего выявления этих случаев и статической проверки правильности исправлений.
Архитектура SwiftUI фич в Airbnb
Мы используем декларативные шаблоны пользовательского интерфейса в Airbnb уже много лет, используя нашу библиотеку Epoxy на основе UIKit и однонаправленные системы потоков данных. При внедрении SwiftUI в наш экранный слой мы решили продолжить использовать нашу существующую библиотеку однонаправленных потоков данных. Это упростило процесс постепенного внедрения SwiftUI в нашу большую кодовую базу, и мы обнаружили, что это улучшает качество и удобство обслуживания функций.
Однако мы заметили, что SwiftUI фичи, использующие нашу библиотеку однонаправленного потока данных, не работали так хорошо, как мы ожидали, и нам не сразу стало очевидно, в чем проблема. Понимание характеристик производительности SwiftUI является важным требованием для создания производительного набора инструментов SwiftUI, выходящего за рамки «стандартного».
Понимание различий представлений SwiftUI
При работе с декларативными системами пользовательского интерфейса, такими как SwiftUI, важно убедиться, что фреймворк знает, какие представления необходимо повторно оценить и повторно отобразить при изменении состояния экрана. Изменения обнаруживаются путем сравнения сохраненных свойств представления каждый раз, когда обновляется его родительский элемент. В идеале тело представления будет повторно оцениваться только тогда, когда его свойства действительно изменятся:
Однако такое поведение не всегда соответствует действительности (подробнее об этом чуть позже). Ненужные оценки тела представления снижают производительность, выполняя ненужную работу.
Как узнать, как часто тело представления повторно оценивается в реальном приложении? Простой способ визуализировать это — использовать модификатор, который применяет случайный цвет к представлению каждый раз, когда оно отображается. При тестировании этого на различных представлениях на экранах нашего приложения, наиболее чувствительных к производительности, мы быстро обнаружили, что многие представления повторно оценивались и перерисовывались чаще, чем необходимо:
Алгоритм сравнения представлений SwiftUI
Встроенный алгоритм сравнения (diffing algorithm) SwiftUI часто упускается из виду и официально не документируется, но он оказывает огромное влияние на производительность. Чтобы определить, нужно ли повторно оценивать тело представления, SwiftUI использует алгоритм сравнения на основе рефлексии или отражения (reflection-based) для сравнения каждого из сохраненных свойств представления:
- Если тип является
Equatable
, SwiftUI сравнивает старые и новые значения, используя соответствие типаEquatable
. В противном случае: - SwiftUI сравнивает типы значений (например, структуры), рекурсивно сравнивая каждое свойство экземпляра
- SwiftUI сравнивает ссылочные типы (например, классы), используя ссылочную тождественность
- SwiftUI пытается сравнивать замыкания по тождественности. Однако большинство нетривиальных замыканий нельзя сравнить надежно.
Если все свойства представления получаются равными предыдущим значениям, то тело не переоценивается, а содержимое не перерисовывается. Значения, использующие обертки свойств SwiftUI, такие как @State
и @Environment
, не участвуют в этом алгоритме сравнения, а вместо этого запускают обновления представлений с помощью различных механизмов.
При просмотре различных представлений в нашей кодовой базе мы обнаружили несколько общих шаблонов, которые мешали алгоритму сравнения SwiftUI:
- Некоторые типы изначально не поддерживаются, например замыкания
- Простые типы данных, хранящиеся в представлении, могут неожиданно сравниваться по ссылке, а не по значению
Вот пример представления SwiftUI со свойствами, которые плохо взаимодействуют с алгоритмом сравнения:
struct MyView: View { /// A generated data model that is a struct with value semantics, /// but is copy-on-write and wraps an internal reference type. /// Compared by reference, not by value, which could cause unwanted body evaluations. let dataModel: CopyOnWriteDataModel /// Other miscellaneous properties used by the view. Typically structs, but sometimes a class. /// Unexpected comparisons by reference could cause unwanted body evaluations. let requestState: MyFeatureRequestState /// An action handler for this view, part of our unidirectional data flow library. /// Wraps a closure that routes the action to the screen's action handler. /// Closures almost always compare as not-equal, and typically cause unwanted body evaluations. let handler: Handler<MyViewAction> var body: some View { ... } }
Если представление содержит любое значение, которое не является дифференцируемым, все представление становится недифференцируемым. Предотвратить это масштабируемым способом с помощью существующих инструментов практически невозможно. Это открытие также раскрывает проблему производительности, вызванную нашей библиотекой однонаправленного потока данных: обработка действий основана на замыканиях, но SwiftUI не может различать замыкания!
В некоторых случаях, как в случае с обработчиками действий из нашей библиотеки однонаправленного потока данных, создание дифференцируемого значения потребовало бы больших, инвазивных и потенциально нежелательных изменений архитектуры. Даже в более простых случаях этот процесс все еще занимает много времени, и нет простого способа предотвратить появление регрессии в дальнейшем. Это большое препятствие при попытке улучшить и поддерживать производительность в масштабе больших кодовых баз с множеством различных участников.
Управление сравнением представлений SwiftUI
К счастью, у нас есть другой вариант: если представление соответствует Equatable
, SwiftUI будет сравнивать его, используя свое соответствие Equatable
вместо использования алгоритма сравнения на основе рефлексии по умолчанию.
Преимущество этого подхода в том, что он позволяет нам выборочно решать, какие свойства следует сравнивать при дифференцировании нашего представления. В нашем случае мы знаем, что объект-обработчик не влияет на содержимое или идентичность нашего представления. Мы хотим только, чтобы наше представление было повторно оценено и повторно отображено при обновлении значений dataModel
и requestState
. Мы можем выразить это с помощью пользовательской реализации Equatable
:
// An Equatable conformance that makes the above SwiftUI view diffable. extension MyView: Equatable { static func ==(lhs: MyView, rhs: MyView) -> Bool { lhs.dataModel == rhs.dataModel && lhs.requestState == rhs.requestState // Intentionally not comparing handler, which isn't Equatable. } }
Однако:
- Это большой дополнительный шаблон для инженеров, особенно для представлений с большим количеством свойств
- Написание и поддержка кастомного соответствия подвержено ошибкам. Вы можете легко забыть обновить соответствие
Equatable
при добавлении новых свойств позже, что приведет к ошибкам.
Поэтому вместо того, чтобы вручную писать и поддерживать соответствия Equatable
, мы создали новый макрос @Equatable
, который генерирует соответствия для нас.
// A sample SwiftUI view that has adopted @Equatable // and is now guaranteed to be diffable. @Equatable struct MyView: View { // Simple data types must be Equatable, or the build will fail. let dataModel: CopyOnWriteDataModel let requestState: MyFeatureRequestState // Types that aren't Equatable can be excluded from the // generated Equatable conformance using @SkipEquatable, // as long as they don’t affect the output of the view body. @SkipEquatable let handler: Handler<MyViewAction> var body: some View { ... } }
Макрос @Equatable
генерирует реализацию Equatable
, которая сравнивает все сохраненные свойства экземпляра представления, исключая свойства с обертками свойств SwiftUI, такими как @State
и @Environment
, которые запускают обновления представления через другие механизмы. Свойства, которые не являются Equatable
и не влияют на отрисовку тела представления, можно пометить с помощью @SkipEquatable
, чтобы исключить их из сгенерированной реализации. Это позволяет нам продолжать использовать обработчики действий на основе замыканий из нашей библиотеки однонаправленного потока данных, не влияя на процесс диффинга SwiftUI!
После принятия макроса @Equatable
в представлении это представление гарантированно будет дифференцируемым. Если инженер позже добавит свойство, не являющееся Equatable
, сборка завершится ошибкой, что выявит потенциальную регрессию в поведении сравнения. Это фактически делает макрос @Equatable
сложным линтером — что действительно ценно для масштабирования этих улучшений производительности в кодовой базе со многими компонентами и многими участниками, поскольку это снижает вероятность появления регрессий в дальнейшем.
Управление размером тел представлений
Еще одним важным аспектом сравнения SwiftUI является понимание того, что SwiftUI может сравнивать только правильные структуры представлений. Любой другой код, такой как вычисляемые свойства или вспомогательные функции, которые генерируют представление SwiftUI, не может быть дифференцирован.
Рассмотрим следующий пример:
// Complex SwiftUI views are often simplified by // splitting the view body into separate computed properties. struct MyScreen: View { /// The unidirectional data flow state store for this feature. @ObservedObject var store: StateStore<MyState, MyAction> var body: some View { VStack { headerSection actionCardSection } } private var headerSection: some View { Text(store.state.titleString) .textStyle(.title) } private var actionCardSection: some View { VStack { Image(store.state.cardSelected ? "enabled" : "disabled") Text("This is a selectable card") } .strokedCard(.roundedRect_mediumCornerRadius_12) .scaleEffectButton(action: { store.handle(.cardTapped) }) } }
Это распространенный способ организации сложных представлений, поскольку он упрощает чтение и поддержку кода. Однако в рантайме SwiftUI эффективно встраивает представления, возвращаемые из свойств, в основное тело представления, как если бы мы вместо этого написали:
// At runtime, computed properties are no different // from just having a single, large view body! struct MyScreen: View { @ObservedObject var store: StateStore<MyState, MyAction> // Re-evaluated every time the state of the screen is updated. var body: some View { Text(store.state.titleString) .textStyle(.title) VStack { Image(store.state.cardSelected ? "enabled" : "disabled") Text("This is a selectable card") } .strokedCard(.roundedRect_mediumCornerRadius_12) .scaleEffectButton(action: { store.handle(.cardTapped) }) } }
Поскольку весь этот код является частью одного и того же тела представления, весь он будет переоцениваться при изменении состояния любой части экрана. Хотя этот конкретный пример прост, по мере того, как представление становится больше и сложнее, его переоценка станет более затратной. В конечном итоге при каждом обновлении экрана будет выполняться большой объем ненужной работы, что скажется на производительности.
Для повышения производительности мы можем реализовать код макета в отдельных представлениях SwiftUI. Это позволяет SwiftUI правильно различать каждое дочернее представление, переоценивая их тела только при необходимости:
struct MyScreen: View { @ObservedObject var store: StateStore<MyState, MyAction> var body: some View { VStack { HeaderSection(title: store.state.titleString) CardSection( isCardSelected: store.state.isCardSelected, handler: store.handler) } } } /// Only re-evaluated and re-rendered when the title property changes. @Equatable struct HeaderSection: View { let title: String var body: some View { Text(title) .textStyle(.title) } } /// Only re-evaluated and re-rendered when the isCardSelected property changes. @Equatable struct CardSection: View { let isCardSelected: Bool @SkipEquatable let handler: Handler<MyAction> var body: some View { VStack { Image(store.state.isCardSelected ? "enabled" : "disabled") Text("This is a selectable card") } .strokedCard(.roundedRect_mediumCornerRadius_12) .scaleEffectButton(action: { handler.handle(.cardTapped) }) } }
Разбивая представление на более мелкие, изменяемые части, SwiftUI может эффективно обновлять только те части представления, которые фактически изменились. Такой подход помогает поддерживать производительность по мере усложнения функции.
Правило линтера для сложности представления
Большие, сложные представления не всегда очевидны во время разработки. Легкодоступные метрики, такие как общее количество строк, не являются хорошим показателем сложности. Чтобы помочь инженерам узнать, когда пора рефакторить представление на более мелкие, изменяемые части, мы создали пользовательское правило SwiftLint, которое анализирует тело представления с помощью SwiftSyntax и измеряет его сложность. Мы определили метрику сложности представления как значение, которое увеличивается каждый раз, когда вы составляете представления с использованием вычисляемых свойств, функций или замыканий. С помощью этого правила мы автоматически запускаем оповещение в Xcode, когда представление становится слишком сложным (ограничение сложности настраивается, и в настоящее время мы допускаем максимальный уровень сложности 10).
Правило отображается как предупреждение во время локальных сборок Xcode, оповещая инженеров как можно раньше. На этом снимке экрана предел сложности установлен на 3, а это конкретное представление имеет сложность 5.
Заключение
Понимая, как работает сравнение представлений SwiftUI, мы можем использовать макрос @Equatable
, чтобы гарантировать, что тела представлений будут переоцениваться только тогда, когда значения внутри представлений фактически изменятся, разбивать представления на более мелкие части для более быстрой переоценки и поощрять разработчиков рефакторить представления, прежде чем они станут слишком большими и сложными.
Применение этих трех методов к представлениям SwiftUI в нашем приложении привело к значительному сокращению ненужных переоценок и повторных рендерингов. Возвращаясь к примерам из более ранних источников, вы видите гораздо меньше повторных рендеров в строке поиска и панели фильтров:
Используя результаты нашей системы оценки производительности страниц, мы обнаружили, что внедрение этих методов в наших самых сложных экранах SwiftUI действительно повышает производительность для наших пользователей. Например, мы сократили задержку прокрутки на 15% на нашем главном экране поиска, внедрив @Equatable
в его наиболее важных представлениях и разбив большие тела представлений на более мелкие diffable части. Эти методы также дают нам гибкость в использовании архитектуры фич, которая наилучшим образом соответствует нашим потребностям, без ущерба для производительности или наложения обременительных ограничений (например, полностью избегая замыканий в представлениях SwiftUI).
Конечно, эти методы не являются панацеей. Не обязательно, чтобы все функции SwiftUI использовали их, и этих методов самих по себе недостаточно, чтобы гарантировать отличную производительность. Однако понимание того, как и почему они работают, служит ценной основой для создания производительных функций SwiftUI и упрощает обнаружение и избежание проблемных шаблонов в вашем собственном коде.
Если вы заинтересованы в том, чтобы присоединиться к нам в нашем стремлении создать лучшее приложение для iOS в App Store, посетите нашу страницу вакансий для ознакомления с открытыми вакансиями в сфере iOS.
-
Видео и подкасты для разработчиков3 недели назад
Пагинация: от идеи до реализации
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.25
-
Видео и подкасты для разработчиков3 недели назад
История, принципы и концепции библиотеки навигации Decompose
-
Исследования3 недели назад
Bidease: мобильный маркетинг 2025 — баланс AI, удержания и конфиденциальности