Разработка приложения на SwiftUI. Часть 1: поток данных и Redux
После участия в сессии State of the Union на WWDC 2019 я решил детально изучить SwiftUI. Я потратил много времени на работу с ним и теперь приступил к разработке реального приложения, которое может оказаться полезным широкому кругу пользователей.
Его я назвал MovieSwiftUI — это апп для поиска новых и старых фильмов, а также их сбора в коллекцию при помощи TMDB API. Я всегда любил фильмы и даже создал компанию, работающую в этой сфере, правда давно. Компанию сложно было назвать классной, а вот приложение — да!
Напоминаем:для всех читателей «Хабра» — скидка 10 000 рублей при записи на любой курс Skillbox по промокоду «Хабр».
Взаимодействует с API — это делает почти любое современное приложение.
Загружает асинхронные данные по запросам и парсит JSON в Swift-модели, используя Codable.
Показывает изображения, загружаемые по запросу, и кэширует их.
Это приложение для iOS, iPadOS, и macOS обеспечивает лучший UX для пользователей этих ОС.
Пользователь может генерировать данные, создавать собственные списки фильмов. Приложение сохраняет и восстанавливает пользовательские данные.
Представления, компоненты и модели четко разделены при помощи паттерна Redux. Поток данных здесь однонаправлен. Он может быть полностью кэширован, восстановлен и перезаписан.
Приложение использует базовые компоненты SwiftUI, TabbedView, SegmentedControl, NavigationView, Form, Modal и т.п. Также оно обеспечивает кастомные представления, жесты, UI/UX.
На самом деле анимация плавная, гифка получилась немного дерганая
Работа над приложением дала мне много опыта, и в целом это положительный опыт. Я смог написать полнофункциональное приложение, в сентябре я его улучшу и выложу в AppStore, одновременно с выходом iOS 13.
Redux, BindableObject и EnvironmentObject
На сегодняшний момент я работаю с Redux уже около двух лет, так что относительно хорошо в этом разбираюсь. В частности, я использую его во фронтенде для React веб-сайта, а также для разработки нативных iOS (Swift) и Android (Kotlin) приложений.
Я ни разу не пожалел о выборе Redux в качестве архитектуры потока данных для создания приложения на SwiftUI. Наиболее сложные моменты при использовании Redux в приложении UIKit — это работа со store, а также получение и извлечение данных и сопоставление их с вашими представлениями / компонентами. Для этого пришлось создать своего рода библиотеку коннекторов (на ReSwift и ReKotlin). Работает хорошо, но довольно много кода. К сожалению, он (пока) не open source.
Хорошие новости! Единственное, о чем стоит беспокоиться со SwiftUI — если вы планируете использовать Redux, — это store, состояния и редьюсеры. Взаимодействие со store полностью берет на себя SwiftUI благодаря @EnvironmentObject. Так, store начинается с BindableObject.
Я создал простой пакет Swift, SwiftUIFlux, который обеспечивает базовое использование Redux. В моем случае это часть MovieSwiftUI. Также я написал пошаговый туториал, который поможет использовать этот компонент.
Как это работает?
final public class Store<State: FluxState>: BindableObject {
public let willChange = PassthroughSubject<Void, Never>()
private(set) public var state: State
private func _dispatch(action: Action) {
willChange.send()
state = reducer(state, action)
}
}
Каждый раз, когда вы запускаете действие, вы активируете редуктор. Он будет оценивать действия в соответствии с текущим состоянием приложения. Далее он будет возвращать новое модифицированное состояние в соответствии с видом действия и данными.
Ну а поскольку store является BindableObject, он будет уведомлять SwiftUI об изменении своего значения, используя свойство willChange, предоставленное PassthroughSubject. Это так потому, что BindableObject должен предоставлять PublisherType, но реализация протокола отвечает за управление им. В общем, это очень мощный инструмент от Apple. Соответственно, в следующем цикле рендеринга SwiftUI поможет отобразить тело представлений в соответствии с изменением состояния.
Собственно, это все — сердце и магия SwiftUI. Теперь в любом представлении, которое подписано на состояние, представление будет отображаться в соответствии с тем, какие данные получены из состояния и что изменилось.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let controller = UIHostingController(rootView: HomeView().environmentObject(store))
window.rootViewController = controller
self.window = window
window.makeKeyAndVisible()
}
}
}
struct CustomListCoverRow : View {
@EnvironmentObject var store: Store<AppState>
let movieId: Int
var movie: Movie! {
return store.state.moviesState.movies[movieId]
}
var body: some View {
HStack(alignment: .center, spacing: 0) {
Image(movie.poster)
}.listRowInsets(EdgeInsets())
}
}
Store внедряется как EnvironmentObject при запуске приложения, а затем доступно в любом представлении при помощи @EnvironmentObject. Производительность не снижается, поскольку производные свойства быстро извлекаются или вычисляются из состояния приложения.
Код, который указан выше, изменяет изображение, если меняется постер к фильму.
И выполняется это фактически всего одной строкой, с помощью которой производится подключение представлений к состоянию. Если вы работали с ReSwift на iOS или даже connect с React, вы поймете, в чем магия SwiftUI.
А теперь можно попробовать активировать действие и опубликовать новое состояние. Вот более сложный пример.
struct CustomListDetail : View {
@EnvironmentObject var store: Store<AppState>
let listId: Int
var list: CustomList {
store.state.moviesState.customLists[listId]!
}
var movies: [Int] {
list.movies.sortedMoviesIds(by: .byReleaseDate, state: store.state)
}
var body: some View {
List {
ForEach(movies) { movie in
NavigationLink(destination: MovieDetail(movieId: movie).environmentObject(self.store)) {
MovieRow(movieId: movie, displayListImage: false)
}
}.onDelete { (index) in
self.store.dispatch(action: MoviesActions.RemoveMovieFromCustomList(list: self.listId, movie: self.movies[index.first!]))
}
}
}
}
В коде выше я использую действие .onDelete из SwiftUI для каждого IP. Это позволяет строке в списке отображать обычный свайп iOS для удаления. Поэтому, когда пользователь касается кнопки удаления, он запускает соответствующее действие и удаляет фильм из списка.
Ну а поскольку свойство списка является производным от состояния BindableObject и внедряется как EnvironmentObject, то SwiftUI обновляет список, поскольку ForEach связан с вычисляемым свойством movies.
Вот часть редьюсера MoviesState:
func moviesStateReducer(state: MoviesState, action: Action) -> MoviesState {
var state = state
switch action {
// other actions.
case let action as MoviesActions.AddMovieToCustomList:
state.customLists[action.list]?.movies.append(action.movie)
case let action as MoviesActions.RemoveMovieFromCustomList:
state.customLists[action.list]?.movies.removeAll{ $0 == action.movie }
default:
break
}
return state
}
Редуктор выполняется, когда вы отправляете действие и возвращаете новое состояние, как сказано выше.
Я пока не буду вдаваться в подробности — откуда SwiftUI на самом деле знает, что отображать. Для того, чтобы понять это более глубоко, стоит просмотреть сессию WWDC о потоке данных в SwiftUI. Также там подробно рассказывается, зачем и когда использовать State, @Binding, ObjectBinding и EnvironmentObject.