MVVM за межами MVC: як використовувати MVVM в iOS

Сьогодні ми поговоримо про патерн MVVM. Обговоримо його переваги в порівнянні з MVC та розглянемо два приклади реалізації: маленький і великий. Ви зможете використати останній у своїй роботі як приклад хорошої архітектури для майже будь-якого проекту на MVVM. Отже, почнемо з основ :)

Основи

Один з найпоширеніших патернів iOS, з якого зазвичай починають новачки, — це MVC (Model-View-Controller). До певного моменту ми всі використовуємо його у своїх проектах. Але час минає, і ваш Controller стає все більшим, перевантажуючи себе.

Дозвольте нагадати, що Controller в MVC може взаємодіяти як з Model, так і з View. MVVM має трохи іншу структуру, яку варто запам'ятати: Користувач → View → ViewController → ViewModel → Model. Це означає, що користувач бачить кнопку (View), натискає на неї, а потім ViewController виконує дії з UI, наприклад, змінює колір цієї кнопки. Далі ViewModel надсилає запит на дані до сервера, додає дані до Model і виконує інші дії з Model.

Головний висновок: у нас з'явився новий клас ViewModel, який взаємодіє з Model, що означає, що Controller більше не відповідає за це. Тепер Controller виконує те, що йому належить: працює з Views і навіть не знає, що Model існує.

Практика

Бібліотеки, що працюють з патерном MVVM, включають ReactiveCocoa, SwiftBond/Bond та ReactiveX/RxSwift. Сьогодні ми поговоримо про останній фреймворк — RxSwift. Якщо ви хочете дізнатися більше, прочитайте про різницю між RxSwift та ReactiveCocoa. Коротко, RxSwift — це більш сучасне рішення, написане на Swift, тоді як ReactiveCocoa існує трохи довше, і його ядро написане на Objective-C. ReactiveCocoa має багато шанувальників, і для нього доступно багато навчальних матеріалів.

Bond дещо менший, і це хороший вибір для новачків, але ми ж професіонали, чи не так? Тож залишимо його осторонь. RxSwift — це розширення ReactiveX, і воно набирає все більше шанувальників. Але вибір, звісно, за вами.

Простий приклад MVVM з ReactiveX

Простий приклад

Почнемо з простого прикладу (він дійсно дуже базовий, можливо, я не повинен був його вам показувати, але що ж :) Завдання просте: ми використовуємо UIPageControl для відображення деяких зображень.

Нам потрібно лише два елементи для реалізації: UICollectionView та UIPageControl. О, і коли ви запускаєте додаток, вам потрібно продемонструвати користувачеві логіку вашого додатку, ви також можете це використати.

А ось наше "шедевр":

Сторінка для простого прикладу MVVM

І ще одна річ. Щоб наші зображення були правильно центровані під час прокручування, ми використовуємо CollectionViewFlowLayoutCenterItem та асоціюємо його з класом UICollectionViewFlowLayoutCenterItem.swift (ви можете знайти його у папці проекту). Ось посилання на GitHub.

Наш Podfile:

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

RxCocoa — це розширення для всіх елементів 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(){
 
        //ініціалізуємо viewModel
        viewModel = ViewModel()
 
        viewModel.getData()//встановлюємо pageCtr.numberOfPages//зображення не повинні бути nil
            .filter {[unowned self](images)-> Bool in
                self.pageCtrl.numberOfPages = images.count
                return images.count > 0}
 
            //прив'язуємо до collectionView//встановлюємо pageCtrl.currentPage на вибраний рядок
            .bindTo(collView.rx_itemsWithCellIdentifier("Cell", cellType: Cell.self)){[unowned self](row, element, cell)in
                cell.cellImageView.image = element
                self.pageCtrl.currentPage = row
            }
 
            //додаємо до disposeableBag, коли система викликає deinit - ми позбудемося цього з'єднання
            .addDisposableTo(disposeBag)}

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

Давайте детальніше розглянемо метод ViewModel getData() класу. Якщо б ми не отримували дані з сервера (ми розглянемо це трохи пізніше), я б додав метод для отримання даних, але оскільки ми їх отримуємо, я використовую private dataSource з зображеннями, які я просто додав до проекту.

func getData()-> 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:

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. У нас буде лише чотири кінцеві точки, але ви можете додати стільки, скільки потрібно у вашому проекті.

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\": \"Оновлення прикладу з виправленням розширення String шляхом зміни на Optional\", \"body\": \"Виправте це, будь ласка.\"}".dataUsingEncoding(NSUTF8StringEncoding)!}}}

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

Розпочнемо з нашого прикладу. Ось storyboard:

storyboard for complex MVVM example

Зв'язування елементів з нашим контролером:

@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):

По-перше, ми створили два методи: findRepository та findIssues. Перший повертає optional Repository об'єкт, а другий — Observable[Issue] за тією ж логікою.

mapObjectOptional() метод повертає optional об'єкт у випадку, якщо нічого не знайдено, а mapArrayOptional() повертає optional array. debug() — відображає налагоджувальну інформацію в консолі.

Далі, trackIssues() об'єднує ці два методи. flatMapLatest() є важливим елементом, що створює одну послідовність за іншою. Його основна відмінність від flatMap() полягає в тому, що коли flatMap() отримує значення, він запускає тривалу задачу, а при отриманні наступного значення завершує попередню операцію. А це не те, що нам потрібно, оскільки користувач може почати вводити інший текст. Нам потрібно скасувати попередню операцію та розпочати нову — тут нам допоможе 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)
 
        //якщо елемент tableView вибрано - зняти виділення)
        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)

Отже, переглянувши весь код, ви можете побачити, що з MVVM нам потрібно набагато менше рядків коду для написання цього додатку, ніж з шаблоном MVC. Але коли ви починаєте використовувати MVVM, це не означає, що він повинен використовуватися скрізь — вибір шаблону має бути логічним і послідовним. Не забувайте, що вашою головною метою є написання хорошого коду, який легко змінювати та розуміти.

Код з цієї статті доступний на GitHub.

Потрібна розробка MVP, iOS та Android додатків або прототипування? Ознайомтеся з нашим портфоліо та замовте зараз!