3 способа разделения состояния в SwiftUI, которые вам НЕОБХОДИМО знать 🚀💯

Во многих паттернах архитектуры SwiftUI принято разделять логику и пользовательский интерфейс на небольшие ObservableObjects следующим образом:

import Foundation
import SwiftUI

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        Text(viewModel.text)
    }
}

extension ContentView {
    class ViewModel: ObservableObject {
        @Published var text: String
    } 
}
Вход в полноэкранный режим Выход из полноэкранного режима

И этот метод превосходен, поскольку позволяет разделить этот код на различные файлы, предотвращая необходимость в больших файлах с огромным количеством кода.

Однако существует множество случаев, когда вы хотите получить доступ к свойствам других ViewModel, внешних по отношению к вашему представлению, как в следующем примере:

extension PreferencesView {
    class ViewModel: ObservableObject {
        [...]
        @AppStorage("showFollowersInProfileView")
        var showFollowersInProfileView: Bool = true
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима
extension ProfileView {
    class ViewModel: ObservableObject {
        [...]
        @Published var followers: Int
    }                  
}
Войти в полноэкранный режим Выход из полноэкранного режима
struct ProfileView: View {
    @ObservedObject var viewModel = ViewModel()
    var body: some View {
        [...]
        Text("(viewModel.followers)")
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Как узнать, включена ли настройка показа подписчиков?

Решение #1: Синглтоны

Очень простым решением для этого было бы добавить синглтон к PreferencesView.viewModel, чтобы каждое представление могло получить доступ к общему состоянию:

extension PreferencesView {
    class ViewModel: ObservableObject {
        [...]

        /// A singleton everybody can access to.
        static let shared = ViewModel()

        @AppStorage("showFollowersInProfileView")
        var showFollowersInProfileView: Bool = true
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы сможем получить доступ к предпочтениям в нашем ProfileView.swift следующим образом:

struct ProfileView: View {
    @ObservedObject var viewModel = ViewModel()

    @ObservedObject
    var preferences = PreferencesView.ViewModel.shared

    var body: some View {
        [...]
        if preferences.showFollowersInProfileView {
            Text("(viewModel.followers)")
        }
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

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

ПРИМЕЧАНИЕ: Если вы используете этот подход, то для того, чтобы изменения отражались в других представлениях, вам нужно изменить PreferencesView.ViewModel.shared, а не PreferencesView.viewModel(). Это также относится к PreferencesView.

Однако этот метод следует использовать только в том случае, если к данной ViewModel будут обращаться несколько представлений. Если вы хотите получить доступ к определенному свойству только в нескольких представлениях, вам следует попробовать следующий метод.

Решение №2: Инъекция зависимостей

Из Википедии:

В программной инженерии внедрение зависимостей — это шаблон проектирования, в котором объект или функция получает другие объекты или функции, от которых он зависит. Являясь формой инверсии управления, инъекция зависимостей нацелена на разделение проблем создания объектов и их использования, что приводит к свободно связанным программам.

Проще говоря, внедрение зависимостей означает, что вместо того, чтобы создавать что-то самому, любой написанный вами фрагмент кода должен получить все необходимое для функционирования от родителя. Например, все компоненты, необходимые объекту или методу, могут быть переданы в качестве аргументов.

Допустим, у вас есть VStack с двумя представлениями, и вы хотите, чтобы оба представления имели доступ к одной и той же модели представления.

struct ContentView: View {

    var body: some View {
        TabView {
            FirstView()
                .tabItem {
                    Label("First", systemImage: "house")
                }

            SecondView()
                .tabItem {
                    Label(
                        "Second",
                        systemImage: "square.and.pencil"
                    )
                }
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима
struct FirstView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        VStack {
            Text("(viewModel.count)")
            Button("Increment", action: {
                viewModel.incrementCount()
            })
            Button("Decrement", action: {
                viewModel.decrementCount()
            })
        }
    }
}

extension FirstView {
    class ViewModel: ObservableObject {
        @Published var count: Int = 0

        func incrementCount() {
            count+=1
        }

        func decrementCount() {
            count-=1
        }
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима
struct SecondView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Как бы вы получили доступ к подсчету в SecondView? Довольно просто: вам понадобится ContentView, чтобы инстанцировать модель представления, а затем передать ее предкам через конструктор.

Этого можно добиться следующим образом:

struct ContentView: View {

    // We initialize the view model in the parent view
    @StateObject var viewModel = FirstView.ViewModel()

    var body: some View {
        TabView {
            // And we pass it down the ancestors through the constructor
            FirstView(viewModel: viewModel)
                .tabItem {
                    Label("First", systemImage: "house")
                }

            SecondView(firstViewModel: viewModel)
                .tabItem {
                    Label("Second", systemImage: "square.and.pencil")
                }
        }
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима
struct FirstView: View {
    @StateObject var viewModel: ViewModel

    var body: some View {
        VStack {
            Text("(viewModel.count)")
            Button("Increment", action: {
                viewModel.incrementCount()
            })
            Button("Decrement", action: {
                viewModel.decrementCount()
            })
        }
    }
}

extension FirstView {
    class ViewModel: ObservableObject {
        @Published var count: Int = 0

        func incrementCount() {
            count+=1
        }

        func decrementCount() {
            count-=1
        }
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима
struct SecondView: View {
    @StateObject var firstViewModel: FirstView.ViewModel

    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Заметили, как мы сначала создаем модель представления в родительском представлении, а затем передаем ее вниз по предкам через конструкторы? Таким образом, и FirstView, и SecondView будут иметь общее состояние.

Очевидно, что этот пример — излишество, поскольку мы могли бы исправить это с помощью простых переменных @Binding и @State. Однако, когда все усложняется, мы можем использовать этот подход.

Использование объектов окружения

Хотя пример, показанный ранее, работает правильно, SwiftUI предоставляет нам обертку свойств @EnvironmentObject для данных, которые должны быть разделены между многочисленными представлениями в вашем проекте. Это позволяет нам распространять данные модели везде, где это необходимо, и гарантирует, что наши представления будут постоянно обновляться, как только эти данные изменятся.

Рассмотрите @EnvironmentObject как лучшую, более простую альтернативу использованию @ObservedObject в нескольких представлениях. Вы можете создать некоторые данные в каком-то представлении и поместить их в среду, чтобы остальные представления автоматически получили к ним доступ.

Пример, показанный ранее, можно переписать следующим образом:

struct ContentView: View {

    // We initialize the view model in the parent view
    @StateObject var viewModel = FirstView.ViewModel()

    var body: some View {
        TabView {
            // And we pass it down the ancestors through the constructor
            FirstView()
                .environmentObject(viewModel)
                .tabItem {
                    Label("First", systemImage: "house")
                }

            SecondView()
                .environmentObject(viewModel)
                .tabItem {
                    Label("Second", systemImage: "square.and.pencil")
                }
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима
struct FirstView: View {
    @EnvironmentObject var viewModel: ViewModel

    var body: some View {
        VStack {
            Text("(viewModel.count)")
            Button("Increment", action: {
                viewModel.incrementCount()
            })
            Button("Decrement", action: {
                viewModel.decrementCount()
            })
        }
    }
}

extension FirstView {
    class ViewModel: ObservableObject {
        @Published var count: Int = 0

        func incrementCount() {
            count+=1
        }

        func decrementCount() {
            count-=1
        }
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима
struct SecondView: View {
    @EnvironmentObject var firstViewModel: FirstView.ViewModel

    var body: some View {
        Text("(firstViewModel.count)")
            .padding()
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Если вы запустите приложение, то заметите, что оно ведет себя точно так же, как и в предыдущем примере.

Решение #3: Хранилища (вложенные наблюдаемые объекты)

Последним решением является внедрение Store в наше приложение. Заимствованное из Redux, хранилище — это неизменяемое дерево объектов, которое отвечает за поддержание состояния приложения.

Хотя хранилища в Redux немного отличаются, мы можем реализовать похожее поведение в SwiftUI с помощью вложенных наблюдаемых объектов.

Мы начнем с создания нашего объекта Store, как показано ниже, где у нас есть модель представления FirstView в качестве опубликованного свойства:

final class Store: ObservableObject {
    @Published var firstViewModel = FirstView.ViewModel()
}
Вход в полноэкранный режим Выйти из полноэкранного режима

И мы инициализируем ее в нашем приложении и передаем ее вниз по представлениям-предкам через .environmentObject:

import SwiftUI

@main
struct ExampleApp: App {
    @StateObject var store = Store()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(store)
        }
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

В качестве альтернативы мы можем инициализировать его в нашем ContentView, хотя я предпочитаю делать это в нашем приложении, потому что таким образом мы гарантируем, что существует только один единственный Store.

Итак, это должен быть наш ContentView:

struct ContentView: View {

    var body: some View {
        TabView {
            FirstView()
                .tabItem {
                    Label("First", systemImage: "house")
                }

            SecondView()
                .tabItem {
                    Label(
                        "Second",
                        systemImage: "square.and.pencil"
                    )
                }
        }
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Заметили, что нам больше не нужно добавлять .environmentObject к каждому из представлений? Поскольку мы инициализируем объект в нашем главном приложении, каждое представление-предок будет иметь к нему доступ, что является одной из основных причин, почему я предпочитаю объекты окружения, а не обычную инъекцию зависимостей.

struct FirstView: View {
    @EnvironmentObject var store: Store

    var body: some View {
        VStack {
            Text("(store.firstViewModel.count)")
            Button("Increment", action: {
                store.firstViewModel.incrementCount()
            })
            Button("Decrement", action: {
                store.firstViewModel.decrementCount()
            })
        }
    }
}

extension FirstView {
    class ViewModel: ObservableObject {
        @Published var count: Int = 0

        func incrementCount() {
            count+=1
        }

        func decrementCount() {
            count-=1
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима
struct SecondView: View {
    @EnvironmentObject var store: Store

    var body: some View {
        Text("(store.firstViewModel.count)")
            .padding()
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Причина в том, что SwiftUI перерисовывает пользовательский интерфейс только тогда, когда обнаруживает изменение в одной из своих переменных состояния и объектов состояния/наблюдения. Как SwiftUI определяет, когда одна из переменных @Published изменяется внутри наблюдаемого объекта? Довольно просто: благодаря objectWillChange, который, согласно Apple, является издателем, который испускается до того, как объект изменился.

Вы, вероятно, уже знаете, что такое издатель, если когда-либо использовали фреймворк Apple Combine. Если нет, то издатель — это, говоря коротко, нечто, что раскрывает значения, которые могут изменяться, и на что подписывается подписчик, чтобы получать все эти обновления.

Этот издатель сообщает SwiftUI, когда наблюдаемый/состояние объекта изменилось, и, таким образом, он может знать, когда перерисовывать пользовательский интерфейс. Когда вы изменяете переменную @Published, она под капотом вызывает этого издателя objectWillChange.

Однако, когда наблюдаемый объект изменяется внутри другого наблюдаемого объекта, objectWillChange родительского объекта не срабатывает, вместо этого он срабатывает только в дочернем, и в результате SwiftUI не знает, когда обновлять наш интерфейс.

К счастью, это можно легко исправить: все, что нам нужно сделать, это подписаться на этого издателя внутри нашего магазина и вручную вызвать издателя objectWillChange магазина.

Этого можно добиться с помощью Combine следующим образом:

import Combine

final class Store: ObservableObject {
    @Published var firstViewModel = FirstView.ViewModel()

    private var anyCancellable: AnyCancellable? = nil

    init() {
        anyCancellable = firstViewModel.objectWillChange.sink { [weak self] _ in
            self?.objectWillChange.send()
        }
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь, если вы запустите приложение, вы увидите, что оно снова работает правильно.

Заключение

В этой статье вы рассмотрели несколько способов разделения состояния между представлениями в SwiftUI.

Надеюсь, эти приемы помогут вам сделать кодовую базу более чистой и организованной. Спасибо за чтение!

Оцените статью
devanswers.ru
Добавить комментарий