MVVM для iOS с Combine и SwiftUI

23 minute read

Разработка под 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 закончено. Больше информации по ссылкам.

Ссылки

Откуда я брал информацию и идеи.

comments powered by Disqus