👀 はじめに
個人開発で「野球ログ」という野球の試合管理アプリをリリースしたので、使用技術や開発の苦労したポイントを紹介しようと思います🎉
🚀 アプリの紹介
「野球ログ」は野球の試合を管理するアプリです!試合状況をチームメンバーにリアルタイムで共有できること、野球の膨大なデータをできる限り見やすい UI にすることにこだわって作成しました!
iOS 版:
https://apps.apple.com/jp/app/%E9%87%8E%E7%90%83%E3%83%AD%E3%82%B0/id6479306483
Android 版:
https://play.google.com/store/apps/details?id=com.lily17.yakyulog&pcampaignid=web_share
⚾️ 実際のアプリ
ホーム画面
チームの試合結果が一覧で見ることができる画面です。カードをタップするとリアルタイムで試合の詳細データが反映された画面に遷移します。
成績ランキング画面
チーム内の選手のこれまでの成績をランキング形式で表示することができます。バッティング成績とピッチング成績の両方のランキングを見ることが可能です。
ホーム画面 | 成績ランキング画面 |
---|---|
試合入力画面
試合のデータを 1 球ごとに入力できます。コース、球種など詳細に試合を入力し、それに対するバッターのアクションを選択できます。またランナーがいる状態ではバッターのアクションに対してランナーの進塁状況を自由に記録することが可能です。これらの入力結果は同じチームに所属するユーザーのアプリにリアルタイムで反映されます。
チーム画面
チームの様々な情報を確認、編集ができます。また選手の個人データを確認、編集することも可能です。選手の成績は入力中の試合が終了した際に自動で集計されます。現在は一般的な指標しか閲覧できませんが、今後はより詳細な指標や打球傾向、球種ごとの打率/被打率なども掲載予定です。
試合入力画面 | チーム画面 |
---|---|
|
💻 使用技術
フロントエンドは Flutter バックエンドは Firebase を用いてアプリを作成しました。以下ではそれぞれの選定理由や詳細を説明します。
フロントエンド
Flutter 3.19.4
iOS と Android を同時に開発でき、ネット上の情報量も多く私自身も使用経験がある Flutter を採用しました。
https://flutter.dev/
ディレクトリ構成
MVVM などのアーキテクチャを採用せず、以下のようなディレクトリ構成でアプリを作成しました。
後述する Firestore とデータをやり取りするためのデータモデル用に data/
を定義し、それ以外の UI の部分はすべて ui/
に定義しています。ui/
配下には再帰的に component/
, hook/
, screen/
が配置されるようにし、各 Component や Hooks はそのディレクトリ配下でのみ使用可能にしました。
参考:
https://speakerdeck.com/wasabeef/o-extended-2023-flutter-huo-yong-shi-li?slide=27
https://techblog.enechain.com/entry/flutter-rearchitecture-from-mvvm
lib/
├── data/
│ ├── team/
│ │ └── team.dart
│ └── member/
│ └── member.dart
└── ui/
├── component/
├── hook/
└── screen/
├── auth/
│ ├── component/
│ ├── hook/
│ └── auth_screen.dart
└── root/
├── home/
│ ├── component/
│ │ └── item/
│ │ └── sample_item.dart
│ ├── hook/
│ └── home_screen.dart
└── game/
├── component/
├── hook/
└── game_screen.dart
状態管理
アプリの状態には 1 つの Widget に完結する Ephemeral State1 とグローバルな状態の App State2 が存在します。Ephemeral State の管理には flutter_hooks を、 App State の管理には flutter_riverpod を使用しました。riverpod_generator と riverpod_annotation を使用することでアノテーションからコードが自動生成されるため便利です。
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'firestore.g.dart';
@Riverpod(keepAlive: true, dependencies: [])
FirebaseFirestore firebaseFirestore(FirebaseFirestoreRef ref) {
return FirebaseFirestore.instance;
}
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'firestore.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$firebaseFirestoreHash() => r'a37cb0844c7084f3310a26178d45e4b6f3dbe7ef';
/// See also [firebaseFirestore].
@ProviderFor(firebaseFirestore)
final firebaseFirestoreProvider = Provider<FirebaseFirestore>.internal(
firebaseFirestore,
name: r'firebaseFirestoreProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$firebaseFirestoreHash,
dependencies: const <ProviderOrFamily>[],
allTransitiveDependencies: const <ProviderOrFamily>{},
);
typedef FirebaseFirestoreRef = ProviderRef<FirebaseFirestore>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
ルーティング
Flutter のルーティングパッケージは有名どころで go_router や auto_routeがあります。auto_route の方が知見があったためこちらを採用しました。こちらも auto_route_generator を使うことでコードが自動生成されます。auto_route の活用事例についてはこのスライドが非常にわかりやすくおすすめです!
その他の使用パッケージ
バージョンは割愛しています。
# For Routing
auto_route
# For Firebase
firebase_core
cloud_firestore
cloud_functions
firebase_auth
firebase_storage
google_sign_in
firebase_remote_config
firebase_analytics
firebase_crashlytics
# For Annotation
freezed_annotation
json_annotation
riverpod_annotation
# For Hooks
flutter_hooks
# For Riverpod
hooks_riverpod
# For DateTime
intl
# For UI
gap
shimmer
auto_size_text
fl_chart
# For Web launcher
universal_html
# For WebView
flutter_inappwebview
device_preview
# For Image
image_picker
extended_image
flutter_image_compress
# For Horizontal Table
horizontal_data_table
# For Shared Preferences
shared_preferences
# For Share
share_plus
# For Haptic Feedback
vibration
# For Generator
auto_route_generator
build_runner
freezed
json_serializable
riverpod_generator
# For Linter
very_good_analysis
# For Assets
flutter_gen_runner
flutter_svg
flutter_native_splash
flutter_launcher_icons
バックエンド
Firebase を採用しました。
- Firebase Authentication
- Cloud Firestore
- Cloud Functions for Firebase
- Cloud Storage for Firebase
- Firebase Remote Config
- Google Analytics for Firebase
- Firebase Crashlytics
今回は supabase の使用も検討していましたが、最終的に Firebase を採用したのでその経緯を以下で説明したいと思います。
https://supabase.com/
supabase を検討した理由
単純な興味
supabase はオープンソースの Firebase alternative です。最近 X などでよく話題になっているため一度使ってみたいという気持ちがありました。
https://x.com/dshukertjrjp/status/1785860743629513011
RDB を使用できる
野球というゲームの性質上、膨大なデータを処理する必要があります。例えばピッチャーの投球に対してバッターは見逃し、空振り、ファール、ヒッティングなど様々なアクションを選択でき、さらにヒッティングした場合にはアウト、セーフ、エラーなどアクションが絡み合います。これらのフィールド内のアクションに加えて選手交代などの特殊なアクションも多数存在します。うまくリレーションをつけて構造化できれば RDB のメリットを活かして野球のデータを非常に効率よく扱えるのではと考えました。
Firebase を採用した理由
バックエンドに時間をかけたくなく、Firebase の方が慣れていた
私自身がモバイルアプリエンジニアで、バックエンド関連の知見が少ないことに加え、Firebase は使用経験があったことでより早くアプリを開発しリリースするために Firebase の方が良い選択肢なのではないかと考えました。supabase の長所である RDB は非常に魅力的ですが、使用するために学習が必要だと感じました。
情報量が Firebase の方が多い
2 の理由も 1 とほぼ同じで、やはりバックエンドに時間をかけたくないという思いが強いため生じたものになります。supabase は非常に新しいサービスです。公式ドキュメントや supabase のエンジニアのタイラーさん の日本語サポートが充実しています。しかし現状では圧倒的に Firebase の情報量(特に日本語の)が多いため、バックエンドに時間をかけたくない身としてはこちらの方が魅力的でした。
プッシュ通知の実装が supabase だけで完結しない
アプリにプッシュ通知を組み込みたい場合、Firebase では Firebase Cloud Messaging が存在し Firebase だけで手軽に使用できます。しかし、supabase を使用する場合では supabase 以外のサービスを用いる必要がありそうだったのでこの点では Firebase の方が優れているのかなと感じました。
以上の理由から今回は Firebase を用いてアプリの開発を行いました。
🏃苦労した点
ここからはアプリ開発で苦労した点を紹介したいと思います。苦労したポイントは大きく 2 点あり、「野球ドメイン」と「アプリ開発以外の部分」です。
苦労した点 1: 野球というドメインの難しさ
野球の試合を記録するアプリを開発するにあたって、非常に難しいと感じたのは以下の点になります。
- データの構造化
- 試合を記録させる UI
- マネタイズ方法
データの構造化
様々なアクションが複雑に絡みあう野球の試合データをどのように構造化し Firestore に保存するのかが当初の一番の課題でした。データの構造化を誤ると Firestore の CRUD 回数が肥大化しコストの増加に直結します。
こちらは Baseball Savant という MLB(Mejor League Baseball) の選手データを誰でも閲覧できる神サイトより引用した 2023 年の大谷翔平選手のバッティングスタッツの一部です。2023 年にバッター大谷選手に対してどこにどんな球が投じられたのか、ホームラン、ツーベースなどがフィールドのどこに飛んだのか、打球速度と打球角度がどうだったかの図が非常にわかりやすく視覚化されています。
このように1人の打者だけでも莫大な量のデータが存在し、それをアプリユーザーが滞りなく閲覧するためにはデータの構造が非常に大切です。
余談
MLB ではあらゆるデータを MLB 機構が一括管理し誰でも簡単にアクセスできる体制が整っています。しかし、NPB(Nippon Professional Baseball) では球団ごとにトラッキングデータを管理しておりデータ取得、分析の体制に球団格差があるためデータが公開されないそうです。3早く MLB のようにデータが公開され日本のプロ野球選手のデータを見てみたいものです。
最終的には以下のよう teams コレクションに members と games サブコレクションを作成しにデータを保存しています。
*セキュリティーの観点から表示しているのはコレクションの一部の情報のみです
:::details Firestore の teams コレクションのデータ構造の一部
# collection
teams:
{ teamId }: # 自動生成される一意の id
# field
battingStats: # チームの打者のスタッツ
atBat: number
average: number
basesOnBalls: number
caughtStealing: number
doubleH: number
gameNumber: number
hit: number
hitByPitch: number
homeRun: number
onBasePercentage: number
onBasePlusSlugging: number
runBattedIn: number
singleH: number
sluggingPercentage: number
stolenBases: number
tripleH: number
createdAt: string
name: string
pitchingStats: # チームの投手のスタッツ
basesOnBalls: number
earnedRun: number
earnedRunAverage: number
fieldingIndependentPitching: number
hit: number
hitByPitch: number
homeRun: number
lose: number
pitchingInning: number
strikeOut: number
strikeOutToWalkRatio: number
walksAndHitsPerInningPitched: number
win: number
proceedingGameId: string # 進行中の試合の gameId
showedName: string
# collection
games:
{ gameId }: # 自動生成される一意の id
# field
ballPark: string
gameDate: timestamp
hasDH: boolean
isBatFirst: boolean
isOfficialGame: boolean
opponentTeamName: string
# collection
gameInfo:
{ gameInfoId }: # 自動生成される一意の id
# field
battingOrder: List<string> # 打順
createdAt: string
gameStats: # 試合のスタッツ
opponentErrors: number # 相手チームの失策数
opponentHits: number # 相手チームの安打数
opponentScores: List<number> # 相手チームの得点数
teamErrors: number # 自チームの失策数
teamHits: number # 自チームの安打数
teamScores: List<number> # 自チームの得点数
hitterAction:
{hitterActionId}: # 自動生成される一意の id
atBat: number
battingOrder: number
errors: null
hitterActionDetail: string
hitterId: string
optionalAction: string
pitchResult:
pitchPositionList: List<number>
pitchType: List<string>
runBattedIn: number
inning: string # 回数 (1回表 -> 1T, 1回裏 -> 1B など)
isOffense: boolean # 攻撃か守備か
opponentBattingNumber: number
outsResult: number # アクション後のアウト数
positionOrder: List<string> # 守備位置
runnerAction: # ランナーのアクション
errors: string
pitchCount: number
runnerActionDetail: string
runnersMapBefore: Map<string, string> # ランナーの状況
runnersResultMap: Map<string, string> # ランナーの状況
scoreResult: number # そのアクション後の得点
specialAction: Map<string, dynamic>? # 特殊アクション
teamBattingNumber: number
# collection
members:
{ memberId }: # 自動生成される一意の id
# field
battingAndThrowing: string
battingStats: # メンバーの打者のスタッツ
fieldingStats:# メンバーの守備のスタッツ
height: number
name: string
pitchingStats: # メンバーの投球スタッツ
showedName: string
uniformNumber: number
weight: number
# collection
hitterAction:
{hitterActionId}: # 自動生成される一意の id
atBat: number
battingOrder: number
errors: null
hitterActionDetail: string
hitterId: string
optionalAction: string
pitchResult:
pitchPositionList: List<number>
pitchType: List<string>
runBattedIn: number
# collection
runnerAction:
{runnerActionId}: # 自動生成される一意の id
errors: string
pitchCount: number
runnerActionDetail: string
runnersMapBefore: Map<string, string> # ランナーの状況
# collection
pitcherAction:
{pitcherActionId}: # 自動生成される一意の id
atBat: number
battingOrder: number
errors: null
hitterActionDetail: string
hitterId: string
optionalAction: string
pitchResult:
pitchPositionList: List<number>
pitchType: List<string>
runBattedIn: number
ポイントは teams/games/gameInfo
コレクションに試合の時系列データスタックさせて保存することで、アクションの取り消し操作が可能な点です。
また試合終了後に、Cloud Functions を利用して gameInfo から各選手の Member コレクションにその試合での hitterAction などをコピーすることで試合が作成、消去された時に個人成績が更新されるようになっています。
アクション取り消し前(1アウトになっている) | アクション取り消し後(ノーアウトに戻っている) |
---|---|
試合を記録させる UI
野球のアクションには非常に様々な事象が存在します。球種、コース、打球結果など通常のアクションに加え、選手の交代、ボーク、エラー、ワイルドピッチなどが連鎖して発生する可能性があります。
それらのアクションの入力を網羅しつつ、ユーザーが入力しやすい UI を作成することは非常に難易度が高いです。工夫点としてはできる限り 1 画面のボタン数を減らすこと、ボタン入力以外の入力操作を増やす(コース選択の UI やランナー状況選択の UI など)ことを意識しました。
コース選択 | 球種選択 | 選手交代 |
---|---|---|
打球方向の選択 | 打席結果の選択 | ランナーの状況選択 |
---|---|---|
マネタイズ方法
現時点でアプリに広告やサブスクなどを導入していないため全くマネタイズできていません。しかし、今後それらを導入した際に課題となりうる点についてご紹介します。それは、「市場の特性」、「チームという性質上課金対象は誰になるのか」という点です。
このアプリの対象ユーザーは野球をやっている人並びにその関係者(部活動の保護者やマネージャー)です。誰もが使用するようなツール系のアプリとは異なり市場が狭く、かつ学生などが多いため課金率も高くなさそうです。
また今回ユーザーは必ずいずれかのチームに所属することになります。この場合サブスクを導入したとして、にチーム全員が課金対象なのか、チームの作成者やオーナーなど特定の1人が課金対象なのかなど考えることが多くなりそうです。
苦労した点 2: モバイルアプリを開発するだけではないということ
個人開発でアプリをリリースするには機能開発だけでなく仕様の策定、デザイン、リリース準備、マーケティングなどたくさんのことが必要になります。
デザイン
コーディングはある程度ベストプラクティスが存在し学習方法が体系化されているのに対し、デザインは一定の原則などはあるものの学習方法があまり体系化されていないのかなと感じました。(良いデザインの勉強方法などあれば教えていただきたいです🙏)
以下のようなサイトで色々なアプリの UI を参考にしながら今回のアプリを作成しました。
https://mobbin.com/browse/ios/app
https://ui-pocket.com/apps/#google_vignette
https://www.uisources.com/home
ログイン画面や設定画面など多くのアプリで存在する UI は比較的作りやすかったのですが、試合入力などのこのアプリ特有の画面はデザインするのに非常に苦労しました。
それ以外にもデザインする必要があるものは、アプリロゴ、リリース用のストア画像など多岐に渡ります。アプリロゴは Canva で作成。ストア画像はAppMockUp を用いて作成しました。
https://www.canva.com/ja_jp/
https://studio.app-mockup.com/
リリース周り
Android と iOS どちらも対応する必要があるリリース周りは非常に苦労した分、初期リリースの喜びもひとしおでした。特に iOS ではこの春から Privacy Manifests 対応が必要になったりと苦労しました。
Privacy Manifests の対応では以下のツールを使うと対応状況が確認できるためおすすめです。
https://github.com/crasowas/app_store_required_privacy_manifest_analyser
下の例では Firebase Auth パッケージが対応できていないと表示されています。
==================== Analyzing Pods Directory ====================
Analyzing AppAuth 🎯 ...
⚠️ Missing privacy manifest file!
API usage analysis result(s): 0
Analyzing BoringSSL-GRPC ...
⚠️ Missing privacy manifest file!
API usage analysis result(s): 1
[0] NSPrivacyAccessedAPICategoryFileTimestamp:stat:../app/ios//Pods/BoringSSL-GRPC/src/crypto/x509/by_dir.c
🛠️ Descriptions for the following required API reason(s) may be missing: 1
[0] NSPrivacyAccessedAPICategoryFileTimestamp
Analyzing Firebase ...
⚠️ Missing privacy manifest file!
API usage analysis result(s): 0
Analyzing FirebaseABTesting 🎯 ...
💡 Found privacy manifest file(s): 1
[0] ../app/ios//Pods/FirebaseABTesting/FirebaseABTesting/Sources/Resources/PrivacyInfo.xcprivacy
API usage analysis result(s): 0
✅ All required API reasons have been described in the privacy manifest.
Analyzing FirebaseAnalytics ...
⚠️ Missing privacy manifest file!
API usage analysis result(s): 0
Analyzing FirebaseAppCheckInterop ...
⚠️ Missing privacy manifest file!
API usage analysis result(s): 0
Analyzing FirebaseAuth 🎯 ...
💡 Found privacy manifest file(s): 1
[0] ../app/ios//Pods/FirebaseAuth/FirebaseAuth/Sources/Resources/PrivacyInfo.xcprivacy
yAPI usage analysis result(s): 6
[0] NSPrivacyAccessedAPICategoryFileTimestamp:creationDate:../app/ios//Pods/FirebaseAuth/FirebaseAuth/Sources/Backend/RPC/FIRGetAccountInfoResponse.h
[1] NSPrivacyAccessedAPICategoryFileTimestamp:creationDate:../app/ios//Pods/FirebaseAuth/FirebaseAuth/Sources/Public/FirebaseAuth/FIRUserMetadata.h
[2] NSPrivacyAccessedAPICategoryUserDefaults:NSUserDefaults:../app/ios//Pods/FirebaseAuth/FirebaseAuth/Sources/Storage/FIRAuthUserDefaults.m
[3] NSPrivacyAccessedAPICategoryFileTimestamp:creationDate:../app/ios//Pods/FirebaseAuth/FirebaseAuth/Sources/User/FIRUser.m
[4] NSPrivacyAccessedAPICategoryFileTimestamp:creationDate:../app/ios//Pods/FirebaseAuth/FirebaseAuth/Sources/User/FIRUserMetadata.m
[5] NSPrivacyAccessedAPICategoryFileTimestamp:creationDate:../app/ios//Pods/FirebaseAuth/FirebaseAuth/Sources/User/FIRUserMetadata_Internal.h
🛠️ Descriptions for the following required API reason(s) may be missing: 1
[0] NSPrivacyAccessedAPICategoryFileTimestamp
Analyzing FirebaseAuthInterop ...
⚠️ Missing privacy manifest file!
API usage analysis result(s): 0
Privacy Manifests 対応以外にもストアに表示する PR 用の文言や、プライバシーポリシー URL を作成する必要がありました。プライバシーポリシー URL に関しては Notion の公開ページや Github Pages などを用いて作成しています。ありがたいことにプライバシーポリシーの雛形を公開してくださっているサイトも存在します。
https://kiyaku.jp/hinagata/privacy.html
マーケティング
マーケティングをしてアプリの認知を広め、ユーザーを獲得していく必要があります。しかしこちらの方面に全く詳しくないため試行錯誤しながら続けていこうと考えています。現状ではこの記事や X でのアカウント運用、知り合いの野球チームに導入してもらうなど小さなことしかできていません😭
X のアカウントのフォローもよろしくお願いします!
https://x.com/yakyulog_ja/status/1785157143198679221
🙇♂️ おわりに
初の技術記事の執筆で至らない点が多かったと思いますが、最後まで読んでいただきありがとうございました 🙇♂️
まだまだ書ききれなかった点なども多いので今後また新しく記事を書いていこうと思います!