社内向けオペレーションアプリをFlutterで開発している話
この記事はLuup Advent Calendarの10日目の記事です。
こんにちは、モバイルアプリエンジニアの大瀧です。
今回は、Flutterで開発されている社内向けオペレーションアプリについてご紹介します。
OPSアプリの概要
LUUPでは日々ユーザーが快適に使えるよう、車両のバッテリー交換や故障車両の改修やメンテナンス・ポートの設置など様々なオペレーション作業が裏側で行われています。それらを支えるためのツールとして社内向けにオペレーションアプリを開発しています。
主に以下の機能があります。
- メンテナンス・バッテリー交換・故障修理が必要な車両情報の可視化
- 車両の偏りがある地域の再配置[1]対象ポートの可視化
- 車両の電子錠やバッテリーの施錠解錠
- 車両の故障履歴や修理履歴などの閲覧・記録
Luupのオペレーション業務の概要に関してはこちらを参照ください。
Flutter採用の背景
元々、このOPSアプリはAndroidネイティブで実装されていましたが、技術的負債が少なからず存在していたのでFlutterで作り直すに決めました。
Flutterの採用の経緯としては限られた工数でiOSとAndroidの両方に対応できる点が大きかったです。
オペレーションアプリの利用者はLuupの従業員だけでなく、遠隔地域などで現地の作業を担う協力企業がいます。協力会社で利用可能な端末がiPhoneの場合には、Android端末を用意する必要がありました。クロスプラットフォーム開発により、このコストも削減できました。
また、社内でFlutterへのモチベーションが高い上に、ネイティブでの実装経験があればFlutterの学習コストはそこまで高くないことを自分自身感じていたのも、採用を進めた理由の1つでした。
アーキテクチャ
使用している主なライブラリ
お馴染みのRiverpodを全面的に利用しています。
- flutter_riverpod
- flutter_hooks
- hooks_riverpod
- freezed
- riverpod_generator
ディレクトリー構造はレイヤードアーキテクチャを参考にしています。
lib/
├── data/
│ ├── domain/ ・・・ドメインモデルを定義
│ ├── repository/ ・・・DBやAPIとの通信を実装
│ ├── entity/ ・・・主にデータモデルを定義
├── usecase/ ・・・ビジネスロジックを実装
├── service/ ・・・サードパーティのサービスを利用する際の処理を実装
├── ui/
│ ├── page/ ・・・画面の実装
│ ├── notifier/ ・・・Riverpodを用いてViewModel相当の状態管理
│ ├── state/ ・・・画面の状態を宣言
└── main.dart
Riverpodの運用ルール
Riverpodには様々な機能があり、どのように運用するかはチームやプロジェクトによって異なります。我々は以下のようなルールを設けています。
- 原則1画面につき1Notifier(NotifierProvider or AsyncNotifierProvider)
- 基本的にはUIコンポーネントはステートレスに実装して、画面の状態は全て1つのNotifierで管理する
- FutureProviderやStreamProviderは利用しない
- Notifierの記述にコード生成(riverpod_generator)を使用する
- Riverpodが推奨している
- 将来的なRiverpodのバージョンアップによる破壊的変更に対してもマイグレーションが容易になる
- hooksは必要最小限の使用にとどめる
- useStateなどStateを管理するhooksはSSOTの原則を破り、コードの見通しが悪くなる
- 公式にも推奨はしてないと記載がある
Hooks は強力なツールです、しかし全ての人にとって適しているわけではありません。
Riverpod を始めてつかうかたは、Hooks の使用を避けることをお勧めします。
アプリの配信方法について
AndroidはGithub Actions経由でGoogle Play Consoleへ、iOSはXcode Cloudを利用してApp Store Connectへアップロードしています。
Xcode Cloud上でのFlutterのビルドですが、--dart-define-from-file
を使った環境変数の設定をしているので公式のサンプル通りにはいかず、ちょっと工夫が必要でした。せっかくなのでci_post_clone.shを載せておきます。
ci_post_clone.sh
#!/bin/sh
# Fail this script if any subcommand fails.
set -e
# The default execution directory of this script is the ci_scripts directory.
cd $CI_PRIMARY_REPOSITORY_PATH # change working directory to the root of your cloned repo.
# Install Flutter using git.
git clone https://github.com/flutter/flutter.git --depth 1 -b $FLUTTER_VERSION $HOME/flutter
export PATH="$PATH:$HOME/flutter/bin"
flutter --version
# Install Flutter artifacts for iOS (--ios), or macOS (--macos) platforms.
flutter precache --ios
# Install Flutter dependencies.
flutter pub get
# Install CocoaPods using Homebrew.
HOMEBREW_NO_AUTO_UPDATE=1 # disable homebrew's automatic updates.
brew install cocoapods
# Install CocoaPods dependencies.
cd ios && pod install # run `pod install` in the `ios` directory.
cd ..
# Import ENV
if [ $CI_XCODE_SCHEME = "dev" ]; then
echo $ENCRYPT_FLAVOR_DEV_ENV | base64 -d > dart_defines/dev.env
flutter build ios --no-codesign --flavor=dev --dart-define-from-file=dart_defines/dev.env
elif [ $CI_XCODE_SCHEME = "stg" ]; then
echo $ENCRYPT_FLAVOR_STG_ENV | base64 -d > dart_defines/stg.env
flutter build ios --no-codesign --flavor=stg --dart-define-from-file=dart_defines/stg.env
elif [ $CI_XCODE_SCHEME = "prod" ]; then
echo $ENCRYPT_FLAVOR_PROD_ENV | base64 -d > dart_defines/prod.env
flutter build ios --no-codesign --flavor=prod --dart-define-from-file=dart_defines/prod.env
else
echo "Unknown Scheme"
exit 1
fi
exit 0
また、執筆時点(2024年11月)ではLuup関係者に閉じて配信するためにiOSはApple Business Managerのカスタムアプリ配布を利用し、AndroidはManaged Google Playを利用して配信しています。
特にカスタムアプリについては割とクセがあるので、別途記事にしたいと思っています。
まとめ
今回はFlutterを用いて開発している社内向けオペレーションアプリについてご紹介しました。
Riverpodを用いることでより堅牢で保守性の高くテスタブルなコードを書くことができていて、かなり満足しています。また、Flutterの特徴であるUI構築のスピードの速さは、要件変更の多い社内向けアプリとの相性が特に良いと感じています。
一方で、クロスプラットフォームならではの課題(プラットフォーム固有の機能実装や、OS特有の挙動の違いへの対応など)を感じることもあります。これらの具体的な課題と解決方法については、今後別途記事として共有していきたいと思います。
最後に、弊社のオペレーションやプロダクトに興味を持っていただけた方は、以下のリンクからお気軽にご連絡ください!
-
車両が余っているポートから、車両が足りていないポートへの車両の移動のことをLuup社内では「再配置」と呼んでいます。 ↩︎
Discussion