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