Starting with Project Refactoring: Sharing Five Swift Modules

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Five years ago, my initial motivation for learning Swift was simple: I wanted to develop an app to record and manage my own medical examination data to help with postoperative health management. It’s been four years since the release of version 2.0 of Health Notes. Looking back at that code now (over 95% of which was written four years ago), I find it hard to face—frankly speaking, I think it’s poorly written. This thought has been lingering in my mind, prompting me over the past two years to plan a complete refactoring of the app. Finally, in September of this year, I decided to embark on this refactoring journey.

During the refactoring process, I decided to extract some functional modules from the project and share them as open source. The main reasons for doing this are:

  • Better code abstraction and organization through modularization
  • Forcing myself to write more standardized code by sharing
  • Hoping these modules can help other developers

This article will briefly introduce several libraries I’ve open-sourced over the past two months.

SimpleLogger

SimpleLogger is a lightweight logging module. Although its features are restrained—the most notable characteristic at present might be its support for Swift 6—it is an indispensable piece of infrastructure that I use in all my code modules.

Main features:

  • Provides two built-in implementations (both support Sendable)
  • Supports custom backend extensions
  • Allows flexible control of log output through environment variables

Basic usage:

Swift
import SimpleLogger

let logger: LoggerManagerProtocol = .default(subsystem: "com.example.app", category: "general")
logger.info("App started")

iCloudSyncStatusKit

In apps using Core Data with CloudKit, it’s crucial to be aware of the user’s iCloud account status and data synchronization progress in real time. Therefore, I modularized this requirement and packaged it into the iCloudSyncStatusKit library.

Responding to Data Sync Status

iCloudSyncStatusKit supports tracking multiple sync statuses (such as importing, exporting, setup, and idle). Here’s how to display the current sync event in SwiftUI:

Swift
struct ContentView: View {
    @StateObject var syncManager = SyncStatusManager()

    var body: some View {
        VStack {
            Text("Sync Event: \(syncManager.syncEvent)")
        }
    }
}

Checking iCloud Account Status

This library also supports checking the device’s iCloud account status and performing corresponding actions based on the result. The following code shows how to use an asynchronous call to check the account status and handle the unavailable case:

Swift
let status = await syncManager.validateICloudAvailability { status, error in
    // Handle the case where the iCloud account is unavailable using a closure
}

if status == .available {
    // Start synchronization
} else {
    // Handle the case where the iCloud account is unavailable
}

Handling Insufficient Space Situations

When instantiating SyncStatusManager, you can provide a closure to handle the quotaExceeded status (i.e., insufficient iCloud storage). For example, when synchronization fails and indicates insufficient space, you can prompt the user to clean up or expand storage:

Swift
let syncManager = SyncStatusManager { 
    if $0 == .quotaExceeded {
        // Remind the user to clean up iCloud storage
    }
}

It’s worth noting that situations of insufficient iCloud space are not always explicitly reported through error prompts and can only be handled as a limited mechanism.

The design idea of this library was inspired by this article: General Findings About NSPersistentCloudKitContainer

ObservableDefaults

ObservableDefaults is an @Observable macro extension based on the Observation framework. It allows specific variables to be automatically associated with UserDefaults keys and respond in real time to UserDefaults content changes from any source.

In my article UserDefaults and Observation in SwiftUI: How to Achieve Precise Responsiveness, I delve into its implementation details.

In the current project, I mostly adopt the ”observe-first” mode (observeFirst). In this mode, only properties marked with specific annotations will be associated with UserDefaults, achieving precise data synchronization. Here is an example:

Swift
@ObservableDefaults(observeFirst: true)
public class Test2 {
    // Automatically adds @ObservableOnly, supports observation but won't persist
    public var name: String = "fat"

    // Automatically adds @ObservableOnly
    public var age = 109

    // Properties that need to be persisted must be marked with @DefaultsBacked, and can specify the UserDefaults key name
    @DefaultsBacked(userDefaultsKey: "myHeight")
    public var height = 190

    // This property is neither observed nor persisted
    @Ignore
    public var weight = 10
}

CoreDataEvolution

CoreDataEvolution was inspired by SwiftData’s @ModelActor and introduces an elegant concurrent programming experience similar to SwiftData for Core Data. In my article Core Data Reform: Achieving Elegant Concurrency Operations like SwiftData, I discuss in detail the design philosophy and implementation principles of this library.

In actual use, I found that automatically generating constructors completely following the @ModelActor pattern might limit the flexibility of the Actor. In many scenarios, building data processing modules requires custom parameters. Therefore, I added the disableGenerateInit parameter in @NSModelActor. When this parameter is set to true, automatic constructor generation is disabled. This seemingly minor change actually greatly enhances flexibility in practical development.

Swift
// disableGenerateInit defaults to false; if not provided, constructors are automatically generated
@NSModelActor(disableGenerateInit: true) 
public actor DataHandler {
    func createNewItem(_ timestamp: Date = .now, showThread: Bool = false) throws -> NSManagedObjectID {
        let item = Item(context: modelContext)
        item.timestamp = timestamp
        try modelContext.save()
        return item.objectID
    }

    // Custom constructor, supports more parameter configurations
    init(container: NSPersistentContainer, viewName: String) {
        modelContainer = container
        let context = container.newBackgroundContext()
        context.name = viewName // Added context naming logic
        modelExecutor = .init(context: context)
    }
}

CoreDataEvolution also provides an NSMainModelActor, allowing developers to declare classes running on the main thread using viewContext.

ModelActorX

ModelActorX is an enhanced implementation of ModelActor in SwiftData. This library not only allows control over automatic initializer generation through the disableGenerateInit parameter but also provides an initializer method using the view context to create Actors. This approach specifically addresses the current issue in iOS 18 where data modifications on non-main threads (using @ModelActor) do not trigger automatic view updates.

The @MainModelActorX attribute enables developers to declare a actor that operates on the main thread using mainContext. This implementation allows developers to address the responsiveness issue in iOS 18 without altering existing code. Once the official fix is released, developers can seamlessly switch back to the native ModelActor implementation.

Swift
@ModelActorX
actor MainDataHandler {
    func newItem(date: Date) throws -> PersistentIdentifier {
        let item = Item(timestamp: date)
        modelContext.insert(item)
        try modelContext.save()
        return item.persistentModelID
    }

    func getTimestampFromItemID(_ itemID: PersistentIdentifier) -> Date? {
        return self[itemID, as: Item.self]?.timestamp
    }
  
    func updateItem(id: PersistentIdentifier, date: Date) {
        guard let item = self[id, as: Item.self] else { return }
        item.timestamp = date
        try? modelContext.save()
    }
}

// Usage

Task{ @MainActor in
   let handler = DataHandler(mainContext: container.mainContext) // Use the view context for construction
   await handler.updateItem(id: id, date: .now) // Even on the main thread, you can still use `await`
}

ModelActorX only improves the responsiveness for data modifications made by developers within private contexts; it cannot resolve the issue of delayed responses to cloud-synced data changes in iOS 18.

Conclusion

This process of modularization and open-sourcing is, in a sense, a review and distillation of my five-year journey learning Swift. Through refactoring and sharing, not only can the code become more elegant and maintainable, but it also allows me to contribute to the Swift community.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed