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?

SwiftUI × SwiftData で筋トレ記録アプリを作った話 — v1.0からv1.0.5までの実装ログ

0
Posted at

自己紹介

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

はじめに

こんにちは、@kotaro_ai_lab です。

本業は SES エンジニアをしながら、個人で iOS アプリを開発しています。今回は、リリース済みの筋トレ記録アプリ WorkoutDiary を題材に、SwiftUI × SwiftData の実装ノウハウを v1.0 から v1.0.5 までのバージョン履歴と一緒にまとめます。

「SwiftData 触ってみたいけど、サンプルより一段リアルな実装例が見たい」
@Observable パターンって実プロジェクトだとどう書くの?」
「Apple 審査ってどんなところで指摘されるの?」

そんな方の参考になれば嬉しいです。

技術スタック

項目 採用
言語 Swift 6
UI SwiftUI
永続化 SwiftData
課金 StoreKit 2
広告 Google AdMob
グラフ Swift Charts
プロジェクト管理 XcodeGen(project.yml
最低 OS iOS 18.0+

iOS 18 を最低要件にすることで、SwiftData の #Predicate@Observable を制約なく使えるようにしています。

1. SwiftData モデル設計

筋トレ記録アプリは、ざっくり次の 3 つのエンティティで成り立ちます。

  • Exercise:種目(ベンチプレス、スクワットなど)
  • WorkoutSession:その日のワークアウト 1 回分
  • WorkoutSet:1 セット(重量 × レップ数 or 時間 × 距離)

1-1. Exercise モデル

import Foundation
import SwiftData

@Model
final class Exercise {
    var id: UUID
    var name: String
    var category: String
    var memo: String
    var createdAt: Date

    /// 「その他」カテゴリで有酸素型を選択した場合 true
    var useCardioInput: Bool = false

    @Relationship(deleteRule: .cascade, inverse: \WorkoutSet.exercise)
    var sets: [WorkoutSet]

    init(name: String, category: String, memo: String = "", useCardioInput: Bool = false) {
        self.id = UUID()
        self.name = name
        self.category = category
        self.memo = memo
        self.createdAt = Date()
        self.sets = []
        self.useCardioInput = useCardioInput
    }

    var isCardio: Bool {
        category == ExerciseCategory.cardio.rawValue || useCardioInput
    }
}

ポイントは 2 つ。

1) すべての保存プロパティに「初期値」または init での代入を必ず書く

SwiftData は内部的にスキーマを生成するため、初期値が無いプロパティをあとから追加すると、既存ユーザーのデータでマイグレーションエラーになります。useCardioInput: Bool = false のように、新規追加プロパティはデフォルト値を必ず設定するのがハマらない秘訣です。

2) @RelationshipdeleteRule: .cascade

種目(Exercise)が削除されたら、その種目に紐づくセット(WorkoutSet)も自動で消したいので cascade を指定。inverse: を書くことで双方向の関係を明示します。これを書き忘れると、リレーションが片側通行になりクラッシュやデータ不整合を引き起こすので必須です。

1-2. WorkoutSession と WorkoutSet

@Model
final class WorkoutSession {
    var id: UUID
    var date: Date
    var note: String

    @Relationship(deleteRule: .cascade, inverse: \WorkoutSet.session)
    var sets: [WorkoutSet]

    init(date: Date = Date(), note: String = "") {
        self.id = UUID()
        self.date = date
        self.note = note
        self.sets = []
    }
}

@Model
final class WorkoutSet {
    var id: UUID
    var exercise: Exercise?
    var weight: Double
    var reps: Int
    var order: Int
    var session: WorkoutSession?

    /// 有酸素用: 時間(分)
    var duration: Double = 0
    /// 有酸素用: 距離(km)
    var distance: Double = 0

    /// ウエイトトレーニング用
    init(exercise: Exercise, weight: Double, reps: Int, order: Int) {
        self.id = UUID()
        self.exercise = exercise
        self.weight = weight
        self.reps = reps
        self.order = order
    }

    /// 有酸素トレーニング用
    init(exercise: Exercise, duration: Double, distance: Double, order: Int) {
        self.id = UUID()
        self.exercise = exercise
        self.weight = 0
        self.reps = 0
        self.order = order
        self.duration = duration
        self.distance = distance
    }
}

WorkoutSet を「ウエイト」「有酸素」両対応にするため、init を 2 つ用意して呼び分けています。最初は別モデルに分けようかと迷いましたが、SwiftData のリレーションが複雑になりすぎるので 1 モデルに統合しました。実プロダクトでは「DRY すぎるモデル分割」より「呼び出し側がシンプル」を優先する方が事故が少ないです。

2. @Observable + @MainActor パターン

iOS 17 以降、ObservableObject ではなく @Observable マクロを使うのが推奨です。WorkoutDiary では ViewModel をすべてこのパターンで書いています。

import Foundation
import SwiftData
import Observation

@Observable
final class WorkoutSessionViewModel {
    var selectedDate: Date = Date().startOfDay

    func findOrCreateSession(
        for date: Date,
        in sessions: [WorkoutSession],
        context: ModelContext
    ) -> WorkoutSession {
        if let existing = sessions.first(where: { $0.date.isSameDay(as: date) }) {
            return existing
        }
        let session = WorkoutSession(date: date.startOfDay)
        context.insert(session)
        return session
    }

    func addSet(
        to session: WorkoutSession,
        exercise: Exercise,
        weight: Double,
        reps: Int,
        context: ModelContext
    ) {
        let order = session.sets.count
        let workoutSet = WorkoutSet(exercise: exercise, weight: weight, reps: reps, order: order)
        workoutSet.session = session
        session.sets.append(workoutSet)
    }
}

UI に直接触れる ViewModel やサービスには @MainActor を付けます。

@MainActor
final class AchievementService {
    static let shared = AchievementService()
    private init() {}
    // ...
}

@MainActor を付けておくと「このクラスのメソッドは UI スレッドからしか呼ばれない」ことが型レベルで保証され、Swift 6 のデータ競合チェックを安全にパスできます。

3. 実装サンプル:自己ベスト(PR)検出

筋トレアプリで地味に大事なのが「自己ベスト更新」の検出。WorkoutDiary では PRDetector という enum でロジックを分離しています。

import Foundation
import SwiftData

struct PersonalRecord: Identifiable {
    let id = UUID()
    let exerciseName: String
    let newWeight: Double
    let previousWeight: Double
    let isCardio: Bool
}

enum PRDetector {
    /// セッション内の全種目について PR を一括検出
    static func detectPRs(
        session: WorkoutSession,
        allSessions: [WorkoutSession]
    ) -> [PersonalRecord] {
        // 当日以外のセッションを過去履歴とする
        let previousSessions = allSessions.filter {
            !$0.date.isSameDay(as: session.date)
        }

        var results: [PersonalRecord] = []
        for group in session.exerciseGroups {
            let isCardio = group.exercise.isCardio
            let maxValue = isCardio
                ? (group.sets.map(\.duration).max() ?? 0)
                : (group.sets.map(\.weight).max() ?? 0)

            guard maxValue > 0 else { continue }

            let previousMax = previousSessions.flatMap { $0.sets }
                .filter { $0.exercise?.id == group.exercise.id }
                .map { isCardio ? $0.duration : $0.weight }
                .max() ?? 0

            if maxValue > previousMax && previousMax > 0 {
                results.append(
                    PersonalRecord(
                        exerciseName: group.exercise.name,
                        newWeight: maxValue,
                        previousWeight: previousMax,
                        isCardio: isCardio
                    )
                )
            }
        }
        return results
    }
}

/// Epley公式: 1RM = weight × (1 + reps / 30)
enum OneRepMaxCalculator {
    static func calculate(weight: Double, reps: Int) -> Double {
        guard reps > 0, weight > 0 else { return 0 }
        if reps == 1 { return weight }
        return weight * (1.0 + Double(reps) / 30.0)
    }
}

ロジックを enumstatic func に閉じ込めることで、状態を持たないことが型レベルで保証され、テストもしやすくなります(インスタンス化不要)。

4. バージョン別 実装ログ

ここからが本題。各バージョンで「何にハマって、何を学んだか」を時系列で書きます。

v1.0(初回リリース)

最低限の機能で出すことを優先:種目登録 / セット記録 / カレンダー表示。SwiftData 初採用だったので、複雑なクエリは避けて @Query でモデルを全件取得し、ViewModel 側で in-memory フィルタする方針にしました。

学び:

  • 最初のリリースは「データが消えないこと」が最優先。凝った機能より、CRUD の信頼性を固める方が圧倒的に重要。

v1.0.1(iPad レイアウト修正)

iPhone でしか動作確認していなかったため、iPad で NavigationSplitView の挙動が崩れて Reject 寸前。.navigationSplitViewStyle(.balanced) を明示することで解消しました。

NavigationSplitView {
    SidebarView()
} detail: {
    ContentView()
}
.navigationSplitViewStyle(.balanced)

学び:

  • 「Universal アプリ」として申請するなら、iPad シミュレータで最低 1 周は触ること。

v1.0.2(Apple 審査リジェクト対応:無料トライアル削除 + Force unwrap 全廃)

ここが一番きつかったバージョンです。

Apple 審査からの指摘:「無料トライアルの解約方法がアプリ内から見えるようにすること」「ダークモード強制適用が HIG 違反」。トライアル仕様を一度撤去し、ダークモード強制を解除しました。

自主的な大規模リファクタ:当時のコードベース全体を洗ったところ、!(force unwrap)や try! が散在していたので、全廃しました。guard let / if let / do-catch への置き換えです。

// Before(クラッシュ予備軍)
let session = sessions.first { $0.date.isSameDay(as: date) }!

// After
guard let session = sessions.first(where: { $0.date.isSameDay(as: date) }) else {
    return
}

学び:

  • Apple 審査は「ユーザーが詰む導線が無いか」を厳しく見る。サブスク系は特に解約導線を必ず提示。
  • ! を使った瞬間に未来の Crashlytics 通知が予約される。書かない癖をつけるしかない。

v1.0.3(タイマーのバックグラウンド対応)

インターバルタイマー機能を追加しましたが、アプリをバックグラウンドにすると Timer.scheduledTimer が止まる問題に直面。Date ベースに切り替え、復帰時に経過秒数を再計算する方式で解決しました。

// 開始時刻を保存
var startedAt: Date?

// 残り秒数は「現在時刻 - 開始時刻」から都度算出
var remainingSeconds: Int {
    guard let startedAt else { return totalSeconds }
    let elapsed = Int(Date().timeIntervalSince(startedAt))
    return max(0, totalSeconds - elapsed)
}

学び:

  • iOS のバックグラウンド実行は基本「動かない前提」で設計する方が楽。Timer ではなく Date を信じる。

v1.0.4(App Store 版チェックの追加)

TestFlight ビルドと本番ビルドで微妙に挙動が違うことに気づき、Bundle.main.appStoreReceiptURL を見て分岐するロジックを入れました。広告 ID もテスト ID と本番 ID が混在していないか自動チェック。

学び:

  • 「テスト ID 残したまま本番リリース」は AdMob のポリシー違反でアカウント停止リスク。CI でチェックを入れる価値あり。

v1.0.5(ストリーク強化 + ローカル通知 + PR 後 Paywall)

ユーザーの継続率を上げる施策をまとめて投入:

  1. ストリーク(連続トレーニング日数)の強化
  2. ローカル通知 2 種(リマインダー / ストリーク危機)
  3. PR 達成後に Paywall を出すトリガー

ストリーク計算は地味に難しく、Calendar.startOfDay(for:) で日付の境界を揃えるのがコツ。

@MainActor
final class AchievementService {
    static let shared = AchievementService()
    private init() {}

    /// 連続トレーニング日数を計算
    func calculateStreak(from sessions: [WorkoutSession]) -> Int {
        let calendar = Calendar.current
        let today = calendar.startOfDay(for: Date())

        var trainingDays: Set<Date> = []
        for session in sessions where !session.sets.isEmpty {
            trainingDays.insert(calendar.startOfDay(for: session.date))
        }
        guard !trainingDays.isEmpty else { return 0 }

        var streak = 0
        var checkDate = today

        // 今日トレーニングしていなければ昨日からチェック
        if !trainingDays.contains(today) {
            guard let yesterday = calendar.date(byAdding: .day, value: -1, to: today) else {
                return 0
            }
            checkDate = yesterday
            if !trainingDays.contains(checkDate) { return 0 }
        }

        while trainingDays.contains(checkDate) {
            streak += 1
            guard let prev = calendar.date(byAdding: .day, value: -1, to: checkDate) else { break }
            checkDate = prev
        }
        return streak
    }
}

「今日トレーニングしてない場合は昨日基準で計算」のロジックが入っているのがポイントです。これを入れないと、毎日 0 時きっかりに「ストリーク途切れた」と表示されてしまい、UX が最悪になります。

学び:

  • ストリーク UI は「ユーザーの体感」と「カレンダー上の事実」のすり合わせが本質。境界条件を丁寧に設計する。

5. ハマりどころまとめ

最後に、SwiftData × SwiftUI でよく踏むトラップをまとめておきます。

症状 原因 対策
アプリ起動直後にクラッシュ @Model プロパティに初期値が無くマイグレーションが失敗 新規プロパティはデフォルト値を必ず設定
リレーションを介した削除でクラッシュ @Relationshipinverse: 指定漏れ 必ず inverse を書く
@Query のフィルタが効かない #Predicate 内で複雑な式を書いている シンプルな式に絞り、複雑なフィルタは Swift 側で
Swift 6 でデータ競合警告 UI 触る class に actor 指定が無い @MainActor を付ける
Force unwrap でクラッシュ報告 ! を書いている guard let / if let で書き直す

おわりに:このアプリ、リリースしています

ここまでの実装は、すべて App Store でリリース済みのアプリで動いているコードです。

📱 WorkoutDiary(筋トレ日記)
シンプルな UI でセット数・重量・レップ・有酸素を記録できる、iPhone 専用の筋トレ記録アプリです。

👉 App Store で見る

「自分が毎日使いたい筋トレアプリ」を起点に、Claude Code を相棒に半年ほどかけて育てています。バージョンを重ねるごとに「Apple 審査・iPad 対応・Swift 6 移行・継続施策」と、個人開発でしか味わえない学びがありました。

同じく SwiftUI × SwiftData で個人アプリを作っている方の参考になれば嬉しいです。

参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

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?