ReduxVM 2.0.4

ReduxVM 2.0.4

Kocherovets Dmitry 维护。



 
依赖
DeclarativeTVC>= 0
RedSwift~> 2.0
 

ReduxVM 2.0.4

  • 作者
  • Dmitry Kocherovets

描述

ReduxVM - 是用于构建 iOS 应用的库,其结构如下所示:

ReduxVM

箭头显示了数据在不同模块间流动的方向。如您所见,ReduxVM 实现了单向架构。唯一违反这一规则的地方是副作用。因为数据只能单向流动,所以更容易跟踪代码并以视觉形式识别应用中的任何问题。

整个架构分为两部分 - 背景(Background)和前景(Foreground)。ReduxVM 自动实现这种划分。根据名称,这些部分分别在主线程或后台线程执行。既然视图模块只在主线程上执行,因此 ReduxVM 出盒禁用了其他模块执行 UI 操作造成的阻塞。需要注意的是,整个背景部分在同一个线程上同步执行。但是,每个动作都会添加一个新的任务到这个线程,因此所有动作都会按顺序执行,不会相互干扰。

以下是所有模块及其简要描述。

  • 状态(State) 存储应用当前状态,并允许模块订阅以跟踪变化。在 ReduxVM 中,表示者和交互者模块连接到状态模块,但也可以直接订阅状态(如果需要的话)。状态是 Swift 结构体,通常由多个子结构体组成,描述不同的业务逻辑部分。
  • 动作(Action) 初始化应用状态的变化。它代表一个 Swift 结构体。其目的是简单通过类型和可能的字段来存储关于当前动作的信息。动作从视图转换到后台线程,并且在那里被处理。需要注意的是,只有通过动作才能改变状态,而且只有动作才有读取状态的权限。
  • Reducer 是一个改变 State 的纯同步函数。在 ReduxVM 中,Reducer 是一个接受当前 State 作为输入并输出新的 State 的结构 Action 方法,该新 State 将覆盖当前 State。严格来说,State 作为输入输出参数(inout)传递。需要注意的是,它是一个纯函数,除了替换一个结构为另一个结构——旧 State 为新 State——没有其他操作。Reducer 总是在系统中注册新 Action 之后运行一次。
  • Presenter 是 State 和 UI 之间的连接点。创建 View 时也会创建相应的 Presenter。Presenter 在创建时会订阅 State,并在其更新时接收通知。存在几种机制来优化从 State 到 Presenter 的无谓调用。Presenter 的任务是将其关联的 State 数据转换到 Props,以便 View 显示。
  • Props 是一个包含简单信息以供 View 通过模块显示的结构。此类信息包括字符串、数字、日期和其他原始数据类型。字段描述了 Props 关联的 View 结构,而不涉及业务逻辑和领域知识。此外,Props 可能包含 Action 触发器。Props 并不知道这些触发器是什么以及它们的用途,它只简单地存储它们。View 继承自 UIViewController,应该仅显示通过 Props 传递给它信息。它还知道,例如,当点击按钮时,它应该从 Props 启动某个 Action,但就像 Props 一样,它并不知道它启动了什么。重要的是要注意,View 不应该知道任何关于业务逻辑的信息;如果存储应用程序状态信息,那应该是纯 UI 相关的信息。例如,在 UIViewController 中动画状态。
  • Interactor 是与 UI 没有直接关联的 Store 订阅者,即 Interactor 的代码在后台线程中执行。Interactor 是一组 SideEffects 的管理实体,这些 SideEffects 通常在程序上具有逻辑相关性。例如,这可能是一个处理网络的 Interactor。每当 State 更改时,每个 Interactor 都会检查其 SideEffects,了解它们是否应该启动,如果是,则启动。
  • SideEffect 由两部分组成。第一部分是检查 State 当前状态下的触发条件。第二部分是反应于 State 变更的一定动作。SideEffect 没有写入 State 的权限,因此它在工作完成后(例如,从网络获取数据后),注册一个在系统中会改变 State 的 Action。

ReduxVM 为所有 State 订阅者提供的是不仅提供当前的 State 版本,而且还提供了一个包含当前 State、前一个 State、导致此更改的 Action 以及用于简化当前状况分析的辅助方法的特殊对象 Box。

优点和缺点

优点

  • 大量标准化的模块可以在学习后轻松定位需要关注的地方。
  • 单向数据流:实现多向数据流的程序可能难以阅读和调试。一次更改可能导致一系列事件将数据发送到应用程序的各个部分。单向流更可预测,显著降低了阅读代码所需的认知负荷。
  • 目前尚无良好的处理 Swift 中横切关注点(Cross Cutting Concern)的方法。在 ReduxVM 中,您可以免费获得它。您可以使用 middleware 和 interactor 解决各种任务,这使您能够轻松应对诸如日志记录或统计等问题。
  • 测试:ReduxVM 的创建是为了使其非常简单易测试。Reducer 包含测试所需的代码,并且它们是纯函数。纯函数总是对相同的输入产生相同的结果,不依赖于应用程序的状态并且没有副作用。
  • 在 ReduxVM 中,Presenter 模块的标准实现仪魔术括号包含一个或两个纯函数,这使得测试变得非常简单。不需要进行 Props 测试。
  • View 模块只依赖于其 Props,因此只需创建 Props 的测试结构,就可以使用任何所需数据渲染 View 并测试其功能,例如,通过比较生成的截图与模板。
  • 负责检查触发性的 SideEffect 部分也是纯函数。可以通过遵循在 演讲 中介绍的方法来大幅度简化其余 SideEffect 的测试。ReduxVM 自然支持这种方法。
  • 在状态由单个结构定义并且流程单向时,调试变得更简单。
  • 由于系统中几乎所有的活动都是通过 Action 触发的,并且 Action 遵循相同的路径,因此可以在日志中打印出几乎所有的应用程序工作信息,这很容易就帮助了调试。
  • 可以通过保存应用到 State 的所有 Action 来组织保存,然后可以根据初始 State 和这个历史记录来恢复特定软件会话的全部生命周期,这将非常有用,特别是对于调试。
  • 还可以通过用所需数据填充 Store 的测试内容并立即展示所需的应用程序屏幕来简化开发。因为屏幕的工作仅取决于 State -> Presenter -> Props -> View 的组合,所以可以直接看到它的外观,而不需要通过常规方法可能要经过的漫长路径。
  • ReduxVM 可以很好地处理应用程序的增长。因为应用程序的状态存储在一个树状结构中,所以只需添加所需的子结构就可以扩展应用支持的数据模型。
  • 这也有助于解决支持多个具有基本功能应用程序的问题。可以保持在一个项目内,并建立几个目标,其中包括只提供从子结构收集 State 的不同源代码。每个子项目都将只包含其所需的全部数据。
  • Reducer 包含了对应用程序状态变化的清晰描述,阅读它们可以获得足够全面的了解,即它是如何工作的。
  • State 是应用程序的所有内容的唯一真相来源。因此排除了在应用程序的不同位置显示相同逻辑数据实际上显示不同数据的情况。任何对 State 的更改都会自动传播到所有活动的 View。这对于与安全和财务相关的应用程序尤其重要。
  • 尽管功能强大,但 ReduxVM 的架构非常简单,初学者需要一天左右的时间来熟练掌握。
  • ReduxVM 允许将业务逻辑与副作用分离。
  • State 的实现采用了普通的 Swift 结构,这些结构是不变的,因此不需要担心 Store 中未经架构定义的意外更改。
  • 平台无关性:RedSwift 的所有元素——Stores、Reducers 和 Actions——都独立于平台。可以轻松地用于 iOS、macOS 或 tvOS,在 iPhone 和 iPad 应用程序之间共享业务逻辑。

缺点

缺点更多与纯 Redux 架构有关。因此,每个缺点旁边都指出了 ReduxVM 中的问题解决方案。

  • 高度模块化导致文件数量增加。因此,即使是简单的更改也可能需要同时在多个位置进行编辑。这需要完全掌握架构。《ReduxVM 有模板,可以自动生成所有必要文件和它们之间的连接,为 ViewController。
  • 由于State可能很大,所以编写Reducer时需要小心,以免占用过多时间。在ReduxVM中,这部分代码在后台线程中运行,因此降低了其对UI的影响。
  • 数据模型的开发比其他方法更为复杂。然而,一旦模型开发完成,对整个应用程序的工作理解会更加清晰。
  • 该方法鼓励使用声明式编程风格。绝大多数由此引起的问题通过ReduxVM引入的Presenter和Props以及以声明式风格编写的工作在表格和集合中的服务类来解决。在ReduxVM中使用DeclarativeTVC库来处理列表界面。

如何使用

代码示例取自专门用来演示ReduxVM工作的项目MoviesDB

State

创建项目从创建State开始。这是由StateType协议标记的结构。

struct MoviesState: StateType {

    enum Category: Int {
        case nowPlaying = 0
        case upcoming = 1
        case trending = 2
        case popular = 3
    }
    var selectedCategory = Category.nowPlaying

    var isNowPlayingLoading = false
    var nowPlayingPage: Int = 0
    var nowPlayingMovies = [ServerModels.Movie]()
    
    ...
}

项目中可能存在许多此类State,每个State都负责应用程序的一部分。它们被组织在满足RootStateType协议的根State中。

struct State: RootStateType {

    var moviesState = MoviesState()
    
    ...
}

如图所示,State中的字段以var的形式创建,以便可以编辑它们。

Store

程序的主要State管理由Store类执行。

open class Store<State: RootStateType>: StoreTrunk {

...

    public required init(
        state: State?,
        queue: DispatchQueue,
        middleware: [Middleware] = [],
        statedMiddleware: [StatedMiddleware<State>] = []
    )

在这里,state是我们的根状态;queue是执行整个系统的后台队列(图中的蓝色区域);middleware是一组Middleware。

DI

该库假定使用某种依赖注入(DI)实现来绑定其组件。示例中使用的是DITranquillity库,但也可以使用任何其他库,例如Swinject。初始化库的绑定示例可能如下所示。

public class AppFramework: DIFramework {
    public static func load(container: DIContainer) {

        container.register (State.init)
            .lifetime(.single)

        container.register { DispatchQueue(label: "queueTitle", qos: .userInteractive) }
            .as(DispatchQueue.self, name: "storeQueue")
            .lifetime(.single)

        container.register {
            Store<State>(state: $0,
                         queue: $1,
                         middleware: [
                             LoggingMiddleware(loggingExcludedActions: [])
                         ])
        }
            .lifetime(.single)

        container.register { APIInteractor(store: $0, api: UnauthorizedAPI.self) }
            .lifetime(.single)

        container.registerStoryboard(name: "Main").lifetime(.single)
        container.registerStoryboard(name: "Movies").lifetime(.single)
        container.registerStoryboard(name: "Movie").lifetime(.single)

        container.append(part: MoviesVCModule.DI.self)
        container.append(part: MoviesTVCModule.DI.self)
        container.append(part: Movies2TVCModule.DI.self)
        container.append(part: MovieVCModule.DI.self)
    }
}

let container = DIContainer()

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        DISetting.Log.level = .warning

        container.append(framework: AppFramework.self)

        if !container.validate() {
            fatalError()
        }

        container.initializeSingletonObjects()

        return true
    }

操作

接下来可以创建用于更改状态的 Actions

extension MoviesState {

   struct LoadAction: Action {

       let category: Category

       func updateState(_ state: inout State) {
           switch category {
           case .nowPlaying:
               state.moviesState.isNowPlayingLoading = true
           case .upcoming:
               state.moviesState.isUpcomingLoading = true
           case .trending:
               state.moviesState.isTrendingLoading = true
           case .popular:
               state.moviesState.isPopularLoading = true
           }
       }
   }
   
   ...

Action 的程序实现是一个结构,其字段是设定变化的参数,以及一个函数 func updateState(_ state: inout State),它通过引用传递根状态。

Reducer

updateState 函数中实际发生状态更新。换句话说,在这个函数的术语中,它是一个 Reducer。

限制操作

在实际使用库的过程中,我们发现引入一种特殊的 Action 类型很有用,这种类型在一段时间内不应被系统重复处理。例如,这有助于避免在不需要时重复触发按钮点击。为此,库中有一个 ThrottleAction 协议。

public protocol ThrottleAction {
    
    var interval: TimeInterval { get }
}

public extension ThrottleAction {
    
    var interval: TimeInterval {
        0.3
    }
}

...

struct IncrementAction: Action, ThrottleAction {

    func updateState(_ state: inout St) {
        state.counter.counter += 1
    }
}

中间件

在状态更新之前,Action 会由不能改变状态的 Middlewares 对象处理。它们可以用作,例如,日志记录。库支持两种类型的 Middlewares,其中一种是仅接收 Action,另一种 besides Action 还接收当前状态。

open class Middleware {

    public init() { }

    open func on(action: Dispatchable,
                 file: String,
                 function: String,
                 line: Int
    ) {

    }
}

open class StatedMiddleware<State: RootStateType> {

    public init() { }

    open func on(action: Dispatchable,
                 state: State,
                 file: String,
                 function: String,
                 line: Int
    ) {

    }
}

库中有一个默认的 LoggingMiddleware 实现。默认情况下,系统中的所有 Action 都会被记录,有时可能会方便地禁用某些 Action 的日志记录,在 loggingExcludedActions 中列出这些 Action。在 firstPart 参数中,设定从行的哪个元素开始记录文件名。例如,对于完整的路径 A/B/C/C/D,如果设置 firstPart 等于 C,则输出为 C/D。

public class LoggingMiddleware: Middleware {

    var consoleLogger = ConsoleLogger()

    var loggingExcludedActions = [Dispatchable.Type]()

    var firstPart: String?
    var startIndex: String.Index?

    public init(loggingExcludedActions: [Dispatchable.Type], firstPart: String? = nil) {

        super.init()
        self.loggingExcludedActions = loggingExcludedActions
        self.firstPart = firstPart
    }

    public override func on(action: Dispatchable,
                            file: String,
                            function: String,
                            line: Int) {

        if loggingExcludedActions.first(where: { $0 == type(of: action) }) == nil {

            let printFile: String
            if 
                startIndex == nil,
                let firstPart = firstPart
            {
                let components = file.components(separatedBy: firstPart + "/")
                if let component = components.last
                    {
                    startIndex = file.index(file.endIndex, offsetBy: -component.count - (firstPart + "/").count)
                }
            }
            if let startIndex = startIndex
            {
                let substring = file[startIndex..<file.endIndex]
                printFile = String(substring)
            }
            else
            {
                printFile = file
            }

            print("---ACTION---", to: &consoleLogger)
            dump(action, to: &consoleLogger)
            print("file: \(printFile):\(line)", to: &consoleLogger)
            print("function: \(function)", to: &consoleLogger)
            print(".", to: &consoleLogger)
            consoleLogger.flush()
        }
    }
}

VC

库包含两个类:VC 和 TVC,分别代替对应的 UIViewConttroler 和 UITableViewController。

class MoviesVC: VC, PropsReceiver {

    typealias Props = MoviesVCModule.Props

    override func render() {

        guard let props = props else { return }

        navigationItem.title = props.title

        navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: props.rightBarButtonImageName),
                                                            style: .plain,
                                                            target: self,
                                                            action: #selector(changeMode))

        if props.showsGeneralView && containerView1.isHidden {
            setupTables(showsGeneralView: true)
        } else if !props.showsGeneralView && containerView2.isHidden {
            setupTables(showsGeneralView: false)
        }
    }
    
    @IBAction func changeMode() {
        props?.changeViewModeCommand.perform()
    }
    ...

在这里可以查看与 ReduxVM 相关的所有部分。

class MoviesVC: VC, PropsReceiver { - 用户视图控制器,继承自 VC 同时也应满足 PropsReceiver 协议。

typealias Props = MoviesVCModule.Props - 需要指定 MoviesVCModule.Props 的具体结构作为该视图控制器的属性。

override func render() { - 需要实现 render 函数,该函数在属性变化时被调用。在该函数中,根据属性值对界面元素进行配置。

props?.changeViewModeCommand.perform() - 在响应界面事件的方法中,可以调用属性中的命令。

完整的实现可以查看链接

Props

Props 的结构如下

    struct Props: Properties, Equatable {
        let title: String
        let rightBarButtonImageName: String
        let showsGeneralView: Bool
        let changeViewModeCommand: Command
    }

Presenter

每个 VC 都关联自己的 Presenter,该 Presenter 会订阅 State 的变化并生成新的属性值供 VC 使用。

    class Presenter: PresenterBase<State, Props, ViewController> {

        override func reaction(for box: StateBox<State>) -> ReactionToState {
            return .props
        }

        override func props(for box: StateBox<State>, trunk: Trunk) -> Props? {

            let title: String
            let rightBarButtonImageName: String
            if box.state.moviesState.viewMode == .general {
                switch box.state.moviesState.selectedCategory {
                case .nowPlaying:
                    title = "Now Plaing"
                case .upcoming:
                    title = "Upcoming"
                case .trending:
                    title = "Trending"
                case .popular:
                    title = "Popular"
                }
                rightBarButtonImageName = "rectangle.3.offgrid.fill"
            } else {
                title = "Movies"
                rightBarButtonImageName = "rectangle.grid.1x2"
            }

            return Props(
                title: title,
                rightBarButtonImageName: rightBarButtonImageName,
                showsGeneralView: box.state.moviesState.viewMode == .general,
                changeViewModeCommand: Command { trunk.dispatch(MoviesState.ChangeViewModeAction()) }
            )
        }
    }

在 Presenter 中需要实现至少一个函数 override func props(for box: StateBox<State>, trunk: Trunk) -> Props? {。它接收包含新状态、旧状态和导致状态变化的 Action 的 StateBox 对象。同时传入一个 Trunk 对象,是用于通过 dispatch 函数发送所有新 Action 的总线。函数 props(for 输出新的结构 Props 用于 VC。

通过函数 override func reaction(for box: StateBox<State>) -> ReactionToState { 可以配置 Presenter 的行为。默认情况下,它会设置当 Presenter 重新计算新的属性并传递给 VC 的行为。但它也可以根据 StateBox 中的信息决定忽略状态变化而不采取任何行动。

完整的实现,包括依赖注入(DI)的实现,可以查看链接

TVC

实现表单界面依赖于库 DeclarativeTVC。如果只需显示表格并响应单元格点击,则可以编写以下代码

class MoviesTVC: TVC, PropsReceiver {

    typealias Props = TableProps

}

此外还需要实现如滑动删除单元格等功能。这需要重新实现相应的方法,因为 TVC 是 UITableViewController 的子类。

Props

TVCMust满足TableProperties协议,并有一个默认的TableProps实现。

public struct TableProps: TableProperties, Equatable {

    public var tableModel: TableModel
    public var animations: DeclarativeTVC.Animations?
}

这里可以指定单元格、标题和底部的模型,以及表格更新的动画。详细信息请参阅DeclarativeTVC库的描述。

Presenter

MoviesTVC的演示者

 class Presenter: PresenterBase<State, TableProps, ViewController> {

        override func onInit(state: State, trunk: Trunk) {

            switch state.moviesState.selectedCategory {
            case .nowPlaying:
                if state.moviesState.nowPlayingMovies.count == 0 {
                    trunk.dispatch(MoviesState.LoadAction(category: .nowPlaying))
                }
             
            ... 
              
            }
        }

        override func reaction(for box: StateBox<State>) -> ReactionToState {

            if !box.isNew(keyPath: \.moviesState.selectedCategoryMovies) {
                return .none
            }

            return .props
        }

        override func props(for box: StateBox<State>, trunk: Trunk) -> TableProps? {

            var rows = [CellAnyModel]()

            rows.append(
                SegmentedCellVM(
                    selectedIndex: box.state.moviesState.selectedCategory.rawValue,
                    
                    ...

                    })
            )

            if box.state.moviesState.selectedCategoryMovies.count > 0 {

                for movie in box.state.moviesState.selectedCategoryMovies {
                    rows.append(
                        MovieCellVM(title: movie.title,
                        
                        ...
                    )
                }
            } else {
                for _ in 0 ..< 20 {
                    rows.append(
                        MovieStubCellVM()
                    )
                }
            }
            return TableProps(tableModel: TableModel(rows: rows))
        }
    }

这里可以看到演示者另一个使用场景。

函数override func onInit(state: State, trunk: Trunk)在创建演示者时调用。还有一个函数open func onDeinit(state: State, trunk: Trunk)在演示者销毁时调用。需要注意的是,演示者的生命周期由其关联的VC或TVC控制。

Interactor

创建交互者时需要列出它所处理的副作用。

class APIInteractor: Interactor<State> {

    fileprivate let api: UnauthorizedAPI.Type 

    init(store: Store<State>, api: UnauthorizedAPI.Type) {

        self.api = api

        super.init(store: store)
    }

    override var sideEffects: [AnySideEffect] {
        [
            LoadNowPlayingMoviesSE(),
            LoadUpcomingMoviesSE(),
            LoadTrendingMoviesSE(),
            LoadPopularMoviesSE(),
            
            CreateDetailsSE(),
        ]
    }

    deinit {
    }
}

实质上,交互者负责订阅状态更新并将其信息传递给其副作用。它还可以重写open func onInit()方法,该方法在创建交互者时调用。

InteractorLogger

为了记录副作用,引入了InteractorLogger类,可以重写其中的日志记录功能。默认情况下,系统会记录所有副作用,但是对于一些副作用,有时禁用日志记录会更方便,这在loggingExcludedSideEffects中列出。

public class InteractorLogger {

    static var consoleLogger = ConsoleLogger()
    
    public static var loggingExcludedSideEffects = [AnySideEffect.Type]()

    public static var logger: ((AnySideEffect) -> ())? = { sideEffect in

        if loggingExcludedSideEffects.first(where: { $0 == type(of: sideEffect) }) == nil {

            print("---SE---", to: &consoleLogger)
            dump(sideEffect, to: &consoleLogger)
            print(".", to: &consoleLogger)
            consoleLogger.flush()
        }
    }
}

Side Effect

副作用实现的例子如下。

extension APIInteractor {

    fileprivate struct LoadNowPlayingMoviesSE: SideEffect {

        func condition(box: StateBox<State>) -> Bool {

            if let action = box.lastAction as? MoviesState.LoadAction,
                action.category == .nowPlaying,
                box.isNew(keyPath: \.moviesState.isNowPlayingLoading) {

                return true
            }
            return false
        }

        func execute(box: StateBox<State>, trunk: Trunk, interactor: APIInteractor) {

            _ = interactor.api.request(target: UnauthorizedAPI.nowPlaying(page: box.state.moviesState.nowPlayingPage + 1))
            {
                (result: Result<ServerModels.NowPlaying, Error>) in

                switch result {
                case .success(let data):
                    trunk.dispatch(MoviesState.AppendNowPlayingMoviesAction(movies: data.results))
                case .failure:
                    trunk.dispatch(MoviesState.ErrorLoadingAction(category: .nowPlaying))
                }
            }
        }
    }
    
    ...

如所示,它由两个函数组成。

func condition(box: StateBox) -> Bool { 这是一个触发副作用的条件函数,输入一个已知对象 StateBox,输出是一个布尔值表示是否需要执行副作用。

func execute(box: StateBox, trunk: Trunk, interactor: APIInteractor) { 这是副作用的工作部分。除了 StateBox,还需要 trunk 以便副作用可以修改状态,以及指向属于此副作用的交互器的链接。

在本例中可以看出,StateBox 包含一个辅助函数 isNew,用于检查指定 keyPath 的值是否已更改。

每个副作用还有两个字段

    var queue: DispatchQueue? { nil }
    var async: Bool { true }

它们可以用来自定义执行此副作用的队列,以及是否异步执行。默认情况下,队列未指定,副作用的执行与 ReduxVM 的主后台队列相同。使用此设置可以方便地在单独的队列中组织与某个服务的工作,为所有此类副作用创建具有所需队列的基本协议。如果服务执行一些复杂的操作且不希望在主队列中执行,则这很有用。可以参考此链接中的示例。

用例

本节将提供示例,说明在 ReduxVM 范围内如何执行各种场景。

来源

创建这个库受到了 Alexey Demedetskiy 的演讲启发,特别是 这个演讲

实现副作用受到了 Vitalii Malakhovskiy 的演讲 的影响

Redux 部分的库是在 RedSwift 中实现的,它是 ReSwift 的一个改进版本,后者是一个具有 katana-swift 风格的程序接口的库。

DeclarativeTVC 的创建受到了 Alexander Zimin 的演讲 的启发