PromisedArchitectureKit 2.2.0

PromisedArchitectureKit 2.2.0

Ricardo Pallas维护。



  • 作者:
  • rpallas92

PromisedArchitectureKit V2

CI Status Version License Platform

专为PromiseKit设计的最简单架构,现在V2更为简单易懂。

V2 目标

PromisedArchitectureKit V2 被设计成通过强制约束来保证正确性和简单性。

简介

PromisedArchitectureKit 是一个尝试强制执行正确性和简化应用程序和系统状态管理的库。它帮助您编写行为一致、易于测试的应用程序。它受到 Redux 和 RxFeedback 的启发。

动机

我一直试图找到一种正确的方法和架构来简化管理移动应用程序状态并易于测试的复杂性。

我最初从 模型-视图-控制器(MVC) 开始,然后是 模型-视图-视图模型(MVVM),还包括模型-视图-表示者(MVP)和Clean架构。与MVVM和MVP相比,MVC的测试并不容易。MVVM和MVP易于测试,但问题是UI状态可能会变得一团糟,因为没有集中更新的方式,代码中可能会有很多改变状态的方法。

随后出现了 ElmRedux,以及其他类似Redux的架构,如Redux-Observable、RxFeedback、Cycle.js、ReSwift等。这些架构(包括PromisedArchitectureKit)与MVP的主要区别在于,它们引入了对UI状态更新方式的约束,以强制执行正确性并使应用更容易推理。

与这些类似Redux的架构不同,PromisedArchitectureKit使用异步reducer(使用PromiseKit)来封装效果,然后为你运行副作用,并使用结果调用UI。

PromisedArchitectureKit帮助你运行副作用。你的代码保持100%纯净。

快速开始

安装

PromisedArchitectureKit可以通过CocoaPods获得。要安装它,只需将以下行添加到Podfile中

pod 'PromisedArchitectureKit'

PromisedArchitectureKit

PromisedArchitectureKit本身非常简单。它看起来是什么样子的

self.system = System.pure(
	initialState: State.start,
   	reducer: State.reduce,
   	uiBindings: [view?.updateUI]
)

核心概念

您应用的每个屏幕(以及整个应用)都有自己的状态。在PromisedArchitectureKit中,这个状态由枚举表示。例如,电子商务应用中的商品详情页面(PDP)的状态可能如下所示

enum State {
    case start
    case loading
    case productLoaded(Product)
    case addedToCart(Product, CartResponse)
    case error(Error)
}

在这个屏幕上,应用加载商品,然后它可以显示商品或错误。商品加载完成后,用户可以将它添加到购物车。

这个State枚举代表了电子商务应用中“PDP屏幕”的状态。通过使用实际上代表屏幕状态的枚举的方式,视图直接映射到状态

view = f(state).

这个“f”函数将是我们在后面会看到的UI绑定函数。

要改变状态中的某些内容,您需要分发一个事件。事件是一个描述发生了什么的枚举。以下是一些示例事件

enum Event {
    case loadProduct
    case addToCart
}

通过要求每次更改都描述为一个事件,我们可以清晰地了解应用中的情况。如果有什么变化,我们就知道为什么会有这样的变化。

事件就像发生了什么事件的面包屑。最后,为了将状态和动作联系起来,我们编写了一个名为 reducer 的函数。reducer 只是一个函数,它 接受状态和动作作为参数,并返回应用的下个状态(异步方式)

(State, Event) -> AsyncResult<State>

AsyncResult 只是 Promise 的包装。

我们为每个屏幕的每个状态编写一个 reducer 函数。对于 PDP 屏幕

   static func reduce(state: State, event: Event) -> AsyncResult<State> {
       switch event {

       case .loadProduct:
           let productResult = getProduct(cached: false)
           
           return productResult
               .map { State.productLoaded($0) }
               .stateWhenLoading(State.loading)
               .mapErrorRecover { State.error($0) }
           
       case .addToCart:
           let productResult = getProduct(cached: true)
           let userResult = getUser()
           
           return AsyncResult<(Product, User)>.zip(productResult, userResult).flatMap { pair -> AsyncResult<State> in
               let (product, user) = pair
               
               return addToCart(product: product, user: user)
                   .map { State.addedToCart(product, $0) }
                   .mapErrorRecover{ State.error($0) }
           }
           .stateWhenLoading(State.loading)
       }
   }

请注意,reducer 是一个纯函数,在引用透明度方面,并且对于状态 S 和事件 E,它总是返回相同的状态描述,并且没有副作用(它只返回描述效果的描述,库将为您运行它们)。

这基本上是 PromisedArchitectureKit 的基本思想。 注意,我们没有使用任何 PromisedArchitectureKit API。它提供了一些工具来简化这种模式,但主要思想是描述您在响应事件的情况下如何在时间上更新状态,并且您编写的 90% 代码都是标准的 Swift,因此可以轻松测试 UI 逻辑。

但异步代码和副作用,如 API 调用、数据库调用、日志记录、读取和写入文件怎么办?

使用 PromiseKit 作为时间抽象

Promise 用于处理异步操作。PromisedArchitectureKit 使用它们来触发对某些状态的反应。Promise 的示例

    func getProduct() -> Promise<Product> {
        return Promise { seal in
            DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
                seal.fulfill("Yeezy 500")
            }
        }
    }

该函数返回一个将返回产品的 Promise。它等待 5 秒钟,然后返回该产品。它模拟了一个网络调用。

不要害怕 AsyncResult

AsyncResult 只是比 Promise 更强大一些的包装器。它就像是强化版的 Promise。

但别担心。如果你的整个应用都使用 Promises,那也没关系。你可以继续使用 Promises,并在 reducer 函数中轻松地将它们转换为 AsyncResults。

如何从一个 Promise 获取 AsyncResult?

let asyncResult = AsyncResult(promise)

就这么多!

如果想执行网络调用、数据库调用等操作怎么办?

如果我们想从后端加载产品,就需要进行网络调用,这是一个副作用,并且它是异步的。

为了实现这一点,我们将使用 Promises 来处理异步代码。由于 reducer 函数以异步方式返回新状态,我们可以将 Promises 映射到新状态。

例如,我们处于“启动状态”,并且我们希望在触发“loadProduct”事件时加载产品并进入“loadedProduct”状态。在reducer中我们执行

    static func reduce(state: State, event: Event) -> AsyncResult<State> {
        switch event {

        case .loadProduct:
            let productResult = getProduct(cached: false)
            
            return productResult
                .map { State.productLoaded($0) }
                .stateWhenLoading(State.loading)
                .mapErrorRecover { State.error($0) }
                
        (...)

这是在做什么?按步骤分解

  • 当触发“loadProduct”事件时
	switch event {
   		case .loadProduct:
  • 我们获得了产品(AsyncResult)
	let productResult = getProduct(cached: false)
  • 如果产品成功检索,我们将返回“loadedProduct”状态
return productResult
 	.map { State.productLoaded($0) }
  • 我们希望在Promise执行并得到解决之前,发送加载状态到UI,以便UI可以显示加载指示器
.stateWhenLoading(State.loading)
  • 如果产品未能成功检索,我们将返回错误状态
	.mapErrorRecover { State.error($0) }

相当简单且清晰。

这里没有副作用:这里只描述了它。实际上,副作用将由库执行。

更新视图

在新状态改变后,视图的updateUI函数会根据新状态被调用。然后视图将负责更新其UI组件。

示例

    func updateUI(state: State) {
        showLoading()
        addToCartButton.isEnabled = false
        refreshButton.isHidden = false

    
        switch state {
        case .start:
            productTitleLabel.text = ""
            descriptionLabel.text = ""
            imageView.image = nil
        case .loading:
            refreshButton.isHidden = true
            showLoading()
            
        case .productLoaded(let product):
            productTitleLabel.text = product.title
            descriptionLabel.text = product.description
            updateImage(with: product.imageUrl)
            addToCartButton.isEnabled = true
            hideLoading()
            
        case .error(let error):
            descriptionLabel.text = error.localizedDescription
            hideLoading()
            
        case .addedToCart(_, let cartResponse):
            hideLoading()
            addToCartButton.isEnabled = true
            showAddedToCartAlert(cartResponse)
        }

        print(state)
    }

因此,演示者会计算下一个状态,并将其发送到视图。视图将相应地绘制自己。

库底层做什么?

库的核心很小。可以在这里粘贴

//
//  System.swift
//  PromisedArchitectureKit
//
//  Created by Pallas, Ricardo on 7/3/18.
//

import Foundation
import PromiseKit

public final class System<State, Event> {

    internal var eventQueue = [Event]()
    internal var callback: ((State) -> ())? = nil

    internal var initialState: State
    internal var reducer: (State, Event) -> AsyncResult<State>
    internal var uiBindings: [((State) -> ())?]
    internal var currentState: State

    private init(
        initialState: State,
        reducer: @escaping (State, Event) -> AsyncResult<State>,
        uiBindings: [((State) -> ())?]
        ) {
        self.initialState = initialState
        self.reducer = reducer
        self.uiBindings = uiBindings
        self.currentState = initialState
    }

    public static func pure(
        initialState: State,
        reducer: @escaping (State, Event) -> AsyncResult<State>,
        uiBindings: [((State) -> ())?]
        ) -> System {
        
        let system = System<State,Event>(initialState: initialState, reducer: reducer, uiBindings: uiBindings)
        system.bindUI(initialState)
        return system
    }

    public func addLoopCallback(callback: @escaping (State)->()){
        self.callback = callback
    }

    var actionExecuting = false

    public func sendEvent(_ action: Event) {
        assert(Thread.isMainThread)
        if actionExecuting {
            self.eventQueue.append(action)
        } else {
            actionExecuting = true
            let _ = doLoop(action).done { state in
                assert(Thread.isMainThread, "PromisedArchitectureKit: Final callback must be run on main thread")
                if let callback = self.callback {
                    callback(state)
                }
                self.actionExecuting = false
                if let nextEvent = self.eventQueue.first {
                    self.eventQueue.removeFirst()
                    self.sendEvent(nextEvent)
                }
            }
        }
    }

    private func doLoop(_ event: Event) -> Promise<State> {
        return Promise.value(event)
            .then { event -> Promise<State> in

                let asyncResultState = self.reducer(self.currentState, event)

                if let stateWhenLoading = asyncResultState.loadingResult {
                    self.bindUI(stateWhenLoading)
                }

                return asyncResultState.promise
            }
            .map { state in
                self.currentState = state
                self.bindUI(state)
                return state
            }
    }

    private func bindUI(_ state: State) {
        self.uiBindings.forEach { uiBinding in
            uiBinding?(state)
        }
    }
}

它对doLoop函数执行循环。什么是循环?它是事件触发、新状态计算以及相应更新UI的全过程。

以加载产品为例

  1. 视图发送一个loadProduct事件。调用sendEvent函数,该函数调用doLoop函数。

  2. doLoop函数执行reducer抛出的副作用并异步获取新状态。如果指定了加载状态,它将在运行副作用之前通知UI。之后,它更新当前状态并使用新状态调用UI。

总之:该系统监听事件,运行副作用以获取新状态,并通知UI状态已更改。

为什么我应该使用PromisedArchiterueKit V2 ?

如前所述,库的目标是为强制执行正确性约束,并使架构更容易阅读和推理。这些约束是:每个屏幕的状态数量是有限的,可以更改状态的事件数量是有限的,库决定何时更新UI。

这些限制有一定的优势,这种权衡是值得的。该库提供的主要优势包括

  • 库将所有副作用_channel执行,使您的代码保持纯净。
  • 当需要时,库会更新视图,您无需关心。
  • 您可以通过读取State枚举了解屏幕的内容。
  • 编译时就知道您的视图句柄是状态。
  • 通过读取Event枚举您知道可以执行哪些操作。
  • 编译时您知道所有事件都由RxPresenter处理。
  • 每次状态变化时都会调用一个单独的函数。这可以用来做好的数据分析,例如。

示例

要运行示例项目,请克隆仓库,然后首先从示例目录运行pod install

ViewController的代码

import UIKit
import PromisedArchitectureKit

class ViewController: UIViewController, View {
    
    @IBOutlet weak var productTitleLabel: UILabel!
    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var descriptionLabel: UILabel!
    @IBOutlet weak var addToCartButton: UIButton!
    @IBOutlet weak var refreshButton: UIButton!
    
    var presenter: Presenter! = nil
    var indicator: UIActivityIndicatorView! = nil
    
    override func viewDidLoad() {
        super.viewDidLoad()
        addLoadingIndicator()
        
        presenter = Presenter(view: self)
        presenter.controllerLoaded()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        presenter.sendEvent(Event.loadProduct)
    }
    
    private func addLoadingIndicator() {
        indicator = UIActivityIndicatorView(style: UIActivityIndicatorView.Style.gray)
        indicator.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height)
        indicator.center = view.center
        view.addSubview(indicator)
        view.bringSubviewToFront(indicator)
        UIApplication.shared.isNetworkActivityIndicatorVisible = true
    }
    
    // MARK: - User Actions
    @IBAction func didTapRefresh(_ sender: Any) {
        presenter.sendEvent(Event.loadProduct)
    }
    
    @IBAction func didTapAddToCart(_ sender: Any) {
        presenter.sendEvent(Event.addToCart)
    }

    // MARK: - User Outputs
    func updateUI(state: State) {
        showLoading()
        addToCartButton.isEnabled = false
        refreshButton.isHidden = false

    
        switch state {
        case .start:
            productTitleLabel.text = ""
            descriptionLabel.text = ""
            imageView.image = nil
        case .loading:
            refreshButton.isHidden = true
            showLoading()
            
        case .productLoaded(let product):
            productTitleLabel.text = product.title
            descriptionLabel.text = product.description
            updateImage(with: product.imageUrl)
            addToCartButton.isEnabled = true
            hideLoading()
            
        case .error(let error):
            descriptionLabel.text = error.localizedDescription
            hideLoading()
            
        case .addedToCart(_, let cartResponse):
            hideLoading()
            addToCartButton.isEnabled = true
            showAddedToCartAlert(cartResponse)
        }

        print(state)
    }
    
    private func showLoading() {
        indicator.startAnimating()
    }
    
    private func hideLoading() {
        indicator.stopAnimating()
    }
    
    private func showAddedToCartAlert(_ message: String) {
        let alertController = UIAlertController(title: "Added to cart", message:
            message, preferredStyle: UIAlertController.Style.alert)
        alertController.addAction(UIAlertAction(title: "Dismiss", style: UIAlertAction.Style.default,handler: nil))
        self.present(alertController, animated: true, completion: nil)
    }
    
    private func updateImage(with urlPath: String) {
        if let url = URL(string: urlPath), let data = try? Data(contentsOf: url) {
            let image = UIImage(data: data)
            imageView.image = image
        }
    }

}

Presenter的代码

import Foundation
import PromisedArchitectureKit
import PromiseKit

typealias CartResponse = String
typealias User = String

struct Product: Equatable {
    let title: String
    let description: String
    let imageUrl: String
}

protocol View: class {
    func updateUI(state: State)
}

// MARK: - Events
enum Event {
    case loadProduct
    case addToCart
}

// MARK: - State
enum State {
    case start
    case loading
    case productLoaded(Product)
    case addedToCart(Product, CartResponse)
    case error(Error)
    
    static func reduce(state: State, event: Event) -> AsyncResult<State> {
        switch event {

        case .loadProduct:
            let productResult = getProduct(cached: false)
            
            return productResult
                .map { State.productLoaded($0) }
                .stateWhenLoading(State.loading)
                .mapErrorRecover { State.error($0) }
            
        case .addToCart:
            let productResult = getProduct(cached: true)
            let userResult = getUser()
            
            return AsyncResult<(Product, User)>.zip(productResult, userResult).flatMap { pair -> AsyncResult<State> in
                let (product, user) = pair
                
                return addToCart(product: product, user: user)
                    .map { State.addedToCart(product, $0) }
                    .mapErrorRecover{ State.error($0) }
            }
            .stateWhenLoading(State.loading)
        }
    }
}

fileprivate func getProduct(cached: Bool) -> AsyncResult<Product> {
    let delay: DispatchTime = cached ? .now() : .now() + 3
    let product = Product(
        title: "Yeezy Triple White",
        description: "YEEZY Boost 350 V2 “Triple White,” aka “Cream”. \n adidas Originals has officially announced its largest-ever YEEZY Boost 350 V2 release. The “Triple White” iteration of one of Kanye West’s most popular silhouettes will drop again on September 21 for a retail price of $220. The sneaker previously dropped under the “Cream” alias.",
        imageUrl: "https://static.highsnobiety.com/wp-content/uploads/2018/08/20172554/adidas-originals-yeezy-boost-350-v2-triple-white-release-date-price-02.jpg")
    
    let promise = Promise { seal in
        DispatchQueue.main.asyncAfter(deadline: delay) {
            seal.fulfill(product)
        }
    }

    return AsyncResult<Product>(promise)
}

fileprivate func addToCart(product: Product, user: User) -> AsyncResult<CartResponse> {
    let randomNumber = Int.random(in: 1..<10)

    let failedPromise = Promise<CartResponse>(error: NSError(domain: "Error adding to cart",code: 15, userInfo: nil))
    let promise = Promise<CartResponse>.value("Product: \(product.title) added to cart for user: \(user)")

    if randomNumber < 5 {
        return AsyncResult<CartResponse>(failedPromise)
    } else {
        return AsyncResult<CartResponse>(promise)
    }
}

fileprivate func getUser() -> AsyncResult<User> {
    let promise = Promise { seal in
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            seal.fulfill("Richi")
        }
    }

    return AsyncResult<User>(promise)
}

// MARK: - Presenter
class Presenter {
    
    var system: System<State, Event>?
    weak var view: View?
    
    init(view: View) {
        self.view = view
    }
    
    func sendEvent(_ event: Event) {
        system?.sendEvent(event)
    }
    
    func controllerLoaded() {
        system = System.pure(
            initialState: State.start,
            reducer: State.reduce,
            uiBindings: [view?.updateUI]
        )
    }
}

额外功能:分析

如果您想向您的应用添加分析,您会在代码中频繁调用一些TrackingService.trackEvent方法。这有时会变得很乱。

幸运的是,PromiseArchitectureKit包括了"addLoopCallback(callback: @escaping (State)->())"函数,该函数将在每次状态变化时被调用。该函数接收新状态作为参数,可用于分析。

分析示例

func handleAnalitycs(state: State) {
    switch state {
    case .start:
        EventTracker.trackEvent(event: .pdpShown)
        
    case .loading:
        EventTracker.trackEvent(event: .pdpLoading)

    case .productLoaded(let product):
        EventTracker.trackEvent(event: .productLoaded, attr: product)

    case .error(let error):
        EventTracker.trackEvent(event: .pdpError, attr: error)

        
    case .addedToCart(let product, _):
        EventTracker.trackEvent(event: .pdpAddedToCart, attr: product)

    }
}


func controllerLoaded() {
    system = System.pure(
        initialState: State.start,
        reducer: State.reduce,
        uiBindings: [view?.updateUI]
    )
        
    system?.addLoopCallback(callback: handleAnalytics)
}
    

通过将handleAnalytics方法作为一个系统循环回调,我们将所有分析集中在一个地方。

免责声明:这只适用于与逻辑相关的分析。如果您需要跟踪像“用户滚动了”这样的东西,您将需要像不使用库一样执行。

作者

Ricardo Pallás

许可证

PromisedArchitectureKit遵循MIT许可证。有关更多信息,请参阅LICENSE文件。