18 网络层架构:如何设计网络访问与 JSON 数据解析?
为了存取服务器上的数据,并与其他用户进行通信,几乎所有的 iOS App 都会访问后台 API 。目前流行的后台 API 设计有几种方案: RESTful、gRPC、GraphQL 和 WebSocket。其中,gRPC 使用 Protobuf 进行数据传输, GraphQL 和 RESTful 往往使用 JSON 进行传输。
为了把访问后台 API 的网络传输细节给屏蔽掉,并为上层模块提供统一的访问接口,我们在架构 App 的时候,往往会把网络访问封装成一个独立的 Networking 模块。像我们的 Moments App 也不例外,它的这个模块负责访问 BFF,同时把返回的 JSON 数据进行解码。所以,这一讲,我主要介绍下 Networking 模块的架构设计与实现,以及如何使用 Swift 的 Codable 来解码返回的 JSON 数据。
Networking 模块架构
下图是朋友圈功能 Networking 模块的具体架构。
从上面的图可以看到,作为 Networking 模块的使用者,Repository 模块位于 Networking 模块的上层。在朋友圈功能里面, Repository 模块负责统一管理所有朋友圈的数据,由MomentsRepo
和MomentsRepoType
所组成。
其中MomentsRepoType
是用于定义接口的协议,实现的逻辑都在遵循了该协议的MomentsRepo
结构体里面。 当MomentsRepo
需要访问网络数据时,就需要使用到 Networking 模块的组件。
在朋友圈功能里面,MomentsRepo
使用了GetMomentsByUserIDSessionType
来获取朋友圈信息,并使用了UpdateMomentLikeSessionType
来更新点赞信息。
GetMomentsByUserIDSessionType
和UpdateMomentLikeSessionType
是 Networking 模块里的两个协议, 它们的实现类型分别是GetMomentsByUserIDSession
和UpdateMomentLikeSession
结构体。其中,GetMomentsByUserIDSession
通过访问 BFF 来读取朋友圈信息,而UpdateMomentLikeSession
通过 BFF 来更新点赞信息。当 BFF 返回时,它们都会使用JSONDecoder
来把返回的 JSON 数据解码成名为MomentsDetails
的 Model 数据。
那为什么MomentsRepo
依赖GetMomentsByUserIDSessionType
协议而不是GetMomentsByUserIDSession
结构体?因为这样能使MomentsRepo
依赖于抽象的接口,而不是具体实现,在 Swift 中,这种模式叫作面对协议编程(Protocol Oriented Programming)。使用了这种模式以后,我们可以很灵活地替换具体的实现类型,提高架构的可扩展性和灵活性。
目前,我们把访问 GraphQL 的技术细节封装在GetMomentsByUserIDSession
里面。假如以后需要把后台改成 gRPC API,在 Moments App 中可以实现另一个结构体来遵循GetMomentsByUserIDSessionType
协议,比如命名为GetMomentsByUserIDSessionGRPC
,然后把所有访问的 gRPC 的操作都封装在里面(如下图所示),这样我们在不改变MomentsRepo
的情况下就支持了新的网络 API。
Networking 模块实现
有了架构设计以后我们一起看看 Networking 模块的实现。首先,我会先介绍下底层 HTTP 网络通信模块,然后以 Moments App 朋友圈信息的网络请求为例,为你介绍下怎样开发一个网络请求模块,以及解码 JSON 返回数据。
底层 HTTP 网络通信模块
为了方便访问支持 RESTFul 和 GraphQL 的 API, 在 Moments App 中,我们开发了一个底层 HTTP 网络通信模块,该模块把所有 HTTP 请求封装起来,核心是APISession
协议。下面是它的定义。
protocol APISession {
associatedtype ReponseType: Codable
func post(_ path: String, parameters: Parameters?, headers: HTTPHeaders) -> Observable<ReponseType>
}
APISession
定义了post(_ path: String, parameters: Parameters?, headers: HTTPHeaders) -> Observable<ReponseType>
方法来发起 HTTP POST 请求,然后返回Observable<ReponseType>
。有了 Observable 序列,我们就能把网络返回数据引进到以 RxSwift 所连接的 MVVM 框架中。
你可能问,为什么Observable
存放的是ReponseType
类型呢?由于APISession
并不知道每一个网络请求返回数据的具体类型,因此使用associatedtype
来定义ReponseType
,以迫使所有遵循它的实现类型都必须指定ReponseType
的具体数据类型。
例如在GetMomentsByUserIDSession
里面的Session
结构体,我们使用typealias
来指定ReponseType
的具体类型为Response
,其代码示例如下。
typealias ReponseType = Response
为了方便共享 HTTP 网络请求的功能,我们为APISession
定义了协议扩展,并给post(_ path: String, parameters: Parameters?, headers: HTTPHeaders) -> Observable<ReponseType>
方法提供默认的实现。具体代码示例如下。
extension APISession {
func post(_ path: String, headers: HTTPHeaders = [:], parameters: Parameters? = nil) -> Observable<ReponseType> {
return request(path, method: .post, headers: headers, parameters: parameters, encoding: JSONEncoding.default)
}
}
为了提高代码的可重用性,我们定义了名叫request(_ path: String, method: HTTPMethod, headers: HTTPHeaders, parameters: Parameters?, encoding: ParameterEncoding) -> Observable<ReponseType>
的私有方法,来支持 HTTP 的其他 Method,代码示例如下。
private func request(_ path: String, method: HTTPMethod, headers: HTTPHeaders, parameters: Parameters?, encoding: ParameterEncoding) -> Observable<ReponseType> {
let url = baseUrl.appendingPathComponent(path)
let allHeaders = HTTPHeaders(defaultHeaders.dictionary.merging(headers.dictionary) { $1 })
return Observable.create { observer -> Disposable in
let queue = DispatchQueue(label: "moments.app.api", qos: .background, attributes: .concurrent)
let request = AF.request(url, method: method, parameters: parameters, encoding: encoding, headers: allHeaders, interceptor: nil, requestModifier: nil)
.validate()
.responseJSON(queue: queue) { response in
// 处理返回的 JSON 数据
}
return Disposables.create {
request.cancel()
}
}
}
有了request()
方法,我们就可以支持不同的 HTTP Method 了。如果需要支持 HTTP GET 请求的时候,只需把HTTPMethod.get
传递给该方法就可以了。
request()
方法的核心逻辑是怎么样的呢?在该方法里面,我们首先使用Observable.create()
方法来创建一个 Observable 序列并返回给调用者,然后在create()
方法的封包里使用 Alamofire 的request()
方法发起网络请求。为了不阻挡 UI 的响应,我们把该请求安排到后台队列中执行。当我们得到返回的 JSON 以后,会使用下面的代码进行处理。
switch response.result {
case .success:
guard let data = response.data else {
// if no error provided by Alamofire return .noData error instead.
observer.onError(response.error ?? APISessionError.noData)
return
}
do {
let model = try JSONDecoder().decode(ReponseType.self, from: data)
observer.onNext(model)
observer.onCompleted()
} catch {
observer.onError(error)
}
case .failure(let error):
if let statusCode = response.response?.statusCode {
observer.onError(APISessionError.networkError(error: error, statusCode: statusCode))
} else {
observer.onError(error)
}
}
其逻辑是,当网络请求成功了,就把返回的 JSON 数据通过JSONDecoder
解码成ReponseType
类型,并通过onNext
方法发送到 Observable 序列中,接着调用onCompleted
方法来关闭数据流;如果发生网络错误,就通过onError
方法来发送错误事件。
请求朋友圈信息模块
有了底层 HTTP 网络通信模块以后,我们来看看怎样开发一个网络请求模块。
在 Moments App 中,为了分离责任和方便管理,我们为每一个网络请求都定义了一个协议以及对应的实现结构体。在我们的例子中,它们分别是GetMomentsByUserIDSessionType
协议和GetMomentsByUserIDSession
结构体。
其中GetMomentsByUserIDSessionType
协议的定义如下。
protocol GetMomentsByUserIDSessionType {
func getMoments(userID: String) -> Observable<MomentsDetails>
}
该协议只定义了一个getMoments(userID: String) -> Observable<MomentsDetails>
方法来提供访问朋友圈信息的接口。因为每个用户的朋友圈信息都不一样,我们需要把用户 ID 传递给该方法,并返回包含了MomentsDetails
的 Observable 序列。
接下来看看GetMomentsByUserIDSession
结构体的实现。因为GetMomentsByUserIDSession
遵循了etMomentsByUserIDSessionType
协议,因此必须实现来自该协议的getMoments(userID: String) -> Observable<MomentsDetails>
方法。具体实现如下所示。
func getMoments(userID: String) -> Observable<MomentsDetails> {
let session = Session(userID: userID)
return sessionHandler(session).map {
$0.data.getMomentsDetailsByUserID }
}
该方法通过sessionHandler
来获取网络请求的结果。其中sessionHandler
是一个封包,它接收了类型为Session
的入口参数,我们可以在init
方法里面看到sessionHandler
的具体实现,如下所示:
init(togglesDataStore: TogglesDataStoreType = InternalTogglesDataStore.shared, sessionHandler: @escaping (Session) -> Observable<Response> = {
$0.post($0.path, headers: $0.headers, parameters: $0.parameters)
}) {
self.togglesDataStore = togglesDataStore
self.sessionHandler = sessionHandler
}
其中$0
表示入口参数Session
的对象, 由于Session
遵循了APISession
协议,它可以直接调用APISession
的扩展方法post
来发起 HTTP POST 请求,并获取类型为Response
的返回值。
那返回值Response
的类型是怎样定义出来的呢?其实它的定义来自 BFF 返回值 JSON 的数据结构,该 JSON 包含了data
属性,data
下有一个getMomentsDetailsByUserID
属性,具体数据结构如下。
{
"data": {
"getMomentsDetailsByUserID": {
// MomentsDetails object
"userDetails": {...},
"moments": [...]
}
}
}
{
"data": {
"getMomentsDetailsByUserID": {
// MomentsDetails object
"userDetails": {...},
"moments": [...]
}
}
}
我们可以根据 JSON 的数据结构来定义 Swift 的Response
类型,它也有类型为Data
的data
属性。在Data
类型下还包含了类型为MomentsDetails
的getMomentsDetailsByUserID
属性。
struct Response: Codable {
let data: Data
struct Data: Codable {
let getMomentsDetailsByUserID: MomentsDetails
}
}
为了把 Observable 序列的类型从Response
转换成MomentsDetails
类型,我们在getMoments
方法里调用了转换操作符map { $0.data.getMomentsDetailsByUserID }
从Response
里抽出getMomentsDetailsByUserID
进行返回。
接着我们看看Session
结构体的具体实现。 该结构体负责准备 GraphQL 请求的数据,这些数据包括 URL 路径、HTTP 头和参数。URL 路径比较简单,是一个值为/graphql
的常量。HTTP 头也是一个默认的HTTPHeaders
对象。最重要的数据是类型为Parameters
的parameters
属性。我们通过init
方法来看看该属性是怎样进行初始化的。它的实现代码如下所示。
init(userID: String) {
let variables: [AnyHashable: Encodable] = ["userID": userID]
parameters = ["query": Self.query,
"variables": variables]
}
首先我们把传递进来的userID
存放到类型为[AnyHashable: Encodable]
的variables
变量里面,然后把它与query
属性一同赋值给parameters
。
那么query
是怎样来的呢?因为所有的 GraphQL 的请求都需要发送 Query,在朋友圈信息请求的例子也不例外,query
属性就是用于定义要发送的 Query 的,其定义如下。
private static let query = """
query getMomentsDetailsByUserID($userID: ID!) {
getMomentsDetailsByUserID(userID: $userID) {
userDetails {
id
name
avatar
backgroundImage
}
moments {
id
userDetails {
name
avatar
}
type
title
photos
createdDate
}
}
}
"""
}
在该 Query 定义中,我们定义了类型为ID!
的入口参数$userID
,同时定义了返回值的数据结构,例如返回getMomentsDetailsByUserID
,在它里面包含userDetails
和moments
两个属性。在开发过程中我们往往要经常调试 Query,你可以使用 GraphiQL 工具来进行调试。你可以在 Moments App 的 BFF来尝试调试上面的 Query,执行效果如下。
在此,我们已经讲完Session
的实现了,有了 URL 路径,HTTP 头和参数。sessionHandler
就可以使用它来发起 HTTP POST 请求。具体调用如下所示。
session.post(session.path, headers: session.headers, parameters: session.parameters)
解码 JSON 返回数据
当我们从 BFF 取得 JSON 返回数据的时候,需要把它解析为 Swift Model 来引入 MVVM 架构里面。那怎样才能把 JSON 数据解码成 Model 类型MomentsDetails
呢?
这要从返回 JSON 的数据结构入手。JSON 返回结果是由上面的 Query 定义所决定的,在getMomentsDetailsByUserID
对象里面包含了userDetails
和moments
属性,具体的 JSON 如下。
{
"userDetails": {
"id": "0",
"name": "Jake Lin",
"avatar": "https://avatar-url",
"backgroundImage": "https://background-image-url"
},
"moments": [
{
"id": "0",
"userDetails": {
"name": "Taylor Swift",
"avatar": "https://another-avatar-url"
},
"type": "PHOTOS",
"title": null,
"photos": [
"https://photo-url"
],
"createdDate": "1615899003"
}
]
}
{
"userDetails": {
"id": "0",
"name": "Jake Lin",
"avatar": "https://avatar-url",
"backgroundImage": "https://background-image-url"
},
"moments": [
{
"id": "0",
"userDetails": {
"name": "Taylor Swift",
"avatar": "https://another-avatar-url"
},
"type": "PHOTOS",
"title": null,
"photos": [
"https://photo-url"
],
"createdDate": "1615899003"
}
]
}
有了 JSON 数据结构,我们就可以定义一个 Swift 的 Model 来进行映射,例如把该 Model 命名为MomentsDetails
。它也包含了userDetails
和moments
两个属性,但我们没办法从 JSON 中看出来,所幸 GraphQL 为我们提供了 Schema ,它可以描述各个数据的具体类型。
下面是MomentsDetails
及其子类型的 Schema 定义。其中MomentsDetails
包含了userDetails
和moments
两个属性 ,userDetails
为非空的UserDetails
类型。而moments
的类型是包含非空的Moment
数组,同样地,该数组自己也不能为空。具体定义如下所示。
type MomentsDetails {
userDetails: UserDetails!
moments: [Moment!]!
}
type Moment {
id: ID!
userDetails: UserDetails!
type: MomentType!
title: String
url: String
photos: [String!]!
createdDate: String!
}
type UserDetails {
id: ID!
name: String!
avatar: String!
backgroundImage: String!
}
enum MomentType {
URL
PHOTOS
}
有了上面的 GraphQL Schema,加上 JSON 数据结构,我们可以完成MomentsDetails
的映射。
struct MomentsDetails: Codable {
let userDetails: UserDetails
let moments: [Moment]
}
具体做法是把 GraphQL 中的type
映射成struct
,然后每个属性都使用let
来定义成常量。在 GraphQL 中,!
符合表示非空类型,因此在 Swift 中也使用非空类型。在我们的例子中userDetails
属性的类型为非空的UserDetails
,而moments
是Moment
类型的数组。下面是UserDetails
类型的定义,它有id
,name
等属性。
struct UserDetails: Codable {
let id: String
let name: String
let avatar: String
let backgroundImage: String
}
接着我们看看Moment
类型定义。
struct Moment: Codable {
let id: String
let userDetails: MomentUserDetails
let type: MomentType
let title: String?
let url: String?
let photos: [String]
let createdDate: String
struct MomentUserDetails: Codable {
let name: String
let avatar: String
}
enum MomentType: String, Codable {
case url = "URL"
case photos = "PHOTOS"
}
}
Moment
类型包含了id
,title
,userDetails
和type
等属性。其中title
在 GraphQL 中 Schema 里面没有定义为!
,表示这个属性可能为空,当我们映射成 Swift 类型时使用了?
来表示这个属性是可空类型(Optional)。userDetails
属性的类型是一个嵌套类型MomentUserDetails
,我推荐把所有的子类型都内嵌到父类型里面,这样能把所有的类型定义统一封装在MomentsDetails
里面,访问的时候就有命名空间。
最后我们看一下type
属性,它在 GraphQL 里的定义是一个枚举。我们把它映射为类型是MomentType
的一个枚举。由于 GraphQL 会通过字符串来传输enum
,当我们在 Swift 中映射成枚举类型时,需要把该enum
定义为字符串类型,并为每一个case
都指定需要映射的字符串值。例如我们给url
指定为"URL"
。
为了让 Swift 帮我们进行自动的解码与编码,我们把所有所有类型都遵守了Codable
协议,下面是Codable
协议的定义。
public typealias Codable = Decodable & Encodable
Codable
其实是Decodable
和Encodable
两个协议合体,一个类型遵循了Codable
表示该类型同时遵循Decodable
和Encodable
两个协议。如下图所示,因为 BFF 返回的是 JSON 数据,我们可以使用JSONDecoder
把 JSON 数据解码成 Swift 的 Model 类型,反过来,我们可以使用JSONEncoder
把 Swift 的 Model 编码成 JSON 数据。
在 Swift 4 之前,我们需要使用JSONSerialization
来反序列化 JSON 数据,然后把每一个属性单独转换成所需的类型。后来出现 SwiftyJSON 等库,帮我们减轻了一部分 JSON 转型工作,但还是需要大量手工编码来完成映射。
Swift 4 以后,出现了Codable
协议,我们只需要把所定义的 Model 类型遵守该协议,Swift 在调用JSONDecoder
的decode
方法时就能自动完成转型。这样既能减少编写代码的数量,还能获得原生的性能。以下是APISession
里面转换 JSON 到 Model 类型的代码:
let model = try JSONDecoder().decode(ReponseType.self, from: data)
我们只需要把转换的 Model 类型告诉decode
方法即可。为了处理转换失败的情况,我们使用了try
语句。当转型失败时,它会返回nil
,使得我们的程序不会崩溃。
这里有一个技巧,假如你在开发中转型失败了,可以把 Model 定义的一部分属性先注释起来,找出引起转型失败的那个属性;然后,通过 GraphQL Schema 来检查该属性的数据类型,并判断该属性能否为空,最后根据 Schema 的定义来修改转型失败的属性。
总结
至此,我们就有了一个开发网络模块的模板,下面我来总结一下开发网络模块的具体流程。
- 根据 BFF 返回的 JSON 数据以及 GraphQL 的 Schema ,定义 Model 的数据类型,请记住所有类型都需要遵循
Codable
协议。 - 定义一个网络请求的协议,并提供一个请求的方法,该方法需要接收请求所需的所有参数,并返回包含 Model 类型的 Observable 序列。这样上层模块就能使用响应式编程的方式来处理网络请求的结果了。
- 遵循上述的协议并实现一个网络请求的结构体。在该结构体里定义一个遵循了
APISession
协议的Session
结构体,并在Session
结构体内定义发送给 GraphQL 的query
属性,我们可以通过 GraphiQL 工具来测试 Query 的定义。
思考题:
请问在你们项目中是如何解析网络返回的 JSON 数据呢?能否分享一下经验。
可以把你的答案写得留言区哦,下一讲我将介绍如何使用仓库模式设计数据存储层。
源码地址:
底层 HTTP 网络通信模块:https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Foundations/Networking 请求朋友圈信息模块:https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Features/Moments/Networking
19 数据层架构:如何使用仓库模式设计数据存储层?
数据是 App 的血液,没有了数据,App 就没办法工作了。但是要保持数据的一致性,并不是一件简单的事情。因为在 App 中多个页面共享同一份数据的情况经常出现。比如,朋友圈时间轴列表页面和朋友圈详情页都共享了朋友圈数据,当我们在详情页点了赞,怎样让时间轴页面同步状态数据呢?如果有多于两个页面,它们之间又怎样保持同步呢?
目前比较流行的方案是使用Repository(数据仓库)模式。 例如 Android Architecture Components 就推荐使用该模式。在 Moments App 中,我也使用 Repository 模式维护唯一数据源,通过RxSwift 的 Subject保证数据的自动更新。为了与具体的数据库进行解耦并提高程序的灵活性,我还使用了DataStore 模块来抽象本地数据存储与访问。这一讲我就和你介绍下我是怎么做的。
Repository 模式的架构
所谓 Repository 模式,就是为数据访问提供抽象的接口,数据使用者在读写数据时,只调用相关的接口函数,并不关心数据到底存放在网络还是本地,也不用关心本地数据库的具体实现。使用 Repository 模式有以下几大优势:
- Repository 模块作为唯一数据源统一管理所有数据,能有效保证整个 App 数据的一致性;
- Repository 模块封装了所有数据访问的细节,可提高程序的可扩展性和灵活性,例如,在不改变接口的情况下,把本地存储替换成其他的数据库;
- 结合 RxSwift 的 Subject, Repository 模块能自动更新 App 的数据与状态。
我们以朋友圈功能为例,看看如何使用 Repository 模式。下面是 Repository 模块的架构图。
ViewModel 模块是 Repository 模块的上层数据使用者,在朋友圈功能里面,MomentsTimelineViewModel
和MomentListItemViewModel
都通过MomentsRepoType
的momentsDetails
Subject 来订阅数据的更新。
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
协议的定义。
protocol PersistentDataStoreType {
var momentsDetails: ReplaySubject<MomentsDetails> { get }
func save(momentsDetails: MomentsDetails)
}
该协议提供了momentsDetails
属性来给数据使用者读取朋友圈数据,并提供了save(momentsDetails: MomentsDetails)
方法来保存朋友圈信息。
在 Moments App 里面,我们为PersistentDataStoreType
协议提供一个封装了 UserDefaults 的实现,其具体代码如下。
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
类型,然后发送给momentsDetails
Subject 属性。这样momentsDetails
属性就可以把数据事件中转给外部的订阅者了。
save(momentsDetails: MomentsDetails)
方法用于保存数据,首先把传递进来的momentsDetails
对象通过JSONEncoder
来编码,并把编码后的数据写入 UserDefaults 中。这里需要注意,我们在读写 UserDefaults 时,提供的 Key 必须保持一致。为了保证这一点,我们使用了同一个私有属性momentsDetailsKey
来进行读写。
接着来看 Repo 模块,下面是MomentsRepoType
协议的定义。
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()
方法里面看到它是怎样转发数据的。
init(persistentDataStore: PersistentDataStoreType,
getMomentsByUserIDSession: GetMomentsByUserIDSessionType,
updateMomentLikeSession: UpdateMomentLikeSessionType) {
self.persistentDataStore = persistentDataStore
self.getMomentsByUserIDSession = getMomentsByUserIDSession
self.updateMomentLikeSession = updateMomentLikeSession
persistentDataStore
.momentsDetails
.subscribe(momentsDetails)
.disposed(by: disposeBag)
}
其核心代码是订阅persistentDataStore
的momentsDetails
属性,然后把接收到所有事件都转发到自己的momentsDetails
属性。
然后我们来看getMoments()
和updateLike()
方法。 其代码如下。
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 模块来完成。在这个方法里面,我们调用了getMomentsByUserIDSession
的getMoments()
方法来发起 BFF 的网络请求。当我们得到朋友圈数据时,就会调用persistentDataStore
的save()
方法,把返回数据保存到本地。updateLike()
方法通过访问 BFF 来更新点赞信息。在这个方法里面,我们调用了updateMomentLikeSession
的updateLike()
方法来发起更新请求。当我们得到更新后的朋友圈数据时,也会调用persistentDataStore
的save()
方法把数据保存到本地。
当其他模块,例如 ViewModel 模块想得到自动更新的朋友圈数据时,只需要订阅MomentsRepoType
的momentsDetails
Subject 属性即可。下面是MomentsTimelineViewModel
中的例子代码。
momentsRepo.momentsDetails.subscribe(onNext: {
// 接收并处理朋友圈数据更新
}).disposed(by: disposeBag)
RxSwift Subject
你可以看到,在 Repository 模块里面,我大量使用了 RxSwift 的 Subject 来中转数据事件。 在 RxSwift 里面,常见的 Subject 有PublishSubject、BehaviorSubject 和 ReplaySubject。它们的区别在于订阅者能否收到订阅前的事件。那么,在程序代码中它们是如何工作的呢?接下来我就为你一一介绍下。
PublishSubject
首先看一下 PublishSuject。顾名思义,PublishSuject 用于发布(Publish)事件,它的特点是订阅者只能接收订阅后的事件。下面是 PublishSuject 的例子代码。
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)
事件。
下面是整段程序的执行效果。
observer1: next(2)
observer1: next(3)
observer2: next(3)
observer1: completed
observer2: completed
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 是怎样工作的。
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
事件后,observer1
和observer2
都停止接收其他事件了。其运行效果如下:
observer1: next(1)
observer1: next(2)
observer2: next(2)
observer1: next(3)
observer2: next(3)
observer1: completed
observer2: completed
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 工作的大致过程。
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
订阅时会马上得到1
和2
两个值。
接着replaySubject
再发出一个next(3)
事件。当observer2
订阅的时候会接收到最近的两个值2
和3
。在此以后observer1
和observer2
会不断接收replaySubject
的事件,直到收到completed
事件后停止。其运行效果如下:
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
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 来避免这种情况。这就是为什么我们把UserDefaultsPersistentDataStore
和MomentsRepo
的momentsDetails
Subject 属性都定义为 ReplaySubject 而不是 BehaviorSubject 的原因。
除了上面的三个 Subject 以外,RxSwift 还为我们提供了两个特殊的 Subject:PublishRelay 和 BehaviorRelay,它们的名字和 BehaviorSubject 和 ReplaySubject 非常类似,区别是 Relay 只中继next
事件,我们并不能往 Relay 里发送completed
或error
事件。
总结
在这一讲中,我们介绍了 Repository 模式的架构与实现,然后通过例子来解释各种 Subject 的区别。我把本讲 Subject 的例子代码都放在项目中的RxSwift Playground 文件里面,希望你能多练习,灵活运用。
下面是一些在项目场景中使用 Subject 的经验,希望对你有帮助。
- 如果需要把 Subject 传递给其他类型发送消息,例如在朋友圈时间轴列表页面把 Subject 传递给各个朋友圈子组件,然后接收来自子组件的事件。 这种情况我们一般会传递 PublishSubject,因为在传递前在主页面(如例子中的朋友圈时间轴页面)已经订阅了该 PublishSubject,子组件所发送事件,主页面都能收到。
- BehaviorSubject 可用于状态管理,例如管理页面的加载状态,开始时可以把 BehaviorSubject 初始化为加载状态,一旦得到返回数据就可以转换为成功状态。
- 因为 BehaviorSubject 必须赋予初始值,但有些情况下,我们并没有初始化,如果使用 BehaviorSubject 必须把其存放的类型定义为 Optional 类型。为了避免使用 Optional,我们可以使用 bufferSize 为 1 的 ReplaySubject 来代替 BehaviorSubject。
- Subject 和 Relay 都能用于中转事件,当中转的事件中没有
completed
或error
时,我们都选择 Relay。
思考题
请问你们的 App 使用本地数据库吗?使用的是哪一款数据库,有没有试过替换数据库的情况,能分享一下这方面的经验吗?
请把你的想法写到留言区哦,下一讲我将介绍如何使用 ViewModel 模式来为 UI 层的准备呈现的数据。