[SwiftUI 系列-2] SwiftUI 基础概念和设计理念

SwiftUI的设计理念

Swift UI 自2019年由Apple引入,在新版本iOS系统中被不断引入和完善,部分新功能特性甚至要求只能使用Swift UI来开发。Swift UI的设计和发展通常基于以下原则:

  • 声明式

SwiftUI是声明式UI,使用声明式让开发者更关注展示的数据本身,更少关注数据如何展示。

声明式的概念包含以下含义

  • 语言负责描述UI的展示和行为,布局和渲染由引擎负责

    • 描述UI对布局和显示没有完全的控制权,通过引擎可以适配不同平台,在不同设备和平台展示是不一样的

    • 天生具有良好的跨平台性

    • 在开发者控制性上有一定的限制

  • 声明UI不能直接变换显示,需要通过状态和事件来驱动UI的变换

    • 刷新UI需要更新State,并通过State配合声明UI重新计算新的UI显示。

      • 为了提高UI刷新响应,需要局部化刷新state并刷新UI的机制
    • 通过事件的处理来更新State,整个UI实际上是一个大型状态机实现

      • 为了避免事件传递处理遍历state,需要提供局部事件处理机制
    • 存在UI 刷新性能问题需要注意

  • 语言描述声明,声明可以转换为结构,通常是树形结构

    • SwiftUI中View实际是个结构体Struct,View是可以很便宜创建和销毁的,也便于复用

    • 分支和循环计算都可可以转换为树上的节点,为了便于跟踪节点和渲染的路径,可以将声明语言AST结构Path作为渲染节点的ID

  • 自动化

SwiftUI的布局渲染引擎声明式简化了对UI的描述,针对间距,位置等可以自动进行计算和适配。同时SwiftUI简化了状态和UI元素的绑定和转换,

  • 结构组合式

SwiftUI一个大的设计理念是基于组合而不是基于继承来设计,对代码的复用和创建复杂视图都提供了极大的便利性。复杂UI可以通过组合布局,例如HStack,VStack等来构建。通过modifier可以应用于各种元素而不需要重复编写配置代码。

  • 一致性

SwiftUI的热加载刷新和预览UI,使得开发SwiftUI所见即所得,同时SwiftUI针对不同的平台提供了自动适配的UI组件,在开发时仅需简单配置就可以自动适应不同平台,在不同平台提供了操作一致性。

SwiftUI 基础概念

View & modifier

在传统的命令式编程布局系统中,我们对一些 UI 系统结构是通常是通过继承实现的,再编写代码时通过对属性的调用来修改视图的外观,如颜色透明度等。 但这会带来导致类继承结构比较复杂,如果设计不够好会造成 OOP 的通病类爆炸,并且通过继承来的数据结构,子类会集成父类的存储属性,会导致子类实例在内存占据比较庞大,即便很多属性都是默认值并不使用。 如图

image.png

在 SwiftUI 中奖视图的修饰期抽象为 Modifier, Modifier通常只含有 1-2 个存储属性,通过原始控件的 Extension 可以在视图定义中添加各种 Modifier,它们在运行时的实现通常是一个闭包,在运行时 SwiftUI 构建出真实的视图。

image.png

之前在做UIKit开发的时候设置多个属性转变为使用多个modifier来修改样式。Modifier是个组合模式链式调用的实现,多个modifier的使用有时候也带来一些管理负担,可以通过自定义ViewModifier来组合多个modifier的效果。

struct CaptionTextFormat: ViewModifier {
func body(content: Content) -> some View {
        content.font(.caption)
           .foregroundColor(.secondary)
}}

SwiftUI 元控件

在 SwiftUI 系统中我们使用结构体遵守 View 协议,通过组合现有的控件描述,实现 Body 方法,但 Body 的方法会不会无限递归下去?

在 SwiftUI 系统中定义了 6 个元/主 View Text Color Spacer Image Shape Divider , 它们都不遵守 View 协议,只是基本的视图数据结构。

其他常见的视图组件都是通过组合元控件和修饰器来组合视图结构的。如 Button Toggle 等。

关于 SwiftUI 的视图和修饰器可以参考 Github 整理的速查表 Jinxiansen/SwiftUI

DataFlow in SwiftUI

任何程序都不可能是静态的,充满着数据状态。传统的命令式编程通过成员变量来管理状态,这使得状态的复杂度成指数级增长。举一个最简单的例子。假设一个视图只有 4 种状态,组合起来就有16种,但是人脑处理状态的复杂度是有限的,状态的复杂度一旦超过人脑的复杂度,就会产生大量的 Bug。

image.png

管理数据状态是非常复杂的一件事情,在SwiftUI中提出了数据状态管理的一些原则:

  • Source of Truth 是指我们真是的业务逻辑数据,可以认为是系统的输入,比如常量(Constant)和应用程序状态(@State)。

  • Dervied Value 是指 SwiftUI 框架中使用的数据,可以认为是系统的中间状态,比如View的属性(Property)和状态引用(@Binding)。

image.png

Constant

通常部分 UI 数据是不可改变的,比如一个 Text 的 Color, 这部分我们可以直接使用 Modifier 的构造器讲属性传递进去即可,这里就不做多解释了。


Text("Hello world")
                .color(.red)

@State

那在某些情况下我们某些 UI 元素会有一系列的响应事件,会导致视图发现变化,那么 SwiftUI 是怎么做到的? 就是通过前面的新语法 Property warpper , 对所有使用 State 标记的属性 都会代理到 SwiftUI 中 State 的方法实现,同时框架,计算Diff,刷新界面。 顺便这里强调下在 SwiftUI 中 Views area a function of State not of a sequence of Eventt , View 是真实视图的状态,并不是一系列变化的事件。

struct PlayerView: View {
    let episode: Episode
    @State private var isPlaying = false
    var body: some View {
        VStack {
            Text(episode.title)
            Button(action: {
                self.isPlaying.toggle()
            }) {
                Image(systemName: isPlaying ? "pause.cicle" : "play.circle")
            }
        }
    }
}
image.png

@ObserverObject

和@State的概念有点相似,@ObserverObject是针对复杂数据结构需要进行监听跟踪的数据,使用@ObserverObject需要让类型遵守ObververObject协议。这样在对象的@Published标记的属性上有变更时候就会通知view进行数据刷新。这个依赖Combine的实现做的一个包装。

class ItemViewModel : ObservableObject {
   @ Published private ( set ) var item: Item 
    private let onItemChange: ( Item ) -> Void

  init(item: Item, onItemChange: @escaping (Item) -> Void) 
{
 self.item = item self.onItemChange = onItemChange 
} 
func toggleFavoriteStatus() { 
  item.isFavorite.toggle() onItemChange(item) 
} 
}

struct ItemView: View { 
@ObservedObject var viewModel: ItemViewModel 
  var body: some View {
 HStack { Text(viewModel.item.title) 
Spacer() 
Button(action: viewModel.toggleFavoriteStatus) {
 Image(systemName: viewModel.favoriteButtonIconName) .foregroundColor(viewModel.favoriteButtonColor) 
} 
}} }

@StateObject

@stateObject的概念和@observerObject几乎是一样的,区别在于对象持有权。通常@StateObject是被当前类持有,而@ObserverObject则由父View或者外部传递过来进行监听的。

@Binding

再很多时候我们会总归抽象减少我们的代码长度,比如将上文中的 Button 抽象为一个 PlayerButton, 这时候存在一个问题,State 属性我们是再重新声明一份吗?

struct PlayButton: View {
    @State private var isPlaying = false
    var body: some View {
        Button(action: {
            self.isPlaying.toggle()
        }) {
            Image(systemName: isPlaying ? "pause.cicle" : "play.circle")
        }
    }
}

这样写会有一个问题,我们会产生两个 Derived Value,虽然两者都可通知 SwiftUI 做界面刷新,但是 PlayerViewPlayerButton 的数据同步又成了问题。

SwiftUI 推荐使用 @Binding 解决,我们来看下 Binding 的实现。


@propertyDelegate public struct Binding<Value> {
    public var value: Value { get nonmutating set }

    /// Initializes from functions to read and write the value.
    public init(getValue: @escaping () -> Value, setValue: @escaping (Value) -> Void)

}

Binding 结构体使用闭包捕获了原本的属性值,使得属性可以用引用的方式保留。


struct PlayerView: View {
    let episode: Episode
    @State private var isPlaying = false
    var body: some View {
        VStack {
            Text(episode.title)
            PlayButton($isPlaying)
        }
    }
}
image.png

这里 State 实现了 BindingConvertible 协议,使得 State 可以直接转换为 Binding。

@ ObservableObject & Combine

UI 除了受用户点击影响 有时候还来自于外部的通知,如一个IM类消息,收到远程的消息,或者一个定时器被触发,

image.png

在 SwiftUI 中通过最新的 Combine 框架可以很方便的响应外部变化,开发者只需实现 ObservableObject 协议即可

class PodcastPlayerStore: ObservableObject {
    var didChange = PassthoughSubject<Void, Never>()
    func advance() {
        // ..
        didChange.send()
    }
}

struct PlayerView: View {
    let episode: Episode
    @State private var isPlaying = false
    @State private var currentTime: TimeInterval = 0.0 
    var body: some View {
        VStack {
            Text(episode.title)
            PlayButton($isPlaying)
        }
        .onReceive(PodcastPlayerStore.currentTimePublisher) { newTime in 
            self.currentTime = newTime
        }
    }
}

@ ObserverObject

使用 State 的方式可以通知单个视图的变化,但是有时候我们需要多个视图共享一个元素信息,并且在数据信息发送变化时通知 SwiftUI 刷新所有布局,这时候可以使用 @ ObserverObject

final class PodcastPlayer: ObservableObject {
    var isPlaying: Bool = false {
        didSet {
            didChange.send(self)
        }
    }

    func play() {
        isPlaying = true
    }

    func pause() {
        isPlaying = false
    }

    var didChange = PassthroughSubject<PodcastPlayer, Never>()
}

struct EpisodesView: View {
    @ObserverObject var player: PodcastPlayer
    let episodes: [Episode]

    var body: some View {
        List {
            Button(
                action: {
                    if self.player.isPlaying {
                        self.player.pause()
                    } else {
                        self.player.play()
                    }
            }, label: {
                    Text(player.isPlaying ? "Pause": "Play")
                }
            )
            ForEach(episodes) { episode in
                Text(episode.title)
            }
        }
    }
}

@ ObserverObject 的函数定义:


@propertyWrapper @frozen public struct ObservedObject < ObjectType > : DynamicProperty where ObjectType : ObservableObject {

@dynamicMemberLookup @frozen public struct Wrapper {

public subscript < Subject >(dynamicMember keyPath: ReferenceWritableKeyPath < ObjectType , Subject >) -> Binding < Subject > { get }

}

public init (initialValue: ObjectType )

public init (wrappedValue: ObjectType )

public var wrappedValue : ObjectType

public var projectedValue : ObservedObject < ObjectType >. Wrapper { get }

}

系统通过 ObservedObject<ObjectType>.Wrapper 感知外部数据的变化。

@EnviromemntObject

有时候一些环境变量是共享的,我们可以通过 EnviromentObject 获取共享的信息,这些共享的数据信息回沿着 View 树的的结构向下传递。 类似于 Flutter 的 Theme 和 ScopeModel ,比较简单这里就不多做解释了

let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(rootView: LandmarkList().environmentObject(UserData()))

struct LandmarkList: View {
    @EnvironmentObject private var userData: UserData

    var body: some View {
        Text("")
    }
image.png

@Enviroment

前面提到的环境信息一般是指用户自定义共享信息,但是同时系统存在大量的内置环境信息,如时区,颜色模式等,可以直接订阅系统的环境信息,使得 SwiftUI 自动获取到环境信息的变化,自动刷新布局。

struct CalendarView: View {
    @Environment(\.calendar) var calendar: Calendar
    @Environment(\.locale) var locale: Locale
    @Environment(\.colorScheme) var colorScheme: ColorScheme

    var body: some View {
        return Text(locale.identifier)
    }
}

总结

以上是 SwiftUI 官方在各种教程和 WWDC session 提到的数据流管理方案,总结下来是以下几点

  1. 对于不变的常量直接传递给 SwiftUI 即可。

  2. 对于控件上需要管理的状态使用 @State 管理。

  3. 对于外部的事件变化使用 ObservableObject 发送通知。

  4. 对于需要共享的视图可变数据使用 @ ObservedObject 管理。

  5. 不要出现多个状态同步管理,使用 @Binding 共享一个 Source of truth

  6. 对于系统环境使用 @Enviroment 管理。

  7. 对于需要共享的不可变数据使用 @EnviromemntObject 管理。

  8. @Binding 具有引用语义,可以很好的和 @Binding @ ObservedObject @State @StateObject 协作,避免出现多个数据不同步。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容