Творческая переработка MVVM и TCA на примере iOS

от автора

Какая лучше???

Какая лучше???

Всем привет, меня зовут Дмитрий Лоренц, я iOS-разработчик в IT-компании GRI. Наш основной клиент — Sunlight, для него мы разрабатываем нескольких мобильных приложений по полному циклу и поддерживаем сайт.

В этой статье я расскажу про нашу новую архитектуру для iOS-приложения и поделюсь некоторыми советами, как упростить себе жизнь и сделать код более лаконичным и читаемым.

За основу мы взяли архитектуру MVVM (ModelViewViewModel), в которой 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 и оптимизировать взаимодействие компонентов. Надеюсь, мой опус был понятен и полезен 🙂 Спасибо за внимание, и удачи в работе!

Для более глубоко погружения в проект оставляю ссылку на гитхаб.


ссылка на оригинал статьи https://habr.com/ru/articles/913610/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *