Разработка
Шаблоны проектирования для SwiftUI
Давайте рассмотрим некоторые паттерны, которые можно применить в SwiftUI.
Шаблоны проектирования путают умы людей, работающих со SwiftUI. Они задаются такими вопросами, как «Какой паттерн больше подходит для SwiftUI?» или «Нужно ли вообще использовать шаблон?». Apple также предоставила некоторую информацию об использовании паттернов для SwiftUI на WWDC. Давайте рассмотрим некоторые паттерны, которые можно применить в SwiftUI.
1. MVVM (Model-View-ViewModel)
В MVVM нам нужно обратить внимание на то, чтобы правильно применить логику наблюдаемости, так как в шаблоне есть другой слой представления по сравнению с проектом Swift. Пример класса представления вы можете увидеть ниже.
struct SearchView: View {
@ObservedObject var viewModel: SearchViewModel
var body: some View {
NavigationView {
VStack {
SearchBarView(text: self.$viewModel.searchTerm, onSearchTermChanged: self.viewModel.onSearchTermChanged)
}
}
}
}
Экземпляр SearchViewModel определен как ObservedObject
, поэтому при изменении модели представления UI будет перезагружен, и мы сможем увидеть соответствующие изменения. Еще одним важным моментом здесь является то, что класс ViewModel соответствует протоколу ObservableObject
.
xxxxxxxxxx
final class SearchViewModel: ObservableObject { }
Мы можем использовать несколько специальных макросов для переменных класса ViewModel, к которым мы хотим применить изменения. Одним из них является макрос @Published
. Мы определили переменную results
в классе SearchViewModel как Published
. Каждый раз, когда переменная results
будет обновляться, соответствующее представление будет перезагружаться.
2. Координатор
Координатор выделяется тем, что предлагает более упрощенную структуру навигационных переходов между представлениями. Другими словами, он позволяет нам взять навигационные операции из представления и использовать их в более управляемой и многократно используемой структуре. Возможно, вы уже слышали о MVVM+C, поэтому мы можем использовать координатор для навигационных операций с MVVM.
Допустим, в проекте есть представление login. Давайте создадим структуру Output
, которая позволит нам перенести навигационные операции из слоя представления. Для этого примера предположим, что из LoginView мы можем перенаправлять на главный экран и экран ForgotPasswordView, поэтому мы создали два метода внутри структуры Output
.
xxxxxxxxxx
struct LoginView: View {
struct Output {
var goToMainScreen: () -> Void
var goToForgotPassword: () -> Void
}
var output: Output
}
Мы можем осуществлять переход к соответствующим экранам через экземпляр output
, как в примере в body ниже.
xxxxxxxxxx
var body: some View {
Button(
action: {
self.output.goToMainScreen()
},
label: {
Text("Login")
}
).padding()
Button(
action: {
self.output.goToForgotPassword()
},
label: {
Text("Forgot password")
}
)
}
Итак, теперь давайте рассмотрим, как мы можем инициировать экземпляр output
в классе координатора.
Предположим, что у нас есть класс координатора для каждого модуля и главный класс координатора, из которого мы можем управлять всеми классами координаторов. Для этого примера создадим класс AuthenticationCoordinator
для управления операциями навигации в модуле login и класс AppCoordinator для управления всеми координаторами.
Создадим наблюдаемый экземпляр AppCoordinator в главной структуре приложения. Выполним маршрутизацию через NavigationStack и определим путь NavigationStack как appCoordinator.path
, а rootView NavigationStack как appCoordinator.view()
.
xxxxxxxxxx
@main
struct SwiftUI_CApp: App {
@StateObject private var appCoordinator = AppCoordinator(path: NavigationPath())
var body: some Scene {
WindowGroup {
NavigationStack(path: $appCoordinator.path) {
appCoordinator.view()
.navigationDestination(
for: AuthenticationCoordinator.self
) { coordinator in
coordinator.view()
}
}
.environmentObject(appCoordinator)
}
}
}
Для .navigationDestination
, если он имеет тип AuthenticationCoordinator
, мы вызываем метод view()
соответствующего координатора.
Теперь давайте посмотрим на rootView из NavigationStack, который является методом view()
координатора AppCoordinator.
xxxxxxxxxx
final class AppCoordinator: ObservableObject {
@Published var path: NavigationPath
init(path: NavigationPath) {
self.path = path
}
@ViewBuilder
func view() -> some View {
MainView()
}
}
xxxxxxxxxx
struct MainView: View {
@EnvironmentObject var appCoordinator: AppCoordinator
var body: some View {
Group {
AuthenticationCoordinator(
page: .login,
navigationPath: $appCoordinator.path,
output: .init(
goToMainScreen: {
print("Go to main screen")
}
)
).view()
}
}
}
Мы инициализировали MainView в view
методе AppCoordinator. В MainView мы перешли к AuthenticationCoordinator
.
В класс AuthenticationCoordinator
мы инжектировали страницу, на которую хотим перейти, navigationPath
и output
. В классе координатора у нас также есть структура Output
, потому что у него также есть вывод, например, переход на главный экран после завершения операций входа в систему.
xxxxxxxxxx
enum AuthenticationPage {
case login, forgotPassword
}
final class AuthenticationCoordinator: Hashable {
@Binding var navigationPath: NavigationPath
private var output: Output?
private var page: AuthenticationPage
struct Output {
var goToMainScreen: () -> Void
}
init(
page: AuthenticationPage,
navigationPath: Binding<NavigationPath>,
output: Output? = nil
) {
self.page = page
self.output = output
self._navigationPath = navigationPath
}
}
После создания AuthenticationCoordinator
в MainView мы вызвали метод view()
, который я напишу ниже. С помощью этого метода мы создаем экземпляр соответствующего экрана (login или forgotPassword). Таким образом, мы инициируем соответствующее представление.
xxxxxxxxxx
@ViewBuilder
func view() -> some View {
switch self.page {
case .login:
loginView()
case .forgotPassword:
forgotPasswordView()
}
}
private func loginView() -> some View {
let loginView = LoginView(output: .init(goToMainScreen: { self.output?.goToMainScreen()},
goToForgotPassword: { self.push(AuthenticationCoordinator(page: .forgotPassword, navigationPath: self.$navigationPath)) }))
return loginView
}
private func forgotPasswordView() -> some View {
let forgotPasswordView = ForgotPasswordView(output: .init(goToForgotPasswordWebsite: { self.goToForgotPasswordWebsite() }))
return forgotPasswordView
}
3. Clean Swift (VIP)
В Clean Swift мы выполняем все операции обновления представления с помощью наблюдения. Мы можем начать применять шаблон, создав шаблон VIP. В этом примере есть 4 различных слоя (Worker-Presenter-Interactor-View). В ContentView мы вызываем метод builder
, который позволяет нам инициализировать экземпляры слоев.
xxxxxxxxxx
struct ContentView: View {
var body: some View {
CreateChocolateBuilder.build()
}
}
Мы определяем переменную interactor
в слое представления, чтобы запустить use-cases. Также мы инициализируем наблюдаемую displayModel
, чтобы быть в курсе обновлений в viewModel
и применять их к представлению.
xxxxxxxxxx
struct CreateChocolateView: View {
var interactor: CreateChocolateBusinessLogic?
@StateObject var chocolate = ChocolateDisplayModel()
}
Мы вызвали метод interactor.loadChocolate
в .onAppear
, так что VIP-цикл начался.
xxxxxxxxxx
var body: some View {
NavigationView {
Form {
}
.navigationTitle("Chocolate")
.onAppear {
interactor?.loadChocolate(request: CreateChocolateModels.LoadChocolate.Request())
}
}
.navigationViewStyle(.stack)
}
Давайте посмотрим на класс interactor
. Вместо реального вызова API мы извлекаем данные из JSON, создаем ответ и форвардим его презентеру.
xxxxxxxxxx
protocol CreateChocolateBusinessLogic {
func loadChocolate(request: CreateChocolateModels.LoadChocolate.Request)
}
final class CreateChocolateInteractor: CreateChocolateBusinessLogic{
// MARK: Private Properties
private let worker: CreateChocolateWorker
private let presenter: CreateChocolatePresentationLogic
// MARK: Initializers
init(presenter: CreateChocolatePresentationLogic, worker: CreateChocolateWorker) {
self.presenter = presenter
self.worker = worker
}
// MARK: Business Logic
func loadChocolate(request: CreateChocolateModels.LoadChocolate.Request) {
let chocolate = Bundle.main.decode(Chocolate.self, from: "chocolate.json")
let response = CreateChocolateModels.LoadChocolate.Response(chocolateData: chocolate)
presenter.presentChocolate(response: response)
}
}
В презентере мы создаем viewModel
с соответствующей ей моделью displayModel
и отправляем ее на слой представления через метод показа.
xxxxxxxxxx
protocol CreateChocolatePresentationLogic {
func presentChocolate(response: CreateChocolateModels.LoadChocolate.Response)
}
final class CreateChocolatePresenter: CreateChocolatePresentationLogic {
// MARK: Public Properties
var view: CreateChocolateDisplayLogic?
// MARK: Presentation Logic
func presentChocolate(response: CreateChocolateModels.LoadChocolate.Response) {
let displayModel = ChocolateDisplayModel(displayedChocolate: response.chocolateData.godiva)
let viewModel = CreateChocolateModels.LoadChocolate.ViewModel(displayModel: displayModel)
view?.displayChocolate(viewModel: viewModel)
}
}
Мы обновляем обозреваемую переменную экземпляра displayModel
в методе display, чтобы соответствующее представление было обновлено новыми данными.
xxxxxxxxxx
protocol CreateChocolateDisplayLogic {
func displayChocolate(viewModel: CreateChocolateModels.LoadChocolate.ViewModel)
}
extension CreateChocolateView: CreateChocolateDisplayLogic {
func displayChocolate(viewModel: CreateChocolateModels.LoadChocolate.ViewModel) {
chocolate.displayedChocolate = viewModel.displayModel.displayedChocolate
}
}
4. MV (Model-View)
Этот паттерн, который Apple представила на конференции WWDC, основан на логике «View is the ViewModel». Все бизнес-операции мы выполняем в классе модели. Пример вы можете увидеть ниже.
xxxxxxxxxx
@MainActor
class Model: ObservableObject {
@Published var orders: [Order] = []
let orderService: OrderService
init(orderService: OrderService) {
self.orderService = orderService
}
func sortOrders() {
// sort orders
}
func filterOrders() {
// filter orders
}
}
В классе представления выполняются все операции над представлением и, кроме того, все операции над ViewModel.
xxxxxxxxxx
struct AddNewCoffeeOrderView: View {
@State private var name: String = ""
@State private var coffeeName: String = ""
@State private var total: String = ""
@State private var errors: AddNewCoffeeOrderError = AddNewCoffeeOrderError()
let order: Order?
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var model: Model
init(order: Order? = nil) {
self.order = order
}
private func placeOrder(_ order: Order) async {
try? await model.placeOrder(order)
dismiss()
}
private func updateOrder(_ order: Order) async {
try? await model.updateOrder(order)
dismiss()
}
private func updateOrPlaceOrder() async {
if let order {
// update the order
let order = Order(id: order.id)
await updateOrder(order)
} else {
// place the order
let order = Order(id: 1)
await placeOrder(order)
}
}
var isFormValid: Bool {
if name.isEmpty {
errors.name = "Name cannot be empty!"
}
if coffeeName.isEmpty {
errors.coffeeName = "Coffee name cannot be empty"
}
return errors.name.isEmpty && errors.coffeeName.isEmpty && errors.total.isEmpty
}
var body: some View {
NavigationStack {
Form {
TextField("Name", text: $name)
!errors.name.isEmpty ? Text(errors.name) : nil
TextField("Coffee name", text: $coffeeName)
!errors.coffeeName.isEmpty ? Text(errors.coffeeName) : nil
TextField("Total", text: $total)
!errors.total.isEmpty ? Text(errors.total) : nil
}
}
}
}
Вот и все. Не забудьте поделиться с коллегами статьей.
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.11
-
Новости1 неделя назад
Видео и подкасты о мобильной разработке 2025.14
-
Видео и подкасты для разработчиков3 недели назад
Javascript для бэкенда – отличная идея: Node.js, NPM, Typescript
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.12