原文:Creating a Service Layer in Swift
什么是服务层?
服务层允许你将与框架和 API 相关的逻辑转移到它们自己的类或结构体中。一个好的做法是创建一个 protocol 并添加所需的方法和计算属性。你的实现将是一个遵守该协议的类或结构体。
为本文创建的示例项目使用了 UIKit、MVVM 设计模式和苹果的 Combine 框架的一部分。如果你对 Combine 不熟悉,那也没关系。你不需要成为 Combine 的专家,也能从本文中受益。
优点
创建服务层允许你从视图模型(MVVM)或视图控制器(MVC)中抽取出特定的框架逻辑。下面是这种方法的几个好处:
可重用
比方说,你需要从几个不同的视图模型中找到一个特定的端点。你不希望重复这个网络逻辑。通过将网络逻辑放在一个服务中,你可以从视图模型的服务实例中访问这些端点方法。
swift
protocol JsonPlaceholderServiceProtocol {
func fetchUsers(completion: @escaping (Result<[User], Error>) -> Void)
}
final class JsonPlaceholderService: JsonPlaceholderServiceProtocol {
<...>
func fetchUsers(completion: @escaping (Result<[User], Error>) -> Void) {
guard let url = URL(string: baseUrlString + Endpoint.users.rawValue) else { return }
urlSession.dataTask(with: url) { (data, response, error) in
if let error = error {
completion(.failure(error))
}
do {
let users = try JSONDecoder.userDecoder().decode([User].self, from: data!)
completion(.success(users))
} catch let err {
completion(.failure(err))
}
}.resume()
}
}
更容易编写单元测试
将你的服务创建为一个协议,然后用一个类或结构来实现,这是一个好的做法。通过创建一个协议,为单元测试目的创建一个服务的模拟将更加容易。你不希望在你的测试中碰到实际的 REST APIs。
swift
@testable import ServiceLayerExample
final class MockJsonPlaceholderService: JsonPlaceholderServiceProtocol {
func fetchUsers(completion: @escaping (Result<[User], Error>) -> Void) {
let pathString = Bundle(for: type(of: self)).path(forResource: "users", ofType: "json")!
let url = URL(fileURLWithPath: pathString)
let jsonData = try! Data(contentsOf: url)
let users = try! JSONDecoder.userDecoder().decode([User].self, from: jsonData)
completion(.success(users))
}
}
可读性
将你的依赖关系分离到他们自己的类型中,对于新的和现有的开发者来说,生活会更容易。通过将所有的框架/API 逻辑保存在一个文件中,开发人员可以快速了解项目中使用的内容。
可替换性
假设你现在的应用使用 Firebase,而你想切换到 Realm。你所有的存储提供者的逻辑都将集中在一个地方,使这个大的转变能够更顺利一些。例如,Firebase 和 MongoDB Realm 都有用于验证其服务的方法。把这些功能集中在一个地方会使转换变得更容易。
示例项目概述
下面的概述部分的代码已经缩短,以减少文章的长度。你可以在 GitHub 上找到完整的文件。
View
UserViewController
将包含一个 UITableView
来显示检索到的用户。我没有使用 Storyboard,所以视图控制器是以编程方式构建的。
swift
import Combine
import UIKit
fileprivate let cellId = "userCell"
final class UserViewController: UIViewController {
private var cancellables: Set<AnyCancellable> = []
private let viewModel = UserViewControllerViewModel()
private let tableView: UITableView = {
let tv = UITableView(frame: .zero, style: .insetGrouped)
tv.register(UITableViewCell.self, forCellReuseIdentifier: cellId)
tv.translatesAutoresizingMaskIntoConstraints = false
return tv
}()
override func viewDidLoad() {
super.viewDidLoad()
//<view setup>
// 1 - Subscribes to the viewModel to be notified when there are changes
viewModel.objectWillChange
.receive(on: RunLoop.main) // 切换到主线程
.sink { [weak self] in
self?.tableView.reloadData()
}.store(in: &cancellables)
}
}
extension UserViewController: UITableViewDelegate, UITableViewDataSource {
//<tableView set up methods>
}
- 视图控制器通过 Combine 的
ObservableObject
协议对视图模型进行订阅。当用户对象从/users
端点被检索到时,视图控制器将被通知。
由于 fetchUsers
方法是在后台线程上调用 URLSession
的 dataTask
方法,我们需要通过调用.receive(on:)
操作符确保我们在主线程上收到这些更新。
ViewModel
正如在介绍中提到的,该示例项目使用的是 MVVM 架构。UserViewController
将持有我们的 UserViewControllerViewModel
的一个实例。我们将使用 Combine 的 ObservableObject
协议来订阅视图模型的变化以更新视图。这个订阅是在视图控制器的 viewDidLoad
方法中创建的。
swift
import Combine
import Foundation
final class UserViewControllerViewModel: ObservableObject {
@Published var users: [User] = []
// 1 - used to access the `fetchUsers` method
private let service: JsonPlaceholderServiceProtocol
// 2 - pass in an instance of `JsonPlaceholderServiceProtocol`.
// This will be used to pass in a mock during testing.
init(service: JsonPlaceholderServiceProtocol = JsonPlaceholderService()) {
self.service = service
retrieveUsers()
}
// 3 - fetches users from the service.
private func retrieveUsers() {
service.fetchUsers { [weak self] result in
switch result {
case .success(let users):
self?.users = users.sorted(by: { $0.name < $1.name })
case .failure(let error):
print("Error retrieving users: \(error.localizedDescription)")
}
}
}
}
- 用于访问
fetchUsers
方法的服务属性。 - 服务属性是通过传入一个符合服务协议的
JsonPlaceholderService
实例来设置的。 - 视图模型的
retrieveUsers
方法通过服务属性访问服务的fetchUsers
方法。
Service
本文的示例项目将从 Jsonplaceholder 上打到 /user
端点。这个端点将返回一个由十个不同的用户 JSON 对象组成的数组。如果你想尝试扩展这个项目,这个网站也有一些其他的端点,你可以点击。JsonPlaceholderServiceProtocol 只要求一个方法的一致性。fetchUsers
方法使用 URLSession
的 dataTask
方法从 /user
端点检索 json 数据:
swift
// 1 - This will be the type that is passed into the `UserViewControllerViewModel`.
// This will also be used to "mock" the service.
protocol JsonPlaceholderServiceProtocol {
func fetchUsers(completion: @escaping (Result<[User], Error>) -> Void)
}
// 2 - A concrete implementation of the JsonPlaceholder service.
final class JsonPlaceholderService: JsonPlaceholderServiceProtocol {
// MARK: Types
enum Endpoint: String {
case users = "/users"
}
// MARK: Properties
private let baseUrlString = "https://jsonplaceholder.typicode.com"
private let urlSession: URLSession
// MARK: Initialization
init(urlSession: URLSession = URLSession.shared) {
self.urlSession = urlSession
}
// MARK: Methods
// 3 - this method will retrieve the user objects from the /users endpoint.
// This method will be mocked.
func fetchUsers(completion: @escaping (Result<[User], Error>) -> Void) {
guard let url = URL(string: baseUrlString + Endpoint.users.rawValue) else { return }
urlSession.dataTask(with: url) { (data, response, error) in
if let error = error {
completion(.failure(error))
}
do {
let users = try JSONDecoder.userDecoder().decode([User].self, from: data!)
completion(.success(users))
} catch let err {
completion(.failure(err))
}
}.resume()
}
}
- 这将是该服务的类型。这个类型是一个协议,所以它可以被模拟(在下一节解释)。
JsonPlaceholderService
是该协议的具体实现。这将被用来点击服务的端点并检索用户对象。- 用来通过
URLSession
dataTask
方法检索用户对象的方法。
Mock Service
为了对服务进行适当的单元测试,你需要创建一个模拟的服务。这可以通过多种方式完成,但我更喜欢协议方式。如果你觉得这样更容易理解,你也可以通过子类创建模拟。
swift
@testable import ServiceLayerExample
final class MockJsonPlaceholderService: JsonPlaceholderServiceProtocol {
func fetchUsers(completion: @escaping (Result<[User], Error>) -> Void) {
// 1 - retrieves a path to the users.json file in the Fixtures folder.
let pathString = Bundle(for: type(of: self)).path(forResource: "users", ofType: "json")!
let url = URL(fileURLWithPath: pathString)
let jsonData = try! Data(contentsOf: url)
// 2 - decodes the fixtures into `User` objects.
let users = try! JSONDecoder.userDecoder().decode([User].self, from: jsonData)
completion(.success(users))
}
}
- 这一行是一个小技巧,可以从你的测试包中获得
.json
文件。.json
文件应该包含你测试的端点的有效 JSON 对象。 - 将
.json
文件解码成一个模拟成功网络响应的用户对象数组。
单元测试
UserViewControllerViewModelTests
类有一个测试方法,以确保 fetchUsers
方法能正确解码并返回 JSON。
swift
import XCTest
@testable import ServiceLayerExample
class UserViewControllerViewModelTests: XCTestCase {
var mockJsonPlaceholderServiceProtocol: JsonPlaceholderServiceProtocol!
var subject: UserViewControllerViewModel!
override func setUp() {
super.setUp()
mockJsonPlaceholderServiceProtocol = MockJsonPlaceholderService()
subject = UserViewControllerViewModel(service: mockJsonPlaceholderServiceProtocol)
}
// 1 - ensures the `fetchUsers` method of the JsonPlaceholderServiceProtocol properly decodes the JSON into `User` objects.
func testFetchUsers() {
//method `retrieveUsers` call in the UserViewControllerViewModel's init. This occurs in the setUp method above.
XCTAssertEqual(subject.users.count, 10)
//`users` array is sorted A-Z
let firstUser = subject.users.first!
XCTAssertEqual(firstUser.id, 5)
XCTAssertEqual(firstUser.name, "Chelsey Dietrich")
}
}
- 一个测试方法,检查用户数组是否包含与
.json
文件相同数量的用户对象。当主体在setUp
方法中被初始化时,这个方法被调用。
总结
在你的代码库中添加一个服务层有很多好处。它不仅能使你的代码库保持模块化,你还会从可重用性、单元测试覆盖率、可读性和可替换性中受益。
我总是觉得在解释新的概念时,最好是保持例子极其纤细和简单。保持你的代码库模块化的全部意义在于,你可以轻松地扩展其功能。
这篇文章的样本项目可以在我的 GitHub 上找到。
Josh 是 Livefront 的一名 iOS 开发者,他是明尼苏达州东南地区的大使。