Swift Architecture Best Practices
On iOS applications, architecture
Dicusses WWDC talks which are noted here.
The making of Ice Cubes, an open source, SwiftUI Mastodon client.
Organzation
Use a lot of self-contained swift packages.
The packages are split by domains and features. There is very little code in the app itself; everything is self-contained in its own package.
Architecture
MVVM
The main view hold the viewModel using a
@StateObject
and the viewModel is passed as an@ObservedObject
or a simple let variable in the subviews where it’s needed.
EnvironmentObject
Heavy use of environment object (also covered by WWDC talks on dataflow)
WindowGroup {
appView
.applyTheme(theme)
.environmentObject(appAccountsManager)
.environmentObject(appAccountsManager.currentClient)
.environmentObject(quickLook)
.environmentObject(currentAccount)
.environmentObject(currentInstance)
.environmentObject(userPreferences)
.environmentObject(theme)
.environmentObject(watcher)
.environmentObject(pushNotificationsService)
}
There is no downside to injecting any number of them. It’s all about which view is connected to which environment object and how many updates you’re making in them.
🚨
You have to be aware including @EnvironmentObject at the top of your view don’t have any performance cost by itself. But updating any @Published property within this environment object will trigger a view update if the view is connected to it. Even if you’re not directly observing or using this property within your view. So be conscious about that.
Navigation
Central app router with NavigationStack
. Agree on RouterDestinations
and SheetDestinations
to avoid spontaneous transitions or segues.
public enum RouterDestinations: Hashable {
case accountDetail(id: String)
case accountDetailWithAccount(account: Account)
case accountSettingsWithAccount(account: Account, appAccount: AppAccount)
case statusDetail(id: String)
case statusDetailWithStatus(status: Status)
case remoteStatusDetail(url: URL)
case conversationDetail(conversation: Conversation)
case hashTag(tag: String, account: String?)
case list(list: Models.List)
case followers(id: String)
case following(id: String)
case favoritedBy(id: String)
case rebloggedBy(id: String)
case accountsList(accounts: [Account])
}
public enum SheetDestinations: Identifiable {
case newStatusEditor(visibility: Models.Visibility)
case editStatusEditor(status: Status)
case replyToStatusEditor(status: Status)
case quoteStatusEditor(status: Status)
case mentionStatusEditor(account: Account, visibility: Models.Visibility)
case listEdit(list: Models.List)
case listAddAccount(account: Account)
case addAccount
case addRemoteLocalTimeline
case statusEditHistory(status: String)
case settings
case accountPushNotficationsSettings
case report(status: Status)
case shareImage(image: UIImage, status: Status)
}
Have an ObervableObject
that handles navigation.
@MainActor
public class RouterPath: ObservableObject {
public var client: Client?
public var urlHandler: ((URL) -> OpenURLAction.Result)?
@Published public var path: [RouterDestinations] = []
@Published public var presentedSheet: SheetDestinations?
public init() {}
public func navigate(to: RouterDestinations) {
path.append(to)
}
}
Append and inject the navigation handler to any NavigationStack
. Bind the path to the Stacks’ path property.
struct NotificationsTab: View {
@StateObject private var routerPath = RouterPath()
var body: some View {
NavigationStack(path: $routerPath.path) {
NotificationsListView()
.withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
}
.environmentObject(routerPath)
}
Extend View
to evaluate Router- and SheetDestinations.
@MainActor
extension View {
func withAppRouter() -> some View {
navigationDestination(for: RouterDestinations.self) { destination in
switch destination {
case let .accountDetail(id):
AccountDetailView(accountId: id)
case let .accountDetailWithAccount(account):
AccountDetailView(account: account)
....
}
}
}
func withSheetDestinations(sheetDestinations: Binding<SheetDestinations?>) -> some View {
sheet(item: sheetDestinations) { destination in
switch destination {
case let .replyToStatusEditor(status):
StatusEditorView(mode: .replyTo(status: status))
.withEnvironments()
...
}
}
func withEnvironments() -> some View {
environmentObject(CurrentAccount.shared)
.environmentObject(UserPreferences.shared)
...
}
}
In the actual view navigate to views
@EnvironmentObject private var routerPath: RouterPath
...
AvatarView(url: notification.account.avatar)
.contentShape(Rectangle())
.onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: notification.account))
}
or present sheets
Button {
routerPath.presentedSheet = .addAccount
} label: {
Image(systemName: "person.badge.plus")
}
Introducing MVVM into your SwiftUI project
How to Use the Coordinator Pattern in SwiftUI
SOLID Principles in Swift
The Single-responsibility principle: “There should never be more than one reason for a class to change. In other words, every class should have only one responsibility”.
The Open–closed principle: “Software entities … should be open for extension, but closed for modification.”
The Liskov substitution principle: “Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.”
The Interface segregation principle: “Clients should not be forced to depend upon interfaces that they do not use.”
The Dependency inversion principle: “Depend upon abstractions, [not] concretions.”
Single-responsibility principle:
class NetworkManager {
var userAPIHandler: UserAPIHandler?
var parseDataHandler: ParseDataHandler?
var saveDataToDBHandler: SaveDataToDBHandler?
init(userAPIHandler: UserAPIHandler, parseDataHandler: ParseDataHandler, saveDataToDBHandler: SaveDataToDBHandler) {
self.userAPIHandler = userAPIHandler
self.parseDataHandler = parseDataHandler
self.saveDataToDBHandler = saveDataToDBHandler
}
func handleAllActions() {
guard let userAPIHandler else { return }
guard let parseDataHandler else { return }
guard let saveDataToDBHandler else { return }
let userData = userAPIHandler.getUsers()
let userArray = parseDataHandler.parseDataToJson(data: userData)
saveDataToDBHandler.saveDataToDB(users: userArray)
}
}
class UserAPIHandler {
func getUsers() -> Data {
//Send API request and wait for a response
}
}
class ParseDataHandler {
func parseDataToJson(data: Data) -> [String] {
// parse the data and convert it to array
}
}
class SaveDataToDBHandler {
func saveDataToDB(users: [String]) {
// save that array into CoreData...
}
}
**Open-close principle**
Extensions, protocols and proper inheritance.
**Liskov substitution principle**
> when we inherit from a base class, the subclass should not modify the behaviour of the base class functions
**Interface Segregations**
> users should not depend on interfaces or functionality they don’t need. It advises against providing overly complex or hard-to-understand interfaces.
- Objects only expose the methods they really implement.
```swift
protocol Flyable {
func fly()
}
protocol Swimmable {
func swim()
}
protocol Feedable {
func eat()
}
class Flamingo: Flyable, Swimmable, Feedable {
func eat() {
print("I can eat")
}
func fly() {
print("I can fly")
}
func swim() {
print("I can swim")
}
}
class Dogs: Feedable {
func eat() {
print("I can eat")
}
}
Dependency inversion principle
high-level modules should not depend on low-level modules. You should depend on interfaces, not implementations.