Stream、どう活用していますか?

👀 Streamとは?

非同期でデータの流れを扱うためのクラスです。データが発生するたびにリスナーが反応し、
リアルタイムに処理を行うことができます。

Futureとの違い

🤔 Streamはいつ使う?

Streamは、時間の経過とともに変化するデータや複数回発生するイベントを扱いたい場合に使用されます。

1. Firebase / Supabaseのリアルタイムデータ取得

  • FirestoreやSupabaseのonSnapshotなどは、リアルタイムでデータの変更を通知するStreamを提供
StreamBuilder(
  stream: FirebaseFirestore.instance.collection('posts').snapshots(),
  builder: (context, snapshot) {
    return ...
  },
);

2. ユーザーの入力イベント処理

  • ボタン連打、連続クリックの検知
  • テキスト入力の監視(例:検索 + debounce)
Timer? debounce;
SearchResult results = SearcuResult.empty();

void onTextChanged(String query) {
	if (debounce?.isActive ?? false) {
		debounce?.cancel();
	}
	
	debounce = Timer(const Duration(milliseconds: 300), async () {
		final results = await repository.search(query);
		setState(() => this.results = results);
  });
}

3. センサー / ネットワーク関連のイベント

  • ネットワーク接続の状態変更
  • WebSocketメッセージの受信
connectivity.listen((result) {
	if (result == ConnectivityResult.none) {
    SnackBar.show('ネットワーク状況が不安定です'),
  }
});

🔥 Streamをもっと活用してみよう

💡 以下では、ストリームを活用したユースケースを紹介します。

1. ジャイロスコープセンサーを使うアニメーション

ユーザーの端末の傾きに合わせてUIが傾くアニメーションをStreamで実装することもできます。

class ParentWidget extends StatefulWidget {
	StreamController _controller = StreamController();
	...
	@override
	void initState() {
			// sensor_plusパッケージが提供するストリーム
			accelerometerEvents.listen((event) {
				...
			});
	}
	
	@override
	Widget build(BuildContext context) {
		return Row(
			children: datas.map(() {
				// 各Childウィジェットにストリームを渡す
				return ChildWidget(value: _controller.stream);
			})
		);
	}
}

class ChildWidget extends StatelessWidget {
	final Stream stream;
	
	@override
	Widget build(BuildContext context) {
		return StreamBuilder(
			...
			child: Transform(
				transform: Matrix4.identity()
					..rotateX(..)
					..rotateY(..) 
			),
		);
	}
}

Q. setStateを使っても同じように実装できると思います。

A. 簡単な実装であれば問題はありませんが、、

Flutterのウィジェットツリーは、不変(immutable)な構造になっています。
そのため、親ウィジェットから子ウィジェットに渡す値が変更されると、
子ウィジェットのbuild()メソッドが呼び出されます。

class ParentWidget extends StatefulWidget {
  const ParentWidget({super.key});
  
  ...

  int count = 0;

  @override
  Widget build(BuildContext context) {
    print('🔵 Parent build');
    return Column(
      children: [
        MiddleWidget(count: count),
        ElevatedButton(
          onPressed: () => setState(() => counter++),
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

class MiddleWidget extends StatelessWidget {
  final int count;

  const MiddleWidget({super.key, required this.count});

  @override
  Widget build(BuildContext context) {
    print('🟠 Middle build');
    return BottomWidget(count: count);
  }
}

class BottomWidget extends StatelessWidget {
  final int count;

  const BottomWidget({super.key, required this.count});

  @override
  Widget build(BuildContext context) {
    print('🟢 Bottom build');
    return Text('Count: $count');
  }
}

--------------------------------------------

🔵 Parent build
🟠 Middle build
🟢 Bottom build
// Increment (setState)
🔵 Parent build
🟠 Middle build
🟢 Bottom build

MiddleWidgetは、その値を使っていない状況ですが、値を受け取ったという理由だけで、再ビルドが発生します。つまり、意図しない再ビルドが起きやすくなります。

ストリームはどうでしょう?

新しいデータが流れても、ストリームの住所(参照)は同じなので、自然に再ビルドは発生しません。
アプリの規模が大きくなったり、アニメーションなどのインタラクションが多くなる場合、開発者が意図的にレンダリング設計を行わなければ、アプリのパフォーマンスは低下し、一部の端末ではアプリが強制終了する事故が発生する可能性があると思います。

class ParentWidget extends StatefulWidget {

	...
	
  late final StreamController<int> controller;
  
  @override
  void initState() {
	  super.initState();
	  controller = StreamController()..add(count);
  }
  
  ...

  @override
  Widget build(BuildContext context) {
    print('🔵 Parent build');
    return Column(
      children: [
        MiddleWidget(stream: controller.stream),
        ElevatedButton(
          onPressed: () => controller.add(++count)
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

class MiddleWidget extends StatelessWidget {
  final Stream<int> stream;
  ...

  @override
  Widget build(BuildContext context) {
    print('🟠 Middle build');
    return BottomWidget(stream: stream);
  }
}

class BottomWidget extends StatelessWidget {
  final Stream<int> stream;
	...

  @override
  Widget build(BuildContext context) {
    print('🟢 Bottom build');
    return StreamBuilder<int>(
      stream: stream,
      builder: (context, snapshot) {
        print('🟢 StreamBuilder build');
        return Text('${snapshot.data}');
      },
    );
  }
}

🔵 Parent build
🟠 Middle build
🟢 Bottom build
🟢 StreamBuilder build
// Increment (Stream emit)
🟢 StreamBuilder build
// Increment (Stream emit)
🟢 StreamBuilder build

2. サーバーデータ管理

🔥 以下の要件を満たすアプリを作りましょう!
 クイズを検索するサーバーAPIがある
 クイズを「いいね」するとローカルに保存し、ハートアイコンを表示する
 ・「いいね」クイズのみを表示する複数の画面が存在する

Repositoryクラスを作成しましょう!

  • 画像を検索するサーバーAPIを呼び出す searchQuizzes
  • ローカルストレージから「いいね」した画像を取得する getFavoriteQuizzes
  • 画像の「いいね」をトグル処理する toggleFavoriteQuiz
class MyRepository {
	Future<SearchResult<Quiz>> searchQuizzes(String query) {
		return http.get(...);
  }
  
  Future<List<Quiz>> getFavoriteQuizzes() {
    return sqflite.query(...);
  }
  
  Future<void> toggleFavoriteQuiz(Quiz quiz) {
    return document.isFavorite ? sqflite.delete(...) : sqflite.insert(...);
  }
}

あと、画像検索画面を作成しましょう!

class QuizSearchScreen extends StatefulWidget {
  const QuizSearchScreen({super.key});

  TextEditingController queryController = TextEditingController();
  SearchResult<Quiz> searchResult = SearchResult.empty();
  List<Quiz> favoriteQuizzes = [];

  Future<void> searchQuizzes() async {
    final searchResult = await myRepository.serachImages(queryController.text);
    setState(() => this.searchResult = searchResult);
  }

  Future<void> getFavoriteQuizzes() async {
    final favoriteImages = await myRepository.getFavoriteImages();
    setState(() => this.favoriteImages = favoriteImages);
  }
  
  Future<void> toggleFavoriteQuiz(Quiz quiz) async {
	  await myRepository.toggleFavoriteQuiz(quiz);
	  await getFavoriteQuizzes();
	  Toast.show("完了!");
  }

  @override
  void initState() {
    super.initState();
    getFavoriteQuizzes(); // いいねしたクイズの初期化
  }
  
  ...
  
}

QuizSearchScreenウィジェットは意図通りに動作しますが、問題があります。

  1. 「いいね」したクイズを表示する別のウィジェットを作ると、同じロジックの重複
  2. 最新のデータを反映させるための追加の作業が必要

1. 同じロジックの重複

ロジックの重複は、providerflutter_hooks のようなライブラリを使っても解決することもできます。

ライブラリを使わず、純粋な Flutter だけで解決するにはどうすればよいでしょうか?

その答えは、mixin です!

mixin CounterMixin<T extends StatefulWidget> on State<T> {
  int count = 0;

	void increment() => count += 1;
  void decrement() => count -= 1;
}
class CounterA extends StatefulWidget { ... }
class _CounterAState extends State<CounterA> with CounterMixin {
	void executeIncrement() {
		print("I have increment");
		increment();
	}
}

class CounterB extends StatefulWidget { ... }
class _CounterBState extends State<CounterB> with CounterMixin {
	void executeDecrement() {
		print("I have decrement")
		decrement();
	}
}

複数の画面で共通して使われる入力フォームのロジックや、複数のウィジェットで共通して使われるアニメーションなどを、mixin を使って効果的に処理することができます。


2. 最新のデータを反映させるための追加の作業が必要

「いいね」のトグル処理を行うときには、以下のような手順が必要です。

  1. MyRepository.toggleFavoriteQuiz() 呼び出し
  2. MyRepository.getFavoriteQuizzes()呼び出し

データ変更が必要な機能が50以上、極端的に100以上のデータ変更が必要な機能が増えたらどうでしょうか?

画面を更新するために、毎回全データを再取得しなければならない状況が発生します。

これはヒューマンエラーにもつながり、生産性が下げてしまう恐れもあると思います。

また、なぜUI側がtoggleFavoriteQuiz というロジックがデータの更新まで必要だという細かい仕様まで把握していなければならないのでしょうか?

ここで考えられる原因は2つがあるかなと思います。

  1. Repositoryのロジックがうまくカプセル化されていない。
  2. Futureは一度だけデータを渡す (one-time delivery)

Streamを活用した改善

まず、MyRepositoryのコードを見てみると、単純にデータを返すメソッドしかないですね。

Repositoryでデータのキャッシュや一時的な変数を持たせることで、より効果的に利用することができます。

これから、favorite quiz に関するデータを Stream を使ってリファクタリングしましょう!

MyRepositoryの修正

my_repository.dart

class MyRepository {
	MyRepository() {
		_initialize();
	}
	
	// StreamController定義。
  final _favoriteQuizzesSubject = BehaviorSubject<List<Quiz>>.seeded(
    const [],
  );
  
	// 初期化。
  void _initialize() async {
    final source = await sqflite.query(...);
    final quizzes = source.map(Quiz.fromJson).toList();

    _favoriteQuizzesSubject.add(quizzes);
  }
  
	Future<SearchResult<Quiz>> searchQuizzes(String query) {
		return http.get(...);
  }
	
	// StreamControllerのStreamを返す
  Stream<List<Quiz>> getFavoriteQuizzes() {
    return _favoriteQuizzesSubject.stream;
  }
  
  // 1. 現在、ストリームに入っているデータを取得
  // 2. 「いいね」ビジネスロジック処理
  // 3. ストリームに反映
  Future<void> toggleFavoriteQuiz(Quiz quiz) {
    final favoriteQuizzes = [..._favoriteQuizzesSubject.value];
    final isFavorite = favoriteQuizzes.contains(document);

    if (isFavorite) {
      favoriteImages.remove(quiz);
      await _sqflite.delete(...)
    } else {
      favoriteImages.add(quiz);
      await _sqflite.insert(...);
    }
    
    _favoriteQuizzesSubject.add(favoriteQuizzes);
  }
  
  // メモリリークには気をつけましょう!
  void dispose() {
	  _favoriteQuizzesSubject.close();
  }
}
  1. 「いいね」したクイズのストリームを管理する _favoriteQuizzesSubject が追加されました。
  2. getFavoriteQuizzesメソッドは、_favoriteQuizzesSubjectのstreamを返します。
  3. toggleFavoriteメソッドは、ビジネスロジックを実行しつつ、_favoriteQuizzesSubjectに最新のデータを反映します。

💡 データにアクセスするDataSourceクラスを追加して、リモートとローカルデータへのアクセスをカプセル化することもできますが、今回の勉強会ではパスします!

では、UIコードはどうでしょうか?

quiz_search_screen.dart

class QuizSearchScreen extends StatefulWidget {
  const QuizSearchScreen({super.key});
  
  ...
  
  Future<void> toggleFavoriteQuiz(Quiz quiz) async {
	  await myRepository.toggleFavoriteQuiz(quiz);
		Toast.show("完了!");
  }
  
  ...
  
  return StreamBuilder<List<Quiz>>(
	  stream: _myRepository.getFavoriteQuizzes(),
		builder: (context, snapshot) {
			...
		}
  )
}
  1. getFavoriteQuizzes メソッドは削除されました。
  2. toggleFavoriteQuiz はもはやビジネスロジックについて知りません。
  3. 他の状態管理ライブラリへの乗り換えでもビジネスロジックは変更する必要がないです。
    a.Presentation Layerでは自然にUIロジックのみに専念できるようになり、関心の分離ができました。

riverpodの場合

@riverpod
Stream<List<Quiz>> favoriteQuizzes(Ref ref) {
	final repository = ref.watch(myRepositoryProvider);
  return repository.getFavoriteQuizzes();
}

...

@riverpod
class MyClass extends _$MyClass {
	
	...

	void toggleFavoriteQuiz(Quiz quiz) {
		final repository = ref.read(myRepositoryProvider);
		repository.toggleFavoriteQuiz(quiz);
	}
}

BLoCの場合

Future<void> _onStarted(MyStarted event, Emitter<State> emit) async {
  await emit.forEach(
    _repository.getFavoriteQuizzes(),
    onData: (quizzes) {
      return state.copyWith(favoriteQuizzes: quizzes);
    },
  );
}

void _onFavoritePressed(MyFavoritePressed event, Emitter<State> emit) {
	_repository.toggleFavoriteImage(event.quiz)
}

まとめ

  • StreamのObservableな特徴を使ってアニメーションを実現することもできました。
  • 重複するロジックを純粋なFlutterで、Flutterらしく解決できました。
  • 特定の状態管理ライブラリに依存せずに、データ管理が可能でした。

参考

あわせて読みたい
Understanding Future and Stream in Flutter Future and Stream are fundamental in Flutter for asynchronous programming. In this article we'll compare these two with each other.
Bloc
Bloc State Management Library Official documentation for the bloc state management library. Support for Dart, Flutter, and AngularDart. Includes examples and tutorials.
あわせて読みたい