SwiftData で @Attribute(.unique) を後から追加する
AI 主体で執筆
SwiftData のモデルに @Attribute(.unique) を付け忘れていた。リリース後に重複データの問題が起きてから付け直したくなった ── このときに一番怖いのは、既存ユーザー端末でマイグレーションがクラッシュすることです。60d Memo v1.0 リリース後に同じ状況に直面し、クラウド同期前提の設計を活かして 3 段階で安全に切り替えた記録をまとめます。
前提:なぜ最初から付けなかったのか
60d Memo はメモをユーザーの Google ドライブに保存するアプリで、ローカルには SwiftData で同じデータのコピーを持ちます。メモの主キーは UUID。つまり仕様上、衝突はほぼありえない値です。
それでも実装初期は、Drive との同期バグで同じ UUID のメモが 2 件ローカルに入るケースが稀に発生していました。@Attribute(.unique) を最初から付けていれば、そもそも重複が入らなかった。しかし当時は「後で付ければいいや」と判断し、そのまま v1.0 をリリース。リリース直前にも重複を検知する同期ロジックを組んだので、実害はほとんど起きていない ── という状態でした。
そして v1.0 配布後、根本対策としてモデル側に制約を付けることにしました。ところが、この後付けが一筋縄ではいかないのです。
一つ目の罠:@Attribute(.unique) は CloudKit と併用できない
Apple の公式ドキュメント Syncing model data across a person’s devices に、こう書かれています。
The framework synchronizes changes concurrently and at opportune times, which means CloudKit is unable to enforce the unique property option.
要するに、SwiftData + CloudKit の組み合わせで .unique 制約はサポートされない。もし CloudKit 同期を有効にしたモデルに @Attribute(.unique) を付けてしまうと、ModelContainer のロード自体が失敗します(=アプリ起動時クラッシュ)。
CloudKit で同様の一意性を担保したいときの定番パターンは次の 3 つです。
- fetch-or-create パターン: 起動時にクエリし、なければ作る。
- 手動デデュープ: 起動時やフォアグラウンド復帰時に重複を見つけてマージする。
- 複合キー: title + author などを合わせた文字列を主キーとして使う。
60d Memo の同期先は CloudKit ではなく Google Drive。CloudKit 同期は無効なので、@Attribute(.unique) は問題なく使えます。自分のアプリが CloudKit を使っているかを先に確認するのが、この機能を使う上での大前提です。
二つ目の罠:既存データに重複があるとマイグレーションが死ぬ
CloudKit を使わないなら @Attribute(.unique) は使える。でもリリース済みのアプリに後付けするときに残るのがこの罠です。
既存のローカル SQLite ストアに id が重複したレコードが 1 件でもあると、新スキーマへのマイグレーション時に一意制約違反で失敗します。SwiftData は ModelContainer の初期化時にこれを検知し、ModelContainer(for:configurations:) が throw します。普通に書いていれば、そのままアプリがクラッシュします。
ここで取れる選択肢は三つ。
- A. VersionedSchema + SchemaMigrationPlan でカスタムマイグレーションを書き、
willMigrateで重複を統合してから新スキーマに移行する(Apple 推奨の王道) - B. マイグレーション前に、コード上でローカル重複を掃除するバージョンを先に配布する。全ユーザーに浸透したのを見計らって、次のバージョンで
@Attribute(.unique)を入れる - C. マイグレーション失敗時にローカルストアを作り直す ── ただしクラウド側に正本がある場合に限る
60d Memo では B + C の合わせ技を採りました。B 単独ではデータ浸透の完了を 100% 確定できないので、念のため C の安全装置を加える、という発想です。
60d Memo の 3 段階ロールアウト
@Attribute(.unique) を追加(v1.0.x)
モデル定義に一行追加するだけ。段階 1 のビルドに更新済みの端末では、ローカル重複は既に解消されているはずなので、マイグレーションはシームレスに通る。
ModelContainer の初期化時に catch し、ローカルストアのファイルを削除してから再作成。Drive から再同期することで、ユーザーのメモは Drive に保存されている分だけ戻ってくる。
実装
モデル側:たった一行
段階 2 の変更は、これだけです。
@Model
final class Memo {
@Attribute(.unique) var id: UUID
var title: String
var body: String
// ...
}
コンテナ側:マイグレーション失敗時のフォールバック
段階 3 の安全装置は、ModelContainer を生成するヘルパーに実装します。
import Foundation
import os
import SwiftData
private let logger = Logger(subsystem: "com.example.app", category: "persistence")
enum SharedModelContainer {
static let shared: ModelContainer = {
let schema = Schema([Memo.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [config])
} catch {
// マイグレーション失敗(スキーマ変更や @Attribute(.unique) 違反など)。
// クラウドに正本があるのでローカル DB 消失は許容できる前提。
logger.error("Migration failed, recreating store: \(error)")
let url = config.url
let paths = [
url,
url.deletingPathExtension().appendingPathExtension("store-shm"),
url.deletingPathExtension().appendingPathExtension("store-wal"),
]
for path in paths {
try? FileManager.default.removeItem(at: path)
}
do {
return try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("Could not create ModelContainer after reset: \(error)")
}
}
}()
}
SwiftData のデフォルトストアは SQLite の default.store と、WAL モード用の default.store-shm / default.store-wal の 3 ファイルで構成されるので、3 つとも消します。消した後に再度 ModelContainer を作ると、空のストアでアプリが起動し、その後のクラウド同期で本来の状態に戻る、という動作になります。
注意: この手順はローカルだけにしか存在しないデータ(=まだクラウドに一度も同期していないメモ)を失う可能性があります。採用する前に、クラウドに正本があることを自分のアプリの設計で必ず確認してください。
なぜローカル消失を許容できるのか
60d Memo のコアコンセプトは「データの所有権をユーザーの Google ドライブに返す」こと。SwiftData のローカルストアはビューア兼キャッシュという位置づけで、正本は Drive 側にあります。
この設計だからこそ、「マイグレーションが死んだらローカルを作り直せばよい」という大胆な安全装置が取れる。もしローカル SwiftData が唯一の保存先だったら、この手は絶対に使えません。必ず VersionedSchema による正攻法のマイグレーションを書くべきです。
言い換えると、クラウド同期前提の設計は、スキーマ変更の自由度を一段上げる。これは今回の実装で得られた一番大きな学びでした。
ロールアウト戦略のコツ
- 段階 1 → 段階 2 の間隔は、自分のユーザー層の更新速度を見て決める。個人開発なら TestFlight 配布ユーザーの自然な更新で 1〜2 週間みておけば十分なことが多い。
- 段階 2 のリリース直後はクラッシュ監視を厚めにする(Xcode Organizer を毎日確認)。マイグレーション起因のクラッシュは起動直後に出るので見つけやすい。
- 段階 3 のフォールバックが本当に発火したかを見られるように、
Loggerやos_logでログを残しておくと後で判断できる。App Store では詳細なテレメトリを送れないが、TestFlight ではフィードバックで拾える。
他のアプリで真似する前のチェックリスト
この「マイグレーション失敗時に DB を作り直す」方針は、合う合わないが明確です。採用する前に以下を確認してください。
- [ ] ローカル SwiftData はキャッシュで、正本が他(サーバー/クラウド/ファイル)にあるか?
- [ ] ユーザーがオフラインでローカルに作った未同期データを失うリスクを、UI や仕様で説明できているか?
- [ ] リセット後、自動再同期が確実に走る導線が実装されているか?(起動時
.taskや scenePhase 監視など) - [ ] CloudKit を使っていないことを確認したか?(使っているなら
@Attribute(.unique)そのものが使えない) - [ ] ローカルデータにクラウドに送らないフィールド(下書き・検索履歴など)がある場合、それを別ストアに分けているか?
どれか一つでも外れているなら、素直に VersionedSchema + SchemaMigrationPlan でカスタムマイグレーションを書く方が安全です。
まとめ
@Attribute(.unique)は CloudKit と併用不可。自分のアプリが CloudKit を使っているか最初に確認する- 後付けの最大の罠は既存重複データでのマイグレーション失敗。そのままだと起動時クラッシュになる
- 対処の王道は
VersionedSchema+SchemaMigrationPlan - クラウドに正本がある設計なら、3 段階ロールアウト(重複修復ビルドを先に配布 → 制約追加 → 失敗時はストア再作成)で割り切る選択もある
- この割り切りはクラウド同期前提の設計がもたらす副産物。ローカル単独ストアのアプリでは真似しない