Swift
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Optionals
-
Section 4: Object-Oriented and Value Types
-
Section 5: Memory Management
-
Section 6: Generics and Error Handling
-
Section 7: Concurrency
-
Section 8: Working with Collections
-
Section 9: Codable and Data Handling
-
Section 10: Protocol-Oriented Programming
-
Section 11: Testing and Tooling
-
Section 12: Practical Projects
-
Section 13: Interview Practice
-
Section 14: More Practice Exercises
-
Section 15: More Standard Library
-
Section 16: Advanced Concurrency
-
Section 17: Foundation Framework Deep Dive
-
Section 18: URLSession and Networking Deep Dive
-
Section 19: Combine Framework
-
Section 20: SwiftUI Fundamentals for Swift Developers
-
Section 21: Server-Side Swift with Vapor
-
Section 22: Swift Package Manager Deep Dive
-
Section 23: Swift Concurrency Deep Dive
-
Section 24: More Language Features
-
Section 25: Error Handling Deep Dive
-
Section 26: Testing Deep Dive
-
Section 27: Data Structures and Algorithms in Swift
-
Section 28: More Practice Exercises
-
Section 29: More Interview Practice
-
Section 30: Swift Macros (Swift 5.9+)
-
Section 31: Property Wrappers Ecosystem
-
Section 32: Swift Interop Deep Dive
-
Section 33: iOS App Architecture Patterns
-
Section 34: Performance and Debugging
-
Section 35: App Distribution and CI/CD
-
Section 36: More Practical Projects
-
Section 37: SwiftData and Persistence
-
Section 38: More Design Patterns
-
Section 39: More Review and Practice
-
Section 40: More Foundation Deep Dive
-
Section 41: Advanced Collections in Swift
-
Section 42: Advanced Generics Practice
-
Section 43: UIKit for Legacy and Hybrid Apps
-
Section 44: watchOS and visionOS Development Basics
-
Section 45: More Networking Patterns
-
Section 46: More Testing Practice
-
Section 47: Accessibility in Swift Apps
-
Section 48: Localization
-
Section 49: More Practical Projects Round 2
-
Section 50: Swift Charts Framework
-
Section 51: More Interview and Algorithm Practice
-
Section 52: Final Practice and Mastery
-
Section 53: Swift Compiler and Build System
-
Section 54: More Concurrency Practice
-
Section 55: App Store Guidelines and Review
-
Section 56: More Design and Architecture
54: Building a Local Persistence Layer
Up until now, your data has lived and died with the lifecycle of your app. You refresh the simulator, and your state vanishes. In a real-world app, that's a disaster. Whether it's a user's preferences, a cached feed, or a list of saved items, you need a way to make that data survive a reboot. I've seen a lot of developers approach this as a "utility" problem—something you just solve with a quick helper function—but that's a trap that leads to a codebase that's nearly impossible to test or evolve.
The Temptation of the Quick Save
Let's say we're building a simple Reading List app. You have a Book struct, and you want to save the user's list to the disk. The naive approach—the one I see most often in early projects—is to just sprinkle JSONEncoder and FileManager calls directly inside your ViewModel. It looks something like this:
class ReadingListViewModel: ObservableObject {
@Published var books: [Book] = []
func addBook(_ book: Book) {
books.append(book)
// Naive persistence: doing it all right here
if let data = try? JSONEncoder().encode(books) {
let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("books.json")
try? data.write(to: url)
}
}
}
On the surface, this works. It's fast to write, and for a tiny app, it gets the job done. But we're thinking as engineers, not just coders. The problem here is that your ViewModel is now doing two entirely different jobs: it's managing the UI state and it's managing the file system. It's tightly coupled to the disk. If you decide tomorrow that a JSON file is too slow and you want to move to SwiftData or a SQLite database, you have to hunt down every single place you wrote to a file and rewrite the logic.
Abstracting the Storage Engine
The better way is to build a dedicated persistence layer. I prefer using a Repository pattern. Instead of the ViewModel knowing how the data is saved, it should only know that it can be saved. We define a protocol that describes the capabilities we need, and then we create a concrete implementation that handles the messy details of the file system.
protocol ReadingListStorage {
func save(_ books: [Book]) throws
func load() throws -> [Book]
}
class FileReadingListStorage: ReadingListStorage {
private let fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("books.json")
func save(_ books: [Book]) throws {
let data = try JSONEncoder().encode(books)
try data.write(to: fileURL, options: .atomic)
}
func load() throws -> [Book] {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode([Book].self, from: data)
}
}
Now, your ViewModel just takes a ReadingListStorage object through its initializer. It doesn't know if it's talking to a JSON file, a mock object for testing, or a remote server. This is called Dependency Injection, and it's the secret to keeping your Swift code from becoming a "big ball of mud" as the project grows.
Where the Naive approach breaks
You might be thinking, "Why bother with the extra files and protocols for a simple list?" Here is where the naive approach actually fails in production. First, there's the issue of Atomicity. If the app crashes or the battery dies exactly while data.write(to: url) is executing in your ViewModel, you can end up with a corrupted JSON file that prevents the app from ever launching again. In the better approach, I used .atomic, which writes to a temporary file first and then swaps it, ensuring the old save is preserved if the new one fails.
Second, testing becomes a nightmare. If your persistence logic is baked into the ViewModel, you can't write a unit test for your business logic without actually writing files to your Mac's hard drive. That makes tests slow and flaky. With the repository pattern, you can create a MockStorage class that just keeps the books in an array in memory. Your tests run in milliseconds, and they don't leave "junk" files all over your system.
Lastly, consider performance. Writing the entire array to disk every time a single book is added is fine for ten books. It's a disaster for ten thousand. By isolating the persistence layer, you can optimize the save method—perhaps by adding a debounce timer or switching to a database—without touching a single line of code in your UI layer.
📋 Practical Task
Exercise: Implementing a Persistent Bookmark Manager
Build a persistence layer for a Bookmark Manager app. Your goal is to move the storage logic out of the view logic and into a dedicated repository.
- Create a
Bookmarkstruct that conforms toCodable(includetitleandurl). - Define a protocol named
BookmarkPersistencewith two methods:saveBookmarks(_ bookmarks: [Bookmark]) throwsandfetchBookmarks() throws -> [Bookmark]. - Create a class
DiskBookmarkStoragethat implements this protocol usingJSONEncoderandFileManager. Ensure you use the.atomicwrite option. - Create a
BookmarkViewModelthat accepts any object conforming toBookmarkPersistencevia its initializer. - Implement a method in the ViewModel called
persistCurrentBookmarks()that calls the storage layer, and aloadSavedBookmarks()method that populates the ViewModel's local array.
Challenge: Create a MockBookmarkStorage class that implements the protocol but stores the bookmarks in a simple local array. Initialize your ViewModel with this mock to verify that your ViewModel logic works independently of the actual disk.
There are no comments for now.