İçeriğe geç

SwiftPM + CoreData: SwiftUI Preview'ları Çalışmıyor mu? İşte Düzeltmek İçin 5 İpucu

SwiftPM ile modülerleştirilmiş ve CoreData kullanan uygulamalarda SwiftUI preview'larının çalışmamasına neden olan Xcode hatalarını düzeltme.

SwiftPM + CoreData: SwiftUI Preview'ları Çalışmıyor mu? İşte Düzeltmek İçin 5 İpucu

SwiftUI preview’larım, Point-Free’nin modülerleştirme yaklaşımını kullanarak Xcode’da Open Focus Timer projesini kurduğum günden beri düzgün çalışmıyordu — model katmanım için iyi bir başlangıç noktası elde etmek amacıyla CoreData checkbox’ını işaretlemiştim. Bu oldukça can sıkıcıydı, çünkü daha hızlı build’ler ve dolayısıyla daha güvenilir SwiftUI preview’ları almak, uygulamam küçük parçalara modülerleştirmek istememin ana nedenlerinden biriydi.

Yayınlarımdan birinde (bu, Twitch’te canlı yayın yaparak tamamen açık bir şekilde geliştirdiğim açık kaynak bir uygulama) bu sorunu bir kez ve tamamen çözmeye karar verdim. Ve başarısız oldum:

Twitter’daki harika Swift topluluğunun yardımıyla sorunun temel nedenini buldum: SwiftUI preview’ları, CoreData modelleri referans verildiğinde sorun çıkarıyor.

Ama bunun basit bir geçici çözümle düzeltilebilecek bir yol sorunu olduğunu düşünürken, o kadar basit değildi. Evet, bir yol sorunu var, ama preview’ları çözerken birden fazla hata seviyesiyle karşılaştım. Ve yol boyunca SwiftUI preview’larını nasıl debug edeceğimi öğrendim. Öğrendiklerimi paylaşayım…

#1: Package Manifest’te Açık Bağımlılıklar

Her şeyden önce. Point-Free’nin modülerleştirme yaklaşımını kullanmak, elle yönetmen gereken bir Package.swift dosyan olacağı anlamına gelir. Her modül için bir target, bir testTarget ve bir library girişi eklersin ve her target için bağımlılıkları belirtmen gerekir. Xcode burada o dosyada yaptığın değişiklikleri tanımaktan başka hiçbir şekilde yardımcı olmuyor. Çok sayıda paket varken manifest dosyası oldukça büyüyebilir ve şu an bunu kolaylaştıracak bir yardım bilmiyorum. İşte şu anda benim manifest’im şöyle görünüyor:

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

Bu dosyayı elle yönetmenin sorunu sadece manuel iş değil. Xcode bağımlılıklar konusunda tutarsız davranıyor gibi görünüyor: Örneğin Simülatörü hedefleyen normal bir build yaptığında, bir bağımlılığın bağımlılığı otomatik olarak target’ına bağlanıyor gibi görünüyor. Yani TimerFeature’ım Utility‘yi import ediyorsa ama bu TimerFeature target’ının altında bağımlılık olarak listelenmemişse, Xcode yine de hatasız derleyebilir çünkü başka bir bağımlılık, örneğin Model de Utility’ye bağımlıdır ve Xcode dolaylı olarak TimerFeature içinde Utility’ye erişebilir.

Bu kulağa çok faydalı gelse de oldukça sinir bozucu olabiliyor çünkü SwiftUI preview’ları farklı çalışıyor. Görebildiğim kadarıyla, preview’larda bu geçişli tür dolaylı import’lar çalışmıyor. Aynı şey testleri çalıştırırken de geçerli gibi görünüyor (en azından bazen). Başka bir deyişle: Her target için dependencies‘i her zaman iki kez kontrol etmek ve bir target’ta yaptığın her import‘u Package.swift manifest dosyanızdaki ilgili target’a eklemeyi unutmamak önemli.

Belki gelecekte birisi bunu kolaylaştıracak bir araç yazar.

#2: Üretilen Kodun Xcode Tarafından Güvenilir Şekilde Algılanmaması

Karşılaştığım bir diğer sorun, build’lerim başarılı olsa bile, Xcode’un (“Build succeeded” dialogunu gösterdikten sonra) PreviewProvider içindeki editörde FocusTimer’ı bulamadığını belirten bir hata göstermesiydi:

Error stating <code>FocusTimer</code> can’t be found in scope despite <code>import Model</code> & successful build.

Bu üretilmiş dosyaları modelde her değişiklik yaptığında silip yeniden oluşturman gerekecek (veritabanı migrasyon sorunlarını önlemek için bunu nadiren yapman gerekir zaten). Ek olarak, Xcode’da modeli seç ve Codegen’i Manual/None olarak ayarla.

Clicking on “Diagnostics” just stating “MessageError: Connection interrupted”.

Bunu yaptıktan sonra editör artık hata göstermiyor.

#3: SwiftUI Diagnostics != SwiftUI Crash Reports

İşte SwiftUI preview’ları başarısız olduğunda Diagnostics butonuna bastıktan sonra böyle hatalardan nasıl faydalanılacağına dair (benim gibi) merak edenler için bir öğrenim:

Clicking on “Diagnostics” just stating “MessageError: Connection interrupted”.

Vurgulanan klasörün içeriğini görüntülemek, SwiftUI preview build’i hakkında farklı türde ayrıntılar tutan birçok dosyayı ortaya çıkaracaktır. Debug için en faydalı dosya CrashLogs klasörünün içindedir; burada Xcode’da çift tıklayarak kolayca açabileceğimiz bir veya birden fazla .ips dosyası bulabilirsin:

Contents of Xcode Previews .ips file with a proper error console output.

Neyse ki burada Twitter’daki Swift topluluğundan nazik bir geliştiricinin işareti yardımcı oldu ve beni StackOverflow’daki bu cevaba yönlendirdi.

Temel olarak Xcode’da (veya SwiftPM’de?) şu an bir bug olduğunu ve Bundle.module‘ün SwiftUI preview’larında yanlış yolu gösterdiğini söylüyor. Bunu düzeltmek için özel bir aramayla bir Bundle extension’ı eklemeyi öneriyorlar. İşte benim kodlama ve yorum tarzıma uyacak şekilde biraz düzenlenmiş tam kod:

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

Bu kodu kopyalayıp yapıştırırken packageName ve targetName değişkenlerini kendi paket ve target adlarına göre düzenlemeyi unutma.

Geçici çözümü #if DEBUG içine sardığıma dikkat et, böylece production kodum yanlışlıkla bu yol aramasını kullanmaz ve resmi Bundle.module‘e güvenir. Ayrıca StackOverflow’da bulunan geçici çözüm kodundaki fatalError’ı kaldırdım, böylece özel arama yollarında bir Bundle bulamazsa çökmek yerine Bundle.module’ü fallback olarak return ediyorum. Bu, kodu daha dayanıklı yapmayı ve gelecekteki bir Xcode sürümünde bu bug düzeltilse bile özel arama yolları artık çalışmasa bile çalışmaya devam etmesini amaçlıyor.

Şimdi PersistenceController’da yapmam gereken son değişiklik, Bundle.module çağrısını yeni Bundle.swiftUIPreviewsCompatibleModule çağrısıyla değiştirmekti:

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

Ve sonunda SwiftUI preview’larım tekrar çalışmaya başladı!

Bu yazıyı beğendin mi? Swift ipuçları ve indie geliştirici güncellemeleri için Bluesky ve Mastodon üzerinden takip et.