Не MVC единым: как применять MVVM в iOS

Сегодня мы будем рассматривать паттерн MVVM. Поговорим о его преимуществах по сравнению с MVC, а также рассмотрим один очень маленький пример и один достаточно большой, который в дальнейшем вы сможете применять в своей работе как образец хорошей архитектуры для практически любого проекта, использующего MVVM. Итак, начнем с основ :)

Основы

В iOS одним из самых часто используемых паттернов, с которого начинают все новички, является MVC (Model-View-Controller). До какой-то поры мы повсеместно используем его в своих проектах. Но со временем наш контроллер «раздувается» до огромных размеров и начинает брать на себя всю нагрузку.

Напомню, что в MVC контроллер может общаться как с моделью (Model), так и с представлением (View). В MVVM мы имеем несколько другую схему, и ее очень важно запомнить: User → View → ViewController → ViewModel → Model. То есть, пользователь видит кнопку (View), нажимает на нее, а далее нагрузку берет на себя ViewController, выполняя какие-то действия с интерфейсом, например, меняет цвет этой кнопки. Далее, ViewModel посылает запрос серверу на получение данных, добавляет их в Model или выполняет какие-то другие действия с моделью.

Главное, что тут нужно запомнить: в MVVM у нас появился новый класс ViewModel, который сам общается с моделью, то есть мы сняли с контроллера эту обязанность и теперь контроллер занимается тем, чем надо — он работает с представлениями (View) и даже не знает о существовании модели.

Практика

Работая с паттерном MVVM, мы имеем в распоряжении такие библиотеки: ReactiveCocoa, SwiftBond/Bond и ReactiveX/RxSwift. Сегодня будем рассматривать последний фреймворк — RxSwift. Если интересуют детали, то вот более подробно о различиях между RxSwift и ReactiveCocoa. В двух словах: RxSwift — это более современное решение, написанное на Swift, тогда как ReactiveCocoa несколько постарше и его «ядро» писалось на Objective-C. ReactiveCocoa имеет очень большое число поклонников и довольно таки много туторов написано на нем.

Bond — несколько меньший фреймворк, он больше для новичков, но мы же с вами крутые спецы, зачем нам этот детский сад ;) Сегодня мы будем использовать RxSwift.

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

Простой пример MVVM в ReactiveX

Простой пример

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

Для этой цели нам потребуется всего два элемента: UICollectionView и UIPageControl. Да, кстати, когда требуется при старте приложения показать пользователю, только одному вам понятную и гениальную, логику работы вашего приложения, такая штука тоже подойдет.

Финальный вид нашего «шедевра»:

Storyboard для простого примера MVVM

Единственный момент, чтобы при скролле наши картинки могли быть правильно отцентрированы, используем CollectionViewFlowLayoutCenterItem и ассоциируем его с классом UICollectionViewFlowLayoutCenterItem.swift (можете найти его в папке с проектом). Вот код на GitHub.

Так должен выглядеть наш Podfile:

target 'PageControl_project'do
use_frameworks!
pod 'RxCocoa'
pod 'RxSwift'
end

RxCocoa — это extension для всех UIKit’овских элементов. То есть, мы можем написать: UIButton().rx_tap и получим ControlEvent, который является ObservableType. Представьте, что у нас есть объект UISearchBar. Обычно, не используя RxSwift, мы бы подписали наш контроллер как делегат и следили бы за изменением свойства text. Используя RxSwift, мы можем написать что-то в этом роде:

searchBar
   .rx_text
   .subscribeNext {(text)in
 	print(text)}

И теперь главное. Для нашей задачи мы не подписываем контроллер как делегат для UICollectionView, а делаем вот что:

 override func viewDidLoad(){
        super.viewDidLoad()
        setup()}
 
    func setup(){
 
        //initialize viewModel
        viewModel = ViewModel()
 
        viewModel.getData()//set pageCtr.numberOfPages//images should not be nil
            .filter {[unowned self](images)-> Bool in
                self.pageCtrl.numberOfPages = images.count
                return images.count > 0}
 
            //bind to collectionView//set pageCtrl.currentPage to selected row
            .bindTo(collView.rx_itemsWithCellIdentifier("Cell", cellType: Cell.self)){[unowned self](row, element, cell)in
                cell.cellImageView.image = element
                self.pageCtrl.currentPage = row
            }
 
            //add to disposeableBag, when system will call deinit - we`ll get rid of this connection
            .addDisposableTo(disposeBag)}

В итоге, мы пишем меньше кода, код становится более читабельным, и если у нас нет данных (метод класса ViewModel.getData() вернет Observable<[UIImage?]>) то ничего и не произойдет, мы даже не запустим весь наш процесс.

Теперь подробней разберем метод класса ViewModel getData(). Если бы мы получали данные от сервера (что будет показано чуть позже), я бы добавил метод для получения данных, а так, использую private dataSource с картинками, который просто добавил в проект.

func getData()-&gt; Observable&lt;[UIImage?]&gt; {
        let obsDataSource = Observable.just(dataSource)
 
        return obsDataSource
    }
 

Тут мы создаем объект Observable и используем метод just, который говорит: вернуть последовательность, которая содержит только один элемент — массив элементов UIImage.

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

Сложный пример

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

Когда работаете с последовательностями, в конце каждого вызова необходимо добавлять addDisposableTo(disposeBag) к объекту.

let disposeBag = DisposeBag() — в примере он объявлен как свойство. Это нужно, чтобы во время вызова системой deinit, происходило освобождение ресурсов для Observable-объектов.

Далее, в следующем проекте будем использовать Moya — это абстрактный класс над, например, Alamofire, который, в свою очередь, является абстрактным классом над NSURLSession и т.д. Для чего он нужен? Чтобы абстрагироваться еще больше, и чтобы ваш код выглядел профессионально, а также не имел «немного» отличающихся, но все же одинаковых методов во время создания запросов к серверу.

Moya имеет расширение, написанное специально для RxSwift. Называется оно Moya/RxSwift (да, вот так банально и называется, а вы что думали?).

Начнем с Podfile’a:

platform :ios, '8.0'
use_frameworks!
 
target 'RxMoyaExample'do
 
pod 'Moya/RxSwift'
pod 'Moya-ModelMapper/RxSwift'
pod 'RxCocoa'
pod 'RxOptional'
 
end

Чтобы можно было работать с Moya, нам понадобится создать enum и подчинить его протоколу TargetType. В папке с проектом ReactX этот файл называется GithubEndpoint.swift. Мы будем использовать api для github. У нас будет всего четыре endpoint’a, но в своем проекте можете добавить столько, сколько вам нужно.

enum GitHub {case UserProfile(username: String)case Repos(username: String)case Repo(fullName: String)case Issues(repositoryFullName: String)}
 
private extension String {
    var URLEscapedString: String {return stringByAddingPercentEncodingWithAllowedCharacters(NSCharacterSet.URLHostAllowedCharacterSet())!}}

Private extension для String понадобится нам позже. Подчиняем наш GithubEndpoint протоколу TargetType.

extension GitHub: TargetType {
    var baseURL:NSURL{returnNSURL(string:"https://api.github.com")!}
 
    var path: String {switch self {case .Repos(let name):return"/users/\(name.URLEscapedString)/repos"case .UserProfile(let name):return"/users/\(name.URLEscapedString)"case .Repo(let name):return"/repos/\(name)"case .Issues(let repositoryName):return"/repos/\(repositoryName)/issues"}}
 
    var method: Moya.Method {return .GET
    }
 
    var parameters:[String:AnyObject]? {returnnil}
 
    var sampleData:NSData{switch self {case .Repos(_):return"{{\"id\": \"1\", \"language\": \"Swift\", \"url\": \"https://api.github.com/repos/mjacko/Router\", \"name\": \"Router\"}}}".dataUsingEncoding(NSUTF8StringEncoding)!
 
        case .UserProfile(let name):return"{\"login\": \"\(name)\", \"id\": 100}".dataUsingEncoding(NSUTF8StringEncoding)!
 
        case .Repo(_):return"{\"id\": \"1\", \"language\": \"Swift\", \"url\": \"https://api.github.com/repos/mjacko/Router\", \"name\": \"Router\"}".dataUsingEncoding(NSUTF8StringEncoding)!
 
        case .Issues(_):return"{\"id\": 132942471, \"number\": 405, \"title\": \"Updates example with fix to String extension by changing to Optional\", \"body\": \"Fix it pls.\"}".dataUsingEncoding(NSUTF8StringEncoding)!}}}

Если вы используете методы, отличные от GET, как в нашем примере, то можете опять применить конструкцию switch.parameters — т. к. мы ничего не передаем, то просто возвращаем nil. Вы можете, используя switch, передавать дополнительную информацию, необходимую вашему серверу. sampleData — поскольку Moya работает с тестами, то эта переменная обязательна.

Приступим к нашему примеру. Так выглядит storyboard:

storyboard для сложного примера MVVM

Связываем элементы с нашим контроллером:

@IBOutlet weak var searchBar: UISearchBar!
@IBOutlet weak var tableView: UITableView!

Добавим несколько свойств в нашем ViewController:

var provider: RxMoyaProvider<github>!
var latestRepositoryName: Observable<string> {return searchBar
            .rx_text
            .filter { $0.characters.count &gt; 2}
            .throttle(0.5, scheduler: MainScheduler.instance)
            .distinctUntilChanged()}
</string></github>

provider — это и есть Moya-объект, типом у которого выставлен наш enum.

latestRepositoryName — Observable<String>. Каждый раз, когда пользователь начинает что-то писать в searchBar, мы следим за изменениями, точнее подписываемся на изменения. rx_text — из импортированного нами RxCocoa, категории для UIKit-элементов. Можете посмотреть сами на другие свойства.

Дальше мы фильтруем текст и используем только тот, в котором больше 2 символов.

throttle — очень полезное свойство: если пользователь очень быстро печатает, мы выставляем небольшую задержку, чтобы зря не «беспокоить» сервер.

distinctUntilChanged — проверяет предыдущий текст, если текст изменился, то он пропускает его дальше.

Создаем модель:

import Mapper
 
struct Repository: Mappable {
    let identifier: Int
    let language: String
    let name: String
    let fullName: String
 
    init(map: Mapper) throws {
        try identifier = map.from("id")
        try language = map.from("language")
        try name = map.from("name")
        try fullName = map.from("full_name")}}
 
struct Issue: Mappable {
    let identifier: Int
    let number: Int
    let title: String
    let body: String
 
    init(map: Mapper) throws {
        try identifier = map.from("id")
        try number = map.from("number")
        try title = map.from("title")
        try body = map.from("body")}}

Идем дальше, создаем наш ViewModel(IssueTrackerModel):

import Foundation
import Moya
import Mapper
import Moya_ModelMapper
import RxOptional
import RxSwift
 
struct IssueTrackerModel {
    let provider: RxMoyaProvider<github>
    let repositoryName: Observable<string>
 
    func trackIssues()-&gt; Observable&lt;[Issue]&gt; {return repositoryName
            .observeOn(MainScheduler.instance)
            .flatMapLatest { name -&gt; Observable<repository> in
 
                return self.findRepository(name)}
            .flatMapLatest { repository -&gt; Observable&lt;[Issue]?&gt; in
                guard let repository = repository else{return Observable.just(nil)}
 
                return self.findIssues(repository)}
            .replaceNilWith([])}
 
    private func findIssues(repository: Repository)-&gt; Observable&lt;[Issue]?&gt; {return self.provider
        .request(GitHub.Issues(repositoryFullName: repository.fullName))
        .debug()
        .mapArrayOptional(Issue.self)}
 
    private func findRepository(name: String)-&gt; Observable<repository> {return self.provider
        .request(GitHub.Repo(fullName: name))
        .debug()
        .mapObjectOptional(Repository.self)}}
</repository></repository></string></github>

Вначале мы создали два метода: findRepository и findIssues. Первый будет возвращать optional Repository объект, второй — по такой же логике будет возвращать уже Observable[Issue].

Метод mapObjectOptional() вернет optional-объект, в случае, если ничего не будет найдено, так же как и mapArrayOptional() вернет optional array. debug() — выведет debug-информацию на консоль.

Далее, метод trackIssues() объединяет эти два метода. Важным моментом тут является flatMapLatest(), который создает одну последовательность из другой. Его принципиальное отличие от flatMap() в том, что, когда flatMap() получает значение, он начинает длинную операцию (long task) и при получении следующего значения вначале доделывает предыдущую операцию. И это не совсем то, что нам нужно, т. к. пользователь уже может начать новый ввод текста. Нам нужно отменить предыдущую операцию и начать новую — для этого как раз и подходит flatMapLatest().

Observable.just(nil) — просто вернет nil, который в дальнейшем будет заменен на пустой массив следующим методом. replaceNilWith([]) — заменит nil в пустом массиве.

Теперь нам нужно связать эти данные с UITableView. Помним, что нам не нужно подписываться на UITableViewDataSource, у RxSwift есть для этой цели метод rx_itemsWithCellFactory. Вот как выглядит метод setup(), находящийся во viewDidLoad():

func setup(){
 
        provider = RxMoyaProvider<github>()
 
        issueTrackerModel = IssueTrackerModel(provider: provider, repositoryName: latestRepositoryName)
 
        issueTrackerModel
            .trackIssues()
            .bindTo(tableView.rx_itemsWithCellFactory){(tableView, row, item)in
                print(item)
                let cell = tableView.dequeueReusableCellWithIdentifier("Cell")
                cell!.textLabel?.text = item.title
 
                return cell!}
            .addDisposableTo(disposeBag)
 
        //if tableView item is selected - deselect it)
        tableView
            .rx_itemSelected
            .subscribeNext { indexPath inif self.searchBar.isFirstResponder()==true{
                    self.view.endEditing(true)}}.addDisposableTo(disposeBag)}
</github>

Еще один немаловажный момент — в каком потоке будут происходить операции. У нас есть два метода: subscribeOn() и observeOn(). В чем же разница между ними? subscribeOn() указывает, в каком потоке начать всю цепь событий, тогда как observeOn() — где начать следующую (см. картинку ниже).

Разница между subscribeOn и observeOn

Пример:

.observeOn(ConcurrentDispatchQueueScheduler(globalConcurrentQueueQOS: .Background))
 
.subscribeOn(MainScheduler.instance)

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

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

Код из статьи см. на нашем GitHub.

Нужен MVP, разработка под iOS, Android или прототип приложения? Ознакомьтесь с нашим портфолио и сделайте заказ уже сегодня!