
Сьогодні ми поговоримо про патерн 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, і воно набирає все більше шанувальників. Але вибір, звісно, за вами.
Простий приклад
Почнемо з простого прикладу (він дійсно дуже базовий, можливо, я не повинен був його вам показувати, але що ж :) Завдання просте: ми використовуємо UIPageControl
для відображення деяких зображень.
Нам потрібно лише два елементи для реалізації: UICollectionView
та UIPageControl
. О, і коли ви запускаєте додаток, вам потрібно продемонструвати користувачеві логіку вашого додатку, ви також можете це використати.
А ось наше "шедевр":
І ще одна річ. Щоб наші зображення були правильно центровані під час прокручування, ми використовуємо 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<[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:
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:
Зв'язування елементів з нашим контролером:
@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()} </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()
— де має розпочатися наступний (див. зображення нижче).
Ось приклад:
.observeOn(ConcurrentDispatchQueueScheduler(globalConcurrentQueueQOS: .Background)) .subscribeOn(MainScheduler.instance)
Отже, переглянувши весь код, ви можете побачити, що з MVVM нам потрібно набагато менше рядків коду для написання цього додатку, ніж з шаблоном MVC. Але коли ви починаєте використовувати MVVM, це не означає, що він повинен використовуватися скрізь — вибір шаблону має бути логічним і послідовним. Не забувайте, що вашою головною метою є написання хорошого коду, який легко змінювати та розуміти.
Код з цієї статті доступний на GitHub.
Потрібна розробка MVP, iOS та Android додатків або прототипування? Ознайомтеся з нашим портфоліо та замовте зараз!