(二)SwiftUI - 数据流

1,869 阅读8分钟

SwiftUI 数据流

随着 SwiftUI 新的 UI 框架的到来,在数据管理方面有了新的典范和工具,例如 @State@BindingObservedObject。在讲解 SwiftUI 提供的这些属性标志符之前,最好要理解管理数据方式改变的原因,以及数据管理方式的改变到底要解决什么样的问题,然后再学习这些标志符才更有意义。

UIKit 框架伴随开发人员走过了10多年风雨,在使用 UIKit 开发时,需要手动维护视图-数据的同步,随着业务的增加,维护视图-数据的依赖关系是一个不小的工作量,如果没有一套合理的管理方式,便会出现视图-数据不同步的问题。虽然可以使用通知、KVO 等技术保持数据和视图的同步,但这并不是好的方式, UIKit 并没有提供技术从根本解决这些问题。UIKit 本身是就是命令式的开发方式,需要 controller 处理各种事件,协调 View、Model 之间的同步,于是非常容易出现臃肿的 controller 的情况。

SwiftUI 在设计时便考虑到这些因素,并且从根本上解决了这些问题,所以在后面使用 SwiftUI 数据管理工具的时候,会发现更易使用、更能切中要害。

数据是驱动界面的所有信息, SwiftUI 制定了2条数据流转的原则:

  • Data Access as a Dependency :数据一旦被 View 使用,就会与 View 形成依赖,当数据变动 SwiftUI 会自动同步更新 View。
  • A single source of truth :单一数据源原则,视图中的数据源只能有一个。

视图层级结构上的数据只有一个数据源,而且应该如此。数据可以来自视图内部,例如按钮的高亮状态,也可以来自外部,例如登录界面的用户数据。其实不管数据来自哪里,应该只有一个数据源,因为多份数据可能导致数据之间不同步。大家可能都有体会,当多个视图有多份数据的时候,要维护数据之间的信息同步其实很麻烦。

维护两份数据源的例子,Feed 列表和 Feed 详情页使用不同数据。详情页的收藏状态变化后,Feed 列表没有同步变化。

在 SwiftUI 中我们可以把数据变换分成两类,内部、外部变化。先来看一下内部数据,例如下面播放按钮的状态、列表中过滤收藏开关的状态,这些数据只需要保存在 View 内部就可以了。

以播客的播放器为例子,看一下数据流工具的用法

@State

@State 是为 View 内部使用而设计的,Apple 推荐其和 private 一起使用,用来强调 @State 属性只由 View 内部使用。@State 变量的内存有 SwiftUI 管理,框架会为变量分配一块内存,并且当变量变化的时候,框架会重新渲染视图。

首先实现一个下面的播放界面,

代码如下:

import SwiftUI

struct Episode {
    var title: String
    var showTitle: String
}

struct PlayerView : View {
    let episode = Episode(title: "WWDC 2019", showTitle: "Data Flow Throght SwiftUI")

    private var isPlaying: Bool = false

    var body: some View {
        VStack {
            Text(episode.title).foregroundColor(isPlaying ? .white: .gray)
            Text(episode.showTitle).font(.caption).foregroundColor(.gray)
            Button(action: {
                print("Hello WWDC 2019")
            }) {
                Image(systemName: isPlaying ? "pause.circle" : "play.circle")
            }
        }
    }
}

我们不能直接拿到控件修改 UI,只能修改属性间接改变 UI。上面代码中,isPlaying 与 Text 访问形成依赖关系,控制标题的颜色,此外还与按钮颜色有依赖,控制播放按钮的图标。如果想点击按钮修改 isPlaying 的值,在按钮点击事件里直接写上 self.isPlaying.toggle() 不就可以了吗?由于 PlayerView 是结构体,按照 Swift 的语法,结构体的属性不能修改,否则会报错:

Cannot use mutating member on immutable value: 'self' is immutable

正确的做法是在 isPlaying 上添加 @State,

struct PlayerView : View {
    let episode = Episode(title: "WWDC 2019", showTitle: "Data Flow")
    @State private var isPlaying: Bool = false  // 修改

    var body: some View {
        VStack {
            Text(episode.title).foregroundColor(isPlaying ? .white: .gray)
            Text(episode.showTitle).font(.caption).foregroundColor(.gray)
            Button(action: {
                self.isPlaying.toggle() // 修改
            }) {
                Image(systemName: isPlaying ? "pause" : "play")
            }
        }
    }
}

@State 的作用域是本View,和 private 一起用。修改 @State 属性会刷新界面。

@Binding (共享绑定)

@Binding 是用在父子控件同步数据的时候使用。如果我们把播放按钮抽取成一个单独的控件,可把代码修改成下面这样

struct PlayerView : View {
    let episode = Episode(title: "WWDC 2019", showTitle: "Data Flow Throght SwiftUI")
    @State private var isPlaying: Bool = false

    var body: some View {
        VStack {
            Text(episode.title).foregroundColor(isPlaying ? .blue : .red)
            Text(episode.showTitle).font(.caption).foregroundColor(.gray)
            PlayerButton(isPlaying: $isPlaying)
        }
    }
}

struct PlayerButton : View {
    @Binding var isPlaying: Bool

    var body: some View {
        return Button(action: {
            self.isPlaying.toggle()
        }) {
            Image(systemName: isPlaying ? "pause.circle" : "play.circle")
        }
    }
}

创建 PlayerButton 的时候,isPlaying 前用 $ 表示传递一个依赖。@Binding 用来显示定义一个依赖、并且不持有父控件数据。对于用 @Binding 标记的任何变量,你可以对其进行读写操作。当子控件的 @Binding 属性修改时,父控件的 UI 都会更新。

从上面的例子,可以看出 SwiftUI 在管理数据和 UIKit 有很大的改变,我们只需要遵守 SwiftUI 的数据管理原则就可以管理好数据,框架会做好数据-视图的同步,开发者只需要关注业务逻辑。

上图可以看出 SwiftUI 事件、数据的流转过程。SwiftUI 不再需要 ViewController 承载 View 控件,处理各种事件。

目前,我们只谈到了 View 内部的数据变化,@State 修饰 View 内部、私有的属性,@Binding 可以声明对 @State 属性的依赖。

@ObservedObject

@State 只是标记视图内部的状态,通常情况下,UI 和业务代码、数据是分开的,这个时候想把外部数据传递到视图上,就可以用 ObservableObject

使用的时候需要做3件事:

  • 数据必须是遵守 ObservableObject 的 class 类型。
  • 在自定义的数据模型中,对于改变后需要刷新 UI 的属性用 @Published 标记。
  • 在 view 内部用 @ObservedObject 符号修饰对象实例。

例子:

class UserSettings: ObservableObject {
    @Published var score = 0
}

上述 model 中代码不多,这是因为 SwiftUI 为我们做了很多事。

  • 遵守 ObservableObject 协议后,在实例的属性发生变化后能让 view 刷新。
  • @Published 是告诉 SwiftUI,score 属性发生变化可以触发 view 刷新。

在 view 里使用:

struct ContentView: View {
    @ObservedObject var settings: UserSettings
    
    var body: some View {
        VStack {
            Text("Your score is \(settings.score)")
            Button(action: {
                self.settings.score += 1
            }) {
                Text("Increase Score")
            }
        }
    }
}

@EnvironmentObject

当我们需要把 @ObservedObject 的属性传给很多个子视图,或者要访问的数据离当前 view 很远,一个个传很不实际,这就需要用到 @EnvironmentObject 了。

EnvironmentObject 类似一个全局的 @State@EnvironmentObject 数据的作用域是整个 View 的层级结构,可以在 View、或子视图任何地方用 @EnvironmentObject 访问数据。

例子:

class GlobalState: ObservableObject {
    @Published var currentTopic: String = "Default GlobalState"
}

struct EnvironmentView: View {
    @EnvironmentObject var globalState: GlobalState
    
    var body: some View {
        VStack {
            Text("EnvironmentView")
            Text(globalState.currentTopic)
            
            HStack {
                EnvironmentOneView()
                EnvironmentTwoView()
            }
        }
    }
}

struct EnvironmentView_Previews: PreviewProvider {
    static var previews: some View {
        EnvironmentView().environmentObject(GlobalState())
    }
}



struct EnvironmentOneView: View {
    @EnvironmentObject var g: GlobalState
    
    var body: some View {
        VStack {
            Text("EnvironmentOneView")
            Text(g.currentTopic)
        }
    }
}

struct EnvironmentTwoView: View {
    @EnvironmentObject var globalState: GlobalState
    
    var body: some View {
        VStack {
            Text("EnvironmentTwoView")
            Text(globalState.currentTopic)
            Button("changeTopic") {
                globalState.currentTopic = "Changed Topic"
            }
        }
    }
}

@StateObject

这是一个在2020年 WWDC 新加的一个修饰符。为什么要新加这个修饰符呢?

@State 只能修饰基本值类型,对于稍微复杂的、需要定义为 class 类型的内部数据,只能用 @ObservedObject 修饰,@ObservedObject 属性是存在 View 中的,会随着 View 的创建被多次创建,有一些情况 View 释放会导致 @ObservedObject 数据丢失。

@StateObject 是 @State 的 class 版本,可以用来修饰对象,@State 与 @StateObject 属性的生命周期都是由 SwiftUI 接管。@StateObject 的出现刚好解使用 @ObservedObject 导致决数据丢失的问题。

看一个 @ObservedObject 导致数据丢失的例子:

struct StateObjectView: View {
    @State var showName: Bool = false
    
    var body: some View {
        VStack {
            Button("name:\(showName ? "Jaly" : "Null")") {
                showName.toggle()
            }
            StateSubview().padding()
        }
    }
}

struct StateSubview: View {
    @ObservedObject var settings: UserSettings = UserSettings()
    
    var body: some View {
        VStack {
            Text("Your score is \(settings.score)")
            Button(action: {
                self.settings.score += 1
            }) {
                Text("Increase Score")
            }
        }
    }
}

点击名字按钮可以显示、隐藏姓名,点击 Increase Score 可以增加分数,现在都没有问题,最后点击 name 隐藏姓名后,分数确消失了。这是因为点击 name 按钮后,View 刷新导致 StateSubview 重新创建,StateSubview 内的 settings 由 View 管理内存, settings 在每次刷新会生成新的数据,导致之前的数据丢失。使用 @StateObject 修饰 settings 便可解决这个问题。

从上面例子中可以看到 @StateObject、@ObservedObject 在内存管理方面的差异,具体使用哪个修饰符还要根据具体业务场景决定。

总结

管理工具特性

  • @State
    • 值改变后,会 reload View 显示最新信息;
    • 内存由 SwiftUI 管理,只要 View 存在属性便会在内存中保留;
    • 只能用于基本数据类型、结构体等值传递的类型,不能用于引用类型;
    • 私有,只能本 View 内使用;
  • @Binding
    • 父子视图之间进行数据源共享,双向绑定,一般只接受处理值类型
  • ObservedObject
    • 使用外部的 class 类型(遵守 ObservableObject 协议)而不能用基本类型;
    • View 自己管理内存;
    • 非私有,多个 View 间共享数据;
  • StateObject
    • @State 的 class 版本,内存由 SwiftUI 管理,可看做 @State 和 @ObservedObject 的结合体
  • @EnvironmentObject
    • 全局的数据绑定机制,View 的层级结构中可以随意访问绑定数据

以 @State 为例,看一下 SwiftUI 数据管理工具的实现。 SwiftUI 用 @State 来维护状态,状态改变后,会自动更新 UI。类似的语法还有 @Binding,@@Environment 等。

State 定义:

@propertyWrapper
public struct State<Value> : DynamicProperty {
    public init(wrappedValue value: Value)
    public init(initialValue value: Value)
    public var wrappedValue: Value { get nonmutating set }
    public var projectedValue: Binding<Value> { get }
}

@State 是一个 @propertyWrapper 修饰的结构体,@State 修饰的属性,会转换成下面这样的伪代码:

@State private var isPlaying: Bool = false

// 转换为:

var $isPlaying: Bool = false
public var isPlaying: Bool {
    get {
                ...
        createDependency(view, value) // 建立视图与数据依赖关系
        return $text.value // 返回一个引用
    }
    set {
        $text.value = newValule
        notify(to: swiftui) // 通知 SwiftUI 数据有变化
              ...
    }
}

在获取数据的时候建立视图与数据的依赖,在 @State 属性变化后,能够触发 View 重新绘制。

@propertyWrapper 是 Swift 5.1 的一个特性,后面会单独讲解。

本文 demo