tva
← コラム

数千問を扱う試験対策アプリの構築

試験対策アプリの多くで問題となるのは、問題の内容ではなく、それを支えるアーキテクチャです。一つの市場で一つの資格を扱うだけなら、フラットなJSONファイルに数百問を保存する構成で十分です。しかし、地域別の違い、複数の難易度、試験実施団体ごとに異なる出題形式、時差のある地域のユーザーが同時に競うランキングを追加すると、最初の週に選んだ設計が8週目には不具合を招くようになります。

当社は、複数の地域の専門資格に対応する試験対策アプリを構築しました。データモデリング、Swift Package Managerによるモジュール化、Supabaseバックエンド、オフラインでの動作など、設計上の判断は最初から明らかだったわけではありません。最初の試作からリリース版までに、大きく変わったものもあります。

データモデリングの課題

試験問題の単純なモデルは、問題文、四つの選択肢、正答のインデックスを持つテーブルです。画像付きの問題、コードブロックを含む問題、選択肢の数が変わる問題、複数の正答がある問題に対応するまでは、この構成で機能します。コンテンツを投入した後にスキーマを変更するのは高くつくため、最初から対応すべき内容の全範囲を考慮して設計する価値があります。

最終的に採用したスキーマでは、問題にtypeフィールド(single-choice、multi-choice、ordering、code-snippet)を設け、選択肢をJSONBのoptions列に保存します。これにより、種類ごとに別テーブルを作らずに、各出題形式が必要なデータを保持できます。正答は単一の整数ではなく、インデックスのJSONB配列として保存し、スキーマを変えずに複数正答の問題に対応します。

問題はdomainsに属し、それがexamsに属し、さらにregionsに属します。この4階層は過剰な設計に見えるかもしれません。しかし、同じ資格認定団体が市場ごとに異なる出題範囲を定める場合や、内容が一部重なる日本とドイツの資格試験を一つのアプリで扱う場合に役立ちます。この階層により、「この地域の、この試験の、この難易度の全問題」を、アプリケーション側で絞り込まず、一つのパラメーター化クエリで取得できます。

難易度は列挙型ではなく、整数(1〜5)で保存します。列挙型ではレベルを追加する際にマイグレーションが必要ですが、整数なら不要です。フロントエンドで整数を表示用のラベルに対応付けます。

Swift Package Managerによるモジュール設計

この規模のコードベースを一つのXcodeターゲットにまとめると、ビルド時間が増え、層の境界を守ることが難しくなります。ネットワークのモジュールからUIコンポーネントにアクセスできるべきではありません。問題処理エンジンも、iOSアプリ内で動いているか、テスト実行基盤で動いているかを意識するべきではありません。Swift Package Managerなら、これらの境界を明示し、コンパイラーで強制できます。

当社が採用したパッケージの依存関係は、主に四つのパッケージからなります。

  • QuestionEngine:問題選択、解答評価、セッションの採点、間隔反復のスケジュールといった純粋なロジックです。UIKit、SwiftUI、Supabaseへの依存はなく、単独で完全にテストできます。
  • ExamData:データモデルとSupabaseクライアントのラッパーです。Question、Exam、UserProgress、LeaderboardEntryの型を定義し、SupabaseのSwiftクライアントをインポートします。
  • OfflineStore:Core Dataの構成と同期ロジックです。型定義はExamDataに依存し、端末内のキャッシュと同期用の変更追跡を処理します。
  • AppUI:SwiftUIのビューとビューモデルです。QuestionEngineとExamDataをインポートし、OfflineStoreについて直接は認識しません。

メインのアプリターゲットが四つのパッケージを組み合わせます。QuestionEngineにプラットフォームへの依存がないため、シミュレーターを起動せずにテストスイート全体を実行できます。また、SupabaseのSwiftクライアントのマイナーバージョンで互換性を壊す変更があった際も、影響をExamDataに限定できました。一つのパッケージを更新するだけで、残りは変更せずコンパイルできました。

一方、XcodeではSPMのパッケージ解決とキャッシュが不安定になることがあり、特にクリーンビルド後に起きやすいという難点があります。CIビルド前にxcodebuild -resolvePackageDependenciesを実行するMakefileのターゲットを用意すれば、こうした予期しない問題の大半を解消できます。

Supabaseバックエンド

このバックエンドにSupabaseが適していた理由は二つです。行レベルセキュリティでアプリケーション層のガードを追加せずにマルチテナントのデータアクセスを制御できることと、スキーマがすべてPostgreSQLなので、ランキングの読み取りモデルとレポート層の構築時に、そのクエリ機能を全面的に利用できることです。

ユーザーの学習の進捗は、問題ごと、セッションごとに保存します。各行には問題ID、解答、正誤、所要時間、セッションのタイムスタンプを記録します。多くのアプリで必要とするより細かな粒度ですが、これにより間隔反復のスケジュールを組めます。アルゴリズムが参照するのは累積スコアだけでなく、すべての履歴です。このテーブルの集計ビューがダッシュボードの統計を提供するため、アプリケーション側で計算する必要はありません。

行レベルセキュリティのポリシーにより、ユーザーが読み書きできるのは自分の進捗行だけに制限します。questions、exams、domains、regionsの各テーブルは公開で読み取り可能です。コンテンツへのアクセスに認証が不要なため、オフライン同期が簡単になります。認証済みセッションを必要とするのは、ユーザー固有のテーブルだけです。

当初の誤りの一つは、問題コンテンツの同期にSupabase Realtimeを使おうとしたことです。Realtimeは、小規模で頻度の高い行の変更を想定しており、比較的静的なコンテンツを数千行同期する用途には向きません。questionsテーブルへのRealtimeサブスクリプションを、試験レコードのcontent_updated_atタイムスタンプを確認し、内容が変わったときだけ差分をダウンロードする定期ポーリングに置き換えました。洗練された方法とはいえませんが、この規模のコンテンツライブラリでははるかに安定します。

オフラインファーストの設計

ネットワーク接続が必須の試験対策アプリは、飛行機の中で勉強する受験者には役立ちません。ここではオフラインファーストは任意の選択ではなく、主要な利用場面です。

当社は、Wi-Fi接続時にコンテンツを配信する方式と、問題処理エンジンの主なデータソースとなる端末内のCore Dataキャッシュを組み合わせました。ネットワークに接続した状態でアプリを起動すると、前回の同期以降に試験コンテンツが更新されたかを確認します。更新があれば差分をダウンロードし、Core Dataに書き込みます。接続がなければ、問題なくCore Dataから読み取ります。ユーザーの進捗はまず端末内に書き込み、接続が利用可能になった時点でSupabaseと同期します。

同期の競合モデルは、意図的にシンプルにしています。問題コンテンツはサーバーを正本とし、ユーザーの進捗はクライアントを正本とします。競合する双方向のコンテンツ編集はありません。進捗行は追記専用で、完了したセッションは更新せず、新しいセッションだけを追加します。そのため、同期ロジックは「前回成功した同期より新しいタイムスタンプの行をアップロードする」という単純なものになります。このルールをシンプルに保つには、アップロードを再試行しても重複が生じないよう各行に一意のIDを持たせ、行のタイムスタンプと同期マーカーの両方でデバイスの時計を一貫して使う必要があります。

重要な点として、端末内のデータの鮮度をユーザーに示す必要があります。「コンテンツの最終更新は3日前です」というバナーなら、データの状態を適切に伝えられます。問題集に新しい問題が追加されているのに、説明なく古い内容を提供すると、より最近同期した他のユーザーとスコアが異なる場合に混乱を招きます。

同時更新が集中するランキング

ランキングの読み取りは簡単ですが、同時更新の下で正しく書き込む処理は意外に難しいものです。ユーザーごとのscore列を各セッションの終了後に更新する単純な方法では、多くのユーザーが同時に終了すると書き込みの競合が発生します。また、同時に行われる二つの更新が互いを上書きする競合状態にも弱くなります。

うまく機能したのは、更新し続けるスコア列ではなく、セッション履歴から導出した読み取り用モデルとしてランキングを扱う方法です。PostgreSQLのマテリアライズドビューが、定期更新時にユーザーごとのセッションスコアを集計します。書き込みのたびではなく数分ごとに更新するため、ランキングはリアルタイムではなく結果整合性を持つ形になります。学習アプリには許容できるトレードオフです。セッション終了後、数分以内に順位が更新され、リアルタイムの試験競技イベントを除くすべての用途に対応できます。

リアルタイムの順位が重要な競技イベントでは、Supabaseの外部にRedisを使った別のカウンターを用意します。ただし、通常の学習では、マテリアライズドビューのほうがシンプルで低コストであり、スコア列を更新して維持する方式で生じる、更新の消失につながる競合を回避できます。

今なら変更する点

選択肢データにJSONBのoptions列を使った判断は正しかったものの、初期段階で選択肢オブジェクトのスキーマを十分に定義していませんでした。開発者ごとに少し異なるフィールドを追加し、explanationを持つ選択肢、imageUrlを持つ選択肢、どちらも持たない選択肢が混在しました。挿入時に検証済みの構造を強制しなかったため、コンテンツが不統一になりました。管理ツールのTypeScript側にZodスキーマを、Postgresの列にCHECK制約を追加していれば、早い段階で検出できたはずです。JSONBの構造は、後からではなく、データを受け入れる境界で検証するべきです。

AppUIとOfflineStoreのSPMパッケージ境界は厳格すぎました。複数のSwiftUIビューが、キャッシュされたデータを表示しているのか、最新データを表示しているのかを知る必要があり、その情報はOfflineStoreにありました。最終的には、OfflineStoreが適合できるプロトコルをAppUI経由で渡す形にしました。これは適切な抽象化ですが、後付けするより、初めからそのプロトコルを設計しておくほうが明快でした。

関連記事

関連記事