SwiftUI 程序中管理视图级状态

3,934 阅读7分钟

SwiftUI 与 Apple 之前的 UI 框架的区别不仅在于如何定义视图和其他 UI 组件,还在于如何在使用它的应用程序中管理视图级状态。

SwiftUI 不使用委托、数据源或 UIKit 和 AppKit 等命令式框架中常见的任何其他状态管理模式,而是附带了一些[属性包装器],使我们能够准确地声明如何观察、呈现和处理数据。因我们的观点而改变。

本周,让我们仔细看看每个属性包装器,它们如何相互关联,以及它们如何构成 SwiftUI 整体状态管理系统的不同部分。

[状态属性]

由于 SwiftUI 主要是一个UI 框架(尽管它也开始获得用于定义更高级别构造的 API,例如[应用程序和场景]),因此它的声明式设计不一定需要影响应用程序的整个模型和数据层 - 但是而只是与我们的各种观点直接相关的状态。

例如,假设我们正在开发一个允许用户通过输入 a和一个地址SignupView在应用程序中注册新帐户的应用程序。然后,我们将使用这两个值来形成一个模型,该模型被传递给一个闭包——为我们提供三个状态:username``email``User``handler

struct SignupView: View { 
var handler: (User) -> Void 
var username = "" 
var email = ""
var body: some View { ... } }

由于这三个属性中只有两个 -usernameemail- 实际上将由我们的视图进行修改,并且由于这两个状态可以保持私有,因此我们将使用 SwiftUI 的State属性包装器来标记它们 - 像这样:

struct SignupView: View {
    var handler: (User) -> Void
    
    @State private var username = ""
    @State private var email = ""

    var body: some View {
        ...
    }
}

这样做会自动在这两个值和我们的视图本身之间创建连接 - 这意味着每次这两个值中的任何一个发生更改时,我们的视图都会重新渲染。在我们的 中body,我们将这两个属性中的每一个绑定TextField到相应的属性,以使它们可供用户编辑 - 为我们提供以下实现:

struct SignupView: View {
    var handler: (User) -> Void

    @State private var username = ""
    @State private var email = ""

    var body: some View {
        VStack {
            TextField("Username", text: $username)
            TextField("Email", text: $email)
            Button(
                action: {
                    self.handler(User(
                        username: self.username,
                        email: self.email
                    ))
                },
                label: { Text("Sign up") }
            )
        }
        .padding()
    }
}

SoState用于表示 SwiftUI 视图的内部状态,并在该状态更改时自动更新视图。State因此,保留-wrapped 属性通常是一个好主意private,这确保它们只会在该视图的主体内发生变化(尝试在其他地方改变它们实际上会导致运行时崩溃)。

[双向绑定]

查看上面的代码示例,我们将每个属性传递给它们的方式TextField是在这些属性名称前面加上$. 这是因为我们不仅仅是将普通String值传递到这些文本字段中,而是绑定到我们的State-wrapped 属性本身。

为了更详细地探讨这意味着什么,现在假设我们想要创建一个视图,让我们的用户编辑他们最初在注册时输入的个人资料信息。由于我们现在希望修改外部状态值,而不仅仅是私有状态值,因此这次我们将标记我们的usernameemail属性:Binding

struct ProfileEditingView: View {
    @Binding var username: String
    @Binding var email: String

    var body: some View {
        VStack {
            TextField("Username", text: $username)
            TextField("Email", text: $email)
        }
        .padding()
    }
}

很酷的是,绑定不仅限于单个内置值,例如字符串或整数,还可以用于将任何 Swift 值绑定到我们的视图之一。例如,以下是我们如何将User模型本身传递给ProfileEditingView,而不是传递两个单独的usernameemail值:

struct ProfileEditingView: View {
    @Binding var user: User

    var body: some View {
        VStack {
            TextField("Username", text: $user.username)
            TextField("Email", text: $user.email)
        }
        .padding()
    }
}

就像我们在将StateBinding-wrapped 属性$传递到各种实例时如何为它们添加前缀一样,在将任何值连接到我们自己定义的属性TextField时,我们也可以做完全相同的事情。State``Binding

例如,下面是一个使用-wrapped 属性ProfileView跟踪模型的实现,然后在将上述实例呈现为工作表时将绑定传递给该模型- 这将自动同步用户所做的任何更改该原始属性的值:User``State``ProfileEditingView``State

struct ProfileView: View {
    @State private var user = User.load()
    @State private var isEditingViewShown = false

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Username: ")
                .foregroundColor(.secondary)
                + Text(user.username)
            Text("Email: ")
                .foregroundColor(.secondary)
                + Text(user.email)
            Button(
                action: { self.isEditingViewShown = true },
                label: { Text("Edit") }
            )
        }
        .padding()
        .sheet(isPresented: $isEditingViewShown) {
            VStack {
                ProfileEditingView(user: self.$user)
                Button(
                    action: { self.isEditingViewShown = false },
                    label: { Text("Done") }
                )
            }
        }
    }
}

请注意,我们还可以State通过为包装属性分配一个新值来改变它,就像我们在“完成”按钮的操作处理程序中isEditingViewShown设置的那样。false

因此, -Binding标记的属性在给定视图和在该视图外部定义的状态属性之间提供了双向连接,并且 -State包装Binding的属性可以通过在属性名称前加上 . 前缀作为绑定传递$

[观察物体]

两者StateBinding共同点是它们处理在 SwiftUI 视图层次结构本身内管理的值。然而,虽然当然可以构建一个将其所有状态保留在其各种视图中的应用程序,但就架构和关注点分离而言,这通常不是一个好主意,并且很容易导致我们的视图变得相当庞大和复杂

值得庆幸的是,SwiftUI 还提供了许多机制,使我们能够将外部模型对象连接到我们的各种视图。其中一种机制是ObservableObject协议,当与ObservedObject属性包装器结合使用时,我们可以设置对在视图层外部管理的引用类型的绑定。

作为一个例子,让我们更新ProfileView上面定义的——通过将管理模型的责任User从视图本身转移到一个新的专用对象中。现在,我们可以使用许多不同的隐喻来描述这样的对象,但由于我们希望创建一种类型来控制我们模型之一的实例 - 让我们将其设为符合以下条件的模型控制器  SwiftUI 的ObservableObject协议:

class UserModelController: ObservableObject {
    @Published var user: User
    ...
}

属性Published包装器用于定义对象的哪些属性在修改时应触发观察通知。

有了上面的类型,现在让我们回到我们的ProfileView并让它观察我们的 new 实例UserModelController作为 an ObservedObject,而不是使用State-wrapped 属性来跟踪我们的User模型。真正巧妙的是,我们仍然可以轻松地将模型绑定到我们的ProfileEditingView,就像以前一样,因为ObservedObject-wrapped 属性也可以转换为绑定 - 像这样:

struct ProfileView: View {
    @ObservedObject var userController: UserModelController
    @State private var isEditingViewShown = false

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Username: ")
                .foregroundColor(.secondary)
                + Text(userController.user.username)
            Text("Email: ")
                .foregroundColor(.secondary)
                + Text(userController.user.email)
            Button(
                action: { self.isEditingViewShown = true },
                label: { Text("Edit") }
            )
        }
        .padding()
        .sheet(isPresented: $isEditingViewShown) {
            VStack {
                ProfileEditingView(user: self.$userController.user)
                Button(
                    action: { self.isEditingViewShown = false },
                    label: { Text("Done") }
                )
            }
        }
    }
}

State然而,我们的新实现与之前使用的基于 - 的实现之间的一个重要区别是,我们的UserModelControllernow 需要作为其初始化程序的一部分注入到 our 中。ProfileView

这样做的原因,除了它“迫使”我们在代码库中建立一个更明确定义的依赖关系图之外,还在于标记为 的属性并不意味着对该属性所指向的对象拥有任何形式的ObservedObject所有权。

因此,虽然像下面这样的东西可能在技术上可以编译,但它最终可能会导致运行时问题 - 因为当我们的视图UserModelController在更新期间重新创建时,存储在我们的视图中的实例最终可能会被释放(因为我们的视图现在是它的主要所有者) ):

struct ProfileView: View {
    @ObservedObject var userController = UserModelController.load()
    ...
}

重要的是要记住,SwiftUI 视图不是对屏幕上呈现的实际 UI 组件的引用,而是描述我们的 UI 的轻量级值 - 因此它们不具有与实例等相同类型的生命周期UIView

为了解决上述问题,Apple 在 iOS 14 和 macOS Big Sur 中引入了一个新的属性包装器,称为StateObject. 标记为 的属性的StateObject行为方式与 完全相同ObservedObject- 此外,SwiftUI 将确保存储在此类属性中的任何对象不会意外释放,因为框架在重新渲染视图时重新创建视图的新实例:

struct ProfileView: View {
    @StateObject var userController = UserModelController.load()
    ...
}

尽管从技术上讲,从现在开始可以使用- 我仍然建议在观察外部对象时使用,并且仅在处理视图本身拥有的对象时使用。将and视为相当于and的引用类型,或者强属性和弱属性的 SwiftUI 版本。StateObject``ObservedObject``StateObject``StateObject``ObservedObject``State``Binding

[观察和改变环境]

最后,让我们看一下如何使用 SwiftUI 的环境系统在两个不直接连接的视图之间传递各种状态。虽然在父视图与其子视图之一之间创建绑定通常很容易,但在整个视图层次结构中传递某个对象或值可能相当麻烦——而这正是环境旨在解决的问题类型。

使用 SwiftUI 环境的主要方式有两种。一种是首先在想要检索给EnvironmentObject定对象的视图中定义一个-wrapped 属性- 例如,如何检索包含颜色信息的对象:**ArticleView``Theme

struct ArticleView: View {
    @EnvironmentObject var theme: Theme
    var article: Article

    var body: some View {
        VStack(alignment: .leading) {
            Text(article.title)
                .foregroundColor(theme.titleTextColor)
            Text(article.body)
                .foregroundColor(theme.bodyTextColor)
        }
    }
}

然后,我们必须确保Theme在视图的父级之一中提供环境对象(本例中是一个实例),SwiftUI 将处理其余的事情。这是使用environmentObject修饰符完成的,例如如下所示:

struct RootView: View {
    @ObservedObject var theme: Theme
    @ObservedObject var articleLibrary: ArticleLibrary

    var body: some View {
        ArticleListView(articles: articleLibrary.articles)
            .environmentObject(theme)
    }
}

请注意,我们不需要将上述修饰符应用于将使用环境对象的确切视图 - 我们可以将其应用于层次结构中位于其上方的任何视图。

使用 SwiftUI 环境系统的第二种方法是定义一个自定义EnvironmentKey- 然后可以使用它向内置类型分配值或从内置EnvironmentValues类型检索值:

struct ThemeEnvironmentKey: EnvironmentKey {
    static var defaultValue = Theme.default
}

extension EnvironmentValues {
    var theme: Theme {
        get { self[ThemeEnvironmentKey.self] }
        set { self[ThemeEnvironmentKey.self] = newValue }
    }
}

完成上述操作后,我们现在可以使用属性包装器(而不是)来标记视图的theme属性,并传入我们希望检索其值的环境键的键路径:Environment``EnvironmentObject

struct ArticleView: View {
    @Environment(.theme) var theme: Theme
    var article: Article

    var body: some View {
        VStack(alignment: .leading) {
            Text(article.title)
                .foregroundColor(theme.titleTextColor)
            Text(article.body)
                .foregroundColor(theme.bodyTextColor)
        }
    }
}

上述两种方法之间的一个显着区别是,基于键的方法要求我们在编译时定义一个默认值,而基于EnvironmentObject- 的方法假设将在运行时提供这样的值(如果不这样做将导致碰撞)。

[结论]

SwiftUI 管理状态的方式绝对是该框架最有趣的方面之一,并且可能需要我们稍微重新思考数据在应用程序内传递的方式 - 至少在涉及将直接使用和变异的数据时通过我们的用户界面。