【WWDC2019 之 SwiftUI】03 - SwiftUI 的数据流

这篇文章我们来看一下在 SwiftUI 中如何将数据作为依赖连接起来,同时保持 UI 的显示是正确并可预测的。这里主要讲解 SwiftUI 中的五个数据流工具:Property@State@Binding@ObjectBinding@EnvironmentObject

数据流工具

Property

Property 是我们目前开发中最常见的,它就是一个简单的属性,没什么特别。例子:

struct ContentView : View {
    var body: some View {
        ChildView(text: "Demo")
    }
}

struct ChildView: View {
    let text: String
    
    var body: some View {
        Text(text)
    }
}

ChildView 需要 Parent View 给它传一个字符串,并且 ChildView 本省不需要对这个字符串进行修改,所以直接定义一个 Property,在使用的时候,直接让 Parent View 告诉它就好了。

@State

我们先看一个官方给的错误例子:

struct PlayerView : View {
    let episode: Episode
    var isPlaying: Bool
    
    var body: some View {
        VStack {
            Text(episode.title)
            Text(episode.showTitle).font(.caption).foregroundColor(.gray)
            
            Button(action: {
                // 错误:Cannot use mutating member on immutable value: 'self' is immutable
                self.isPlaying.toggle()
            }) {
                Image(systemName: isPlaying ? "pause.circle" : "play.circle")
            }
        }
    }
}

上面的代码中,我们想在 Button 被点击后直接使用 self.isPlaying.toggle() 切换 isPlaying 的值,但这是不行的,因为 PlayerView 是 struct 类型,self 是不可变的,并且 isPlaying 是一个普通的属性。为了达到我们的需求,@State 的作用就来了。我们把上面的代码改成:

struct PlayerView : View {
    let episode: Episode
    @State private 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")
            }
        }
    }
}

我们用 @State 标记 isPlaying 属性,这样 isPlaying 就可以在 View 的内部被更改,并且被更改后,与 isPlaying 相关的 View 也会更新,本例中 Image 就会在 pause.circleplay.circle 之间切换。

总结:@State 的作用是让被它标记的属性可以在 View 内部修改,并且 View 也会重新渲染。

@Binding

有时候我们想让 Child View 修改 Parent View 传给它的数据,并且数据修改后,Parent View 重新渲染。这时我们就得用到 @Binding

我们把 @State 例子中的 Button 重构为 PlayButton,代码如下:

struct PlayerView : View {
    let episode: Episode
    @State private var isPlaying: Bool = false
    
    var body: some View {
        VStack {
            Text(episode.title)
            Text(episode.showTitle).font(.caption).foregroundColor(.gray)
            PlayButton(isPlaying: $isPlaying)
        }
    }
}

struct PlayButton : View {
    @Binding var isPlaying: Bool
    
    var body: some View {
        Button(action: {
            self.isPlaying.toggle()
        }) {
            Image(systemName: isPlaying ? "pause.circle" : "play.circle")
        }
    }
}

PlayButton 中,用 @Binding 标记 isPlaying 属性,意味着可以对传入的数据进行修改;在 PlayerView 使用时,传入的属性 isPlaying 需要有 $ 前缀,并且被传入属性不能是普通的属性,而要求是可读可写的属性(被@State / @Binding / @ObjectBinding 标记)。

@Binding 在很多系统自带的 View 中使用,如 ToggleTextFieldSlider 等等。

@ObjectBinding

其实在很多情况下,我们的数据来源于外部的数据模型。我们也想要在当外部数据发生变化时,能及时更新我们的 UI。而 @ObjectBinding 就是为这种需求而设计的。

对于 @ObjectBinding标记的属性,它必须遵循 BindableObject 协议,这个协议的定义如下:

public protocol BindableObject : AnyObject, DynamicViewProperty, Identifiable, _BindableObjectViewProperty {
    associatedtype PublisherType : Publisher where Self.PublisherType.Failure == Never
    var didChange: Self.PublisherType { get }
}

Publisher 是与 SwiftUI 一起推出的响应式编程框架 Combine 的一个协议。所以想要熟练使用 BindableObject, 学习 Combine 是必不可少的。

下面是 @ObjectBinding 的演示代码:

class MyModelObject : BindableObject {
    var didChange = PassthroughSubject<Void, Never>()
    
    func changeData() {
        // 修改数据
        // ...
        
        // 通知订阅者数据发生变化
        didChange.send()
    }
}

struct MyView : View {
    @ObjectBinding var model: MyModelObject
    
    // ...
}

当调用 didChange.send() 之后,MyView 接收到通知,View 就会重新渲染。

@ EnvironmentObject

我们刚刚学习的 Property@Binding 都只能从 Parent View 一层一层的往 Child View 传递。所以当我们的 View 层级关系比较复杂、有些属性只在很深层级的 View 才用到时,用 Property@Binding 的方式就会非常麻烦。苹果使用 @ EnvironmentObject 来解决这个问题。

我们先看一个 demo,然后通过 demo 来讲解 @ EnvironmentObject 的使用。

class MyModelObject : BindableObject {
    var didChange = PassthroughSubject<Void, Never>()
    var count = 0
    
    func updateCount() {
        count += 1
        didChange.send()
    }
}

struct ContentView : View {
    var body: some View {
        RootView().environmentObject(MyModelObject())
    }
}

struct RootView: View {
    var body: some View {
        VStack(spacing: 20) {
            ChildView1()
            ChildView2()
        }
    }
}

struct ChildView1: View {
    @EnvironmentObject var model: MyModelObject

    var body: some View {
        Button(action: {
            self.model.updateCount()
        }) {
            Text("Button")
        }
    }
}

struct ChildView2: View {
    @EnvironmentObject var model: MyModelObject
    
    var body: some View {
        Text("\(model.count)")
    }
}

RootView 包含了 ChildView1ChildView2,两个 Child View 都持有被 @EnvironmentObject 标记的 MyModelObject 类型的属性,当 ChildView1 的按钮被点击时,MyModelObject 的数据被更新,ChildView2 的 View 重新渲染。整个过程中两个 Child View 没有从 RootView 中直接接受参数,只有 RootView在初始化的时,通过 environmentObject() 方法把 MyModelObject 注入到整个 View 层级中,这个层级中所有的 View 都可以通过 @Environment的方式访问 MyModelObject 。需要注意的一点是,使用 environmentObject() 注入的对象必须是 BindableObject 类型。

五个数据流工具总结

  • Property:当 View 所需要的属性只要求可读,则使用 Property。
  • @State: 当 View 所需要的属性只在当前 View 和它的 Child Views 中使用,并且在用户的操作过程中会发生变化,然后导致 View 需要作出改变,那么使用 @State。 因为只在当前 View 和它的 Child Views 中使用,跟外界无关,所以被 @State 标记的属性一般在定义时就有初始值。
  • @Binding:当 View 所需要的属性是从它的直接 Parent View 传入,在内部会对这个属性进行修改,并且修改后的值需要反馈给直接 Parent View,那么使用 @Binding
  • @ObjectBinding:用于直接绑定外部的数据模型和 View。
  • @EnvironmentObject:Root View 通过 environmentObject()BindableObject 注入到 View 层级中,其中的所有 Child Views 可以通过 @EnvironmentObject 来访问被注入的 BindableObject

接收其他外部变化

有时我们的 View 需要监听外部的其他变化,并做出相应的改变,可以使用 receive(on:),这里面的 closure 参数是在主线程执行的。

以下是官方的 Demo 代码:

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

总结

数据在整个 App 中是非常重要的一部分,在使用上面讲到的工具之前,先仔细研究自己的数据结构,然后选择合适的工具,把数据注入到 UI 中。

想要更详细了解文章的内容,可以点击查看下面的视频。想及时看到我的新文章的,可以关注我。

参考资料

Data Flow Through SwiftUI - WWDC 2019 - Videos - Apple Developer

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,830评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,992评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,875评论 0 331
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,837评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,734评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,091评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,550评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,217评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,368评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,298评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,350评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,027评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,623评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,706评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,940评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,349评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,936评论 2 341

推荐阅读更多精彩内容