Обзор компонентов

Обзор компонентов

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

Сервис

Вначале была функция. Точнее, вначале были данные, т.к. без данных функция не имеет смысла. С другой стороны, если нет функции, откуда взялись данные? Чтобы не погружаться в проблему курицы и яйца, объединим функцию и данные и будем считать, что одно вытекает из другого, что они неразрывно связаны. Тогда получится, что вначале был объект. Будем также считать, что объект — это состояние (кучка данных) + набор методов (функций), которые с этим состоянием умеют работать. Чтобы не навлечь на себя гнев адептов ООП за столь вольную трактовку Парадигмы, сформулируем отдельный специальный термин: «Сервис».

👉
Сервис — это объект, который выполняет задачи, определенные в рамках своей зоны ответственности, реагируя на сообщения. Сообщение — это обращение к методу или свойству того или иного сервиса.

Примерами сервисов являются локальное хранилище и сетевой слой. Отличительными особенностями сервиса являются:

  • императивный интерфейс;
  • внутреннее состояние.

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

Сервис бесполезен, если им никто не пользуется. Значит, внешняя по отношению к сервису среда должна его инициализировать, а затем пинговать сообщениями. Сообщения также не берутся из ниоткуда: их порождают другие сервисы. В контексте iOS исключение — это сервис-патриарх UIApplicationMain. Его создает и параметризирует с помощью AppDelegate функция main (видимо, все-таки вначале была функция!):

#import "AppDelegate.h"
int main(int argc, char *argv[])
{
    @autoreleasepool {
        return UIApplicationMain(
            argc,
            argv,
            nil,
            NSStringFromClass([AppDelegate class])
        );
    }
}

UIApplicationMain получает сообщения не от сервисов, а напрямую от операционной системы. Однако обычно в этот код мы не заглядываем, поэтому сервисом-праотцом будем считать AppDelegate. У него даже нет явного конструктора (инициализатора), поэтому AppDelegate – идеальный кандидат на роль сущности, появившейся из небытия!

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    var appController: IAppController?

    func application(
        _ application: UIApplication, didFinishLaunchingWithOptions
        launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        self.appController = AppController() // тривиальный
        self.window = appController?.start() // нетестируемый код
        return true
    }
}

protocol IAppController {
    func start() -> UIWindow
}

class AppController: IAppController {

    func start() -> UIWindow {
        let window = UIWindow()
        window.rootViewController = UINavigationController(
            rootViewController: StartViewController()
        )
        window.makeKeyAndVisible()
        return window
    }
}

Единственный недостаток AppDelegate как сервиса в том, что его инициализатор недоступен. Все свои зависимости AppDelegate инициализирует самостоятельно, что в общем случае приводит к невозможности «честно» покрыть его Unit-тестами. С другой стороны, этот класс настолько плотно увязан с окружением, т.е. операционной системой, что тестировать его нет смысла. Проще превратить его в тривиальный прокси-объект, вся ответственность которого будет заключаться в передаче системных событий другим специализированным сервисам.

💡
Кое-какая тривиальная логика в AppDelegate все-таки должна быть. Технически мы можем использовать один большой «пользовательский» делегат, куда «системный» AppDelegate будет проксировать все события напрямую. В этом случае придется в Unit-тестах «пользовательского» делегата эти события формировать, что не всегда возможно – у объектов системных событий инициализатор может быть недоступен.

Каждый сервис должен быть разделен на интерфейс и реализацию. В контексте iOS и языка Swift интерфейс обычно представлен протоколом, а реализация — классом.Связи между сервисами в терминах UML описываются диаграммой классов, соответственно, где описываемый сервис на диаграмме представлен классом, а все его зависимости — интерфейсами. Такую упрощенную диаграмму классов удобно называть диаграммой сервисов. Диаграмма сервисов позволяет легко оценить зависимости каждого сервиса и понять, насколько сложно будет покрыть сервис Unit-тестами.

Диаграмма сервисов описывает зависимости сервиса PhoneViewModel.
Диаграмма сервисов описывает зависимости сервиса PhoneViewModel.

На диаграмме сервисов используется три вида стрелок: наследование, использование и создание. Стрелка наследования связывает интерфейс и реализацию. Стрелка использования показывает, какие интерфейсы нужны сервису для работы. Стрелка создания показывает, кто отвечает за создание сервиса (места, в которых сервис используется).

💡
Состояние приложения хранится в совокупности состояний сервисов приложения.

Сервисы хранят состояние приложения, поэтому ветвления в бизнес-логике сервиса описываются диаграммой состояний.

Диаграмма состояний для сервиса входа.
Диаграмма состояний для сервиса входа.

Выше приведен пример диаграммы состояний для сервиса, отвечающего за вход в приложение. Сервис получает от пользователя номера телефона, затем ожидает SMS-код подтверждения. Если код верный, сервис получает от backend-а токен авторизации, в противном случае можно запросить код повторно, но не ранее чем через 60 секунд. Тестировать такой сервис непросто, потому что в нем смешивается несколько типов логики:

  1. бизнес-логика – ограничение на количество SMS-кодов (SMS стоят денег);
  2. UI-логика: индикация обратного отсчета и навигация;
  3. техническая логика – токен авторизации.

Предположим, нам удалось распределить ответственности входа так, чтобы все сервисы, отвечающие за вход, оставались тестируемыми. Например, UI-логику можно отдать вью-контроллерам, а таймер SMS-кодов держать внутри AuthAPI. Тогда AuthViewModel останется тестируемой:

import Foundation

final class AuthViewModel: IAuthViewModel {
    private let authAPI: IAuthAPI
    private let storage: IStorage
    init(authAPI: IAuthAPI, storage: IStorage) {
        self.authAPI = authAPI
        self.storage = storage
    }
    var onFinish: VoidCompletion = nil
    private var phone = ""

    var isRequestAvailable: Bool { authAPI.isRequestAvailable }

    func apply(phone: String, completion: @escaping VoidHandler) {
        self.phone = phone
        authAPI.requestSMS(phone: phone, completion: completion)
    }

    func check(code: String, onError: ErrorCompletion) {
        authAPI.getAuthToken(
            phone: self.phone, smsCode: code
        ) { [weak self] result in
            switch result {
            case .success(let token):
                self?.storage.save(token: token)
                self?.onFinish?()
            case .failure(let error):
                onError?(error)
            }
        }
    }
}

Наглядность по-прежнему обеспечивается диаграммой состояний, различие лишь в том, что диаграмма описывает теперь совокупность сервисов, а не отдельный объект. А вот с пластичностью не все гладко: вспомогательные сервисы, обслуживающие AuthViewModel, знают друг о друге. Это можно исправить, если жесткие зависимости заменить на контекстные через механизм подписок на события. Но эти подписки тоже нужно где-то сформировать, а затем это «где-то» протестировать!

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        let window = UIWindow()
        let authAPI = AuthAPI()
        let storage = Storage()
        let authVM = AuthViewModel(authAPI: authAPI, storage: storage)
        authVM.onFinish = {
            // в обработчике нетестируемая логика!
            let booksVM = BooksViewModel()
            let booksVC = BooksViewController(viewModel: booksVM)
            let navigationVC = UINavigationController(rootViewController: booksVC)
            window.rootViewController = navigationVC
        }

        let phoneVC = PhoneViewController(viewModel: authVM)
        let navigationVC = UINavigationController(rootViewController: phoneVC)
        window.rootViewController = navigationVC
        window.makeKeyAndVisible()

        return true
    }
}

Таким образом, использование сервисов и подписок в AppDelegate обеспечивает пластичность и наглядность, но логика формирования подписок остается нетестируемой. Значит, нужно перенести эту логику в тестируемую область. Для этого понадобится операция.

Операция

👉
Операция — это неделимая последовательность сообщений между сервисами.

Как правило, операция представлена отдельным объектом, который получает сервисы в качестве зависимостей. Основное отличие операции от сервиса в том, что она не имеет императивного интерфейса, т.е. не предоставляет доступа к своему состоянию и не имеет каких-либо методов в интерфейсе, кроме метода launch. Метод launch принимает на вход параметры, описывающие контекст запуска (переменные и/или дополнительные контекстные зависимости), а также замыкание onFinish, если операция асинхронная.

import UIKit

protocol IAuthOp {
    func launch(onFinish: VoidCompletion)
}

final class AuthOp: IAuthOp {
    private let window: UIWindow
    private let authAPI: IAuthAPI
    private let storage: IStorage

    init(window: UIWindow, authAPI: IAuthAPI, storage: IStorage) {
        self.window = window
        self.authAPI = authAPI
        self.storage = storage
    }

    func launch(onFinish: VoidCompletion) {
        let authVM = AuthViewModel(authAPI: authAPI, storage: storage)
        authVM.onFinish = { [weak self] in
            guard let self = self else { return }
            let booksVM = BooksViewModel()
            let booksVC = BooksViewController(viewModel: booksVM)
            let navigationVC = UINavigationController(
                rootViewController: booksVC
            )
            self.window.rootViewController = navigationVC
            onFinish?()
        }

        let phoneVC = PhoneViewController(viewModel: authVM)
        let navigationVC = UINavigationController(
            rootViewController: phoneVC
        )
        window.rootViewController = navigationVC
        window.makeKeyAndVisible()
    }
}

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

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    private var authOp: IAuthOp?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions:
    [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        let window = UIWindow()
        let authAPI = AuthAPI()
        let storage = Storage()

        self.authOp = AuthOp(
            window: window,
            authAPI: authAPI,
            storage: storage
        )

        self.authOp?.launch(onFinish: { [weak self] in
            self?.authOp = nil
        })

        return true
    }
}
💡
Время жизни операции ограничено, но в контексте iOS сильную ссылку на асинхронную операцию все равно нужно где-то хранить. Если этого не сделать, то при выходе из скоупа операция будет удалена, что не всегда допустимо. Для того, чтобы не беспокоиться о хранении ссылок, операция может создавать сильную ссылку сама на себя в инициализаторе, а при вызове onFinish освобождать ее.

«Неделимая последовательность» в определении операции накладывает два ограничения на ее код:

  1. операция не может вызывать другие операции и не имеет к ним доступа;
  2. в последовательности сообщений не должно быть логических развилок и циклов (условия и циклы нуждаются в состоянии, поэтому содержатся в сервисах).

Такие ограничения позволяют легко описывать операцию с помощью диаграмм последовательности. Задача операции состоит в том, чтобы организовать обмен сообщениями между сервисами. В таком контексте удобно использовать термин «диаграмма сообщений». Каждое обращение к сервису в коде операции имеет взаимно однозначное отображение в стрелку-сообщение на диаграмме – так обеспечивается наглядность.

Диаграмма сообщений для операции входа: постоянная часть.
Диаграмма сообщений для операции входа: постоянная часть.

Диаграмма сообщений может состоять из нескольких частей. Одна часть – постоянная. Это сообщения, сформированные непосредственно в методе launch и отвечающие за конфигурацию сервисов, за их взаимную настройку (подписки). Другая часть вариативная – это уже сообщения внутри этих предварительно настроенных сервисов. Такое разделение обусловлено тем, что сервисы содержат состояние и логические развилки, поэтому для описания этой вариативной части может потребоваться несколько диаграмм.

Диаграмма сообщений для операции входа: вариативный раздел AuthFlow. Этот вариант описывает успешный вход. Для описания ошибки входа потребуется отдельная диаграмма.
Диаграмма сообщений для операции входа: вариативный раздел AuthFlow. Этот вариант описывает успешный вход. Для описания ошибки входа потребуется отдельная диаграмма.

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

При таком подходе возникает проблема циклической зависимости: сервис содержится в контейнере и использует фабрику операций, а фабрика должна иметь доступ к контейнеру с сервисами. Обойти проблему можно через двухэтапную инициализацию сервисов. Для начала задействуем особую операцию makeServicesOp, которая будет отвечать исключительно за инстанцирование (создание объектов) сервисов. Метод launch в makeServicesOp возвращает в AppDelegate контейнер с ключевыми сервисами, после чего AppDelegate передает ссылку на этот контейнер в фабрику операций. При создании всех последующих операций фабрика сможет пользоваться любыми сервисами в контейнере уже без ограничений.

💡
Проблема циклических зависимостей между сервисами также решается двухэтапой инициализацией и разделением зависимостей на два вида: жесткие и контекстные. Жесткие передаются в конструктор и формируются в ходе makeServicesOp, а контекстные могут быть сформированы в отдельной операции. Например, сразу после makeServicesOp можно выполнить специальную операцию setupServicesOp, которая будет отвечать за оформление у сервисов подписок на события друг друга.
func application(
    _ application: UIApplication, didFinishLaunchingWithOptions
    launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
    // создаем сервисы
    let services = opFactory
        .make_makeInitialServicesOp()
        .launch()
    opFactory.setInitialServices(services: services)
    // инициализируем сервисы
    scenarioFactory.make_setupInitialServicesOp().launch()
    // запускаем приложение
    scenarioFactory.make_startOp().launch()
    
    return true
}
💡
Любую жесткую зависимость можно превратить в контекстную, а вот наоборот — не всегда, т.к. есть вероятность получить цикл. По возможности следует использовать именно контекстные зависимости. Это делает Unit-тесты сервисов менее хрупкими: меньше зависимостей в конструкторе —> меньше вероятность изменений конструктора при модификации сервиса —> меньше вероятность переписывать все тесты сервиса.
💡
При подготовке тестов операции иногда непросто провести границу между тестами самой операции, которые по своей сути интеграционные, и Unit-тестами композитного сервиса, у которого есть сервисы-зависимости. Обе категории можно считать интеграционными, поскольку в них проверяется совокупная работа группы сервисов. В этом случае можно обратиться к документации: если тест пишется на основе диаграммы сообщений – это тест на операцию, а если на основе диаграммы состояний – это тест на сервис.

Код в объекте операции тестируется легко, если операция не создает сущности самостоятельно и все зависимости получает извне. Обычно такими зависимостями являются ключевые сервисы. В Unit-тесте их можно заменить mock-объектами, а через последовательность обращений к mock-объектам можно отследить и проверить на корректность цепочку сообщений в операции.

На практике нас ждет несколько проблем. Во-первых, системные сущности заменить на mock-объект не получится. Ярким примером являются сущности из UIKit, например, UIWindow. Работу с таким сущностями желательно инкапсулировать внутри ключевого сервиса.

import XCTest

final class AuthTests: XCTestCase {

    final class AuthApiMock: IAuthAPI {
        func requestSMS(phone: String,
                        completion: @escaping VoidHandler) {}
        func getAuthToken(phone: String, smsCode: String,
                          completion: @escaping StringHandler) {}
        var isRequestAvailable: Bool { return true }
    }
    
    final class StorageMock: IStorage {
        func save(token: String) {}
    }
    
    func testWindowSetup() throws {
        // MARK: Setup
        let window = UIWindow()
        let authApi = AuthApiMock()
        let storage = StorageMock()
        let authOp = AuthOp(
            window: window,
            authAPI: authApi,
            storage: storage)

        // MARK: Action
        authOp.launch(onFinish: nil)

        // MARK: Test
        XCTAssertNotNil(window.rootViewController)
        XCTAssertTrue(window.isKeyWindow)
    }
}

Во-вторых, помимо ключевых сервисов в операции могут использоваться контекстные сервисы. Время жизни и параметры контекстного сервиса определяются контекстом его использования. Типичный представитель контекстных сервисов — это вью-модель. Отличительными характеристиками сервиса являются императивный интерфейс и наличие состояния. Интерфейс вью-модели обрабатывает команды, получаемых с UI-слоя и предоставляет доступ к данным, отображаемым на UI-слое, поэтому вью-модель – это сервис. Вью-модель формируется непосредственно перед показом соответствующего экрана с учетом контекста, поэтому она называется контекстным сервисом. Помимо времени жизни контекстный сервис отличается от ключевого сервиса отсутствием доступа к фабрике операций. Операция непосредственно (без использования фабрик) инициализирует вью-модель, передает ей на вход все необходимые ключевые сервисы и подписывает слушателей на события вью-модели. Иными словами, операция формирует контекстные связи вью-модели с окружающим миром.

import UIKit

protocol IAuthOp {
    func launch(onFinish: VoidCompletion)
}

final class AuthOp: IAuthOp {
    private let window: UIWindow
    private let authAPI: IAuthAPI
    private let storage: IStorage

    init(window: UIWindow, authAPI: IAuthAPI, storage: IStorage) {
        self.window = window
        self.authAPI = authAPI
        self.storage = storage
    }
    
    func launch(onFinish: VoidCompletion) {
        let authVM = AuthViewModel(authAPI: authAPI, storage: storage)
        authVM.onFinish = { [weak self] in
            guard let self = self else { return }
            let booksVM = BooksViewModel()
            let booksVC = BooksViewController(viewModel: booksVM)
            let navigationVC = UINavigationController(
                rootViewController: booksVC)
            self.window.rootViewController = navigationVC
            onFinish?()
        }
        
        let phoneVC = PhoneViewController(viewModel: authVM)
        let navigationVC = UINavigationController(
            rootViewController: phoneVC)
        window.rootViewController = navigationVC
        window.makeKeyAndVisible()
    }
}

Протестировать саму вью-модель легко, но как протестировать настройку контекста, т.е. связку между вью-моделью и операцией? Заменить вью-модель mock-объектом не получится, т.к. вью-модель создается внутри и недоступна извне операции. Для этого понадобится роутер.

Роутер

👉
Роутер — это ключевой сервис, который отвечает за весь UI-слой.

Роутер отвечает за UI-логику и является сервисом, поскольку у него есть свой внутренний контекст (состояние). Этот контекст роутер обрабатывает вместе с внешним контекстом, полученным через вью-модель. Формально все сообщения для роутера выглядят так: «Покажи такой-то экран, вот тебе вью-модель, она расскажет подробности». Применительно к iOS роутер для каждой вью-модели инстанцирует соответствующий UIViewController.

import UIKit

final class Router: IRouter {
    
    var window: UIWindow?
    
    func setup() {
        let window = UIWindow()
        window.makeKeyAndVisible()
        self.window = window
    }

    func showOnboarding(viewModel: IOnboardingViewModel) {
        // Вью-контроллеры инстанцирует исключительно роутер!
        let onboardingVC = OnboardingViewController(viewModel: viewModel)
        let navVC = UINavigationController(rootViewController: onboardingVC)
        window?.rootViewController = navVC
    }

    func showPhone(viewModel: IAuthViewModel) {
        let phoneVC = PhoneViewController(viewModel: viewModel)
        // Роутер сам создал все вью-контроллеры, поэтому состояние
        // и предысторию UI-слоя знает лучше всех. Если онбординг уже
        // показывался, роутер откроет экран входа через уже готовый
        // UINavigationController с анимацией
        if let navVC = window?.rootViewController
            as? UINavigationController {
            navVC.pushViewController(phoneVC, animated: true)
        } else {
            let navVC = UINavigationController(rootViewController: phoneVC)
            window?.rootViewController = navVC
        }
    }

    func showBooks(viewModel: IBooksViewModel) {    
        ...

Роутер обеспечивает тестируемость операции. Во-первых, благодаря инкапсуляции UI-слоя, все зависимости операции могут быть представлены ключевыми сервисами, один из которых – роутер. Во-вторых, операция отдает созданную ей вью-модель наружу в роутер. Это позволяет протестировать корректность настройки вью-модели: RouterMock воздействует на вью-модель —> вью-модель эмитирует события в замыкание-подписку (сформировано операцией) —> отправляются сообщения к сервисам-зависимостям операции. Зависимости операции в Unit-тестах тоже представлены mock-объектами. Коорректность настройки контекстных связей между вью-моделью и ключевыми сервисами отслеживаем через обращения к этим mock-объектам. Таким образом, операция становится полностью тестируемой.

import Foundation

protocol IAuthOp {
    func launch(onFinish: VoidCompletion)
}

final class AuthOp: IAuthOp {
    private let authAPI: IAuthAPI
    private let router: IRouter
    private let storage: IStorage

    init(authAPI: IAuthAPI, router: IRouter, storage: IStorage) {
        self.authAPI = authAPI
        self.router = router
        self.storage = storage
    }
    
    func launch(onFinish: VoidCompletion) {
        let authVM = AuthViewModel(authAPI: authAPI, storage: storage)
        authVM.onFinish = { [weak self] in
            self?.router.showBooks(viewModel: BooksViewModel())
            onFinish?()
        }
        router.setup()
        router.showPhone(viewModel: authVM)
    }
}
💡
UI/UX — это один из источников сложности, и роутер позволяет инкапсулировать эту сложность. Помимо этого, в UI сосредоточена большая часть платформенных различий между клиентскими приложениями. Каждая платформа обладает собственным инструментарием для работы с UI, поэтому роутер — это самая платформенно-зависимая сущность. Применительно к iOS в роутере выполняется инстанцирование вью-контроллеров (и вообще почти вся работа с UIKit).

Следует различать и разделять бизнес-логику и UI-логику. Факт навигационного перехода на определенный экран по событию — это бизнес-логика. Обработка позиции скролла, индикаторы загрузки, анимации и вид навигационного перехода — это все UI-логика. Разберем пример.

Предположим, поступила задача от маркетинга: обрабатывать диплинк с акцией «Книга месяца со скидкой 90%». Обрабатывать — значит уметь открывать экран просмотра книги в произвольный момент времени. С точки зрения бизнес-логики все просто: получили диплинк с ID книги — открыли книгу. Если бы не требование UX-проектировщика: нельзя прерывать пользовательский контекст при открытии акции. Это означает, что UI-логика открытия диплинка зависит от контекста:

  • если пользователь находится на экране с каталогом книг, то акционную книгу следует открыть через push в UINavigationController каталога — так же, как открываются все остальные карточки товаров;
  • если пользователь уже находится на экране с книгой, то в случае совпадения открытой и акционной книг ничего не делаем, а в случае различия снова выполняем push книги в текущий в навигационный стек;
  • если пользователь находится на вкладке с настройками, где навигационного стека не предусмотрено, то экран с акционной книгой показываем модально.

Роутер всегда точно знает, какой экран он сейчас показывает (потому что сам отвечает за их показ) и какой экран от него требуют показать. Таким образом, роутер может самостоятельно принять решение, какой вид навигационного перехода следует применить: вызывающая сторона (бизнес-логика) может об этом не беспокоиться. Остальное приложение не знает о UI вообще ничего. Для остальных классов роутер — это просто еще один сервис, который выводит изображение на экран и принимает через этот экран команды пользователя (касания).

💡
Понятие роутера кардинально меняет подход к разработке клиентских приложений. Разработчик перестает мыслить в терминах экранов и переключает фокус на бизнес-логику. Может ли приложение вообще работать без UI? Конечно может! Если мы вместо честного роутера с вью-контроллерами под капотом подсунем в приложение через интерфейс роутера некий сетевой RPC-адаптер, то приложением можно будет управлять/пользоваться через удаленный терминал! После запуска на экране девайса будет пустой черный или белый экран, но при этом приложение будет работать как прежде! Это открывает широкие возможности для высокоуровневых интеграционных тестов, не говоря уже о кроссплатформенных решениях.

Таким образом, благодаря роутеру можно разделить UI-логику и бизнес-логику, а для последней обеспечить пластичность, наглядность и тестируемость. Проверяем:

  1. Тестируемость — обеспечена. Закрыли проблему связки между вью-моделью и операцией: RouterMock воздействует на ViewModel ➝ ViewModel эмитирует события в замыкание-подписку (сформирована внутри операции) ➝ замыкание отправляет сообщения к сервисам-зависимостям операции ➝ mock-зависимости уведомляют тест.
  2. Наглядность — обеспечена. Операция по-прежнему может быть описана диаграммой сообщений, просто на диаграмме появляется еще один сервис — роутер. Связка вью-модели и роутера позволяет полностью изолировать бизнес-логику от UI-логики,
  3. Пластичность — обеспечена. Мы можем из любой операции запросить у роутера показ любого экрана. Операция должна всего лишь предоставить контекст, т.е. вью-модель. Как обработать этот контекст, т.е. что делать с вью-моделью — это уже забота роутера.

А что насчет масштабирования? Действительно, приложение не может состоять из одной операции и набора сервисов. Во-первых, есть параметры запуска приложения, такие как диплинки или пуш-уведомления. Эти параметры определяют выбор операции – где будем хранить логику запуска? Во-вторых, операций всегда будет несколько, значит, появляется межоперационная логика. Например, мы запускаем OnboardingOp только при первом запуске приложения, а BooksOp запускается только в том случае, если пользователь авторизован. Если не авторизован, то следует сначала запустить AuthOp, а после ее успешного завершения уже BooksOp.

«Межоперационную» логику и цепочки запуска операций в терминах UML можно описать диаграммой деятельности, а в контексте ROSS ее удобно называть диаграммой операций.

Диаграмма операций для сценария входа.
Диаграмма операций для сценария входа.
💡
Диаграмму операций может сформировать не только системный аналитик или программист, но и владелец продукта (бизнес-аналитик) на основе User Stories. Так мы обеспечиваем наглядность.

Перед нами снова встает задача тестирования, только на этот раз нужно протестировать упомянутую «межоперационную» логику. Как и в случае с операцией, мы перенесем эту логику во внешнюю сущность. Эта сущность называется сценарий.

Сценарий

👉
Сценарий — это последовательность операций.

Сценарии формируют каркас приложения. Можно изучить только эти классы и в общих чертах понять, как приложение устроено и какую функциональность оно предоставляет. Сценарий полностью описывается диаграммой операций, что обеспечивает высокую наглядность. Технически сценарий представляет собой объект, который создается и запускается на выполнение точно так же, как и операция. У сценария также есть метод launch, который может принимать замыкание onFinish и другие параметры. Отличие сценария от операции в том, операция не может обращаться к другим операциям, а сценарий может. У сценария есть доступ к фабрике операций, в то время как операция ничего не знает о других операциях, соответственно, не имеет доступа к фабрике операций.

💡
Как только у операции возникает потребность обратиться к другой операции, она либо превращается в сценарий, либо делегирует задачу в какой-либо сервис. Операция неделима!

Сценарий отвечает за запуск операций, обработку результата их выполнения и передачу данных от одной операции к другой. Сценарий не создает операции сам — для этого есть фабрика операций. Тестирование сценария выполняется через отслеживание обращений к фабрике операций: обращения должны быть в правильной последовательности. Такие тесты уже достаточно высокоуровневые, чтобы конкурировать с UI-тестами. Вместе с тем тесты сценария остаются достаточно легкими и нехрупкими, сохраняя наглядность за счет использования диаграммы операций.

Сценарий формируется с помощью фабрики сценариев, а запустить сценарий на выполнение может только сервис, имеющий доступ к этой фабрике. Так мы обеспечиваем тестируемость как самого сценария, так и запускающего его сервиса. Теоретически доступ к фабрике сценариев может быть у любого ключевого сервиса, но на практике для запуска сценариев удобно использовать специальный сервис — AppService. Его ответственность состоит исключительно в том, чтобы реагировать на входящие сообщения и запускать различные сценарии. Сам сценарий доступа к фабрике сценариев не имеет. Если какому-либо сценарию в ходе выполнения нужно запустить другой сценарий, он обращается к AppService. Для этого интерфейс AppService передается в сценарий через параметры его метода launch.

💡
Весь контекст, необходимый для работы, сценарий должен получить в методе launch, у сценария нет доступа к контейнеру сервисов. В противном случае возникнут сложности с тестированием.

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

Хороший сценарий принимает в качестве зависимостей фабрику операций и AppService. В этом случае в тестах сценария придется поддерживать только интерфейс для AppService.

Терпимый сценарий — это фабрика операций + AppService + прочие сервисы. Наличие отличных от AppService сервисов на входе — это признак того, что в сценарии есть бизнес-логика, которую было бы неплохо перенести в операции.

Плохой сценарий — это фабрика операций + контейнер сервисов. В этом случае контейнер с сервисами выступает как сервис-локатор, в результате чего сценарий становится нетестируемым.

Доступ к AppService позволяет сценарию оставаться пластичным. AppDelegate может делегировать в AppService работу со сценариями, по-прежнему оставаясь нетестируемым, но зато тривиальным. В терминах UML AppService легко описать диаграммой прецедентов. И это название хорошо отражает происходящее, поскольку при необходимости AppService может запускать не только сценарии, но и одиночные операции, для которых создавать целый сценарий избыточно.

💡
Операции могут быть запущены как в сценариях, так и в AppService. А нужен ли вообще сценарий как самостоятельная сущность? Может, сценарий – это просто вырожденный случай сервиса? Ответ становится очевидным, если вспомнить, что мы говорим об архитектуре клиентских приложений, в которых особенно важны UserFlows. UserFlow представлен в документации в виде диаграммы операций. Сценарий – это прямое отображение диаграммы операций в код.

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

Диаграмма прецедентов будет включать 4 прецедента:

  • вход;
  • поиск в каталоге;
  • оплата;
  • просмотр книги.

Вход представлен сценарием, который включает в себя 3 операции: онбординг, оплата на онбордиге и авторизация. Поиск в каталоге — это одиночная операция. Оплата тоже выглядит одиночной операцией, но лишь на первый взгляд. Во-первых, теперь она может запускаться как на онбординге, так и после авторизации. Во-вторых, она должна учитывать контекст — платил ли пользователь уже? Из этого следует, что оплата должна быть представлена сценарием, включающим 3 отдельных операции: проверка статуса оплаты, оплата в авторизованном состоянии и оплата на онбординге. Следовательно, из сценария входа оплату убираем: для запуска оплаты сценарий входа теперь обращается к AppService.

Просмотр книги — это сценарий, который включает 2 операции:

  • проверка статуса оплаты (если не оплачено — запускается сценарий оплаты);
  • непосредственно просмотр книги.

Предположим, что затея с платежкой увенчалась финансовым успехом и наш маркетолог продолжает генерировать идеи. Пусть на этот раз бизнес-гипотеза звучит так: «Если дать пользователю посмотреть каталог книг без регистрации, то конверсия в оплату увеличится; регистрацию запрашивать только перед просмотром книги, при этом оплатить без регистрации по-прежнему можно». Снова начнем с проектирования. Диаграмма прецедентов теперь включает только 3 прецедента:

  • вход;
  • оплата;
  • просмотр книги.

Прецедент «вход» представлен сценарием из 2-х операций: онбординг и поиск в каталоге. Оплата по-прежнему вызывается через AppService, просмотр тоже запрашивается через AppService.

Прецедент «просмотр» представлен сценарием из 2-х операций: авторизация и просмотр. Оплата запрашивается через AppService.

Прецедент «оплата» по-прежнему представлен сценарием из 3-х операций: проверка статуса оплаты, оплата в авторизованном состоянии и оплата на онбординге.

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

РОСС

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

  1. тестируемость обеспечена тем, что мы можем покрыть Unit-тестами все составляющие приложения: сервисы, операции, сценарии и даже UI-логику в роутере;
  2. пластичность обеспечена возможностью перестраивать операции и сценарии, не прибегая к масштабному рефакторингу — зависимости всегда под рукой;
  3. наглядность обеспечена диаграммами сообщений, операций, и прецедентов, которые однозначно отображаются в код. Диаграммы являются основной частью Технического задания, формируемого аналитиками и проектировщиками для программистов, а также являются «источником правды» для тестировщиков.

Полученная структура состоит из 4-х компонентов: Роутер, Операция, Сервис и Сценарий. Вместе они образуют аббревиатуру РОСС. Примечательно, что на английском языке аббревиатура звучит идентично: ROSS (Router, Operation, Service, Scenario).