axiom-realm-migration-ref について
Realm から SwiftData へアプリのデータモデルと保存データを移す開発者向けのリファレンススキルです。モデルや並行処理の変換、移行方式、CloudKit への移行、データ検証を扱います。
主な機能
- 01Realm から SwiftData へのモデル変換例
- 02Swift Concurrency へのスレッド処理移行
- 03直接移行と変換レイヤー方式
- 04CloudKit 同期への移行ガイド
- 05データ整合性と移行の確認
活用シーン
- 01Realm アプリの SwiftData 移行計画
- 02Realm のリレーションとスレッド処理の変換
- 03インポートしたレコードとリレーションの検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-realm-migration-refインストール後、Claude Code で/axiom-realm-migration-refを入力して読み込まれたか確認します。
詳細
Realm から SwiftData への移行 — リファレンスガイド
目的: Realm から SwiftData への完全な移行手順 Swift バージョン: Swift 5.9+(厳密な並行処理を推奨する Swift 6) iOS バージョン: iOS 17+(iOS 26+ を推奨) 背景: Realm Device Sync は 2025 年 9 月 30 日に終了します。このガイドは、期限前に移行する Realm ユーザーにとって必須です。
重要なスケジュール
Realm Device Sync の廃止期限 = 2025 年 9 月 30 日
アプリで Realm Sync を使用している場合:
- ⚠️ 2025 年 9 月 30 日までに必ず移行してください
- ✅ SwiftData が推奨される代替手段です
- ⏰ 残り時間:現在の日付によりますが、本番アプリの移行には 2〜8 週間かかります
このガイドには、移行を成功させるために必要なすべてが記載されています。
移行戦略の概要
Phase 1 (Week 1-2): Preparation & Planning
├─ Audit current Realm usage
├─ Understand model relationships
├─ Plan data migration path
└─ Set up test environment
Phase 2 (Week 2-3): Development
├─ Create SwiftData models from Realm schemas
├─ Implement data migration logic
├─ Convert threading model to async/await
└─ Test with real data
Phase 3 (Week 3-4): Migration
├─ Migrate existing app users' data
├─ Run in parallel (Realm + SwiftData)
├─ Verify CloudKit sync works
└─ Monitor for issues
Phase 4 (Week 4+): Production
├─ Deploy update with parallel persistence
├─ Gradual cutover from Realm to SwiftData
├─ Deprecate Realm code
└─ Monitor CloudKit sync health
パート 1:パターンの対応関係
モデル定義の変換
Realm → SwiftData:基本モデル
// REALM
class RealmTrack: Object {
@Persisted(primaryKey: true) var id: String
@Persisted var title: String
@Persisted var artist: String
@Persisted var duration: TimeInterval
@Persisted var genre: String?
}
// SWIFTDATA
@Model
final class Track {
@Attribute(.unique) var id: String // remove if using CloudKit sync
var title: String
var artist: String
var duration: TimeInterval
var genre: String?
init(id: String, title: String, artist: String, duration: TimeInterval, genre: String? = nil) {
self.id = id
self.title = title
self.artist = artist
self.duration = duration
self.genre = genre
}
}
主な違い:
- Realm:
@Persisted(primaryKey: true)→ SwiftData:@Attribute(.unique)(CloudKit 同期ではサポートされていません。CloudKit を使用する場合は削除してください) - Realm:暗黙的な初期化 → SwiftData:明示的な初期化が必要
- Realm:
Object基底クラス → SwiftData:final classに付ける@Modelマクロ
Realm → SwiftData:リレーションシップ
// REALM: One-to-Many
class RealmAlbum: Object {
@Persisted(primaryKey: true) var id: String
@Persisted var title: String
@Persisted var tracks: RealmSwiftCollection<RealmTrack>
}
// SWIFTDATA: One-to-Many
@Model
final class Album {
@Attribute(.unique) var id: String
var title: String
@Relationship(deleteRule: .cascade, inverse: \Track.album)
var tracks: [Track] = []
}
@Model
final class Track {
@Attribute(.unique) var id: String
var title: String
var album: Album? // Inverse automatically maintained
}
主な違い:
- Realm:明示的な
RealmSwiftCollection型 → SwiftData:ネイティブの[Track]配列 - Realm:手動でのリレーションシップ管理 → SwiftData:逆方向のリレーションシップを自動管理
- Realm:削除ルールなし → SwiftData:
deleteRule: .cascade / .nullify / .deny
Realm → SwiftData:インデックス
// REALM
class RealmTrack: Object {
@Persisted(primaryKey: true) var id: String
@Persisted(indexed: true) var genre: String
@Persisted(indexed: true) var releaseDate: Date
}
// SWIFTDATA
@Model
final class Track {
@Attribute(.unique) var id: String
@Attribute(.indexed) var genre: String = ""
@Attribute(.indexed) var releaseDate: Date = Date()
}
パート 2:スレッドモデルの変換
Realm のスレッド処理 → Swift の並行処理
Realm:手動のスレッド処理
class RealmDataManager {
func fetchTracksOnBackground() {
DispatchQueue.global().async {
let realm = try! Realm() // Must get Realm on each thread
let tracks = realm.objects(RealmTrack.self)
DispatchQueue.main.async {
self.updateUI(tracks: Array(tracks))
}
}
}
func saveTrackOnBackground(_ track: RealmTrack) {
DispatchQueue.global().async {
let realm = try! Realm()
try! realm.write {
realm.add(track)
}
}
}
}
問題点:
- 手動の DispatchQueue によるスレッド処理はエラーが起きやすい
- オブジェクトに誤ったスレッドからアクセスしやすい
- コンパイル時の保証がない
SwiftData:アクターベースの並行処理
actor SwiftDataManager {
let modelContainer: ModelContainer
func fetchTracks() async -> [Track] {
let context = ModelContext(modelContainer)
let descriptor = FetchDescriptor<Track>()
return (try? context.fetch(descriptor)) ?? []
}
func saveTrack(_ track: Track) async {
let context = ModelContext(modelContainer)
context.insert(track)
try? context.save()
}
}
// Usage (automatic thread handling)
@MainActor
class ViewController: UIViewController {
@State private var tracks: [Track] = []
private let manager: SwiftDataManager
func loadTracks() async {
tracks = await manager.fetchTracks()
}
}
利点:
- DispatchQueue の手動操作が不要
- コンパイル時のスレッド安全性
- アクターによる分離を自動化
- Swift 6 の厳密な並行処理に対応
よく使われるスレッド処理パターン
| Realm のパターン | SwiftData のパターン |
|---|---|
DispatchQueue.global().async | バックグラウンドアクターでの async/await |
realm.write { } | context.insert() + context.save() |
| 手動で管理するスレッドローカルの Realm インスタンス | 共有の ModelContainer + バックグラウンドの ModelContext |
Thread.isMainThread のチェック | @MainActor アノテーション |
パート 3:スキーマ移行戦略
シンプルなスキーマ移行(直接変換)
シンプルなスキーマ(テーブル 5 個未満、フィールド 10 個未満)のアプリでは、直接移行は簡単です。
actor SchemaImporter {
let realmPath: String
let modelContainer: ModelContainer
func migrateFromRealm() async throws {
// 1. Open Realm database
let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath))
let realm = try await Realm(configuration: realmConfig)
// 2. Create SwiftData context
let context = ModelContext(modelContainer)
// 3. Migrate each model type
try migrateAllTracks(from: realm, to: context)
try migrateAllAlbums(from: realm, to: context)
try migrateAllPlaylists(from: realm, to: context)
// 4. Save all at once
try context.save()
print("Migration complete!")
}
private func migrateAllTracks(from realm: Realm, to context: ModelContext) throws {
let realmTracks = realm.objects(RealmTrack.self)
for realmTrack in realmTracks {
let sdTrack = Track(
id: realmTrack.id,
title: realmTrack.title,
artist: realmTrack.artist,
duration: realmTrack.duration,
genre: realmTrack.genre
)
context.insert(sdTrack)
}
}
private func migrateAllAlbums(from realm: Realm, to context: ModelContext) throws {
let realmAlbums = realm.objects(RealmAlbum.self)
for realmAlbum in realmAlbums {
let sdAlbum = Album(
id: realmAlbum.id,
title: realmAlbum.title
)
context.insert(sdAlbum)
// Connect relationships after creating all records
for realmTrack in realmAlbum.tracks {
if let sdTrack = findTrack(id: realmTrack.id, in: context) {
sdAlbum.tracks.append(sdTrack)
}
}
}
}
private func findTrack(id: String, in context: ModelContext) -> Track? {
let descriptor = FetchDescriptor<Track>(
predicate: #Predicate { $0.id == id }
)
return try? context.fetch(descriptor).first
}
}
複雑なスキーマ移行(変換レイヤー)
複雑なスキーマ、多数の計算プロパティ、またはデータ変換を含むアプリ向け:
// Step 1: Define transformation layer
struct TrackDTO {
let realmTrack: RealmTrack
var id: String { realmTrack.id }
var title: String { realmTrack.title }
var cleanTitle: String { realmTrack.title.trimmingCharacters(in: .whitespaces) }
var durationFormatted: String {
let minutes = Int(realmTrack.duration) / 60
let seconds = Int(realmTrack.duration) % 60
return String(format: "%d:%02d", minutes, seconds)
}
}
// Step 2: Migrate through transformation layer
actor ComplexMigrator {
let modelContainer: ModelContainer
func migrateWithTransformation(from realm: Realm) throws {
let context = ModelContext(modelContainer)
let realmTracks = realm.objects(RealmTrack.self)
for realmTrack in realmTracks {
let dto = TrackDTO(realmTrack: realmTrack)
// Transform data during migration
let sdTrack = Track(
id: dto.id,
title: dto.cleanTitle, // Cleaned version
artist: realmTrack.artist,
duration: realmTrack.duration
)
context.insert(sdTrack)
}
try context.save()
}
}
パート 4:CloudKit 同期への移行
Realm Sync → SwiftData CloudKit
非推奨となった Realm Sync は、自動同期を提供していました。SwiftData は CloudKit を直接使用します。
// REALM SYNC: Automatic but deprecated
let config = Realm.Configuration(
syncConfiguration: SyncConfiguration(user: app.currentUser!)
)
// SWIFTDATA: CloudKit (recommended replacement)
let schema = Schema([Track.self, Album.self])
let config = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private("iCloud.com.example.MusicApp")
)
let container = try ModelContainer(for: schema, configurations: config)
同期ステータスの監視
@MainActor
class CloudKitSyncMonitor: ObservableObject {
@Published var isSyncing = false
@Published var lastSyncDate: Date?
@Published var syncError: Error?
let modelContainer: ModelContainer
func startMonitoring() {
// Monitor CloudKit sync notifications
NotificationCenter.default.addObserver(
forName: NSNotification.Name("CloudKitSyncDidComplete"),
object: nil,
queue: .main
) { [weak self] _ in
self?.isSyncing = false
self?.lastSyncDate = Date()
}
}
func syncNow() async {
isSyncing = true
do {
let context = ModelContext(modelContainer)
// SwiftData sync happens automatically
// Manually fetch to trigger sync
let descriptor = FetchDescriptor<Track>()
_ = try context.fetch(descriptor)
} catch {
syncError = error
}
isSyncing = false
}
}
移行のタイミング:Realm Sync → CloudKit
Timeline:
Week 1-2: Development & Testing
├─ Create SwiftData models
├─ Test migrations in non-CloudKit mode
└─ Prepare CloudKit configuration
Week 3: CloudKit Sync Testing
├─ Enable CloudKit in test build
├─ Verify sync works with small datasets
├─ Test multi-device sync
└─ Test conflict resolution
Week 4+: Production Rollout
├─ Deploy app with SwiftData + CloudKit
├─ Initially run parallel (Realm Sync + SwiftData CloudKit)
├─ Monitor both sync mechanisms
├─ Gradually deprecate Realm Sync
└─ Final cutoff before Sept 30, 2025
パート 5:実際の移行シナリオ
シナリオ A:小規模アプリ(10,000 レコード未満)
期間:1〜2 週間 データサイズ:10 MB 未満
// 1. Export Realm data
let realmPath = Realm.Configuration.defaultConfiguration.fileURL!
// 2. Migrate in background task
actor SmallAppMigration {
let modelContainer: ModelContainer
func migrateSmallApp() async throws {
let realmConfig = Realm.Configuration(fileURL: realmPath)
let realm = try await Realm(configuration: realmConfig)
let context = ModelContext(modelContainer)
// All-at-once migration (safe for < 10k records)
let allTracks = realm.objects(RealmTrack.self)
for realmTrack in allTracks {
let track = Track(from: realmTrack)
context.insert(track)
}
try context.save()
print("✅ Migrated \(allTracks.count) tracks")
}
}
// 3. Deploy
// Option 1: Migrate on first launch (offline)
// Option 2: Provide manual "Migrate Data" button
// Option 3: Automatic migration in background
シナリオ B:中規模アプリ(100,000〜1,000,000 レコード)
期間:3〜4 週間 データサイズ:100 MB〜1 GB 課題:進捗報告、メモリ管理
actor MediumAppMigration {
let modelContainer: ModelContainer
let realmPath: String
typealias ProgressCallback = (Int, Int) -> Void
func migrateMediumApp(onProgress: @MainActor ProgressCallback) async throws {
let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath))
let realm = try await Realm(configuration: realmConfig)
let context = ModelContext(modelContainer)
let allTracks = realm.objects(RealmTrack.self)
let totalCount = allTracks.count
// Chunk-based migration for memory efficiency
var count = 0
for chunk in Array(allTracks).chunked(into: 5000) {
for realmTrack in chunk {
let track = Track(from: realmTrack)
context.insert(track)
}
// Save periodically
try context.save()
count += chunk.count
await onProgress(count, totalCount)
// Check for cancellation
if Task.isCancelled {
throw CancellationError()
}
}
}
}
// 4. Show progress UI
@MainActor
class MigrationViewController: UIViewController {
@IBOutlet weak var progressView: UIProgressView!
@IBOutlet weak var statusLabel: UILabel!
func startMigration() {
Task {
do {
try await migrator.migrateMediumApp { current, total in
self.progressView.progress = Float(current) / Float(total)
self.statusLabel.text = "Migrated \(current) of \(total)..."
}
self.statusLabel.text = "✅ Migration complete!"
} catch {
self.statusLabel.text = "❌ Migration failed: \(error)"
}
}
}
}
シナリオ C:大規模アプリ(エンタープライズ、1,000,000 レコード超)
期間:6〜8 週間 データサイズ:1 GB 超 課題:ダウンタイムの最小化、データの完全性、ロールバック計画
class EnterpriseGradualMigration {
let coreDataStack: CoreDataStack // Existing Realm
let modelContainer: ModelContainer
let batchSize = 10000
// Phase 1: Parallel migration
func startGradualMigration() async {
var offset = 0
let totalRecords = countAllRecords()
while offset < totalRecords {
let batch = fetchRealmBatch(limit: batchSize, offset: offset)
try? await migrateBatch(batch)
offset += batchSize
await reportProgress(offset, totalRecords)
}
}
private func migrateBatch(_ batch: [RealmTrack]) async throws {
let context = ModelContext(modelContainer)
for realmTrack in batch {
let track = Track(from: realmTrack)
context.insert(track)
track.migrationStatus = .completedPhase1
}
try context.save()
// Give main thread time to breathe
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
}
// Phase 2: Verify all migrated
func verifyMigrationComplete() async throws {
let sdContext = ModelContext(modelContainer)
let sdCount = try sdContext.fetch(FetchDescriptor<Track>())
let realmCount = countAllRealmRecords()
guard sdCount.count == realmCount else {
throw MigrationError.countMismatch(sd: sdCount.count, realm: realmCount)
}
print("✅ Verified: \(sdCount.count) records migrated")
}
// Phase 3: Rollback plan
func rollbackToRealm() {
// Keep Realm database intact until 100% confident
// Only delete Realm after running stable on SwiftData for 2+ weeks
}
}
パート 6:テストと検証
データ完全性チェックリスト
SwiftData を本番環境で稼働させる前に:
@MainActor
class MigrationVerifier {
func verifyMigration() async throws {
print("🔍 Running migration verification...")
// 1. Count verification
let sdCount = try await countSwiftDataRecords()
let realmCount = countRealmRecords()
print("✓ Record count: SD=\(sdCount), Realm=\(realmCount)")
guard sdCount == realmCount else {
throw VerificationError.countMismatch
}
// 2. Data integrity sampling (spot checks)
try await verifySampleRecords(count: min(100, sdCount / 10))
print("✓ Spot checked 100 records - all valid")
// 3. Relationship integrity
try await verifyRelationships()
print("✓ All relationships intact")
// 4. CloudKit sync test
try await verifyCloudKitSync()
print("✓ CloudKit sync working")
// 5. Performance test
try await verifyPerformance()
print("✓ Query performance acceptable")
print("✅ All verifications passed!")
}
private func verifySampleRecords(count: Int) async throws {
let sdContext = ModelContext(modelContainer)
let descriptor = FetchDescriptor<Track>()
let tracks = try sdContext.fetch(descriptor)
let sample = Array(tracks.prefix(count))
for track in sample {
// Verify fields populated
assert(!track.id.isEmpty, "Track has empty ID")
assert(!track.title.isEmpty, "Track has empty title")
assert(track.duration > 0, "Track has invalid duration")
}
}
private func verifyRelationships() async throws {
let sdContext = ModelContext(modelContainer)
let albumDescriptor = FetchDescriptor<Album>()
let albums = try sdContext.fetch(albumDescriptor)
for album in albums {
// Verify inverse relationships
for track in album.tracks {
assert(track.album?.id == album.id, "Relationship broken")
}
}
}
private func verifyCloudKitSync() async throws {
let sdContext = ModelContext(modelContainer)
// Insert test record
let testTrack = Track(
id: "test-" + UUID().uuidString,
title: "Test Track",
artist: "Test Artist",
duration: 240
)
sdContext.insert(testTrack)
try sdContext.save()
// Verify CloudKit sync initiated
// (Check iCloud → iPhone → Settings → iCloud for sync status)
print("ℹ️ Check iCloud app to verify sync initiated")
}
private func verifyPerformance() async throws {
let sdContext = ModelContext(modelContainer)
let start = Date()
let descriptor = FetchDescriptor<Track>(
sortBy: [SortDescriptor(\.title)]
)
_ = try sdContext.fetch(descriptor)
let elapsed = Date().timeIntervalSince(start)
print("Fetch time: \(String(format: "%.2f", elapsed))s")
guard elapsed < 2.0 else {
throw VerificationError.performanceIssue
}
}
}
パート 7:トラブルシューティング
一般的な移行時の問題
| 問題 | 原因 | 解決策 |
|---|---|---|
| "Property must have default" | CloudKit の制約 | デフォルト値を追加します:var title: String = "" |
| リレーションシップが同期されない | 逆方向のリレーションシップがない | inverse: \Track.album を追加します |
| 同期が停止する | CloudKit の認証に関する問題 | 「設定」→「iCloud」→「CloudKit」を確認します |
| インポート中にメモリ使用量が増大する | チャンク化されていない | バッチインポート(1回につき1000件)を実装します |
| データ損失 | バックアップがない | 移行後2週間はRealmのコピーを保持します |
パート 8:成功基準
以下を満たした場合、移行は成功です。
- すべてのデータが正しく移行されている(件数が一致する)
- サンプルレコードの検証に合格する(100件以上のレコードを抜き取り検査する)
- リレーションシップが維持されている(逆方向のリレーションシップが機能する)
- CloudKit の同期が有効で、正常に機能している
- パフォーマンスが許容範囲内である(クエリが1秒未満)
- データ競合が発生しない(Swift 6 の厳密な並行処理)
- 実機でテスト済みである(シミュレーターだけでなく)
- ロールバック計画が文書化され、テスト済みである
- Realm データベースをバックアップとして2週間保持する
- 本番環境で1週間、クラッシュがゼロである
クイックリファレンス:コマンドチェックリスト
# 1. Audit Realm usage
grep -r "RealmTrack\|RealmAlbum" . --include="*.swift"
# 2. Count Realm records (in app)
let realm = try! Realm()
let count = realm.objects(RealmTrack.self).count
# 3. Export Realm database
cp ~/Library/Developer/Realm/my_realm.realm ~/Downloads/backup.realm
# 4. Test SwiftData models
// Create in-memory test container
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Track.self, configurations: config)
# 5. Verify CloudKit
Settings → [Your Name] → iCloud → Check CloudKit status
リソース
WWDC:2024-10137
ドキュメント:/swiftdata
スキル:axiom-swiftdata、axiom-swift-concurrency、axiom-database-migration
作成日:2025-11-30 ステータス:本番環境対応の移行ガイド 緊急度:Realm Device Sync は2025年9月30日に終了 移行所要時間の目安:アプリの複雑さに応じて2~8週間