Simple software design can only be achieved by really fully understanding the problem
First you have to really understand what’s going on, in all its complexities, and then come up with a solution so simple that – in hindsight – it is the obvious way to do it.
Don't over-engineer your code
Don't fall into the tutorial trap
Don't just copy and paste code without understanding it
Don't blindly follow someone else's strategy
Avoid sacrificing software design for testability
Consider mocking only as a last resort
Be careful, in an effort to make code more testable, we can very often find ourselves introducing a ton of new protocols and other kinds of abstractions, and end up making our code significantly more complicated.
There's an object for that!
Identify Objects
Identify Tasks
Identify Dependencies
Don’t fight the system
Don’t literally start with a protocol
Avoid unnecessary internal & external dependencies
Avoid too many layers of abstraction or complexity
Use nested type for supporting or to hide complexity
Post
Replies
Boosts
Views
Activity
We are living in Object Oriented Programming (OOP) world!
Don't do Clean / SOLID anti-patterns, be an OOP engineer
In the agile world use Feature Driven Development (FDD)
Avoid as you can Test Driven Development (TDD) / Extreme Programming (EP)
Yes, Swift is POP (the OOP where you can use structs as objects and protocols for generalization).
In app, you store and process data by using a data model that is separate from its UI
Adopt the ObservableObject protocol for model classes
Use ObservableObject where you need to manage the life cycle of your data
Typically, the ObservableObject is part of your model
StoreKit 2 is a great example how to create your model using Active Record and Factory Method patterns.
StoreKit 2
Remember:
SwiftUI automatically performs most of the work traditionally done by view controllers (view models too).
Declarative UI does not require MVVM. We are now in an era where declarative UI is commonplace in iOS development.
The other things you can do with property wrappers, modifiers and custom views.
Everyday I see bad developers, normally coming from other platforms, ignoring the obvious.
In the field of React/Vue/Flutter, which also uses declarative UI, the architecture of MVVM is not adopted, and it seems strange to adopt MVVM only in SwiftUI.
Part I
Be careful, in an effort to make code more testable, we can very often find ourselves introducing a ton of new protocols and other kinds of abstractions, and end up making our code significantly more complicated. Avoid sacrificing software design for testability and consider mocking only as a last resort.
Model (Data / Networking / Algorithms) objects represent special knowledge and expertise. They hold an application’s data and define the logic that manipulates that data.
The model layer is not simple structs (POJOs). A model is a system of objects (structs / classes) with properties, methods and relationships. For simple apps we can define one model, for complex apps we can have many models as needed (and makes sense).
Popular Model design patterns: Active Record, Data Mapper and Factory Method are the key' to avoid massive ViewControllers / ViewModels (and unnecessary layers).
See “The Features of the Main Data Access Patterns Applied in Software Industry” by Marcelo Rodrigues de Jesus
Model is everything your app (or part / system) can do, in a pure OOP. View is just an interaction layer and handle presentation logic.
See WWDC2010 Session - MVC Essential Design Pattern for Flexible Software
See StoreKit 2 documentation for an example of the model of In-App Purchases.
See MusicKit documentation for an example of the model of Music Catalog.
Normally Apple split frameworks, but not exclusive, into (e.g.):
Contacts (non-UI, model layer)
ContactsUI (UI, view layer)
Remember not all your code need tests, people are over-testing today. In many cases if you design the software applying the best patterns you will never need a test at all. Tests, pull-requests, ... are talked today because people don't plan & design software, people create many dependencies & layers making isolations / unchanged code difficult.
Think your software, just focus on your model(s) and your objects!
About the tests:
Unit Tests (should used where there's non other objects / systems integration, test your model)
Integration Tests (should used where there's other objects / systems integration, test your model)
UI Tests (test your UI and presentation logic)
But an ugly guy said: "Ahhh you should do Unit Tests for integrations integrations, use mocks for them" and boom! This guy killed the software design. Wrong!! First you should avoid this and second there's many ways to mock without complicate or sacrifice the software design. Default rule: Use Unit Tests only when you need to test a calculation, algorithm, ... not when you do a request to web service. Don’t fall in unit test troll!
See “Unit Testing is Overrated” by Oleksii Holub
“Unit Tests” project in Xcode is Unit Tests and Integration Tests.
Part II
This is the reason for Massive View Controllers. When you define the model as POJOs (simple structs) you end with many logic inside the ViewControllers / ViewModels. VC and VM should only handle presentation logic. In many cases impossible to test non-ui. Also theres a bad dependencies on you non-ui code. To avoid this you end with unnecessary layers. And… you have to make change for multiplatform views.
But if you go OOP & software design patterns, everything will be perfect. Also, as you see, every object become independent because “Service Objects” just gives the access / door, don’t know what the other objects want. Whenever possible, each object should be concrete and independent. If we add / remove / move a object, the others should keeping working and remaining unchanged. Your model is ready for every platform views, terminal, tests, …
NOTE: Imagine the CLLocationManager using await / async, no delegates. And yes you can do an locations manager if need.
Hope new frameworks this WWDC from ground for Swift and Swift Concurrency.
CLLocation and CLLocationManager just become Location:
try await Location.current (gives current user location)
for await location in Location.updates (gives every locations changes, async sequence)
Part III
Shopping App
The Model Layer
KISS - Simple design become more easy to do, more easy to maintain / scale, more easy to find & fix bugs, more easy to test.
You can use in UIKit, SwiftUI, iOS, tvOS, macOS, Terminal, Unit Tests, …hey! You can make a Swift Package and share with other apps. It works like magic!
In a big app you can have more models “Workout model” + Nutrition model”, “LiveTV model” + “VideoOnDemand model” + “Network & Subscription model”, …
You can use the “TEST” (like DEBUG) flag for mocks, in WebService provider level but also in your factory methods. Also you can extend the provider and override the request method… there are 1001 ways to mock… but remember don’t sacrifice the simplicity for testing.
The View Layer
A view has a body and handle presentation logic.
SwiftUI automatically performs most of the work traditionally done by view controllers (and view models), but what about the other work like fetch states or pagination? SwiftUI is very good at composable / components and there are modifiers and property wrappers, just use your imagination and the power of the SwiftUI… yes you don’t need extra layers, in last resort make utility objects (e.g. FetchableObject) in your “Shared” folder. With Swift Concurrency everything becomes easy to use and to reuse.
@AsyncState private var products: [Product] = []
List { … }
.asyncState(…)
Example of fetch state property wrapper + modifier.
import SwiftUI
public enum AsyncStatePhase {
case initial
case loading
case empty
case success(Date)
case failure(Error)
public var isLoading: Bool {
if case .loading = self {
return true
}
return false
}
public var lastUpdated: Date? {
if case let .success(d) = self {
return d
}
return nil
}
public var error: Error? {
if case let .failure(e) = self {
return e
}
return nil
}
}
extension View {
@ViewBuilder
public func asyncState<InitialContent: View,
LoadingContent: View,
EmptyContent: View,
FailureContent: View>(_ phase: AsyncStatePhase,
initialContent: InitialContent,
loadingContent: LoadingContent,
emptyContent: EmptyContent,
failureContent: FailureContent) -> some View {
switch phase {
case .initial:
initialContent
case .loading:
loadingContent
case .empty:
emptyContent
case .success:
self
case .failure:
failureContent
}
}
}
@propertyWrapper
public struct AsyncState<Value: Codable>: DynamicProperty {
@State public var phase: AsyncStatePhase = .initial
@State private var value: Value
public var wrappedValue: Value {
get { value }
nonmutating set {
value = newValue
}
}
public var isEmpty: Bool {
if (value as AnyObject) is NSNull {
return true
} else if let val = value as? Array<Any>, val.isEmpty {
return true
} else {
return false
}
}
public init(wrappedValue value: Value) {
self._value = State(initialValue: value)
}
@State private var retryTask: (() async throws -> Value)? = nil
public func fetch(expiration: TimeInterval = 120, task: @escaping () async throws -> Value) async {
self.retryTask = nil
if !(phase.lastUpdated?.hasExpired(in: expiration) ?? true) {
return
}
Task {
do {
phase = .loading
value = try await task()
if isEmpty {
self.retryTask = task
phase = .empty
} else {
phase = .success(Date())
}
} catch _ as CancellationError {
// Keep current state (loading)
} catch {
self.retryTask = task
phase = .failure(error)
}
}
}
public func retry() async {
guard let task = retryTask else { return }
await fetch(task: task)
}
public func hasExpired(in interval: TimeInterval) -> Bool {
phase.lastUpdated?.hasExpired(in: interval) ?? true
}
public func invalidate() {
if case .success = phase {
phase = .success(.distantPast)
}
}
}
extension View {
@ViewBuilder
public func asyncState<T: Codable,
InitialContent: View,
LoadingContent: View,
EmptyContent: View,
FailureContent: View>(_ state: AsyncState<T>,
initialContent: InitialContent,
loadingContent: LoadingContent,
emptyContent: EmptyContent,
failureContent: FailureContent) -> some View {
asyncState(state.phase,
initialContent: initialContent,
loadingContent: loadingContent,
emptyContent: emptyContent,
failureContent: failureContent)
}
}
Hope popular needs / solutions implemented in SwiftUI out of the box.
I understand your concerns but in case of SwiftUI (declarative) the “MVVM” (original from 2005) is not the correct approach. Maybe the problem is people calling ViewModel and MVVM.
Let me explain, in MVVM almost every View most have a ViewModel, basically you have a middle layer (data binding). In SwiftUI (declarative) you don’t need that, in SwiftUI you can use the concept of “Store” to do the odd jobs. In fact this is a “model type” but we should avoid call ViewModel (and MVVM) because the conflict.
This is MVVM:
HomeView - HomeViewModel
ProductListView - ProductListViewModel
ProductDetailView - ProductDetailViewModel
ProfileView - ProfileViewModel
This is SwiftUI:
CurrentProfile (or in generic Current<Profile>)
ProductStore (or in generic Store<Product>)
AppState / NavigationState
HomeView
ProductListView
ProductDetailView
ProfileView
Focus on the model (data + business logic)
Create views that reflect the model
If needed use a model type (e.g. Store) in your view layer to do odd jobs
But why I’m complain about the use of MVVM for SwiftUI? Because I worked for Microsoft platforms (community leader) and evangelist the MVVM from 2005.
SwiftUI is a modern UI framework and in the declarative world MVVM (original) feels unnecessary and very limited. The problem is people understand what MVVM architecture is in fact. Maybe they are just calling every non-view objects (e.g. store, state, current, …) ViewModels but this is not MVVM.
Another suggestion.
In many cases we don’t use the NavigationLink because we have a custom UI and can’t change de pressed style. Will be great to have a “NavigationLinkStyle” (with isPressed) or a NavigationButton or data-drive push capabilities for the Button.
In fact Button is used for push all the time.
MVVM vs SwiftUI
Mail app
App Store app
Shopping app
State and Data Flow
Manage Model Data in Your App
WWDC09-204
WWDC09-226
WWDC20-10040
WWDC21-10022
WWDC22-10054
Another suggestion.
Example from docs:
NavigationStack {
List(parks) { park in
NavigationLink(park.name, value: park)
}
.navigationDestination(for: Park.self) { park in
ParkDetails(park: park)
}
}
In this case, we are duplicating the “park” data because that park is already in the stack. The navigation stack contains the park and we are making an unnecessary copy for ParkDetails view.
Why not to use the @Environment to get the pushed value (in this case the park) on ParkDetail? Every pushed View most have the corresponding value available in the @Environment.
Before:
struct ParkDetail: View {
let park: Park
}
After:
struct ParkDetail: View {
@Environment(\.pushedData) var park: Park
}
Also we can do the same for other cases like modals:
struct ParkPreview: View {
@Environment(\.presentedData) var park: Park
}
Sorry, the examples are not testable? Not clean?
Today developers are obsessed with tests and other ugly thinks. I see projects delayed with lots problems because devs today want to be SOLID and testable.
Agile, SOLID and over testing works like a cancer for development teams.
Sorry but if people want to be complicated they should move to Android or other platforms.