Meine SwiftUI Previews funktionierten nicht richtig, seitdem ich das Projekt für den Open Focus Timer in Xcode mit Point-Frees Modularisierungsansatz eingerichtet hatte – mit aktivierter CoreData-Checkbox, um einen guten Ausgangspunkt für meine Modellschicht zu bekommen. Das war ziemlich nervig, denn schnellere Builds und damit zuverlässigere SwiftUI Previews waren einer der Hauptgründe, warum ich mich überhaupt dafür entschieden hatte, meine App in kleine Stücke zu modularisieren.
Also habe ich in einem meiner Streams (das ist eine Open-Source-App, die ich komplett öffentlich entwickle und dabei live auf Twitch streame) beschlossen, dieses Problem ein für alle Mal zu lösen. Und bin gescheitert:
Just failed at fixing a #SwiftUI preview error during my #livestream. Could fix one issue, but then got stuck at "MessageError: Connection interrupted". Any ideas? The project is open source:https://t.co/ppevrcRMtK
— Cihat Gündüz (@Jeehut) March 2, 2022
I started getting this error here:https://t.co/AQz2l7vnTv pic.twitter.com/aTYQy5gzHi
Dank der Hilfe der großartigen Swift-Community auf Twitter konnte ich die Ursache des Problems herausfinden: SwiftUI Previews bekommen Probleme, wenn CoreData-Modelle in ihnen referenziert werden.
Aber während ich dachte, es sei nur ein Pfadproblem, das sich mit einem einfachen Workaround beheben ließe, war es doch nicht so einfach. Ja, es gibt ein Pfadproblem, aber beim Lösen der Previews bin ich auf mehrere Fehlerebenen gestoßen. Und ich habe dabei gelernt, wie man SwiftUI Previews debuggt. Lass mich meine Erkenntnisse teilen …
#1: Explizite Dependencies im Package-Manifest
Das Wichtigste zuerst. Point-Frees Modularisierungsansatz zu verwenden bedeutet, dass du eine Package.swift-Datei manuell pflegen musst. Für jedes Modul fügst du einen target-, einen testTarget- und einen library-Eintrag hinzu und für jedes Target musst du die Dependencies angeben. Xcode hilft hier in keiner Weise, außer dass es die Änderungen erkennt, die du in dieser Datei vornimmst. Bei vielen Packages kann die Manifestdatei erheblich wachsen, und es gibt derzeit keine Hilfe, die mir bekannt wäre, um das einfacher zu machen. So sieht mein Manifest aktuell aus:
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "OpenFocusTimer",
defaultLocalization: "en",
platforms: [.macOS(.v12), .iOS(.v15)],
products: [
.library(name: "AppEntryPoint", targets: ["AppEntryPoint"]),
.library(name: "Model", targets: ["Model"]),
.library(name: "TimerFeature", targets: ["TimerFeature"]),
.library(name: "ReflectionFeature", targets: ["ReflectionFeature"]),
.library(name: "Resources", targets: ["Resources"]),
],
dependencies: [
// Commonly used data structures for Swift
.package(url: "https://github.com/apple/swift-collections", from: "1.0.2"),
// Handy Swift features that didn't make it into the Swift standard library.
.package(url: "https://github.com/Flinesoft/HandySwift", from: "3.4.0"),
// Handy SwiftUI features that didn't make it into the SwiftUI (yet).
.package(url: "https://github.com/Flinesoft/HandySwiftUI", .branch("main")),
// ⏰ A few schedulers that make working with Combine more testable and more versatile.
.package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.5.3"),
// A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.33.1"),
// Safely access Apple's SF Symbols using static typing Topics
.package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "2.1.3"),
],
targets: [
.target(
name: "AppEntryPoint",
dependencies: [
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "HandySwift", package: "HandySwift"),
.product(name: "HandySwiftUI", package: "HandySwiftUI"),
"Model",
"ReflectionFeature",
"TimerFeature",
"Utility",
]
),
.target(
name: "Model",
dependencies: [
.product(name: "OrderedCollections", package: "swift-collections"),
.product(name: "HandySwift", package: "HandySwift"),
.product(name: "SFSafeSymbols", package: "SFSafeSymbols"),
],
resources: [
.process("Model.xcdatamodeld")
]
),
.target(
name: "TimerFeature",
dependencies: [
.product(name: "HandySwift", package: "HandySwift"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
"Model",
"ReflectionFeature",
"Resources",
.product(name: "SFSafeSymbols", package: "SFSafeSymbols"),
"Utility",
]
),
.target(
name: "ReflectionFeature",
dependencies: [
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "HandySwift", package: "HandySwift"),
"Model",
"Resources",
"Utility",
]
),
.target(
name: "Resources",
resources: [
.process("Localizable")
]
),
.target(
name: "Utility",
dependencies: [
.product(name: "CombineSchedulers", package: "combine-schedulers"),
"Model",
]
),
.testTarget(
name: "ModelTests",
dependencies: ["Model"]
),
.testTarget(
name: "TimerFeatureTests",
dependencies: [
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
"TimerFeature",
]
),
]
)Das Problem bei der manuellen Pflege dieser Datei ist nicht nur die manuelle Arbeit. Xcode verhält sich anscheinend inkonsistent bezüglich der Dependencies: Wenn du zum Beispiel einen normalen Build für den Simulator machst, scheint eine Dependency einer Dependency automatisch zu deinem Target gelinkt zu werden. Wenn also mein TimerFeature zum Beispiel Utility importiert, es aber nicht als Dependency unter dem TimerFeature-Target aufgelistet ist, kann Xcode trotzdem ohne Fehler kompilieren, wenn eine andere Dependency, z.B. Model, ebenfalls von Utility abhängt – so kann Xcode indirekt auf Utility innerhalb von TimerFeature zugreifen, weil TimerFeature Model als Dependency gelistet hat.
Obwohl das sehr nützlich klingt, kann es ziemlich frustrierend werden, weil SwiftUI Previews anders funktionieren. Für sie funktioniert diese transitive Art von impliziten Imports – soweit ich das beurteilen kann – nicht. Das Gleiche scheint auch für das Ausführen von Tests zu gelten (zumindest manchmal). Anders gesagt: Es ist wichtig, die dependencies für jedes Target immer doppelt zu prüfen und nicht zu vergessen, jeden import, den du in einem Target verwendest, auch zum entsprechenden Target in deiner Package.swift-Manifestdatei hinzuzufügen.
Vielleicht schreibt ja jemand in Zukunft ein Tool, das das einfacher macht. 🤞
#2: Generierter Code wird von Xcode nicht zuverlässig erkannt
Ein weiteres Problem, auf das ich gestoßen bin: Selbst wenn meine Builds erfolgreich waren, zeigte Xcode (nachdem es mir den “Build succeeded”-Dialog gezeigt hatte) einen Fehler im Editor innerhalb des PreviewProvider an, der besagte, dass FocusTimer nicht gefunden werden könne:

Beachte, dass du diese generierten Dateien jedes Mal löschen und neu erstellen musst, wenn du eine Änderung am Modell vornimmst (was du ohnehin selten machen solltest, um Datenbank-Migrationsprobleme zu vermeiden). Wähle außerdem das Modell in Xcode aus und setze Codegen auf Manual/None.

Damit zeigt der Editor keinen Fehler mehr an.
#3: SwiftUI Diagnostics != SwiftUI Crash Reports
Hier ein Tipp für alle (wie mich), die sich fragen, wie man mit Fehlern wie diesem umgeht, wenn man nach dem Fehlschlagen der SwiftUI Previews auf den Diagnostics-Button drückt:

Wenn du den Inhalt des hervorgehobenen Ordners anschaust, findest du viele Dateien mit verschiedenen Details über den SwiftUI-Preview-Build. Die nützlichste Datei zum Debuggen liegt im Ordner CrashLogs, wo du eine oder mehrere .ips-Dateien findest, die wir einfach per Doppelklick in Xcode öffnen können:

Zum Glück half hier der bereits erwähnte Hinweis eines hilfsbereiten Entwicklers aus der Swift-Community auf Twitter, der mich auf einen Thread mit dieser Antwort auf StackOverflow verwies.
Es besagt im Wesentlichen, dass es derzeit einen Bug in Xcode (oder SwiftPM?) gibt, der dazu führt, dass Bundle.module in SwiftUI Previews auf den falschen Pfad zeigt. Zur Lösung schlagen sie vor, eine Bundle-Extension mit einer benutzerdefinierten Suche hinzuzufügen. Hier der vollständige Code, leicht angepasst an meinen Coding- und Kommentarstil:
import Foundation
extension Foundation.Bundle {
/// Workaround for making `Bundle.module` work in SwiftUI previews. See: https://stackoverflow.com/a/65789298
///
/// - Returns: The bundle of the target with a path that works in SwiftUI previews, too.
static var swiftUIPreviewsCompatibleModule: Bundle {
#if DEBUG
// adjust these for each module
let packageName = "OpenFocusTimer"
let targetName = "Model"
final class ModuleToken {}
let candidateUrls: [URL?] = [
// Bundle should be present here when the package is linked into an App.
Bundle.main.resourceURL,
// Bundle should be present here when the package is linked into a framework.
Bundle(for: ModuleToken.self).resourceURL,
// For command-line tools.
Bundle.main.bundleURL,
// Bundle should be present here when running previews from a different package (this is the path to "…/Debug-iphonesimulator/").
Bundle(for: ModuleToken.self).resourceURL?.deletingLastPathComponent().deletingLastPathComponent()
.deletingLastPathComponent(),
Bundle(for: ModuleToken.self).resourceURL?.deletingLastPathComponent().deletingLastPathComponent(),
]
// The name of your local package, prepended by "LocalPackages_" for iOS and "PackageName_" for macOS.
let bundleNameCandidates = ["\(packageName)_\(targetName)", "LocalPackages_\(targetName)"]
for bundleNameCandidate in bundleNameCandidates {
for candidateUrl in candidateUrls where candidateUrl != nil {
let bundlePath: URL = candidateUrl!.appendingPathComponent(bundleNameCandidate)
.appendingPathExtension("bundle")
if let bundle = Bundle(url: bundlePath) { return bundle }
}
}
return Bundle.module
#else
return Bundle.module
#endif
}
}Wenn du diesen Code kopierst und einfügst, stelle sicher, dass du die Variablen
packageNameundtargetNamean dein Package und deine Target-Namen anpasst.
Beachte, dass ich den Workaround in ein #if DEBUG eingewickelt habe, um sicherzustellen, dass mein Produktionscode nicht versehentlich diese Pfadsuche verwendet, sondern sich auf das offizielle Bundle.module verlässt. Außerdem habe ich den fatalError aus dem StackOverflow-Workaround-Code entfernt, sodass er, falls er kein Bundle in den benutzerdefinierten Suchpfaden findet, nicht abstürzt, sondern stattdessen Bundle.module als Fallback zurückgibt. Das soll den Code robuster machen und auch dann weiterhin funktionieren, wenn dieser Bug in einem zukünftigen Xcode-Release behoben wird, die benutzerdefinierten Suchpfade aber möglicherweise nicht mehr funktionieren.
Nun war die letzte Änderung, die ich im PersistenceController vornehmen musste, den Aufruf von Bundle.module durch einen Aufruf des neuen Bundle.swiftUIPreviewsCompatibleModule zu ersetzen:
let modelUrl = Bundle.swiftUIPreviewsCompatibleModule.url(forResource: "Model", withExtension: "momd")!
let managedObjectModel = NSManagedObjectModel(contentsOf: modelUrl)!
container = NSPersistentContainer(name: "Model", managedObjectModel: managedObjectModel)Und endlich funktionierten meine SwiftUI Previews wieder!

