文章链接
尽管今年的WWDC
已经落幕,但在过去的一个多月时间,苹果给iOS
开发者带来了许多惊喜,其中堪称最重量级的当属SwiftUI
和Combine
两大新框架
在更早之前,由于缺少系统层的声明式UI
语言,在iOS
系统上的UI
开发对于开发者而言,并不友善,而从iOS13
开始,开发者们终于可以摆脱落后的布局系统,拥抱更简洁高效的开发新时代。与SwiftUI
配套发布的响应式编程框架Combine
提供了更优美的开发方式,这也意味着Swift
真正成为了iOS
开发者们必须学习的语言。本文基于Swift5.1
版本,介绍SwiftUI
是如何通过结合Combine
完成数据绑定
SwiftUI
首先来个例子,假如我们要实现上图的登陆界面,按照以往使用UIKit
进行开发,那么我们需要:
- 创建一个
UITextField
,用于输入账户 - 创建一个
UITextField
,用于输入密码 - 创建一个
UIButton
,设置点击事件将前两个UITextField
的文本作为数据请求
而在使用SwiftUI
进行开发的情况下,代码如下:
public struct LoginView : View {
@State var username: String = ""
@State var password: String = ""
public var body: some View {
VStack {
TextField($username, placeholder: Text("Enter username"))
.textFieldStyle(.roundedBorder)
.padding([.leading, .trailing], 25)
.padding([.bottom], 15)
SecureField($password, placeholder: Text("Enter password"))
.textFieldStyle(.roundedBorder)
.padding([.leading, .trailing], 25)
.padding([.bottom], 30)
Button(action: {}) {
Text("Sign In")
.foregroundColor(.white)
}.frame(width: 120, height: 40)
.background(Color.blue)
}
}
}
在SwiftUI
中,使用@State
修饰的属性会在发生改变的时候通知绑定的UI
控件强制刷新渲染,这种新命名归功于新的PropertyWrapper
机制。可以看到SwiftUI
的控件命名上和UIKit
几乎保持一致的,下表是两个标准库上的UI
对应表:
SwiftUI | UIKit |
---|---|
Text | UILabel / NSAttributedString |
TextField | UITextField |
SecureField | UITextField with isSecureTextEntry |
Button | UIButton |
Image | UIImageView |
List | UITableView |
Alert | UIAlertView / UIAlertController |
ActionSheet | UIActionSheet / UIAlertController |
NavigationView | UINavigationController |
HStack | UIStackView with horizatonal |
VStack | UIStackView with vertical |
Toggle | UISwitch |
Slider | UISlider |
SegmentedControl | UISegmentedControl |
Stepper | UIStepper |
DatePicker | UIDatePicker |
View
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol View : _View {
/// The type of view representing the body of this view.
///
/// When you create a custom view, Swift infers this type from your
/// implementation of the required `body` property.
associatedtype Body : View
/// Declares the content and behavior of this view.
var body: Self.Body { get }
}
虽然在SwiftUI
中使用View
来表示可视控件,但实际上大相径庭,View
是一套容器协议,不展示任何内容,只定义了一套视图的交互、布局等接口。UI
控件需要实现协议中的body
返回需要展示的内容。另外View
还扩展了Combine
响应式编程的订阅接口:
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
/// Adds an action to perform when the given publisher emits an event.
///
/// - Parameters:
/// - publisher: The publisher to subscribe to.
/// - action: The action to perform when an event is emitted by
/// `publisher`. The event emitted by publisher is passed as a
/// parameter to `action`.
/// - Returns: A view that triggers `action` when `publisher` emits an
/// event.
public func onReceive(_ publisher: P, perform action: @escaping (P.Output) -> Void) -> SubscriptionView
where P : Publisher, P.Failure == Never
/// Adds an action to perform when the given publisher emits an event.
///
/// - Parameters:
/// - publisher: The publisher to subscribe to.
/// - action: The action to perform when an event is emitted by
/// `publisher`.
/// - Returns: A view that triggers `action` when `publisher` emits an
/// event.
public func onReceive
(_ publisher: P, perform action: @escaping () -> Void) -> SubscriptionView
where P : Publisher, P.Failure == Never
}
@State
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@propertyDelegate public struct State : 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 { get }
/// Produces the binding referencing this state value
public var delegateValue: Binding { get }
/// Produces the binding referencing this state value
/// TODO: old name for storageValue, to be removed
public var storageValue: Binding { get }
}
Swift5.1
的新特性之一,开发者可以将变量的IO
实现封装成通用逻辑,用关键字(更新于@propertyDelegate
beta4
版本,使用@propertyWrapper
替代)修饰读写逻辑,并以@wrapperName var variable
的方式封装变量。以WWDC Session 415视频中的例子实现对变量copy-on-write
的封装:
@propertyWrapper
public struct DefensiveCopying {
private var storage: Value
public init(initialValue value: Value) {
storage = value.copy() as! Value
}
public var wrappedValue: Value {
get { storage }
set {
storage = newValue.copy() as! Value
}
}
/// beta4版本更新,必须声明projectedValue后才能使用$variable的方式访问Wrapper
/// beta3版本使用wrapperValue命名
public var projectedValue: DefensiveCopying {
get { self }
}
}
public struct Person {
@DefensiveCopying(initialValue: "")
public var name: NSString
}
另外以PropertyWrapper
封装的变量,会默认生成一个命名为,更新后会默认生成$name
的DefensiveCopying
类型的变量_name
命名的Wrapper
类型参数,或声明关键变量wrapperValue/projectedValue
后生成可访问的$name
变量,下面两种值访问操作是相同的:
extension Person {
func visitName() {
printf("name: \(name)")
printf("name: \($name.value)")
}
}
Combine
Customize handling of asynchronous events by combining event-processing operators.
引用官方文档的描述,Combine
是一套通过组合变换事件操作来处理异步事件的标准库。事件执行过程的关系包括:被观察者Observable
和观察者Observer
,在Combine
中对应着Publisher
和Subscriber
异步编程
很多开发者认为异步编程会开辟线程执行任务,多数时候程序在异步执行时确实也会创建线程,但是这种理解是不正确的,同步编程和异步编程的区别只在于程序是否会堵塞等待任务执行完毕,下面是一段无需额外线程的异步编程实现代码:
class TaskExecutor {
static let instance = TaskExecutor()
private var executing: Bool = false
private var tasks: [() -> ()] = Array()
private var queue: DispatchQueue = DispatchQueue.init(label: "SerialQueue")
func pushTask(task: @escaping () -> ()) {
tasks.append(task)
if !executing {
execute()
}
}
func execute() {
executing = true
let executedTasks = tasks
tasks.removeAll()
executedTasks.forEach {
$0()
}
if tasks.count > 0 {
execute()
} else {
executing = false
}
}
}
TaskExecutor.instance.execute()
TaskExecutor.instance.pushTask {
print("abc")
TaskExecutor.instance.pushTask {
print("def")
}
print("ghi")
}
单向流动
如果A
事件会触发B
事件,反之不成立,可以认为两个事件是单向的,好比说我饿了,所以我去吃东西
,但不会是我去吃东西,所以我饿了
。在编程中,如果数据流动能够保证单向,会让程序变得更加简单。举个例子,下面是一段非单向流动的常见UI
代码:
func tapped(signIn: UIButton) {
LoginManager.manager.signIn(username, password: password) { (err) in
guard err == nil else {
ERR_LOG("sign in failed: \(err)")
return
}
UserManager.manager.switch(to: username)
MainPageViewController.enter()
}
}
在这段代码中,Action
实际上会等待State/Data
完成后,去更新View
,View
会再去访问数据更新状态,这种逻辑会让数据在不同事件模块中随意流动,易读性和可维护性都会变得更差:
而一旦事件之间的流动采用了异步编程的方式来处理,发出事件的人不关心等待事件的处理,无疑能让数据的流动变得更加单一,Combine
的意义就在于此。SwiftUI
与其结合来控制业务数据的单向流动,让开发复杂度大大降低:
Publisher
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol Publisher {
/// The kind of values published by this publisher.
associatedtype Output
/// The kind of errors this publisher might publish.
///
/// Use `Never` if this `Publisher` does not publish errors.
associatedtype Failure : Error
/// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)`
///
/// - SeeAlso: `subscribe(_:)`
/// - Parameters:
/// - subscriber: The subscriber to attach to this `Publisher`.
/// once attached it can begin to receive values.
func receive(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}
Publisher
定义了发布相关的两个信息:Output
和Failure
,对应事件输出值和失败处理两种情况,以及提供了receive(subscriber:)
接口注册事件订阅者。在iOS13
之后,苹果基于Foundation
标准库实现了很多Combine
的响应式接口,包括:
URLSessionTask
可以在请求完成或者请求出错时发出消息NotificationCenter
新增响应式编程接口
以官方的NotificationCenter
扩展为例创建一个登录操作的Publisher
:
extension NotificationCenter {
struct Publisher: Combine.Publisher {
typealias Output = Notification
typealias Failure = Never
init(center: NotificationCenter, name: Notification.Name, Object: Any? = nil)
}
}
let signInNotification = Notification.Name.init("user_sign_in")
struct SignInInfo {
let username: String
let password: String
}
let signInPublisher = NotificationCenter.Publisher(center: .default, name: signInNotification, object: nil)
另外还需要注意的是:Self.Output == S.Input
限制了Publisher
和Subscriber
之间的数据流动必须保持类型一致,大多数时候总是很难维持一致性的,所以Publisher
同样提供了map/compactMap
的高阶函数对输出值进行转换:
/// Subscriber只接收用户名信息
let signInPublisher = NotificationCenter.Publisher(center: .default, name: signInNotification, object: nil)
.map {
return ($0 as? SignInfo)?.username ?? "unknown"
}
Subscriber
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol Subscriber : CustomCombineIdentifierConvertible {
/// The kind of values this subscriber receives.
associatedtype Input
/// The kind of errors this subscriber might receive.
///
/// Use `Never` if this `Subscriber` cannot receive errors.
associatedtype Failure : Error
/// Tells the subscriber that it has successfully subscribed to the publisher and may request items.
///
/// Use the received `Subscription` to request items from the publisher.
/// - Parameter subscription: A subscription that represents the connection between publisher and subscriber.
func receive(subscription: Subscription)
/// Tells the subscriber that the publisher has produced an element.
///
/// - Parameter input: The published element.
/// - Returns: A `Demand` instance indicating how many more elements the subcriber expects to receive.
func receive(_ input: Self.Input) -> Subscribers.Demand
/// Tells the subscriber that the publisher has completed publishing, either normally or with an error.
///
/// - Parameter completion: A `Completion` case indicating whether publishing completed normally or with an error.
func receive(completion: Subscribers.Completion)
}
Subscriber
定义了一套receive
接口用来接收Publisher
发送的消息,一个完整的订阅流程如下图:
在订阅成功之后,receive(subscription:)
会被调用一次,其类型如下:
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol Subscription : Cancellable, CustomCombineIdentifierConvertible {
/// Tells a publisher that it may send more values to the subscriber.
func request(_ demand: Subscribers.Demand)
}
Subscription
可以认为是单次订阅的会话,其实现了Cancellable
接口允许Subscriber
中途取消订阅,释放资源。基于上方的NotificationCenter
代码,完成Subscriber
的接收部分:
func registerSignInHandle() {
let signInSubscriber = Subscribers.Assign.init(object: self.userNameLabel, keyPath: \.text)
signInPublisher.subscribe(signInSubscriber)
}
func tapped(signIn: UIButton) {
LoginManager.manager.signIn(username, password: password) { (err) in
guard err == nil else {
ERR_LOG("sign in failed: \(err)")
return
}
let info = SignInfo(username: username, password: password)
NotificationCenter.default.post(name: signInNotification, object: info)
}
}
Combine与UIKit
得力于Swift5.1
的新特性,基于PropertyWrapper
和Combine
标准库,可以让UIKit
同样具备绑定数据流动的能力,预设代码如下:
class ViewController: UIViewController {
@Publishable(initialValue: "")
var text: String
let textLabel = UILabel.init(frame: CGRect.init(x: 100, y: 120, width: 120, height: 40))
override func viewDidLoad() {
super.viewDidLoad()
textLabel.bind(text: $text)
let button = UIButton.init(frame: CGRect.init(x: 100, y: 180, width: 120, height: 40))
button.addTarget(self, action: #selector(tapped(button:)), for: .touchUpInside)
button.setTitle("random text", for: .normal)
button.backgroundColor = .blue
view.addSubview(textLabel)
view.addSubview(button)
}
@objc func tapped(button: UIButton) {
text = String(arc4random() % 101)
}
}
每次点击按钮的时候生成随机数字符串,然后textLabel
自动更新文本
Publishable
字符串在发生改变的时候需要更新绑定的label
,在这里使用PassthroughSubject
类对输出值类型做强限制,其结构如下:
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
final public class PassthroughSubject
Publishable
的实现代码如下(7.25更新):
@propertyWrapper
public struct Publishable {
private var storage: Value
var publisher: PassthroughSubject
public init(initialValue value: Value) {
storage = value
publisher = PassthroughSubject()
Publishers.AllSatisfy
}
public var wrappedValue: Value {
get { storage }
set {
if storage != newValue {
storage = newValue
publisher.send(storage)
}
}
}
public var projectedValue: Publishable {
get { self }
}
}
UI extensions
通过extension
对控件进行扩展支持属性绑定:
extension UILabel {
func bind(text: Publishable) {
let subscriber = Subscribers.Assign.init(object: self, keyPath: \.text)
text.publisher.subscribe(subscriber)
self.text = text.value
}
}
这里需要注意的是,创建的Subscriber
会被系统的libswiftCore
持有,在控制器生命周期结束时,如果不能及时的cancel
掉所有的subscriber
,会导致内存泄漏:
func freeBinding() {
subscribers?.forEach {
$0.cancel()
}
subscribers?.removeAll()
}
最后放上运行效果:
其他
从今年wwdc
发布的新内容,不难看出苹果的野心,由于Swift
本身就是一门特别适合编写DSL
的语言,而在iOS13
上新增的两个标准库让项目的开发成本和维护成本变得更低的特点。由于其极高的可读性,开发者很容易就习惯新的标准库。目前SwiftUI
实时用到了UIKit
、CoreGraphics
等库,可以看做是基于这些库的抽象封装层,随着后续Swift
的普及度,苹果底层可以换掉UIKit
独立存在,甚至实现跨平台的大前端一统。当然目前苹果上的大前端尚早,不过未来可期
参考阅读
SwiftUI Session
Property Wrappers
Combine入门导读
新晋网红SwiftUI