原文:Creating a Service Layer in Swift

什么是服务层?

服务层答应你将与结构和 API 相关的逻辑转移到它们自己的类或结构体中。一个好的做法是创立一个 protocol 并增加所需的办法和计算属性。你的完结将是一个遵守该协议的类或结构体。

为本文创立的示例项目运用了 UIKit、MVVM 设计模式苹果的 Combine 结构的一部分。假如你对 Combine 不熟悉,那也没关系。你不需求成为 Combine 的专家,也能从本文中受益。

优点

创立服务层答应你从视图模型(MVVM)或视图控制器(MVC)中抽取出特定的结构逻辑。下面是这种办法的几个优点:

可重用

比方说,你需求从几个不同的视图模型中找到一个特定的端点。你不期望重复这个网络逻辑。经过将网络逻辑放在一个服务中,你能够从视图模型的服务实例中拜访这些端点办法。

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。

@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,所以视图控制器是以编程方式构建的。

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>
}
  1. 视图控制器经过 Combine 的 ObservableObject 协议对视图模型进行订阅。当用户目标从 /users 端点被检索届时,视图控制器将被告诉。

由于 fetchUsers 办法是在后台线程上调用 URLSessiondataTask 办法,咱们需求经过调用.receive(on:) 操作符保证咱们在主线程上收到这些更新。

ViewModel

正如在介绍中提到的,该示例项目运用的是 MVVM 架构。UserViewController 将持有咱们的 UserViewControllerViewModel 的一个实例。咱们将运用 Combine 的 ObservableObject 协议来订阅视图模型的变化以更新视图。这个订阅是在视图控制器的 viewDidLoad 办法中创立的。

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)")
            }
        }
    }
}
  1. 用于拜访 fetchUsers 办法的服务属性。
  2. 服务属性是经过传入一个契合服务协议的 JsonPlaceholderService 实例来设置的。
  3. 视图模型的 retrieveUsers 办法经过服务属性拜访服务的 fetchUsers 办法。

Service

本文的示例项目将从 Jsonplaceholder 上打到 /user 端点。这个端点将返回一个由十个不同的用户 JSON 目标组成的数组。假如你想测验扩展这个项目,这个网站也有一些其他的端点,你能够点击。JsonPlaceholderServiceProtocol 只要求一个办法的一致性。fetchUsers 办法运用 URLSessiondataTask 办法从 /user 端点检索 json 数据:

// 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()
    }
}
  1. 这将是该服务的类型。这个类型是一个协议,所以它能够被模仿(在下一节解说)。
  2. JsonPlaceholderService 是该协议的具体完结。这将被用来点击服务的端点并检索用户目标。
  3. 用来经过 URLSession dataTask 办法检索用户目标的办法。

Mock Service

为了对服务进行适当的单元测验,你需求创立一个模仿的服务。这能够经过多种方式完结,但我更喜爱协议方式。假如你觉得这样更简单了解,你也能够经过子类创立模仿。

@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))
    }
}
  1. 这一行是一个小技巧,能够从你的测验包中取得 .json 文件。.json 文件应该包括你测验的端点的有用 JSON 目标。
  2. .json 文件解码成一个模仿成功网络呼应的用户目标数组。

单元测验

UserViewControllerViewModelTests 类有一个测验办法,以保证 fetchUsers 办法能正确解码并返回 JSON。

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")
    }
}
  1. 一个测验办法,检查用户数组是否包括与 .json 文件相同数量的用户目标。当主体在 setUp 办法中被初始化时,这个办法被调用。

总结

在你的代码库中增加一个服务层有许多优点。它不仅能使你的代码库坚持模块化,你还会从可重用性、单元测验覆盖率、可读性和可替换性中受益。

我总是觉得在解说新的概念时,最好是坚持比如极端纤细和简单。坚持你的代码库模块化的全部含义在于,你能够轻松地扩展其功能。

这篇文章的样本项目能够在我的 GitHub 上找到。

Josh 是 Livefront 的一名 iOS 开发者,他是明尼苏达州东南地区的大使。