私見でまとめるRiverpodが導入する設計思想
この記事では、筆者の見解ベースになりますが、Riverpodがアプリケーションに導入する設計思想について考察します。状態管理ライブラリが持つ設計思想は、ライブラリを採用したアプリケーションに影響を強く及ぼします。
設計思想
筆者が考える、Riverpodが導入する主な設計ルールは次の通りです。
- PDS (Presentation Domain Separation)
- Reactive Programming
- Immutable objects
- 富豪的プログラミング (による生産性向上)
- (Implicit interfaceを利用した) DI
それぞれ、筆者の見解をベースに要点をまとめます。より厳密な定義や詳細を確認したい方は、各項目に可能な限りリンクを添付しますので、そちらをベースに考察していただければと。
PDS (Presentation Demain Separation)
筆者の理解では、MVC・MVP・MVVMなどのアーキテクチャパターンは、PDSを実現するために利用されます。アーキテクチャにより細かな違いはありますが、それはどのようにビジネスロジックとUIを分離するかの議論の違いです。[1]
Flutterでは、状態をStatefulWidget
のState
に記述できます。この仕組みは、Widget build(BuildContext context)
メソッドの結果として描画されるViewと、そのViewに対するLogicを分離します。当たり前すぎるため見逃されがちですが、この分離は、宣言的UI以前と比較してみると非常に重要であることが確認できます。
Viewに対応するロジックの他に議論するべきロジックといえば、ビジネスロジックです。
In Flutter, views are the widget classes of your application. Views are the primary method of rendering UI, and shouldn't contain any business logic. They should be passed all data they need to render from the view model.
公式ドキュメントのガイドはMVVM
アーキテクチャを採用していますが、そこでもビジネスロジックとUIの分離が強調されています。
ビジネスロジックは、StatefulWidget
のState
クラスに記述可能です。しかし、これを行なってしまうと、View用のロジックとビジネスロジックが1カ所に混在してしまいます。うまく混在させれば何とかなるかもなのですが、多くの場合、仕組みを導入して混在を避けます。これが、さまざまな状態管理ライブラリが登場する理由です。
Flutterの仕組みを利用すると、ビジネスロジックはInheritedWidget
を利用することでUIから分離可能です。InheritedWidget
がロジックをまとめたオブジェクトを配布し、UIがcontext
経由で参照するイメージです。この設計の欠点は、習得コストが高く、コード記述量も多いことです。結果として、スケールしにくい仕組みと言えます。
現時点では、InheritedWidget
の上質なラッパーであるProviderを利用する方が一般的です。本質的には大きな違いがありませんが、しかしコードの読みやすさや記述量の違いは大きなものになります。
よりよくビジネスロジックを分離するのはどうすれば良いかをFlutterコミュニティは考え続けています。たとえばBLoCはFlutter黎明期に紹介されましたし、Reduxやget_itのような、コミュニティに人気のあるパターンも複数存在します。
Riverpodでは、Provider
やNotifier
が[2]ビジネスロジックを担い、WidgetRef
経由でUIが参照します。Riverpodを採用することは、コードをProvider
やNotifier
を使って分割すること同義です。これをまとめると、RiverpodはPDSを実現するツールです。
Reactive Programming
ReactiveX is a combination of the best ideas from the Observer pattern, the Iterator pattern, and functional programming
筆者の理解では、Reactive Programmingはイベントをストリームとみなして処理する手法です。より具体的には、rxdartのようなツールを導入することで、実現される実装を指します。プログラミング言語によってはIteratorパターンの側面が強くなりますが、FlutterとDartにおいてはObserverパターンとしての側面が強いと思います。
Riverpodの基本的な使い方は、Observerパターンの実例です。次の(簡易的な)コードはUser
オブジェクトの変更が、Text(username)
に反映される例です。ref.watch
を介して、それぞれのObserverがUIに変更を反映します。
Stream<User?> user(Ref ref) {
final auth = ref.watch(authProvider);
return auth.authStateChanges();
}
String username(Ref ref) {
final user = ref.watch(userProvider).valueOrNull;
return user?.username ?? 'none';
}
~~~略~~~
Widget build(BuildContext context) {
final username = ref.watch(usernameProvider);
return Text(username);
}
ref.watch
は『参照しているProviderの更新を反映する』という仕組みを持っています。userProvider
がpublishするイベントをusernameProvider
がsubscribe。そしてusernameProvider
がpublishするイベントをUIがsubscribeします。純粋なProvider
は、Rxにおけるmap
です。
Notifier
はユーザーの操作を受け付けることができます。これにより、ユーザーの操作をビジネスロジックを表現するNotifier
に反映し、Notifier
の変更をUIに反映する仕組みが実現されます。
class Counter extends _$Counter {
int build() => 0;
void increment() {
state++;
}
}
String countText(Ref ref) {
final counter = ref.watch(counterProvider);
return 'Count: ${counter}';
}
~~~略~~~
Widget build(BuildContext context) {
final countText = ref.watch(countTextProvider);
return Column(
children: [
Text(countText),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: Text('Increment'),
),
],
);
}
RiverpodはReactive Programmingの設計思想を導入しています。ベースはFlutterフレームワークが持つChangeNotifier
をListenableBuilder
の関係だと考えられますが、より洗練され一般化された仕組みです。
Immutable objects
Dartのオブジェクト比較についておさらいします。知っているよ、という方は読み飛ばしてください。次のコードの振る舞いと、その理由を紹介するパートです。
void main() {
final list1 = [1, 2, 3];
final list2 = list1;
list2.remove(0);
print(list1 == list2); // -> true
const list3 = [1, 2, 3];
final list4 = [...list3];
list4.remove(0);
print(list3 == list4); // -> false
}
list1
とlist2
は同じオブジェクトを参照しているため、list2
の変更がlist1
にも反映されます。一方list3
とlist4
は新たなリストを生成しているため、list4
の変更がlist3
には反映されません。
続いて、次のコードをDartPadで動かしてみます。
void main() {
final list1 = ["Flutter"];
final list2 = ["Flutter"];
print(list1 == list2); // -> false
const list3 = ["Flutter"];
const list4 = ["Flutter"];
print(list3 == list4); // -> true
}
list1
とlist2
は同じ"Flutter"
を保持するリストです。単純な比較では、異なるオブジェクトとして扱われます。ややこしいのですが、list3
とlist4
はconst
を利用し最適化されたため、true
が返されます。
このコードは、次のように比較することで、list1
とlist2
を意図した通りに比較できます。
import 'package:collection/collection.dart';
void main() {
final list1 = ["Flutter"];
final list2 = ["Flutter"];
print(ListEquality().equals(list1, list2));
const list3 = ["Flutter"];
const list4 = ["Flutter"];
print(list3 == list4);
}
利用しているAPIはcollection
のListEquality
です。なお、DeepCollectionEquality
を利用することで、リストの要素の順番が同一かどうかも判定条件に含めることができます。["Flutter", "Developer"]
と["Developer", "Flutter"]
を異なるリストとして判定するか、同一のリストとして判定するかは、判定の意図によって変わります。大抵の場合は、DeepCollectionEquality
を利用することで、リストの要素の順番も考慮した比較ができるようになります。[3]
比較の問題は、List
やMap
だけの問題ではありません。Dartのオブジェクトの比較においても発生します。
void main() {
final object1 = GoodClass("first");
final object2 = object1;
object2.goodString = "two";
print(object1 == object2); // -> true
print(object1.goodString); // -> two
print(object2.goodString); // -> two
}
class GoodClass {
GoodClass(
this.goodString,
);
String goodString;
}
上の例では、object1
とobject2
は同じオブジェクトを参照しています。この例はあからさまな例ですが、複雑なコードや込み入ったコードでは誤って値を更新してしまうことがあります。
上の問題は、オブジェクトの値を外部から書き換えられるようになっているため、発生しているとも言えます。const
で宣言できるように変更し、同一のStringを与えてみます。
void main() {
final object1 = GoodClass("value");
final object2 = GoodClass("value");
print(object1 == object2); // -> false
print(object1.goodString); // -> value
print(object2.goodString); // -> value
}
class GoodClass {
const GoodClass(
this.goodString,
);
final String goodString;
}
GoodClass
のインスタンスをconst
で宣言することで、goodString
の値を変更できないようになりました。しかしGoodClass
の==
がオーバーライドされていないため、object1
とobject2
はデフォルトの比較になっています。
The default behavior for all Objects is to return true if and only if this object and other are the same object.
この問題を、==
演算子のオーバーライドで修正します。
void main() {
final object1 = GoodClass("value");
final object2 = GoodClass("value");
print(object1 == object2); // -> true
print(object1.goodString); // -> value
print(object2.goodString); // -> value
}
class GoodClass {
const GoodClass(
this.goodString,
);
final String goodString;
bool operator ==(Object other) {
return identical(this, other) ||
other is GoodClass && other.goodString == goodString;
}
}
これでGoodClass
のインスタンスが同一であるかどうかを判定できるようになりました。
Riverpodはドキュメントの中で、freezedやbuilt_value、そしてequatableを利用することを推奨しています。これらのライブラリは、Immutable objectsを簡単に生成し、また適切な==
演算子のオーバーライドを記述します。
もしもImmutable objectsが利用されない場合、Riverpodは『与えられたオブジェクトのequarls判定が適切かどうか』の判断が困難になります。できるかもしれませんし、できないかもしれません。この問題に対して、Riverpodは何らかの解決策を示しません。利用者が自らの責任で解決、つまりImmutable objectsを利用する必要があります。[4]
ビジネスロジックをRiverpodで管理している場合、上記の理由により、主要な状態はImmutable objectsで実装されます。結果として、RiverpodはImmutable objectsの利用をアプリケーションに(半ば)強制します。
富豪的プログラミング(による生産性向上)
そもそもFlutter自体にそういった側面がありますが、Riverpodはオブジェクトの生成と破棄や、オブジェクトそのもののサイズを抑えることに関心がありません。むしろ、実装をシンプルに保ち可読性を高めるために、多くのオブジェクトを生成することを提案しています。
例えば、RiverpodにはautoDispose
という機能があります。これは、Widgetのライフサイクルに紐づいた状態を管理するための仕組みです。autoDispose
を利用することで、状態の生成と破棄を自動で行なうことができます。これにより、状態の管理を簡単に行なうことができます。詳細は、次の記事を参照してください。
FlutterがDartを言語として採用した理由のひとつに、Dartは短命なオブジェクトを効率的に扱うことができる言語だから、という理由があります。
Fast allocation
The Flutter framework uses a functional-style flow that depends heavily on the underlying memory allocator efficiently handling small, short-lived allocations. This style was developed in languages with this property and doesn't work efficiently in languages that lack this facility.
であれば、RiverpodをFlutterで利用する際に、短命なオブジェクトを多く作成することは問題になりません。[5]これらの事情により、Riverpodを利用すると記述されているコードそのものに集中しやすくなります。これは、一般的には富豪的なプログラミング呼ばれるスタイルでありつつも、その問題点をDartの言語特性により解消していると言えるでしょう。
そもそも論で言えば宣言的UI自体が富豪的プログラミングの側面を持ちます。Riverpodは、ロジックの記述と富豪的プログラミングを組み合わせることで、より生産性を向上させる仕組みになっています。
(Implicit interfaceを利用した) DI
DI(Dependency Injection)は、アプリケーションのコンポーネント間の依存関係を解決するための手法です。DIを利用することで、コンポーネント間の結合度を下げ、テストしやすいコードを書くことができます。と、一般的な説明を書きましたが、DIそのものの説明はkobakei氏の動画を参照してください。
代表的なDIの例として、複数のRepositoryを持つUseCaseを考えます。
LikeUseCase likeUseCase(Ref ref) {
return LikeUseCase(
repository: ref.watch(likeRepositoryProvider),
analyticsRepository: ref.watch(analyticsRepositoryProvider),
);
}
class LikeUseCase {
const LikeUseCase({
required this.articleRepository,
required this.analyticsRepository,
});
final ArticleRepository articleRepository;
final AnalyticsRepository analyticsRepository;
Future<void> like() async {
await articleRepository.like();
unawaited(analyticsRepository.sendEvent('like'));
}
Future<void> unlike() async {
await articleRepository.unlike();
unawaited(analyticsRepository.sendEvent('unlike'));
}
}
LikeUseCase
やLikeUseCase
を利用するコードのテストコードを書く際、LikeUseCase
の依存関係にMockなどを差し込む必要が生じます。ここで注目したいのは、MockはArticleRepository
やAnalyticsRepository
のinterfaceを実装している必要がある点です。
DartにはImplicit interfaceという機能があります。これは、クラスが実装しているinterfaceを明示的に宣言しなくても、クラスのinterfaceが暗黙的に用意される仕組みです。この仕組みがあるため、Dartにおいては、interfaceとimplementationのコードを分蹴る必要がありません。
通常はMockitoやMocktailなどを、Mockを作成します。しかし、やりようによっては手でMockを作成できます。
class MockArticleRepository implements ArticleRepository {
bool liked = false;
Future<void> like() async {
liked = true;
}
Future<void> unlike() async {
liked = false;
}
}
class MockAnalyticsRepository implements AnalyticsRepository {
final List<String> events = String<>[];
Future<void> sendEvent(String event) async {
events.add(event);
}
}
あとはテストコードにおいて、Provider
をMockに差し替えるだけです。
void main() {
test('LikeUseCase', () {
final mockArticleRepository = MockArticleRepository();
final mockAnalyticsRepository = MockAnalyticsRepository();
final container = createContainer(
overrides: [
likeRepositoryProvider.overrideWith(
(ref) => mockArticleRepository,
),
analyticsRepositoryProvider.overrideWith(
(ref) => mockAnalyticsRepository,
),
],
);
final likeUseCase = container.read(likeUseCaseProvider);
expect(likeUseCase.like(), completion(null));
expect(mockArticleRepository.liked, isTrue);
expect(mockAnalyticsRepository.events, contains('like'));
expect(likeUseCase.unlike(), completion(null));
expect(mockArticleRepository.liked, isFalse);
expect(mockAnalyticsRepository.events, contains('unlike'));
});
}
/// https://riverpod.dev/docs/essentials/testing から引用
ProviderContainer createContainer({
ProviderContainer? parent,
List<Override> overrides = const [],
List<ProviderObserver>? observers,
}) {
final container = ProviderContainer(
parent: parent,
overrides: overrides,
observers: observers,
);
addTearDown(container.dispose);
return container;
}
コードで示したように、RiverpodはDIの機能を提供します。そしてDartのImplicit interfaceと組み合わせることで、テストコードを比較的簡単に記述できるようになります。
まとめ
Riverpodが導入する思想、パラダイムに見慣れないものはあるかもしれません。しかし、Riverpodはそれらの知識を必要とせずともアプリケーションを開発できる仕組みを提供しています。Riverpodにさまざまな設計思想が上手に組み込まれているため、利用者は意識を向ける必要がほぼありません。
ここで重要なことは、Riverpodが想定している方法で、アプリケーションを実装したほうがよい、ということです。Riverpodの提供する機能を利用することで、設計思想のメリットを享受できるようになります。
筆者の力量不足で上手にまとめられた気がしていないのですが、記事が参考になれば幸いです。
Discussion