コンテンツへスキップ

SwiftPM + CoreData:SwiftUIプレビューが動かない?修正のための5つのヒント

SwiftPMでモジュール化しCoreDataを使用するアプリにおいて、SwiftUIプレビューを失敗させるXcodeのバグを修正する方法。

SwiftPM + CoreData:SwiftUIプレビューが動かない?修正のための5つのヒント

私のSwiftUIプレビューは、Point-Freeのモジュール化アプローチを使ってXcodeでOpen Focus Timerプロジェクトをセットアップした日から正しく動作していませんでした。CoreDataのチェックボックスを有効にしてモデルレイヤーの良い出発点を得ていたのですが、これはかなりストレスでした。そもそもアプリを小さなチャンクにモジュール化した主な理由の一つが、ビルドの高速化、つまりSwiftUIプレビューやテストなどの信頼性向上だったからです。

そこで配信の一つで(これはオープンソースアプリで、Twitchでライブ配信しながら完全に公開で開発しています)、この問題に取り組んでSwiftUIプレビューのエラーを一度に解決しようとしました。そして失敗しました。

TwitterのSwiftコミュニティからの助けのおかげで、問題の根本原因を特定できました。SwiftUIプレビューはCoreDataモデルが参照されると問題が起きるのです。

しかし、単純なワークアラウンドで修正できるパスの問題だと思っていましたが、そう単純ではありませんでした。確かにパスの問題は関係していましたが、プレビューを修正する過程で複数段階のエラーに遭遇しました。そしてその過程でSwiftUIプレビューのデバッグ方法を学びました。その知見を共有します。

#1:Package Manifestで依存関係を明示的に記述する

まず基本から。Point-Freeのモジュール化アプローチを使うということは、手動で管理するPackage.swiftファイルがあるということです。各モジュールについて、targettestTargetlibraryのエントリを追加し、各ターゲットの依存関係を指定する必要があります。Xcodeはこのファイルの変更を認識する以外、何も手伝ってくれません。パッケージが多くなると、マニフェストファイルはかなり大きくなり、これを楽にするヘルプは現時点では知りません。私のマニフェストは現在こんな感じです。

// 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",
      ]
    ),
  ]
)

このファイルを手動で管理する際の問題は、手作業だけではありません。Xcodeは依存関係の扱いに一貫性がないようです。例えばシミュレータ向けの通常ビルドでは、依存関係の依存関係が自動的にリンクされるように見えます。つまりTimerFeatureUtilityをインポートしているのに、TimerFeatureのターゲットの依存関係にリストされていなくても、別の依存関係(例えばModel)もUtilityに依存していれば、XcodeはTimerFeature内で間接的にUtilityにアクセスでき、エラーなくコンパイルできる場合があります。

これは便利に聞こえますが、非常にフラストレーションの原因になり得ます。SwiftUIプレビューは動作が異なるからです。私が確認した限り、このような推移的な暗黙のインポートはプレビューでは機能しません。テストの実行でも同様のようです(少なくとも時々は)。つまり、各ターゲットのdependenciesを常にダブルチェックし、ターゲット内で行うすべてのimportPackage.swiftマニフェストファイルの関連ターゲットに追加し忘れないことが重要です。

将来、誰かがこれを楽にするツールを作ってくれるかもしれません。🤞

#2:生成されたコードがXcodeに確実に認識されない

もう一つ遭遇した問題は、ビルドが成功しても、Xcodeが(「Build succeeded」ダイアログを表示した後に)PreviewProvider内のエディタでFocusTimerが見つからないというエラーを表示することでした。

ビルド成功&<code>import Model</code>にもかかわらず、<code>FocusTimer</code>がスコープ内に見つからないというエラー。

モデルに変更を加えるたびにこれらの生成ファイルを削除して再作成する必要があります(データベースマイグレーションの問題を防ぐため、変更は頻繁に行わないようにしましょう)。さらに、Xcodeでモデルを選択し、CodegenManual/Noneに設定してください。

「Diagnostics」をクリックすると「MessageError: Connection interrupted」と表示される。

これを行うと、エディタにエラーが表示されなくなります。

#3:SwiftUIの診断 != SwiftUIのクラッシュレポート

SwiftUIプレビューが失敗したときにDiagnosticsボタンを押した後、このようなエラーをどう活用すればいいか悩んでいる方(私もそうでした)へのヒントです。

「Diagnostics」をクリックすると「MessageError: Connection interrupted」と表示される。

ハイライトされたフォルダの内容を見ると、SwiftUIプレビュービルドに関するさまざまな詳細情報を含む多くのファイルがあります。デバッグに最も役立つファイルはCrashLogsフォルダ内にあり、1つまたは複数の.ipsファイルがあります。これをXcodeでダブルクリックして簡単に開くことができます。

Xcodeプレビューの.ipsファイルの内容。適切なエラーコンソール出力が表示されている。

ありがたいことに、TwitterのSwiftコミュニティの親切な開発者からの指摘が助けとなり、StackOverflowのこの回答を含むスレッドを教えてくれました。

基本的に、現在Xcode(またはSwiftPM?)にバグがあり、SwiftUIプレビューでBundle.moduleが間違ったパスを指すということです。修正するために、カスタム検索を行うBundle拡張を追加することが提案されています。以下が私のコーディング・コメントスタイルに合わせて少し調整した完全なコードです。

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
  }
}

このコードをコピー&ペーストする際は、packageNametargetName変数をお使いのパッケージとターゲットの名前に合わせて調整してください。

ワークアラウンドを#if DEBUGで囲んでいることに注目してください。本番コードが誤ってこのパス検索を使わず、公式のBundle.moduleに依存するようにするためです。また、StackOverflowにあったワークアラウンドコードからfatalErrorを削除し、カスタム検索パスでBundleが見つからない場合にフォールバックとしてreturn Bundle.moduleするようにしています。これにより、将来のXcodeリリースでこのバグが修正された場合でもカスタム検索パスが動作しなくなっても、コードがより堅牢に動作し続けるようにしています。

最後に、PersistenceControllerBundle.moduleの呼び出しを新しいBundle.swiftUIPreviewsCompatibleModuleの呼び出しに置き換えました。

let modelUrl = Bundle.swiftUIPreviewsCompatibleModule.url(forResource: "Model", withExtension: "momd")!
let managedObjectModel = NSManagedObjectModel(contentsOf: modelUrl)!
container = NSPersistentContainer(name: "Model", managedObjectModel: managedObjectModel)

そしてついに、SwiftUIプレビューが再び動作するようになりました!

この記事が参考になりましたか?BlueskyMastodonでフォローして、Swiftのヒントやインディー開発の最新情報をチェックしてください。