Как разлучить ViewController с View в iOS и зачем это нужно

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привет, меня зовут Антон Водолазский, я 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, из контроллера.

👍НравитсяПонравилось5
В избранноеВ избранном2
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Отличная статья, которая наглядно демонстрирует как избавиться от кучи кода по обработке взаимодействия пользователя с UI на уровне ViewController-a.

Сам придерживаюсь подхода «UI из кода» — если проект новый / есть время на рефакторинг старого.

На просторах iOS-разработки редко встретишь тех, кто решается на отказ от Storyboards / XIB, и их главный аргумент — нету превью кода.

Благо с 2019 года стал доступен SwiftUI — сам по себе он для продакшена еще сыроват, но главная фича — Previews — доступна для использования и на UIKit.

Взял на вооружение идею автора о превьюхах из доклада на CocoaHeads:

www.youtube.com/...​tch?v=V03NMBjumjA&t=1038s

Самое полезное из доклада — код, вынес в отдельный gist — может кому пригодиться:

gist.github.com/...​fdc2fe3827ad7ff92755a6bc2

Спасибо за труд. Интересно.
Есть вопрос, не легче ли для DRY и создания View через код, использовать Фабричный метод?

Спасибо за вопрос, моя цель была вынести View и при этом не писать явно init или фабричный метод для создания

Подписаться на комментарии