A New, Functional, Modern Reactive State Management Library for UIKit and SwiftUI (The iOS implementation of Recoil)
MIT License
RecoilSwift是一个针对SwiftUI
的轻量级、可组合的状态管理框架,同时兼容UIKit
。它可以作为传统的MVVM
或者Redux-like
架构方案(如:reswift
、TCA
)的替代者。
注意: 从0.3版本开始,RecoilSwift已经支持了
UIKit
。如果你想在UIKit
中使用RecoilSwift,你可以查看master分支的例子。但需要注意的是,我们目前仍处于beta阶段,未来的接口可能会有所调整。
Recoil
是由Facebook
提出的一种可组合的应用状态管理方案。它简化了Redux
,可以作为Redux
的优雅替代者。
想要更快速的了解Recoil,你可以观看下面的视频,或者访问官网。
当前的iOS架构模式(如:MVVM
)在配合声明式编程时存在一些问题,而且痛点众多。因此,在声明式的UI框架中,很多开发者更倾向于选择Redux-like
的状态管理架构方案(如:ReSwift
、TCA
)。但Redux
方案复杂,学习成本较高,同时模板代码过多,写起来比较累。Recoil
应运而生,它主要有以下特点:
使用Recoil后,代码会变得更加简洁,同时不同的组件可以非常方便地共享状态。
📕 API 文档
在Recoil中,有两个基本的概念:
Atoms
):原子是状态的基本单元,是一种有状态的对象。原子可以被读取和写入,其类型可以是任意数据类型。Selectors
):选择器从一个或多个原子中派生出新的状态,这种派生状态可以被订阅以获取状态的更新,它们也可以作为其他选择器的输入。通常,我们会在Atoms
中存放源数据,在selector
中放置业务逻辑。而选择器的业务单元是可以被组合的。如下图所示:
上图中, 黄色的是Atoms
, 棕色的是 Selectors
, 箭头表示状态的组合,依赖关系。
Atom
不能依赖其他Atom
。Selector
可以组合其他 Selector
或 Atom
并自动建立依赖关系,它是响应式的,任何上游的值变动,下游的选择器都会自动重新执行求值总而言之:
这三个特性使你的代码更加简洁,同时提高了代码的重用性。
前置条件: iOS 13+,Xcode 14.3+
.Pagckage(url: "https://github.com/hollyoops/RecoilSwift.git", from: "master")
你也可以用CocoaPods安装
pod 'RecoilSwift'
你可以在 UIKit
和 SwiftUI
中使用 RecoilSwift。
(在UIKit中 使用RecoilSwift, 请查看 更多用法)
在 SwiftUI 中,RecoilSwift 提供了两类方式的 API:基于 PropertyWrapper
的 API 和基于 Hooks 的 API。PropertyWrapper
API 更符合 iOS 规范,更适合原生开发者。Hooks API 更贴合官方 API,更适合前端开发者。
下面是基于 PropertyWrapper
的 API 使用方式,Hooks API 的使用方式请查看这里。
首先,请使用 RecoilRoot
包裹你的View。
struct YourApp: App {
var body: some scene {
WindowGroup {
RecoilRoot {
AppView()
}
}
}
}
RecoilSwift 提供两种定义状态的方式:使用 State Function
创建状态和继承协议生成自定义状态。
State Function
创建状态:通过atom
和 selector
函数创建状态,这种方式的优势是 API 更贴近官方 API,某些情况下更简洁。但你需要遵循以下模式。
struct CartState {
/// 1. 定义计算属性
static var allCartItem: Atom<[CartItem]> {
/// 2. 用函数创建状态
atom { [CartItem]() }
}
/// UI显示逻辑:如果商品个数小于10个,则显示原本数量,否者个显示9+
static var numberOfProductBadge: RecoilSwift.Selector<String?> {
selector { accessor -> String? in
/// 注意:下面这个简单的 `get` 方法,是从其他的`atom/selector`中获取数据
/// 其实它还和`allCartItem`建立了上下游关系。当allCartItem数据发生变化,
/// 当前`numberOfProductBadge` 会自动重新计算。这让不同状态可以组合,重用,非常强大!
let items = try accessor.get(allCartItem)
let count = items.reduce(into: 0) { result, item in
result += item.count
}
return count < 10 ? "\(count)" : "9+"
}
}
}
这里数据源是allCartItem,它是我们用 atom
函数创建的 同步Atom,表示购物车内商品列表。numberOfProductBadge
是一个我们用 selector
函数创建的同步Selector,表示购物车里所有商品的个数的总和。当购物车里面的商品列表发生的变化,这个numberOfProductBadge
自动发生重新计算,并刷新UI。
在UI上这样使用:
struct YourView: View {
@RecoilScope var recoil
var body: some View {
// 当 `numberOfProductBadge` 的值发生改变,`View` 会自动重新渲染,拿到最新的值
let badge = recoil.useValue(CartState.numberOfProductBadge)
Text(badge)
}
}
如果你不想使用函数创建状态,你可以自己定义一个类,并继承以下协议之一来生成自定义状态:
SyncAtomNode
同步的Atom协议AsyncAtomNode
异步的Atom协议SyncSelectorNode
同步Selector协议AsyncSelectorNode
异步Selector协议struct AllCartItem: SyncAtomNode, Hashable {
typealias T = [CartItem]
func defaultValue() -> [CartItem] {
[]
}
}
struct NumberOfProductBadge: SyncSelectorNode, Hashable {
typealias T = String?
func getValue(accessor: StateAccessor) -> String? {
let items = try accessor.get(AllCartItem()) //创建对象
let count = items.reduce(into: 0) { result, item in
result += item.count
}
return count < 10 ? "\(count)" : "9+"
}
}
在UI上这样使用:
struct YourView: View {
@RecoilScope var recoil
var body: some View {
let badge = recoil.useValue(NumberOfProductBadge())
Text(badge)
}
}
有些时候你的状态可能需要接受一些外部的参数。这个时候这个时候你就需要用到带参的状态。和定义状态一样,RecoilSwift提供两种方式去定义带参的状态:
1. atomFamily & selectorFamily 函数创建带参数的状态:
var remoteDataById: AsyncSelectorFamily<String, String> {
selectorFamily { (id: String, get: Getter) async -> [String] in
let posts = try await fetchAllData()
return posts[id]
}
}
struct YourView: View {
@RecoilScope var recoil
var body: some View {
let loadable = recoil.useLoadable(remoteDataById(id))
return VStack {
if loadable.isLoading {
ProgressView()
}
if let err = loadable.errors.first {
errorView(err)
}
// when data fulfill
if let names = loadable.data {
dataView(allBook: names, onRetry: loadable.load)
}
}
}
}
2. 使用带参数的自定义状态:
我们自定义了一个异步 Selector
,它远程获取一篇文章的内容
struct RemoteData: AsyncSelectorNode, Hashable {
typealias T = String
let id: String
func getValue(accessor: StateAccessor) async throws -> String {
let posts = try await fetchAllData()
return posts[id]
}
}
然后这样使用:
var body: some View {
let loadable = recoil.useLoadable(RemoteData(id))
...
}
有时候,我们想查看整个应用的状态图,确保状态之间的关系正确无误。RecoilSwift 提供了 SnapshotView
来帮助你调试状态。你只需在 RecoilRoot 中启用 shakeToDebug
,然后摇动手机即可自动弹出应用状态图。
RecoilRoot(shakeToDebug: true) {
content
}
上图中, 黄色的是Atoms, 棕色的是 Selectors。 箭头表示状态的组合,依赖关系。
在RecoilSwift中,您可以借助@RecoilTestScope
来进行状态测试。
final class AtomAccessTests: XCTestCase {
/// 1. 初始化scope
@RecoilTestScope var recoil
override func setUp() {
_recoil.purge()
}
func test_should_returnUpdatedValue_when_useRecoilState_given_stringAtom() {
/// 通过 `useRecoilXXX` API 订阅状态
let value = recoil.useBinding(TestModule.stringAtom, default: "")
XCTAssertEqual(value.wrappedValue, "rawValue")
value.wrappedValue = "newValue"
/// 通过 `useRecoilValue` API 订阅并获取状态的最新值
let newValue = recoil.useValue(TestModule.stringAtom)
XCTAssertEqual(newValue, "newValue")
}
}
有时,您可能需要进行更全面的端到端测试。例如,您可能希望模拟View的渲染,此时,可以借助ViewRenderHelper
进行从视图到状态的端到端测试。
ViewRenderHelper
能够模拟视图的多次渲染,
/// 1. 引入测试框架
import RecoilSwiftTestKit
final class AtomAccessWithViewRenderTests: XCTestCase {
// ...
func test_should_atom_value_when_useValue_given_stringAtom() async {
/// `ViewRenderHelper` 的回调可能会被多次触发,
let view = ViewRenderHelper { recoil, sut in
let value = recoil.useValue(TestModule.stringAtom)
/// 一旦`expect` 的期望得到满足,测试即视为成功,否则在超时时,测试将失败
sut.expect(value).equalTo("rawValue")
}
/// 模拟视图渲染
await view.waitForRender()
}
}
final class AtomReadWriteTests: XCTestCase {
@RecoilTestScope var recoil
override func setUp() {
_recoil.purge()
}
func test_should_return_rawValue_when_read_only_atom_given_stringAtom() {
/// 注意:需要定义HookTest,并将Scope传入
let tester = HookTester(scope: _recoil) {
useRecoilValue(TestModule.stringAtom)
}
XCTAssertEqual(tester.value, "rawValue")
}
}
很多时候我们的Selector, 会依赖其他状态。 比如下面的代码, state
依赖了一个上游的状态 (state -> upstreamState
):
struct MultipleTen {
static var state: Selector<Int> {
selector { context in
try context.get(upstreamState) * 10
}
}
static var upstreamState: Atom<Int> {
atom { 0 }
}
}
但是我们在单元测试时候,很多时候我们不想要测试这个 UpstreamState
. 我们想要stub/mock它。 我们可以通过下面的代码来RecoilTestScope
的stub, 方法来stub
状态:
func test_should_return_upstream_asyncError_when_get_value_given_upstream_states_hasError() async throws {
// stub `upstreamState` 让其返回错误, 你也可以stub返回其他的正确值
// _recoil.stubState(node: AsyncMultipleTen.upstreamState, value: 100)
_recoil.stubState(node: AsyncMultipleTen.upstreamState, error: MyError.param)
do {
_ = try await accessor.get(AsyncMultipleTen.state)
XCTFail("should throw error")
} catch {
XCTAssertEqual(error as? MyError, MyError.param)
}
}
你也可以在 UIKit 中使用 RecoilSwift,甚至在 UIKit 和 SwiftUI 中混合使用。你唯一需要做的就是让你的 UIViewController
或 UIView
继承 RecoilUIScope
协议。
/// 1. 继承 RecoilUIScope 协议
extension BooksViewController: RecoilUIScope {
/// 2. 实现 refresh 方法,该方法会在你订阅的状态发生改变时被调用
func refresh() {
/// 3. 获取并订阅状态的值
let value = recoil.useValue(MyState())
// 4. 将状态的值绑定到 UI 上
valueLabel.text = value
...
}
}
extension BooksViewController: RecoilUIScope {
func refresh() {
let booksLoader = recoil.useLoadable(BookList.currentBooks)
if let error = booksLoader.errors.first {
loadingSpinner.stopAnimating()
tableView.isHidden = true
emptyDataLabel.isHidden = true
errorLabel.text = error.localizedDescription
errorLabel.isHidden = false
} else if let books = booksLoader.data {
loadingSpinner.stopAnimating()
if books.isEmpty {
tableView.isHidden = true
emptyDataLabel.isHidden = false
} else {
tableView.isHidden = false
emptyDataLabel.isHidden = true
self.books = books
tableView.reloadData()
}
} else {
tableView.isHidden = true
emptyDataLabel.isHidden = true
loadingSpinner.startAnimating()
}
}
}
更多请查看 Example
里面的UIKit的例子
RecoilSwift 提供了一套基于 Hooks API 的用法,Hooks 非常接近官方的 API,Hook API 以 use
开头,例如 useRecoilXXX
。这种方式更适合前端开发者,没有任何学习门槛。
由于基于 Hooks API,因此你的 View 必须满足 Hooks 的规范。
/// 1. 继承 `HookView` 接口
struct YourView: HookView {
/// 2. 实现 `hookBody`
var hookBody: some View {
/// 3. 使用 Hooks API,订阅状态
let names = useRecoilValue(namesState)
let filteredNames = useRecoilValue(filteredNamesState)
return VStack {
Text("Original names: \(names.joined(separator: ","))")
Text("Filtered names: \(filteredNames.wrappedValue.joined(separator: ","))")
Button("Reset to original") {
filteredNames.wrappedValue = names
}
}
}
}
请注意,使用 Hooks API 的 View 须继承 HookView
接口,并实现 hookBody
属性。或者用 HookScope
包裹住你的Hooks API代码的。你可以使用 useRecoilValue
等一系列Hook API
来订阅状态,并根据需要更新状态。
请查看 这里
以下示例非常简单,但强烈建议查看对应的代码。类似 Redux,Recoil 面向状态编程,使页面间的状态共享和重用变得十分容易。并且状态逻辑都是纯函数,测试也非常简单。
Facebook Recoil (Recoil.js)
Recoil for Android
Hooks
欢迎你对 RecoilSwift 做出贡献。你可以通过提交 issue 或者 pull request 来帮助我们改进 RecoilSwift。
最后,如果你喜欢我们的项目,别忘了给我们一个 star ⭐,这是对我们工作的最大鼓励。