MVVM для iOS с Combine и SwiftUI
Разработка под iOS постоянно развивается. Сравнительно недавно появился Swift. Разработчики пришли к выводу, что для сложных приложений обычной MVC архитектуры недостаточно. Разработка через storyboard не настолько гибкая, как хотелось бы. Со временем, пришли к VIPER - одной из вариаций чистой архитектуры.
Параллельно с этим, развивалась и совершенствовалась фронтенд разработка. Реактивная архитектура, однонаправленный поток данных и вот это все.
Потом появился SwiftUI и Combine, которые должны сделать жизнь iOS разработчика лучше. Уже скоро это станет стандартом разработки под iOS. И сейчас самое время начать разбираться с этими хипстерскими технологиями.
В этой статье будем экспериментировать с superhero-api и сделаем приложение со списком супер героев.
Combine
Combine можно использовать когда у вас есть запросы за данными. С его помощью можно управлять потоками данных внутри вашего приложения.
Работу этого фреймворка можно сравнить с конвейером. Есть три основных элемента: паблишеры, операторы и сабскрайберы. В связке они работают так: сабскрайбер запрашивает у паблишера данные, паблишер отправляет данные сабскрайберу, по пути данные проходят через операторы.
Паблишеры
Если совсем просто - то пабоишеры предоставляют данные при необходимости. Данные доставляются как определенные нами объекты. Кроме этого, мы можем обрабатывать ошибки. Есть два типа паблишеров.
Just
- предоставляет только результатFuture
- предоставляет замыкание, которое в итоге возвращает ожидаемое значение или неудачно завершается.
Subject
- особый вид паблишера, который используется для отправки данных одному или сразу нескольким подписчикам. Есть два вида встроенных subject в Combine: CurrentValueSubject
и PassthroughSubject
. Они очень похожи, но CurrentValueSubject
должен инициализироваться с начальным значением.
Сабскрайберы
Подписчик запрашивает у паблишера данные. Он может отменить запрос, если это необходимо. Это прекратит подписку и завершит всю потоковую обработку данных от паблишера. Есть лва встроенных типа сабскрайберов, встроенных в Combine: Assign
и Sink
. Assign
присваивает значения объектам напрямую, а Sink
определяет замыкание, аргументы которого это данные отправленные паблишером.
Операторы
Оператор работает как прослойка между паблишером и сабскрайбером. Когда паблишер общается с оператором, он действует как сабскрайбер, а когда сабскрайбер общается с оператором, он действует как паблишер. Операторы нужны для изменения данных внутри конвейера. Например, нам нужно отфильтровать nil значения, указать метку времени, отформатировать данные и тд. Операторами могут быть функции .map
, .filter
, .reduce
и другие.
Приступаем
Теперь мы немного знаем про Combine и попробуем создать реактивное приложение.
Создаем новый проект, называем его SuperHero и выбираем SwiftUI вместо Storyboard.
Начнем с самого главного - модели, которую будем заполнять данными из АПИ. Список всех героев получаем по URL https://cdn.rawgit.com/akabab/superhero-api/0.2.0/api/all.json
. Для каждого героя отдается много информации, мы пока будем использовать только самую важную - id и name
1import Foundation
2
3struct Hero: Codable, Identifiable {
4 let id: Int
5 let name: String
6
7 enum CodingKeys: String, CodingKey {
8 case id
9 case name
10 }
11}
Enum CodingKeys
нам пока не нужен - он понадобится когда названия полей в JSON будут отличаться от параметров в структуре. Но мне хочется поэкспериментировать с CodingKey
Теперь нужна заготовка ViewModel
. Самый удобный паттерн для создания приложений все еще MVVM. И мы будем его реализовывать, но уже с помощью Combine. А пока просто заглушка:
1import Foundation
2
3class HeroesViewModel: ObservableObject {
4 @Published var heroes: [Hero] = []
5}
Врапер @Published
позволяет Swift следить за любыми изменениями этой переменной. Если что-то поменяется, то все свойства body
во всех представлениях, где используется переменная heroes
будут обновлены.
И теперь сделаем заготовочку для нашего HeroesView
1import SwiftUI
2
3struct HeroesView: View {
4 @ObservedObject var viewModel = HeroesViewModel()
5
6 var body: some View {
7 List(viewModel.heroes) { hero in
8 HStack {
9 VStack(alignment: .leading) {
10 Text(hero.name).font(.headline)
11 }
12 }
13 }
14 }
15}
16
17struct HeroesView_Previews: PreviewProvider {
18 static var previews: some View {
19 HeroesView()
20 }
21}
Добавляем врапер @ObservedObject
чтобы отлавливать все изменения объекта viewModel
. Вот тут List(viewModel.heroes) { movie in
мы пробегаем по всему списку с героями. Позже, этот список будет сам престраиваться, при получении данных по сети.
Обратите внимание, что я переименовал стандартный ContentView
в HeroesView
. И надо не забыть поменять код в SceneDelegate
:
1let contentView = HeroesView().environment(\.managedObjectContext, context)
Сетевые запросы
Воспользуемся Alamofire для запросов. Для этого установим Alamofire через pods.
# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'
target 'SuperHero' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
# Pods for SuperHero
pod 'Alamofire', '~> 5.2'
end
И делаем сервис, который будет получать данные из API
1import Foundation
2import Alamofire
3import Combine
4
5class HeroesService {
6 let url = "https://cdn.rawgit.com/akabab/superhero-api/0.2.0/api/"
7
8 func fetch() -> AnyPublisher<[Hero], AFError> {
9 let publisher = AF.request(url + "all.json").publishDecodable(type: [Hero].self)
10 return publisher.value() // value publisher
11 }
12}
Alamofire с 5 версии стала поддерживать Combine, что очень радует.
В коде выше мы сначала создаем паблишера. Обратите внимание на возвращаемый тип: AnyPublisher<[Hero], AFError>
- это и есть наш паблишер(если я ничего не напутал). Дальше мы модем подписаться на него и получать данные уже в ViewModel
1class HeroesViewModel: ObservableObject {
2
3 @Published var heroes: [Hero] = []
4 var cancellation: AnyCancellable?
5 let service = HeroesService()
6
7 init() {
8 fetchHeroes()
9 }
10
11 func fetchHeroes() {
12 cancellation = service.fetch()
13 .mapError({ (error) -> Error in
14 print(error)
15 return error
16 })
17 .sink(receiveCompletion: { _ in }, receiveValue: { heroes in
18 self.heroes = heroes
19 })
20 }
21}
.sink
- тот самый сабскрайбер, который получает значения через замыкания. self.heroes = heroes
- присваивает значение переменной, помеченной @Published
. В View эти изменения заставят обновиться var body: some View
и отрендерить новые данные.
Навигация
Отлично, у нас есть список всех героев. Теперь сделаем детальный просмотр каждого героя. А для этого нам нужна навигация. Добави NavigationView
в HeroesView
. При тапе на имя будем переходить на детальное представление. Поэтому добавим NavigationLink
1struct HeroesView: View {
2 @ObservedObject var viewModel = HeroesViewModel()
3
4 var body: some View {
5 NavigationView {
6 List(viewModel.heroes) { hero in
7 HStack {
8 VStack(alignment: .leading) {
9
10 NavigationLink(destination: HeroView(id: hero.id)) {
11 Text(hero.name)
12 }
13
14 }
15 }
16 }
17 }
18 .navigationBarTitle("Navigation", displayMode: .inline)
19 }
20}
HeroView
можно объявить в отдельном файле, но мне лень, поэтому я описал все в одном.
1struct HeroView: View {
2 var id: Int?
3
4 @ObservedObject var viewModel = HeroViewModel()
5
6 var body: some View {
7 HStack {
8 Text(viewModel.hero?.name ?? "")
9 }.onAppear {
10 self.viewModel.getHero(id: self.id ?? 0)
11 }
12
13 }
14}
15
Как видно, мы передаем id
при создании HeroView(id: hero.id)
. А дальше получаем данные по герою используя его id
. Для этого у нас есть отдельная HeroViewModel
1import Foundation
2import Combine
3
4class HeroViewModel: ObservableObject {
5
6 @Published var hero: Hero?
7 var cancellation: AnyCancellable?
8 let service = HeroesService()
9
10 func getHero(id: Int) {
11 cancellation = service.get(id: id)
12 .mapError({ (error) -> Error in
13 print(error)
14 return error
15 })
16 .sink(receiveCompletion: { _ in },
17 receiveValue: { hero in
18 self.hero = hero
19 })
20 }
21}
Один в один как HeroesViewModel
но тут мв получаем данные только по одному герою, по его id и нам не нужно делать запросы при инициализации самой модели. Вместо этого, запрос за данными будет происходить по событию .onAppear
в HeroView
И последний штрих - загрузка картинки по URL. К сожалению, SwiftUI не умеет делать это сам(не умеет делать это просто). Воспользуемся сторонней библиотекой URLImage
. Сама либа доступна на гитхаб.
Сделаем небольшую обертку для ее использования:
1struct Image: View {
2 var url: String?
3
4 var body: some View {
5 guard let u = URL(string: url ?? "") else {
6 return AnyView(Text("Loading..."))
7 }
8 return AnyView(URLImage(u))
9 }
10}
И теперь используем эту обертку в нашей детальной вьюхе.
1struct HeroView: View {
2 var id: Int?
3
4 @ObservedObject var viewModel = HeroViewModel()
5
6 var body: some View {
7 VStack {
8 Text(viewModel.hero?.name ?? "")
9 Image(url: viewModel.hero?.images?.large ?? "")
10 }.onAppear {
11 self.viewModel.getHero(id: self.id ?? 0)
12 }
13
14 }
15
Что должно получится:
На этом краткое введение в использование Combine закончено. Больше информации по ссылкам.
Ссылки
Откуда я брал информацию и идеи.
- Проект на гитхабе
- Официальная документация по Combine
- Большущая статья про Combine, SwiftUI и как надо делать приложения под iOS. Я понял не все, но я стараюсь: Modern MVVM iOS App Architecture with Combine and SwiftUI
- Навигация в SwiftUI. Туториал от hackingwithswift.
- Еще одна статья про Combine, но уже попроще: Combine networking with a hint of SwiftUI