Noveo

Наш блог Отладка SwiftUI Views

Отладка SwiftUI Views

Senior iOS-разработчик Noveo Александр рассказывает о тонкостях работы с View в SwiftUI.

SwiftUI-Views Noveo

SwiftUI прикладывает массу усилий, чтобы UI не лагал при перерисовке. Если мы хотим лучше понимать, как работает фреймворк под капотом, — стоит копнуть его чуть глубже.

 

По сути, нас интересуют два события: когда пересоздается View и когда на самом деле запрашивается body для перерисовки. Для работы с этими событиями я создал обертку, позволяющую их отслеживать:

 

import SwiftUI

public struct DebugView<MainView: View>: View {
    private let view: MainView
    private let logType: LogType

    private enum LogType {
        case onlyDescription(String)
        case descriptionAndDumpView(String)
        case dumpView
    }

    private var about: String {
        switch logType {
            case let .onlyDescription(description):
                return "\(description)"
            case let .descriptionAndDumpView(description):
                return "\(description): \(view)"
            case .dumpView:
                return "\(view)"
            }
        }

    public init(view: MainView, description: String?, dumpView: Bool = true) {
        self.view = view
        if let description = description {
            if dumpView {
                logType = .descriptionAndDumpView(description)
            } else {
                logType = .onlyDescription(description)
            }
        } else {
            logType = .dumpView
        }
        print("init: \(about)")
    }

    public var body: some View {
        print("body: \(about)")
        return view
    }
}

extension View {
    public func debug() -> DebugView<Self> {
        return DebugView(view: self, description: nil)
    }

    public func debug(_ description: String, dumpView: Bool = false) -> DebugView<Self> {
        return DebugView(
            view: self,
            description: description,
            dumpView: dumpView
        )
    }
}

Вот ее Gist.

 

По большому счету, это View-прокси. Давайте попробуем поработать с этим. Создадим playground, в котором посмотрим работу с SwiftUI Views в динамике:

 

import SwiftUI
import PlaygroundSupport

private struct MyListView: View {
    @State var numberOfViews: Int = 1
    var body: some View {
        VStack(spacing: 30) {
            ForEach(0..<numberOfViews, id: \.self) { id in
                Text("\(id)").debug("Text: \(id)")
            }
            HStack {
                Text("\(numberOfViews)")
                Button("numberOfViews") {
                    self.numberOfViews += 1
                }
            }
        }
    }
}

PlaygroundPage.current.setLiveView(MyListView().debug("MyListView"))

При старте в логах мы увидим

init: MyListView
body: MyListView
init: Text: 0
body: Text: 0

Выглядит разумно, при старте приложения система сначала создает MyListView, берет у нее body, видит, что нужен один Text, создает его и затем уже у него просит body.

 

Нажимаем один раз на кнопку numberOfViews. В логах добавится следующее:

init: Text: 0
init: Text: 1
body: Text: 1

А вот это уже интересно: мы видим, что пересоздания MyListView не происходит, что выглядит логично, а вот Text создается 2 раза (View стало 2 после увеличения счетчика), но body запросился только у нового элемента на экране.

 

Если еще раз нажать на кнопку numberOfViews, увидим уже ожидаемое

init: Text: 0
init: Text: 1
init: Text: 2
body: Text: 2

Т.е. body будет вызываться только для новых элементов.

 

Попробуем понять, как и почему так работает система.

 

Создадим свою View — MyTextView и сделаем так, чтобы при каждом создании генерировался уникальный контент:

private struct MyText: View {
    var body: some View {
        return Text("\(Int.random(in: 0...100))")
    }

Меняем вызов на

ForEach(0..<numberOfViews, id: \.self) { id in
    MyText().debug("MyText: \(id)")
}

На старте видим в логах

init: MyListView
body: MyListView
init: MyText: 0
body: MyText: 0

После нажатия на кнопку numberOfViews

init: MyText: 0
init: MyText: 1
body: MyText: 1

При этом мы ожидаем, что на экране для ранее показанного элемента изменится число (random же как-никак), но по факту число не меняется. Т.е. система создает MyText, но, по всей видимости, отбрасывает, не перерисовывая. Почему? Потому что она считает, что все MyText одинаковы, т.к. эта структура не реализует Equatable.

 

Проверим нашу догадку:

private struct MyTextEquatable: View, Equatable {
    private let id: Int
    init() {
        id = Int.random(in: 0...100)
    }
    var body: some View {
        return Text("\(id)")
    }
}

Меняем вызов на

ForEach(0..<numberOfViews, id: \.self) { id in
    MyTextEquatable().debug("MyTextEquatable: \(id)")
}

Первый запуск:

init: MyListView
body: MyListView
init: MyTextEquatable: 0
body: MyTextEquatable: 0

Нажимаем на кнопку:

init: MyTextEquatable: 0
body: MyTextEquatable: 0
init: MyTextEquatable: 1
body: MyTextEquatable: 1

Заработало, это видно не только из логов, но и в preview: после каждого нажатия кнопки все числа меняются. Теперь, кажется, все встает на свои места, при каждом обновлении View система создает его детей (именно поэтому инициализаторы для View должны быть максимально легковесными, никакой «тяжелой» логики в конструктор помещать не стоит) и сравнивает по протоколу Equatable, изменилось ли внутреннее состояние; если нет — система считает, что перерисовка не нужна, т.к. View не обновилось.

 

Проверим, создадим следующее:

private struct StupidView: View, Equatable {
    private let id: Int
    init(id: Int) {
        self.id = id
    }

    var body: some View {
        return Text("\(id)")
    }

    static func == (lhs: StupidView, rhs: StupidView) -> Bool {
        return false
    }
}

Меняем вызов на

ForEach(0..<numberOfViews, id: \.self) { id in
    StupidView().debug("StupidView: \(id)")
}

При старте

init: MyListView
body: MyListView
init: StupidView: 0
body: StupidView: 0

После нажатия

init: StupidView: 0
init: StupidView: 1
body: StupidView: 1

Что за черт, мы же всегда возвращаем false при сравнении! Чтобы поотлаживать SwiftUI view, надо будет создать полноценный проект и создать там новый файл с содержимым:

import SwiftUI

private struct MyListView: View {
    @State var numberOfViews: Int = 1
    var body: some View {
        VStack(spacing: 30) {
            ForEach(0..<numberOfViews, id: \.self) { id in
                StupidView(id: id).debug("StupidView: \(id)")
            }
            HStack {
                Text("\(numberOfViews)")
                Button("numberOfViews") {
                    self.numberOfViews += 1
                }
            }
        }
    }
}

private struct StupidView: View, Equatable {
    private let id: Int
    init(id: Int) {
        self.id = id
    }

    var body: some View {
        return Text("\(id)")
    }

    static func == (lhs: StupidView, rhs: StupidView) -> Bool {
        return false
    }
}

struct EquatableSwiftUIStupid_Previews: PreviewProvider {
    static var previews: some View {
        MyListView()
    }
}

Если в preview вызывать контекстное меню у кнопки play, появится возможность сделать Debug preview.

SwiftUI-Views Noveo

Что мы наблюдаем при отладке? Точки останова срабатывают внутри var body, но не срабатывают внутри static func ==: почему-то система не вызывает наш метод для сравнения. Очень уж умный SwiftUI. В итоге я нашел, как это обойти: использовать вместо Int клас- обертку Holder. Видимо, в этом случае SwiftUI решает все же положиться на предоставленную реализацию, т.к. у нас уже не простой value type.

 

Проверим:

private class Holder {
    var id: Int

    init(id: Int) {
        self.id = id
    }
}

private struct StupidViewWithHolder: View, Equatable {
    private let holder: Holder
    init(holder: Holder) {
        self.holder = holder
    }

    var body: some View {
        return Text("\(holder.id)")
    }

    static func == (lhs: Self, rhs: Self) -> Bool {
        return false
    }
}

Меняем вызов на

ForEach(0..<numberOfViews, id: \.self) { id in
    StupidViewWithHolder(holder: Holder(id: id))
                    .debug("StupidViewWithHolder: \(id)")
}

При старте

init: MyListView
body: MyListView
init: StupidViewWithHolder: 0
body: StupidViewWithHolder: 0

После нажатия

init: StupidViewWithHolder: 0
body: StupidViewWithHolder: 0
init: StupidViewWithHolder: 1
body: StupidViewWithHolder: 1

Наконец-то мы добились реальной перерисовки элементов!

 

Замечу, что на это не стоит закладываться при разработке, т.к. это внутреннее поведение SwiftUI, которое может измениться в любой момент. Но знания могут помочь понять, что в принципе может пойти не так, и не тратить кучу времени на отладку реально сложных View. Теперь мы знаем очередной подводный камень SwiftUI, к тому же новый инструмент по дебагу View доказал свою состоятельность. Надеюсь, было полезно :)

 

Скачать Playground со всеми примерами из статьи можно здесь: Playground.

Если вы нашли ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter.

НазадПредыдущий пост ВпередСледующий пост

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: