Понимание перерисовки/зависимости 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()
}

Попробуйте избавиться от хэшируемых и равнозначных реализаций. Это всегда вызывает проблемы со SwiftUI. Также лучше использовать TimelineView вместо Timer.

lorem ipsum 26.08.2024 20:03

К вашему сведению, SwiftUI не рисует, это просто структуры данных, которые различаются, и только если есть разница, что-то рисуется (и на самом деле рисование выполняют объекты UIKit)

malhal 26.08.2024 20:22

@malhal - не имеет значения, какая базовая структура выполняет рисунок. AppKit/UIKit может выполнять рисование по трафарету внутри подпунктов представления (грязные прямоугольники) - дело в том, что платформа SwiftUI заставляет отрисовывать представления, которые не должны быть такими, что, насколько я понимаю, противоречит документации.

vade 26.08.2024 20:26

@loremipsum Никакой разницы. Проблема, насколько я могу судить, заключается не в наблюдаемом объекте (в данном случае в часах), а в том, что основное представление контента, имеющее дочерние представления, имеющие зависимости от наблюдения, иногда запускает рисование в других несвязанных дочерних элементах. Кроме того, проблема не в анимации, а в отображении сетевых данных, потоковой передаче данных и данных датчиков. Все это вызовет ненужное рисование вида с вышеуказанной настройкой. Я пытаюсь найти, как избежать этого случая.

vade 26.08.2024 20:28

@vade, я пытаюсь объяснить, что не имеет значения, сколько раз объявляется модель Color View, она рисуется только в том случае, если цвет отличается. При инициализации структур модели View практически отсутствуют накладные расходы, это похоже на инициализацию Int. Если вы хотите повысить производительность, не инициализируйте объекты внутри структур View таким образом let clock = BPMClock() это утечка памяти.

malhal 26.08.2024 20:55

Дело не в том, сколько раз «это объявляется» — я пытаюсь подчеркнуть, что вся структура представления перестраивается/переинициализируется. Это единственная причина, по которой цвет меняется. Я не инициализирую подобные модели, учитывая реальную кодовую базу. Я просто изолировал некоторое поведение, при котором представления перерисовываются без необходимости в соответствии с документацией SwiftUI.

vade 26.08.2024 21:04

Я думаю, дело здесь в том, что два представления с одинаковым контентом ведут себя по-разному. Один определен встроенно, другой нет. Один обновляется, когда другое дочернее представление в другом поддереве получает наблюдаемое изменение свойства. другой нет. Я хочу знать, почему.

vade 26.08.2024 21:09
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
7
80
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 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)
}

Другие вопросы по теме