
Всем привет, меня зовут Дмитрий Лоренц, я 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 и оптимизировать взаимодействие компонентов. Надеюсь, мой опус был понятен и полезен :) Спасибо за внимание, и удачи в работе!
Для более глубоко погружения в проект оставляю ссылку на гитхаб.
