全部文章

用 ReSwift 收拢复杂文档列表的数据流

让筛选、元数据更新、缓存与网络共享一份权威状态,通过 Action、Reducer 和 Middleware 拒绝过期结果,明确状态归属。

一份数据,为什么会有那么多版本

列表通常从“请求 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。

ReSwift 单向数据流视图派发事件,Middleware 执行副作用,Reducer 更新状态,Selector 生成展示数据。

重新梳理加载顺序

  1. 页面派发 FeedScreenAppeared。
  2. Middleware 读取缓存,存在时派发 CachedFeedLoaded。
  3. 创建请求 ID,派发 FeedRequested。
  4. 请求完成后派发 RemoteFeedLoaded 或 FeedRequestFailed。
  5. Reducer 只接受仍然有效的请求结果。
  6. 订阅者根据新状态重新计算可见数据。
  7. 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.

改造最重要的成果,是用明确协议替换隐式修改:筛选不再破坏源数据,局部响应不再覆盖完整文档,过期结果可以确定地拒绝,缓存也成为可测试的数据流组成部分。