Don’t over-engineering! No suggested architecture for SwiftUI, just MVC without the C.
On SwiftUI you get extra (or wrong) work and complexity for no benefits. Don’t fight the system.
Don’t over-engineering! No suggested architecture for SwiftUI, just MVC without the C.
On SwiftUI you get extra (or wrong) work and complexity for no benefits. Don’t fight the system.
PART 1
I've been looking at learning more and implementing the Active Record
pattern, and had some more questions + some gotchas that I noticed.
If I understand the pattern correctly, Active Record is used for capturing global state via statics, and locals via functions. This state isn't tied to a specific view or workflow.
Product.all
call..task
modifier.@State
or @StateObject
after calling an update function.The problems I ran into mostly revolved around 3 above. Using the same example as before where we have files on a file system and we want to mark some of them as favourites:
struct File: Identifiable, Equatable, Hashable {
var id: UUID = UUID()
var name: String
var date: Date
var size: Int64
}
struct Folder: Identifiable {
var id: UUID = UUID()
var name: String
var files: [File]
}
If I use an observable object to hold all of the state, I end up with this:
class FilesStore: ObservableObject {
var all: [File] {
return folders.flatMap { $0.files }
}
@Published var favorites: Set<File> = []
@Published var folders: [Folder] = [
Folder(name: "Classes", files: [
File(name: "File 5.txt", date: Date(timeIntervalSinceNow: -300000), size: 8234567),
File(name: "File 6.txt", date: Date(timeIntervalSinceNow: -290000), size: 4890123),
File(name: "File 7.txt", date: Date(timeIntervalSinceNow: -280000), size: 11234567),
]),
Folder(name: "Notes", files: [])
]
func isFavorite(_ file: File) -> Bool {
return favorites.contains(file)
}
func toggleFavorite(_ file: File) {
if (favorites.contains(file)) {
favorites.remove(file)
} else {
favorites.insert(file)
}
}
}
If I use these @Published vars directly in the view, then everything just "works" because all updates via @Published vars are propagated directly so everything stays in sync.
Here are the corresponding views:
struct ContentView: View {
@StateObject var filesStore = FilesStore()
var body: some View {
NavigationView {
FolderListView()
FileListView(folderName: "All Files", files: filesStore.all)
}
.environmentObject(filesStore)
}
}
struct FolderListView: View {
@EnvironmentObject var filesStore: FilesStore
var body: some View {
let favorites = filesStore.favorites
List {
Section {
FolderListRow(folderName: "All Files", files: filesStore.all)
if (!favorites.isEmpty) {
FolderListRow(folderName: "Favorites", files: Array(favorites))
}
}
Section("My folders") {
ForEach(filesStore.folders) { folder in
FolderListRow(folderName: folder.name, files: folder.files)
}
}
}
.navigationTitle("Folders")
.listStyle(.insetGrouped)
}
}
struct FolderListRow: View {
let folderName: String
let files: [File]
var body: some View {
NavigationLink(destination: FileListView(folderName: folderName, files: files)) {
HStack {
Text(folderName)
Spacer()
Text(files.count.formatted())
.foregroundStyle(.secondary)
}
}
}
}
struct FileListView: View {
@EnvironmentObject var filesStore: FilesStore
let folderName: String
let files: [File]
var body: some View {
List(files) { file in
let isFavorite = filesStore.isFavorite(file)
VStack() {
HStack {
Text(file.name)
Spacer()
if isFavorite {
Image(systemName: "heart.fill")
.foregroundColor(.red)
.font(.caption2)
}
}
}
.swipeActions(edge: .leading) {
Button {
filesStore.toggleFavorite(file)
} label: {
Image(systemName: isFavorite ? "heart.slash" : "heart")
}
.tint(isFavorite ? .gray : .red)
}
}
.animation(.default, value: files)
.listStyle(.plain)
.navigationTitle(folderName)
}
}
With the Active Record pattern, I remove FilesStore and reorganized the code as follows:
// Stores
class FilesystemStore {
static var shared = FilesystemStore()
var folders: [Folder] = [
Folder(name: "Classes", files: [
File(name: "File 5.txt", date: Date(timeIntervalSinceNow: -300000), size: 8234567),
File(name: "File 6.txt", date: Date(timeIntervalSinceNow: -290000), size: 4890123),
File(name: "File 7.txt", date: Date(timeIntervalSinceNow: -280000), size: 11234567),
]),
Folder(name: "Notes", files: [])
]
}
class FavoritesStore {
static var shared = FavoritesStore()
var favorites: Set<File> = []
func isFavorite(_ file: File) -> Bool {
return favorites.contains(file)
}
func toggleFavorite(_ file: File) {
if (favorites.contains(file)) {
favorites.remove(file)
} else {
favorites.insert(file)
}
}
}
// Active record -- contents
extension Folder {
static var all: [Folder] {
return FilesystemStore.shared.folders
}
}
extension File {
static var all: [File] {
return Folder.all.flatMap { $0.files }
}
}
// Active record -- favorites
extension File {
static var favorites: Set<File> {
FavoritesStore.shared.favorites
}
static let favoriteUpdates = PassthroughSubject<Set<File>, Never>()
func isFavorite() -> Bool {
return FavoritesStore.shared.isFavorite(self)
}
func toggleFavorite() {
FavoritesStore.shared.toggleFavorite(self)
File.favoriteUpdates.send(File.favorites)
}
}
The problem I ran into with this is that the view is now reaching directly into the model to do things like toggle if a file is a favorite or not. Because those properties are being set directly, we now need a way to update the view state to reflect the change. I handled that by using Combine to publish updates (I'm sure it's possible with AsyncStream too, like StoreKit 2 is doing, but I didn't figure out how to do this).
Continued in part 2 below...
PART 2
The second problem is that now the view also won't update unless I'm sure to also add a reference to the @StateObject to that view, so it knows that it needs to update. For example, when using the Active Record pattern, I can call file.isFavorite()
instead of filesStore.isFavorite(file)
to know if a file is a favorite or not. Because I'm calling the method directly on the file instead of going through the StateObject, I can bypass the @Published var and thus if I'm not careful, I'll miss updates since SwiftUI no longer knows to update this view.
Here is the view code when using Active Record:
struct ContentView: View {
@StateObject var favoritesObject = MyFavoritesObject()
var body: some View {
NavigationView {
FolderListView()
FileListView(folderName: "All Files", files: File.all)
}
.environmentObject(favoritesObject)
}
}
struct FolderListView: View {
@EnvironmentObject var favoritesObject: MyFavoritesObject
var body: some View {
let favorites = favoritesObject.favorites
List {
Section {
FolderListRow(folderName: "All Files", files: File.all)
if (!favorites.isEmpty) {
FolderListRow(folderName: "Favorites", files: Array(favorites))
}
}
Section("My folders") {
ForEach(Folder.all) { folder in
FolderListRow(folderName: folder.name, files: folder.files)
}
}
}
.navigationTitle("Folders")
.listStyle(.insetGrouped)
}
}
struct FolderListRow: View {
let folderName: String
let files: [File]
var body: some View {
NavigationLink(destination: FileListView(folderName: folderName, files: files)) {
HStack {
Text(folderName)
Spacer()
Text(files.count.formatted())
.foregroundStyle(.secondary)
}
}
}
}
struct FileListView: View {
// Needed to get favorite updates
@EnvironmentObject var favoritesObject: MyFavoritesObject
let folderName: String
let files: [File]
var body: some View {
List(files) { file in
let isFavorite = file.isFavorite()
VStack() {
HStack {
Text(file.name)
Spacer()
if isFavorite {
Image(systemName: "heart.fill")
.foregroundColor(.red)
.font(.caption2)
}
}
}
.swipeActions(edge: .leading) {
Button {
file.toggleFavorite()
} label: {
Image(systemName: isFavorite ? "heart.slash" : "heart")
}
.tint(isFavorite ? .gray : .red)
}
}
.animation(.default, value: files)
.listStyle(.plain)
.navigationTitle(folderName)
}
}
So the differences are basically that we can call methods like File.all
or file.toggleFavorite()
instead of passing through FilesStore
. The problem is with the flow of data as mentioned above.
Even with the Combine mechanism, I also have to be sure that the view has a reference to the state object via @EnvironmentObject, otherwise it still won't update since it's reading file.isFavorite() directly.
Of course this example is somewhat contrived, and even when using the state objects, "real" production code would have to query the file system async and then update the published vars. The difference is that I can handle that internally and update the @Published vars as needed, while with Active Record, I need to be sure to have some mechanism to propagate those updates so that the view is still updated, with Combine or a similar mechanism.
Another alternative would be to call a load() method manually on the view or state object every time a mutating function is called, but that seems cumbersome.
I'm most likely missing something or not fully understanding the patterns, so hoping that you or someone else can illuminate this further. It just looks like from my POV, you still need an intermediate StateObject when using Active Record. I also find the code easier to understand and reason about when doing everything through a StateObject instead of also having functions and static collection vars.
This was a fun exercise to go through and I definitely learned something. Hoping to continue the discussions. :)
For an Application you can use active record for that. This works great for a lib, data access, data model.
struct File: Identifiable, Equatable, Hashable {
var id: UUID = UUID()
var name: String
var date: Date
var size: Int64
var isFavourite: Bool // change attribute or reference on set / didSet
func save()
func delete()
static var allFiles: [File] { … }
static var onlyFavouriteFiles: [File] { … }
}
struct Folder: Identifiable {
var id: UUID = UUID()
var name: String
var files: [File] // or computed property that fetch (on demand) files from this folder
static var folders: [Folder] { … }
}
But in SwiftUI (declarative view layer) you can also need a state(s). You can have a FileStore, FolderStore, FileManagement, … that is part of your model. Assuming that we use system FileManager and you load the items sync when needed.
class FileManagement: ObservableObject {
var folders: [Folder] = Folder.folders
func createFile(…) { … } // do changes, call objectWillChange.send()
func deleteFile(…) { … } // do changes, call objectWillChange.send()
func setFileAsFavourite(…) { … } // do changes, call objectWillChange.send()
}
struct MyApp: App {
@StateObject var fileManagement = FileManagement()
@State var selectedFolder: Folder? = nil
@State var selectedFile: File? = nil
var body: some Scene {
WindowGroup {
// Three-column
NavigationSplitView {
FolderList($selectedFolder)
} content: {
if let folder = selectedFolder {
FileList($selectedFile, files: folder.files) // or FileList($selectedFile, folder: folder)
} else {
Text(“Select a folder”)
}
} detail: {
if let file = selectedFile {
FileView(file: file) // details, attributes, preview, …
} else {
Text(“Select a file”)
}
}
.environmentObject(fileManagement)
}
}
}
Note: For changes use FileManagment methods, not File / Folder methods (FileManagement will call them). Also you can just do the operations in FileManagement and remove File / Folder methods.
// Do the operations in disk
class FileManagement: ObservableObject {
var folders: [Folder] = Folder.folders
func createFile(…) { … } // do changes, call objectWillChange.send()
func deleteFile(…) { … } // do changes, call objectWillChange.send()
func setFileAsFavourite(…) { … } // do changes, call objectWillChange.send()
}
// Do the operations in memory
class FileManagement: ObservableObject { // or class FolderStore: ObservableObject {
@Published var folders: [Folder] = []
func loadFolders() { folders = Folder.folders } // or func load() { folders = Folder.folders }
func createFile(…) { … } // change the folders property hierarchy
func deleteFile(…) { … } // change the folders property hierarchy
func setFileAsFavourite(…) { … } // change the folders property hierarchy
}
Also you can just use only the File and Folder active record with a FileWatcher (notify file system changes):
struct File: Identifiable, Equatable, Hashable {
var id: URL { url }
var url: URL
var name: String
var date: Date
var size: Int64
var isFavourite: Bool // change attribute or reference on set / didSet
func save()
func delete()
static var allFiles: [File] { … }
static var onlyFavouriteFiles: [File] { … }
}
struct Folder: Identifiable {
var id: URL { url }
var url: URL
var name: String
var files: [File] // or computed property that fetch (on demand) files from this folder
static var folders: [Folder] { … }
}
struct MyApp: App {
@StateObject var fileWatcher = FileWatcher() // Notify file system changes
@State var selectedFolder: Folder? = nil
@State var selectedFile: File? = nil
var body: some Scene {
WindowGroup {
// Three-column
NavigationSplitView {
FolderList($selectedFolder, folders: Folder.folders)
} content: {
if let folder = selectedFolder {
FileList($selectedFile, files: folder.files) // or FileList($selectedFile, folder: folder)
} else {
Text(“Select a folder”)
}
} detail: {
if let file = selectedFile {
FileView(file: file) // details, attributes, preview, …
} else {
Text(“Select a file”)
}
}
}
}
}
Also, you can use FileManagement as the only SSOT. Note: File and Folder structs can be simple structs, not an active records, you do all the things in FileManagement.
class FileManagement: ObservableObject {
var folders: [Folder] { Folder.folders }
@Published var selectedFolder: Folder? = nil
var files: [File] { selectedFolder?.files ?? [] }
@Published var selectedFile: File? = nil
func startMonitoringChanges() // call objectWillChange.send() on changes
func stopMonitoringChanges()
// Folder and file operations here if needed / wanted
}
struct MyApp: App {
@StateObject var fileManagement = FileManagement()
var body: some Scene {
WindowGroup {
// Three-column
NavigationSplitView {
FolderList()
} content: {
FileList()
} detail: {
FileView() // details, attributes, preview, …
}
.environmentObject(fileManagement)
.onAppear(perform: fileManagement.startMonitoringChanges)
}
}
}
Remember: Don’t worry about “reloading” folders and files, SwiftUI will check the differences and only update what changes. This is the reason why we should use Identifiable protocol.
Thanks very much for these posts. I'll collect all my questions here since the comments are limited:
call objectWillChange.send()
, is this replacing @Published? I.e: We call objectWillChange.send(), then SwiftUI will redraw the view, and automatically get the new contents when it re-reads Folder.folders?shared
static vars? Or otherwise how is this generally done? How does the Active Record actually access the objects that it needs to via those static vars? If you want to swap implementations, for testing purposes or maybe because you actually need different implementations at runtime, how is this usually handled?SwiftUI is really interesting and I'm finding it much easier to develop out new features. Learning about these patterns is super helpful, so thanks for taking the time to engage!
1 - Active Record (like Data Mapper or Repository) is a pattern, very popular, for data access, we use where needed, I use some example to show that model is not only property’s structs. For a library you can use Product, Order as active record but for an app you should include a ProductStore (state), OrderStore (state)… also you can implement all Product, Order tasks on Store and not use Product, Order as active record at all. Remember: typically a framework, like StoreKit 2, need to be flexible, more stateless. A framework is used by an app. For an app like SwiftUI you need one or more state objects, stateful.
2 - Yes, objectWillChange.send() do the @Published job, also @Published is a convenience for objectWillChange.send() and use it. See 2019 post about this, initially (SwiftUI betas)we don’t have @Published. Forget in last posts, you should call objectWillChange.send() before you change the property, not after, be careful with asyncs stuffs.
@Published var selectedFolder: Folder? = nil
=
var selectedFolder: Folder? = nil {
willSet {
objectWillChange.send()
}
}
3 - Yes!
4 - As I said in 1 point, you can handle everything on ObservableObject and keep only property structs, and just add some needed tasks to structs.
struct Product {
// Properties (data)
// Tasks
func purchase() async throws { ... }
// Factory Method
static products: [Self] {
get async throws {
return try await WebService.shared.request(…)
}
}
}
class ProductStore: ObservableObject {
@Published var products: [Product] = []
func load() async {
do {
products = try await Product.products
} catch {
// handle error
}
}
}
or
struct Product {
// Properties (data)
// Tasks
func purchase() async throws { ... }
}
class ProductStore: ObservableObject {
@Published var products: [Product] = []
func load() async {
do {
products = try await WebService.shared.request(…)
} catch {
// handle error
}
}
}
or
struct Product {
// Properties (data)
}
class ProductStore: ObservableObject {
@Published var products: [Product] = []
func purchase(_ product: Product) async throws { ... }
func load() async {
do {
products = try await WebService.shared.request(…)
} catch {
// handle error
}
}
}
5 - In active record you use static func / vars for “Factory Method”, not single instance. For this pattern you only use shared (single instance) for your service / provider objects (e.g. WebService.shared.request(…)). And in general for SwiftUI you should avoid singleinstance for non service / provider objects, use @EnvironmentObject.
ObservableObject is a working object where we aggregate related data and tasks.
We see ObservableObject as:
ObservableObject (working, in-memory data) from:
Example: My last app (multiplatform) have in-app purchases (use StoreKit 2), I have a SubscriptionStore (ObservableObject) for:
I can add purchase task to SubscriptionStore but use product.purchase() from StoreKit 2 in View. As I use this object in different views I use @EnvironmentObject to have one instance and access from any view in hierarchy.
The app use 2 data access models based on Active Record, the my data (web service) model (part active record, part handle some things in the stores) and the StoreKit 2 model.
Remember: Active Record is about data access, not state. SwiftUI views need a state, local (@State) and external (@StateObject).
Imagine the CoreLocation 2 using await / async, no delegates. Now we use CLLocation (property only struct) and CLLocationManager (object). In future we could use Location as Active Record:
Also, how the long wait SwiftData (Core Data next generation) be like:
We have Active Record for data access in PHP Laravel database and Swift server-side Vapor (Fluent database).
Why doesn't Apple just come out and say if they designed SwiftUI to use MVVM or MV?
Easy, the MVVM comes from old technology. Also Microsoft, who sell MVVM from 2005 is not using it on new UI framework (declarative). SwiftUI is new tech, modern, declarative. They eliminate the middle layer. Is part of evolution, simplicity. We only need a View layer and a Model layer. Inside Model layer we can do what we want (also some VMs I see on blogs and tutorials are state / store, part of the model). We can’t use MVVM for SwiftUI without problems and limitations.
Talking and wanting MVVM or MVC today is the same wanting C++ or Pascal. No one want go back! There’s a modern and more easy techs.
The evolution:
SwiftUI automatically performs most of the work traditionally done by view controllers. Fact: SwiftUI View is a ViewModel.
To remember! The model layer are not (and never was) only property structs. The model layer is our data, services / networking, state, business objects, processors, …
Many devs don’t understand the MVC / MVVM. Fact: VM (reactive) = C (imperative)
It's obvious - with SwiftUI we should drop the C from MVC, but still we should keep the business logic somewhere.
Let's try to reason where is the best place? (We are developers working on a moderate iOS project - not a small one, but not something huge.)
in the view? - No. (Too many reasons to avoid it - complex views, unreadable views, hard to unit test, etc.)
in the model - Probably.
in the controller - No. There is no controller in SwiftUI.
Otherwise we need some place which we can call whatever we want. (Some people prefer to use "ViewModel". Please, don't mess this with MVVM design pattern.).
Another generic problem that we should solve - caching (because we fetch some data from a server and we store it on the device, while the app is running, or even persist it between different sessions.) Also this data may be updated from time to time based on other users (imagine a chat functionality in an app).
Who should be responsible for storing that data? It's not the view. It's not the controller. Guess, who? - The model.
Well, if we continue the same way, the model is not anymore a typical model (closer to POJO) - it's growing and getting wiser and powerful (and massive :D). Which leads to some sort of separation if we want to make our codebase maintainable.
In summary - if the project you are working is growing then you have to introduce something that will make it easier to grow. It's up to you to pick the right tools.
MVVM or something similar might be an overkill in the beginning of any project. But at some point partially using any architectural Design Pattern (or just the good bits) will solve a lot of problems.
Another generic problem that we should solve - caching (because we fetch some data from a server and we store it on the device, while the app is running, or even persist it between different sessions.) Also this data may be updated from time to time based on other users (imagine a chat functionality in an app). Who should be responsible for storing that data? It's not the view. It's not the controller. Guess, who? - The model.
Yes 🙌
Finished an app with caching (local data sync from remote), our model have few “stores” as source of truth to do that job, and again EnvironmentObject is our best friend.
Another Example
We can have a single store for everything (not recommended for big, multi section / tab apps) or multi stores, separated by use case / data type / section / tab. Again, stores (ObservableObjects), we can call other names, are part of the model (yes we can separate from our data model). Also we can do everything in stores and not use active record pattern at all but my last experience tell me that is good to separate our data model (using a data access pattern) from state (we can call store object, business object, use case object, state object, external source of truth object, …).
This is the SwiftUI (declarative UI) approach. With MVVM pattern:
Who needs a ViewModel today?
Little, simple and clean code. Everything works great on iOS, iPadOS, macOS, tvOS, Unit / Integration Tests server. Very productive team with well defined responsabilities. Note: Just an example based on real SwiftUI app.