Flutterアプリにおける、過不足ない設計の考察🎅

状態管理で特に念頭におくべき原則を抑える

mono 
Flutter 🇯🇵
37 min readDec 24, 2022

--

Photo by Hush Naidoo Jade Photography on Unsplash

「一般的なモバイルアプリ」の設計全般において、特に何に気を付ける必要があるか、あるいは逆にあまり気にしてなくても良いのではと思うことなどを述べていきます。
(…のつもりでしたが、後者含めると1記事に収めるの困難で、最後にさらっと触れつつ別記事で手厚く書きたいところです🤔)

ここでの「一般的なモバイルアプリ」は規模観点では以下程度のイメージですが、それを超えるような規模でも通ずる内容も多いと思っています。

  • コード量: 数万〜十数万行
  • 実装者: 一桁人

種類としては(スマホ向けの)クライアントアプリコードであり、以下などではないです。

  • パッケージ・ライブラリではない
  • サーバーサイドではない

この種類によって適切な組み方はけっこう変わり、アプリコードは依存関係の末端側(基本的に依存される側にはならない)なこともあり、比較的アバウトに組んで良い(雑なコードで良いという意味ではない)と思っています。また、一部の特殊なアプリでは例外的にあてはまらない内容も多少あるかもしれません(95%以上はあてはまる内容だろうとは思っています)。

例えば、以下のような感じで、Flutterプロジェクトとしては不自然な回りくどい作りになっていたり、本当に必要かどうか疑わしいようなレイヤーが挟まってたりと思うことがあります。

  • 以前の(Flutter以外の)プロジェクトを踏襲してMVVM採用しよう
  • Clean Architecture・Domain Driven Design(DDD)について調べたら、このレイヤーを挟むと良いらしいので、そのまま導入しよう

結果的にそれがある程度うまくハマることもあるかもしれませんが、闇雲に様々なパターンを取り入れず、まずはFlutterにおいて必ず守るべき基本原則をしっかり抑えて、さらに本当に必要な設計要素を適宜足し算で合わせていく方が過不足ないプロジェクトコードになると考えています。

Flutterアプリにおいて本当に念頭におくべきことは、まず第一に以下で、

  • Single Source of Truth(SSOT)原則に従った状態管理
  • 状態の流れを単方向データフローで組む

次点で以下(上の2点ほど遵守必須ではないが大抵守る方が良い)だと思っています。

  • immutableプログラミングの徹底
  • Unit/Widget Testが可能に
  • 単一責任の原則を意識

これらさえきちんと満たして型セーフにお行儀良く組んでいれば、大抵は凝った工夫せずとも自然と及第点は満たす作りになってくる印象です(要件の複雑度に応じて適宜工夫は必要になりますが一般的なアプリではシンプルに済むところも多いと思います)。逆にこの基本を疎かにしながら、「MVVM採用」「Clean Architecture遵守」などと何となく掲げても、無駄に冗長だったりごちゃごちゃした歪な作りになりがちに思います。

以下、それぞれ噛み砕いて説明していきます(きちんと理解して書いている人にとっては当たり前の内容が多いかもしれません)。

全体的に Riverpod 利用前提のところが多いですが、それ以外のパッケージ利用でも通ずる(応用可能な)内容がほとんどのはずです。

Single Source of Truth(SSOT)原則に従った状態管理

SSOTについては、アプリ設計というよりデータ設計的な文脈ですが、Wikipediaでは以下のように説明されています。

信頼できる唯一の情報源 (Single Source of Truth; SSOT) とは、情報システムの設計と理論においては、すべてのデータが1か所でのみ作成、あるいは編集されるように、情報モデルと関連するデータスキーマとを構造化する方法である。データへのリンクで可能なものは参照のみである。プライマリ以外の他の場所のすべてのデータは、プライマリの「信頼できる情報源」の場所を参照するだけであり、プライマリのデータが更新された場合、どこかで重複したり失われたりすることなく、システム全体に伝播される。

https://ja.wikipedia.org/wiki/%E4%BF%A1%E9%A0%BC%E3%81%A7%E3%81%8D%E3%82%8B%E5%94%AF%E4%B8%80%E3%81%AE%E6%83%85%E5%A0%B1%E6%BA%90

SSOTを正しく意識できている場合、例えば以下のようなよくある要件を満たしたい時、特別な工夫なく容易かつ確実に実現できるはずです。

  1. 記事一覧画面で、自分のlike表示がされている(未like)
  2. 詳細画面に遷移後、likeするとその詳細画面でlike済みに変わる
  3. 一覧画面に戻ると、その記事がlike済みになっている

この際、SSOTになってない場合は以下のように脆い状態になります:

  1. 一覧画面と詳細画面の記事データソースが別管理(同じ記事のインスタンスが2つ存在)
  2. 詳細画面の記事インスタンスのlikeをtrueに変更(この時点では一覧画面のその記事インスタンスは未like)
  3. 一覧画面の記事インスタンスにもlike済みであることを同期(ここに抜け漏れがあるとバグったり、あるいは概ね正しく組んでいてもその処理が煩雑になりがちだったり一時的に表示不整合が生じるなどしがち)
  4. 同期処理完了後、一覧でもlike済みになる

SSOTを満たしたコード例(Riverpod利用)は、一貫して利用されるデータソースを以下のように定義し、

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'example.freezed.dart';

final articlesProvider = StateNotifierProvider<ArticlesNotifier, List<Article>>(
(ref) => ArticlesNotifier(),
);

// SSOTを満たした、記事データソース
class ArticlesNotifier extends StateNotifier<List<Article>> {
ArticlesNotifier()
: super(List.generate(10, (index) => Article(id: '$index')));

void like(String id) {
final index = state.indexWhere((article) => article.id == id);
final article = state[index];
state = List.of(state)
..[index] = article.copyWith(
isLiked: true,
);
}
}

@freezed
class Article with _$Article {
const factory Article({
required String id,
@Default(false) bool isLiked,
}) = _Article;
const Article._();
}

利用側では、以下のように一覧・詳細ともにその同一のデータソースを参照・操作します:

// 一覧画面
class ArticleListView extends ConsumerWidget {
const ArticleListView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final articles = ref.watch(articlesProvider);
return ListView(
children: [
for (final article in articles) Text('$article'),
],
);
}
}

// 詳細画面
class ArticleDetailView extends ConsumerWidget {
const ArticleDetailView({
super.key,
required this.id,
});

final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final article = ref.watch(
articlesProvider.select(
(articles) => articles.firstWhere((article) => article.id == id),
),
);
return TextButton(
child: Text('$article'),
onPressed: () {
ref.read(articlesProvider.notifier).like(id);
},
);
}
}

一覧と詳細での表示不整合が絶対に生じ得ない作りになっていることが分かるはずです。もちろん、一覧と詳細で都合の良いデータ構造が異なりそれぞれ加工が必要なこともありますが、その場合でも大元のSSOTなデータソースから派生させる作りにすることがポイントです。

上の例はローカルに閉じた簡易コードですが、Firestoreを使う場合は次のようなコードになります:

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'example.freezed.dart';

final articlesRefProvider = Provider<CollectionReference<Article>>(
(ref) => FirebaseFirestore.instance.collection('articles').withConverter(
fromFirestore: (snapshot, options) =>
Article.fromJson(snapshot.data()!..['id'] = snapshot.id),
toFirestore: (article, options) => article.toJson()..remove('id'),
),
);

// データソース
final articlesProvider = StreamProvider<List<Article>>(
(ref) => ref
.watch(articlesRefProvider)
.snapshots()
.map((snap) => snap.docs.map((doc) => doc.data()).toList()),
);

// like操作のためのProvider
final articleLikeServiceProvider = Provider(ArticleLikeService.new);

class ArticleLikeService {
ArticleLikeService(this._ref);

final Ref _ref;
void like(String id) {
_ref.read(articlesRefProvider).doc(id).update({'isLiked': true});
}
}

// 一覧画面
class ArticleListView extends ConsumerWidget {
const ArticleListView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final articles = ref.watch(articlesProvider).value ?? [];
return ListView(
children: [
for (final article in articles) Text('$article'),
],
);
}
}

// 詳細画面
class ArticleDetailView extends ConsumerWidget {
const ArticleDetailView({
super.key,
required this.id,
});

final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final article = ref.watch(
articlesProvider.select(
(articles) => articles.value!.firstWhere((article) => article.id == id),
),
);
return TextButton(
child: Text('$article'),
onPressed: () {
ref.read(articleLikeServiceProvider).like(id);
},
);
}
}

Firestoreの場合は、それ自体( FirebaseFirestore.instance )が SSOT とみなせるので、上のように必ずしも同じProvider参照せずとも以下でも良いです。

// データソース
final articleProviderFamily = StreamProvider.family<Article, String>(
(ref, id) => ref
.watch(articlesRefProvider)
.doc(id)
.snapshots()
.map((doc) => doc.data()!),
);

// 詳細画面からの参照
final article = ref.watch(articleProviderFamily(id));

ref.invalidate を用いたテクニック

Firestoreの場合に一貫して最新値を得るのは監視ベースで組む( snapshots() 活用する)だけで済んで簡単ですが、Web APIから取得したデータをSSOTとして扱う場合は、特にキャッシュが絡む場合に少し悩ましいこともあります。

スマートな対処例として、以下のセッションでのライブコーディング題材としても使われた pub.dev クライアントサンプルアプリ のコードがとても参考になります。

Riverpod 2.0と同時にリリースされた riverpod_generator を利用したコードとなっていますが、StateNotifier(Provider)と似たようなものだと思いながら雰囲気で読めるはずですし、書き換え可能です。

以下のようにパッケージのmetricsの状態管理をするStateNotifierっぽいクラスに定義された like() メソッドが呼ばれると、それが影響を及ぼすProvider(自身およびlikedPackagesProvider)が invalidate されて再取得(キャッシュを破棄してWeb APIリクエストし直し)を促されるようになっています。

https://github.com/rrousselGit/riverpod/blob/cf1974eb873c9e5372f10c3b8d202a404bb180f4/examples/pub/lib/detail.dart#L58-L91 を改変

自前でごちゃごちゃ状態保持・管理するのではなく、適当なタイミングでキャッシュ破棄する単純な操作だけでSSOTからデータが取り直されるというシンプルな作りで、とても良いと思います 👍

StateNotifier(Provider)と似たようなものだと思いながら雰囲気で読めるはずですし、書き換え可能です。

参考に以下で書き換えた版も載せておきます:

状態の流れを単方向データフローで組む

以下のようにコマンドとクエリを明確に分ける、コマンドクエリ責務分離(CQRS)という考え方があります。

  • 副作用を与えるだけで戻り値 void のコマンド(上述のlike操作など)
  • 副作用を与えずにデータを得るだけのクエリ

SSOT および CQRS に則ったデータの流れになるように組むと、自然と単方向データフロー(Unidirectional Data Flow)に行き着きます。

以下のスライド内の図などが分かりやすいです:

以前ツイートしたProviderの利用箇所と流れの図も単方向データフローを強く意識したものとなっています:

アプリ全体として大きな単方向データフローの流れ(A・B)があり、要件に応じてCのように局所的な単方向データフローも混ざることがあります(編集画面で初期値・ユーザー入力値をそこに閉じて状態管理必要な場合など)。「各データストア」部分が大元の SSOT に相当します。また、CQRS観点では、Aがクエリで、Bがコマンドに相当します。

Redux/Fluxなどと基本方針は同じで、Flutterでもそれらに準拠したようなパッケージが存在しますが、Riverpodを適材適所に使うことで冗長なコードなくシンプルに組めると感じています。

単方向データフローにすると、データがあちこち行ったり来たりすることなく、基本的に大元の SSOT から適宜加工されながら流れてくるということで、追いやすくなります(末端から遡って行けば大元のSSOTに辿り着きます)。

一方、StateNotifierを多用していると、単方向データフローを徹底できてないサインかもしれません。編集画面やその他ユーザー入力値の一時保持など必要な画面ではStateNotifier利用が適してますが、多くのアプリではそこまで出番が多くないはずです。最新データの再取得観点では以下で済むので、その理由でStateNotifierを使う必然性もあまり無いです。

  • Firestore使っている場合は監視によるStreamProviderベースで組めばリアルタイム更新される
  • Web API利用などでFutureProviderで組んでいる場合はautoDisposeにしたり(リスナーがゼロになると次回watch時に取得し直しされる)、任意のタイミングで ref.invalidte を読んで再取得を促せる

StateNotifierだと、SSOTから得られる値が流れる以外に、任意のタイミングで state = で値をセットできる余地があるため、周辺コードをよく確認しないとデータの流れを正確に読み解けなくなるので、StateNotifierは本当に必要なところで最小限の利用にとどめるのが良いです。

ここまでが最重要な考えで、次点で大事な考えについて続けて述べていきます。

immutableプログラミングの徹底

immutableプログラミングについては、以下の記事で手厚く述べました。

この記事に書いたこと以上に付け加えることはあまりないですが、一貫してimmutableプログラミングを徹底することで、記事に記載の各種恩恵を得られるので遵守することをお勧めします。

特に、freezed v2 からは、デフォルトではそれで定義したクラスのコレクション系のフィールドに対してmutable操作すると実行時エラーが出るようになった( Unmodifiable 系として扱われるようになった)ので、自然と徹底しやすくなったはずです。
(Dartの標準のコレクション系クラスに寄り添うと、mutable操作を実行時エラーでしか検知できない(事前に静的エラーで弾けない)ですが、大抵は開発中に気付けるはずで足を引っ張られることは少ないはずです)

また、ちょうど最近、Riverpod v2用のドキュメントに、Immutability(不変性)の重要性とRiverpodでの取り扱い方のページが追加されて、こちらも参考になります👍

Unit/Widget Testが可能なこと

各種処理をProviderで適切に包むようにしておくと、テスト可能なコードにできます。

Flutterでは、以下の3種類のテストがあります。

  • Unit Test: 単体テスト
  • Widget Test: 特定のWidgetのテスト
  • Integration Test: 統合テスト

それぞれ、詳しくは以下を参照してください:

Integration Testのみ、iOS/Androidなどのシミュレーター・実機上での実行が必要で、実行速度が遅い一方で実際にネットワーク通信したりネイティブAPIを使ったりできます(必ずそうしなくてはいけないわけではなく一部差し替えても良いです)。

https://docs.flutter.dev/testing

一方、Unit/Widget Testはシミュレーター・実機を使わずに高速に実行されます。そのため、ネイティブAPIを使えないのはもちろん、ネットワーク通信など時間がかかったり動作が不安定なものの利用も極力避けるべきです(たまにランダムに失敗するようなテストは避けるべきなので)。

Unit/Widget Test実行時、処理内容に応じて以下のような考慮事項があります:

  • ネイティブAPI: 使えない
  • ネットワーク通信: 原則避けるべき
  • ローカルDBアクセス: 具体的な永続化処理はネイティブ依存で動かない(それをオンメモリに切り替えられるものは動く)
  • 現在時刻のように随時変動する環境情報: テスト時は特定の時刻に固定できないと困ることが多い

つまり、これらへの依存がうまく切り離されていないと、Unit/Widget Testを書きたくても書けません。

個人的にはアプリコードのテストコードはそこまで多く書かなくても良いと思っています(費用対効果的にあまりペイしないことも多いと思うので)が、Unit/Widget Testがとても効果的な場面もちょくちょくあり、そういう時に手動確認ではなく自動テストで済ませられる手段を持っておくのは大事です。これらへ依存したコードを適当にベタ書きで済ませてしまった場合、実際にテストを書きたくなった時に、よほどの小規模プロジェクトでない限りテスト可能なコードへの書き換えにはかなり大きな対応コストが強いられます(テスト可能なように概ね心掛けていたものの抜けがあって一部のテストが書き難いことに気付いて実装を調整する程度ならよくあることで、問題ないと思います👌)。

依存を適切に切り離してテスト可能な作りにするには通常、コードの冗長さ・手間などが増えるトレードオフがありますが、Flutter(Dart単体でも同様)の場合は、Riverpodを使えば足を引っ張られる感じはほぼ無いレベルに感じています( get_it なども悪くないですが provider と同様に型セーフではないことはマイナス面です)。

例えば、次のようなAPI通信の関数をRiverpodを使って差し替え可能にすると、

import 'dart:convert';
import 'package:http/http.dart';

Future<Map<String, dynamic>> fetchUser(String id) async {
final result = await Client().get(Uri.parse('https://example.com/users/$id'));
// 実際には、Mapで済ませずにfreezedで定義したクラスに変換
return (jsonDecode(result.body) as Map).cast<String, dynamic>();
}

// 利用側
final user = await fetchUser('xxx');

以下のようになって、ほとんど差がないコード量・使い勝手で済みます。

final userProviderFamily = FutureProvider.family<Map<String, dynamic>, String>((ref, id) async {
final result = await Client().get(Uri.parse('https://example.com/users/$id'));
return (jsonDecode(result.body) as Map).cast<String, dynamic>();
});

// 利用側
final user = await ref.watch(userProviderFamily('xxx'));

さらに、良い感じのキャッシュ機構も付くなどのメリットも得られます( autoDispose モディファイアーで制御可能)。

上記の本実装に対して、次のようなダミー実装を用意して差し替えればテスト可能になります👌

  • 本実装: ネットワーク通信を伴うWeb API呼び出しをしてユーザー情報を返す
  • ダミー実装: ネットワーク通信などせず即座に同じ型のダミーデータを返す単純な処理をする

具体的な差し替えコード例としては、以下のようにoverridesでProviderを差し替えると、その ProviderContainerProviderScope でアクセスされた時に差し替えられたものになります。

  test('Unit Testでは大抵こうする', () {
final container = ProviderContainer(
overrides: [
// idに応じて適当なダミーデータを返す
userProviderFamily.overrideWith((ref, id) => <String, dynamic>{}),
],
);
addTearDown(container.dispose);
// overrideされたダミーデータが得られる
final alice = container.read(userProviderFamily('alice'));
});

testWidgets('Widget Testでは大抵こうする', (teser) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// idに応じて適当なダミーデータを返す
userProviderFamily.overrideWith((ref, id) => <String, dynamic>{})
],
// ここ配下ではoverrideされたダミーデータが得られる
child: ...,
),
);
});

より詳しくは、以下など参照ください。

現在時刻のように随時変わるもの: テスト時は特定の時刻に固定できないと困ることが多い

ちなみに、これは clock パッケージをProviderで提供・利用するようにするのがお勧めです。

インターフェース(abscract class)の別途定義は不要

上の例では、非同期関数をFutureProviderで包むことによって差し替え可能にしましたが、クラスの場合は特に普通に組んでProviderで包むだけで良いです👌

// Providerで包んでそれ経由で利用するようにして、差し替え可能に
final someServiceProvider = Provider((ref) => SomeService());

class SomeService {
void foo() {
print('foo');
}

void bar() {
print('bar');
}
}

// テスト用のクラス
class FakeSomeService implements SomeService {
@override
void foo() {
print('[test] foo');
}

@override
void bar() {
print('[test] bar');
}
}

// 差し替え例
final container = ProviderContainer(
overrides: [
// idに応じて適当なダミーデータを返す
someServiceProvider.overrideWith((ref) => FakeSomeService()),
],
);

次のように別途インターフェース定義しても手間が増えたり取り回しにくくなるだけなので、単に差し替え可能にしたいというだけならこう書くべきではないです。

final someServiceProvider = Provider<SomeServiceInterface>(
(ref) => SomeService(),
);

// インターフェース定義
abstract class SomeServiceInterface {
void foo();
void bar();
}

class SomeService implements SomeServiceInterface {
@override
void foo() {
print('foo');
}

@override
void bar() {
print('bar');
}
}

以下に記載のように、Dartの全てのclassは暗黙的にインターフェースを定義してます。

Implicit interfaces

Every class implicitly defines an interface containing all the instance members of the class and of any interfaces it implements. If you want to create a class A that supports class B’s API without inheriting B’s implementation, class A should implement the B interface.

https://dart.dev/guides/language/language-tour#implicit-interfaces

つまり、class SomeService {} が定義されると、次のような状態になります:

  • SomeSeriviceインターフェースが定義される
  • class SomeService {} はそのデフォルト実装となる
  • 先述の通り、Provider経由でクラスインスタンス提供するようにしていれば差し替えは容易

Dartでは特にアプリコードにおいて、依存性逆転の原則(Dependency inversion principle, DIP)は全く気にする必要がないと思っています。
(逆に、プラグインパッケージで採用されている Federated plugins 構成はDIPに則った例で、そのように適材適所で有用と認識している前提です。)

ちなみに、 flutter_testライブラリに含まれる Fakeクラス を継承すると未実装が残っていてもコンパイル通るようになり(未実装のものが実際に呼ばれるとUnimplementedErrorエラー発生)、そのテストと関係ないメソッド実装を無視できて便利です。

class FakeSomeService extends Fake implements SomeService {
@override
void foo() {
print('[test] foo');
}
// bar実装なくともコンパイルエラーにならない(上のfoo実装も同様)
}

また、より高度・複雑なテストは以下のいずれかが必要になってきます:

どちら使えば良いか迷うので決着付いて欲しいところですが、
[Proposal] Merge Mocktail into Mockito はありつつも統合は当面望み薄な雰囲気です。

ちなみに、個人的にはMockクラス用意するようなテストはアプリコードだとあまり書かなくても良いかな感覚です(費用対効果的に)。

真っ当なテストコードを書いていくのは本実装コード書くより難易度高めとも思いますが、不慣れでも以下のような感じで取り組んでいくのが良いと思います。

  • Providerでの提供・利用を徹底してテスト可能な状態を保つように心掛ける
  • 特に手動で面倒な動作確認していることに気付いたら(色々な入力に組み合わせ条件整えてUI確認繰り返し、など)、自動テストで済ませるようにトライしながら、テストの恩恵を理解して慣れていく(テストコードを実際にいくつか書かないとProviderで差し替え可能にする意義が理解しにくいはず)

単一責任の原則

Wikipediaでは以下のように説明されています。

単一責任の原則 (たんいつせきにんのげんそく、: single-responsibility principle) は、プログラミングに関する原則であり、モジュール、クラスまたは関数は、単一の機能について責任を持ち、その機能をカプセル化するべきであるという原則である。モジュール、クラスまたは関数が提供するサービスは、その責任と一致している必要がある[1]

単一責任の原則は、ロバート・C・マーティン英語版)によって定義された。この原則について、彼は、「クラスを変更する理由は、ひとつだけであるべきである」[1] と表し、「変更する理由」に関して、「この原則は、人についてのものである」と述べ[2]、アクターについてのものであると補足した[3]

https://ja.wikipedia.org/wiki/%E5%8D%98%E4%B8%80%E8%B2%AC%E4%BB%BB%E3%81%AE%E5%8E%9F%E5%89%87

あまり厳密に難しく考え過ぎずとも、それぞれの関数・クラス・メソッドが以下を概ね満たすように組む程度で及第点なイメージです👌

  • それぞれの責務を表す端的な名前が付けられている
  • 実際の処理内容もそれに従っていて、余計なことをしていない(他のことも合わせてする必要がある場合は適切に委譲するなど)
  • コード量が多過ぎない(多過ぎる場合は、もっと細かい責務に細分化できないか考える)

この原則を守れていると特定の依存だけを差し替えしやすくなるので、テストコードの書きやすさにも繋がります。逆に色々乱雑に混ざったコードだと、それが困難かつ手間がかかるようになってしまい、差し替えコード増にも繋がり、メンテナンスしにくいテストコードを招きます。

さらに、以下を満たせていることも意識すると、より良くなると思います👌

  • 高凝集(特定の責務が散らばらずに1箇所・あるいは近い場所に閉じていること)
  • 疎結合(単一の機能としてうまく切り離されていること)

それぞれ、実装作業にあたって以下のような違和感があれば、うまく満たせてない疑いがあります。

  • 特定機能の実装であちこちに点在する色んなクラス・Providerを触る → 高凝集でないかも?
  • どの機能実装する時にも弄るような寄せ集めクラスがある → 疎結合でないかも

次のようなイマイチな実コードを見る機会は、意外とかなり多く感じます。

// 無駄に寄せ集められている(≠疎結合)実装(意外と多い)
class Foo {
// f1用
String? v1;
// f2用
String? v2;

// f2と直接関係しない, v2も使わない
void f1() {
print(v1);
}

// f1と直接関係しない, v1も使わない
void f2() {
print(v2);
}
}

// 基本的にこちらが良い
class Foo1 {
String? v1;
void f1() {
print(v1);
}
}

class Foo2 {
String? v2;
void f2() {
print(v2);
}
}

providerパッケージ はたくさんのProviderを定義すると取り回しが面倒な感じもありましたが、riverpodパッケージでのProviderは定義も利用も手軽にできて使い勝手よく、単一責任ごとにProviderを用意するのが良いです 👌

以上、Flutterアプリにおいて満たすと良いと思っている指針でした。

サンプルとしては、以下は基本的にそれらを満たしていると思っています。

Riverpodリポジトリのexamplesは、テストコードがないのが惜しいなと思っています。「Riverpodを用いたアプリコードに対するテストコードのサンプル」を求めている人は僕含めてとても多いと思うので、そのプルリクなどするのはとても意義がありそうです。
( https://github.com/mono0926/wdb106-flutter はざっとテストコード書いてあるので、ある程度参考になるはずです)

サンプルではない機能の多い実アプリになると作りがガラリと変わるわけではなく、個々のコンポーネントはサンプルと同様に小さく保ちつつ、その数が増えていくイメージです。

学習観点だと、大きなコードボリュームのプロジェクトで格闘するよりも、小さいサンプルで基礎を確実に抑えていくようにした方が捗り、また基礎がきちんと身に付けばその延長で自然と高機能な実アプリも無理なく組めるようになるはずです。

ただ、サンプルと比べて、コード量が多くなりがちな実アプリで悩ましく感じる点としてディレクトリ構成どうするか問題はあると思うので、それにも少し触れます。

ディレクトリ構成

よく話題に挙がる、ディレクトリ構成はどうすれば良いのか問題ですが、基本的な考えとして「関連性のあるものを寄せる」(分けるのは機能ごと)が大事だと思っています。

これらの記事が、僕が良いと思うイメージです:

Widgetと密接に関係するProviderがある場合、それらは必ずしも分離せず同一ファイルでも良いです。その考え前提で、実際にはファイルが長くなって扱いにくいのを解消するためにファイル分けて隣に置く、くらいがちょうど良いことが多いと思いますが。

一昔前はレイヤーごと(Model/UIなど)に分割する例が多かった気がして分業する際も同様にそこで区切られることも多かった印象ですが、最近はモバイルアプリ実装では機能ごとに一気通貫で担当(サーバーサイドがFirebaseの場合はそちらも含めて)することが多い気がします。そういう意味でもそれに沿ったフォルダ分けが自然かつ扱いやすく思います。

という前提で、プロジェクト全体から使われるような類のモデル・汎用Widgetなどはトップレベルに置いて、各利用箇所から多少離れてしまうのは仕方ないですね。関連性のあるもの同士を、不必要に距離を遠ざけなければ良いです。

こういう大枠の共通認識があれば細部は各開発者が柔軟に対応で良い気も半分する一方、チーム開発だとある程度画一的なルールがあると捗る面もあるので、トップレベルおよび各機能配下のディレクトリ構成は何かしら明確なルールを定めたりテンプレート設けたりするのも良いと思います(細部の具体的な構成は特に正解はなく決めの問題だと思います)。

ここまでが自分が重要と思っていることで、次に、逆に必ずしもそうしなくて良い(適宜割り切って端折るのが合理的なことが多い)と思うことについて触れていきたいところですが、長くなり過ぎたので、気が向いたら別記事として書こうかなと思います🐶
(少し書いてたら終わりが見えなくなってきて、別記事のDraftに移動しました🫠)

ただ、せっかくなので、その必ずしもそうしなくて良いと思っていることを箇条書きにしておきます。「常に不要」という意味ではなく、「常に必要なわけではないだろう」程度の部分否定です 🐶

  • マルチパッケージ構成に凝る
  • モデルとUIの中間レイヤーなどを画一的に常に設けること
  • モデル用とUI用で同じ対象について別途クラス定義してレイヤー間で詰め替え
  • MVVM の ViewModel的なもの
  • 依存パッケージなどの乗り換え可能な作りにする(その型への依存などの隠蔽も徹底)
  • 依存性逆転の原則(Dependency inversion principle, DIP)
  • サービスロケーターパターンを避けようとする(調べるとアンチパターンと書かれていて気になるかもしれないが、Riverpodはサービスロケーターパターン前提であり問題・実害ない)

どのように組むがの良いかはケースバイケースですが、とりあえず「最近流行ってるから」「本に書いてあったから」とかではなく、それをプロジェクトコードに取り入れた場合を想像したり試してみたりした上で、具体的なメリットがデメリットを上回る手応えを得てから採用するのが重要だと思っています。

以上、設計周りについての意見でした。良いお年を🎅🎍

Photo by Laura Beth Snipes on Unsplash

--

--