こんにちは! KEPPLE CREATORS LAB エンジニアの谷米です。
7 月に「KEPPLE」というスタートアップの情報を気軽に知ることが出来るメディアのアプリをリリースしました。
弊社で初めて Flutter を採用し、初のアプリ開発でした。
本記事では開発する上で予め知っておきたかったことや苦労した点などを共有したいと思います。
採用理由と主に利用したパッケージ
リリースまでの期間が短く、かつプロジェクトのメンバー全員が Flutter 未経験で知識もほぼゼロという前提があったため、インターネットに情報が多く、なるべくアプリ開発においてデファクトスタンダードであろうものを採用しました。 また、今後アプリの機能を拡充する際のスケーラビリティも意識しました。
バージョン管理
FVM / https://fvm.app/
開発者間で Flutter SDK のバージョンを合わせるために FVM でバージョン管理しています。
開発の途中で FVM のバージョンが v2 から v3 になり fvm_config.json
が非推奨になり .fvmrc
に変更するという出来事がありました。
状態管理
状態管理には Riverpod と flutter_hooks を使いました。
Riverpod / https://riverpod.dev/ja/
flutter_fooks / https://pub.dev/packages/flutter_hooks
flutter_hooks は React Hooks から影響を受けており、 useState や useEffect など API の命名と機能が似ています。
そのため Web エンジニアとしては親しみやすかったです。
ルーティング
go_router / https://pub.dev/packages/go_router
WebView
flutter_inappwebview / https://pub.dev/packages/flutter_inappwebview
開発の初期段階では Flutter 公式が提供している webview_flutter を使っていました。
しかし、一部の Android 端末で別アプリに遷移したあと戻ってくるとフリーズするバグや動作が重いといった問題があり、webview_flutter と並んでよく使われている flutter_inappwebview を採用しました。
flutter_inappwebview の方が多機能で動作が軽いと謳われていますが、本アプリではそこまで複雑なことをする予定はないのと出来るだけサードパーティに依存したくないため、折を見て webview_flutter に戻したいと考えています。
Riverpod を使ってページを構築する
API から取得したデータを FutureProvider に格納し、AsyncValue.when
を使ってページを構築しました。
FutureProvider が返す AsyncValue は非常に扱いやすく、ローディングやエラー時の出し分けも簡単に書けるので便利でした。
final _futureProvider = FutureProvider.autoDispose((ref) async { return _fetchData(); }); class SamplePage extends HookConsumerWidget { const SamplePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final asyncValue = ref.watch(_futureProvider); return Scaffold( child: asyncValue.when( loading: () {}; error: (error, stackTrace) {}; data: (data) { return SampleWidget(data); } ); ) } }
FutureProvider のデータをユーザーによって操作したいケース
ほとんどのページではデータを取得したあとその状態のまま描画していますが、ユーザーの操作によって FutureProvider に格納したデータの中身を更新したい場合があります。例えばリストの並べ替えやチェックボックスの更新です。 そのような場合は AsyncNotifierProvider を使います。 以下は実際のアプリで企業を追加したいリストを選択する画面です。
class _AsyncListItemsNotifier extends AutoDisposeAsyncNotifier<List<ListItem>> { @override FutureOr<List<ListItem>> build() { return fetchListItems(); } void reorder(int oldIndex, int tmpNewIndex) { final newIndex = tmpNewIndex > oldIndex ? tmpNewIndex - 1 : tmpNewIndex; final value = state.value; if (value != null) { final item = value.removeAt(oldIndex); value.insert(newIndex, item); } } } final asyncListItemsProvider = AsyncNotifierProvider.autoDispose<_AsyncListItemsNotifier, List<ListItem>>( () { return _AsyncListItemsNotifier(); }, );
ロゴ画像の表示
今回開発したアプリ内には企業のロゴを表示する Widget があります。
ロゴ画像には PNG や JPEG だけでなく SVG もあるので、 SVG に対応していない NetworkImage は採用できませんでした。
画像描画ライブラリのメジャーなものだと flutter_svg があるのですが、<style>
タグなど一部対応していない要素やプロパティがあったため、結果的に jovial_svg を選択しました。
ScalableImageWidget.fromSISource( si: ScalableImageSource.fromSvgHttpUrl( Uri.parse(logoUrl!), ), onLoading: (_) { return const Center( child: CircularProgressIndicator(), ); }, onError: (_) { return Image.asset('assets/images/placeholder_icon.png'); }, ),
画像から主要なカラーを抽出する
企業詳細ページでは企業のロゴ画像から特定のカラーを抽出しカバー画像に設定しています。
これは palette_generator というライブラリを使用して実装しました。
https://pub.dev/packages/palette_generator
通常サンプル通り実装すれば簡単にカラーを取得できるのですが、画像が SVG だった場合少し工夫が必要です。
初めに URL から取得した SVG 画像をキャンバス上に描画し、それを Image に変換することで palette_generator でカラーを取得できるようになります。
import 'package:jovial_svg/jovial_svg.dart'; Future<ui.Image> _getSvgImage(String url) async { final si = await ScalableImage.fromSvgHttpUrl( Uri.parse(url), ); await si.prepareImages(); final recorder = ui.PictureRecorder(); final canvas = ui.Canvas( recorder, ); si.paint(canvas); final size = ui.Size(si.viewport.width, si.viewport.height); final picture = recorder.endRecording(); final image = await picture.toImage( size.width.round(), size.height.round(), ); return image; }
一部端末で日本語フォントが中華フォントになる
言語設定を何もしていない場合、Android では漢字が一部中華フォントになってしまいます。
言語設定を行う場合には公式ドキュメントに則って flutter_localizations を設定するのに加えて、 supportedLocales に Locale('Ja')
を指定する必要があります。
当初第二引数に 'JP'
を指定していたのですが、その場合 OPPO など一部端末では中華フォントのままでした。
ビルドとデプロイの自動化
GitHub Actions を使って iOS と Android のビルド、DeployGate へのアップロードまでを行っています。(※ 本番ビルドとデプロイについてはまだ手動です)
共通処理として flutter pub get
するまでの一連の流れを Composite Actions で定義します。
Composite Actions では以下のことをしています。
.fvmrc
からバージョンを取得し Flutter をセットアップする- API 側のリポジトリから OpenAPI の定義ファイルを取得する
- OpenAPI Generator を使って Dart ファイルを生成する
flutter pub get
で依存関係をインストールする
API 側のリポジトリから OpenAPI の定義ファイルを取得するために、GitHub App を作成してトークンを払い出し GitHub API を叩いています。
- uses: actions/create-github-app-token@v1 id: app-token with: app-id: ${{ inputs.GITHUBAPP_ID }} private-key: ${{ inputs.GITHUBAPP_PRIVATE_KEY }} repositories: "sample_server" - name: Download API docs env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | mkdir -p ../sample_server gh api \ -H "Accept: application/vnd.github+json" \ /repos/xxx/sample_server/contents/api-docs.yaml \ --jq '.download_url' \ | xargs curl -o ../sample_server/api-docs.yaml shell: bash
iOS のビルドをどの方法で行うか
iOS のビルドは Linux で出来ないため macOS 環境で実行しました。
iOS のビルド方法は Xcode Cloud や fastlane などいくつか方法があります。
今回は分かりやすさと参考資料の多さから Provisioning Profile と証明書を GitHub Secrets で管理し xcodebuild コマンドでビルドする方法を選択しました。
今回の方法を実践してみて、Provisioning Profile と証明書の管理が大変だとわかったので、今後は Xcode Cloud に切り替えたい気持ちです。
DeployGate へのアップロード
各 OS のビルドが完了したら IPA と APK ファイルを ZIP 化して Artifacts へアップロードしています。
それらをダウンロードし、DeployGate の API を叩いて iOS と Android それぞれアップロードします。
https://docs.deploygate.com/ja/docs/api/application/upload/
- name: Deploy to Deploygate (iOS) run: | curl \ --url "https://deploygate.com/api/users/{OWNER_NAME}/apps" \ -H "Authorization: Bearer ${{ secrets.DEPLOYGATE_API_KEY }}" \ -X POST \ -F "file=@sample.ipa" \ --form-string "distribution_key=${{ secrets.DEPLOYGATE_IOS_DISTRIBUTION_KEY }}" \ --form-string "disable_notify=yes"
最後に
今回は一旦リリースして区切りがついたということで、自身の整理も兼ねて記事に書いてみました。
まだまだ改善点や反省点がたくさんありますし、この記事で書いたことが必ずしも正しい選択だったとは限らないので、今後もより良いアプリを目指して開発していきたいです。