【非公開×公開】新感覚の勉強記録アプリ「Study Vault」を作ってみた 144時間
はじめに
アプリの概要
本アプリは、ユーザーの勉強時間を管理・記録する非公開×公開を組み合わせた勉強アプリです。毎日ランダムな時間(12:00〜21:00)に勉強時間が公開されるまでは他のユーザーの勉強時間が分からない仕組みとなっています。ここから、「他の人はどのくらい勉強しているんだろう...」という不安感を利用して「勉強しよう」という気持ちを引き立てるアプリになっています。
目的
このアプリを開発しようと思ったきっかけは、大学受験時に使用していた勉強記録アプリの経験からです。以前のアプリでは勉強時間をリアルタイムで共有できましたが、他のユーザーがあまり勉強していないと「自分も勉強しなくて良いかもしれない」と安心してしまうことがありました。この課題を解決するために、プログラミングを学んだ今、自分自身の手で新しい勉強記録アプリを作りたいと考え、開発を始めました。
Study Vaultの由来
このアプリのコンセプトは、勉強記録を特定の時間まで非公開にする点にあります。
Vaultは日本語で「金庫」という意味があり、勉強記録にロックがかかっている「非公開である」
という特徴を表現しています。また、金庫は大切なものを蓄える場所です。このアプリも、勉強記録を大切に蓄えていくという点でStudy Vaultという名前にしました。
課題
- 他のユーザーの勉強時間が公開されることで、ユーザーのモチベーションに悪影響を与える可能性がある
- 競争心を煽るような勉強記録アプリを実現したい
解決策
勉強時間の非公開化
写真共有アプリ「BeReal」のように、毎日ランダムな時間に写真を投稿する機能から着想を得て、特定の時間まで勉強時間を非公開にするアイデアを採用しました。これにより、他のユーザーの勉強状況が分からず、自分自身の勉強意欲を維持することが期待できます。また、特定の時間までに多く勉強し、他のユーザーを驚かせるなど、勉強を継続する動機付けにも繋がります。
競争心を刺激する機能
月間・週間ランキング機能を実装し、ゲーム感覚で勉強に取り組めるよう工夫しました。ランキングは全ユーザーの中での順位を表示するため、世界中のユーザーと競い合うことができ、より高いモチベーションを維持することが可能です。
アプリの主な機能
- 勉強時間の記録: ストップウォッチ機能または手動入力で教材ごとに勉強時間を記録
- 勉強時間の非公開管理: 勉強時間は設定された時間まで非公開
- 教材の登録: 使用する教材を登録・管理
- 目標設定: 目標とする勉強時間を設定
- フォロー機能: 他のユーザーをフォローして進捗を確認
- ランキング機能: 月間・週間の勉強時間ランキングを表示
技術スタック
アーキテクチャー図
技術選定
フロントエンド
- Flutter: クロスプラットフォーム開発を効率化するために選定。iOSとAndroidの両方に対応可能。
バックエンド
- Firebase Authentication: ユーザーのログイン認証を管理
- Firebase Firestore: データベースとして使用
- Firebase FireStorage: ファイルストレージを担当
- Firebase Functions: ランキング集計 & 毎日の公開時間を設定 & 公開時間になったら通知を担当
- Python: Funtionsのバックエンドの処理を記述
バックエンドの処理
import datetime
import logging
from google.cloud import firestore
from firebase_functions import scheduler_fn
from firebase_admin import initialize_app
initialize_app()
# 毎日0時0分(日本時間)に実行されるクラウド関数
@scheduler_fn.on_schedule(schedule="0 0 * * *", timezone="Asia/Tokyo")
def add_daily_to_weekly(event):
"""DailySummaryを元にWeeklySummaryを更新し、その後DailySummaryを削除します。
この関数は日本時間0時00分に1日1回実行されます。処理内容:
1. 前日のDailySummaryドキュメントをすべて取得します。
2. 取得したDailySummaryの 'achievedStudyTime' を対応するWeeklySummaryレコードに加算します。
WeeklySummaryが存在しない場合は新規作成します。
3. 処理済みのDailySummaryドキュメントを削除します。
引数:
event: スケジュール実行時のトリガーイベント。
"""
db = firestore.Client()
try:
# 現在時刻(東京時間)と昨日の日付を取得
tokyo_tz = datetime.timezone(datetime.timedelta(hours=9))
now_tokyo = datetime.datetime.now(tokyo_tz)
yesterday = now_tokyo - datetime.timedelta(days=1)
formatted_date = yesterday.strftime('%Y-%m-%d')
# 昨日のDailySummaryをクエリで取得
daily_query = db.collection('DailySummary').where('targetDay', '==', formatted_date)
daily_docs = list(daily_query.stream())
if not daily_docs:
# 昨日のDailySummaryが存在しない場合は何もせず処理終了
logging.info("昨日のデータはありません。")
return
# 一連の更新操作をバッチで行う
batch = db.batch()
for daily_doc in daily_docs:
daily_data = daily_doc.to_dict()
# DailySummaryのフィールド取得
user_id = daily_data.get('userId')
achieved_time = daily_data.get('achievedStudyTime', 0)
target_day_str = daily_data.get('targetDay')
# 日付文字列をdatetimeオブジェクトに変換
target_day = datetime.datetime.strptime(target_day_str, '%Y-%m-%d').date()
# ISOカレンダー(年,週番号)を取得し、'YYYY-Www'形式の週番号文字列を生成
iso_calendar = target_day.isocalendar() # (year, week, weekday)
iso_year = iso_calendar[0]
iso_week = iso_calendar[1]
target_week = f"{iso_year}-W{iso_week:02d}"
# WeeklySummaryから該当ユーザー&週のドキュメント取得(存在しなければ新規作成)
weekly_query = (db.collection('WeeklySummary')
.where('userId', '==', user_id)
.where('targetWeek', '==', target_week)
.limit(1))
weekly_docs = list(weekly_query.stream())
if weekly_docs:
# 既存のWeeklySummaryを更新
weekly_doc = weekly_docs[0]
weekly_ref = weekly_doc.reference
weekly_data = weekly_doc.to_dict()
updated_time = weekly_data.get('achievedStudyTime', 0) + achieved_time
# 週単位の学習時間を追加
batch.update(weekly_ref, {'achievedStudyTime': updated_time})
else:
# 対応するWeeklySummaryがない場合は新規作成
new_weekly_ref = db.collection('WeeklySummary').document()
batch.set(new_weekly_ref, {
'userId': user_id,
'achievedStudyTime': achieved_time,
'targetWeek': target_week,
'targetStudyTime': 0 # 必要に応じて変えられる
})
# 処理済みのDailySummaryは削除
batch.delete(daily_doc.reference)
# 全ての変更をコミット
batch.commit()
logging.info("昨日の勉強時間をWeeklySummaryに加算し、DailySummaryを削除しました。")
except Exception as e:
logging.error(f"エラーが発生しました: {e}")
選定理由
Flutter
- 開発効率の向上: iOSとAndroidのネイティブ言語を個別に開発する時間を節約できるため
- 一貫性のあるUI: クロスプラットフォームで統一感のあるユーザーインターフェースを提供可能
Firebase
- 統合管理: 認証、データベース、ストレージ、サーバーレス機能を一つのプラットフォームで管理でき、予算管理が容易
- APIキー管理の簡素化: Firebaseに全てを集約することで、APIキーの管理が容易になる
- NoSQLの学習: リレーショナルデータベースしか経験してこなかったので、NoSQLを学んでみたいと思ったから
- 無料枠: firebaseの利用しているサービスには無料枠があり小規模の時にランニングコストがかからない
データベース設計
初期のデザイン案
※画面の遷移を表す矢印が消えてしまっています
アプリ画面一覧
一番苦労した点
データベースの読み取り回数、書き込み回数の増加
Firestoreはクエリの処理時間ではなく、読み取り回数や書き込み回数によって費用が計算されます。初期段階では、アプリの実装によって非常に多くの読み取り回数が発生してしまい、課題となりました。
解決策
-
非公開時間中の勉強記録をすぐにデータベースに送信しない
非公開時間中であれば、他のユーザーが自分の今日の勉強時間を参照することはありません。この特性を利用し、非公開時間中はクライアント側の軽量なデータベースに勉強記録を一時保存するようにしました。これにより、自分の今日の勉強時間を参照することは可能なまま、データベースへの読み取り回数や書き込み回数を大幅に削減しました。 -
キャッシュの利用
一度取得したデータについてはクライアント側でキャッシュを作成するようにしました。例えば、ユーザーアイコンのような頻繁に更新されないデータについては、キャッシュを利用することでデータベースへのアクセスを減らしました。 -
データベース設計の工夫
勉強時間が記録された際にデータベースの「その日の勉強時間」だけを更新し、週ごとの勉強時間の更新は毎日0時にCloud Functionsを利用してスケジューリング処理を行う設計に変更しました。また、「その日の勉強時間」を保存するドキュメントは、その日に勉強したユーザーのみに作成する仕組みを採用し、無駄な書き込みを削減しました。この工夫により、週ごとの勉強時間の更新処理は、その日に勉強したユーザーにのみ行われるようにしました。
まとめ
本アプリは、勉強時間の非公開化と競争心を刺激するランキング機能を組み合わせることで、ユーザーの勉強意欲を高めることを目指しています。FlutterとFirebaseを活用することで、効率的かつ効果的な開発を実現し、ユーザーにとって使いやすい勉強管理ツールを提供します。今後の開発を通じて、さらに多くの機能を追加し、ユーザーのニーズに応えていく予定です。
参考にしたサイト
Discussion