
Всем привет, меня зовут Дмитрий Лоренц, я iOS-разработчик в IT-компании GRI. Наш основной клиент — Sunlight, для него мы разрабатываем нескольких мобильных приложений по полному циклу и поддерживаем сайт.
В этой статье я расскажу про нашу новую архитектуру для iOS-приложения и поделюсь некоторыми советами, как упростить себе жизнь и сделать код более лаконичным и читаемым.
За основу мы взяли архитектуру MVVM (Model—View—ViewModel), в которой View отвечает за графическое представление данных, вся бизнес логика сосредоточена внутри ViewModel. ViewModel обрабатывает запросы от View, обновляет свои данные, и View посредством data binding автоматически обновляет своё представление, что очень удобно. Model — модель для хранения и передачи данных.
Также мы обратили свой взор в сторону TCA, у которой есть:
UI— графическое представление данных;Action— набор допустимых действий;State— текущее состояние данных;Environment— набор внешних сервисов;Reducer— механизм, меняющий состояния и порождающий эффекты;Effect— задача, по завершении которой в Reducer возвращаетсяAction.
Какие были запросы и почему решили дорабатывать архитектуру? Проект мобильного приложения Sunlight достаточно старый, да и навигация на SwiftUI не так давно обрела работоспособность, поэтому вся навигация в проекте реализована через UIKit. SwiftUI, начиная с iOS 14, более-менее позволяет верстать сложные интерфейсы, поэтому была задача делать модули как на UIKit, так и на SwiftUI. Соответственно, нужен был Builder модуля, который принимает как UIVIew, так и View, и отдаёт UIViewController. При этом было большое желание абстрагироваться от многопоточности, унести её в архитектуру модуля и оставить разработчику только вёрстку и реализацию бизнес-логики, больше ни о чём не думая.
Как итог объединения MVVM и TCA родилась конструкция, содержащая в себе основные элементы из MVVM: Model, View, ViewModel, и помимо этого добавились несколько из TCA: State, Reducer и Action. Рассмотрим их по порядку.
State
State — это общая модель с данными, хранящая в себе состояние View и его Subview. Обычно в MVVM все эти данные «россыпью» хранятся во ViewModel, а в нашей архитектуре все UI-параметры вынесены в State. Остальные переменные, необходимые для функционирования ViewModel, лежат внутри ViewModel. Ниже — пример реализации State с @Published и вычисляемыми переменными.
Реализация State
import Combine
import UIKit
final class ProductCardState: ViewStateProtocol {
// MARK: — Properties
@Published var article: String
@Published var loadingState: LoadingState
@Published var position: Position
@Published var currentSlidingStep: Int
@Published var isImageSliderVertical: Bool
@Published var productImages: [NetworkImage]
@Published var actualPrice: String
@Published var initialPrice: String
@Published var basketLoadingState: LoadingState
@Published var isPriceCellVisible: Bool
@Published var isAvailableToBuy: Bool
@Published var isAddedToBasket: Bool
@Published var bottomSafeAreaInset: CGFloat
@Published var priceDescription: String
var shouldShowPriceInButton: Bool {
if case .bottom = position {
return true
}
return !isPriceCellVisible
}
var navigationHeaderOpacity: Double {
switch position {
case .bottom:
0
case .middle:
0
case .top:
1
}
}
var offset: Double {
switch position {
case .bottom:
UIScreen.main.bounds.height — PublicConstant.initialOffset
case .middle:
isImageSliderVertical ? UIScreen.main.bounds.height / 2.0 : UIScreen.main.bounds.width
case .top:
PublicConstant.navBarHeight
}
}
var navigationHeader: NavigationHeader.ViewState {
.init(
article: article,
loadingState: loadingState,
opacity: navigationHeaderOpacity
)
}
var priceCell: PriceCell.ViewState {
.init(
name: "Серебрянные часы Bastet. Швейцарский механизм и знаменитые Белорусские стрелки", // stub data
bages: ["НОВИНКА", "ХИТ", "ИТАЛИЯ"],
actualPrice: actualPrice,
initialPrice: initialPrice,
priceDescription: priceDescription,
position: position
)
}
var footerButtons: FooterButtons.ViewState {
.init(
loadingState: loadingState,
bottomInset: bottomSafeAreaInset,
basketLoadingState: basketLoadingState,
actualPrice: actualPrice,
initialPrice: initialPrice,
shouldShowPriceInButton: shouldShowPriceInButton,
isAvailableToBuy: isAvailableToBuy,
isAddedToBasket: isAddedToBasket
)
}
var imageSliderAssembly: ImageSliderAssembly.ViewState {
get {
.init(
currentStep: currentSlidingStep,
loadingState: loadingState,
position: position,
initialOffset: PublicConstant.initialOffset,
isImageSliderVertical: isImageSliderVertical,
productImages: productImages
)
}
set {
currentSlidingStep = newValue.currentStep
}
}
// MARK: — Lifecycle
init(input: ProductCard.Input?) {
article = input?.article ?? ""
loadingState = .loading
position = .bottom
currentSlidingStep = 0
isImageSliderVertical = true
productImages = []
actualPrice = ""
initialPrice = ""
basketLoadingState = .hide
isPriceCellVisible = false
isAvailableToBuy = true
isAddedToBasket = false
bottomSafeAreaInset = 0
priceDescription = ""
}
}
extension ProductCard.ViewState {
enum Position {
case bottom
case middle
case top
}
}
extension ProductCard.ViewState {
enum PublicConstant {
static let initialOffset = 146.0
static let navBarHeight = 104.0
}
}State соответствует протоколу ViewStateProtocol, который имеет инициализатор с Input: чтобы была возможность передавать входные данные в модуль и метод update() для обновления собственного состояния в потоке main.
Реализация ViewStateProtocol
// MARK: — ViewState
@MainActor
protocol ViewStateProtocol: ObservableObject, Sendable {
associatedtype Input
init(input: Input?)
}
extension ViewStateProtocol {
func update(_ handler: @Sendable @MainActor (Self) -> Void) async {
await MainActor.run { handler(self) }
}
}Это необходимо, так как все UI-параметры, отвечающие за внешний вид View и его Subview, хранятся внутри State. Соответственно, при их изменении View сразу перерисовывает своё состояние. Сам по себе State — это класс, так удобнее его использовать в различных сущностях (View, ViewModel), передавать в Subview (рассмотрим далее), и всегда это один и тот же экземпляр.
View
View соответствует протоколу ViewProtocol, который в свою очередь предполагает передачу в инициализатор State и Reducer (о нём чуть позже).
// MARK: — View
protocol ViewProtocol {
associatedtype ViewState: ViewStateProtocol
associatedtype ViewModel: ViewModelProtocol
@MainActor
init(state: ViewState, reducer: Reducer<ViewModel>)
}
Пример реализации View
import SwiftUI
struct ProductCardView: View, ViewProtocol {
@ObservedObject var state: ProductCard.ViewState
let reducer: Reducer<ProductCard.ViewModel>
private var isDragGestureEnabled: Bool {
if case .bottom = state.position {
return true
}
return false
}
init(state: ProductCard.ViewState, reducer: Reducer<ProductCard.ViewModel>) {
self.state = state
self.reducer = reducer
}
var body: some View {
ProductCardViewLayout(
header: { header },
sideButtons: { sideButtons },
slider: { slider },
content: { content },
footer: { footer(geometry: $0) }
)
.onAppear { reducer(.viewDidLoad) }
.animation(.easeInOut(duration: 1.0), value: state.position)
.animation(.default, value: state.isPriceCellVisible)
.animation(.default, value: state.isAddedToBasket)
.animation(.default, value: state.basketLoadingState)
.animation(.default, value: state.loadingState)
}
private var header: some View {
NavigationHeader(
state: state.navigationHeader,
onAction: { reducer(.onNavigationHeaderAction($0)) }
)
}
private var sideButtons: some View {
SideButtons(
position: state.position,
loadingState: state.loadingState,
onAction: { reducer(.onSideButtonsAction($0)) }
)
.opacity(1 — state.navigationHeaderOpacity)
}
private var slider: some View {
ImageSliderAssembly(
state: $state.imageSliderAssembly,
onAction: { reducer(.onImageSliderAction($0)) }
)
}
private var content: some View {
ProductCardList(state: state, reducer: reducer)
.offset(y: state.offset)
}
private func footer(geometry: GeometryProxy) -> some View {
FooterButtons(
state: state.footerButtons,
onAction: { reducer(.onFooterButtonsAction($0)) }
)
.onAppear {
reducer(.setBottomSafeAreaInset(geometry.safeAreaInsets.bottom))
}
.animation(.easeInOut(duration: 1.0), value: state.position)
.animation(.default, value: state.isPriceCellVisible)
.animation(.default, value: state.isAddedToBasket)
.animation(.default, value: state.basketLoadingState)
.animation(.default, value: state.loadingState)
}
}View может напрямую читать все свойства State и даже изменять их при использовании Binding. Это сделано намеренно, так как от Binding не хотелось отказываться, но и городить каждый раз get {} set {} в коде тоже не было желания. Если необходимо изменить какой-либо из параметров State без использования Binding (например, нажатие кнопки), то всё обновление происходит традиционно через ViewModel. И тут мы видим, что View не имеет никакой ViewModel, зато имеет некий Reducer. Что же это за сущность такая и для чего нужна?
Reducer
Reducer — это вспомогательный класс для взаимодействия с ViewModel. Так как ViewModel это актор, то его методы и параметры доступны через await. Чтобы код был чище и каждый раз не писать конструкцию Task { await viewModel.handle(...) }, применён Reducer, который принимает в себя необходимый Action и дальше выполняет всю необходимую обработку под капотом.
// MARK: — Reducer
final class Reducer<ViewModel>: Sendable where ViewModel: ViewModelProtocol {
private let viewModel: ViewModel
init(viewModel: ViewModel) {
self.viewModel = viewModel
}
nonisolated func callAsFunction(_ action: ViewModel.Action) {
Task { [weak self] in
await self?.viewModel.handle(action)
}
}
}
Пример использования Reducer:
private var header: some View {
NavigationHeader(
state: state.navigationHeader,
onAction: { reducer(.onNavigationHeaderAction($0)) }
)
}
То есть просто отдаём в Reducer необходимый кейс из enum Action (иногда он содержит связанный параметр), и всё — под капотом идёт асинхронная обработка внутри ViewModel.
Для взаимодействия с ViewModel предусмотрен enum Action.
Action
Action — это enum, который своими кейсами полностью описывает возможные взаимодействия View с ViewModel. View не может напрямую обращаться к методам ViewModel, они по большей части приватные и наружу «торчит» только функция handle(_ action: Action) для обработки кейса который декларируется внутри enum ViewModel. По сути, он содержит в себе все возможные методы, которые View может вызвать у ViewModel, своего рода протокол для взаимодействия с ViewModel.
Пример реализации Action:
enum Action {
case viewDidLoad
case dismiss
case updatePosition(CGFloat)
case setPriceCellVisible(Bool)
case saveLastSlidingStep(Int)
case setBottomSafeAreaInset(CGFloat)
case onNavigationHeaderAction(NavigationHeader.Action)
case onSideButtonsAction(SideButtons.Action)
case onImageSliderAction(ImageSliderAssembly.Action)
case onFooterButtonsAction(FooterButtons.Action)
case onPriceCellAction(PriceCell.Action)
}
ViewModel
У неё такая же функциональность, как в архитектуре MVVM: она инкапсулирует всю бизнес-логику, взаимодействует с сервисами и роутером. ViewModel соответствует протоколу ViewModelProtocol, в рамках которого принимает в себя Input, Output и Router.
// MARK: — ViewModel
protocol ViewModelProtocol: Sendable {
associatedtype Input
associatedtype Output
associatedtype Action
associatedtype ViewState: ViewStateProtocol
associatedtype Router: RouterProtocol
@MainActor
init(state: ViewState, input: Input?, output: Output?, router: Router?)
func handle(_ action: Action) async
}
Input нужен для инициализации внутренних параметров ViewModel, которые не относятся напрямую к UI, но необходимы для реализации бизнес-логики.
Output — это структура, или протокол, содержащий в себе методы/замыкания для взаимодействия с внешними модулями, своего рода реализация Delegate. В рамках тестового приложения эта функциональность не представлена.
Router традиционно отвечает за навигацию по приложению.
Как и в MVVM, ViewModel ничего не знает про View, взаимодействие реализовано через реактивный подход.
Пример реализации ViewModel
actor ProductCardViewModel: ViewModelProtocol {
// MARK: - Nested Types
enum Action {
case viewDidLoad
case dismiss
case updatePosition(CGFloat)
case setPriceCellVisible(Bool)
case saveLastSlidingStep(Int)
case setBottomSafeAreaInset(CGFloat)
case onNavigationHeaderAction(NavigationHeader.Action)
case onSideButtonsAction(SideButtons.Action)
case onImageSliderAction(ImageSliderAssembly.Action)
case onFooterButtonsAction(FooterButtons.Action)
case onPriceCellAction(PriceCell.Action)
}
// MARK: - Private Properties
private let router: ProductCard.Router?
private let state: ProductCard.ViewState
private let input: ProductCard.Input?
private let output: ProductCard.Output?
private var isAnimating = false
// MARK: - Initializer
init(
state: ProductCard.ViewState,
input: ProductCard.Input?,
output: ProductCard.Output?,
router: ProductCard.Router?
) {
self.state = state
self.input = input
self.output = output
self.router = router
}
// MARK: - Internal Methods
func handle(_ action: Action) async {
switch action {
case .viewDidLoad:
await viewDidLoad()
case .dismiss:
await dismiss()
case let .updatePosition(transition):
await updatePosition(for: transition)
case let .setPriceCellVisible(isPriceCellVisible):
await setPriceCellVisible(isPriceCellVisible)
case let .saveLastSlidingStep(step):
await saveSlidingStep(step)
case let .setBottomSafeAreaInset(inset):
await setBottomSafeAreaInset(inset)
case let .onNavigationHeaderAction(action):
await handleNavigationHeader(action: action)
case let .onSideButtonsAction(action):
await handleSideButtons(action: action)
case let .onImageSliderAction(action):
await handleImageSlider(action: action)
case let .onFooterButtonsAction(action):
await handleFooterButtons(action: action)
case let .onPriceCellAction(action):
await handlePriceCell(action: action)
}
}
}
// MARK: - Private Methods
extension ProductCard.ViewModel {
private func handleNavigationHeader(action: NavigationHeader.Action) async {
switch action {
case .onTapBackButton:
await dismiss()
case .onTapFavoriteButton:
print(action)
case .onTapShareButton:
print(action)
case .onTapSimilarButton:
print(action)
case .onTapSetsButton:
print(action)
}
}
private func handleSideButtons(action: SideButtons.Action) async {
switch action {
case .onTapShareButton:
print(action)
case .onTapSimilarButton:
print(action)
case .onTapSetsButton:
print(action)
}
}
private func handleFooterButtons(action: FooterButtons.Action) async {
switch action {
case .onTapMapButton:
await switchPriceStyle()
case .onTapBasketButton:
await addToBasket()
}
}
private func handlePriceCell(action: PriceCell.Action) async {
switch action {
case let .onSetPriceCellVisible(isVisible):
await setPriceCellVisible(isVisible)
}
}
private func handleImageSlider(action: ImageSliderAssembly.Action) async {
switch action {
case let .onTapSlider(index):
print(index)
case let .onTapReview(index):
print(index)
case let .onSaveSlidingStep(step):
await saveSlidingStep(step)
}
}
private func setInitialState() async {
await state.update { $0.makeStubData() }
}
private func setBottomSafeAreaInset(_ inset: CGFloat) async {
await state.update { $0.bottomSafeAreaInset = inset }
}
private func dismiss() async {
await router?.dismiss()
}
private func saveSlidingStep(_ step: Int) async {
try? await Task.sleep(seconds: 0.1)
await state.update {
$0.currentSlidingStep = step
}
}
private func setPosition() async {
if await !state.isImageSliderVertical {
await state.update { $0.position = .middle }
}
}
private func setPriceCellVisible(_ isVisible: Bool) async {
await state.update { state in
state.isPriceCellVisible = isVisible
}
}
private func addToBasket() async {
await state.update {
$0.basketLoadingState = .loading
}
try? await Task.sleep(seconds: 1.5)
await state.update { state in
state.isAddedToBasket.toggle()
state.basketLoadingState = .hide
}
}
private func viewDidLoad() async {
try? await Task.sleep(seconds: 2)
await setInitialState()
await state.update { $0.loadingState = .hide }
await setPosition()
}
private func updatePosition(for transition: CGFloat) async {
guard !isAnimating else { return }
isAnimating = true
let position = await handle(transition: transition)
await state.update { $0.position = position }
try? await Task.sleep(seconds: Constant.animationDuration)
isAnimating = false
}
private func handle(transition: CGFloat) async -> ProductCard.ViewState.Position {
switch await (state.position, transition) {
case (.bottom, 0...):
.middle
case (.middle, ...0):
await state.isImageSliderVertical ? .bottom : .middle
case (.middle, 0...):
.top
case (.top, ...0):
.middle
default:
await state.position
}
}
private func switchPriceStyle() async {
await state.update { $0.isPriceCellVisible.toggle() }
}
}
extension ProductCard.ViewModel {
private enum Constant {
static let animationDuration = 1.0
}
}Как видите, у ViewModel есть единственный публичный метод handle(_ action: Action) async, который обрабатывает все обращения из View через Reducer. Все остальные методы приватные.
func handle(_ action: Action) async {
switch action {
case .viewDidLoad:
await viewDidLoad()
case .dismiss:
await dismiss()
case let .updatePosition(transition):
await updatePosition(for: transition)
case let .setPriceCellVisible(isPriceCellVisible):
await setPriceCellVisible(isPriceCellVisible)
case let .saveLastSlidingStep(step):
await saveSlidingStep(step)
case let .setBottomSafeAreaInset(inset):
await setBottomSafeAreaInset(inset)
case let .onNavigationHeaderAction(action):
await handleNavigationHeader(action: action)
case let .onSideButtonsAction(action):
await handleSideButtons(action: action)
case let .onImageSliderAction(action):
await handleImageSlider(action: action)
case let .onFooterButtonsAction(action):
await handleFooterButtons(action: action)
case let .onPriceCellAction(action):
await handlePriceCell(action: action)
}
}
Module
Module — это общий файл, где хранятся все протоколы, отвечающие за функционирование модуля. То есть он описывает всю архитектуру и её сущности.
Пример реализации Module
import SwiftUI
import Combine
// MARK: — ViewState
@MainActor
protocol ViewStateProtocol: ObservableObject, Sendable {
associatedtype Input
init(input: Input?)
}
extension ViewStateProtocol {
func update(_ handler: @Sendable @MainActor (Self) -> Void) async {
await MainActor.run { handler(self) }
}
}
// MARK: — ViewModel
protocol ViewModelProtocol: Sendable {
associatedtype Input
associatedtype Output
associatedtype Action
associatedtype ViewState: ViewStateProtocol
associatedtype Router: RouterProtocol
@MainActor
init(state: ViewState, input: Input?, output: Output?, router: Router?)
func handle(_ action: Action) async
}
// MARK: — View
protocol ViewProtocol {
associatedtype ViewState: ViewStateProtocol
associatedtype ViewModel: ViewModelProtocol
@MainActor
init(state: ViewState, reducer: Reducer<ViewModel>)
}
// MARK: — Router
@MainActor
protocol RouterProtocol: Sendable {
var parentViewController: UIViewController? { get set }
init()
}
// MARK: — Reducer
final class Reducer<ViewModel>: Sendable where ViewModel: ViewModelProtocol {
private let viewModel: ViewModel
init(viewModel: ViewModel) {
self.viewModel = viewModel
}
nonisolated func callAsFunction(_ action: ViewModel.Action) {
Task { [weak self] in
await self?.viewModel.handle(action)
}
}
}
// MARK: — Module
protocol ModuleProtocol {
associatedtype Input
associatedtype Output
associatedtype ViewState: ViewStateProtocol where ViewState.Input == Input
associatedtype ViewScene: ViewProtocol where ViewScene.ViewState == ViewState, ViewScene.ViewModel == ViewModel
associatedtype ViewModel: ViewModelProtocol where ViewModel.Input == Input, ViewModel.Output == Output, ViewModel.ViewState == ViewState, ViewModel.Router == Router
associatedtype Router: RouterProtocol
}
extension ModuleProtocol {
@MainActor
static func build(input: Input? = nil, output: Output? = nil) -> UIViewController {
let state = ViewState(input: input)
var router = Router()
let viewModel = ViewModel(
state: state,
input: input,
output: output,
router: router
)
let reducer = Reducer(viewModel: viewModel)
let view = ViewScene(state: state, reducer: reducer)
if let vc = view as? UIViewController {
router.parentViewController = vc
return vc
} else if let view = view as? (any View) {
let viewController = UIHostingController(rootView: AnyView(view))
router.parentViewController = viewController
return viewController
} else {
fatalError("Unexpected view type")
}
}
}
extension ModuleProtocol where ViewScene: View {
@MainActor
static func preview(input: Input? = nil, output: Output? = nil) -> some View {
let state = ViewState(input: input)
let router = Router()
let viewModel = ViewModel(
state: state,
input: input,
output: output,
router: router
)
let reducer = Reducer(viewModel: viewModel)
return ViewScene(state: state, reducer: reducer)
}
}
final class Builder<M>: Sendable where M: ModuleProtocol {
@MainActor
static func build(input: M.ViewModel.Input? = nil, output: M.ViewModel.Output? = nil) -> UIViewController {
let state = M.ViewState(input: input)
var router = M.Router()
let viewModel = M.ViewModel(
state: state,
input: input,
output: output,
router: router
)
let reducer = Reducer(viewModel: viewModel)
let view = M.ViewScene(state: state, reducer: reducer)
if let vc = view as? UIViewController {
router.parentViewController = vc
return vc
} else if let view = view as? (any View) {
let viewController = UIHostingController(rootView: AnyView(view))
router.parentViewController = viewController
return viewController
} else {
fatalError("Unexpected view type")
}
}
}
extension Builder where M.ViewScene: View {
@MainActor
static func preview(input: M.ViewModel.Input? = nil, output: M.ViewModel.Output? = nil) -> some View {
let state = M.ViewState(input: input)
let router = M.Router()
let viewModel = M.ViewModel(
state: state,
input: input,
output: output,
router: router
)
let reducer = Reducer(viewModel: viewModel)
return M.ViewScene(state: state, reducer: reducer)
}
}Рассмотрим некоторые граничные случаи для красоты кода и упрощения восприятия.
Передача входных параметров в Subview
Если у какой-либо Subview имеется более двух входных параметров, то чтобы не утяжелять инициализатор и не передавать туда под десяток входных параметров, мы можем создать структуру ViewState внутри самого Subview, и там уже описать все необходимые параметры для функционирования этого Subview.
Пример реализации в ImageSliderAssembly:
struct ImageSliderAssembly: View {
// MARK: — Nested Types
struct ViewState {
var currentStep: Int
let loadingState: LoadingState
let position: ProductCard.ViewState.Position
let initialOffset: CGFloat
let isImageSliderVertical: Bool
let productImages: [NetworkImage]
}
Логичнее и лаконичнее было бы назвать его просто State: но тогда есть проблема засечки с неймингом модификатора @State, поэтому пришли к названию ViewState.
Вот так это инициализируется внутри View:
private var slider: some View {
ImageSliderAssembly(
state: $state.imageSliderAssembly,
onAction: { reducer(.onImageSliderAction($0)) }
)
}
Всего одна строчка и никакой «портянки» параметров.
Так это собирается в единую переменную imageSliderAssembly внутри State:
var imageSliderAssembly: ImageSliderAssembly.ViewState {
get {
.init(
currentStep: currentSlidingStep,
loadingState: loadingState,
position: position,
initialOffset: PublicConstant.initialOffset,
isImageSliderVertical: isImageSliderVertical,
productImages: productImages
)
}
set {
currentSlidingStep = newValue.currentStep
}
}
Здесь же видим частный случай, когда в вычисляемой переменной необходимо реализовать Binding. Всё достаточно понятно и лаконично.
Передача замыканий в Subview для отработки нажатия кнопок и прочей функциональности
Как и в случае с входными параметрами, Subview может принимать много замыканий (action), и передавать это обилие в инициализаторе неудобно. Для этого у нас каждая Subview может иметь свой личный Action.
Рассмотрим на примере того же ImageSliderAssembly:
struct ImageSliderAssembly: View {
…
enum Action {
case onTapSlider(Int)
case onTapReview(Int)
case onSaveSlidingStep(Int)
}
// MARK: — Properties
@Binding var state: ViewState
let onAction: (Action) -> Void
Внутри ImageSliderAssembly есть три различных action, которые объединены в один общий Action. Также есть одно-единственное замыкание onAction, которое приходит из инициализатора (здесь не прописано, так как структура автоматически под капотом создаёт инициализатор) и обрабатывает все возможные действия внутри этого Subview.
Как это обрабатывается внутри Subview:
private var assembly: some View {
ZStack(alignment: .bottom) {
VStack(spacing: 0) {
ImageSlider(
currentStep: $state.currentStep,
media: state.productImages,
onTapSlider: { onAction(.onTapSlider($0)) },
onSaveSlidingStep: { onAction(.onSaveSlidingStep($0)) }
)
.frame(height: sliderFrameHeight)
if isNeedBottomSpacer { Spacer() }
}
SliderPageControl(totalSteps: state.productImages.count, currentStep: state.currentStep)
}
}
Просто вызывается замыкание onAction, в которое передаётся один из кейсов внутреннего enum Action.
Как инициализируется ImageSliderAssembly внутри View:
private var slider: some View {
ImageSliderAssembly(
state: $state.imageSliderAssembly,
onAction: { reducer(.onImageSliderAction($0)) }
)
}
Общий Action, который живёт внутри ViewModel, содержит case onImageSliderAction, который в связанный параметр принимает ImageSliderAssembly.Action — enum внутри Subview. При инициализации замыкания onAction просто отдаём в Reducer нужный кейс.
Реализация внутри ViewModel:
enum Action {
case viewDidLoad
case dismiss
case updatePosition(CGFloat)
case setPriceCellVisible(Bool)
case saveLastSlidingStep(Int)
case setBottomSafeAreaInset(CGFloat)
case onNavigationHeaderAction(NavigationHeader.Action)
case onSideButtonsAction(SideButtons.Action)
case onImageSliderAction(ImageSliderAssembly.Action)
case onFooterButtonsAction(FooterButtons.Action)
case onPriceCellAction(PriceCell.Action)
}
Как это ViewModel обрабатывает в коде:
func handle(_ action: Action) async {
switch action {
case .viewDidLoad:
await viewDidLoad()
case .dismiss:
await dismiss()
case let .updatePosition(transition):
await updatePosition(for: transition)
case let .setPriceCellVisible(isPriceCellVisible):
await setPriceCellVisible(isPriceCellVisible)
case let .saveLastSlidingStep(step):
await saveSlidingStep(step)
case let .setBottomSafeAreaInset(inset):
await setBottomSafeAreaInset(inset)
case let .onNavigationHeaderAction(action):
await handleNavigationHeader(action: action)
case let .onSideButtonsAction(action):
await handleSideButtons(action: action)
case let .onImageSliderAction(action):
await handleImageSlider(action: action)
case let .onFooterButtonsAction(action):
await handleFooterButtons(action: action)
case let .onPriceCellAction(action):
await handlePriceCell(action: action)
}
}
Внутри публичного метода handle(...) вызывается приватный метод handleImageSlider( action: ImageSliderAssembly.Action), принимающий внутренний Action из Subview. Здесь обработка события реализована аналогично:
private func handleImageSlider(action: ImageSliderAssembly.Action) async {
switch action {
case let .onTapSlider(index):
print(index)
case let .onTapReview(index):
print(index)
case let .onSaveSlidingStep(step):
await saveSlidingStep(step)
}
}
Граничный случай: про большой View и передачу в него State и Reducer.
List с множеством различных ячеек
Последний пример оптимизации кода — это ситуация, когда имеется некий Subview с большим количеством своих Subview. Например, List, у которого множество различных ячеек, неудобно передавать в init кучу различных параметров, чтобы он передал их в ячейки. По большей части ей нужны практически все переменные из State и бОльшая часть кейсов из Action. Как же быть в этом случае? А почему бы не отдать в List полностью весь State и Reducer, а уже внутри List вычленить нужные данные для каждой ячейки?
Максимально лаконичный инициализатор внутри основной View:
private var content: some View {
ProductCardList(state: state, reducer: reducer)
.offset(y: state.offset)
}
Реализация внутри Subview:
struct ProductCardList<S, R>: View where S: ProductCard.ViewState, R: Reducer<ProductCard.ViewModel> {
@ObservedObject var state: S
let reducer: R
Инициализатор здесь не прописан — генерируется структурой автоматически.
Ну и генерация ячеек в зависимости от индекса, аналогично cellForItem в UIKit:
@ViewBuilder
private func getCell(for index: Int) -> some View {
if index == 0 {
PriceCell(
state: state.priceCell,
onAction: { reducer(.onPriceCellAction($0)) }
)
} else if index % 2 == 0 {
ProductCardCell(index: index)
} else {
CellDivider()
}
}
В этом примере ProductCardCell, по сути, просто образец ячейки, чтобы показать, что их может быть много, но не усложнять само тестовое приложение.
Пример использования с UIKit
Наша архитектура может использоваться совместно со SwiftUI и UIKit. Разница только в механизме отслеживания изменений состояний State. И там, и там используется реактивный подход.
Реализация для SwiftUI стандартна и понятна, ниже привожу пример для UIKit, где отслеживается статус загрузки данных для экрана и обновляется UI:
private func bindState() {
state
.$inputState
.receive(on: RunLoop.main)
.sink { [weak self] inputState in
switch inputState {
case .loading:
self?.showSkeletonLoader(self?.state.currentType)
self?.hideErrorView()
case let .reloadButtonTitle(buttonInfo):
self?.setBottomButtonTitle(response: buttonInfo)
case .reloadDataSource:
self?.tableManager.reloadTable()
case .error:
self?.showErrorView()
}
}
.store(in: &bag)
}
Заключение
Я описал, как можно доработать под свои нужды стандартную архитектуру MVVM и оптимизировать взаимодействие компонентов. Надеюсь, мой опус был понятен и полезен :) Спасибо за внимание, и удачи в работе!
Для более глубоко погружения в проект оставляю ссылку на гитхаб.

aerlinn13
Спасибо за статью. Вы случайно не имели опыта работы с ReSwift?