こんにちはこんばんは。イノウエです。
今までWebアプリケーションエンジニアとしてやってきましたが、なんと最近iOSアプリ開発に挑戦しております。(血迷ってはいません)
欲しい機能が揃っている Todoリストアプリが App Store を漁りまくっても存在せず…もう自分で作るか!と1からコネコネしております🐈 ←これはネコ
🐈🐈🐈
Todoリストに繰り返し通知機能を追加しようとしたところ、実機確認でアプリが起動直後に落ちるようになってしまいました。
原因特定に結構手こずったので、本ブログに記録を残したいと思います。
目次
やろうとしたこと
通知の繰り返し間隔(日、曜日、月毎)を表現するために RepeatInterval という enum を定義し、Todo モデルに repeatInterval: RepeatInterval? を追加しました。
@Model
class Todo {
var repeatInterval: RepeatInterval? = nil // 新しく追加
}
ビルドは通ったので実機確認しようとしたところ、起動直後に落ちるようになってしまいました。
症状
エラーメッセージを確認したところ、以下のように書かれていました。
keypath repeatInterval not found in entity Todo
*** Terminating app due to uncaught exception 'NSInvalidArgumentException'
モデルの定義が間違っているのかと思い何度も確認したのですが、そこは問題ないようでした。
ビルドは通っているのにランタイムでクラッシュするという状況です。
終わらない調査
「スキーマ変更でマイグレーションが失敗してるのか?」
と思い、アプリをデバイスから削除して再インストールしてみたものの、全く解消しません。
そこで、App Group(group.com.xxx.xxx)経由で共有している SwiftData ストアはアプリを削除しても残り続けるという iOS の仕様があるそうなので、コードでストアファイルを明示的に削除する実装を追加しました。
最初は .store ファイルだけを削除していたのですが、それでもクラッシュが止まらず…。
調べると SQLite は
.store.store-shm.store-wal
の3ファイル構成になっていて、.store-wal が残っていると古いスキーマが復元されてしまうそうです。
for suffix in ["", "-shm", "-wal"] {
let url = groupURL.appendingPathComponent("todo-list.store\(suffix)")
try? FileManager.default.removeItem(at: url)
}
3ファイルまとめて削除するよう変えましたが、それでもまだクラッシュが続きました。(もう勘弁してくれ🙃)
ログを見ると Schema version changed (2 → 3), recreating store. と出ており、ストアの削除自体は成功しています。
つまりストアの問題ではなく、クエリの実行時に落ちているということになります。
クラッシュのタイミングを絞り込んでいくと、#Predicate で repeatInterval を参照している箇所でした。
調べてみると、SwiftData の既知の制限として #Predicate の中で optional な enum プロパティを参照するとクラッシュするというものがあるようです。
iOS 17 以降ずっと報告されており、Apple Developer Forums や Hacking with Swift などでも広く確認されていました。
対処法
スキーマ変更時は3ファイルまとめて削除する
.store だけでは .store-wal から古いスキーマが復元されてしまうので、3ファイルセットで削除します。
for suffix in ["", "-shm", "-wal"] {
let url = groupURL.appendingPathComponent("todo-list.store\(suffix)")
try? FileManager.default.removeItem(at: url)
}
#Predicate で optional な enum を直接参照しない
// NG: クラッシュする(SwiftData の既知の制限)
let descriptor = FetchDescriptor<Todo>(
predicate: #Predicate { $0.repeatInterval != nil }
)
// OK: 全件取得してから Swift 側でフィルタ
let all = try context.fetch(FetchDescriptor<Todo>())
let todos = all.filter { $0.repeatInterval != nil }
全件フェッチしてから Swift 側でフィルタするのが現状の回避策です。
件数が多い場合はパフォーマンスに注意が必要ですが、今回はそこまで件数が増えない想定なので、とりあえずこれで対応しました。
【追記 2026/06/19】iOS 27 で対応予定
WWDC 2026 にて、iOS 27 で #Predicate 内での enum の直接参照がサポートされることが発表されました。
参考:What’s new in SwiftData - WWDC26
iOS 27 以降では回避策なしにそのまま書けるようになるようです🎉
// iOS 27 以降はこれがそのまま動く(予定)
let descriptor = FetchDescriptor<Todo>(
predicate: #Predicate { $0.repeatInterval != nil }
)
それまでは全件フェッチしてフィルタする方法でしのぐしかなさそうです。
こちらのブログは初心者エンジニアが勉強の記録やアウトプットの一環として執筆しております。
内容に誤りがある可能性が多大にありますのでご了承ください。