Skip to content

19数据层架构:如何使用仓库模式设计数据存储层?

数据是 App 的血液,没有了数据,App 就没办法工作了。但是要保持数据的一致性,并不是一件简单的事情。因为在 App 中多个页面共享同一份数据的情况经常出现。比如,朋友圈时间轴列表页面和朋友圈详情页都共享了朋友圈数据,当我们在详情页点了赞,怎样让时间轴页面同步状态数据呢?如果有多于两个页面,它们之间又怎样保持同步呢?

目前比较流行的方案是使用Repository(数据仓库)模式 。 例如 Android Architecture Components 就推荐使用该模式。在 Moments App 中,我也使用 Repository 模式维护唯一数据源,通过RxSwift 的 Subject 保证数据的自动更新。为了与具体的数据库进行解耦并提高程序的灵活性,我还使用了DataStore 模块来抽象本地数据存储与访问。这一讲我就和你介绍下我是怎么做的。

Repository 模式的架构

所谓 Repository 模式,就是为数据访问提供抽象的接口,数据使用者在读写数据时,只调用相关的接口函数,并不关心数据到底存放在网络还是本地,也不用关心本地数据库的具体实现。使用 Repository 模式有以下几大优势:

  1. Repository 模块作为唯一数据源统一管理所有数据,能有效保证整个 App 数据的一致性;

  2. Repository 模块封装了所有数据访问的细节,可提高程序的可扩展性和灵活性,例如,在不改变接口的情况下,把本地存储替换成其他的数据库;

  3. 结合 RxSwift 的 Subject, Repository 模块能自动更新 App 的数据与状态。

我们以朋友圈功能为例,看看如何使用 Repository 模式。下面是 Repository 模块的架构图。

ViewModel 模块 是 Repository 模块的上层数据使用者,在朋友圈功能里面,MomentsTimelineViewModelMomentListItemViewModel都通过MomentsRepoTypemomentsDetailsSubject 来订阅数据的更新。

Repository 模块分成两大部分: Repo 和 DataStore。其中 Repo 负责统一管理数据(如访问网络的数据、读写本地数据),并通过 Subject 来为订阅者分发新的数据。

Repo 由MomentsRepoType协议和遵循该协议的MomentsRepo结构体所组成。MomentsRepoType协议用于定义接口,而MomentsRepo封装具体的实现,当MomentsRepo需要读取和更新 BFF 的数据时,会调用 Networking 模块的组件,这方面我在上一讲已经详细介绍过了。而当MomentsRepo需要读取和更新本地数据时,会使用到 DataStore。

DataStore 负责本地数据的存储,它由PersistentDataStoreType协议和UserDefaultsPersistentDataStore结构体所组成。其中,PersistentDataStoreType协议用于定义本地数据读写的接口。而UserDefaultsPersistentDataStore结构体是其中一种实现。从名字可以看到,该实现使用了 iOS 系统所提供的 UserDefaults 来存储数据。

假如我们需要支持 Core Data,那么可以提供另外一个结构体来遵循PersistentDataStoreType协议,比如把该结构体命名为CoreDataPersistentDataStore,并使用它来封装所有 Core Data 的访问细节。有了 DataStore 的接口,我们可以很方便地替换不同的本地数据库。

Repository 模式的实现

看完 Repository 模式的架构设计,我们一起了解下 Repo 和 DataStore 的具体实现。

首先我们看一下 DataStore 模块,下面是PersistentDataStoreType协议的定义。

swift
protocol PersistentDataStoreType {
    var momentsDetails: ReplaySubject<MomentsDetails> { get }
    func save(momentsDetails: MomentsDetails)
}

该协议提供了momentsDetails属性来给数据使用者读取朋友圈数据,并提供了save(momentsDetails: MomentsDetails)方法来保存朋友圈信息。

在 Moments App 里面,我们为PersistentDataStoreType协议提供一个封装了 UserDefaults 的实现,其具体代码如下。

swift
struct UserDefaultsPersistentDataStore: PersistentDataStoreType {
    static let shared: UserDefaultsPersistentDataStore = .init()
    private(set) var momentsDetails: ReplaySubject<MomentsDetails> = .create(bufferSize: 1)
    private let disposeBage: DisposeBag = .init()
    private let defaults = UserDefaults.standard
    private let momentsDetailsKey = String(describing: MomentsDetails.self)
    private init() {
        defaults.rx
            .observe(Data.self, momentsDetailsKey)
            .compactMap { $0 }
            .compactMap { try? JSONDecoder().decode(MomentsDetails.self, from: $0) }
            .subscribe(momentsDetails)
            .disposed(by: disposeBage)
    }
    func save(momentsDetails: MomentsDetails) {
        if let encodedData = try? JSONEncoder().encode(momentsDetails) {
            defaults.set(encodedData, forKey: momentsDetailsKey)
        }
    }
}

因为UserDefaultsPersistentDataStore遵循了PersistentDataStoreType协议,因此需要实现momentsDetails属性和save()方法。

其中momentsDetails属性为 RxSwfit 的ReplaySubject类型。它负责把数据的更新事件发送给订阅者。在init()方法中,我们通过了 Key 来订阅 UserDefaults 里的数据更新,一旦与该 Key 相关联的数据发生了变化,我们就使用JSONDecoder来把更新的数据解码成MomentsDetails类型,然后发送给momentsDetailsSubject 属性。这样momentsDetails属性就可以把数据事件中转给外部的订阅者了。

save(momentsDetails: MomentsDetails)方法用于保存数据,首先把传递进来的momentsDetails对象通过JSONEncoder来编码,并把编码后的数据写入 UserDefaults 中。这里需要注意,我们在读写 UserDefaults 时,提供的 Key 必须保持一致。为了保证这一点,我们使用了同一个私有属性momentsDetailsKey来进行读写。

接着来看 Repo 模块,下面是MomentsRepoType协议的定义。

swift
protocol MomentsRepoType {
    var momentsDetails: ReplaySubject<MomentsDetails> { get }
    func getMoments(userID: String) -> Observable<Void>
    func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable<Void>
}

在该协议中,momentsDetails属性用来为订阅者发送朋友圈数据的更新事件。getMoments(userID: String) -> Observable<Void>方法用于获取朋友圈信息数据,而updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable<Void>方法用于更新点赞信息。

因为MomentsRepo结构体遵循了MomentsRepoType协议,它也实现了momentsDetails属性以及getMoments()updateLike()方法。

momentsDetails属性是一个ReplaySubject的对象,用于转发朋友圈数据的更新事件,我们可以从init()方法里面看到它是怎样转发数据的。

swift
init(persistentDataStore: PersistentDataStoreType,
             getMomentsByUserIDSession: GetMomentsByUserIDSessionType,
             updateMomentLikeSession: UpdateMomentLikeSessionType) {
    self.persistentDataStore = persistentDataStore
    self.getMomentsByUserIDSession = getMomentsByUserIDSession
    self.updateMomentLikeSession = updateMomentLikeSession
    persistentDataStore
        .momentsDetails
        .subscribe(momentsDetails)
        .disposed(by: disposeBag)
}

其核心代码是订阅persistentDataStoremomentsDetails属性,然后把接收到所有事件都转发到自己的momentsDetails属性。

然后我们来看getMoments()updateLike()方法。 其代码如下。

swift
func getMoments(userID: String) -> Observable<Void> {
    return getMomentsByUserIDSession
        .getMoments(userID: userID)
        .do(onNext: { persistentDataStore.save(momentsDetails: $0) })
        .map { _ in () }
        .catchErrorJustReturn(())
}
func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable<Void> {
    return updateMomentLikeSession
        .updateLike(isLiked, momentID: momentID, fromUserID: userID)
        .do(onNext: { persistentDataStore.save(momentsDetails: $0) })
        .map { _ in () }
        .catchErrorJustReturn(())
}

getMoments()方法通过请求 BFF 来获取朋友圈信息,因为 Repository 模块所有的网络请求操作都通过调用 Networking 模块来完成。在这个方法里面,我们调用了getMomentsByUserIDSessiongetMoments()方法来发起 BFF 的网络请求。当我们得到朋友圈数据时,就会调用persistentDataStoresave()方法,把返回数据保存到本地。
updateLike()方法通过访问 BFF 来更新点赞信息。在这个方法里面,我们调用了updateMomentLikeSessionupdateLike()方法来发起更新请求。当我们得到更新后的朋友圈数据时,也会调用persistentDataStoresave()方法把数据保存到本地。

当其他模块,例如 ViewModel 模块想得到自动更新的朋友圈数据时,只需要订阅MomentsRepoTypemomentsDetailsSubject 属性即可。下面是MomentsTimelineViewModel中的例子代码。

swift
momentsRepo.momentsDetails.subscribe(onNext: {
    // 接收并处理朋友圈数据更新
}).disposed(by: disposeBag)

RxSwift Subject

你可以看到,在 Repository 模块里面,我大量使用了 RxSwift 的 Subject 来中转数据事件。 在 RxSwift 里面,常见的 Subject 有PublishSubject、BehaviorSubject 和 ReplaySubject。它们的区别在于订阅者能否收到订阅前的事件。那么,在程序代码中它们是如何工作的呢?接下来我就为你一一介绍下。

PublishSubject

首先看一下 PublishSuject。顾名思义,PublishSuject 用于发布(Publish)事件,它的特点是订阅者只能接收订阅后的事件。下面是 PublishSuject 的例子代码。

swift
let publishSubject = PublishSubject<Int>()
publishSubject.onNext(1)
let observer1 = publishSubject.subscribe { event in
    print("observer1: \(event)")
}
observer1.disposed(by: disposeBag)
publishSubject.onNext(2)
let observer2 = publishSubject.subscribe { event in
    print("observer2: \(event)")
}
observer2.disposed(by: disposeBag)
publishSubject.onNext(3)
publishSubject.onCompleted()
publishSubject.onNext(4)

首先,我们生成一个名叫publishSubject的对象,并发出onNext(1)事件,接着通过subscribe方法来生成一个名叫observer1的订阅者。由于publishSubject的订阅者只能收到订阅以后的事件,因此observer1无法收到之前的onNext(1)的事件。

publishSubject发出onNext(2)事件时,observer1就会收到该事件。在此之后,我们又生成了第二个订阅者observer2,该订阅者也没法接收到以前的事件。当publishSubject发出onNext(3)completed事件的时候,两个订阅者都能接收到。因为completed事件把该 Subject 关闭了,之后所有订阅者都不能接收到onNext(4)事件。

下面是整段程序的执行效果。

java
observer1: next(2)
observer1: next(3)
observer2: next(3)
observer1: completed
observer2: completed

PublishSubject 很适合发送新的事件,但有时候,消息发送者需要比订阅者先进行初始化,此时订阅者就无法接收到原有事件。例如在 Moments App 里面,UserDefaultsPersistentDataStore就先于MomentsRepo进行初始化并立刻读取 UserDefaults 里缓存的数据,假如我们使用 PublishSubject,MomentsRepo将无法读取到第一条的朋友圈数据。

那怎样解决这样的问题呢?RxSwift 提供了 BehaviorSubject 和 ReplaySubject 来帮助我们读取在 Subject 里缓存的数据。

BehaviorSubject

BehaviorSubject 用于缓存一个事件,当订阅者订阅 BehaviorSubject 时,会马上收到该 Subject 里面最后一个事件。我们通过例子来看看 BehaviorSubject 是怎样工作的。

swift
let behaviorSubject = BehaviorSubject<Int>(value: 1)
let observer1 = behaviorSubject.subscribe { event in
    print("observer1: \(event)")
}
observer1.disposed(by: disposeBag)
behaviorSubject.onNext(2)
let observer2 = behaviorSubject.subscribe { event in
    print("observer2: \(event)")
}
observer2.disposed(by: disposeBag)
behaviorSubject.onNext(3)
behaviorSubject.onCompleted()
behaviorSubject.onNext(4)

因为 BehaviorSubject 要给订阅者提供订阅前的最后一条事件,我们需要传递初始值来生成BehaviorSubject。在上面的代码中可以看到,我们传递了1来新建behaviorSubject对象,当observer1订阅时马上就能接收到next(1)事件。而observer2订阅的时候只能接收到前一个next(2)事件。接着,它们都能收到next(3)事件。当收到completed事件后,observer1observer2都停止接收其他事件了。其运行效果如下:

java
observer1: next(1)
observer1: next(2)
observer2: next(2)
observer1: next(3)
observer2: next(3)
observer1: completed
observer2: completed

ReplaySubject

BehaviorSubject 只能缓存一个事件,当我们需要缓存 N 个事件时,就可以使用 ReplaySubject。例如我们需要统计最后三天的天气信息,那么可以把 N 设置为 3,当订阅者开始订阅时,就可以得到前三天的天气信息。以下是 ReplaySubject 工作的大致过程。

swift
let replaySubject = ReplaySubject<Int>.create(bufferSize: 2)
replaySubject.onNext(1)
replaySubject.onNext(2)
let observer1 = replaySubject.subscribe { event in
    print("observer1: \(event)")
}
observer1.disposed(by: disposeBag)
replaySubject.onNext(3)
let observer2 = replaySubject.subscribe { event in
    print("observer2: \(event)")
}
observer2.disposed(by: disposeBag)
replaySubject.onNext(4)
replaySubject.onCompleted()
replaySubject.onNext(5)

为了看出与 BehaviorSubject 的不同之处,在这里我把 N 设置为 "2"。首先我们把 2 传入bufferSize来创建一个replaySubject对象,然后发出两个next事件,当observer1订阅时会马上得到12两个值。

接着replaySubject再发出一个next(3)事件。当observer2订阅的时候会接收到最近的两个值23。在此以后observer1observer2会不断接收replaySubject的事件,直到收到completed事件后停止。其运行效果如下:

java
observer1: next(1)
observer1: next(2)
observer1: next(3)
observer2: next(2)
observer2: next(3)
observer1: next(4)
observer2: next(4)
observer1: completed
observer2: completed

除了能缓存更多的数据以外,还有一情况我们会选择使用 ReplaySubject 而不是BehaviorSubject。

在初始化 BehaviorSubject 的时候,我们必须提供一个初始值。如果我没办法提供,只能把存放的类型定义为 Optional (可空)类型。但是我们可以使用 ReplaySubject 来避免这种情况。这就是为什么我们把UserDefaultsPersistentDataStoreMomentsRepomomentsDetailsSubject 属性都定义为 ReplaySubject 而不是 BehaviorSubject 的原因。

除了上面的三个 Subject 以外,RxSwift 还为我们提供了两个特殊的 Subject:PublishRelay 和 BehaviorRelay ,它们的名字和 BehaviorSubject 和 ReplaySubject 非常类似,区别是 Relay 只中继next事件,我们并不能往 Relay 里发送completederror事件。

总结

在这一讲中,我们介绍了 Repository 模式的架构与实现,然后通过例子来解释各种 Subject 的区别。我把本讲 Subject 的例子代码都放在项目中的RxSwift Playground 文件里面,希望你能多练习,灵活运用。

下面是一些在项目场景中使用 Subject 的经验,希望对你有帮助。

  1. 如果需要把 Subject 传递给其他类型发送消息,例如在朋友圈时间轴列表页面把 Subject 传递给各个朋友圈子组件,然后接收来自子组件的事件。 这种情况我们一般会传递 PublishSubject,因为在传递前在主页面(如例子中的朋友圈时间轴页面)已经订阅了该 PublishSubject,子组件所发送事件,主页面都能收到。

  2. BehaviorSubject 可用于状态管理,例如管理页面的加载状态,开始时可以把 BehaviorSubject 初始化为加载状态,一旦得到返回数据就可以转换为成功状态。

  3. 因为 BehaviorSubject 必须赋予初始值,但有些情况下,我们并没有初始化,如果使用 BehaviorSubject 必须把其存放的类型定义为 Optional 类型。为了避免使用 Optional,我们可以使用 bufferSize 为 1 的 ReplaySubject 来代替 BehaviorSubject。

  4. Subject 和 Relay 都能用于中转事件,当中转的事件中没有completederror时,我们都选择 Relay。

思考题

请问你们的 App 使用本地数据库吗?使用的是哪一款数据库,有没有试过替换数据库的情况,能分享一下这方面的经验吗?

请把你的想法写到留言区哦,下一讲我将介绍如何使用 ViewModel 模式来为 UI 层的准备呈现的数据。

源码地址:

RxSwift Playground 文件地址:
https://github.com/lagoueduCol/iOS-linyongjian/blob/main/Playgrounds/RxSwiftPlayground.playground/Contents.swift

Repo 源码地址:
https://github.com/lagoueduCol/iOS-linyongjian/blob/main/Moments/Moments/Features/Moments/Repositories/MomentsRepo.swift

DataStore 源码地址:
https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Foundations/DataStore