[譯] iOS 響應式程式設計:Swift 中的輕量級狀態容器

DeepMissea發表於2017-08-18

iOS 響應式程式設計:Swift 中的輕量級狀態容器

事物的狀態

在客戶端架構如何工作上,每一個 iOS 和 MacOS 開發者都有不同的細微見解。從最經典的蘋果框架所內嵌的
MVC 模式(讀作:臃腫的檢視控制器),到那些 MV* 模式(比如 MVP,MVVM),再到聽起來有點嚇人的 Viper,那麼我們該如何選擇?

這篇文章並不會回答你的問題,因為正確的答案是依據環境而定的。我想要強調的是一個我很喜歡並且經常看到的基本方法,名為狀態容器

狀態容器是什麼?

實質上,狀態容器只是一個圍繞資訊的封裝,是資料安全輸入輸出的守護者。他們不是特別在意資料的型別和來源。但是他們非常在意的是當資料改變的時候。狀態容器的中心思想就是,任何由於狀態改變產生的影響都應該以有組織並且可預測這種方式在應用裡傳遞。

狀態容器以與執行緒鎖相同的方式提供安全的狀態。

這並不是一個新的概念,而且它也不是一個你可以整合到整個應用的工具包。狀態容器的理念是非常通用的,它可以融入進任何應用程式架構,而無需太多的附加規則。但是它是一個強大的方法,是很多流行庫(比如ReactiveReSwift)的核心,比如 ReSwiftReduxFlux 等等,這些框架的成功和絕對數量說明了狀態容器模式在現代移動應用中的有效性。

就像 ReSwift 這樣的響應式庫,狀態容器將 ActionView 之間的缺口橋聯為單向資料流的一部分。然而即使沒有其他兩個元件,狀態容器也很強力。實際上,他們可以做的比這些庫使用的更多。

在這篇文章中,我會演示一個基本的狀態容器實現,我已經把它用於各種沒有引入大型架構庫的專案中。

構建一個狀態容器

讓我們從構建一個基本的 State 類開始。

/// Wraps a piece of state.
class State<Type> {

    /// Unique key used to identify the state across the application.
    let key: String
    /// Holds the state itself.
    fileprivate var _value: Type

    /// Used to synchronize changes to the state value.
    fileprivate let lockQueue: DispatchQueue

    /// Create a state container with the provided `defaultValue`, and associate it with a `key`.
    init(_ defaultValue: Type, key: String) {
        self._value = defaultValue
        self.key = key
        self.lockQueue = DispatchQueue(label: "com.stateContainers.\(key)", attributes: .concurrent)
    }

    /// Invoke this method after manipulating the state.
    func didModify() {
        print("State for key \(self.key) modified.")
    }
}複製程式碼

這個基類封裝了一個任何 Type_value,通過一個 key 關聯,並宣告瞭一個提供 defaultValue 的初始化器。

讀取狀態

為了讀取我們狀態容器的當前值,我們要建立一個計算屬性 value

由於狀態改變通常是由多執行緒觸發並讀取的,所以我們要通過 GCD 使用一個讀寫鎖來確保訪問內部 _value 屬性時的執行緒安全。

 extension State {

    /// The current state value.
    var value: Type {
        var retVal: Type!
        self.lockQueue.sync {
            retVal = self._value // I wish there was a `sync` method that inferred a generic return value.
        }
        return retVal
    }
}複製程式碼

改變狀態

為了改變狀態,我們還要建立一個 modify(_newValue:) 函式。雖然我們可以允許直接訪問設定器,但在這裡的目的是圍繞狀態改變來定義結構。在使用簡單屬性設定器的方法中,通過與我們 API 通訊修改狀態產生的影響。因此,所有的狀態改變都必須通過這個方法來達成。

extension State {

    /// Modifies the receiver by assigning the `newValue`.
    func modify(_ newValue: Type) {
        self.lockQueue.async(flags: .barrier) {
            self._value = newValue
        }

        // Handle the repercussions of the modificationself.
        didModify()
    }
}複製程式碼

為了有趣一些,我們自定義一個運算子!

/// Modifies the receiver by assigning the right-hand side of the operator.
func ~> <T>(lhs: State<T>, rhs: T) {
    lhs.modify(rhs)
}複製程式碼

關於 didModify() 方法

didModify() 是我們狀態容器中最重要的一部分,因為它允許我們定義在狀態改變後所觸發的行為。為了能夠在任何時候這種情況發生時能夠執行自定義的邏輯,State 的子類可以覆蓋這個方法。

didModify() 也扮演著另一個角色。如果我們通用的 Type 是一個 class,狀態器就可以無需知道它就可以更改它的屬性。因此,我們暴露出 didModify() 方法,以便這些型別的更改可以手動傳播(見下文)。

這是在處理狀態時使用引用型別的固有危險,所以我建議儘可能使用值型別。

使用狀態容器

下面是如何使用我們 State 類的最基本的例子:

// State wrapping a value type
let themeColor = State(UIColor.blue, key: "themeColor")
print(themeColor.value) // "UIExtendedSRGBColorSpace 0 0 1 1"複製程式碼

我們也可以使用可選型別:

// State wrapping an optional value type
let appRating = State<Int?>(nil, key: "appRating")
print(String(describing: appRating.value)) // "nil"複製程式碼

改變狀態很容易:

appRating.modify(4)
print(String(describing: appRating.value)) // "Optional(4)"

appRating ~> nil
print(String(describing: appRating.value)) // "nil"複製程式碼

如果我們有無價值的型別(比如在狀態改變時,不觸發 didSet 的型別),我們呼叫 didModify() 方法,讓 State 知道這個改變:

classCEO : CustomDebugStringConvertible {
    var name: String

    init(name: String) {
        self.name = name
    }

    var debugDescription: String {
        return name
    }
}

// State wrapping a reference type
let currentCEO = State(CEO(name: "John Sculley"), key: "currentCEO")
print(currentCEO.value) // "John Sculley"
// 分配一個新的使用者屬性,不需要呼叫 `didSet`
currentCEO ~> CEO(name: "Steve Jobs")
print(currentCEO.value) // "Steve Jobs"
// 就地修改使用者,需要手動呼叫 `didSet`
currentCEO.value.name = "Tim Cook"
currentCEO.didModify()
print(currentCEO.value) // "Tim Cook"複製程式碼

手動呼叫 didModify() 是不好的,因為無法知道引用型別的內部屬性是否改變,因為他們是可以現場(in-place)改變的,如果你有好的方法,@我 @TTillage!

監聽狀態的改變

現在我們已經建立了一個基本的狀態容器,讓我們來擴充套件一下,讓它更強大。通過我們的 didModify() 方法,我們可以用特定子類的形式新增功能。讓我們新增一種方式,來“監聽”狀態的改變,這樣我們的 UI 元件可以在發生更改時自動更新。

定義一個 StateListener

第一步,讓我們定義一個這樣的狀態監聽器:

protocol StateListener : AnyObject {

    /// Invoked when state is modified.
    func stateModified<T>(_ state: State<T>)

    /// The queue to use when dispatching state modification messages. Defaults to the main queue.
    var stateListenerQueue: DispatchQueue { get }
}

extension StateListener {

    var stateListenerQueue: DispatchQueue {
        return DispatchQueue.main
    }
}複製程式碼

在狀態改變時,監聽器會在它選擇的 stateListenerQueue 上收到 stateModified(_state:) 呼叫,預設是 DispatchQueue.main

建立 MonitoredState 的子類

下一步,我們定義一個專門的子類,叫做 MonitoredState,它會對監聽器保持弱引用,並通知他們狀態的改變。一個簡單的實現方式是使用 NSHashTable.weakObjects()

class MonitoredState<Type> : State<Type> {

    /// Weak references to all the state listeners.
    fileprivate let listeners: NSHashTable<AnyObject>

    /// Used to synchronize changes to the listeners.
    fileprivate let listenerLockQueue: DispatchQueue

    /// Create a state container with the provided `defaultValue`, and associate it with a `key`.
    override init(_ defaultValue: Type, key: String) {
        self.listeners = NSHashTable<AnyObject>.weakObjects()
        self.listenerLockQueue = DispatchQueue(label: "com.stateContainers.listeners.\(key)", attributes: .concurrent)
        super.init(defaultValue, key: key)
    }

    /// All of the listeners associated with the receiver.
    var allListeners: [StateListener] {
        var retVal: [StateListener] = []
        self.listenerLockQueue.sync {
            retVal = self.listeners.allObjects.map({ $0 as? StateListener }).flatMap({ $0 }) // remove `nil` values
        }
        return retVal
    }

    /// Notifies all listeners that something changed.
    override func didModify() {
        super.didModify()

        let allListeners = self.allListeners

        let state = self
        for l in allListeners {
            l.stateListenerQueue.async {
                l.stateModified(state)
            }
        }
    }
}複製程式碼

無論何時 didModify 被呼叫,我們的 MonitoredState 類呼叫 stateModified(_state:) 上的監聽者,簡單!

為了新增監聽器,我們要定義一個 attach(listener:) 方法。和上面的內容很像,在我們的 listeners 屬性上,使用 listenerLockQueue 來設定一個讀寫鎖。

extension MonitoredState {

    /// Associate a listener with the receiver's changes.
    func attach(listener: StateListener) {
        self.listenerLockQueue.sync(flags: .barrier) {
            self.listeners.add(listener as AnyObject)
        }
    }
}複製程式碼

現在可以監聽任何封裝在 MonitoredState 裡任何值的改變了!

根據狀態的改變來觸發 UI 的更新

下面是一個如何使用我們新的 MonitoredState 類的例子。假設我們在 MonitoredState 容器中追蹤裝置的位置:

/// The device's current location.
let deviceLocation = MonitoredState<CLLocation?>(nil, key: "deviceLocation")複製程式碼

我們還需要一個檢視控制器來展示當前裝置在地圖上的位置:

// Centers a map on the devices's current locationclass
LocationViewController : UIViewController {

    @IBOutlet var mapView: MKMapView!

    override func viewDidLoad() {
        super.viewDidLoad()
        self.updateMapForCurrentLocation()
    }

    func updateMapForCurrentLocation() {
        if let currentLocation = deviceLocation.value {
            // Center the map on the device's location
            self.mapView.setCenter(currentLocation.coordinate, animated: true)
        }
    }
}複製程式碼

由於我們需要在 deviceLocation 改變的時候更新地圖,所以要把 LocationViewController 擴充套件為一個 StateListener

extension LocationViewController : StateListener {

    func stateModified<T>(_state: State<T>) {
        ifstate === deviceLocation {
            print("Location changed, updating UI")
            self.updateMapForCurrentLocation()
        }
    }
}複製程式碼

然後記住使用 attach(listener:) 把檢視控制器附加到狀態。實際上,這個操作可以在 viewDidLoadinit 或者任何你想要開始監聽的時候來做。

let vc = LocationViewController()
deviceLocation.attach(listener: vc)複製程式碼

現在我們正監聽 deviceLocation,一旦我們從 CoreLocation 得到一個新的定位,我們所要做的只是改變我們的狀態容器,我們的檢視控制器會自動的更新位置!

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    if let closestLocation = locations.first {
        // Triggers `updateMapForCurrentLocation` on the VC asynchronously on the main queue
        deviceLocation ~> closestLocation
    }
}複製程式碼

值得注意的是,由於我們使用了一個弱引用 NSHashTable,在檢視控制器被銷燬時,allListeners 屬性永遠也不會有 deviceLocation。沒有必要“移除”監聽器。

記住,在真實的使用場景裡,要確保檢視控制器的 view 在執行更新 UI 之前是可見的。

保持狀態

OK,現在我們正在獲得好的東東。我們可以把現在所需要的一切裝在狀態容器裡,並且保持可以隨時隨地使用。

  1. 我們現在有一個唯一的 key 用於與後備儲存關聯。
  2. 我們知道值的 Type,通知它應該如何保持。
  3. 我們知道什麼時候值需要從儲存器中載入,使用 init(_defaultValue:key:) 方法。
  4. 我們知道什麼時候值需要被儲存在儲存器中,使用 didModify() 方法。

使用 UserDefaults

讓我們建立一個狀態容器,它可以自動地儲存任何改變到 UserDefaults.standard 中,並且在初始化的時候重新載入之前的這些值。它同時支援可選型別和非可選型別。他也會自動序列化和反序列化符合 NSCoding 的型別,即使 UserDefaults 並沒有直接支援 NSCoding 的使用。

這裡是程式碼,我會在下面講解。

class UserDefaultsState<Type> : MonitoredState<Type> {

    ///1) Loads existing value from `UserDefaults.standard`if it exists, otherwise falls back to the `defaultValue`.
    public override init(_defaultValue:Type, key:String) {
        let existingValue = UserDefaults.standard.object(forKey: key)
        if let existing = existingValue as? Type {
            //2) Non-NSCoding value
            print("Loaded \(key) from UserDefaults")
            super.init(existing, key: key)
        } elseif let data = existingValue as? Data, let decoded = NSKeyedUnarchiver.unarchiveObject(with: data) as? Type {
            //3) NSCoding value
            print("Loaded \(key) from UserDefaults")
            super.init(decoded, key: key)
        } else {
            //4) No existing value
            super.init(defaultValue, key: key)
        }
    }

    ///5) Persists any changes to `UserDefaults.standard`.
    public override func didModify() {
        super.didModify()

        let val = self.value
        if let val = val as? OptionalType, val.isNil {
            //6) Nil value
            UserDefaults.standard.removeObject(forKey:self.key)
            print("Removed \(self.key) from UserDefaults")
        } elseif let val = val as? NSCoding {
            //7) NSCoding value
            UserDefaults.standard.set(NSKeyedArchiver.archivedData(withRootObject: val), forKey:self.key)
            print("Saved \(self.key) to UserDefaults")
        } else {
            //8) Non-NSCoding value
            UserDefaults.standard.set(val, forKey:self.key)
            print("Saved \(self.key) to UserDefaults")
        }

        UserDefaults.standard.synchronize()
    }
}複製程式碼

init(_defaultValue:key:)

  1. 我們的初始化方法檢查 UserDefaults.standard 是否已經包含一個由 key 對應的值。
  2. 如果我們能載入一個物件,並且它剛好是基本型別,我們可以立即使用它。
  3. 如果我們載入的是 Data,那麼使用 NSKeyedUnarchiver 解壓,它會被 NSCoding 儲存,然後我們立即使用它。
  4. 如果 UserDefaults.standard 裡沒有和 key 匹配的值,我們就使用已提供的 defaultValue

didModify()

  1. 在狀態改變的時候,我們想要自動儲存我們的狀態,這樣做的方法依賴於 Type
  2. 如果基本型別是 Optional 的,並且為 nil,我們只需要簡單的把值從 UserDefaults.standard 移除,檢查一個基本型別是否為 nil 有點棘手,不過 用協議擴充套件 Optional 是一個解決方法:
protocol OptionalType {

    /// Whether the receiver is `nil`.var isNil: Bool { get }
}

extension Optional : OptionalType {

    publicvar isNil: Bool {
        return self == nil
    }
}複製程式碼
  1. 如果我們的值符合 NSCoding,我們就需要使用 NSKeyedArchiver 來把它轉換成 Data,然後儲存它。
  2. 除此之外,我們只需把值直接儲存到 UserDefaults 中。

現在,如果我們想要獲得 UserDefaults 的支援,我們要做的僅僅是使用新的 UserDefaultsState 類!

UserDefaults.standard.set(true, forKey: "isTouchIDEnabled")
UserDefaults.standard.synchronize()

let isTouchIDEnabled = UserDefaultsState(false, key: "isTouchIDEnabled")
print(isTouchIDEnabled.value) // "true"

isTouchIDEnabled ~> falseprint(UserDefaults.standard.bool(forKey: "isTouchIDEnabled")) // "false"複製程式碼

我們的 UserDefaultsState 會在其值更改時自動更新它的後臺儲存。在應用啟動的時候,它會自動把 UserDefaultsState 中的現有值投入使用。

支援其他的資料儲存

這只是使用狀態容器的例子之一,State 如何擴充套件到智慧地儲存自己的資料。在我的專案中,也建立了一些子類,當發生更改時,它們將非同步地保留到磁碟或鑰匙串。你甚至可以通過使用不同的子類來觸發與遠端伺服器的同步或者將指定標記錄到分析庫中。它毫無限制。

應用級別的狀態管理

所以這些狀態容器放在哪裡呢?通常我把他們靜態儲存到一個 struct 裡,這樣可以在整個應用裡訪問。這與基於 Flux 庫儲存全域性應用狀態有些相似。

struct AppState {
    static let themeColor = State(UIColor.blue, key: "themeColor")
    static let appRating = State<Int?>(nil, key: "appRating")
    static let currentCEO = State(CEO(name: "Tim Cook"), key: "currentCEO")
    static let deviceLocation = MonitoredState<CLLocation?>(nil, key: "deviceLocation")
    static let isTouchIDEnabled = UserDefaultsState(false, key: "isTouchIDEnabled")
}複製程式碼

你可以使用分離或嵌入式的結構體以及不同的訪問級別來調整狀態容器的作用域。

結論

在狀態容器上管理狀態有很多好處。以前放在單例上的資料,或在網路代理中傳播的資料,現在已經在高層次上浮現出來並且可見。應用程式行為中的所有輸入都突然變得清晰可見並且組織嚴謹。

從 API 響應到特徵切換到受保護的鑰匙串項,使用狀態容器模式是圍繞關鍵資訊定義結構的優秀方式。狀態容器可以輕鬆地用於快取,使用者偏好,分析以及應用程式啟動之間需要保持的任何事情。

狀態容器模式讓 UI 元件不用擔心如何以及何時生成資料,並開始把焦點轉向如何把資料轉換成夢幻般的使用者體驗。

關於作者

CapTecher Tyler Tillage 位於亞特蘭大辦公室,在應用設計和開發有超過六年的經驗。 他專注於移動和 web 的前端產品,並且熱衷於使用成熟的設計模式和技術來構建卓越的使用者體驗。Tyler 曾為每個月數百萬使用者使用的零售和銀行業構建 iOS 應用程式。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章