Каморка сурового программиста, дубль 2

Отладка SwiftUI views

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

Заработало, это видно не только из логов, но и в preivew, после каждого нажатия кнопки - все числа меняются. Теперь кажется все встает на свои места, при каждом обновлении 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.





Что мы наблюдаем при отладке? Точки останова срабатывают внутри 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

Tagged with: