はじめに
「Claude Codeに実装指示書を渡すだけでiOSアプリが作れるのか」という疑問をそのまま実験してみました。対象はiOS 17 / watchOS 10 のSwiftUI + SwiftDataアプリ「SleepLog+」(睡眠録音・いびき傾向の参考ラベル付け・育成キャラ付き)。Phase 1〜7まで約7フェーズに分け、指示書をレビューしながら段階的に実装を進めています。
重要な前提として、このアプリは医療機器ではなく、いびき傾向の「参考ラベル」しか出しません。「無呼吸を診断」「重症度」「AHI」といった医療表現は全フェーズを通じて禁止ワードとして管理しました。
やったこと
指示書ドリブンの開発フロー
各Phaseで以下のサイクルを回しました。
- Claudeと相談しながら実装指示書(Markdown)を作成
- 指示書をClaude Codeに渡して実装させる
- 人間がコードレビュー・フック先の実在確認
- ビルド検証 → 必要なら修正指示
指示書には「フック先の実名」を必ず書く設計が肝でした。
## 正確なフック先(実コード準拠)
- 通常終了 — Features/Session/SessionView.swift の EndSessionSheet の
onSave で sessionService.end(...) を呼んでいる直後。
- Watch からの終了 — AppEnvironment.wireWatchConnectivity() の
watch.onStopRequested で sessionService.cancelToFinish() を呼んでいる箇所。
これがないとClaude Codeが「どのファイルに書くか」で迷い、誤った場所に挿入します。
主要実装トピック
**LiveActivity(ロック画面表示)**では、SleepActivityAttributesをSharedフォルダに置こうとした際に「watchOSはActivityKitが使えない」という地雷を事前検出しました。対策は方式Bで専用フォルダLiveActivityShared/を切ること。
// LiveActivityShared/SleepActivityAttributes.swift
// watchOS に Shared/ ごと取り込むと ActivityKit が無くてビルドエラーになる
// → app + widget だけが参照する専用パスに分離
import ActivityKit
struct SleepActivityAttributes: ActivityAttributes {
// ...
}
**いびき分類(参考ラベル)**は「診断ではない」を守るため、出力を3種に固定しました。
enum AudioEventLabel: String, Codable, CaseIterable {
case snoreLike // 表示: "いびき傾向"
case noise // 表示: "物音"
case unknown // 表示: "不明"
}
final class HeuristicAudioEventClassifier: AudioEventClassifying {
func classify(_ event: DetectedAudioEvent, clipPath: String?) -> AudioEventLabel {
let longEnough = event.durationSeconds >= 2.0
let sustained = (event.peakDecibel - event.averageDecibel) < 8.0
if longEnough && sustained { return .snoreLike }
if event.durationSeconds < 1.0 { return .noise }
return .unknown
}
}
クリップが保存されていなくてもdBFSと継続時間だけで分類できるのがポイントです。
**育成キャラ(おやすみのなかま)**では「睡眠の質では加点しない・行動ベース」を設計の芯にしました。
struct RewardRules {
static func awards(session: SleepSession, settings: UserSettings, streakDays: Int) -> [RewardReason] {
var reasons: [RewardReason] = []
// ✅ セッションを完了した(行動)
reasons.append(.completedSession)
// ✅ 目標就寝に近い時間に開始した(行動)
if isNearBedtime(session.startedAt, target: settings.targetBedtime) {
reasons.append(.nearBedtime)
}
// ❌ 睡眠時間・すっきり感・HealthKit値は一切参照しない
return reasons
}
}
ハマったポイント
x86_64シミュレータ + iOS 26.5 + SwiftData の SIGILL
SwiftDataを使うテストがすべてSIGILLでクラッシュしました。最初は自分のコードのバグを疑いましたが、「自分が引数追加しか触っていないPhase 1の既存テストも同じ箇所で落ちる」ことで環境起因と判断。
// クラッシュスタック(簡略)
Thread 1: EXC_BAD_INSTRUCTION (SIGILL)
frame 0: SwiftData.ModelContext.fetch<...>
frame 1: SleepSessionServiceTests.testStart
Apple Silicon環境か iOS 17/18系シミュレータでは再現しない問題で、「ロジックが正しいことは純粋テストで担保し、SwiftData依存テストはSIGILLで実行不可」という状態として記録しました。
String(localized:) のキーにはStaticStringが必要
Phase 6でローカライズを網羅した際に踏みました。
// ❌ これはコンパイルエラー(補間はStaticStringに使えない)
String(localized: "duration_\(minutes)_min", defaultValue: "\(minutes)分")
// ✅ キーは静的に固定し、位置指定引数で値を渡す
String(localized: "duration_minutes",
defaultValue: "%1$lld分",
arguments: [minutes])
Xcodeのローカライズ自動抽出が使えないヘッドレス環境では全208エントリを手作業でカタログに書き込みました。
Formatter境界テストがロケール依存になる
ローカライズ後、"45分" のような完全一致テストが英語ロケール実行時に落ちました。
// Before(ロケール依存で壊れる)
XCTAssertEqual(Formatters.durationText(minutes: 45), "45分")
// After(数値だけ検証)
XCTAssert(Formatters.durationText(minutes: 45).contains("45"))
学び
指示書に「禁止事項」と「フック先の実名」を書くと精度が上がる
Claude Codeへの指示で効いたのは、「何をするか」より「何をしてはいけないか」と「どのファイルのどの関数の直後に挿入するか」の2点でした。前者は医療表現ガードレール、後者はコードのフック先です。どちらも曖昧だとClaude Codeが合理的な推測をして別の場所に書いてしまいます。
レビューサイクルを「裏取り」として設計する
レビュワー(人間)の役割を「承認する」ではなく「実コードとフック先を照合して地雷を先に潰す」として設計したのが効果的でした。「watchOSでActivityKitが使えない」「@MainActor隔離型をデフォルト引数にすると警告が出る」といったビルド前に検出できる問題は、レビュー段階で弾けました。
バックアップの残高設計は「台帳合計から再計算」が鉄則
育成ポイントのバックアップで、残高(balance)をDTOに含めると復元時にmergeで増殖するリスクがあります。RewardEventの台帳だけを保存し、idempotencyKeyで重複除去した後に合計から再計算する設計にすることで、複数回インポートしても増殖しない構造になりました。
// インポート後は必ず台帳から再計算(残高直接復元はしない)
func recomputeBalance() {
let income = rewardEvents.filter { $0.amount > 0 }.map(\.amount).reduce(0, +)
let spend = rewardEvents.filter { $0.amount < 0 }.map(\.amount).reduce(0, +)
petWallet.balance = income + spend // マイナスにならないよう max(0, ...) もかける
}
(個人の感想)指示書さえ丁寧に作れば、Claude Codeは「実装エンジン」として非常に頼りになります。一方で「ビルドが通ること」と「見た目が意図通りか」は別問題で、SwiftUI描画の最終確認は実機やXcode Previewが不可欠だと実感しました。