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

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

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

Комментарии (4)


  1. aerlinn13
    03.06.2025 19:32

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


  1. Zmey_666 Автор
    03.06.2025 19:32

    Нет, не сталкивался


  1. askl
    03.06.2025 19:32

    Интересная реализация архитектуры. А есть какой-то пример между модульного взаимодействия?


    1. Zmey_666 Автор
      03.06.2025 19:32

      Здесь не стал усложнять. В целом в проекте куча сервисов и взаимодействие через них. В output передаётся замыкание и всё. Что-то похожее, как на скрине