見出し画像

Swiftで学習アプリの「学習履歴」と「継続」を作る:ストリーク・週間アクティビティ・トラック分析

こんにちは。

今回は、**学習アプリを作るなら避けて通れない「学習履歴」**について書いてみます。

音声学習アプリや語学学習アプリを作っていると、

「今日ちゃんと勉強したかな?」

とか、

「この曲、何回くらい練習したんだろう?」

とか、

「最近ちゃんと続いてるな!」

みたいな情報を、ユーザーに見せたくなってきます。

そして、ここで出てくるのが、

  • 学習履歴

  • ストリーク(連続学習日数)

  • 週間アクティビティ

  • 月ごとの学習回数

  • トラックごとの学習状況

あたりです。

今回はまず、UserDefaults + JSONだけで作る最小構成から始めます。

小〜中規模のアプリなら、最初はこれくらいでも十分です。

そのうえで後半では、

  • トラック別の分析

  • SwiftUIダッシュボード

  • CloudKit同期

  • JSON / CSVエクスポート

  • マイグレーション

  • XCTest

  • 実運用でハマりやすいポイント

まで、一気に発展させていきます。


1. まずは「学習した」という記録を残す

最初に必要なのは、ものすごくシンプルです。

「いつ、何を、どんな練習をしたか」

これを記録できればOK。

今回は、例えばこんなデータを持たせます。

  • 学習した日時

  • 学習タイプ

  • トラック名

  • アーティスト名

  • 正解数

  • 問題数

学習タイプは、今回は3種類にしてみます。

enum PracticeType: String, Codable {
    case dictation
    case sectionRepeat
    case flashCard
}

そして、学習履歴そのものはこんな感じ。

struct PracticeRecord: Codable, Equatable {
    let id: UUID
    let date: Date
    let type: PracticeType
    let trackTitle: String
    let trackArtist: String
    let correctCount: Int
    let totalCount: Int

    var scorePercent: Int? {
        guard totalCount > 0 else { return nil }
        return Int(
            (Double(correctCount) / Double(totalCount) * 100).rounded()
        )
    }

    static func == (a: PracticeRecord, b: PracticeRecord) -> Bool {
        return a.id == b.id
    }
}

ここで scorePercent を計算できるようにしておくと、あとで、

「このトラック、前より正解率上がったな」

みたいな表示も簡単にできます。


2. 保存先は、とりあえず UserDefaults でOK

「学習履歴」と聞くと、

Core Dataを使った方がいい?

とか、

CloudKitまで入れないとダメ?

と思うかもしれません。

でも、最初からそこまでやらなくても大丈夫です。

今回のような小〜中規模のデータなら、まずは

UserDefaults + JSON

くらいでも十分扱えます。

final class PracticeHistoryService {

    static let shared = PracticeHistoryService()

    private init() {}

    private let storageKey = "practice_history_v1"
    private var cache: [PracticeRecord]?

    func allRecords() -> [PracticeRecord] {
        if let c = cache {
            return c
        }

        let loaded = loadFromDisk()
        cache = loaded
        return loaded
    }

    func add(_ record: PracticeRecord) {
        var updated = allRecords()

        // 新しいものを先頭に追加
        updated.insert(record, at: 0)

        // とりあえず200件まで
        if updated.count > 200 {
            updated = Array(updated.prefix(200))
        }

        cache = updated
        saveToDisk(updated)
    }

    private func loadFromDisk() -> [PracticeRecord] {
        guard let data = UserDefaults.standard.data(forKey: storageKey) else {
            return []
        }

        return (try? JSONDecoder().decode(
            [PracticeRecord].self,
            from: data
        )) ?? []
    }

    private func saveToDisk(_ records: [PracticeRecord]) {
        guard let data = try? JSONEncoder().encode(records) else {
            return
        }

        UserDefaults.standard.set(data, forKey: storageKey)
    }
}

このくらいなら、かなりシンプルですね。


3. 学習アプリで重要な「ストリーク」

ここからちょっと面白くなります。

学習アプリでは、

「何日連続で学習しているか」

が結構重要です。

いわゆる、

ストリーク(Streak)

ですね。

例えば、

🔥 7日連続学習中!

みたいな表示です。

実装としては、

  1. 過去の学習記録から「学習した日」を取り出す

  2. 今日から昨日、2日前……と確認する

  3. 連続していればカウントする

という考え方で作れます。

func currentStreak() -> Int {
    let calendar = Calendar.current

    let practicedDays = Set(
        allRecords().map {
            calendar.startOfDay(for: $0.date)
        }
    )

    var streak = 0
    var check = calendar.startOfDay(for: Date())

    while practicedDays.contains(check) {
        streak += 1

        guard let prev = calendar.date(
            byAdding: .day,
            value: -1,
            to: check
        ) else {
            break
        }

        check = prev
    }

    return streak
}

これだけでも、

let streak = PracticeHistoryService.shared.currentStreak()

で現在のストリークを取得できます。


4. 「今週どれくらい勉強した?」も欲しい

ストリークとセットで欲しくなるのが、

週間アクティビティ

です。

例えば、

● ● ○ ● ● ○ ●

みたいに、過去7日間で学習した日を表示します。

これも、実装自体はそれほど難しくありません。

func weekActivity() -> [Bool] {
    let calendar = Calendar.current
    let today = calendar.startOfDay(for: Date())

    let practicedDays = Set(
        allRecords().map {
            calendar.startOfDay(for: $0.date)
        }
    )

    return (0..<7).map { offset in
        guard let d = calendar.date(
            byAdding: .day,
            value: -(6 - offset),
            to: today
        ) else {
            return false
        }

        return practicedDays.contains(d)
    }
}

これで、

let week = PracticeHistoryService.shared.weekActivity()

とすれば、

[true, true, false, true, true, false, true]

のようなデータが取れます。

あとはSwiftUI側で丸や四角を並べれば、簡単な学習カレンダーになります。


5. 月ごとの学習回数も集計してみる

もう一つ便利なのが、

「今月は何回勉強した?」

という情報です。

例えば、

  • 1月:23回

  • 2月:31回

  • 3月:18回

みたいな感じですね。

func monthlyCount(year: Int)
    -> [(month: Int, count: Int)] {

    let calendar = Calendar.current
    var counts = [Int: Int]()

    for r in allRecords() {
        let comps = calendar.dateComponents(
            [.year, .month],
            from: r.date
        )

        guard comps.year == year,
              let month = comps.month else {
            continue
        }

        counts[month, default: 0] += 1
    }

    return (1...12).map {
        (month: $0, count: counts[$0] ?? 0)
    }
}

ここまで来ると、かなり「学習アプリっぽく」なってきます。


6. 実際に学習が終わったときはこう記録する

例えばディクテーションの問題を10問やって、8問正解したとします。

その場合は、

let record = PracticeRecord(
    id: UUID(),
    date: Date(),
    type: .dictation,
    trackTitle: "Song A",
    trackArtist: "Artist",
    correctCount: 8,
    totalCount: 10
)

PracticeHistoryService.shared.add(record)

これだけ。

あとは保存したデータから、

  • ストリーク

  • 週間アクティビティ

  • 月間学習回数

  • 正解率

などを計算できます。


ここまででも、実はかなり使える

ここまでの実装だけでも、

「学習履歴を保存する」

という目的は達成できます。

しかも、

  • UserDefaults

  • Codable

  • JSON

  • Calendar

くらいしか使っていません。

最初から大げさなデータベースを導入しなくても、まずはこのくらいから始めるのは全然アリだと思います。

ただ、実際にアプリとして育てていくと、ここから問題が出てきます。

例えば、

「この曲、今まで何回練習した?」

「前回より正解率は上がった?」

「今年と去年で学習量はどう変わった?」

「別端末でも履歴を見たい」

「学習履歴をバックアップしたい」

……となってきます。

ここから先は、単純な履歴保存だけでは足りなくなってきます。


🔒 ここから先は有料パートです

ここからは、実際のアプリとして一段上の学習履歴機能を作る部分に入ります。

有料パートでは、以下をまとめて実装します。

A. 拡張モデル

  • updatedAt の追加

  • 同期を前提にしたデータ構造

  • 後から拡張しやすいモデル設計

ここから先は

9,030字

¥ 500

この記事が気に入ったらチップで応援してみませんか?