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 模块的具体架构。

repository层4层架构_架构

从上面的图可以看到,作为 Networking 模块的使用者,Repository 模块位于 Networking 模块的上层。在朋友圈功能里面, Repository 模块负责统一管理所有朋友圈的数据,由MomentsRepoMomentsRepoType所组成。

其中MomentsRepoType是用于定义接口的协议,实现的逻辑都在遵循了该协议的MomentsRepo结构体里面。 当MomentsRepo需要访问网络数据时,就需要使用到 Networking 模块的组件。

在朋友圈功能里面,MomentsRepo使用了GetMomentsByUserIDSessionType来获取朋友圈信息,并使用了UpdateMomentLikeSessionType来更新点赞信息。

GetMomentsByUserIDSessionTypeUpdateMomentLikeSessionType是 Networking 模块里的两个协议, 它们的实现类型分别是GetMomentsByUserIDSessionUpdateMomentLikeSession结构体。其中,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。

repository层4层架构_ios_02

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类型,它也有类型为Datadata属性。在Data类型下还包含了类型为MomentsDetailsgetMomentsDetailsByUserID属性。

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对象。最重要的数据是类型为Parametersparameters属性。我们通过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,在它里面包含userDetailsmoments两个属性。在开发过程中我们往往要经常调试 Query,你可以使用 GraphiQL 工具来进行调试。你可以在 Moments App 的 BFF来尝试调试上面的 Query,执行效果如下。

repository层4层架构_架构_03


在此,我们已经讲完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对象里面包含了userDetailsmoments属性,具体的 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。它也包含了userDetailsmoments两个属性,但我们没办法从 JSON 中看出来,所幸 GraphQL 为我们提供了 Schema ,它可以描述各个数据的具体类型。

下面是MomentsDetails及其子类型的 Schema 定义。其中MomentsDetails包含了userDetailsmoments两个属性 ,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,而momentsMoment类型的数组。下面是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,userDetailstype等属性。其中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其实是DecodableEncodable两个协议合体,一个类型遵循了Codable表示该类型同时遵循DecodableEncodable两个协议。如下图所示,因为 BFF 返回的是 JSON 数据,我们可以使用JSONDecoder把 JSON 数据解码成 Swift 的 Model 类型,反过来,我们可以使用JSONEncoder把 Swift 的 Model 编码成 JSON 数据。

repository层4层架构_repository层4层架构_04

在 Swift 4 之前,我们需要使用JSONSerialization来反序列化 JSON 数据,然后把每一个属性单独转换成所需的类型。后来出现 SwiftyJSON 等库,帮我们减轻了一部分 JSON 转型工作,但还是需要大量手工编码来完成映射。

Swift 4 以后,出现了Codable协议,我们只需要把所定义的 Model 类型遵守该协议,Swift 在调用JSONDecoderdecode方法时就能自动完成转型。这样既能减少编写代码的数量,还能获得原生的性能。以下是APISession里面转换 JSON 到 Model 类型的代码:

let model = try JSONDecoder().decode(ReponseType.self, from: data)

我们只需要把转换的 Model 类型告诉decode方法即可。为了处理转换失败的情况,我们使用了try语句。当转型失败时,它会返回nil,使得我们的程序不会崩溃。

这里有一个技巧,假如你在开发中转型失败了,可以把 Model 定义的一部分属性先注释起来,找出引起转型失败的那个属性;然后,通过 GraphQL Schema 来检查该属性的数据类型,并判断该属性能否为空,最后根据 Schema 的定义来修改转型失败的属性。

总结

至此,我们就有了一个开发网络模块的模板,下面我来总结一下开发网络模块的具体流程。

  1. 根据 BFF 返回的 JSON 数据以及 GraphQL 的 Schema ,定义 Model 的数据类型,请记住所有类型都需要遵循Codable协议。
  2. 定义一个网络请求的协议,并提供一个请求的方法,该方法需要接收请求所需的所有参数,并返回包含 Model 类型的 Observable 序列。这样上层模块就能使用响应式编程的方式来处理网络请求的结果了。
  3. 遵循上述的协议并实现一个网络请求的结构体。在该结构体里定义一个遵循了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 模式有以下几大优势:

  1. Repository 模块作为唯一数据源统一管理所有数据,能有效保证整个 App 数据的一致性;
  2. Repository 模块封装了所有数据访问的细节,可提高程序的可扩展性和灵活性,例如,在不改变接口的情况下,把本地存储替换成其他的数据库;
  3. 结合 RxSwift 的 Subject, Repository 模块能自动更新 App 的数据与状态。

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

repository层4层架构_swift_05

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协议的定义。

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类型,然后发送给momentsDetailsSubject 属性。这样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)
}

其核心代码是订阅persistentDataStoremomentsDetails属性,然后把接收到所有事件都转发到自己的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 模块来完成。在这个方法里面,我们调用了getMomentsByUserIDSessiongetMoments()方法来发起 BFF 的网络请求。当我们得到朋友圈数据时,就会调用persistentDataStoresave()方法,把返回数据保存到本地。
updateLike()方法通过访问 BFF 来更新点赞信息。在这个方法里面,我们调用了updateMomentLikeSessionupdateLike()方法来发起更新请求。当我们得到更新后的朋友圈数据时,也会调用persistentDataStoresave()方法把数据保存到本地。

当其他模块,例如 ViewModel 模块想得到自动更新的朋友圈数据时,只需要订阅MomentsRepoTypemomentsDetailsSubject 属性即可。下面是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事件后,observer1observer2都停止接收其他事件了。其运行效果如下:

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订阅时会马上得到12两个值。

接着replaySubject再发出一个next(3)事件。当observer2订阅的时候会接收到最近的两个值23。在此以后observer1observer2会不断接收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 来避免这种情况。这就是为什么我们把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 层的准备呈现的数据。