574873073922f4791326fe1485682cd9
SwiftUI 数据流

Session 213 - Building Custom Views with SwiftUI
作者:CoderAFI
校对:冬瓜四娘

SwiftUI 数据流


前言


SwiftUI 的推出,为开发者带来了声明式的界面编程语言,振奋人心,目前比较流行的前端框架如 ReactVueFlutter 也都是采用了类似的界面编码方式。

声明式界面编程语言的好处就是将开发者从视图和数据的状态同步中解放出来,开发者只需要保证数据的业务逻辑表现正常即可,SwiftUI 提供了一整套的数据流工具和运转规范,以保障数据的业务逻辑流程不会混乱,并且能够快速排查问题。

数据流


SwiftUI 是一种数据驱动型的界面框架,它能够完美的与数据结合,在这里所指的数据包含以下两种:

  • 与 View 所对应的数据,可以理解为 View Object
  • 与数据结构相对应的 Model,如 本地存储、网络数据等

SwiftUI 提供了以下五个数据流工具来建立数据和视图的依赖关系:

现在大家可能不知道这些工具怎么用、什么时候用。不过没关系,下面会逐一介绍。

数据流转原则

在介绍这些工具之前,首先要明确下 SwiftUI 中数据流处理数据的基本原则

  • Data Access as a Dependency

    在 SwiftUI 中数据一旦被使用就会成为视图的依赖,也就是说当数据发生变化了,视图展示也会跟随变化,不会像 MVC 模式下那样要不停的同步数据和视图之间的状态变化

  • A Single Source Of Truth

    保持单一数据源,在 SwiftUI 中不同视图之间如果要访问同样的数据,不需要各自持有数据,直接共用一个数据源即可,这样做的好处是无需手动处理视图和数据的同步,当数据源发生变化时会自动更新与该数据有依赖关系的视图

下面以播客的播放器为例,来看下数据流工具的使用方法。

Property 和 @State

首先,实现一个博客播放器界面,代码和效果如下:

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)
            Text(episode.showTitle).font(.caption).foregroundColor(.gray)
            Button(action: {
                print("Hello WWDC 2019")
            }) {
                Image(systemName: isPlaying ? "pause.circle" : "play.circle")
            }
        }
    }
}

上述代码中 isPlaying 作为一个属性 (Property)ButtonImage 属性访问形成依赖关系,用来控制按钮的播放状态,在 SwiftUI 中使用属性(Property)就是这么简单。

现在,我想在按钮点击的时候动态改变 isPlaying 的值,按正常逻辑来说,直接在将 print("Hello WWDC 2019") 替换为 self.isPlaying.toggle() 即可,但由于在 SwiftUIView 都是不可变得 Struct 结构类型,按照 Swift 语法如果修改 isPlaying 的值是不允许的,编译器会报如下错误:

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

正确的做法那就是要用 @State 修饰器来修饰 isPlaying 属性,代码如下:

import SwiftUI

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

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

        @State
    var isPlaying: Bool = false

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

在上述代码中,我们首先将 isPlaying 属性用 @State 修饰器包装起来,然后在 Button 点击之后调用的 Closure 里,动态的改变 isPlaying 的值。这样就做到了属性改变,视图同步刷新,开发者不需要关心数据和视图的状态同步工作,只需要关心数据的获取以及逻辑处理,使用起来非常简单,大大提高了开发效率,爽!

喜欢研究底层的大神,这时候肯定在想 @State 到底是怎么实现的?那么让我们来看下 State 的源代码定义,如下:

/// A linked View property that instantiates a persistent state
/// value of type `Value`, allowing the view to read and update its
/// value.
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@propertyWrapper public struct State<Value> : DynamicViewProperty, BindingConvertible {

    /// Initialize with the provided initial value.
    public init(initialValue value: Value)

    /// The current state value.
    public var value: Value { get nonmutating set }

    /// Returns a binding referencing the state value.
    public var binding: Binding<Value> { get }

    /// Produces the binding referencing this state value
    public var delegateValue: Binding<Value> { get }

    /// Produces the binding referencing this state value
    /// TODO: old name for storageValue, to be removed
    public var storageValue: Binding<Value> { get }
}

通过上述代码,发现 @State 其实是使用 Swift 5.1 的新特性 Property Wrapper来实现的一种属性装饰语法糖(修饰器/装饰器),内部实现大概就是在属性 Get、Set 的时候将一部分可重用的代码包装起来,避免大面积重复代码的出现,这种语法糖其实在 JavaC#TS 等很多语言中都有实现,著名的网络库 Retrofit 网络库也是用了这种语法特点,相信 Swift 很快也会有类似的库出现。

现在让我们把上述示例的 Button 按钮抽取成一个单独的视图组件 PlayerButton,代码如下:

import SwiftUI

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

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)
            Text(episode.showTitle)
                .font(.caption)
                .foregroundColor(.gray)
            PlayerButton(isPlaying: isPlaying)
        }
    }
}

struct PlayerButton : View {
    @State
    var isPlaying: Bool

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

在上面的代码中,这种写法虽然能够实现功能,但是这样写并不符合单一数据源 (A Single Source Of Truth) 的数据处理原则, isPlaying 在父子视图中分别被 @State 修饰,那么他们就分别持有各自的数据源,为了界面显示正常,就必须要手动保持两份数据源的状态同步,例如我现在将父视图 PlayerView 修改为如下代码:

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)
        }
    }
}

这里的第一个 Title Text 的颜色依赖了 isPlaying 属性,但是 isPlaying 属性的是在 PlayButton 子视图中改变的, 要想保持父子视图的数据源状态是同步的,就要使用 @Binding 修饰器,修改后的 PlayButton 代码如下:

struct PlayerButton : View {

    @Binding
    var isPlaying: Bool

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

同时在 PlayView 中在 isPlaying 属性前添加 $ 符号,表示这是一个双向依赖关系,代码修改如下:

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)
        }
    }
}

@Binding 主要是用在父子视图之间数据双向同步的时候,提供如下两个功能:

  • 在不持有数据源的情况下,任意读取
  • @State 中获取数据应用,并保持同步

剖析视图更新机制

通过上面的阐述,我们大概了解到 @State 可以帮助开发者保持视图和数据的状态同步,那么 SwiftUI 是怎么做到这一点的呢?这里我们从 @State 为编译器生成伪代码入手,伪代码如下:

var $isPlaying: Bool = false

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

可以看出 @State 内部其实就是在 Get 方法中建立视图与数据的依赖关系并返回一个当前数据的引用,方便视图获取,然后在 Set 方法中监听到数据发生变化、主动通知 SwiftUI 重新获取视图的 body 属性, 再通过 Function Builders 方法内部重新构建视图 DSL,从而再次触发界面绘制,在绘制界面的过程中会比对每个视图依赖的数据引用,如果发生了变化,则更新相对应的视图,这样做避免全局重绘,流程图如下:

数据流规范

从上面的示例,我们可以提炼出一些对 SwiftUI View 的理解,那就是:

视图不再是一系列操作事件而是数据的函数式表现 (View are a function of State, not of a sequence of events)

通过这种编程思想的改变,SwiftUI 帮助你管理各种复杂的界面和数据的处理,开发者只需要关注数据的业务逻辑即可,但是要想管理好业务数据,还得要遵循数据的流转规范才可以,SwiftUI为我们提供了一个官方的数据流结构图,如下:

从上图可以看出,在 SwiftUI 种数据的流转过程如下:

  • 用户对界面进行操作,产生一个操作行为 action
  • 该行为触发数据改变,并通过 @State 对数据源进行包装
  • @State 检测到数据变化,触发视图重绘
  • SwiftUI 内部按需更新视图,最终再次呈现给用户,等待下次界面操作

以上就是 SwiftUI 的数据流规范,这里要注意的是每个数据流转都是单向、互相隔离的,无论应用程序的逻辑变得多么复杂,内部应该是由无数个这样的单向数据流组合而成,每个数据流都遵循相应的规范,这样开发者在排查问题的时候,不需要再去找所有与该数据相关的界面进行排查,只需要找到相应逻辑下的数据流,分析数据在流程中运转是否正常即可,大大降低了开发者排查问题的难度。

下面列出了 FluxReduxVuex 的数据流模型图,大家可以对比学习:

Flux 数据流

Redux 数据流

Vuex 数据流

在这里笔者认为 SwiftUI 的数据流与 Flux/Redux 的非常相似,当然 Vuex 的做法也是可以实现的。

抛弃 MVC 模型

我们先不评论哪个数据流处理方式更好,先来看看与之前 UIKit 或者 AppKit 中的 MVC 编码方式相比 SwiftUI 有哪些优势。

MVC 模式下,我们都是用 ViewController 来完成各种复杂界面的绘制,然后处理视图被操作后的 target action 或者 delegate,同时还要监听数据变化,手动同步数据到相应的视图,所以当应用程序变复杂后, ViewController 就会变得膨胀、臃肿,这里面 ViewController 很大一部分工作就是在处理数据和视图之间的状态同步,如下图:

ViewController 所承载的界面越多,相对应的业务逻辑就越复杂,不同视图之间的状态同步也会越复杂,最终 MVC 模型在大型项目中的代码结构就会变成下图这样:

这样的代码在外人看来,可以形象的理解为下图 😄,维护这种项目的人每天的心情是可想而知的。

但在 SwiftUI 中,开发者只需要构建一个视图可依赖的数据源,保持数据的单向有序流转即可,其他数据和视图的状态同步问题 SwiftUI 帮你管理,所以 ViewController 在这里也就不需要了,再也不要提什么 ViewController 瘦身了,看看人家 SwiftUI 做的多彻底,😄

SwiftUI 的界面不再像 UIKit 那样,用 ViewController 承载各种 UIVew 控件,而是一切皆 View,所以可以把 View 切分成各种细粒度的组件,然后通过组合的方式拼装成最终的界面,这种视图的拼装方式大大提高了界面开发的灵活性和复用性,视图组件化并任意组合的方式是 SwiftUI 官方非常鼓励的做法。

外部数据


前面我们已经介绍完了 Property@State@Binding 的使用方法,并给出了数据在 SwiftUI 中的流转规范,但是这些都是 View 内部的数据,在应用开发中,还经常要处理很多外部的数据变化,那么这些数据是怎样与 SwiftUI 进行数据交互的呢?

这里所谓的外部数据分为三类:

  • 系统级的消息
  • 网络或本地存储的数据结构
  • 界面之间互相传递的数据

处理这些外部数据,SwiftUI 为我们提供了 Publisher@ObjectBinding@EnvironmentObject 三个工具,它们更新视图的机制跟 @State 是一样的,这里不再赘述,下面逐个讲解下其使用方法:

Publisher

SwiftUI 中可以用 onReceive(Publisher) 方法,来接受外部的系统消息如:通知、定时器等,然后形成数据流 Action 进而改变 State 最终更新视图,流程图和代码如下:

struct PlayerView : View {
        let episode: Episode
        @State private var isPlaying: Bool = true
        @State private var currentTime: TimeInterval = 0.0
        var body: some View {
                VStack { // ...
                    Text("\(playhead, formatter: currentTimeFormatter)")
                }.onReceive(PodcastPlayer.currentTimePublisher) { newCurrentTime in
                        self.currentTime = newCurrentTime
                }
        }
}

@ObjectBinding

在应用开发过程中,很多数据其实并不是在 View 内部产生的,这些数据有可能是一些本地存储的数据,也有可能是网络请求的数据,这些数据默认是与 SwiftUI 没有依赖关系的,要想建立依赖关系就要用 @ObjectBinding 修饰器,同时数据结构也要遵循 BindableObject 的协议,大体使用方法如下:

class PodcastPlayerStore : BindableObject {

        var didChange = PassthroughSubject<Void, Never>()
        // ...

        func advance() {
        currentEpisode = nextEpisode
        currentTime = 0.0
        // Notify subscribers that the player changed
            didChange.send()
        }
}

struct MyView :View {
        @ObjectBinding var store: PodcastPlayerStore ...
}

这里有个误区大家一定要注意,虽然提到外部数据要用 @ObjectBinding 来建立依赖关系,但这并不是说,只要是有网络请求的数据结构都要用 @ObjectBinding,网络请求的数据也可以直接转化为 View 内部的数据,直接用 @State 修饰,在这里 @ObjectBinding 只是提供了一种 SwiftUI 与 外部数据交互的方式,这种方式是可选的,如果开发者想把获取到数据转变成 View 外部的数据结构,那可以使用 @ObjectBinding ,如果想拿到 View 内部来直接使用也是可以的。

@EnvironmentObject

@EnvironmentObject 主要是为了解决跨组件数据传递的问题,业务复杂了之后,有时候会把组件切的很碎,如下图:

这时候组件层级嵌套太深,就会出现数据逐层级传递的问题, @EnvironmentObject 就可以帮助组件快速访问全局数据,避免不必要的组件数据传递问题,如下图:

案例实战

上面的关于 SwiftUI 数据流的理论知识基本讲完了,但是 代码是敲出来,所以我们还需要手动实战一下。

笔者分析了目前最常用的前端编程范式,大概有如下三种:

  • MVC/MVP
  • Reactive Programming
  • MVVM
  • Unidirectional Data Flow (Flux/Redux/Vuex)
top Created with Sketch.