函數語言程式設計 - 實現響應式框架

TangentW發表於2017-10-18

前言

函式式響應式程式設計框架我們應該也用得比較多了,如ReactiveCocoaReactiveX系列(RxSwift、RxKotlin、RxJava),這些框架內部實現都是基於函數語言程式設計的思想來構建的。還記得前不久面試的時候面試官有問道:“有閱讀過ReactiveCocoa的原始碼嗎?有沒有看過其中的核心函式bind?你知道這個函式如何實現的嗎?”。在回答這個問題時,如果面試者只是單純的看過RAC原始碼,雖能憑自己的印象說出這個方法的大概流程,不過對其中的思想可能也只是一知半解,但如果你充分了解過函數語言程式設計,熟悉Monad概念,就能知道bind方法其實就是Monad概念中的一部分,RAC正是利用Monad來實現它的Signal。此時你就可以向面試官開始你的表演了。

請開始你的表演
請開始你的表演

如標題所述,在這篇文章中我們將利用函數語言程式設計的思想,去構建一個小型的響應式框架。它具有響應回撥的能力,且能將一個個事件資料抽象成管道中流動的流體,我們可以對這些事件資料進行若干的轉換,最後再訂閱它們。

本文為《函數語言程式設計》系列文章中的第三篇,若大家對函數語言程式設計感興趣,可以閱讀系列的前兩篇文章:

原理

函式式響應式的本質是什麼

先附上一張流轉換思想的概念圖:

流轉換思想
流轉換思想

在日常專案邏輯的構建中,我們總會對一些資料進行轉換運算,這裡我們將資料的轉換過程抽象成一條包裹著流動資料的管道,資料以流的形式在這條管道中流通,當經過轉換器時,原始的資料流將會被轉換成新的資料流,然後繼續流動下去。針對資料的轉換運算,我們會使用一些函式/方法,將運算的資料作為實參傳入函式中/對運算的物件呼叫方法,得到轉換後的結果。此時整個運算將會同步執行,轉換函式接收舊資料進行轉換,成功後返回新的資料。除此之外,你還可以在這個管道中安置多個轉換器,資料在通過若干的轉換器後便轉換成了最終我們所期望的結果值,並從管道中流出。

不過,事實上專案邏輯中也會涉及到許多非同步進行的操作,如某些較為耗時的操作(資料庫操作、網路請求)、基於事件迴圈(RunLoop)的事件監聽處理(螢幕觸控監聽、裝置感測器監聽),這些操作有的會在後臺建立新的執行緒進行處理,當處理完成後將資料饋回到主執行緒中,有的則是會在整個執行迴圈中通過對每一次迴圈週期從事件佇列中取得需要處理的事件,派發到相應的Handler中。對於這些操作,它們都具有共同點,那就是:資料返回的過程都是通過回撥(Callback)來實現的。

對於如何將流轉換的思想用於Callback上,就是函式式響應式所探討解決的問題。

在前不久我有幸參與了中國2017年Swift大會,會議邀請了RxSwift的作者前來演講,在演講中他闡明瞭RxSwift的本質:

RxSwift just a callback! (RxSwift就是一個回撥)

可能這裡有人會有疑問:為什麼回撥不使用一個簡單的代理模式或者一個閉包,反而構建起這麼複雜且重量級的框架?因為,這些函式式響應式框架要做的事情就是讓回撥結合流轉換的思想,讓開發者只專注於資料的轉換過程而不必多花精力在回撥的設計上,輕鬆寫出簡潔優雅的回撥過程。

核心思想

流轉換的思想為將資料事件抽象成管道中流通的流體,用過轉換器轉換成新的資料事件,若加上回撥的實現,我們可以說這條管道是建立在回撥上的。這時候,我們就可以理清管道和資料的關係:建立在回撥上的管道包裹著資料。換句話說,具有回撥能力的管道作為一個Context(上下文),包裹著基本的資料值,並且它還擁有某種運算的能力,那就是觸發事件、監聽回撥,而這種運算不需要我們去花精力放在上面,我們只想專注於資料的轉換。

看到上面對函式式響應式的描述,你或許也發現了這跟函數語言程式設計裡面一個十分重要的概念高度匹配,那就是Monad(單子)。是的,函式式響應式的核心其實就是建立在Monad之上,所以,要實現函式式響應式,我們須構建出一個Monad,可以把它叫做響應式Monad

看過ReactiveCocoa原始碼的小夥伴可能知道,RACSignal中具有方法bind和派生類RACReturnSignal,它們就是用來實現Monad中的bindreturn函式,所以,Signal就是一個Monad。不過我們這裡需要知道的是,ReactiveCocoa中的bind方法並非完全標準的Monad bind函式,它在引數型別上有所變化,在外表封裝多了一層RACSignalBindBlock,要說最接近Monad bind的,應該就屬RACSignal中的flattenMap方法了(RACSignal的flattenMap方法也是基於bind包裝)。所以,實現了響應式Monad,你就能免費得到flattenMap方法。

因為Monad必定也是一個Functor,所以當你實現一個響應式Monad後,相應的Functor中的map方法你就能很輕易地實現出來了。是的,map方法並非RACSignal所特有的,其也是來自於函數語言程式設計中的Functor

實現

因為個人熱衷於Swift,接下來我將基於Swift語言實現一個簡單的函式式響應式框架。

Event

首先我們來實現Event(事件),像ReactiveCocoaRxSwift中,事件具有三種型別,分別是:

  • next 表示一個資料流元素
  • completed 表示資料流已經完成
  • error 表示資料流中產生了錯誤

這個我實現的事件就簡單一點,它僅具有nexterror型別:

enum Event<E> {
    case next(E)
    case error(Error)
}複製程式碼

Event中的泛型E代表其中資料元素的型別。這裡需要注意的是,當事件型別為error時,其關聯的錯誤例項並沒有型別限制,這裡為了簡單演示我沒有新增約束錯誤例項的泛型,大家在後面如果嘗試自己去實現的話可以稍作優化,如:

enum Event<E, R> where R: Error {
    case next(E)
    case error(R)
}複製程式碼

Observer

Observer要做的事情有兩個,分別是傳送事件以及監聽事件

// MARK: - Protocol - Observer
protocol ObserverType {
    associatedtype E

    var action: (Event<E>) -> () { get }

    init(_ action: @escaping (Event<E>) -> ())

    func send(_ event: Event<E>)
}

extension ObserverType {
    func send(_ event: Event<E>) {
        action(event)
    }

    func sendNext(_ value: E) {
        send(.next(value))
    }

    func sendError(_ error: Error) {
        send(.error(error))
    }
}

// MARK: - Class - Observer
final class Observer<Element>: ObserverType {
    typealias E = Element
    let action: (Event<E>) -> ()

    init(_ action: @escaping (Event<E>) -> ()) {
        self.action = action
    }
}複製程式碼

通過send方法,Observer可以傳送出事件,而通過實現一個閉包並將其傳入到Observer的構造器中,我們就可以監聽到Observer發出的事件。

Signal

接下來就是重頭戲:Signal(命名是我從ReactiveCocoa中直接借鑑而來),它就是我們上面所提到的響應式Monad,整個函式式響應式的核心。

我們先來看看SignalType協議:

// MARK: - Protocol - Signal
protocol SignalType {
    associatedtype E
    func subscribe(_ observer: Observer<E>)
}

extension SignalType {
    func subscribe(next: ((E) -> ())? = nil,
                   error: ((Error) -> ())? = nil) {
        let observer = Observer<E> { event in
            switch event {
            case .error(let e):
                error?(e)
            case .next(let element):
                next?(element)
            }
        }
        subscribe(observer)
    }
}複製程式碼

協議宣告瞭用於訂閱事件的方法subscribe(_:),這個方法接收了一個Observer作為引數,基於此方法我們就可以擴充套件出專門針對特殊事件型別(next、error)的訂閱方法:subscribe(next:error:)

接下來就是Signal的實現:

// MARK: - Class - Signal
final class Signal<Element>: SignalType {
    typealias E = Element

    private var value: E?
    private var observer: Observer<E>?

    init(value: E) {
        self.value = value
    }

    init(_ creater: (Observer<E>) -> ()) {
        let observer = Observer(action)
        creater(observer)
    }

    func action(_ event: Event<E>) {
        observer?.action(event)
    }

    static func `return`(_ value: E) -> Signal<E> {
        return Signal(value: value)
    }

    func subscribe(_ observer: Observer<E>) {
        if let value = value { observer.sendNext(value) }
        self.observer = observer
    }

    static func pipe() -> (Observer<E>, Signal<E>) {
        var observer: Observer<E>!
        let signal = Signal<E> {
            observer = $0
        }
        return (observer, signal)
    }
}複製程式碼

我們可以看到Signal內部具有一個成員屬性observer,當我們呼叫subscribe(_:)方法時就將傳入的引數賦予給這個成員。對於另一個成員屬性value,它的作用是為了讓Signal實現Monad return函式,我在《函數語言程式設計》系列文章的前面已經介紹過,Monad return函式就是將一個基本的資料包裹在一個Monad上下文中。所以在Signal中我定義了類方法return(_:),內部呼叫了針對於value初始化的Signal構造器init(value: E),將一個基本的資料賦予給了value成員屬性。在subscribe(_:)方法的實現中,我們首先對value做非空判斷,若此時value存在,傳入的observer引數將傳送關聯了valuenext事件,這樣做是為了保證整個Signal符合Monad特性。

接著到init(_ creater: (Observer<E>) -> ())構造方法,這個方法接受一個閉包,閉包裡面做的,就是進行某些運算處理邏輯或事件監聽,如網路請求、事件監聽等。閉包帶有一個Observer型別的引數,當閉包中的運算處理邏輯完成或者接收到事件回撥時,就利用這個Observer傳送事件。在這個構造方法實現的內部,我首先將Signal自己的action(_:)方法作為引數傳入Observer的構造器從而建立了一個Observer例項,其中,action(_:)方法做的事情是:指使成員屬性observer將自己接收到的事件引數轉發出去。這裡的設計比較巧妙,我們在構造器閉包型別引數creater中進行處理邏輯或事件監聽,若得到結果,將使用閉包中的Observer引數傳送事件,事件將會傳遞到訂閱了這個Signal的訂閱者中,從而觸發相關回撥。

這裡可能有人會有疑惑:為什麼需要用兩個observer來傳遞事件?可以在subscribe(_:)方法呼叫的時候再順便呼叫creater閉包,把接收到的訂閱者傳入即可。其實,我這麼做的目的是為了保證creater的呼叫跟init(_ creater: (Observer<E>) -> ())同步進行,因為在Signal中我提供了pipe方法。

pipe方法返回一個二元組,第一項為Observer,我們可以利用它來傳送事件,第二項為Signal,我們可以通過它來訂閱事件,它就像RxSwift中的Subject,只不過這裡我將事件傳送者與訂閱者區分開了。這裡有一個需要注意的地方:

上面說到,對於我們使用pipe函式獲取到的Observer,其內部的action成員屬性來自於Signalaction(_:)方法,這個方法引用到了Signal中的成員屬性。由此,我們可以推出此時ObserverSignal具有引用的關係,Observer不釋放,Signal也會一直保留。

接下來就是讓Signal實現Monadbind方法了:

// MARK: - Monad - Signal
extension Signal {
    func bind<O>(_ f: @escaping (E) -> Signal<O>) -> Signal<O> {
        return Signal<O> { [weak self] observer in
            self?.subscribe(next: { element in
                f(element).subscribe(observer)
            }, error: { error in
                observer.sendError(error)
            })
        }
    }

    func flatMap<O>(_ f: @escaping (E) -> Signal<O>) -> Signal<O> {
        return bind(f)
    }

    func map<O>(_ f: @escaping (E) -> O) -> Signal<O> {
        return bind { element in
            return Signal<O>.return(f(element))
        }
    }
}複製程式碼

bind方法接受一個函式作為引數,這個函式的型別為(E) -> Signal<O>E泛型為舊Signal元素中的型別,O則是新Signal元素中的型別,這個bind方法其實跟ReactiveCocoaflattenMap或是RxSwift中的flatMap做的事情一樣,所以在下面的flatMap方法的實現中我只是直接地呼叫bind方法。很多人俗稱這個過程為降維

bind方法的實現中,我們返回一個新的Signal,為了構造這個Signal,我們使用初始化方法init(_ creater: (Observer<E>) -> ()),在creater閉包中訂閱舊的Signal。倘若舊SignalObserver發出error事件,則直接把error事件中關聯的Error例項提取出來,通過creater閉包中作為引數傳入的Observer包裹起來再傳遞出去;而若是舊SignalObserver發出next事件,則先把next關聯的資料元素提取出來,通過呼叫bind傳進來的函式,獲取一箇中間層的Signal,再通過對這個中間層Signal進行訂閱,將事件傳遞到新的Signal中。

creater閉包中我使用了[weak self]捕獲列表來對舊Signal進行若引用以防止迴圈引用的發生,為什麼這裡可能會發生迴圈引用?上面提到過,Observer會引用Signal,而在creater閉包中舊的Signal將引用新SignalObserver,從而可以推出舊的Signal會對新Signal持引用關係,這裡如果不留意的話會造成迴圈引用。

Monad中的bind方法將自動處理上下文。在Signal中,bind則幫我們自己處理好事件的訂閱、轉移、傳遞,而我們只需要專注於純資料的轉換。

map方法的實現十分簡單,通過在內部呼叫bind方法,並將最終資料通過return包裹進Signal上下文中,在這裡我就不多說了。

以上,我們的響應式Monad就實現完成了!

以上只是非常簡單地實現函式式響應式,目的是為了簡單介紹如何利用函數語言程式設計思想去完成響應式的操作,其中並沒有考慮有關跨執行緒排程的問題,大家如果有興趣的可以自己嘗試去進行相關優化。

下面我們來測試使用一下。

簡單使用

通過creater閉包構建Signal

let mSignal: Signal<Int> = Signal { observer in
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        observer.sendNext(1)
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        observer.sendNext(2)
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
        observer.sendNext(3)

    }
}

mSignal.map { $0 + 1 }.map { $0 * 3 }.map { "The number is \($0)" }.subscribe(next: { numString in
    print(numString)
})複製程式碼

輸出:

The number is 6
The number is 9
The number is 12複製程式碼

通過pipe構建Signal

let (mObserver, mSignal) = Signal<Int>.pipe()

mSignal.map { $0 * 3 }.map { $0 + 1 }.map { "The value is \($0)" }.subscribe(next: { value in
    print(value)
})

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    mObserver.sendNext(3)
}

DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    mObserver.sendNext(2)
}

DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
    mObserver.sendNext(1)
}複製程式碼

輸出:

The value is 10
The value is 7
The value is 4複製程式碼

擴充套件

接下來我們對剛剛實現的函式式響應式進行擴充套件,關聯一些平時我們常用到的類。

UIControl

UIControl的觸發事件進行監聽,傳統的做法是通過呼叫addTarget(_:, action:, for:)方法,傳入target以及一個回撥函式Selector。很多人比較厭倦這種方法,覺得每次監聽事件都需要定義一個事件處理函式,比較麻煩,希望能直接通過閉包回撥事件觸發。

這裡只需簡單地封裝一下即可滿足這種需求:

final class ControlTarget: NSObject {
    private let _callback: (UIControl) -> ()

    init(control: UIControl, events: UIControlEvents, callback: @escaping (UIControl) -> ()) {
        _callback = callback
        super.init()
        control.addTarget(self, action: #selector(ControlTarget._handle(control:)), for: events)
    }

    @objc private func _handle(control: UIControl) {
        _callback(control)
    }
}

fileprivate var targetsKey: UInt8 = 23
extension UIControl {
    func on(events: UIControlEvents, callback: @escaping (UIControl) -> ()) {
        var targets = objc_getAssociatedObject(self, &targetsKey) as? [UInt: ControlTarget] ?? [:]
        targets[events.rawValue] = ControlTarget(control: self, events: events, callback: callback)
        objc_setAssociatedObject(self, &targetsKey, targets, .OBJC_ASSOCIATION_RETAIN)
    }
}複製程式碼

在這裡我間接利用ControlTarget物件來將UIControl事件觸發傳遞到閉包中,並通過關聯物件來使得UIControl保持對ControlTarget的引用,以防止其被自動釋放。經過上面簡單的封裝後,我們就能很方面地利用閉包監聽UIControl的事件回撥:

button.on(events: .touchUpInside) { button in
    print("\(button) - TouchUpInside")
}
button.on(events: .touchUpOutside) { button in
    print("\(button) - TouchUpOutside")
}複製程式碼

由此,我們可以簡單地基於上面的封裝來擴充套件我們的函式式響應式:

extension UIControl {
    func trigger(events: UIControlEvents) -> Signal<UIControl> {
        return Signal { [weak self] observer in
            self?.on(events: events, callback: { control in
                observer.sendNext(control)
            })
        }
    }

    var tap: Signal<()> {
        return trigger(events: .touchUpInside).map { _ in () }
    }
}複製程式碼

trigger(events:)方法傳入一個需要進行監聽的事件型別,返回一個Signal,當對應的事件觸發時,Signal中則會發射出事件。而tap返回的則是針對TouchUpInside事件觸發的Signal

使用起來跟RxSwiftReactiveCocoa一樣,十分簡潔優雅:

button.tap.map { _ in "Tap~" }.subscribe(next: { message in
    print(message)
})複製程式碼

上面整個過程的引用關係為: UIControl -> ControlTarget -> _callback -> Observer -> Signal,由此我們知道,只要保持對UIControl的引用,那麼其所關聯的事件監聽Signal則不會被自動釋放,可以在整個RunLoop中持續工作,


NotificationCenter

將函式式響應式適配控制中心,方法跟上面對UIControl的擴充套件一樣,通過一箇中間層NotificationObserver來做事件的傳遞轉發:

final class NotificationObserver: NSObject {
    private unowned let _center: NotificationCenter
    private let _callback: (Notification) -> ()

    init(center: NotificationCenter, name: Notification.Name, object: Any?, callback: @escaping (Notification) -> ()) {
        _center = center
        _callback = callback
        super.init()
        center.addObserver(self, selector: #selector(NotificationObserver._handle(notification:)), name: name, object: object)
    }

    @objc private func _handle(notification: Notification) {
        _callback(notification)
    }

    deinit {
        _center.removeObserver(self)
    }
}

fileprivate var observersKey: UInt = 78
extension NotificationCenter {
    func callback(_ name: Notification.Name, object: Any?, callback: @escaping (Notification) -> ()) {
        var observers = objc_getAssociatedObject(self, &observersKey) as? [String: NotificationObserver] ?? [:]
        observers[name.rawValue] = NotificationObserver(center: self, name: name, object: object, callback: callback)
        objc_setAssociatedObject(self, &observersKey, observers, .OBJC_ASSOCIATION_RETAIN)
    }

    func listen(_ name: Notification.Name, object: Any?) -> Signal<Notification> {
        // Warning: 注意object可能對返回的Signal進行引用,從而造成迴圈引用
        return Signal { [weak self] observer in
            self?.callback(name, object: object, callback: { notification in
                observer.sendNext(notification)
            })
        }
    }
}複製程式碼

由此,我們可以基於上面對NotificationCenter的響應式擴充套件,來完成對UITextFiled文字變化的監聽:

extension UITextField {
    var listen: Signal<String?> {
        return NotificationCenter.default.listen(.UITextFieldTextDidChange, object: self).map { $0.object as? UITextField }.map { $0?.text }
    }
}

// 使用
textField.listen.map { "Input: \($0 ?? "")" }.subscribe(next: {
    print($0)
})複製程式碼

方法呼叫監聽 / 代理呼叫監聽

我們有時候想監聽某個物件中指定方法的呼叫,來實現面向切面程式設計或者埋點,另外,當函式式響應式被引入後,我們希望它能充當代理的職責,監聽代理方法的呼叫。為此我們可以通過對函式式響應式進行擴充套件來支援上面的需求。不過要做這件事情並不簡單,這裡面要涉及多種Runtime特性,如方法交換、方法動態派發、isa交換等Runtime黑科技,要實踐它可能需要投入較大精力,花費較長時間。因本人能力與時間有限,沒有去編寫相應的程式碼,若大家有興趣可以嘗試一下,而後期如果我做了相關的努力,也會公佈出來。

為什麼沒有Disposable

若我們接觸過RxSwiftReactiveSwift,我們會發現每次我們訂閱完一個Observable或者Signal後,會得到訂閱方法返回的一個專門用於回收資源的例項,比如RxSwift中的Disposable,我們可以通過在某個時機呼叫它的dispose方法,或者將其放入一個DisposeBag中來使得資源在最後得到充分的回收。

再來看回我們在上面實現的響應式框架,因為這個框架的實現非常簡單,並不會在訂閱後返回一個專門提供給我們釋放資源的例項,所以我們在使用它的時候要密切留意資源的存活與釋放問題。這裡舉一個例子:

在上面,我們對函式式響應式進行鍼對UIControl的適配時,是通過一箇中間層ControlTarget來完成的,為了保持這個ControlTarget例項的存活,使得它不會被自動釋放,我們先用一個集合來包裹住它,並將這個集合設定為目標UIControl的關聯物件。此時我們可以將這個中間層ControlTarget看做是這個事件流管道中的一個資源,這個資源的銷燬是由目標UIControl來決定的。

對於RxSwift來說,它實現對UIControl的擴充套件原理跟我們寫的差不多,也是通過一箇中間層來完成,但是對於中間層資源的保活與銷燬,它採用的是另一種方法,我們可以看下這段RxSwift的原始碼(為了簡單,刪掉了一些無關的程式碼):

class RxTarget {
    private var retainSelf: RxTarget?

    init() {
        self.retainSelf = self
    }

    func dispose() {
        self.retainSelf = nil
    }
}複製程式碼

這個型別的保活方式十分巧妙,它利用自己對自己的迴圈引用來使得維持生存,而當呼叫dispose方法時,它將解開對自己的迴圈引用,從而將自己銷燬。

通過上面兩個例子的對比,我們可以知道,對於我們自己實現的響應式框架,我們需要把某些精力放在對資源的保活與釋放上,而像RxSwift,它則提供一個統一的資源管理方式,相比起來更加清晰優雅,大家有興趣可以實現一下這種方式。

相關連結

Github - ReactiveObjc
Github - ReactiveCocoa
Github - RxSwift

本文純屬為個人見解,若大家發現文章部分有誤,歡迎在評論區提出。

相關文章