🐥

Flutterアプリ・Pilllの構成について

2022/12/16に公開

この記事は個人開発Advent Calendar 2022 16日目の記事です。

Pilllというピルの服用管理のアプリを作っています。詳しくはこちらから
https://bannzai.hatenadiary.jp/entry/2021/05/10/054207

最近かっこいいLPを作って公開準備中なので見てほしい。ついでにLPについて何かやった方が良いこととかあれば教えて(プチ宣伝)
LP: https://pilllapp.studio.site/

本記事で書くこと

よく質問されるFlutterでどういう構成にしますか?っていうのを少し書いていこうと思います。あとは作ったライブラリの宣伝とスターをいただきます(これが目的)。あと簡単に2022年度の成長具合を数値とともに振り返ります。

構成

すべてはGitHubにあるので詳細を見たいは見てください。そして、スターください。
https://github.com/bannzai/pilll

パッケージ構成ディレクトリの1階層目を見ながら説明します。余計なものは手動で消しましたが、表面上のとしては下のようになっています

$ tree -L 1 lib/
lib/
├── components
├── entity
├── features
├── native
├── provider
└── utils
  • components: 共通コンポーネント化されたもの。Button,Colorとか
  • entity: Firestore(DB)に保存されるリソース
  • features: 達成したい機能ごとにさらにサブディレクトリに分かれている。大きく分けて画面単位。今はないが例えば課金への共通導線の ボタン のようなも含まれていた
  • native: ネイティブ(Swift,Kotlin)の機能とのブリッジングのコードが適当においてある
  • provider: Riverpodのproviderがある。機能によりすぎているものは直接 features に入れている
  • utils: その他の便利機能。例えばPilllだと日付に関する処理が多いのでそういうの

単数系のディレクトリはその下にはファイルが並んでいる。複数系のディレクトリはその下にサブディレクトリがある。ってルールになっています(大体)。

あとは、概ね大きくなっていくのは features 下になるのでこれを例にするのですが、1,2階層目以外のルールは適当でわかりやすい単位でディレクトリを着れば良いと思って適当に切っています。例えばユーザーページでしか使わない機能を持ったボタンが複数ある場合は features/user/buttons/ みたいなディレクトリの切り方をする時もあるし、逆に1つか2つしかないならそのまま features/user/button.dart みたいなファイルを作ったりもします。あまり厳格にしてないです(個人開発だし。っていうのもある)

以上。

状態管理について

多くのモバイルアプリでは、APIやDBから受け取ったものをどうマネジメントしていくかに焦点が当たりやすいです。アプリに作用する状態の管理には Riverpodを使用しており、Flutterでは特にflutter_hookshooks_riverpodも使用して実現しています。選定理由は多くのパターンにも対応でき、書きやすい読みやすい。タイプセーフ。って点です。特にStream形式でデータを取得できるFirestoreとRiverpodは相性がよく容易にSingle Source of Truthも実現できるのも利点です。

ここから自作のライブラリの宣伝が始まります。本題です。よく読みましょう

Firestore x Riverpodを使用していると下のような書き方が多いと予想しています。providerの宣言は省略しています。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class TweetsPage extends HookConsumerWidget {
  const TweetsPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final tweets = ref.watch(tweetsProvider);
    return tweets.when(
      data: (t) => t.isEmpty ? const TweetsEmpty() : TweetsBody(tweets: t),
      error: (error, st) => ErrorPage(error: error),
      loading: () => const Loading(),
    );
  }
}

Firestoreにある tweets というデータ取得をStreamを通じて行っています。メインのコンテンツ(TweetsEmpty|TweetsBody)を表示する。エラーの場合はErrorPage、読み込み時にはLoadingを表示になります。Streamからまだデータを取得していない場合の処理や例外が発生した場合のWidgetをもれなく簡潔に割り当てられることがこの書き方の利点の一つです。ただこの場合もう一つStreamから得られるデータの種類が増えた場合に when がネストすることになります。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class TweetsPage extends HookConsumerWidget {
  const TweetsPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final tweets = ref.watch(tweetsProvider);
    final user = ref.watch(userProvider);
    return tweets.when(
      data: (t) {
        if (t.isEmpty) {
	  return const TweetsEmpty();
	} else {
	  return user
	    .when(
	         data:(data) => TweetsBody(tweets: t, user: user),
                 error: (error, st) => ErrorPage(error: error),
                 loading: () => const Loading(),
            );
	}
      }
      error: (error, st) => ErrorPage(error: error),
      loading: () => const Loading(),
    );
  }
}

実は他にも1つのProviderにまとめて書いてしまいWidgetからは使いやすくするやり方や、tweets,userが状態によってそれぞれ型が分かれているので、 if (tweets is AsyncLoading || user is AsyncLoading) の時はLoading()を返す。という書き方もWidgetに書くことができますが、1つ目のコード例のような書き方と比べると余計なコードが増えることや is AsyncLoading によるチェック漏れの心配も気になります。少なからずコードが素直じゃなくなります。

その課題を解決するために作ったライブラリを紹介します。名付けて、デデン!

_人人人人人人人人人人_
> async_value_group <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^ ̄

async_value_group

async_value_groupを使用すると複数のStream(AsyncValue)をProviderから使用したい場合も下記のように書けます。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:async_value_group/async_value_group.dart';

class TweetsPage extends HookConsumerWidget {
  const TweetsPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    return AsyncValueGroup.group2(
      ref.watch(tweetsProvider),
      ref.watch(userProvider),
    ).when(
      data: (t) => t.t1.isEmpty ? const TweetsEmpty() : TweetsBody(tweets: t.t1, user: t.t2),
      error: (error, st) => ErrorPage(error: error),
      loading: () => const Loading(),
    );
  }
}

AsyncValueGroup.group(n) の引数にWidgetに関連するAsyncValueをセットします。whendataのクロージャの引数にはasync_value_groupで定義しているTuple(n)という型が渡ってきます。Tuple(n)には順番に t(n) のプロパティが生えており、それぞれ AsyncValueGroup に渡した順に型が適応され、すべての AsyncValueが解決したときにdata部分が評価されるようになります。

いかがでしょうか。このアプローチの問題点としては Tuple(n) に自由にプロパティ名を決定できないので、tweetsuserのようなわかりやすい名前をつけられないことが一つ挙げられますが、多くの場合これは型によって守られる部分ではあります。同じ型の場合を使用しなければならないときは十分に気をつけましょう()。とはいえ同じ型の場合は元から気をつけなければいけない場面ではありそうです。

もう一つ注意点としてはこの機能の使い所はAsyncValueをグルーピングしても良い場面に限られます。例えばどちらか片方のリソースを取得した時点でLoading以外のUIを表示したい場合はAsyncValueGroupの使用はミスマッチになります。その場合は if (tweets is AsyncLoading || !(user is AsyncLoading))みたいなコードを書く方がきっとシンプルでしょう。とはいえほとんどはWidgetに必要なものはすべて取ってきてから処理を進めると思います。Pilllではすべて 必要な情報取得 -> メインコンテンツの表示 の処理手順で進めています。

特にFirestoreのようにStreamからデータを取得できるようなサービスとは相性がよくて結構気に入っています。

というわけでとっても便利なAsyncValue.groupはPilllでも大活躍。これは皆さんスターするしかないですね。スターください。もう一回リンク置いておきます。

https://github.com/bannzai/async_value_group/

※ このライブラリを使用しなくても良い書き方があれば教えてください

2022年度の成長について

本題が終わったのでさらっと書きます(おい)が、Pilllの今の様子がなんとなくわかる数字を書いておきます。去年と比較したい方は去年の振り返り記事と比べてください。

https://bannzai.hatenadiary.jp/entry/2021/12/25/175806

課金者数は1000人超えました 🥳

AppStore評価3000件超えてレートも4.7 ⭐️

以上。自慢終わり

来年

来年の展望は決まってないですが引き続き成長させていきたいなとは思っています。

まとめ

スターください!
⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️
https://github.com/bannzai/async_value_group/
⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️

おしまい \(^o^)/

Discussion