SwiftUI, MVVM и протокольно-ориентированное программирование — идеальное сочетание… если все сделано правильно. Выясни как.

Одной из наиболее часто используемых архитектур iOS-приложений является MVVM: Model View View-Model.

Как вы, несомненно, знаете, MVVM был предложен для борьбы с тенденцией к «массивным» контроллерам представления при классической разработке MVC (контроллер представления модели) в UIKit. Основная идея заключалась в том, чтобы отделить бизнес-логику — модель представления — от выводов представления и других «проводников» в представлении и ViewController.

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

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

Но для сравнения, тестирование значений модели представления намного выполнить проще.

SwiftUI и наблюдаемые объекты

SwiftUI поощрял аналогичную архитектурную модель, когда создавал концепцию ObservableObject.

ObservableObject — это сущность, которая существует и сохраняется — вне представления и при обновлении представления, чтобы поддерживать некоторый тип состояния для этого представления (или представлений).

Когда это состояние изменяется или обновляется, любое представление, зависящее от этого состояния, перестраивается и проверяется на наличие изменений, что, в свою очередь, может привести к обновлению некоторой части (или всего) пользовательского интерфейса.

ObservableObject может охватывать несколько представлений, когда также используется в качестве EnvironmentObject, и в этом случае мы, вероятно, будем рассматривать его как своего рода услугу. (Подробнее об этом см. в разделе Микросервисы SwiftUI.)

Но когда он существует в основном для управления состоянием и поведением одного представления, мы снова рассматриваем его как модель представления (в заглавных буквах), а архитектуру снова как MVVM.

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

Список учетных записей

Рассмотрим следующую модель представления SwiftUI, которая используется для представления простого списка учетных записей:

class AccountListViewModel: ObservableObject {
    @Published var accounts: [AccountListModel]?
    @Published var footnote: String?
    @Published var empty: String?
    @Published var error: String?
    func load() {
      // something happens here
    }
}

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

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

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

Загрузка, загрузка, загрузка…

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

func load() {
    if let (accounts, footnote) = API.shared.loadAccounts() {
        if accounts.isEmpty {
            empty = "No accounts"
        } else {
            self.accounts = accounts.map {
                AccountListModel($0)
            }
            self.footnote = footnote
        }
    } else {
        error = "Unable to load accounts"
    }
}

Мы вызываем наш API для загрузки наших учетных записей и соответствующей сноски. Если эта функция возвращает nil, у нас ошибка. И если наш список учетных записей пуст, мы хотим, чтобы наши пользователи тоже знали об этом. В противном случае мы сохраняем наш список, преобразуя каждую учетную запись в AccountListModel, а также сохраняем нашу сноску.

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

Так как же нам убедиться, что логика верна? Проведите несколько тестов!

Тестирование

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

private let vm = AccountListViewModel()
class MVVM_POPTests: XCTestCase {
    func testAccountsLoaded() throws {
        vm.load()     
        XCTAssert(vm.accounts?.count == 2)
        XCTAssertNotNil(vm.footnote)
        XCTAssertNil(vm.empty)
        XCTAssertNil(vm.error)
    }
}

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

Но вот проблема. Используя общий синглтон в качестве нашего API, наша функция загрузки также тесно связана с этим API.

Так как же нам дать ему другой набор данных для игры? Если на то пошло, это говорит с нашим реальным, живым API, так как же мы вообще можем знать, сколько учетных записей у нас будет в реальной жизни? Как бы то ни было, этот тест будет ломаться каждый раз, когда мы добавляем или вычитаем учетную запись.

Очевидно, нам нужно отделить модель представления от API, но как?

Протокол загрузки учетной записи

Ну, это Swift, и эта статья о том, как использовать протоколы с MVVM… поэтому давайте определим протокол для загрузки данных учетной записи и сделаем наш существующий API соответствующим протоколу.

protocol AccountLoading {
    func loadAccounts() -> ([Account], String)?
}
extension API: AccountLoading {}

Затем мы изменим нашу модель представления следующим образом.

class AccountListViewModel {
    ...
    var loader: AccountLoading = API.shared
    func load() {
        if let (accounts, footnote) = loader.loadAccounts() {
            ...
        } else {
            ...
        }
    }
}

Мы добавили переменную loader, которая ожидает экземпляр AccountLoading, но по умолчанию использует наш стандартный общий API.

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

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

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

private let vm = AccountListViewModel()
struct MockTwoAccounts: AccountLoading {
    func loadAccounts() -> ([Account], String)? {
        return ([Account(), Account()], "A footnote")
    }
}
class MVVM_POPTests: XCTestCase {
    func testAccountsLoaded() throws {
        vm.loader = MockTwoAccounts()
        vm.load()
        XCTAssert(vm.accounts?.count == 2)
        XCTAssertNotNil(vm.footnote)
        XCTAssertNil(vm.empty)
        XCTAssertNil(vm.error)
    }
}

Мы заменяем наш вызов API экземпляром обработчика протокола, который на самом деле возвращает две фиктивные учетные записи. Запустите его и BING! Оно работает! У нас есть фиктивные учетные записи и связанная с ними сноска.

Добавим еще один.

struct MockNoAccounts: AccountLoading {
    func loadAccounts() -> ([Account], String)? {
        return ([], "A footnote")
    }
}
    
class MVVM_POPTests: XCTestCase {
    ...
    func testNoAccountsLoaded() throws {
        vm.loader = MockNoAccounts()
        vm.load()
        XCTAssertNil(vm.accounts)
        XCTAssertNil(vm.footnote)
        XCTAssertNotNil(vm.empty)
        XCTAssertNil(vm.error)
    }
    ...
}

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

Для нашего последнего теста мы можем сделать версию MockAccountError, где loadAccounts() просто возвращает nil, и использовать ее, чтобы убедиться, что мы генерируем правильные результаты для нашего состояния обработки ошибок.

Тестируем, тестируем…

Все круто. Поэтому, когда мы с предвкушением запускаем полный набор тестов AccountListViewModel, мы обнаруживаем… ошибку. Что случилось?

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

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

Но если мне случится загрузить свои учетные записи после того, как это произойдет, я получу учетные записи и сноску… и у меня все еще будет мое предыдущее пустое сообщение. То же самое произошло бы, если бы у меня была ошибка ранее. Старое значение останется и не пройдет один из моих загруженных или пустых тестов моей учетной записи.

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

Очевидно, нам понадобится какая-то функция для сброса нашего состояния.

class AccountListViewModel: ObservableObject {
    ...    
    func load() {
        reset()
        if let accounts = loader.loadAccounts() {
            ...
        } else {
            ...
        }
    }
    
    func reset() {
        accounts = nil
        footnote = nil
        empty = nil
        error = nil
    }
}

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

SwiftUI и фиктивные данные

Мы можем (и должны) также определить эти макеты в приложении и использовать их, когда хотим делать предварительный просмотр в SwiftUI. Чтобы проиллюстрировать это, давайте пока создадим простой заполнитель AccountListView.

struct AccountListView: View {
    @StateObject var viewModel = AccountListViewModel()
    var body: some View {
        Text("Will show accounts here...")
    }
}

Обратите внимание, что наша продукция AccountListView использует объект состояния, который по умолчанию соответствует нашему стандарту AccountListViewModel, который, в свою очередь, по умолчанию использует наш стандартный производственный API.

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

struct AccountListView_Previews: PreviewProvider {
  static var previews: some View {
    Group {
      let vm2 = AccountListViewModel(loader: MockTwoAccounts())
      AccountListView(viewModel: vm2)
            
      let vm0 = AccountListViewModel(loader: MockNoAccounts())
      AccountListView(viewModel: vm0)
            
      let vme = AccountListViewModel(loader: MockAccountsError())
      AccountListView(viewModel: vme)
    }
  }
}

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

Тупые взгляды

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

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

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

Давайте посмотрим на реальный вид.

struct AccountListView: View {
    @StateObject var viewModel = AccountListViewModel()
    var body: some View {
        if let accounts = viewModel.showAccounts {
            List {
                ForEach(accounts, id: \.id) { account in
                    AccountListCellView(account: account)
                }
                if let footnote = viewModel.footnote {
                    Text(footnote)
                        .font(.footnote)
                }
            }
        } else if let empty = viewModel.empty {
            Text(empty)
                .foregroundColor(.gray)
        } else if let error = viewModel.error {
            Text(error)
                .foregroundColor(.red)
        } else {
            ProgressView()
                .onAppear {
                    viewModel.load()
                }
        }
    }
}

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

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

Поведение этого представления полностью определяется моделью представления.

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

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

Просто помните, если вы не можете это проверить, вы не можете это доказать.

UIKit

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

...
messageLabel.text = viewModel.emptyMessage
messageLabel.hidden = viewModel.emptyMessage == nil
...

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

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

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

Необходимость писать собственный код привязки представления — одна из причин, по которой код SwiftUI выглядит намного чище и компактнее (и менее подвержен ошибкам), чем код UIKit.

ViewModel — это не протокол

В классическом объектно-ориентированном программировании часто проводится различие между объектами, которые являются чем-то, и объектами, которые содержат что-то. (IS A против HAS A).

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

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

Если в самой модели представления реализован какой-то протокол, позволяющий ее заменить (IS A), то мы теряем эти преимущества. Учитывать:

protocol AccountListViewModeling: ObservableObject {
    var accounts: [AccountListModel]? { get }
    var footnote: String? { get }
    var empty: String? { get }
    var error: String? { get }
    func load()
}

Этот протокол основан на нашей обновленной модели представления. Теперь сделаем макет.

class FakeEmptyAccountListViewModel: AccountListViewModeling {
    var accounts: [AccountListModel]?
    var footnote: String?
    var empty: String?
    var error: String?
    func load() {
        empty = "Nothing to see here."
    }
}

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

Да, я могу это сделать… но это не скажет мне ни одной единственной вещи о том, как моя производственная модель представления будет вести себя, когда она видит пустой набор аккаунтов. Или ошибка. Или даже список счетов.

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

Не меняйте свои модели представления. Замените данные (или поставщиков данных), от которых они зависят.

Просмотр композиции

Одна вещь, которую SwiftUI настоятельно рекомендует, — это композиция представлений — представления, состоящие из представлений, состоящих из других представлений. Наш AccountListView сверху довольно хорош, но я думаю, что мы можем добиться большего.

Наше текущее представление обрабатывает загрузку, состояния ошибок и отображение самого списка учетных записей. Что, если мы сломаем это? Учитывать.

struct AccountListLoadingView: View {
    @StateObject var viewModel = AccountListLoadingViewModel()
    var body: some View {
        if let accounts = viewModel.accounts,
           let footnote = viewModel.footnote {
            AccountListView(accounts: accounts, footnote: footnote)
        } else if let empty = viewModel.empty {
            MessageView(message: empty, color: .gray)
        } else if let error = viewModel.error {
            MessageView(message: error, color: .red)
        } else {
            ProgressView()
                .onAppear {
                    viewModel.load()
                }
        }
    }
}

Теперь наш AccountListLoadingView обрабатывает логику ветвления... и все. Что делает наше представление списка теперь до безобразия простым.

struct AccountListView: View {
    let accounts: [AccountListModel]
    let footnote: String
    var body: some View {
        List {
            ForEach(accounts, id: \.id) { account in
                AccountListCellView(account: account)
            }
            Text(footnote)
                .font(.footnote)
        }
    }
}

Разделение наших представлений на более мелкие представления позволяет каждому из них сосредоточиться на своей области ответственности, что, в свою очередь, делает каждое из этих представлений намного легче для понимания и анализа. Наше новое представление загрузки теперь проще для просмотра, а наш новый AccountListView настолько чист, что ему даже не нужна модель представления.

И это совершенно нормально. MVVM — это инструмент, который можно использовать, если и когда это необходимо.

Реструктуризация

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

Вот наш новый вид загрузки:

struct AccountListLoadingView: View {
    @StateObject var viewModel = AccountListLoadingViewModel()
    var body: some View {
        switch viewModel.state {
        case .loaded(let accounts, let footnote):
            AccountListView(accounts: accounts, footnote: footnote)
        case .empty(let message):
            MessageView(message: message, color: .gray)
        case .error(let message):
            MessageView(message: message, color: .red)
        case .loading:
            ProgressView()
                .onAppear {
                    viewModel.load()
                }
        }
    }
}

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

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

Это означает, что само представление может находиться только в одном из этих состояний.

Вот новая модель представления.

class AccountListLoadingViewModel: ObservableObject {
    
    enum State {
        case loading
        case loaded([Account], String)
        case empty(String)
        case error(String)
    }
    
    @Published var state: State = .loading
    
    var loader: AccountLoading
    
    init(loader: AccountLoading = API.shared) {
        self.loader = loader
    }
    
    func load() {
        if let (accounts, footnote) = loader.loadAccounts() {
            if accounts.isEmpty {
                state = .empty("No accounts")
            } else {
                state = .loaded(accounts, footnote)
            }
        } else {
            state = .error("Unable to load accounts")
        }
    }
}

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

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

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

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

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

Блок завершения

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

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

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

На самом деле, с определенной точки зрения, можно даже рассматривать каждый написанный нами превью как просто еще один тип модульного теста.

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

До скорого.

Обратите внимание, что это еще одна статья из моей продолжающейся серии SwiftUI Series. Не стесняйтесь проверить их.