在 Swift 中创建服务层

原文: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>
}
  1. 视图控制器通过 Combine 的 ObservableObject 协议对视图模型进行订阅。当用户对象从 /users 端点被检索到时,视图控制器将被通知。

由于 fetchUsers 方法是在后台线程上调用 URLSessiondataTask 方法,我们需要通过调用.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)")
            }
        }
    }
}
  1. 用于访问 fetchUsers 方法的服务属性。
  2. 服务属性是通过传入一个符合服务协议的 JsonPlaceholderService 实例来设置的。
  3. 视图模型的 retrieveUsers 方法通过服务属性访问服务的 fetchUsers 方法。

Service

本文的示例项目将从 Jsonplaceholder 上打到 /user 端点。这个端点将返回一个由十个不同的用户 JSON 对象组成的数组。如果你想尝试扩展这个项目,这个网站也有一些其他的端点,你可以点击。JsonPlaceholderServiceProtocol 只要求一个方法的一致性。fetchUsers 方法使用 URLSessiondataTask 方法从 /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()
    }
}
  1. 这将是该服务的类型。这个类型是一个协议,所以它可以被模拟(在下一节解释)。
  2. JsonPlaceholderService 是该协议的具体实现。这将被用来点击服务的端点并检索用户对象。
  3. 用来通过 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))
    }
}
  1. 这一行是一个小技巧,可以从你的测试包中获得 .json 文件。.json 文件应该包含你测试的端点的有效 JSON 对象。
  2. .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")
    }
}
  1. 一个测试方法,检查用户数组是否包含与 .json 文件相同数量的用户对象。当主体在 setUp 方法中被初始化时,这个方法被调用。

总结

在你的代码库中添加一个服务层有很多好处。它不仅能使你的代码库保持模块化,你还会从可重用性、单元测试覆盖率、可读性和可替换性中受益。

我总是觉得在解释新的概念时,最好是保持例子极其纤细和简单。保持你的代码库模块化的全部意义在于,你可以轻松地扩展其功能。

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

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

相关推荐
Swift社区12 小时前
Apple 新品发布会亮点有哪些 | Swift 周报 issue 61
ios·swiftui·swift
营赢盈英3 天前
OpenAI GPT-3 API error: “You must provide a model parameter“
chatgpt·gpt-3·openai·swift
一只不会编程的猫3 天前
高德地图绘图,点标记,并计算中心点
开发语言·ios·swift
loongloongz3 天前
Swift语言基础教程、Swift练手小项目、Swift知识点实例化学习
开发语言·学习·swift
2401_858120537 天前
深入理解 Swift 中的隐式解包可选类型(Implicitly Unwrapped Optionals)
开发语言·ios·swift
quaer7 天前
QT chart案例
开发语言·qt·swift
安和昂8 天前
【iOS】UIViewController的生命周期
ios·xcode·swift
00圈圈8 天前
Swift 创建扩展(Extension)
ios·swift·extension
2401_858120538 天前
Swift 中的函数:定义、使用与实践指南
开发语言·ssh·swift
quaer9 天前
VS+QT--实现二进制和十进制的转换(含分数部分)
java·开发语言·python·qt·swift