アプリ開発振り返り

これは何

起業しアプリをリリースしたいという方から声をかけて頂き, 開発側のリーダーとして初めてアプリの設計からリリースまで一通りを行った際のまとめになります.
具体的な開発・運用についての話とチーム開発の感想, 反省をバランスよく書ければ嬉しい.

まとめ

  • 少人数かつ全員学生なので時間を割けない + 問題解決に時間がかかる上に度重なる方向転換の二重苦で開発が長期化してスピード感が落ちてしまった
  • 代表も業務委託の方も含めて全員で通話をしながら開発するフローは開発サイクルが早く回せたので開発体験がとても良かった

チームについて

メンバー

チームメンバーは開発に直接関わったメインメンバーが5人で全員大学生です. founder, co-founderが2名, UIデザイナの方が1名, エンジニアメンバーは常に募っていたので途中体験で人が入ったり業務委託の方が入ったりして増減します. 自分を含めエンジニアメンバーは業務委託という形での参加でした.

コミュニケーション

主にLINE, Slack->Discordでコミュニケーションをしていました, 毎週平日夜に進捗確認や相談の場としてミーティングを行っていました. 時差は1時間でした. 全員学生だったので割とワイワイ話していました.

ミーティング中に急にリストビューのデザインコンペが始まって1人1案ラフを発表し合うみたいなこともしていました.

またコーディング中は全員でLINE通話or Discordにより通話を繋ぎながら行うので仕様のわからない所はすぐに聞け, また仕様の議論をすぐに出来たり, 指示が出来たので速度感をもって開発に取り組むことが出来ました.

チーム結成(2019.11)~開発開始(2020.5)まで

ここからは時系列に沿って書いていきたいと思います.

技術選定

当時はモバイルアプリ開発経験皆無で, 授業でアプリ開発(Swift)の授業でごく簡単なiOSアプリ作った程度でした. 当初はiOSアプリとして開発するという要件があったのでSwiftでの開発を予定していました. しかし, いずれAndroidにも対応できたら嬉しいという事とメンバーがiOSアプリの開発経験がないので両OSのアプリを実装してリリースするコストを考えるとFlutterが最適なのではないかと考え, Flutterを用いたいと提案した所, 快諾して頂けました. Flutterは元々気になっている技術でもありました. 本アプリではOS-sensitiveな機能が必要になる仕様が無く, 特殊なUIも無かったのでFlutterを用いて正解だったと考えています.

また, アーキテクチャの技術選定についても一任して頂けるとのことでしたので, まだ起業前で資金もない中なるべく手間をかけずに運用したいということを考えるとFirebaseを用いたサーバーレス構成にしました. 現在でも個人開発レベルのモバイルアプリのBaaSとしてFirebaseを用いている人は多いと思います. Firebaseであれば小規模のサービスをほぼ無料で運用することが出来ます.

こうして, Flutterの学習をしつつ, コア機能の実装に取り掛かります. この時点で技術スタックは

  • アプリ
    • UI:
    • 状態管理: StatefulWidget , ChangeNotifier + provider | Flutter Package v4 (一部)
    • 認証: Firebase Auth(mailaddress + password)
  • DB: Firestore
  • API: Cloud Functions(TypeScript)
  • CI/CD: 無し
  • 開発フロー
    • タスク管理: GitHub-Projects
    • ブランチモデル: Gitflow

アプリの構造

後述するflavorによって処理の出し分けをしたいので, アプリのエントリポイント(main_<flavor>.dart)では runAppWithFlavor(Flavor flavor を呼びその中で各種初期化処理をします.

void main() {
	runAppWithFlavor(Flavor.production);
}

runAppWithFlavor() 内では

  • DIコンテナへの登録
  • 各種初期化
  • メンテナンス状態やネットワーク状態等の各種チェック

を行います. アプリ内で使われる Service や Repository, FirebaseFirestore 等の各種外部パッケージのシングルトンなインスタンスはここで get_it | Dart Package で登録されアプリ内の至る所で使えるようにします. 下コードは簡略化したイメージ.

Future<void> runAppWithFlavor(final Flavor flavor) async {
	...
	await setupSingletons();
	await setupFirebase(useMock: useMock);
	await setupServices();
	runApp();
}

Fastlane

Fastlaneを導入してデプロイ作業を楽にしようとしたのですが(1敗目), Codemagic というFlutternにも対応したCI/CDサービスを見つけたのでCI/CDを整備しようと試みます(2敗目).

マスタデータはJSONとmp3でローカルに保持しておき, json_serializable | Dart Package で読み込んでいました.
ここで, 誘って頂いた人が忙しくなるということでleaveすることになりました. 自分も院試勉強やインターンがありあまり時間を取れなくなっていきました.

1人に(2020.6-2020.8)

引き続き画面作成をしていきました. アプリ内に表示する記事の管理を非developper側でしたいという要望があったので, Headless CMS (Contentful) でリッチテキストとして記事を置き, アプリ側でそれを取得して描画するという提案が採用され実装しました. しかし, Contentfulでの記事管理が慣れないということですぐにボツになりました.

ダークモード

また, 審査提出時にはダークモードの対応が必須になるのでダークモードへの対応もしました. ダークモード用のUIデザインが用意されていなかったのでこちらで雰囲気ダークモードを作りました.

flavorと環境切り替え

また, flavorでの環境の切り替え対応もしました. 環境切り替えは規模に関わらずアプリ開発で必須になると思います. 開発時は development, ビルドしてTestFlightに上げるときは staging で, リリースする時は production と3種類の環境を作成していました. Firebaseのプロジェクトも dev と prd を作り, production flavor以外は dev に繋がるようにしていました.
当時(Flutter 1.17以前)は --dart-define が存在しなかったので, 各flavorに対応する main_<flavor>.dart を作っていました.
Xcodeでの Configuration の作成等は Flutterで環境ごとにビルド設定を切り替える | by mono  | Flutter 🇯🇵 | Medium の記事に大いにお世話になりました.

種類ビルドモードFlavorConfiguration名
開発DebugDevelopmentDebug-Development
リリースReleaseProductionRelease-Production

リファクタリング

また, リファクタリングも積極的に行いました. 具体的にはモデルを分割したり, ビジネスロジックを domain/<domain> に切り出し, Widget も機能カテゴリ毎に分けました. 共通のコンポーネントを切り出し, ui/<domain>/widgets に機能特有の Wisget, ui/<domain>/pages にはページとなる Widget を置くようにしました. 1人チームだと大規模なリファクタリングが気兼ねなく出来るというのは良い点だと思います.

Sign in with Apple

また, Sign in with Appleの対応も行いました. ガイドラインによると, サードパーティログインを搭載する際にはSIWAも必須になるので, Google Sign inと一緒に実装しました. 実装といっても, SIWAはApp Store Connectで設定した後, apple_sign_in | Flutter Package を使用するだけでした. 現在このpackageは開発が止まっているので, sign_in_with_apple | Flutter Package を使うことになると思います.

また, 細かいですがCI/CDの為に,semantic versioningを導入しました. 罠としてAndroidアプリは versionCode に単調増加する一意な整数しか指定できないので, Semantic Versionはそれに合わせてコミット数を使うなど(ex. v<maj>.<min>.<patch>+<commits>)すると良いと思います アプリのバージョニング  |  Android デベロッパー  |  Android Developers.

開発時に必要になるタスクは Rakefile に書いていました. リリース時は以下を実行するだけです.

rake dump_build # ex. v0.0.1+123 -> v0.0.1+124
# or
rake dump_patch # ex. v0.0.1+123 -> v0.0.2+124
# or
rake dump_minor # ex. v0.0.1+123 -> v0.1.0+124
# or
rake dump_major # ex. v0.0.1+123 -> v1.0.0+124
 
# リリース作業
rake release_android && rake release_ios

CI/CD

CI/CDに苦戦する図

全体をリファクタリング

また, 人員募集の為, 大学の後輩の方にプロジェクトを説明したりもしましたが「こっちはDDDを守って開発しているけどお前はどう?(意訳)」と言われたので大規模なリファクタリングをしたりもしました.

チームメンバー加入, リリース準備(2020.9-2021.4)

院試が終了し, 9月からは友人を誘ったのでメンバーが2人になり, 開発が加速します. 他メンバーは友達紹介機能の為のDynamic Link実装やGitHub Actionsを含めて満遍なく2人でタスクを消化していくことになります. 仕様や方針が二転三転してほぼ作り直しレベルで大改修をすることになります.

Sentry

エラー監視のために, Sentry を導入しました. アプリ内で投げられて捕捉できなかった例外をトップレベルでcatchしてSentryに報告します. その際に, アプリ自体のバージョン, ビルド番号等も付随します, イメージ

Future<void> runAppWithFlavor(final Flavor flavor) async {
	WidgetsFlutterBinding.ensureInitialized();
	// read env, pubspec
	await setupSingletons(flavor: flavor);
	await SentryFlutter.init(
		(options) {
			final env = GetIt.I<EnvKeys>();
			final pubSpec = GetIt.I<PackageInfo>();
			options
				..dsn = env.sentryDsn
				..release = pubSpec.version
				..environment = flavor.toShortString();
		},
		appRunner: () async {
			runApp();
		}
	);
}

アプリ情報の取得

起動時にメンテナンスをしているかや強制アップデートの確認をするために, 外部から値を取得する機能が欲しくなる. 当初はFirestoreに置いていたが, 後日 Firebase Remote Config によるものに置き換えた.

await remoteConfig.fetch(expiration: const Duration(hours: 5));
await remoteConfig.activateFetched();

アプリ内リソースの管理

また, アプリ内リソースをGoogle Driveによる運用からGithubによる運用に変えました. リソース用リポジトリがsubmoduleとしてアプリリポジトリに依存するようになります.

Cloud Functionやめる

ここで, Cloud functionsである必要が無いと気付き, Cloud FunctionsのAPIをクライアント側に実装することになります.

リファクタリング

ここで, 状態管理ライブラリをproviderから riverpod へ移行します. それに伴い, 気がついた所はすぐにリファクタリングを行っていました,

アプリ開発が始まった当初はFlutterへの理解が浅く今見ると書き直したい部分が多く出てきたので思いついたら片っ端から書き直します. こういう破壊的な修正が出来るのも少人数開発の利点だと思います. また, usecaseのユニットテストを書いたりしていました. PR作成時にユニットテストがまわり, パスしないとPRを出せなくなりました(基本的人権).

git-crypt

尚, 機密情報(.env ファイル, GCPの認証情報, keystoreファイル等)は他メンバーが git-crypt を導入した. git-cryptで暗号化した上でリポジトリに含めて管理していた. .gitattribute に暗号化したい対象を書くとローカルリポジトリでは平文として見えるので, 快適に機密情報の扱うことが出来る. これで .env ファイルの変更を口頭で伝えたり, 新しいチームメンバーに機密情報を手渡しするといった煩わしい作業から開放された.

データマイグレーション

Webサービス開発であれば基本的に全てのユーザーが最新のバージョンを使うことになるのであまり問題にならないと思うが, モバイルアプリではユーザーがアップデートしない限り複数のバージョンが共存することになる. そのためDBにはバージョン毎にユーザーデータ(ユーザーに関連する操作することの出来るデータの意)が存在する.

アプリバージョンで分ける or スキーマバージョンで分ける

まず, 今後もデータのスキーマが変更されることが予想されるので, バージョン毎に分けてユーザーデータを保存することになった. ここで

  • 全てのスキーマをまとめてアプリバージョンで分ける(ex. /versions/v1/user, /versions/v2/user/notes)
  • スキーマ毎に独立してバージョンを分ける(ex. /user/v1, /user/notes/v2)

という2通りの分け方が思いつくがデータマイグレーションを行うとき後者の方式だとアプリバージョンに対応するのがどのスキーマバージョンなのかを管理する必要があり, それに応じてDBのアクセス先を変える必要がある. それは辛いので前者の方式にすることにした.

ただ前者は前者でそのアプリバージョンで1つでもスキーマに変更があるとアプリバージョンが引きづられてしまうという問題がある(これはしょうがないし当たり前かも).

マイグレーションチェーン

アプリバージョンが上がる度にバッチ処理を回して全ユーザーデータのデータ変換をするのも辛く. アプリ内にマイグレーションコードを仕込んでおいて該当するバージョンのユーザーデータが存在しなかったら1世代づつ遡って問い合わせて推移的に変換することでデータ取得時にオンラインでユーザーデータのマイグレーションをすることを思いついた.
よってアップデート前は1つ前のアプリバージョンのスキーマから現バージョンのスキーマへの変換処理を書くだけで良くなった.

abstract class IMigrationExecutor {
	Future<void> migrateAll({required String uuid});
}
 
 
class V0toV1MigrationExecutor implements IMigrationExecutor {
	@override
	Future<void> migrateAll({required String uuid}) async {
		// 取得処理
		final userV0Ss = await _v0UserRef(uuid).get();
 
		// 変換処理
		...
 
		// 書き込み
		await _v1UserRef(uuid).set(userV1);
		await _v1UserNotesRef(uuid).set(notesV1);_
	}
}
# FireStore
/versions/
|- v0/
  |- users/ ---------
|- v1               |
  |- users/ <-------|
    |- notes/ <------

後日, こういうやりかたをマイグレーションチェーンということを知った.

これら一連の流れはドキュメントに書いて共有していました.

業務委託の方join

途中2週間程, 開発を加速したいという理由で代表が業務委託の方を雇った. 最初に概要を説明し, 通話しながら分からないことは都度聞いてもらった, これまではもう一人のメンバーとお互いにPRの軽いコードレビューをしあっていたのだが, 業務委託の契約期間が短いという理由でコードレビューはほとんどせずにマージしてしまった. 後から大部分をリファクタリングすることになるので, 後から考えるとこれは悪手でしかなかった. 業務委託の方にタスクを振りすぎてしまったという部分とコードレビューをしなかったという部分が反省点. コードベースの理解やタスクを行う為に説明するコストも考えると超短期間の業務委託は開発を加速させるという観点から見るとむしろ逆効果かもしれない. 業務委託の方ありがとうございました. 短いスケジュールとなってしまって申し訳ありませんでした.

匿名認証に

βテスト後, ユーザー作成の煩わしさを減らすため, メールアドレスとパスワードユーザー認証から匿名認証にして, データ連携時にメールアドレスやsocial accountと結びつけるようにしました(ソシャゲでよく採られている方式). チームとして小回りが利くので代表や開発チームの思いつきで簡単に方針を変えすぐに実装して探ることが出来るのは良いが, それでリリースが遅れるのは本末転倒な気がしていました.

開発フローについて

途中から機能の見直しや追加機能が入るにつれて, UIデザインや仕様が存在しないのでbizz側とdev側の仕様の認識のズレが大きくなり始めました.
そこでdev側がやるべき事を明確にするためにラベルを導入して開発フローを明確にすることにしました. 流れとしては誰かが漠然とした改善案や問題を draft ラベルをつけIssueに起票し,

お気持ちissue

GitHub issues labelの活用

ラベル説明
draft機能の改善案, 提案, みんなで相談する内容
want-uiUIデザインが揃っていない
reviewbizz側が確認中
feature新機能
unclearやるべきことが十分明確でない
3h,7h,3d実装にかかる時間の予測

リリースまで(2021.4-2021.5)

アプリ内課金

iOS, Androidでのアプリ内課金の実装をしました, アプリ内課金の実装はしたことが無く工数がかかると思われたのですが, 面倒なレシート検証を任せられる RevenueCat が小規模サービスでは有用であると判断し導入したので導入自体はとても容易でした.

purchases_flutter | Flutter Package を用いて課金アイテムの取得処理, 購入処理, 購入履歴の復元等の処理を書きます. リリースビルドでないと確認出来ないのでデバッグが厄介でした. RevenueCatの仕様なのか環境によって価格が1円になる現象どこにも明記されていなくて永遠に嵌っていた.

App審査

1番時間がかかったのが, App審査通過でした, アプリ内課金の実装関連で不備があるらしいのですが, 何回提出してもこのガイドラインに反しているという返答が返ってくるのみでどの部分が悪いのかがわからないので手当たり次第に機能を無効化して何十回も出を繰り返しました.
また, 勘違いをしている可能性があるのですが, アプリ内課金についての罠として, アプリ内課金アイテム自体にも審査があるらしくアプリ内課金アイテムはアプリ審査と同時にしか出せず, 課金アイテムを審査に出すためには正式バージョンをリリースしなければならないので一度課金機能をオフにした状態でリリースし, 正式リリース後アプリ内課金機能を実装してアップデートとしてアプリ内課金をリリースするという流れになるらしい.
しかし, 有料版に関連する機能は実装してしまっているのでそのテストが出来ないという理由でリジェクトされるという. なので正式リリース前に有料版限定に関連する機能を実装した時点でデッドロックに陥ることになる.

を何回も見ました. とにかくアプリ内課金の審査は初見殺しと罠といじわるが多くてつらい.

手元では再現しないのに審査環境でのみクラッシュしたりして

また, アプリ審査では有料版含む全ての機能がテストできないといけなく, 全機能が開放されているデモ用アカウントを用意する必要がある, そこで匿名アカウントとデモ用アカウントを結びつける機能が急遽必要になり実装したりした.

広告実装

firebase_admob で実装, 現在は非推奨になっていて(firebase_admob), google_mobile_ads | Flutter Package で実装済み.
Admobもリリースビルドで反映されなかったりした, リリース後, 少し待つと表示されるようでした.

アプリ内リソースを外部ダウンロード & キャッシュ

コンテンツの追加に伴い, アプリ内リソースの容量が肥大化してアプリのサイズが150MB(Android App Bundleの上限 APK 拡張ファイルの追加とテスト - Play Console ヘルプ)に収まらなくなりました.

そこで, アプリ内リソースを外部ダウンロードにしてキャッシュすることで初回のみダウンロードが発生するようにしました. データはFirebase Storage上に置き, flutter_cache_manager | Flutter Package でキャッシングしました.

完全無料化

ここで収益化は諦めて有料版を無くそうとなる.

Flutter 2.8.0 migration

ずっとFlutter 1.xで開発していたので機会を見て重い腰を上げて終盤に実施しました. 1.26.0-17.8.pre から 2.8.0 へ一瞬(16時間)でビルド出来る状態に移行できました.
公式でmigration guideとtoolを提供してくれているので, 基本は Migrating to null safety | Dart に沿って作業しました.
流れだけ書くと

  1. パッケージがnull-safetyに対応しているかチェック

    fvm dart pub outdated --mode=null-safety
  2. null-safety に対応していないパッケージについて個別に見ていき, null-safety 対応forkがあるか, 代替パッケージを探す.

  3. パッケージを更新

    fvm dart pub upgrade --null-safety
  4. コードの null-safety migrationを行う

    fvm dart migrate --skip-import-check --ignore-errors --ignore-exceptions --ignore-errors

    web viewが立ち上がるのですべてをapply

  5. 個別のパッケージのマイグレーション

    1. FirebaseFirestore
    2. Sentry
    3. reverpod: Riverpod v1.0.0 (stable)の変更点(v0.14.0との比較)
    4. json_serializable
    5. AdMobFlutter -> GoogleMobileAds: アプリ起動時広告  |  Flutter(ベータ版)  |  Google Developers
  6. AndroidX migration: AndroidX Migration | Flutter

良かったこと

Discordで常に通話することで快適な開発が出来た

全員学生で, 住んでいる場所もバラバラな為, 休日にDiscordで通話しながらの開発という形での作業ではありましたが, 1人で作業するといったことはあまりせず, 常に通話をして確認や議論が出来る環境だったことは良かった点だと思います. お互いに質問しながら取り組むことが出来, PRが作成されたらお互いに再優先でレビューをするので仕様決定、実装のサイクルを早く回せたように思います.

全員が同じツール(GitHub, Figma)を使えたこと

内部リリース後にチームでQAをした際に発見したバグをissuesにて報告してもらいたかったのでこちらは ISSUE_TEMPLATE を作り, 開発初期にGitHubのアカウントをつくって頂き, issuesとprojectsの一通りのレクチャーをしました.
また, 定期ミーティングの際はissuesを見ながらそれぞれについて議論するといった使い方が出来たためとても快適な開発フローを回すことが出来ました.

また, Figmaに関しても非デザイナ関係なくミーティング時等にみんなでUIの議論をする際にカーソルやワイヤーフレームで示しながらあれやこれや言い合えるのがとても楽しかったです. 途中から考慮忘れの画面が出て来て, 開発側のほうがFigmaをよく使うようになり, 勝手にデザインしてデザイナの方に確認してもらって実装するといった事が横行してしまっていました. これは開発フローが守れていないので反省すべき点です. 夜中に3分でデザインしたマイページ画面が採用されているのは良いのだろうか?

反省点・難しかった所

時間をあまり割けなかった

これは全員が学業などを並行しながらの作業だったのでどうしようもないではあるのですが, 1人だとしてもフルタイムで作業をしていたら5倍早かったと考えるとやはり作業時間はスピード感において必要不可欠だと思います.
院試勉強とインターンとゼミが重なっていた時のアプリ開発はモチベーションが保てなかったです.

スモールスタートができなかった

前項の「あまり割けなかった」にも起因するのですが, 最低限の機能を作ってまずはリリースして高頻度でリリースをして徐々に育てていくというのがソフトウェア開発の基本であると思うのですが, それが出来ませんでした. 大きな機能変更をするにしても一旦リリースをした後でも遅くないと思います. むしろリリースせずに度重なる方針転換をしても, それは「机上の空論」になってしまうと考えます.

タスクマネジメント

しばしば「どれくらいで出来そう?」と聞かれ大まかな見積もり時間を答えるのですが, 実績時間とかなりズレていました. 世のタスク見積もりする人はなんでバグ修正の完了する時間がわかるのかが謎.

コードレビューはするべき

業務委託の方が短期でjoinした時に, 期限を言い訳にコードレビューを疎かにしてこっちで直していた → 結果かえって時間がかかった.

リリース後のフィードバックループが出来なかった

ソフトウェア開発, 特にモバイルアプリ開発ではリリースした後の機能追加や集客, ユーザーの分析等の「1->10」「10->100」の部分が一番面白い部分だと考えています. 全員が就職等でやはり十分な時間が取れないということでそのようなステップに進む前にチームは解散となってしまいました.

結び

アプリ開発に限らずチーム開発はいかにチーム内のプロダクトに対しての共通理解を醸成させるかが大事であり, そこがチーム開発の難しい所だと考えます. ここに関してはどのような施策が最適解なのがが割とわからないのでこれからも考えていきたいと思います.