Внедрение зависимостей для приложений iOS, часть 1 — DI на основе инициализации

Внедрение зависимостей (DI) — это шаблон проектирования, который отделяет объект от инстанцирования его зависимостей. Объекту не нужно знать, как создавать свои зависимости. Вместо этого мы «вводим» их в него.

В этом контексте мы считаем зависимостью любой другой экземпляр, на который ссылается наш объект и который он использует для выполнения своих обязанностей. Эта концепция применима не только к классам, но и к структурам и любому другому объекту, который может ссылаться на другой объект.

Важно отделить наш объект от его зависимостей, чтобы:

  1. Каждый объект становится меньше и проще, а внесение в них изменений проще, быстрее и менее подвержено ошибкам.
  2. Мы могли бы заменить любой объект в любой момент времени другим объектом с аналогичным интерфейсом, не затрагивая всю систему. Это обеспечивает гибкость конструкции системы и обычно считается передовой практикой.

Давайте представим пример, чтобы полностью понять, что это значит.

Представьте, что у нас есть приложение социальной сети, в котором мы можем делиться постами с друзьями. Если бы мы разрабатывали экран «Поделиться», нам нужно было бы:

  1. Получите список друзей, с которыми мы можем поделиться публикацией.
  2. Отображение списка друзей на экране.
  3. Всякий раз, когда нажимается строка пользователя, делитесь с ним публикацией.
  4. Когда запись была успешно отправлена ​​пользователю, или когда публикация не удалась, показать уведомление в верхней части экрана с результатом операции.

Мы могли бы разбить обязанности этого экрана на разные объекты:

  1. ShareView: представление SwiftUI с компонентом списка, который отображает друзей пользователя. Он также отвечает за обработку пользовательского ввода. Когда пользователь щелкает строку друга, чтобы поделиться публикацией, событие передается в ShareViewModel.
  2. ShareViewModel: ObservableObject, который поддерживает состояние ShareView, получает список друзей и делится публикацией с другом, если пользователь выбрал строку в ShareView.
  3. FriendsRepository: компонент, отвечающий за получение друзей пользователя и асинхронный возврат массива с ними.
  4. ShareService: компонент, отвечающий за выполнение фактической логики для обмена публикацией с другом.
  5. NotificationService: служебный класс для отображения уведомлений верхнего уровня в пользовательском интерфейсе.

Почему внедрение зависимостей важно?

Теперь, когда мы знаем, какие объекты будут составлять наш экран, пришло время написать код. Обычно, если бы мы хотели закодировать приведенный выше пример, это выглядело бы так:

// MARK: - Dependencies
class FriendsRepository {
    func getFriends() async throws -> [User] { ... }
}
 
class ShareService {
    func share(_ post: Post, with user: User) async throws { ... }
}
 
class NotificationService {
    func showSuccess(message: String) { ... }
    func showError(message: String) { ... }
}
 
// MARK: - ViewModel
class ShareViewModel: ObservableObject {
    @Published var friends: [User] = []
 
    private let post: Post
    private let friendsRepository = FriendsRepository()
    private let shareService = ShareService()
    private let notificationService = NotificationService()
 
    init(post: Post) {
        self.post = post
    }
 
    func loadFriends() async {
        do {
            self.friends = try await friendsRepository.getFriends()
        } catch {
            self.friends = []
        }
    }
 
    func share(with friend: User) async {
        do {
            try await shareService.share(post, with: friend)
            notificationService.showSuccess(message: "Post shared with \\(friend.username)")
        } catch {
            notificationService.showError(message: "An error occurred")
        }
    }
}

Давайте оставим представление на другой раз и сосредоточимся на том, где живет реальная логика. Здесь у нас есть некоторая базовая структура для зависимостей, детали реализации которых для нас не имеют значения. Реализация ShareViewModel почти завершена. Он делает то, что ему нужно делать. У него есть метод loadFriends, который может выполняться всякий раз, когда на экране появляется ShareView, и метод share(with:), который следует вызывать, когда пользователь нажимает на строку друга или на кнопку.

Однако есть некоторые проблемы с этой реализацией. Рассмотрим следующие случаи:

  • Мы хотели бы протестировать нашу модель ShareViewModel. В его нынешнем состоянии тестирование было бы очень сложным, поскольку зависимости фиксированы, и мы не можем их имитировать. Например, тестирование метода loadFriends может привести к реальному вызову API.
  • Невозможно повторно использовать ShareViewModel в контексте, отличном от исходного, для которого был разработан класс. Если бы мы хотели использовать разные репозитории FriendsRepository в зависимости от экрана, на котором запущен этот поток общего доступа, это было бы невозможно, если бы зависимости были созданы в ShareViewModel.
  • В случае, если у ShareService или FriendsRepository есть сложные методы инициализации, ShareViewModel будет нести ответственность за принятие решения о том, какие аргументы им передать, и это приведет к тому, что у класса будет слишком много обязанностей.

Шаблон внедрения зависимостей помогает нам преодолеть эти проблемы, «внедряя» (предоставляя) зависимости извне класса. ShareViewModel не должен знать, какой подкласс FriendsRepository ему нужно использовать или как его настроить.

Типы внедрения зависимостей

Важным шагом перед использованием любого типа внедрения зависимостей является реализация зависимостей в виде протоколов, а не в виде классов, как мы делали выше. В этом случае реализация с использованием протоколов будет выглядеть так:

// MARK: - Interfaces
protocol FriendsRepository {
    func getFriends() async throws -> [User]
}
 
protocol ShareService {
    func share(_ post: Post, with user: User) async throws
}
 
protocol NotificationService {
    func showSuccess(message: String)
    func showError(message: String)
}
 
// MARK: - Concrete Implementations
class DefaultFriendsRepository: FriendsRepository {
    func getFriends() async throws -> [User] { ... }
}
 
class DefaultShareService: ShareService {
    func share(_ post: Post, with user: User) async throws { ... }
}
 
class DefaultNotificationService: NotificationService {
    func showSuccess(message: String) { ... }
    func showError(message: String) { ... }
}

Существует множество различных типов внедрения зависимостей, каждый из которых имеет свои сильные стороны. Давайте рассмотрим два наиболее распространенных варианта внедрения зависимостей в iOS: DI на основе Init и DI на основе контейнера. В этой первой части нашей статьи мы рассмотрим DI на основе Init.

1. DI на основе инициализации

Наиболее распространенный и один из самых простых типов внедрения зависимостей включает предоставление зависимости во время инициализации класса.

Давайте посмотрим на пример рефакторинга реализации ShareViewModel:

@Published var friends: [User] = []
 
private let post: Post
 
// Dependencies aren't instantiated in this class
private let friendsRepository: FriendsRepository
private let shareService: ShareService
private let notificationService: NotificationService
 
init(
    post: Post,
    // We 'inject' the dependencies in the init method.
    friendsRepository: FriendsRepository = DefaultFriendsRepository(),
    shareService: ShareService = DefaultShareService(),
    notificationService: NotificationService = DefaultNotificationService()
) {
    self.post = post
    self.friendsRepository = friendsRepository
    self.shareService = shareService
    self.notificationService = notificationService
}
 
func loadFriends() async {
    do {
        self.friends = try await friendsRepository.getFriends()
    } catch {
        self.friends = []
    }
}
 
func share(with friend: User) async {
    do {
        try await shareService.share(post, with: friend)
        notificationService.showSuccess(message: "Post shared with \\(friend.username)")
    } catch {
        notificationService.showError(message: "An error ocurred")
    }
}

Преимущество этого подхода в том, что зависимости не являются необязательными. Это означает, что зависимости объекта будут иметь фактические значения при инициализации объекта.

Обратите внимание, что каждый аргумент в init имеет значение по умолчанию, поэтому, когда нам нужен экземпляр ShareViewModel в приложении, мы можем сделать:

let post: Post = ...
let viewModel = ShareViewModel(post: post)

Нет необходимости заранее знать, как получить зависимости, поэтому создание экземпляра класса так же просто, как и раньше.

Однако при необходимости мы могли бы внедрить другие зависимости. Это может быть полезно, например, в модульных тестах или в случае, если нам нужно будет повторно использовать класс в другом контексте. Давайте посмотрим, как это может помочь нам в написании хороших модульных тестов.

Модульное тестирование и внедрение зависимостей

Внедрение зависимостей имеет решающее значение для эффективного модульного тестирования.

Давайте протестируем наш класс ShareViewModel. Нам нужно убедиться, что для него верно следующее поведение:

  • Учитывая, что FriendsRepository возвращает список друзей, когда ShareViewModel запрашивается для загрузки друзей, тогда свойство друзей ShareViewModel обновляется друзьями, возвращенными FriendsRepository.
  • Учитывая, что FriendsRepository выдает ошибку, когда ShareViewModel запрашивается для загрузки друзей, свойство друзей ShareViewModel обновляется пустым массивом.
  • Если служба ShareService завершается успешно, когда модель ShareViewModel запрашивается, чтобы поделиться публикацией с другом, служба NotificationService используется для отображения сообщения об успешном выполнении.
  • Если служба ShareService выдает ошибку, когда модель ShareViewModel запрашивается, чтобы поделиться публикацией с другом, служба NotificationService используется для отображения сообщения об ошибке.

Довольно простые сценарии. Теперь представьте, что мы не используем никаких инъекций зависимостей, и у нас есть вся функциональность, встроенная в класс. Было бы невозможно определить, работает ли ShareViewModel так, как мы ожидаем, или нет.

Теперь, когда мы описали способ реализации внедрения зависимостей и кодируем для интерфейсов, а не для конкретных реализаций, мы можем использовать макеты вместо конкретных реализаций, чтобы протестировать ShareViewModel.

Макет — это реализация зависимости, специально предназначенная для использования в целях тестирования. В этом случае мы можем определить следующие макеты:

class MockFriendsRepository: FriendsRepository {
    // We can change the result of `getFriends`,
    // in order to test different scenarios.
    var getFriendsResult: Result<[User], Error> = .success([])
 
    func getFriends() async throws -> [User] {
        switch getFriendsResult {
            case let .success(friends): return friends
            case let .failure(error): throw error
        }
    }
}
 
class MockShareService: ShareService {
    // We can force the share function throw an error
    var shareError: Error?
 
    func share(_ post: Post, with user: User) async throws {
        if let error = shareError {
            throw error
        }
    }
}
 
class MockNotificationService: NotificationService {
    // We can also register how many times 
    // a function is being called
    var showSuccessCallCount = 0
    var showErrorCallCount = 0
 
    func showSuccess(message: String) {
        showSuccessCallCount += 1
    }
 
    func showError(message: String) {
        showErrorCallCount += 1
    }
}

Они могут стать более сложными, если нам нужно. Мы могли бы, например, зарегистрировать аргументы, используемые для вызова методов этих макетов. В этом случае давайте будем проще.

Модульное тестирование с использованием DI на основе Init

Для DI на основе инициализации процесс тестирования прост. Мы можем создавать фиктивные зависимости и воссоздавать их каждый раз при запуске тестового примера (в функции setUp).

ViewModel создается с фиктивными вариантами своих зависимостей, и эти фиктивные модели сохраняются в переменных внутри подкласса XCTestCase.

import XCTest
@testable import DIExampleApp
 
class ShareViewModelTests: XCTestCase {
    enum TestError: Error {
        case testCase
    }
 
    private let post = Post(id: 1234)
 
    // We save references to the mocks
    private var friendsRepository: MockFriendsRepository!
    private var shareService: MockShareService!
    private var notificationService: MockNotificationService!
 
    private var viewModel: ShareViewModel!
 
    override func setUp() {
        super.setUp()
 
        // Mocks are regenerated with each test run
        friendsRepository = MockFriendsRepository()
        shareService = MockShareService()
        notificationService = MockNotificationService()
 
        // The view model is instantiated with mocks,
        // instead of actual dependencies.
        viewModel = ShareViewModel(
            post: post,
            friendsRepository: friendsRepository,
            shareService: shareService,
            notificationService: notificationService
        )       
    }
 
    func testLoadFriendsSuccess() async {
        // GIVEN
        let friends = [
            User(id: 1),
            User(id: 2),
            User(id: 3)
        ]
 
        // We can configure the mock to test different scenarios.
        friendsRepository.getFriendsResult = .success(friends)
 
        // WHEN
        await viewModel.loadFriends()
 
        // THEN
        XCTAssertEqual(viewModel.friends, friends)
    }
 
    func testLoadFriendsFailure() async {
        // GIVEN
        friendsRepository.getFriendsResult = .failure(TestError.testCase)
 
        // WHEN
        await viewModel.loadFriends()
 
        // THEN
        XCTAssertEqual(viewModel.friends, [])
    }
 
    func testShareSuccess() async {
        // GIVEN
        shareService.shareError = nil // Not needed, added for clarity
 
        // WHEN
        await viewModel.share(with: User(id: 1))        
 
        // THEN
        XCTAssertEqual(notificationService.showSuccessCallCount, 1)
    }
 
    func testShareFailure() async {
        // GIVEN
        shareService.shareError = TestError.testCase
 
        // WHEN
        await viewModel.share(with: User(id: 1))        
 
        // THEN
        XCTAssertEqual(notificationService.showErrorCallCount, 1)
    }
}

Во второй части статьи «Внедрение зависимостей в приложения iOS» мы обсудим внедрение зависимостей на основе контейнера.