0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Codeで睡眠記録iOSアプリをPhase 1〜7まで一気に実装した話

0
Last updated at Posted at 2026-06-22

はじめに

「Claude Codeに実装指示書を渡すだけでiOSアプリが作れるのか」という疑問をそのまま実験してみました。対象はiOS 17 / watchOS 10 のSwiftUI + SwiftDataアプリ「SleepLog+」(睡眠録音・いびき傾向の参考ラベル付け・育成キャラ付き)。Phase 1〜7まで約7フェーズに分け、指示書をレビューしながら段階的に実装を進めています。

重要な前提として、このアプリは医療機器ではなく、いびき傾向の「参考ラベル」しか出しません。「無呼吸を診断」「重症度」「AHI」といった医療表現は全フェーズを通じて禁止ワードとして管理しました。


やったこと

指示書ドリブンの開発フロー

各Phaseで以下のサイクルを回しました。

  1. Claudeと相談しながら実装指示書(Markdown)を作成
  2. 指示書をClaude Codeに渡して実装させる
  3. 人間がコードレビュー・フック先の実在確認
  4. ビルド検証 → 必要なら修正指示

指示書には「フック先の実名」を必ず書く設計が肝でした。

## 正確なフック先(実コード準拠)
- 通常終了 — 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が不可欠だと実感しました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?