Как разлучить ViewController с View в iOS и зачем это нужно
Привет, меня зовут Антон Водолазский, я iOS-разработчик в OBRIO. Компания входит в экосистему бизнесов Genesis и занимается разработкой мобильных приложений и игр. Ежемесячная аудитория всех наших продуктов превышает 2 миллиона пользователей. А приложение Nebula, над которым я работаю, занимает первое место среди астрологических приложений в США и второе место в мире по выручке и трафику.
На проектах я нередко встречал, что ViewController и View слишком тесно связаны. Часто это и вовсе один класс ViewController, который содержит в себе всю логику Controller и View. В статье хочу рассказать, как я пришел к разделению этих двух слоев. Она будет полезна тем, кто еще не практикует такой подход, уже пишет, или хочет писать UI из кода.
Когда я начинал свою карьеру, многие вещи в разработке мне казались проще. Вот как Apple показывает паттерн MVC:
Выглядит довольно легко: создаешь Model, ViewController, который уже содержит View, и все. Но в один момент, когда мой Controller начал становиться огромным и иметь много ответственностей, я понял, что имею дело с Massive View Controller.
Одно из самых популярных решений этой проблемы — вынос логики в DataSource или отдельный сервис. Но, сделав это, у меня все еще было много View на одном контроллере. Я начал задумываться, почему бы не перенести всю логику, связанную с View.
Пытаясь найти, как это делать правильно, я видел только примеры, похожие на:
class ArticleViewController: UIViewController { var bodyTextView: UITextView var titleLabel: UILabel var dateLabel: UILabel override func viewDidLoad() { super.viewDidLoad() } }
И комментарии типа:
То есть, готового решения проблемы не было.
На тот момент я уже начал писать UI из кода и ничего лучше, чем создавать View в отдельных классах и добавлять их в viewDidLoad(), не придумал. Так и продолжалось некоторое время, пока я не решился поэкспериментировать и найти способ красиво вынести логику View.
Мотивация писать UI из кода:
- челлендж попробовать для себя новый подход;
- проверять код проще, чем со сторибордом;
- низкая скорость загрузки сториборда;
- частые сбои при работе со сторибордом;
- переиспользование/копирование слоя.
Если мы пишем UI из кода, наш контроллер выглядит примерно так:
class ArticleViewController: UIViewController { var bodyTextView = UITextView() var titleLabel = UILabel() var dateLabel = UILabel() override func viewDidLoad() { super.viewDidLoad() setupViews() } func setupViews() { view.addSubview(bodyTextView) view.addSubview(titleLabel) view.addSubview(dateLabel) //layout code below } }
NOTE: подход к разделению также применим и при работе со Storyboard/XIB, но для нашего примера больше подходит из кода.
Итак, наша конечная цель ― вынести всю логику, касающуюся View, в отдельный класс.
Вот как это будет выглядеть в коде:
class ArticleViewController: UIViewController { let articleView = ArticleView() override func viewDidLoad() { super.viewDidLoad() view.addSubview(articleView) } } class ArticleView: UIView { var bodyTextView = UITextView() var titleLabel = UILabel() var dateLabel = UILabel() init() { super.init(frame: .zero) setupViews() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setupViews() { addSubview(bodyTextView) addSubview(titleLabel) addSubview(dateLabel) //layout code below } }
Мы можем еще улучшить этот код ― подменить View контроллера на ArticleView, чтобы не добавлять его как subview:
class ArticleViewController: UIViewController { override func loadView() { view = ArticleView() } override func viewDidLoad() { super.viewDidLoad() } }
Но при обращении к View контроллера тип класса будет UIView. Чтобы иметь доступ к свойствам и функциям ArticleView, можно написать «view as? ArticleView» или вынести приведение типа в отдельное свойство:
public var mainView: ArticleView { guard let customView = view as? ArticleView else { fatalError("Expected view to be of type \(MainView.self) but got \(type(of: view)) instead") } return customView }
Копировать это свойство из контроллера в контроллер — не самое удобное решение. Поэтому можно вынести его в протокол, который будет реализовывать логику приведения типа:
public protocol ViewLoadable { associatedtype MainView: UIView } extension ViewLoadable where Self: UIViewController { /// The UIViewController's custom view. public var mainView: MainView { guard let customView = view as? MainView else { fatalError("Expected view to be of type \(MainView.self) but got \(type(of: view)) instead") } return customView } }
Теперь наш контроллер выглядит следующим образом:
class ArticleViewController: UIViewController, ViewLoadable { typealias MainView = ArticleView override func loadView() { view = MainView() } override func viewDidLoad() { super.viewDidLoad() } }
Мы можем пойти еще дальше и избавиться от метода loadView и typealies, написав класс для UIViewController, от которого будем наследоваться:
open class ViewController<ViewType: UIView>: UIViewController, ViewLoadable { public typealias MainView = ViewType open override func loadView() { let customView = MainView() view = customView } }
Финальная версия нашего контроллера теперь выглядит так:
class ArticleViewController: ViewController<ArticleView> { override func viewDidLoad() { super.viewDidLoad() } }
С текущим решением мы полностью разделили логику контроллера и View.
Подытожим и перечислим ответственности контроллера:
- управление своим жизненным циклом;
- обработка событий для взаимодействия с пользователем;
- передача данных для отображения View.
Для View остается только отрисовка себя и своих subview.
Бонусы, или куда можно двигаться дальше с таким подходом:
- подмена View, например, для работы с iPad;
- выделение кода UI в отдельный фреймворк для ускорения компиляции, разделение ответственности и ресурсов;
- тестирование UI ― удобно делать snapshot-тесты.
В статье мы рассмотрели подход к разделению ответственности контроллера и View. Это позволит сократить количество кода в контроллере, даст возможность переиспользовать View и вынести логику, связанную с отрисовкой UI, из контроллера.
12 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів