Отладка представлений SwiftUI является необходимым навыком при написании динамических представлений с несколькими триггерами перерисовки. Такие обертки свойств, как @State
и @ObservedObjec
t, перерисуют ваше представление на основе измененного значения. Часто это ожидаемое поведение, и все выглядит так, как должно быть. Однако в так называемых массивных представлениях SwiftUI (MSV) может существовать множество различных триггеров, вызывающих неожиданную перерисовку представлений.
Я придумал этот термин MSV, но вы, наверное, поняли, о чем я. В UIKit мы использовали так называемые Massive View Controllers, у которых было слишком много обязанностей. Из этой статьи вы узнаете, почему при написании динамических представлений SwiftUI необходимо предотвращать подобное.
Что такое динамическое представление SwiftUI?
Динамическое представление SwiftUI перерисовывается в результате изменения наблюдаемого свойства. Примером может служить представление таймера, которое обновляет метку с целым числом:
struct TimerCountView: View { @State var count = 0 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { Text("Count is now: \(count)!") .onReceive(timer) { input in count += 1 } } }
При каждом срабатывании таймера счетчик будет увеличиваться. Наше представление перерисовывается благодаря атрибуту @State
, прикрепленному к свойству count. Представление TimerCountView
является динамическим, поскольку его содержимое может меняться.
Для полного понимания того, что я имею в виду под динамикой, полезно рассмотреть пример статического представления. В следующем представлении у нас есть статическая текстовая метка, которая использует вводимую строку заголовка:
struct ArticleView: View { let title: String var body: some View { Text(title) } }
Можно спорить, стоит ли создавать кастомное представление для этого, но сейчас он просто демонстрирует пример статического представления. Поскольку свойство title
является статическим let
без каких-либо атрибутов, можно предположить, что это представление не будет изменяться. Поэтому мы можем назвать его статическим представлением. Отладка статических представлений SwiftUI, скорее всего, нужна лишь иногда.
Проблема массивного представления SwiftUI
Представления SwiftUI с большим количеством триггеров перерисовки могут стать проблемой. Каждый @State, @ObservedObject или другой триггер может вызвать перерисовку представления и повлиять на динамику, например, на анимацию. В таких случаях особенно полезно знать, как отладить представление SwiftUI.
Например, мы можем внедрить в наше представление таймера анимированную кнопку, известную по Looki. Анимация запускается при появлении и вращает кнопку вперед и назад:
struct TimerCountView: View { @State var count = 0 @State var animateButton = true let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { VStack { Text("Count is now: \(count)!") .onReceive(timer) { input in count += 1 } Button { } label: { Text("SAVE") .font(.system(size: 36, weight: .bold, design: .rounded)) .foregroundColor(.white) .padding(.vertical, 6) .padding(.horizontal, 80) .background(.red) .cornerRadius(50) .shadow(color: .secondary, radius: 1, x: 0, y: 5) }.rotationEffect(Angle(degrees: animateButton ? Double.random(in: -8.0...1.5) : Double.random(in: 0.5...16))) }.onAppear { withAnimation(.easeInOut(duration: 1).delay(0.5).repeatForever(autoreverses: true)) { animateButton.toggle() } } } }
Поскольку и таймер, и анимация вызывают перерисовку одного и того же TimerCountView, полученная анимация не соответствует нашим ожиданиям:
Анимация кнопки прыгает, поскольку несколько триггеров перерисовывают один и тот же экран.
Случайное значение для нашего эффекта вращения изменяется при каждой перерисовке View. Таймер и наш булевый переключатель вызывают перерисовку, в результате чего кнопка прыгает, а не плавно вращается.
Приведенный выше пример показывает, к чему может привести представление с несколькими состояниями, в то время как наш пример был относительно небольшим. Представление с большим количеством триггеров может вызвать несколько подобных побочных эффектов, что затрудняет понимание того, какой триггер вызвал проблему.
Прежде чем объяснить, как решить эту проблему, я продемонстрирую несколько приемов, которые можно применить для выявления причины перерисовки представления SwiftUI.
Использование LLDB для отладки изменений
LLDB — это наш инструмент отладки, доступный в консоли Xcode. Он позволяет выводить объекты с помощью po object
и выяснять состояние, когда наше приложение приостановлено, например, точкой останова.
Swift предоставляет нам приватный статический метод Self._printChanges()
, который выводит триггер перерисовки. Мы можем воспользоваться им, установив точку останова в теле SwiftUI и набрав в консоли po Self._printChanges()
:
Как видно, консоль сообщает нам об изменении свойства _count
. Наше представление SwiftUI перерисовывается, поскольку мы наблюдали, что наше свойство count
является значением состояния.
Чтобы окончательно убедиться в том, что свойство count
вызывает проблемы с анимацией, можно временно отключить таймер и повторно запустить наше приложение. Вы увидите плавную анимацию, которая больше не вызывает никаких проблем.
Это был простой пример отладки. Использование Self._printChanges()
может быть полезно в тех случаях, когда необходимо выяснить, какое свойство состояния вызвало перерисовку.
Использование _logChanges в Xcode 15.1 и выше
В Xcode 15.1 появился новый метод отладки, аналогичный Self._printChanges
, который позволяет отлаживать представления SwiftUI:
var body: some View { #if DEBUG Self._logChanges() #endif return VStack { /// ... } }
Этот метод работает аналогично и записывает в лог имена измененных динамических свойств, которые привели к обновлению body
. Информация записывается на информационном уровне с использованием подсистемы com.apple.SwiftUI
и категории «Changed Body Properties», что является большим преимуществом по сравнению с Self._printChanges
, так как позволяет воспользоваться новой консолью отладки Xcode 15.
Решение проблем перерисовки в SwiftUI
Прежде чем перейти к рассмотрению других методов отладки, следует объяснить, как решить описанную выше проблему в SwiftUI. Техника отладки LLDB дала нам достаточно сведений для работы, и мы можем отделить таймер от анимации кнопки.
Мы можем решить нашу проблему, изолировав триггеры перерисовки в отдельные ответственные за это представления. Изолируя триггеры, мы будем перерисовывать только соответствующие представления. В нашем случае мы хотим выделить анимацию кнопки только для того, чтобы она перерисовывалась при переключении нашего булевого ключа animateButton:
struct TimerCountFixedView: View { @State var count = 0 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { VStack { Text("Count is now: \(count)!") .onReceive(timer) { input in count += 1 } AnimatedButton() } } } struct AnimatedButton: View { @State var animateButton = true var body: some View { Button { } label: { Text("SAVE") .font(.system(size: 36, weight: .bold, design: .rounded)) .foregroundColor(.white) .padding(.vertical, 6) .padding(.horizontal, 80) .background(.red) .cornerRadius(50) .shadow(color: .secondary, radius: 1, x: 0, y: 5) }.rotationEffect(Angle(degrees: animateButton ? Double.random(in: -8.0...1.5) : Double.random(in: 0.5...16))).onAppear { withAnimation(.easeInOut(duration: 1).delay(0.5).repeatForever(autoreverses: true)) { animateButton.toggle() } } } }
Запуск приложения с приведенным выше кодом покажет совершенно плавную анимацию, в то время как счетчик продолжает обновляться:
Наша кнопка плавно анимируется, в то время как таймер продолжает обновляться
Таймер больше не изменяет случайное значение эффекта вращения, поскольку SwiftUI достаточно умен, чтобы не перерисовывать нашу кнопку при изменении отсчета. Еще одно преимущество выделения нашего кода в отдельное представление AnimatedButton
заключается в том, что мы можем повторно использовать эту кнопку в любом месте нашего приложения.
Примеры представлений, приведенные в этой статье, все еще относительно невелики. При работе над реальным проектом можно быстро получить View с большим количеством обязанностей и триггеров. Мне в работе помогает разделение ситуаций, когда кастомное представление имеет больше смысла. Всякий раз, когда я создаю свойство представления, например:
var animatedButton: some View { // .. define button }
Я задаю себе вопрос, не имеет ли смысл вместо этого создать:
struct AnimatedButton: View { // .. define button }
Применяя этот подход, вы снизите вероятность возникновения проблем с анимацией в первую очередь.
Отладка изменений с помощью кода
Теперь, когда мы знаем, как работает отладка представлений SwiftUI с помощью метода Self._logChanges()
, мы можем рассмотреть другие ценные способы использования этого метода. Установка точки останова, как в предыдущем примере, работает только тогда, когда вы знаете, в каком представлении возникают проблемы. Возможны случаи, когда у вас есть несколько затронутых представлений, поскольку все они контролируют один и тот же наблюдаемый объект.
Использование кода может стать решением проблемы, поскольку не требует ручного ввода команд lldb после наступления точки останова. Код ускоряет процесс отладки, поскольку он постоянно выполняется, пока перерисовываются представления. Мы можем использовать эту технику следующим образом:
var body: some View { #if DEBUG Self._logChanges() #endif return VStack { // .. other changes } }
Приведенные выше изменения кода выведут все изменения в нашем представлении в консоль:
TimerCountView: @self, @identity, _count, _animateButton changed. TimerCountView: _animateButton changed. TimerCountView: _count changed. TimerCountView: _count changed.
Вы можете заметить, что в консоли появилось несколько новых строк. Новыми являются @self и @identity, и вам может быть интересно, что они означают. Обратившись к документации метода _printChanges, мы получим объяснение:
За @self
и @identity
следуют свойства, которые изменились. В приведенном примере видно, что и _count
, и _animateButton
повлияли на перерисовку представления.
Заключение
Отладка представлений SwiftUI является необходимым навыком при работе с динамическими свойствами представления. Использование статического метода Self._printChanges
или Self._logChanges
позволяет найти первопричину перерисовки. Часто мы можем решить проблемы с анимацией, изолировав представления от отдельных ответственных представлений.
Спасибо!