В тази статия ще говорим за рамката SwiftUI във връзка с Redux. Тази двойка ни позволява да създаваме приложения бързо и лесно. SwiftUI се използва за създаване на потребителски интерфейс в декларативен стил, за разлика от UIKit. Redux от своя страна служи за контрол на състоянието на приложението.

Състояние е основна концепция в SwiftUI и Redux. В нашия случай това е не само модна дума, но и цялост, която ги свързва и им позволява да работят много добре заедно. В тази статия ще се опитаме да покажем, че тезата по-горе е вярна, така че нека започваме!

Преди да навлезем по-дълбоко в писането на код, първо нека разберем какво е Redux и от какво се състои.

Redux е библиотека с отворен код за управление на състоянието на приложение. Най-често се използва във връзка с React или Angular за разработване на клиентската страна. Той съдържа редица инструменти за значително опростяване на прехвърлянето на данни за съхранение през контекста. Нейни създатели са Даниил Абрамов и Андрю Кларк.

За нас Redux не е просто библиотека, той вече е нещо повече. Отдаваме го на архитектурните решения, на които е базирано приложението, основно поради неговия еднопосочен поток от данни.



Многопосочен или еднопосочен поток

За да обясним какво имаме предвид под поток от данни, ще дадем следния пример. Приложение, създадено с помощта на VIPER, поддържа многопосочен поток от данни между модулите:

Redux от своя страна е еднопосочен поток от данни и най-лесно се обяснява на базата на съставните му компоненти.

Нека поговорим по-подробно за всеки компонент на Redux.

Държавата е единственият източник на истина, който съдържа цялата необходима информация за нашето приложение.

Действие е намерението за промяна на състоянието. В нашия случай това е изброяване, което съдържа нова информация, която искаме да добавим или променим в текущото състояние.

Редуктор е функция, която приема действие и текущо състояние като параметри и връща ново състояние. Това е единственият начин да го създадете. Също така си струва да се отбележи, че тази функция трябва да е чиста.

Store е обект, който съдържа State и предоставя всички необходими инструменти за актуализирането му.

Това вероятно би било достатъчно за първи път на теория, нека сега да преминем към практиката.

Реализация на Redux

Един от най-лесните начини да опознаете даден инструмент е да започнете да го използвате. Всеки знае, че ако искате да научите език за програмиране, трябва да напишете приложение на него. Така че нека създадем малко приложение, например прост тренировъчен дневник. Той ще има само четири опции — първата е да покаже списък с тренировки, втората е да добави завършена тренировка, третата е да изтрие и четвъртата е да сортира тренировки. Доста просто приложение, но в същото време ще ни позволи да се запознаем с Redux и SwiftUI.

Създайте чист проект в Xcode, дайте му име WorkoutsDiary и, най-важното, изберете SwiftUI за потребителски интерфейс.

След като създадете проекта, създайте структура за тренировка, която ще отговаря за тренировката, която изпълнихме.

import Foundation
struct Workout: Identifiable {
let id: UUID = .init()
let name: String
let distance: String
let date: Date
let complexity: Complexity
}

Както можете да видите, няма нищо сложно в тази структура, полето id трябва да отговаря на протокола за идентифициране, а полето за сложност е просто enum със следната дефиниция:

enum Complexity: Int {
case low
case medium
case high
}

Сега имаме всичко необходимо, за да започнем внедряването на Redux. Да започнем със създаването на държава.

struct AppState {
var workouts: [Workout]
var sortType: SortType?
}

Състояние е проста структура, която съдържа две полета: workouts и sortType. Първото е списък с тренировки, а второто е незадължително поле, което определя как се сортира списъкът.

SortType е изброяване, което се дефинира, както следва:

enum SortType {
case distance
case complexity
}

За простота ще сортираме по разстояние и трудност в низходящ ред. Това означава, че колкото по-висока е сложността на нашето обучение, толкова по-високо ще бъде показано в нашия списък. Струва си да се отбележи, че sortType е незадължителен тип и може да бъде нула, което означава, че списъкът не е сортиран в момента.

Ще продължим внедряването на нашите компоненти. Нека създадем действие:

enum Action {
case addWorkout(_ workout: Workout)
case removeWorkout(at: IndexSet)
case sort(by: SortType)
}

Както виждаме, Действието е изброяване с три случая, които ни дават възможност да манипулираме нашата държава.

  • addWorkout (_ workout: Workout) просто добавя тренировка, която се предава като параметър.
  • removeWorkout (при: IndexSet) премахва елемента на посочения индекс.
  • sort (by: SortType) сортира списъка за обучение по зададения тип сортиране.

Нека създадем един от най-сложните компоненти. Това е Редуктор:

func reducer(state: AppState, action: Action) -> AppState {
var state = state
switch action {
case .addWorkout(let workout):
state.workouts.append(workout)
case .removeWorkout(let indexSet):
state.workouts.remove(atOffsets: indexSet)
switch type {
case .distance:
state.workouts.sort { $0.distance > $1.distance }
state.sortType = .distance
case .complexity:
state.workouts.sort { $0.complexity.rawValue > $1.complexity.rawValue }
state.sortType = .complexity
}
}
return state
}

Функцията, която написахме, е доста проста и работи по следния начин:

  1. Той копира текущото състояние, за да работи с него.
  2. 2. Въз основа на действие, ние актуализираме нашето копирано състояние.

3. Връщаме актуализираното състояние.

Трябва да се отбележи, че функцията по-горе е чиста функция и това е, което искахме да постигнем! Една функция трябва да отговаря на две условия, за да се счита за „чиста“:

  • Всеки път функцията връща същия резултат, когато бъде извикана със същия набор от данни.
  • Няма странични ефекти.

Последният липсващ Redux елемент е Store, така че нека го внедрим за нашето приложение.

final class Store: ObservableObject {
@Published private(set) var state: AppState
init(state: AppState = .init(workouts: [Workout]())) {
self.state = state
public func dispatch(action: Action) {
state = reducer(state: state, action: action)
}
}

В имплементациите на обекта Store използваме всички предимства на протокола ObservableObject, който ни позволява да изключим писането на голямо количество шаблонен код или използването на рамки на трети страни. Свойството State е само за четене и използва обвивката на свойството @Published, което означава, че когато се промени, SwiftUI ще получава известия. Методът init приема първоначално състояние като параметър с дадена стойност по подразбиране под формата на празен масив от Workout елементи. Функцията dispatch е единственият начин за актуализиране на състоянието: тя заменя текущото състояние с новото, създадено от функцията за намаляване, въз основа на действието, което се предава като параметър.

След като внедрихме всички компоненти на Redux, можем да започнем да създаваме потребителски интерфейс за нашето приложение.

Реализация на приложението

Потребителският интерфейс на нашето приложение ще бъде доста прост. И ще се състои от два малки екрана. Първият и основен е екран, който ще покаже списък с тренировки. Вторият екран е екран за добавяне на тренировка. Освен това всеки елемент ще бъде показан в определен цвят, цветът ще отразява сложността на тренировката. Червените клетки показват най-високата трудност на тренировката, оранжевото е отговорно за средната трудност, а зеленото показва най-лесната тренировка.

Ще внедрим интерфейса с помощта на нова рамка от Apple, наречена SwiftUI. SwiftUI идва да замени познатия ни UIKit. SwiftUI е фундаментално различен от UIKit, главно в това, че е декларативен подход за писане на UI елементи с код. В тази статия няма да се задълбочаваме във всички тънкости на SwiftUI и предполагаме, че вече имате опит със SwiftUI. Ако нямате познания за SwiftUI, съветваме ви да обърнете внимание на документацията от Apple, а именно да разгледате техните няколко пълни урока с добавяне стъпка по стъпка и интерактивно показване на резултата при изглед. Има и връзки към примерни проекти. Тези уроци ще ви позволят бързо да се потопите в декларативния свят на SwiftUI.

Трябва да се има предвид, че SwiftUI все още не е готов за производствени проекти, той е твърде млад и ще мине повече от година, преди да може да се използва по този начин. Освен това не забравяйте, че поддържа само версии на iOS 13.0+. Но също така си струва да се отбележи, че SwiftUI ще работи на всички платформи на Apple, което е голямо предимство пред UIKit!

Нека започнем внедряването от главния екран на нашето приложение. Отидете до файла ContentView.swift и променете текущия код на следния:

struct ContentView: View {
@EnvironmentObject var store: Store
@State private var isAddingMode: Bool = false
var body: some View {
NavigationView {
WorkoutListView()
.navigationBarTitle("Workouts diary", displayMode: .inline)
.navigationBarItems(
leading: AddButton(isAddingMode: self.$isAddingMode),
trailing: TrailingView()
)
}
.sheet(isPresented: $isAddingMode) {
AddWorkoutView(isAddingMode: self.$isAddingMode)
.environmentObject(self.store)
}
}
}

Изгледът на съдържанието е стандартен изглед в SwiftUI. Най-важната част, от наша гледна точка, е редът от код, който съдържа променливата store. Ще създадем @EnvironmentObject. Това ще ни позволи да използваме данни от Магазина, където е необходимо, и освен това автоматично ще актуализира нашите изгледи, ако данните бъдат променени. Това е нещо като Singleton за нашия магазин.

@EnvironmentObject var store: Store

Следният ред код е важен:

@State private var isAddingMode: Bool = false

@State е обвивка, която можем да използваме, за да посочим състоянието на View. SwiftUI ще го съхранява в специална вътрешна памет извън структурата View. Само свързан изглед има достъп до него. Веднага щом стойността на свойството State се промени, SwiftUI изгражда отново View, за да отчете промените в състоянието.

След това ще отидем до файла SceneDelegate.swift и ще добавим кода към метода:

func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
let contentView = ContentView().environmentObject(Store())
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}

По същия начин всеки @EnvironmentObject може да бъде предаден на всяко дъщерно представяне в цялото приложение и всичко това е възможно благодарение на Environment. Променливата isAddingMode е означена със състояние и показва дали вторичният изглед се показва или не. Променливата store се наследява автоматично от WorkoutListView и не е необходимо да я предаваме изрично, но трябва да направим това за AddWorkoutView, тъй като тя е представена под формата на лист, който не е дете на ContentView.

Сега създайте WorkoutListView, който ще наследи от View. Създайте нов swift файл, наречен WorkoutListView.

struct WorkoutListView: View {
@EnvironmentObject var store: Store
var body: some View {
List {
ForEach(store.state.workouts) {
WorkoutView(workout: $0)
}
.onDelete {
self.store.dispatch(action: .removeWorkout(at: $0))
}
.listRowInsets(EdgeInsets())
}
}

Изглед, който използва контейнерния елемент List за показване на списък с тренировки. Функцията onDelete се използва за изтриване на тренировка и използва действието removeWorkout, което се изпълнява с помощта на функцията за изпращане, предоставена от магазина. За показване на тренировката в списъка се използва WorkoutView.

Създайте друг файл WorkoutView.swift, който ще отговаря за показването на нашия артикул в списъка.

struct WorkoutView: View {
let workout: Workout
private var backgroundColor: Color {
switch workout.complexity {
case .low:
return .green
case .medium:
return .orange
case .high:
return .red
}
}
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(workout.name)
Text("Distance:" + workout.distance + "km")
.font(.subheadline)
}
Spacer()
VStack(alignment: .leading) {
Text(simpleFormat(workout.date))
}
}
.padding()
.background(backgroundColor)
}
}
private extension WorkoutView {
func simpleFormat(_ date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd MMM yyyy"
dateFormatter.locale = .init(identifier: "en_GB")
return dateFormatter.string(from: date)
}
}

Този изглед приема обекта за обучение като параметър и се конфигурира въз основа на неговите свойства.

За да добавите нов елемент към списъка, трябва да промените параметъра isAddingMode на true, за да се покаже AddWorkoutView. Тази отговорност е на AddButton.

struct AddButton: View {
@Binding var isAddingMode: Bool
var body: some View {
Button(action: { self.isAddingMode = true }) {
Image(systemName: "plus")
}
}
}

AddButton също си струва да се постави в отделен файл.

Този изглед е прост бутон, който е извлечен от основния ContentView за по-добра структура и разделяне на кода.

Създайте изглед, за да добавите нова тренировка. Създайте нов файл AddWorkoutView.swift:

struct AddWorkoutView: View {
@EnvironmentObject private var store: Store
@State private var nameText: String = ""
@State private var distanceText: String = ""
@State private var complexityField: Complexity = .medium
@State private var dateField: Date = Date()
@Binding var isAddingMode: Bool
var body: some View {
return .red
}
}
var body: some View {
NavigationView {
Form {
TextField("Name", text: $nameText)
TextField("Distance", text: $distanceText)
Picker(selection: $complexityField, label: Text("Complexity")) {
Text("Low").tag(Complexity.low)
Text("Medium").tag(Complexity.medium)
Text("High").tag(Complexity.high)
}
DatePicker(selection: $dateField, displayedComponents: .date) {
Text("Date")
}
}
.navigationBarTitle("Workout Details", displayMode: .inline)
.navigationBarItems(
leading: Button(action: { self.isAddingMode = false }) {
Text("Cancel")
},
trailing: Button(action: {
let workout = Workout(
name: self.nameText,
distance: self.distanceText,
date: self.dateField,
complexity: self.complexityField
)
self.store.dispatch(action: .addWorkout(workout))
self.isAddingMode = false
}) {
Text("Save")
}
.disabled(nameText.isEmpty)
)
}
}

Това е доста голям контролер, който, подобно на други контролери, съдържа променливата store. Той също така съдържа променливите nameText, distanceText, complexityField и isAddingMode. Първите три променливи са необходими за свързване на TextField, Picker, DatePicker, които могат да се видят на този екран. Навигационната лента има два елемента. Първият бутон е бутон, който затваря екрана без добавяне на нова тренировка, а последният добавя нова тренировка към списъка, което се постига чрез изпращане на действието addWorkout. Това действие също затваря новия екран за тренировка.

Не на последно място е TrailingView.

struct TrailingView: View {
@EnvironmentObject var store: Store
var body: some View {
HStack(alignment: .center, spacing: 30) {
Button(action: {
switch self.store.state.sortType {
case .distance:
self.store.dispatch(action: .sort(by: .distance))
default:
self.store.dispatch(action: .sort(by: .complexity))
}
}) {
Image(systemName: "arrow.up.arrow.down")
}
EditButton()
}
}
}

Този изглед се състои от два бутона, които са отговорни за сортиране на списъка с тренировки и за активиране или деактивиране на режима за редактиране на нашия списък с тренировки. Действията за сортиране се извикват с помощта на функцията за изпращане, която можем да извикаме чрез свойството store.

Резултат

Приложението е готово и трябва да работи точно както се очаква. Нека се опитаме да го компилираме и стартираме.

Заключение

Redux и SwiftUI работят много добре заедно. Кодът, написан с помощта на тези инструменти, е лесен за разбиране и може да бъде добре организиран. Друг добър аспект на това решение е отличната възможност за тестване на кода. Това решение обаче не е лишено от недостатъци. Един от тях е голямото количество памет, използвано от приложението, когато състоянието на приложението е много сложно и производителността на приложението може да не е идеална в някои конкретни сценарии, тъй като всички изгледи в SwiftUI се актуализират при създаване на ново състояние. Тези недостатъци могат да имат голямо влияние върху качеството на приложението и взаимодействието с потребителя, но ако ги запомним и подготвим състоянието по разумен начин, отрицателното въздействие може лесно да бъде минимизирано или дори избегнато.

Надяваме се, че сте харесали тази статия и сте научили нещо ново. Ще се видим скоро. Темите, за които ще говорим по-нататък, ще бъдат още по-интересни ;)

Получете достъп до експертен изглед — Абонирайте се за DDI Intel