Сегодня мы будем рассматривать паттерн 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, число его поклонников растет внушительными темпами. Но выбор, как всегда, остается за вами.
Простой пример
Начнем с простого примера (он действительно очень простой, мне даже немного стыдно :) Рассмотрим такую задачу, как использование UIPageControl
для показа картинок, скачанных из интернета и криво обрезанных мною.
Для этой цели нам потребуется всего два элемента: UICollectionView
и UIPageControl
. Да, кстати, когда требуется при старте приложения показать пользователю, только одному вам понятную и гениальную, логику работы вашего приложения, такая штука тоже подойдет.
Финальный вид нашего «шедевра»:
Единственный момент, чтобы при скролле наши картинки могли быть правильно отцентрированы, используем 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()-> Observable<[UIImage?]> { 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:
Связываем элементы с нашим контроллером:
@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 > 2} .throttle(0.5, scheduler: MainScheduler.instance) .distinctUntilChanged()}
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()-> Observable<[Issue]> {return repositoryName .observeOn(MainScheduler.instance) .flatMapLatest { name -> Observable<Repository?> in return self.findRepository(name)} .flatMapLatest { repository -> Observable<[Issue]?> in guard let repository = repository else{return Observable.just(nil)} return self.findIssues(repository)} .replaceNilWith([])} private func findIssues(repository: Repository)-> Observable<[Issue]?> {return self.provider .request(GitHub.Issues(repositoryFullName: repository.fullName)) .debug() .mapArrayOptional(Issue.self)} private func findRepository(name: String)-> Observable<Repository?> {return self.provider .request(GitHub.Repo(fullName: name)) .debug() .mapObjectOptional(Repository.self)}}
Вначале мы создали два метода: 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)}
Еще один немаловажный момент — в каком потоке будут происходить операции. У нас есть два метода: subscribeOn()
и observeOn()
. В чем же разница между ними? subscribeOn()
указывает, в каком потоке начать всю цепь событий, тогда как observeOn()
— где начать следующую (см. картинку ниже).
Пример:
.observeOn(ConcurrentDispatchQueueScheduler(globalConcurrentQueueQOS: .Background)) .subscribeOn(MainScheduler.instance)
Итак, просматривая весь написанный код, вы можете убедиться, насколько меньше строчек нам понадобилось для написания этого приложения и насколько больше бы пришлось написать, используй мы паттерн MVC.
Но если вы начали использовать MVVM, это не означает что нужно «пихать» его куда попало, все должно быть логично и последовательно. Не стоит забывать, что главное — это написание хорошего кода, который легко поправить и легко понять.
Код из статьи см. на нашем GitHub.
Нужен MVP, разработка под iOS, Android или прототип приложения? Ознакомьтесь с нашим портфолио и сделайте заказ уже сегодня!