文章連接html
儘管今年的WWDC
已經落幕,但在過去的一個多月時間,蘋果給iOS
開發者帶來了許多驚喜,其中堪稱最重量級的當屬SwiftUI
和Combine
兩大新框架前端
在更早以前,因爲缺乏系統層的聲明式UI
語言,在iOS
系統上的UI
開發對於開發者而言,並不友善,而從iOS13
開始,開發者們終於能夠擺脫落後的佈局系統,擁抱更簡潔高效的開發新時代。與SwiftUI
配套發佈的響應式編程框架Combine
提供了更優美的開發方式,這也意味着Swift
真正成爲了iOS
開發者們必須學習的語言。本文基於Swift5.1
版本,介紹SwiftUI
是如何經過結合Combine
完成數據綁定編程
首先來個例子,假如咱們要實現上圖的登錄界面,按照以往使用UIKit
進行開發,那麼咱們須要:swift
UITextField
,用於輸入帳戶UITextField
,用於輸入密碼UIButton
,設置點擊事件將前兩個UITextField
的文本做爲數據請求而在使用SwiftUI
進行開發的狀況下,代碼以下:api
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
對應表:app
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 |
@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<P>(_ publisher: P, perform action: @escaping (P.Output) -> Void) -> SubscriptionView<P, Self> 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<P>(_ publisher: P, perform action: @escaping () -> Void) -> SubscriptionView<P, Self> where P : Publisher, P.Failure == Never
}
複製代碼
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@propertyDelegate 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 }
}
複製代碼
Swift5.1
的新特性之一,開發者能夠將變量的IO
實現封裝成通用邏輯,用關鍵字@propertyWrapper
(更新於beta4
版本)修飾讀寫邏輯,並以@wrapperName var variable
的方式封裝變量。以WWDC Session 415視頻中的例子實現對變量copy-on-write
的封裝:dom
@propertyWrapper
public struct DefensiveCopying<Value: NSCopying> {
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<Value>
/// beta3版本使用wrapperValue命名
public var projectedValue: DefensiveCopying<Value> {
get { self }
}
}
public struct Person {
@DefensiveCopying(initialValue: "")
public var name: NSString
}
複製代碼
另外以PropertyWrapper
封裝的變量,會默認生成一個命名爲,更新後會默認生成$name
的DefensiveCopying<String>
類型的變量_name
命名的Wrapper<Value>
類型參數,或聲明關鍵變量wrapperValue/projectedValue
後生成可訪問的$name
變量,下面兩種值訪問操做是相同的:異步
extension Person {
func visitName() {
printf("name: \(name)")
printf("name: \($name.value)")
}
}
複製代碼
Customize handling of asynchronous events by combining event-processing operators.async
引用官方文檔的描述,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
與其結合來控制業務數據的單向流動,讓開發複雜度大大下降:
@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<S>(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"
}
複製代碼
@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<Self.Failure>)
}
複製代碼
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)
}
}
複製代碼
得力於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
自動更新文本
字符串在發生改變的時候須要更新綁定的label
,在這裏使用PassthroughSubject
類對輸出值類型作強限制,其結構以下:
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
final public class PassthroughSubject<Output, Failure> : Subject where Failure : Error {
public init()
/// 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.
final public func receive<S>(subscriber: S) where Output == S.Input, Failure == S.Failure, S : Subscriber
/// Sends a value to the subscriber.
///
/// - Parameter value: The value to send.
final public func send(_ input: Output)
/// Sends a completion signal to the subscriber.
///
/// - Parameter completion: A `Completion` instance which indicates whether publishing has finished normally or failed with an error.
final public func send(completion: Subscribers.Completion<Failure>)
}
複製代碼
Publishable
的實現代碼以下(7.25更新):
@propertyWrapper
public struct Publishable<Value: Equatable> {
private var storage: Value
var publisher: PassthroughSubject<Value?, Never>
public init(initialValue value: Value) {
storage = value
publisher = PassthroughSubject<Value?, Never>()
Publishers.AllSatisfy
}
public var wrappedValue: Value {
get { storage }
set {
if storage != newValue {
storage = newValue
publisher.send(storage)
}
}
}
public var projectedValue: Publishable<Value> {
get { self }
}
}
複製代碼
經過extension
對控件進行擴展支持屬性綁定:
extension UILabel {
func bind(text: Publishable<String>) {
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
獨立存在,甚至實現跨平臺的大前端一統。固然目前蘋果上的大前端尚早,不過將來可期