Понимание перерисовки/зависимости SwiftUI Observable со стеками и пользовательскими представлениями

Я пытаюсь понять, когда SwiftUI на самом деле вызывает перерисовку представлений с использованием зависимостей @Observable/property и случайных цветов фона, чтобы просто получить визуальное представление о вещах.

Я прочитал https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app - и насколько я понимаю, представления, которые используют свойства объектов @Observable, имеют механизм макросов в место для обновления только в случае изменения свойства. Другие представления в дереве не должны обновляться, если их свойство не обновилось.

Из документов Apple:

Вы также можете поделиться объектом данных наблюдаемой модели с другим представлением. Принимающее представление формирует зависимость, если оно считывает какие-либо свойства объект в своем теле. Например, в следующем коде LibraryView использует экземпляр Book совместно с BookView и BookView. отображает название книги. Если название книги изменится, SwiftUI обновляет только BookView, а не LibraryView, потому что только BookView читает свойство title.

Но я не могу добиться единообразия для всех типов представлений?

В приведенном ниже коде я добавил несколько статических представлений, которые случайным образом рисуют цвет фона, и несколько «встроенных» представлений, которые также случайным образом рисуют фон. У меня есть простая ручная анимация на основе таймера, которая просто обновляет наблюдаемое значение свойства в качестве «прокси» для изменений, чтобы проверить отслеживание наблюдений/зависимостей.

Вот ссылка на видео предварительного просмотра Xcode, показывающее то, что я вижу:

https://x.com/_vade/status/1828116344916844806?s=61

Что я ожидал увидеть:

  • Самый верхний «встроенный» созданный текст не должен менять цвет переднего плана — он не зависит от наблюдаемого свойства. На самом деле оно меняется. Почему?

  • Аналогично, представление StaticText под ним не должно менять цвет переднего плана. Это не так, и работает так, как ожидалось. Почему это отличается от представленного выше?

  • Я ожидаю, что кнопка StaticView Start Clock не изменит свой фон. Это не так, я полагаю, по той же причине, что и приведенный выше StaticText не меняется. Тем не менее, представление взаимодействует с часами, но не с основным телом рисования. Ура.

  • Аналогично, StaticView ниже не меняется должным образом. Ура.

  • Я ожидаю, что SimpleSliderView будет обновляться, поскольку он напрямую привязан к часам и наблюдает за свойством, которое обновляется на основе таймера.

  • Я не ожидаю обновления внешнего цвета фона ZStacks, хотя я могу быть убежден, что, поскольку необходимо перерисовать дочернее представление, возможно, потребуется перерисовать его.

  • В чем причина того, что весь ZStack пересчитывает свой фон, а некоторые его дочерние элементы - нет?

Если вышеизложенное является «правильным» поведением, может ли любое встроенное родительское представление, используемое для композиции в SwiftUI (например, HStack/VStack/ZStack), быть написано таким образом, чтобы перерисовка подпредставлений не вызывала перерисовку родителя?

С точки зрения графики я хочу ограничить любую скорость перерисовки/заполнения и обеспечить минимальное количество обновлений за каждое наблюдаемое обновление через наблюдение.

Я тестирую бета-версию macOS 15.1 Beta 2 и хочу полностью использовать любые новые возможности повышения производительности SwiftUI, какие только смогу. Я не ориентируюсь на старые версии. ЙОЛО.

Спасибо за любую информацию! Кажется, очень легко создать плохо работающий код SwiftUI.

Тестовый код:


import SwiftUI


public func remap(input:Double, inMin:Double, inMax:Double, outMin:Double, outMax:Double) -> Double
{
    return ((input - inMin) / (inMax - inMin) * (outMax - outMin)) + outMin;
}

public func remap(input:Float, inMin:Float, inMax:Float, outMin:Float, outMax:Float) -> Float
{
    return ((input - inMin) / (inMax - inMin) * (outMax - outMin)) + outMin;
}


@Observable class BPMClock : Identifiable, Hashable, Equatable
{
    var id: UUID = .init()
    
    static func == (lhs: BPMClock, rhs: BPMClock) -> Bool
    {
        return lhs.id == rhs.id
    }
    
    func hash(into hasher: inout Hasher)
    {
        hasher.combine(self.id)
    }
    
    var bpm:Double = 60.0
    var timeValue:Double = 0.0
    
    @ObservationIgnored private var rawTimeValue:Double = 0.0
    @ObservationIgnored private var lastTime:Double = 0.0
    @ObservationIgnored var timer:Timer? = nil

    func startTimer()
    {
        self.timer = Timer.scheduledTimer(timeInterval: 1/60,
                                          target: self,
                                          selector: #selector(calcTimeAbsolute),
                                          userInfo: nil,
                                          repeats: true)
    }
    
    
    @objc func calcTimeAbsolute()
    {
        let now = Date.timeIntervalSinceReferenceDate
       
        self.calcTime(usingNow: now)
    }
    
    func calcTime(usingNow now:Double)
    {
        let delta = now - self.lastTime
        
        self.rawTimeValue = fmod(delta + self.rawTimeValue,  self.bpmToS(self.bpm) )
                                
        self.timeValue = remap(input: self.rawTimeValue, inMin: 0.0, inMax:self.bpmToS(self.bpm), outMin: 0.0, outMax: 1.0)
        self.lastTime = now
    }
    
    private func bpmToS(_ bpm:Double) -> Double
    {
        let bps =  60.0 / bpm
        return bps
    }
    
    private func sToBpm(_ s:Double) -> Double
    {
        let bpm = s * 60.0
        return bpm
    }
}


struct SimpleSliderView : View
{
    var value: Double
//    @Binding var value:Double
    
    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View
    {
        ZStack
        {
            colors.randomElement()!
            Rectangle().fill(Color.red)
                .frame(width: 200 * value)
            Text("This should obviously redraw")
        }
        .frame(width:200, height: 100 )
    }
}

struct StaticView: View {
    let text:String

    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View {
        ZStack {
            colors.randomElement()!
            Text(text)
        }
    }
}

struct StaticText: View
{
    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View {
        Text("Why does the background Redraw?")
            .foregroundStyle(colors.randomElement()!)
    }
}

struct ContentView: View {
    let clock = BPMClock()
//    @State var clock = BPMClock()
//    @Bindable var clock = BPMClock()
    
    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View {
        ZStack
        {
            colors.randomElement()!

            VStack {
                
                Text("Why do I change color?")
                    .foregroundStyle(colors.randomElement()!)
                
                StaticText()
                
                StaticView(text: "Start Clock \n (this does not redraw)")
                    .frame(width: 100, height: 100)
                    .onTapGesture {
                        self.clock.startTimer()
                    }
                
                StaticView(text: "I should not redraw")
                    .frame(width: 100, height: 100)
                
                SimpleSliderView(value: clock.timeValue)
                    .frame(height: 100)
            }
            .frame(width: 400, height: 400)
        }
    }
}

#Preview {
    ContentView()
}

80
1

Ответ:

Решено

Правильная формулировка стека представлений приведена ниже.

Решение состоит в том, что текущее представление фактически обращается к динамически изменяющемуся свойству ( clock.timeValue) при передаче инициализатору подпредставления. Это не «изолировано», его видит макрос @Observable, и весь ContentView помечен как «грязный».

Решение состоит в том, чтобы StaticSlider принимал в качестве экземпляра часы, а не двойное значение.

Фу

Вот слегка очищенное решение, работающее как положено.

import SwiftUI


public func remap(input:Double, inMin:Double, inMax:Double, outMin:Double, outMax:Double) -> Double
{
    return ((input - inMin) / (inMax - inMin) * (outMax - outMin)) + outMin;
}

public func remap(input:Float, inMin:Float, inMax:Float, outMin:Float, outMax:Float) -> Float
{
    return ((input - inMin) / (inMax - inMin) * (outMax - outMin)) + outMin;
}


@Observable class BPMClock
{
    
    var bpm:Double = 60.0
    var timeValue:Double = 0.0
    
    @ObservationIgnored private var rawTimeValue:Double = 0.0
    @ObservationIgnored private var lastTime:Double = 0.0
    @ObservationIgnored var timer:Timer? = nil

    func startTimer()
    {
        self.timer = Timer.scheduledTimer(timeInterval: 1/60,
                                          target: self,
                                          selector: #selector(calcTimeAbsolute),
                                          userInfo: nil,
                                          repeats: true)
    }
    
    
    @objc func calcTimeAbsolute()
    {
        let now = Date.timeIntervalSinceReferenceDate
       
        self.calcTime(usingNow: now)
    }
    
    func calcTime(usingNow now:Double)
    {
        let delta = now - self.lastTime
        
        self.rawTimeValue = fmod(delta + self.rawTimeValue,  self.bpmToS(self.bpm) )
                                
        self.timeValue = remap(input: self.rawTimeValue, inMin: 0.0, inMax:self.bpmToS(self.bpm), outMin: 0.0, outMax: 1.0)
        self.lastTime = now
    }
    
    private func bpmToS(_ bpm:Double) -> Double
    {
        let bps =  60.0 / bpm
        return bps
    }
    
    private func sToBpm(_ s:Double) -> Double
    {
        let bpm = s * 60.0
        return bpm
    }
}


struct SimpleSliderView : View
{
    @State var clock: BPMClock
//    @Binding var value:Double
    
    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View
    {
        ZStack
        {
            colors.randomElement()!
                
            Rectangle().fill(Color.red)
                .frame(width: 200 * clock.timeValue)
            
            Text("This should obviously redraw")
        }
        .frame(width:200, height: 100 )
    }
}

struct StaticView: View {
    let text:String

    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View {
        ZStack {
            colors.randomElement()!
            Text(text)
        }
    }
}

struct StaticText: View
{
    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View {
        Text("Why do I not change color?")
            .foregroundStyle(colors.randomElement()!)
    }
}


// Doesnt matter if @State or @Bindable, local or global var / let

struct ContentView: View {
    
    @State var clock:BPMClock
    
    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View {
            VStack {
                
                ZStack {
                    colors.randomElement()!
                    Text("Why do I change color?")
                }

                Text("Why do I change color?")
                    .foregroundStyle(colors.randomElement()!)

                StaticText()
                
                StaticView(text: "Start Clock \n (this does not redraw)")
                    .frame(width: 100, height: 100)
                    .onTapGesture {
                        clock.startTimer()
                    }
                
                StaticView(text: "I should not redraw")
                    .frame(width: 100, height: 100)
                
                SimpleSliderView(clock: clock)
                    .frame(height: 100)
            }
            // uncomment me for even more epillepsy
            // .background( colors.randomElement()! )
            .frame(width: 400, height: 400)
        
    }
}

#Preview {
    let bpmClock = BPMClock()
    ContentView(clock: bpmClock)
}