Попрощайтесь с Massive View Controller

Мы все это видели; UIViewController, у которого на протяжении всей жизни выросли руки и ноги.

Основные обязанности UIViewController:

  • Представление данных пользователю
  • Реагирование на взаимодействие с пользователем

Чего следует избегать UIViewController:

  • Выполнение бизнес-логики
  • Поддержание состояния данных
  • Обработка и выполнение навигации
  • Наличие большого количества [частных] зависимостей

Это может выглядеть примерно так:

import Foundation
import UIKit

final class MainViewController: UIViewController {
  private let dataManager = DataManager()
  private let appManager = AppManager.shared
  
  var mainData: MainData {
    didSet {
      tableView.reloadData()
    }
  }

  private let tableView = UITableView()

  public override func viewDidLoad() {
    super.viewDidLoad()
    appManager.doThing {
        self.tableView.reloadData()
    }
    dataManager.getData { result in
        let sortedData = result.sorted()
        self.mainData = sortedData
    }
  }
}

extension MainViewController: UITableViewDataSource, UITableViewDelegate {
  ...
}
  • Частные/неявные зависимости
  • Хранение логики и управление состоянием данных
  • Выполнение собственной бизнес-логики (сортировка результата)
  • Действует как источник данных tableView (содержит логику для удаления из очереди и настройки ячейки).

UIViewController работает на уровне пользовательского интерфейса вашего приложения; он очень тесно взаимодействует с UIKit и другими библиотеками Apple.

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

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

Вы можете писать тесты пользовательского интерфейса для проверки определенных сценариев, но тесты пользовательского интерфейса:

  • Требуется больше времени для запуска — более медленный цикл обратной связи во время разработки
  • Включите больше шаблонного кода и кода установки (например, что произойдет, если вашему UIViewController потребуются данные из API?)
  • Не способны охватить все крайние случаи в вашей логике.

Как правило, использование тестов пользовательского интерфейса для проверки бизнес-логики не рекомендуется.

Разложение

Сейчас у нашего MainViewController много дел.

Вам не нужно заранее выбирать шаблон дизайна! Главное внимание должно быть уделено приведению нашего MainViewController в более чистое и пригодное для тестирования состояние, чтобы можно было уверенно вносить любые дальнейшие изменения в функциональность или архитектуру. Этот процесс называется декомпозиция, и в качестве руководства используется Принцип единой ответственности (наряду с остальным SOLID).

Разложение:

  • Снижает сложность и размер кода, делая его более читабельным и управляемым.
  • Улучшает модульность и связность кода: каждый компонент имеет четкое и конкретное назначение и не зависит ни от чего внешнего.
  • Это повышает возможность повторного использования и ремонтопригодность кода: каждый компонент можно использовать в разных контекстах и ​​легко модифицировать или обновлять, не затрагивая другие.
  • Это облегчает тестирование и отладку кода: каждый компонент можно протестировать и проверить независимо. Ошибки можно изолировать и исправлять быстрее.

Определите и внедрите свои зависимости.

Разрешите изменять свои зависимости для контроля ввода в модульных тестах.

Для начала достаточно простого инициализатора.

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

import Foundation
import UIKit

final class MainViewController: UIViewController {
  private let dataProvider: DataProvider
  private let appManager: AppManagerInterface
  
  init(
    dataProvider: DataProvider,
    appManager: AppManagerInterface
  ) {
     // Set our dependencies here
     ... 
  }
}

Это делает MainViewController более поддающимся тестированию, а также делает его зависимости прозрачными.

Понять, почему важны прозрачные зависимости.

let mainViewController = MainViewController(
  dataProvider: DummyDataProvider(),
  appManager: DummyAppManager()
)

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

Модель представления

Давайте проверим нашу логику данных изолированно.

Наш новый класс MainListViewModel будет отвечать за получение и сортировку данных.

protocol MainListProvider {
  func getMainList(completion: @escaping (MainList) -> Void)
}

final class MainListViewModel: MainListProvider {
  private let dataProvider: DataProvider

  init(
    dataProvider: DataProvider
  ) { ... }

  func getMainList(completion: @escaping (MainList) -> Void) {
    dataProvider.getData { result in
      completion(result.sorted())
    }
  }
}

и обновите наш MainViewController

import Foundation
import UIKit

final class MainViewController: UIViewController {
  private let listProvider: MainListProvider
  private let appManager: AppManagerInterface
  
  var list: MainList {
    didSet {
      tableView.reloadData()
    }
  }

  init(
    listProvider: MainListProvider,
    appManager: AppManagerInterface
  ) {
     // Set our dependencies here
     ... 
  }

  public override func viewDidLoad() {
    super.viewDidLoad()
    appManager.doThing {
        self.tableView.reloadData()
    }
    listProvider.getMainList { result in
        self.list = result
    }
  }
}

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

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

Мы можем легко отслеживать регрессии, если нам нужно ввести дополнительные функции.

Следующие шаги

Примените тот же принцип ко всем другим действиям внутри вашего MainViewController, например:

  • DataSource: отдельная утилита для сопоставления элементов из MainList в MainListTableViewCell.
  • Переместите поведение для обработки событий табличного представления в MainListViewModel или создайте отдельный объект, соответствующий UITableViewDelegate.
  • Вынести поведение для навигации между страницами в отдельный класс; Вы можете позаимствовать поведение Router из шаблона проектирования VIPER. Подробнее о VIPER читайте здесь.
  • Изучите возможные шаблоны проектирования и соглашения, подходящие для вашей кодовой базы и команды.

Заключение

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