一份数据,为什么会有那么多版本
列表通常从“请求 JSON、解码、筛选、交给界面”开始。随着文件夹、文档、权限、分页和缓存加入,repository、view model、派生数组各自保留一份数据;网络和缓存回调又可能以不同顺序写入。
局部操作都合理,整体却无法回答:现在到底哪份数据说了算? 刷新后字段消失、同一文档在不同列表状态下行为不同、旧响应覆盖新结果,根源往往是状态归属失控,而不是 JSON 解码。
本文用一个简化模型说明如何引入 ReSwift。示例省略了完整领域类型、合并规则、取消与分页游标,不是可直接运行的完整应用;重点在于让状态归属和过期响应规则可以被审查。
局部修补为什么不够
Repository、view model 与派生数组都接收异步写入,却没有唯一权威状态。
加锁只能保护单次修改,不能决定修改的语义;增加拷贝会制造更多竞争快照;塞进一个巨型 view model,也不会自动解决顺序问题。
缺少的是变化的方向:一个入口、一种确定的状态变换,以及可观察的结果。
ReSwift 如何分配职责
Store 持有状态,Action 描述事件,Reducer 根据旧状态和事件生成新状态,Middleware 处理网络、缓存、日志等副作用,订阅者只观察渲染所需的部分。
User or system event -> Action -> Middleware -> Reducer -> New State -> Subscriber
视图不再直接改文档对象,网络回调不再绕过规则修改 view model,缓存恢复也不再静默替换数组。复杂性仍在,但每种复杂性有了明确的位置。
不要把原始 JSON 原样放进 Store
深层嵌套的数据即使集中存储,仍然难以安全更新。应该在边界解码一次,再拆成:按稳定 ID 索引的规范实体,以及描述排序与展示的 ID 列表。
struct Document: Equatable {
let id: String
var title: String
var ownerID: String
var metadata: Metadata
}
struct FeedState: StateType, Equatable {
var documentsByID: [String: Document] = [:]
var orderedIDs: [String] = []
var query = FeedQuery()
var pagination = PaginationState()
var request = RequestState.idle
var activeRequestID: UUID?
var cacheRevision: Int = 0
}
更新元数据只修改对应实体,不必在多个 section 和变换后的数组中搜索。筛选则生成可见 ID,不删除原始实体:
func visibleDocumentIDs(in state: FeedState) -> [String] {
state.orderedIDs.filter { id in
guard let document = state.documentsByID[id] else { return false }
return state.query.matches(document)
}
}
约定很简单:State 保存事实,Selector 推导展示,Reducer 是修改事实的唯一入口。
Action 描述发生了什么
struct FeedRequested: Action {
let requestID: UUID
let reason: LoadReason
}
struct CachedFeedLoaded: Action {
let snapshot: FeedSnapshot
}
struct RemoteFeedLoaded: Action {
let requestID: UUID
let page: FeedPage
}
struct FeedRequestFailed: Action {
let requestID: UUID
let error: FeedError
}
struct QueryChanged: Action {
let query: FeedQuery
}
struct DocumentMetadataChanged: Action {
let documentID: String
let metadata: Metadata
}
Action 应表达事件,而不是允许任意改字段的指令。请求 ID 用于判断结果是否仍然属于当前操作,避免依赖回调到达时间猜测新旧。
Reducer 是一致性的边界
Reducer 不执行网络、磁盘、时钟或回调,只同步决定事件是否接受,以及如何合并:
func feedReducer(action: Action, state: FeedState?) -> FeedState {
var state = state ?? FeedState()
switch action {
case let action as FeedRequested:
state.activeRequestID = action.requestID
state.request = .loading
case let action as CachedFeedLoaded:
guard state.documentsByID.isEmpty else { break }
state.merge(action.snapshot)
state.cacheRevision = action.snapshot.revision
case let action as RemoteFeedLoaded:
guard action.requestID == state.activeRequestID else { break }
state.merge(action.page)
state.request = .loaded
case let action as FeedRequestFailed:
guard action.requestID == state.activeRequestID else { break }
state.request = .failed(action.error)
case let action as QueryChanged:
state.query = action.query
case let action as DocumentMetadataChanged:
state.documentsByID[action.documentID]?.metadata = action.metadata
default:
break
}
return state
}
规则由此集中在一起:缓存可以填充空列表,但不能覆盖更鲜的数据;只有当前请求能更新加载状态与结果;筛选改变 query,而不是文档集合;元数据更新保留身份与其他字段。
Middleware 负责副作用
Reducer 回答“这个事件之后状态是什么”,Middleware 回答“这个事件应该启动哪些外部工作”。依赖通过参数注入,不从全局单例获取:
func makeFeedMiddleware(
api: FeedAPI,
cache: FeedCache,
makeRequestID: @escaping () -> UUID
) -> Middleware<FeedState> {
{ dispatch, getState in
{ next in
{ action in
next(action)
switch action {
case is FeedScreenAppeared:
if let snapshot = cache.read() {
dispatch(CachedFeedLoaded(snapshot: snapshot))
}
let requestID = makeRequestID()
dispatch(FeedRequested(
requestID: requestID,
reason: .initial
))
api.fetchFeed { result in
switch result {
case let .success(page):
dispatch(RemoteFeedLoaded(
requestID: requestID,
page: page
))
case let .failure(error):
dispatch(FeedRequestFailed(
requestID: requestID,
error: error
))
}
}
case is RemoteFeedLoaded,
is DocumentMetadataChanged:
if let state = getState() {
cache.write(FeedSnapshot(state: state))
}
default:
break
}
}
}
}
}
网络与缓存的结果重新转换成 Action,Middleware 不直接修改 FeedState。
视图派发事件,Middleware 执行副作用,Reducer 更新状态,Selector 生成展示数据。
重新梳理加载顺序
- 页面派发
FeedScreenAppeared。 - Middleware 读取缓存,存在时派发
CachedFeedLoaded。 - 创建请求 ID,派发
FeedRequested。 - 请求完成后派发
RemoteFeedLoaded或FeedRequestFailed。 - Reducer 只接受仍然有效的请求结果。
- 订阅者根据新状态重新计算可见数据。
- Middleware 将被接受的状态写回缓存。
缓存首屏仍然快速,但它和网络不再通过两条独立通道写入 UI。
元数据更新不应重建整个文档
把局部响应当完整文档替换,是字段丢失的常见原因。这里显式区分 patch 与 replacement:
struct MetadataUpdateRequested: Action {
let documentID: String
let patch: MetadataPatch
}
struct MetadataUpdateSucceeded: Action {
let documentID: String
let metadata: Metadata
}
struct MetadataUpdateFailed: Action {
let documentID: String
let previousMetadata: Metadata
}
Middleware 执行请求。Reducer 可以先乐观应用 patch,再用服务端权威值确认,失败时回滚;无论哪种策略,都保留文档身份和无关字段。
订阅范围要窄
单一 Store 不意味着所有页面订阅整个世界。列表只接收它真正渲染的派生状态:
struct FeedViewState: Equatable {
let documents: [Document]
let isLoading: Bool
let canLoadMore: Bool
}
store.subscribe(self) { subscription in
subscription.select { state in
let ids = visibleDocumentIDs(in: state)
return FeedViewState(
documents: ids.compactMap { state.documentsByID[$0] },
isLoading: state.request == .loading,
canLoadMore: state.pagination.hasNextPage
)
}
}
Equatable 的展示状态可以避免无效刷新。不可见文档的元数据变化不必重建可见列表,query 改变也不修改规范实体。
可测试性来自边界
Reducer 测试只需要输入旧状态与事件,检查输出:
func testStaleResponseCannotReplaceNewerFeed() {
let activeID = UUID()
let staleID = UUID()
var state = FeedState()
state.activeRequestID = activeID
let next = feedReducer(
action: RemoteFeedLoaded(
requestID: staleID,
page: .fixture(title: "Stale")
),
state: state
)
XCTAssertEqual(next, state)
}
Middleware 注入假 API、内存缓存和确定的请求 ID 生成器,用记录器验证事件顺序:
FeedScreenAppeared
CachedFeedLoaded
FeedRequested(id: 42)
RemoteFeedLoaded(id: 42)
失败、缓存缺失、分页和乱序结果都能在不构造 view controller 的情况下覆盖。测试并不是最后补一层 mock,而是把副作用放到可注入边界后,自然得到的结果。
不必把所有状态都放进 Store
Action 和 Reducer 有类型与样板代码成本,根状态也可能膨胀成新的巨型对象。这里集中的是跨网络、缓存、展示共享的持久状态;cell 高亮、正在进行的手势等短暂局部状态仍由视图管理。
Reducer 按领域拆分,Selector 隔离存储布局。只有多方生产和消费、需要共同遵守一致性规则的状态,才值得进入这条管线。
Events are Actions.
Effects live in Middleware.
Rules live in Reducers.
Facts live in State.
Views consume Selectors.
改造最重要的成果,是用明确协议替换隐式修改:筛选不再破坏源数据,局部响应不再覆盖完整文档,过期结果可以确定地拒绝,缓存也成为可测试的数据流组成部分。