はじめに
そろそろアウトプット活動をしていこうと思いつつ、重い腰がなかなか上がらずの日々を過ごしていましたが、最近開発していたアプリについて記事を書いてみようと思い、今回の記事を書くことにしました。
個人開発の記事は初めて書いてみましたが、自分の国語力の低さを痛感しました。。文章構成や表現力が乏しい文章になってしまい、読みづらいかもしれませんが、ご了承ください🙏
今回、アプリを開発する きっかけ となったのは、私自身がジムに通っていていつも思うことがあり、その 解決策 としてアプリを作ろうと思ったことが開発のきっかけとなります。
現在、アプリは TestFlight でのみ配布しています。
App Storeに公開する段階でオープンソースにしたいと考えていますが、App Storeへの公開まで行くかどうかが未定なので、その辺りは今後の課題となります。
いつも思っていたこと
- メニューを事前に決めていないため、現地で決めるのに 時間がかかる。
- 他のアプリでは自分が求めている使い方ができず、記録が続かない。
- 次の予定を立てる際に、過去の実績を参考にすることができず、効率が悪い。
開発に至る理由
初めは ノートで管理 していこうと思いましたが、メニュー設定の手間と管理面で不便な点が多いと感じ、アプリを作ろうと思ったことが開発のきっかけとなります。
あと、 Apple Watchアプリの開発 をしてみたかったのもあり、アプリ開発に臨むことにしました。
用語の明確化
ドキュメント内容やメソッド名・変数名などで 用語の統一 がされていないと、後々混乱が生じると予測していましたので、事前にアプリのドメインに関する用語の意味を調査しました。
- トレーニング: 競技で成果を出すための運動
- ワークアウト: 体型を変えるための運動
- メニュー: トレーニングやワークアウトの組み合わせ
用語の意味を明確にしたところで、「トレーニング」と「ワークアウト」が似たような意味を持っており、混乱の元になると感じたため、用語を統一するためにアプリの使用目的により近いであろう「ワークアウト」という用語で統一することにしました。
開発したアプリ
本題に入る前に、こちらが開発したアプリになります。
アプリ名の「わくこま」ですが、英語でのアプリ名は Workout Commander として命名し、直訳すると「ワークアウトの指揮官」という意味になります。
ワークアウトの計画、管理、実行を指揮する役割を担うアプリやツールを連想させる意味を込め命名しました。
これらの単語の頭文字を取って「わくこま」というアプリ名になりました。
アプリの主な機能は以下となります。
-
メニュー作成
ワークアウト種目(ベンチプレス、デッドリフトなど)を選択し、セット内容を入力します。
セット内容で入力する項目は「セット数」「回数」「重量」「インターバル」など、ワークアウト種目ごとに異なります。 -
ワークアウト実施
メニュー内のワークアウトを実施します。
セット内容で入力したインターバルを計るための タイマー を用いて、セット間のインターバルを管理します。 -
実績登録
ワークアウトの実施が完了したら、実績 を登録します。 -
メニューのPDF出力
メニューを PDFファイル として出力できます。
現在、アプリの開発段階は アルファ版 で止まっており、
アルファ版 → ベータ版 → 正式版 と段階を踏んで機能を増やしていく予定でしたが、 アルファ版 で自分の欲しかった機能(メニュー作成、予定・実績入力)が実装できたため、個人的に満足して使っているせいもあり ベータ版 の着手に至っていない状況です。。 ベータ版以降 で実装予定の「ワークアウト履歴」や「お知らせ表示」は未実装です。
アルファ版 は TestFlight で配布して使用しています。Apple Watchアプリも開発しましたが、私の所有しているApple Watchが 開発したアプリのOSに対応していない ことに実機テスト時に気づいたため、実機での使用ができていません。。
ので、そろそろ新しい機種を買おうと考えています。実際に使ってみるとバグが出るかもしれない(絶対出る)のでApple Watchアプリは完成したとは言えない状態です。
App Store で公開するとなると、これらの機能をしっかり整える必要があるため、やるとなったら先は長そうです。
要件定義
コンセプト決め
アプリ開発に入る前に、まずはアプリの コンセプト を定義しました。
基本的には、自分が求めているアプリになるように目指し、以下のコンセプトからブレないように開発を進めました。
- シンプル
- 機能が最小限
- ユーザービリティが良い
目的の定義
個人開発をしていてよくやってしまうことが(自分だけかもしれませんが。。)アプリの目的を忘れて よくわからない機能 を実装してしまい、アプリの使い勝手を悪くしてしまうことがあります。
そのため、設計・開発段階でアプリの 目的を見失わない よう、以下のように目的を定義しました。
- メニュー設定の 時間と労力を削減 する
- ワークアウト記録の管理を簡単にし、記録管理の手間を削減 する
- インターバルを可視化して、セット間の インターバルを管理 する
目標の定義
コンセプトと目的に基づき、アプリの 目標 を定義しました。
以下の目標が達成されれば、アプリの開発は成功と考えました。
- ジムに行く前にメニューを 事前に作成 するようになる
- ワークアウトの 記録を続ける ことができる
- ワークアウトの実施が 効率化 される
ちなみに、数ヶ月使ってみた結果ですが、今のところ目標は達成されていると感じています🙌
ユースケース作成
まずは、頭の中で漠然としているアプリのイメージを明確化し、実装に落とし込んでいくためにユースケース図を作成しました。
- ジムに行く前に、メニューを作成する
- ジムに行き、作成したメニュー内のワークアウトを実施する
- ワークアウトの実施が完了したら実績を記録して、ワークアウトを終了する
- 3を繰り返してメニュー内のワークアウトを全て実施していく
といったユースケースをイメージしました。
メニュー と ワークアウト の関係としては、ユーザーは日付ごとにメニューの作成が可能で、メニューには複数のワークアウトを登録できる感じです。
メニューとワークアウトの関係)
"田中 太郎" (ユーザー)
|
| - 2024-01-01 (メニュー)
| | - ベンチプレス (ワークアウト)
| | - デッドリフト (ワークアウト)
| | - スクワット (ワークアウト)
|
| - 2024-01-02 (メニュー)
| | - ダンベルカール (ワークアウト)
| | - EZバーカール (ワークアウト)
・・・
サービス定義
アプリの目的・目標やユースケースを踏まえて、サービスを実現するために フロントエンド と バックエンド の要件も定義しました。
フロントエンド要件
フロントエンドの要件は、ユーザーがストレスなく アプリを使用できることに重点を置いて以下の要件を定義しました。
- UI
- シンプルかつ 直感的 にする
- ユーザーが 理解しやすい 画面構成にする
- UX
- 操作性 に優れている
- 機能
- 複雑または直感的でない 機能は排除 する
- ストレスなく使える パフォーマンス を目指す
- 一つの画面に 多数の機能 を詰め込まない
アプリのコンセプトと同様に シンプル かつ ユーザービリティ に優れたアプリを目指すといった感じですね。
バックエンド要件
バックエンドの要件は、主に 管理面 を考慮したものとして、以下の要件を定義しました。
- サーバーの 管理/運用コスト を極力減らす
- レンタルサーバーやAWSなどのPaaSは管理が煩雑で運用コストがかかる
- FirebaseやVercelなどのアプリ開発・ホスティングサービスの利用を検討する
- 可能であれば 無料 または 定額制 のものを選択する
- 従量課金制のサービスは、セキュリティ対策が不十分な場合や予期せぬトラブルが発生した場合にコストがかかる
個人開発のため、管理コストを極力かけずに運用可能 なサービスを選定することが重要であると考えました。
システム構想
要件定義・サービス定義を踏まえて、システム構想を図にまとめました。
フロントエンド
- ユーザー認証には IDプロバイダ認証サービス を使用し、「メールアドレス」または「Google」「Apple」のアカウントなどで認証を行う。
- メニュー登録は iPhone から行う。
- ワークアウト中の「メニュー確認」と「ワークアウト実施」は主に Apple Watch で行うが、 iPhone でも同様の機能を実装し利用可能にする。
バックエンド
- 書込トランザクションが多数発生する想定なので、パフォーマンス性を考慮して NoSQL を使用する。
- 複数のサービスを利用すると管理コストがかかるため、認証サービス と データベース機能 の両方を提供している Firebase を使用する。
従量課金制以外のサービスで今回の要件を満たすものを探しましたが、見つからなかったため従量課金制の Firebase を使用することにしました。
ただ、Firebaseは 無料プラン もあり、 認証サービス と制限付きで データベース機能 の両方を提供しているため、管理コストを抑えることができます。
従量課金制であれば AWS や Azure といった選択肢もありましたが、Firebaseよりも管理・運用コストが高いと感じているので、今回はFirebaseを選定しました。
基本設計/機能設計
開発環境
iPhoneアプリの開発プラットフォームは Expo を使用し、Apple Watchアプリの開発プラットフォームは XCode を使用しました。
ここで違和感があるかもしれませんが、iOSアプリ と watchOSアプリ を開発するのに、なぜ XCode(SwiftやSwiftUI) ではなく Expo(React Native) でiOSアプリを開発したのか?と思うかもしれません。
・・・これは設計当時はあまり深く考えておらず、いつも使っている Visual Studio Code で開発できる Expo を安易に選んだしまったためです😅
iOSアプリの開発 → Apple Watchアプリの開発 と進めていく予定だったため、Apple Watchアプリのことは後回しにしてしまい特に考えもせず、勢いだけでiOSアプリの開発に React Native(Expo) を選定してしまいました(軽率すぎる。。)
このせいでApple Watchアプリ開発時に 後悔 することになります。。
iPhoneアプリ開発環境
役割 | ツール/サービス |
---|---|
コードエディタ | Visual Studio Code |
React Native 開発プラットフォーム | Expo |
Apple Watchアプリ開発環境
役割 | ツール/サービス |
---|---|
統合開発環境 | XCode |
開発支援(Dev)
役割 | ツール/サービス |
---|---|
タスク管理 | Asana |
バージョン管理 | GitHub |
ドキュメント管理 | Notion |
製図 | draw.io |
コミュニケーションツール | Slack |
画面レイアウト | Figma |
運用支援(Ops)
役割 | ツール/サービス |
---|---|
CI/CD | GitHub Actions |
ビルド通知 | Slack |
製図 | draw.io |
機能一覧
ワークアウト追加
メニューに ワークアウトを追加(登録) する機能で、セット内容の入力ではセットごとに 共通の値 を入力するか、セットごとに 個別の値 を入力するかを選択可能です。
セット内容の入力では 前回設定した内容 があれば、その設定内容が 初期入力 されるようになっています。この機能のおかげで、毎回同じ内容を入力する手間が省ける ことに併せて、前の予定を参考にして次の予定を設定することができます。
前回の実績 があれば画面下に表示しているため、前回の実績を参考にして次の予定を設定することもできます。
機能 | デモ動画 | 説明 |
---|---|---|
ワークアウト追加(セット個別) | ・メニューにワークアウトを追加(登録)する機能 ・セット毎に個別の入力が可能 |
|
ワークアウト追加(セット共通) | ・メニューにワークアウトを追加(登録)する機能 ・全セット共通の入力が可能 |
メニュー確認
メニューは日付ごとに確認することができます。
メニュー内のワークアウトをタップすることで、アコーディオンUIが展開され、セット内容を確認することができます。
機能 | デモ動画 | 説明 |
---|---|---|
メニュー確認 | 日付毎のメニューを確認する機能 | |
セット内容確認 | セット内容を確認する機能 |
ワークアウト修正・削除
機能 | デモ動画 | 説明 |
---|---|---|
ワークアウト修正 | メニュー内のワークアウトを修正する機能 | |
ワークアウト削除 | メニュー内のワークアウトを削除する機能 |
ワークアウト実施
ワークアウト実施は iPhone と Apple Watch の両方で行うことができます。
基本的には Apple Watch でワークアウトを実施することを想定していますが、Apple Watch を持っていないユーザー 向けに iPhone でワークアウトを実施する機能を設けています。
実績入力
機能 | デモ動画 | 説明 |
---|---|---|
実績入力 | ・実績を入力する機能 ・全セットの実績が登録されるとワークアウト終了となる |
PDF出力
メニューをPDFファイルとして出力する機能です。
これはアプリの目的としては 必須ではない機能 ですが、紙で管理したい人もいるかも、、と思い記録管理方法の観点から鑑みて アルファ版 で実装しようと思いました。
機能 | デモ動画 | 説明 |
---|---|---|
PDF出力 | ・メニューをPDFとして出力する機能 ・PDFファイルを印刷/共有することも可能 |
システム方式
システム構成図
フロントエンド
フロントエンドは iPhoneアプリ と Apple Watchアプリ の2つで構成されています。
iPhoneアプリ
Expo を使用することでネイティブ部分の意識をあまり持たずに開発を進めることができ、開発効率を上げることができるため使用しています。
Expo DevClient を使用することで、Apple Watch連携などのネイティブ機能を使用することも可能です。
UIライブラリは Native Base を使用する予定でしたが、すでに開発が終了しているということが分かり、後継である gluestack-ui というライブラリを使用することにしました。
状態管理は Redux よりもシンプルで使いやすい Recoil を採用しました。
ルーティングは React Navigation を使用する予定でしたが、 Expo Router という React Navigation をベースとしたラッパーライブラリが出ており、非常にシンプルな画面管理が実現できるようになっていたため、こちらを採用しました。
カテゴリ | ツール/ライブラリ/フレームワーク | 備考 |
---|---|---|
言語 | TypeScript | |
フレームワーク | React Native(Expo) | |
UI | gluestack | Native Base は gluestack に移行したらしい。 |
状態管理 | Recoil | Redux よりも Recoilの方がシンプルで使いやすいので採用した。 |
ルーティング | Expo Router(React Navigation) | Expo で使える React Navigation のラッパーライブラリが出ていて使いやすかったので採用した。 |
コードフォーマッター | Prettier | |
静的解析ツール | ESLint | |
エディタ設定 | EditorConfig |
Apple Watchアプリ
Apple Watchアプリは ネイティブアプリ(Swift、SwiftUIなど)でのみ 開発が可能です。
Swift に比べて SwiftUI は宣言的UIで記述でき、コードがシンプルになることと、ライフサイクル管理が楽になることが個人的にメリットだと感じているため、SwiftUI を採用しました。あとMVVMアーキテクチャの導入が容易であることも採用理由の一つです。
カテゴリ | ツール/ライブラリ/フレームワーク |
---|---|
言語 | Swift |
UI | SwiftUI |
コードフォーマッター | SwiftFormat |
静的解析ツール | SwiftLint |
リソース管理 | SwiftGen |
バックエンド
iOSアプリのCI/CDは GitHub Actions と Expo で構築しています。
iOSアプリは Expo で開発しており、Expo は GitHub Actions と連携してビルド・デプロイを行うことができるため、CI/CDの構築は容易です。
Apple Watchアプリは XCode で開発しているため、CI/CDの構築が困難だと思いました。もしかするとCI/CDの構築を簡単に行う方法があるかもしれませんが、調査と構築に時間がかかると考えたため、一旦保留しています。
アプリケーション開発プラットフォームには Firebase を使用しています。
認証機能には Firebase Authentication を使用し、データベースには NoSQLデータベースである Cloud FireStore を使用しています。
CI/CD
カテゴリ | サービス | 備考 |
---|---|---|
iOSアプリ | GitHub Actions & Expo | |
Watchアプリ | - | CI/CDの構築が困難と考えたため一旦保留する |
アプリ配信サービス
カテゴリ | サービス |
---|---|
iOSアプリ | 正式版: AppStore |
アルファ版/ベータ版: TestFlight | |
Apple Watchアプリ | 正式版: AppStore |
アルファ版/ベータ版: TestFlight |
アプリケーション開発プラットフォーム
カテゴリ | ツール/ライブラリ/フレームワーク |
---|---|
認証 | Firebase Authentication |
データベース | Cloud FireStore |
その他
ワークアウト種目データの管理
アプリ内で使用する ワークアウト種目データ(ワークアウト種目、部位、項目(回数、重量、インターバルなど)) などの管理は Notionデータベース で行うことにしました。
ExcelやGoogleスプレッドシート、またはRDBなどでもデータ管理は可能ですが、ドキュメントや各種データを 一元管理 する目的もありNotionを使用することにしました。
Notionデータベースでワークアウト種目や部位などを管理する テーブル を作成し、それぞれのテーブルに対して リレーション を設定することで、データの整合性を保つように設計しています。
Notionデータベースで定義したデータは Notion API を介して取得しています。
Notionデータベースから取得したデータは、アプリで使用するための データ形式(JSON)に変換 する必要があるため、Notion APIを介してデータを取得し、データの形式を変換するための ツール を作成し、GitHub Actions からツールを実行して使用することにしました。
画面設計
画面レイアウト&画面遷移図
画面レイアウトの設計は Figma を使用しました。
Figmaでは プロトタイプ という機能で画面遷移のデモを確認できるため、画面遷移図としても活用してました。
DB設計
ER図
NoSQLのデータベースを使用しているため、データベース側で リレーションの設定はできません が、データの関連を可視化するためにER図を用いて 擬似的なリレーション を表現しています。
NoSQLには テーブル という概念がないため、「コレクション」「ドキュメント」「フィールド」という概念を用いてデータを管理することになります。
コレクションとドキュメントは以下の性質を持ちます。
- コレクション: 「ドキュメント」を持つ。
- ドキュメント: 「サブコレクション」と「フィールド(値)」を持つ。
アプリで使用しているNoSQLデータベースのコレクションは usersコレクション と workoutsコレクション の2つで構成しており、それぞれのコレクションにサブコレクションが連なる形でデータを管理しています。
コレクションの役割は以下のとおりです。
users(コレクション): ユーザー情報を管理する
| - menus(サブコレクション): ユーザーが登録したメニュー情報を管理する
workouts(コレクション): 全ユーザーのワークアウト情報を管理する
以下は、アプリ内で使用しているNoSQLデータベースの構造例です。
※ m1 は メニュー1 を表し、w1 は ワークアウト1 を表しています。
🗂️ users(コレクション)
| - 📋 "user01"(ドキュメント)
| | - 🗂 menus(サブコレクション)
| | | - 📋 "2024-01-01"(ドキュメント)
| | | - 🖋 id: "m1"(フィールド)
| | | - 🖋 workoutIds: ["w1", "w2", "w3"](フィールド)
| | ・・・
| | | - 📋 "2024-01-03"(ドキュメント)
| | | - 🖋 id: m3(フィールド)
| | | - 🖋 workoutIds: ["w7", "w9", "w10"](フィールド)
| | ・・・
| | ・・・
| |
| | - 🖋 uid: "user01"(フィールド)
| ・・・
|
| - 📋 "user02"(ドキュメント)
・・・
🗂️ workouts(コレクション)
| - 📋 "w1"(ドキュメント)
| | - 🖋 uid: "user01"(フィールド)
| | - 🖋 id: "w1"(フィールド)
| | - 🖋 wEvtId: "we2"(フィールド)
| | - 🖋 sets: [
| { setNumber: 1, rep: 10, weight: 40, interval: 60 },
| { setNumber: 2, rep: 10, weight: 50, interval: 60 },
| { setNumber: 3, rep: 10, weight: 60, interval: 60 },
| ](フィールド)
| ・・・
| - 📋 "w2"(ドキュメント)
| | - 🖋 uid: "user01"(フィールド)
| ・・・
| - 📋 "w3"(ドキュメント)
| | - 🖋 uid: "user01"(フィールド)
| ・・・
| - 📋 "w4"(ドキュメント)
| | - 🖋 uid: "user02"(フィールド)
| ・・・
| - 📋 "w5"(ドキュメント)
| | - 🖋 uid: "user02"(フィールド)
| ・・・
| - 📋 "w6"(ドキュメント)
| - 🖋 uid: "user03"(フィールド)
・・・
・・・
ワークアウト種目データ
アプリ内で使用する ワークアウト種目データ(ワークアウト種目、部位、項目などの情報) は Notionデータベース で管理しているため、そのデータをアプリで使用するために JSONファイル に出力してアプリに組み込んでいます。
そのために Notion API を叩いてデータベースからデータを取得し、JSONファイル として出力するための ワークアウト種目データ出力ツール を作成して使用しています。
ER図
Notionデータベースは以下のように設計しました。
ワークアウト種目テーブル
ワークアウトの種目(ベンチプレス、デッドリフトなど)を登録するテーブルとなります。
このテーブルが マスタテーブル となり、他テーブルとのリレーション設定 により、ワークアウト種目テーブルにデータを登録することで、他テーブルの情報を参照してワークアウト種目テーブルで使用できるようになっています。
部位テーブル
ワークアウト種目で鍛えることができる 部位 を登録するテーブルになります。
ワークアウト種別テーブル
ワークアウト種目の ワークアウト種別 を登録するテーブルになります。
ワークアウト種別というのは「有酸素運動」「ウェイトトレーニング」「自重トレーニング」などのことを指します。
用語の統一を図るために、「トレーニング」という用語は使用せずに「ワークアウト」を使いますと記事の冒頭で言っていましたが、「ウェイトワークアウト」や「自重ワークアウト」とはあまり言わない気がするなーと思い、より一般的に使用されている「ウェイトトレーニング」や「自重トレーニング」という用語を使用しています。
項目テーブル
セット内容の入力時に使用する 項目(回数、重量、インターバルなど)を登録するテーブルになります。
ワークアウト種目ごとに 項目は異なる ため、ワークアウト種目テーブルから項目テーブルにリレーションを設定して、ワークアウト種目ごとに異なる項目を設定することができるようになっています。
アプリ側のセット内容入力時に表示する ボタン情報 もこのテーブルで管理しています。
デフォルト値テーブル
ワークアウト種目別の デフォルト値 を設定するテーブルになります。
デフォルト値テーブルは以下のように使用します。
- デフォルト値テーブルに 登録されていない ワークアウト種目は、項目テーブルの値 をデフォルト値として使用します。
- デフォルト値テーブルに 登録されている ワークアウト種目は、デフォルト値テーブルの値 をデフォルト値として使用します。
例えば、ベンチプレス をデフォルト値テーブルに登録しなかった場合は、項目テーブルの重量のデフォルト値である 5kg が設定されてしまいます。この重量は ダンベルを扱うワークアウトには適切 かもしれません。しかし、ベンチプレスにおいては、バーベルの基本重量が 20kg であるため、5kg がデフォルト値として設定されるのは不適切となります。そのため、このテーブルでベンチプレスのデフォルト値を 40kg に設定することで、より適切な重量をデフォルト値として設定することが可能になります。
ワークアウト種目の追加
ワークアウト種目を追加する場合、ワークアウト種目テーブルに追加するワークアウト種目を登録します。部位などの属性には部位テーブルとのリレーションが設定されているため、部位テーブルから適切な値をリスト形式で選択して設定することができます。これにより、ワークアウト種目の追加時にデータの整合性を保つことが可能です。
Notionデータベースを使用するにあたっての注意点
Notionのデータベースを使用するにあたって、以下の点に注意しました。
- トランザクション処理ができない
- プライマリキー(PK)の設定ができない
- Notionデータベースの値に依存した実装を避ける
トランザクション処理ができない
Notionデータベースでは トランザクション処理ができない ので、データの整合性を保つために、データ編集時に注意が必要でした。
Notionの変更履歴からロールバックすることは可能ですが、データ編集時はデータ整合性が崩れないように操作ミスを避けるように作業していました。
プライマリキー(PK)の設定ができない
Notionデータベースでは プライマリキー(PK)の設定ができない ため、データの一意性を保つためには、システム側での制御ができなく、人間側での管理が必要になります。
ER図でPKを設定しているカラムは、アプリのコード上で使用していたり、サーバーDBのデータに保存されているなど、変更不可のデータ をPKとして表現しています。
Notionデータベースの値に依存した実装を避ける
ユーザーが登録するメニューなどは Firestore で管理しており、ワークアウト種目データは Notionデータベース で管理しているため、異なるデータベースで管理している都合上、データの整合性を保つために、Notionデータベースの値に依存した実装は避けるようにしました。
例えば、アプリ内で特定のキー(ワークアウト種別ID や 部位ID など)に依存した実装を避けることで、データが変更された際に コードの変更 が必要になるリスクを減らして、データ整合性を保つようにしました。
しかし、一部のキー、具体的には「ワークアウト種目ID」と「項目ID」は アプリとの依存が必須 となります。
ワークアウト種目ID は、ユーザーがFirestoreに登録したワークアウト情報がどのワークアウト種目のデータなのかを判別するため、Firestoreにワークアウト種目IDを含める必要があります。
項目ID については、例えば、アプリ内で 重量 のデータを扱う場合、プロパティは weight として扱う必要があり、この weightプロパティ を使用するための条件に 重量のID を使用する必要があります。
重量の入力値が変更された場合 のコード例は以下のようになります。
// 入力された項目ボタンのIDを取得する
const pressedItemId = item.id // 重量のボタンが入力されたら、重量のIDである 2 が入る
// 重量のボタンが押された場合の処理
if (pressedItemId === 2) {
// 入力値を取得する
const val = input.value;
// 重量のデータを取得する
const set = {...set, weight: val} // セット内容の重量の値を更新する
// セット内容を更新する
updateSet(set);
// インターバルのボタンが押された場合の処理
} else if (pressedItemId === 3) {
・・・(省略)・・・
他の項目も項目毎に固有のプロパティを使用するために、項目IDを使用して分岐条件を実装する必要があります。
実際のコードでは、特定のキー(項目IDなど)に依存する箇所は、キーをハードコーディングせず 定数化 し、分岐処理を メソッド化 することで、保守性を高めるように実装しています。
ツールを使用してアプリにワークアウト種目データを組み込む
- GitHub Actions でツールを実行する
- Notion API を叩いてデータベースのデータを取得する
- アプリで使用するため JSONファイル を出力する
- 出力されたJSONファイルをアプリに組み込む
Notionデータベースで定義したワークアウト種目データは、リレーション設定がされているため、ツール側でリレーションを参照してデータ整合性が保たれたJSONファイルが出力される実装になっています。
以下はJSONファイルの出力例となります。
ワークアウト種目のJSONファイル例
・・・
{
"id": 300,
"name": "ベンチプレス",
"partIds": [400], ← 部位テーブルのIDを参照して設定している
"workoutExec": true,
"itemIds": [1, 2, 3] ← 項目テーブルのIDを参照して設定している
},
{
"id": 303,
"name": "インクラインベンチプレス",
"partIds": [400],
"workoutExec": true,
"itemIds": [1, 2, 3]
},
・・・
部位のJSONファイル例
・・・
{
"id": 400,
"name": "大胸筋"
},
{
"id": 500,
"name": "広背筋"
},
・・・
項目のJSONファイル例
[
{
"id": 1,
"name": "回数",
"unit": "回",
"defaultValue": 10,
"minValue": 1,
"maxValue": 50,
"buttonValues": [-1, -5, -10, 10, 5, 1],
"isLastSetUnneeded": false
},
{
"id": 2,
"name": "重量",
"unit": "kg",
"defaultValue": 5,
"minValue": 0,
"maxValue": 300,
"buttonValues": [-1, -5, -10, 10, 5, 1],
"isLastSetUnneeded": false
},
・・・
製造
UI/UXの設計
キーボード入力を無くす
キーボード入力は、入力ミス が発生しやすく、また入力が面倒であると感じたため、キーボード入力以外の方法を検討しました。最初の設計では、スライダー で入力する方法を採用していましたが、「セット内容の入力」と「セット切り替え」を スワイプ で行う都合上、スライダーの操作とセット切り替えの アクションが競合 してしまいました。そのため、スライダーではなく ボタン を使用する入力方法に変更しました。また、スライダーでの入力では微調整しにくい(1kgずつ調整しにくい)ことも考慮し、ボタンが最適だと判断しました。
カレンダー画面のUIを簡素化
カレンダー画面には、セット内容を表示することに特化 したUIになるように設計しました。最初の設計では、カレンダー画面で セット内容の編集 なども可能な設計にしていましたが、フロントエンド要件として定義していた「一つの画面に多数の機能を詰め込まない」という方針に反していると感じたため、カレンダー画面のUIを簡素化しました。
情報の優先度に応じて、UIの位置を最適化
画面ごとに 重要な情報 を目立たせるようにしました。例えば、実績入力画面では、実績を入力するUI が最も重要な情報ですので、実績入力UIを 一番目立つ位置に配置 しています。他の情報を表示するUIは、実績入力UIよりも 目立たない位置 に配置することで、ユーザーが 実績入力UIに注目 しやすくなるようにしました。
UIとアクションの一貫性を保つ
UIの配置やアクションの一貫性を保つことで、アプリ操作を覚えやすくして 直感的 に操作できるようにしました。
例えば、UIの配置場所に困ったら、いつもはナビゲーションバーに配置することが多かったのですが、アクションの一貫性を保つために、決定関連のアクションは画面の一番下 にボタンを用意して、ナビゲーションバーに配置するボタンは 補助アクションに統一 するなど、アプリ内で アクションが統一 されることでユーザーが混乱することなくアプリを使用できるようにしました。
コスト削減
FirestoreはドキュメントのRead/Write数に応じて料金が発生するため、コスト削減のために以下の点に注意しました。
例えば、"2024年1月" のメニューを取得するロジックの場合は以下のようになります。
Firestoreのコスト削減
❌ NGパターン
// メニュー取得対象の日付を取得する
const selDate = "2024-01-01".format("YYYY-MM"); // "2024-01"
// ユーザーの全てのメニューを取得する
const menusDocs = await getFirestoreDocs<Menu>("users", ["user01", "menus"]);
const allMenus = menusDocs.map(menusDoc => menusDoc.data())});
// "2024年1月" のメニューのみを取得する
const filteredMenus = allMenus.filter(menu => menuDoc.data.date.format("YYYY-MM") === selDate);
ユーザーの 全てのメニュー を一度取得し、"2024年1月" のメニューのみを フィルタリング して取得するロジックとなります。全てのメニューを取得してしまうことで、ドキュメントの読み取り回数が増え、それに応じたコストがかかるため、コスト削減のためにコードを最適化する必要があります。
🟢 OKパターン
// メニュー取得対象の日付を取得する
const selDate = "2024-01-01".format("YYYY-MM"); // "2024-01"
// "2024年1月" のメニューのみを取得するクエリを作成する
const query = [orderBy('day'), startAt(selDate), endAt(selDate + '\uf8ff')];
// クエリを用いて "2024年1月" のメニューのみを取得する
const menusDocs = await getFirestoreDocsWithQuery<Menu>("users", ["user01", "menus"], query);
const menus = menusDocs.map(menusDoc => menusDoc.data);
クエリ を用いて、"2024年1月" のメニューを取得するロジックです。
"2024年1月" のドキュメントのみを取得対象とすることで、ドキュメントの読み取り回数を削減し、コストを削減することができました。
コスト削減はもちろんのこと、検索回数が減るということはパフォーマンスも向上します。
ワークアウト種目データの管理コスト削減
Notionデータベース を使用することで、ワークアウト種目データの 管理コストが削減 されたと感じています。ExcelやGoogleスプレッドシート、またはSQLiteやPostgreSQLなどのRDBでデータを管理する選択肢はありましたが、どれを選んでも管理コストがかかると感じ、いっそのことNotionでやってみよう!!と思ったのは良い決断だっとと思っています。
業務で使用するデータでは「可用性」や「データ整合性」などを必要とする場合が多いので、その場合はRDBなどを使用して厳格なデータ管理を行うべきだと思いますが、個人開発のアプリであれば、Notionデータベースを使用することでデータ管理コストを削減できると思います。
変更容易性
ワークアウト項目の追加に伴うコード変更の容易さ
Notionデータベースとアプリの 依存関係を最小限に抑える ことで、項目(重量、回数、インターバルなど)やワークアウト種別(ウェイトトレーニング、有酸素運動など)の増減があった場合に、Notionデータベースを変更するだけで、アプリ側の ロジックの変更をほとんど必要とせず にデータを使用できるよう設計しています。
例えば、セット内容を扱う場合は、以下のようなオブジェクトを生成して使いまわしています。
sets = [
{ serNumber: 1, rep: 10, weight: 40, interval: 60 }, // 1セット目
{ serNumber: 2, rep: 10, weight: 50, interval: 60 }, // 2セット目
{ serNumber: 3, rep: 10, weight: 60, interval: 60 } // 3セット目
]
このセットの内容を変更する際に、重量を更新する場合は、ユーティリティー関数 を使用して更新オブジェクトを取得し、分割代入 を使って更新オブジェクトをセット内容にマージします。これにより、柔軟性のあるコードを実現しています。
// セット内容を更新する
const updateSet(setNumber: number, itemId: number, value: number) {
// 更新対象項目の「更新オブジェクト」を取得する
// itemId: 2(重量), value: 50 の場合、{ weight: 50 } が取得される
const updValObj = getValObjByItemId(itemId, value); // { weight: 50 }
// 1セット目のセット内容を取得
const set = sets.find(set => set.setNumber === setNumber); // { serNumber: 1, rep: 10, weight: 40, interval: 60 }
// 更新後のセット内容を取得する
// 「分割代入」を使用して、更新対象の項目の「更新オブジェクト」をセット内容にマージする(weightのみが更新される)
const updatedSet = { ...set, ...updValObj }; // {serNumber: 1, rep: 10, weight: 50, interval: 60 }
// updateSetでセット内容を更新する処理を行う
}
// セットの項目IDに基づいて、プロパティを含めたセットの値を取得する
const getValObjByItemId = (itemId: number, val: number | undefined): Value => {
switch (itemId) {
case ITEM_ID_REP: // 回数の項目IDは 1
return { rep: val };
case ITEM_ID_WEIGHT: // 重量の項目IDは 2
return { weight: val };
・・・(省略)・・・
}
}
updateSet(1, ITEM_ID_WEIGHT, 50); // 1セット目の重量を 50 に更新する
項目の増減時には、ユーティリティ関数(上記のコードではgetValObjByItemId)を変更するだけ で済むため、コントローラー側(画面側)のコードを変更する必要がありません。これにより、変更容易性のあるコードを実現しています。
ワークアウト種目が どの項目を使用するか は、ワークアウト種目が持つ 項目ID に基づいて項目データを取得し、その 項目データの有無 で制御しています。
例えば、以下は 項目の描画 を行うコードです。
// 指定したIDのワークアウト種目を取得する
const workoutEvt = getWorkoutEvent(workoutEvtId)
// ワークアウト種目が持つ項目IDを取得する
const itemIds = workoutEvt.itemIds;
for (const itemId of itemIds) {
// 項目データ(JSONファイル)から該当する項目IDの項目データを取得する
const item = loadItemByItemId(itemId)
// 項目が定義されていないなどで、項目を取得できない場合は、undefinedを返して項目の描画を行わない
if (item === undefined) {
return undefined;
}
// 項目が取得できた場合は、その項目を描画する
return <Item name={item.name} />
}
これで、項目の増減がある場合も自動的に対応できるような実装にしており、変更容易性のあるコードを実現しています。
役割分離
iPhoneアプリ と Apple Watchアプリ の役割
iPhoneアプリとApple Watchアプリの役割は要件定義の段階で明確にできたため、それぞれのデバイスに適したアプリになったと思います。
最初は、Apple Watchアプリ に iPhoneアプリ と 同レベルの機能(メニュー参照や実績入力) を実装しようとしていましたが、「そもそもApple Watchってそういう使い方しなくない?」というアドバイスを受け、方針を変えました。Apple Watchアプリは、全日ではなく 今日 と 前日 の メニューの表示 と ワークアウト実施 のみに機能を限定し、iPhoneアプリの補助ツール としての役割になるように設計しました。
この設計により、各デバイスの特性に応じて機能を絞ることができ、デバイスの特性を活かしたアプリになりました。
失敗したこと
変更容易性を考慮しない実装になっていた
こだわった実装にて「変更容易性を考慮して実装した」とは言いましたが、ある段階までは変更容易性を考慮していない実装でした。ある段階とは ワークアウト種別の追加 が必要になった時です。
初めはワークアウト種別が「ウェイトトレーニング」と「有酸素運動」の2種類で実装されており、「自重トレーニング」は考慮されていませんでした。
ワークアウト種別ごとに設定可能な項目は以下のように想定していました。
- ウェイトトレーニング: 「回数」「重量」「インターバル」
- 有酸素運動: 「時間」「速度」「傾斜」
- 自重トレーニング: 「回数」「インターバル」
実装は以下のようになっていました。
if (workoutType === "WeightTraining") {
// ウェイトトレーニングの場合の処理
// 「回数」「重量」「インターバル」の項目に対して処理を行う
} else if (workoutType === "Aerobic") {
// 有酸素運動の場合の処理
// 「時間」「速度」「傾斜」の項目に対して処理を行う
}
この実装方法では、 ワークアウトの種別 によって処理を分岐し、ワークアウト種目ごとに項目の設定ができない ことが明らかになりました。
例えば、懸垂の ワークアウト種別は「自重トレーニング」に分類されるため、項目は「回数」「インターバル」のみになり、「重量」の項目はありません。
ただし、重りをつけて懸垂をするケースもあるため、項目に「重量」が必要になります。このような場合、ワークアウト種別は 自重トレーニング ですが、項目に 重量 が必要になるといった 矛盾 が生じるため、ワークアウト種別による処理の分岐をやめ、ワークアウト種目ごとに項目の設定が可能 になるように実装を変更しました。
結果的にコードの大幅な変更が必要となり、多くの時間を要しました。もし更に後の段階で気づいていたら、もっと多くの時間が必要になっていたと思います。アプリの基盤となるコードを書く際には、初期段階から変更容易性を意識して実装することが重要だと感じました。
iPhoneアプリの技術選定
Apple Watchアプリの開発を着手する段階で、React Native(Expo)との連携ってできるのか? と焦りました。。そもそもApple Watchアプリの開発経験がない中で、React Native(Expo)と連携するというハードルの高そうな課題をクリアせざるを得ない状況に頭を悩ませつつ、情報も少ない中で連携方法の調査に時間を費やし、多くの試行錯誤の末、連携を実現できましたが、管理が複雑になってしまったな。。と思いつつ、初めからiOSアプリをSwiftUIで開発していれば良かったと後悔しました😞
開発工数
Gitのコミットログから アルファ版 の完成までにかかった工数は以下の通りでした。
- 12月〜1月: 要件定義/基本設計 & iPhoneアプリの開発
- 1月〜3月: 資格試験の勉強 & Apple Watchの開発
- 3月〜4月: Apple Watchの開発 & 最終調整
1日の開発時間は2、3時間程度で、途中、資格試験の勉強に2ヶ月ほど時間を割いたため、アプリの開発にかかった工数は 3ヶ月弱 ほどになります。
今後
ベータ版 及び 正式版 では「ワークアウト履歴」や「お知らせ表示」などの機能を追加し、さらにユーザーが使いやすいアプリへと改善することが課題となりますが、ベータ版 および 正式版 の開発を進めるかはまだ未定です。
個人的に アルファ版 は引き続き使用していく予定です。
個人開発のアプリを使う メリット としては、自分の好みに合わせて カスタマイズ できる点があるとおもっています。最近は、蓄積したワークアウト実績のデータを、機械学習のデータセット に使用して機械学習の学習材料にして遊んだりしています。学習されたパーソナルデータを分析し、分析結果に基づいたワークアウトメニューを提案するなど、可能性は色々ありそう と思いながら、実現するにはまだまだ壁がたくさんあるな。といった実感を持ちつつ楽しんでいます。
データの活用方法を 自由に考えることができる のは、個人開発のアプリの大きな魅力だと思うので、引き続きアプリを使いながら、新しいアイデアを考えていきたいと思います。
おわりに
いつも個人開発では自分よがりな設計に偏りがちな傾向が多かったのですが、今回は ユーザーの視点を重視 して開発することを心掛けてみました。アプリのコンセプトや目的・目標を明確に定義することで、ユーザーにとって使いやすいアプリとは何かを考え、その視点から開発を進めることができました。
また、初めての試みでしたが、Notionのデータベース を使用してデータ管理に挑戦したことにより、Notionのデータベース機能の使い方についても深く理解することができました。Notionは日常的にナレッジベースとして活用しているため、この新しい知識は今後のナレッジベースの活用をより効率的にするのに役立つと思っています。
業務では主にRDBを使用しており、NoSQLの経験はほとんどありませんでした。今回、NoSQLデータベース設計 について学ぶ機会を得て、NoSQLデータベースのER図を作成することでデータの関連性及びデータ構造を可視化することができました。これにより、NoSQLデータベース設計の基礎を学ぶことができました。
Apple Watchアプリの開発で React Native(Expo)との連携に苦労 し、予想以上に時間がかかってしまったことが反省点です。ただし、この経験を通じて知識が増えたため、今後のReact Nativeアプリ及びApple Watchアプリの開発に活かせると思っています。(思うようにしました)
ただ、技術選定の際にはより詳細な調査を行い、実現可否を早期に判断することが重要 であると学びました。これにより無駄な時間を省くことができるため、技術選定の際は慎重に取り組むようにしようと思います。
最後までお読みいただき、ありがとうございました🙇♂️
ではランニングに行ってきます🏃♂️