问题
每个应用程序都有一种体系结构,无论好坏。由于没有关于如何构建应用程序的通用方法论,每个开发人员/团队都必须在每次构建应用程序时想出他们自己的解决方案。
每个应用程序都必须解决一些基本挑战,不论这个应用程序是什么
- 数据模型共享(不同范围/视图/模块等之间的数据交换和同步);
- 在任何给定时刻在整个应用程序中保持数据一致性;
- 管理应用程序状态;
- 多线程同步。
可选的,还有一些每个应用程序迟早都会面临的更基本挑战(尽管不是所有人都将其作为重点,但项目发展过程中在某个时刻它变得或多或少是必要的)
- 维护(至少一些)代码结构,因此(至少一些)代码组织规则变得必要,尤其是如果有两个或多个开发人员在同时处理应用程序时;
- 将业务逻辑层与表示层分离;
- 消除在运行时导致崩溃的(至少)关键问题(对于终端用户来说这是可怕的经历,对于任何应用程序来说都是非常有害的,考虑到现在的移动应用市场竞争多么激烈);
- 消除由意外行为引起的错误(这种行为往往会导致运行时崩溃);
- 保持源代码文档化。
因此,让我们把**应用程序体系结构**定义为一系列规则,这些规则定义了如何解决上述挑战。
现有的解决方案
非常少数的设计模式试图在较高层次上描述如何组织整体应用程序结构(例如:MVC、MVVM等)。它们并不非常具体,不同的开发人员以稍微不同的方式解释和实现这些模式。
其中最有前景的(并在iOS上相当新)是所谓的由Facebook在他们的Flux框架中引入的"单向数据流模式"。在Swift中为Apple平台编写的该模式最成熟的本地实现是ReSwift。
这是一个非常强大的框架,似乎涵盖了所有根本性需求。然而,有几件事情并不是那么好,并且可能需要改进。
Reducer实现的开销
- Reducer不应有任何内部状态/数据,因此唯一的价值是逻辑,它可以轻松表示为一个纯函数(带有输入参数),因此将reducer作为对象/实例,并以实例成员/函数的形式实现它的功能是没有意义的。
- reducers的实现方式为开发人员增加了不必要的“手动”工作,且随着代码库的增长,很可能导致错误/错误。特别是,开发人员必须将每个实现为对象/结构体,并在应用程序和初始化过程中始终记住创建每个`reducer`的一个实例,并显式将其注册到`store`中。否则,`reducer`将不会包含在`actions`处理链中,并且将静默不工作。
- 整个库架构促进了非常奇怪和不便的组织应用逻辑/代码的方法。每个`action`都应仅表示与该`action`相关的逻辑所需的数据模型,而逻辑本身则分布在一个或多个`reducers`中。在没有详细且最新的文档的情况下,回想/理解特定`action`能做什么的唯一方法是在整个应用程序中搜索对特定`action`做出反应的`reducers`。这对于开发人员来说是一个噩梦,导致开发人员缺乏对整体情况的理解,结果是在应用程序中产生错误/bug/崩溃,并且纯粹的整体应用程序用户体验不佳。
- reducer主要函数应有的写法远非完美。尽管由于它是一种纯粹的“函数式”方法,这可能看起来很酷,但对于实现应用程序功能的开发人员来说,这仍然是一项大量手动的工作。我们不得不在每个单独的`reducer`中检查/展开可选的`state`,在我们开始编写任何特定于应用代码之前,这是荒谬的 - 为什么在任何应用程序生命周期时刻不设置`state`呢?另外,它作为只读输入参数出现,并且你必须返回一个状态值,即使该操作根本未在状态上做出任何更改也是如此。这都使得开发人员(在大多数情况下)要显式地将可选输入状态展开到变量("var")中。我们也不知道`action`是什么,并且必须始终进行可选类型转换或至少检查其类型。这些都带来了许多不必要的复杂性,使得代码背后的逻辑难以阅读和理解,因此,再次强调,这很容易出错。
订阅机制限制
订阅机制需要
- 观察者实现特定的方法(符合协议),这限制了开发者在命名方面的操作;
- 该方法(newState)接收可选值,这要求代码在任何特定应用的代码执行前必须始终进行解包操作,这在很大程度上是开发者进行的大量不必要的手动工作。
中间件
中间件似乎太过繁琐/不必要的复杂化,即使是简单的例子看起来也相当复杂。
愿望清单
这样一个框架应该是一个帮助和激励的工具
- 使应用在任何时间点完全可预测(从而消除崩溃);
- 有效地在不同范围内交换/共享数据(无需在多对多样式中存储和维持大量直接引用)以确保应用的所有部分(包括所有UI)始终保持一致;
- 消除应用程序源代码中的隐式副作用;
- 使应用程序源代码结构良好 - 容易阅读、理解和推理;
- 使应用程序源代码与关键软件设计原则兼容(如关注点分离、封装、黑盒、依赖注入);
- 使应用程序源代码与关键架构模式兼容(如MVVM、MVC);
- 使应用程序源代码易于转换为BDD规范;
- 使应用程序源代码准备进行单元测试(包括独立模块测试和集成测试);
- 保持开发者编写的源代码最小化、紧凑化(使其看起来像规范);
- 将库开销保持得尽可能低(不应涉及运行时的“魔法”,应尽可能少地进行手动操作)。
范围
本库在应用开发过程中提供了最高层的抽象,因此任何特定任务(如网络功能、数据编码/解码、任何类型的计算、GUI配置等)都不在范围之内。
理论基础
每个应用程序由以下两个主要组件组成:模型(静态组件,存储应用程序能够操作的所有可能类型的<_REFERENCE data-model>数据)和业务逻辑(动态组件,表示可能对该数据模型中发生的所有类型的变化)。
另一方面,计算机程序(应用程序)是一个有限状态机。这特别意味着,要编写一个应用程序,我们必须定义应用程序的所有可能状态以及所有可能状态之间的所有转换,允许进行这些转换。
此外,每个应用程序都由功能组成,这些功能可能相互依赖或相互独立。每个功能可能需要存储一些数据以执行操作,表示内部状态,提供一些计算结果等。所需的数据类型的确切集以及数据值可能会随时间而变化。
总之,每个应用程序应表示为一组功能。每个功能可以通过一个或多个或its州定义(每个功能状态对应其自身的模型)加上这些状态之间的转换。
方法概述
应用程序模型(全球模型)是由功能模型组成的复合对象。要完全准确地说,在给定的任何时候,每个功能(如果在全球模型中呈现)都由其状态模型中的一个精确表示。显然,当前在全球模型中呈现的每个功能状态模型都定义了相应功能的当前状态;如果没有给定的功能状态模型在全球模型中表示,那么该特定功能的当前状态是未定义的(该功能当前未使用)。
我们同意在任何给定的时刻,应用程序的全局状态是当前在全球模型中呈现的所有功能状态模型的组合。
这概述了应用程序的静态/数据模型。
应用程序的业务逻辑可以通过不同应用程序全局状态之间的转换来表示。这意味着每个转换都应该改变一个或多个功能当前的状态。在一般情况下,每个转换都包含前提条件,这些条件必须在使用转换之前得到满足,同时也包含转换体,它定义了转换的确切执行方式。转换也用于将来自外部世界的任何类型输入引入应用程序(例如,用户输入、系统通知等)
如何安装
推荐使用CocoaPods进行安装。
工作原理
每个应用程序特性应该由符合Feature
协议的数据类型表示。其名称对应于特性名称。此类数据类型永不应被实例化,并且仅作为对应特性状态的元数据需要。
每个应用程序特性的状态应该由符合FeatureState
协议的数据类型表示,并通过UFLFeature
别名显式定义相应的特性。这些数据类型的实例将用于表示其特性。
所有应用程序特性都应存储在由名为GlobalModel
的数据类型表示的单个全局存储中。每个应用程序应有该类型的唯一实例。它在任何时刻都是真理的单一点,存储全局应用程序状态。从高层次来看,它的工作方式与字典非常相似,其中应用程序特性用作键,而相应的特性状态作为值存储。这意味着GlobalModel
可能在任何给定时刻都包含任何给定的特性,但如果它包含特性——它只包含一个且仅包含一个特定的特性状态;当我们决定将另一个特性状态放入GlobalModel
(在转换之后)——它将覆盖存储在GlobalModel
中的任何先前保存的特性状态(为此特定特性)。
每个转换应由Action
的一个实例表示,这是一个特殊的数据类型(结构体),包含转换名称和体(以闭包形式)。
定义状态转换的特殊技术。直接无法访问行为
初始化器。假设所有转换都应以静态函数的形式定义,这些函数返回行动
实例。这些函数必须封装到符合行为上下文
协议的特殊数据类型中:这个协议提供了对特殊静态函数的独家访问,该函数可以通过传递进入其中的转换体来创建行动
实例。这种技术强制统一源代码,并提供极大的灵活性:封装函数可以接受任意数量的输入参数,这些参数可以捕获到转换体的闭包中,但到最后,转换体始终只是一个没有输入参数的闭包。
在大多数情况下,建议将状态转换封装到相关特性中,因此特性
协议继承了行为上下文
协议。
在我们定义了应用程序特性、其状态和转换后,我们需要使它们一起工作。每个应用程序都需要维护一个——仅有一个分配器
类的实例。建议在应用启动完成后首先创建并开始使用一个实例。
分配器有几个职责
- 存储全局应用程序状态(
全局模型
的唯一实例); - 处理状态转换(修改分配器中存储的
全局模型
实例的行动
数据类型); - 将全局状态变更通知发送给已订阅的观察者(这就是我们可以连接应用程序的不同部分/范围的途径,包括以“响应式”方式向GUI发送更新)。
如何使用
以这种方式导入框架
import XCEUniFlow
分配器
首先,您需要创建一个分配器。在您的AppDelegate
类中声明一个内部实例级常量的推荐方法是。这保证了分配器的生命周期与应用程序本身相同。
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate
{
// ...
let dispatcher = Dispatcher()
// ...
}
分配器代理
出于安全考虑,请不要在应用程序中传递分配器的引用。相反,有一个特殊的小型数据结构,称为分配器代理
,它提供了对特定分配器的必要功能访问,并且通常可以自由传递/复制/存储多次。
如下所示访问分配器代理
let theProxy = dispatcher.proxy
对于任何需要访问全局应用状态的数据类型,建议实现DispatcherInitializable
或DispatcherBindable
协议。这两个协议实现了依赖注入,并统一了潜在观察者与调度器的连接方式。
以下是一个实现DispatcherInitializable
协议的自定义UIWindow
子类的示例。
final
class Window: UIWindow, DispatcherInitializable
{
required
convenience
init(with proxy: DispatcherProxy)
{
self.init(frame: UIScreen.main.bounds)
//===
// here subscribe for updates from dispatcher via proxy, if needed
// store proxy internally, if needed
}
}
以下是一个实现DispatcherBindable
协议的自定义UIWindow
子类的示例。
final
class Window: UIWindow, DispatcherBindable
{
func bind(with proxy: DispatcherProxy) -> Window
{
// here subscribe for updates from dispatcher via proxy, if needed
// store proxy internally, if needed
}
}
当观察者提供对调度器代理的访问时,观察者的责任是订阅或不订阅从调度器接收更新的操作。考虑到观察者将开始初始化全局状态突变(传递给应用程序用户输入、系统通知等)或在执行应用程序时可能需要代理,将代理内部存储起来以供将来使用也是一个不错的选择,因为这是唯一推荐向调度器提交操作的方式。
以下是一个实现DispatcherInitializable
协议的自定义UIViewController
子类的示例。它不订阅调度器通知,但存储proxy
以便将来使用,而与调度器通知无关。
// lets say we have a custom view, subclass of UIView,
// which also accepts proxy during initialization
final
class View: UIView, DispatcherInitializable
{
// instance of this view will be created by the 'Ctrl' class defined below
// ...
}
// ...
final
class Ctrl: UIViewController, DispatcherInitializable
{
private(set)
var proxy: DispatcherProxy!
//===
required
convenience
init(with proxy: DispatcherProxy)
{
self.init(nibName: nil, bundle: nil)
//===
//...
self.proxy = proxy // save proxy for future use
}
// ...
override
func loadView()
{
// we have no guarantee when this method will be called,
// and when it's called - we need to have the proxy available to pass it further
view = View(with: proxy)
}
}
订阅
要订阅来自调度器的通知,基本上需要注册一个对象作为观察者并提供相应的更新处理程序
。可选地,您还可以提供负责将全局应用状态转换成更具体模式的转换处理程序
,这有助于使代码更具声明性。
在大多数情况下,为了订阅观察者以接收调度器的通知,您需要实现之前提到的两个协议之一(DispatcherInitializable
或DispatcherBindable
)。当得到proxy
时,只需将self
作为观察者传递并传递一个自定义闭包/函数到onUpdate
函数中。
以下是自定义基于UIView
的类订阅调度器通知的示例。注意,在下面的示例中,onUpdate
函数接受另一个函数作为输入参数,为了更好的代码组织。
final
class View: UIView, DispatcherInitializable
{
// ...
required
convenience
init(with proxy: DispatcherProxy)
{
self.init(frame: CGRect.zero)
//===
// ...
//===
proxy
.subscribe(self)
.onUpdate(configure)
}
// ...
func configure(with model: GlobalModel)
{
// here use model to re-configure self as needed
}
}
可选地,您可以将自定义闭包/函数传递到onConvert
函数中,该函数接受全局应用状态作为输入参数并返回任何类型的自定义或系统数据类型(“子状态”)。然后将接受子状态作为输入参数的自定义闭包/函数传递到onUpdate
函数中。下面是示例。
final
class View: UIView, DispatcherInitializable
{
// ...
required
convenience
init(with proxy: DispatcherProxy)
{
self.init(frame: CGRect.zero)
//===
// ...
//===
proxy
.subscribe(self)
.onConvert(prepare)
.onUpdate(configure)
}
// ...
func prepare(from globalModel: GlobalModel) -> Int?
{
var result: Int = nil
// if possible, convert globalModel somehow into local model,
// in this example local model represented by "Int"
return result
}
func configure(with localModel: Int)
{
// here use localModel to re-configure self as needed
}
}
请注意,观察者对象在词典中像键一样工作,以识别所有其他订阅中的订阅。每个观察者只能有一个订阅。每个尝试为观察者设置订阅提交都将覆盖该观察者的先前订阅。
功能建模
本方法中最重要的技术之一是如何定义功能、功能状态和状态转换。
让我们来模拟一个简单的搜索功能。
假设我们有一个简单的图形界面,用户必须在这个界面中输入一个搜索关键字(它可能是一个单词或多个单词的字符串,这无关紧要)。
当用户完成输入并开始搜索过程后,输入文本框将不再可编辑,搜索关键词也不能再更改。在后台应用程序正在执行指定的关键词搜索。
当搜索完成后,我们手头将有一个包含搜索结果的数组(可能为空),以及搜索到的对应关键词(只读)。
为了定义应用程序功能,我们声明一个符合Feature
协议的自定义数据类型。特型数据类型不应被实例化,因此使用enum
数据类型来声明应用程序功能是一个好主意。
enum Search: Feature
{
// ...
}
在Search
类型中,我们声明3个嵌套类型,它们将代表相应的Search
状态。
enum Search: Feature
{
struct Preparing: SimpleState { typealias UFLFeature = Search
// getting user input, waiting for start
}
struct InProgress: FeatureState { typealias UFLFeature = Search
// the search process for a given keyword is in progress
}
struct Finished: FeatureState { typealias UFLFeature = Search
// the search process for a given keyword is finished,
// got list of results (may be empty)
}
}
注意,SimpleState
是一个从FeatureState
继承的特殊协议。它所做的只是给库一个提示,即符合该协议的类型可以在不使用参数的情况下进行实例化(使用默认系统的init
构造函数)。此协议建议用于内部没有变量或具有默认值的那些状态。我们将在稍后看到它是如何被使用的。
现在让我们扩展每个功能状态,以便包含反映对应状态本质的必要常量和变量。
开始时,直到用户完成输入并开始搜索过程,Search
功能应该由Preparing
状态表示。在那个状态下,我们不需要在模型中存储任何内容。
当用户完成输入并开始实际的搜索过程,直到搜索过程完成之前,Search
功能应由InProgress
状态表示。当搜索正在进行时,我们需要知道我们现在正在执行搜索的关键词。所以,让我们添加一个常数(!)来在InProgress
状态下存储搜索关键词。
struct InProgress: FeatureState { typealias UFLFeature = Search
// the search process for a given keyword is in progress
let keyword: String // read-only, requires to set value explicitly
}
当搜索过程完成后,Search
功能自动转换为Finished
状态。在这里,我们仍然需要知道我们现在完成了搜索过程的关键词,以及表示结果列表(因为我们不知道结果列表元素的类型是什么,让它是Any
即可,这对于本例的目标并不重要)。
struct Finished: FeatureState { typealias UFLFeature = Search
// the search process for a given keyword is finished,
// got list of results (may be empty)
let keyword: String // read-only, requires to set value explicitly
let results: [Any] // read-only, requires to set value explicitly
}
现在让我们通过定义转换来将这些状态连接起来。
首先,让我们定义初始化特性的转换。
extension Search
{
static
func setup() -> Action
{
return initialization(into: Preparing.self)
}
}
在上面的例子中,已经使用了由库提供的特殊辅助静态函数initialization
。它自动化了许多常规检查和操作。这个特定的辅助对象与一个特定的功能状态一起工作,其中初始状态是未定义的,目标状态是提供的(在我们的例子中是Preparing
)。这个特定的函数仅与符合SimpleState
协议的功能状态一起工作。在幕后,它为您完成了所有必要的检查 - 确保功能尚未在全球状态中,然后在一切正常的情况下,创建目标状态的实例并将其放入全局模型中,或者失败动作处理。稍后我们将详细介绍这一点和其他特殊的辅助对象。
submit,当用户完成输入并启动搜索过程时,我们需要从Preparing
状态转换到InProgress
状态。下面是如何实现它的一个例子。
static
func begin(with word: String) -> Action
{
return transition(from: Preparing.self, into: InProgress.self) { _, become, submit in
become { InProgress(keyword: word) }
//===
var list: [Any] = []
// do the search here, on background thread most likely
// when search is finished - return to main thread and
// deliver results by submitting another action via 'submit' handler
// ...
submit { finished(with: word, results: list) }
}
}
在上面的例子中,使用了一个由库提供的特殊辅助静态函数transition
。它自动化了许多常规检查和操作。这个特定的辅助对象在同一特性的两个提供的状态之间进行转换。在幕后,它为您完成了所有必要的检查 - 确保功能已经在全球状态中,并且其当前状态是提供的(在这个例子中是Preparing
),然后在一切正常的情况下,允许您创建目标状态的实例并将其稍后放入全局模型中,或者失败动作处理。稍后我们将详细介绍这一点和其他特殊的辅助对象。
最后,当搜索完成时,我们需要从InProgress
状态过渡到Finished
状态。以下是如何实现的一个示例。
static
func finished(with word: String, results list: [Any]) -> Action
{
return transition(from: InProgress.self, into: Finished.self) { _, become, _ in
become { Finished(keyword: word, results: list) }
}
}
执行状态转换
要开始状态转换过程,通过派发程序的proxy
提交相应的Action
(如有必要,包含相应的参数)(参见以下示例)。
let proxy = // get proxy from dispatcher
proxy.submit { Search.setup() } // initialize feature in global model
// ... wait for user input and initiation of actual search process...
let word = // get input from user
proxy.submit { Search.begin(with: word) } // actually start search process
// ...
请注意,所有操作都在主线程上按顺序,一个接一个地串行处理,顺序与提交的顺序相同(先入先出)。
末尾注意事项
以上是一个解决这类问题的基本示例。根据特定搜索的需求,该示例可以被扩展到具有专门的失败状态(也可能存储发生的错误)等状态。此外,应该有一个废弃搜索结果并准备进行新搜索的状态转换(解初始化转换),以防搜索视图完全关闭且我们不需要以任何方式将有关Search
功能的内存内容保留下来。等等。
正面结果
使用这个框架作为应用程序的基石,有几个积极的成果。
- 方法论鼓励以函数式的方式编写应用程序源代码,这消除了副作用,使其组织得更良好,更容易阅读和理解;
- 它为将应用程序从非常少的功能扩展到数十甚至数百个功能提供了非常清晰的策略;
- 它消除了意外行为,因为如果你正确编写状态转换——检查所有必要的先决条件并将必要的数据安全地存储到临时变量中,然后再进行实际的转换——那么就没有出现意外行为或运行时异常的机会;
- 与“传统”的命令式编程或其他任何流行的架构模式相比,它极大地提高了代码库的模块化和可测试性,使得模块和集成测试变得轻而易举;
- 每个转换(及其触发点)可以轻松地转换为BDD场景,反之亦然;
- 很容易将任何数据传递到应用程序的任何部分的任何范围,只需订阅从派发程序的更新,从全局应用程序状态读取/写入所需的数据;
- 应用程序仍然与MVC、MVVM等其他现有架构模式兼容,因为该库只组织模型层。
- 无需牺牲性能,因为这个库没有任何开销,没有运行时魔法,一切都是 用纯 Swift编写的。
与Objective-C的兼容性
对于Swift 3 + Objective-C的混合环境,请使用版本1.1.1。对于与Swift 2.2和Swift 2.3(以及Objective-C)的兼容性,请使用旧版本。
从版本2.0.0开始,不再支持与Objective-C的互操作性。
未来计划
项目已经经历了几个小的和3个主要的更新。当前的记法被认为是稳定的,并且在易用性、简洁和自解释API以及功能方面相当平衡。几乎任何类型的功能都可以使用提出的方法来实现。
贡献、反馈、问题...
如果您有任何反馈或问题,请随时提出问题。如果您想提出改进意见或发现错误,请创建一个issue,或者在GitHub上fork并提交一个pull request。任何类型的贡献都将备受感激!