Edited at

【BLoC/RxDart入門】 Flutterの公式チュートリアルを書き換える


完成デモ

2018-05-26 17.04.14.gif

GitHub にあげてあります => https://github.com/sensuikan1973/Flutter_RxDart_GetStarted

公式チュートリアル「GetStarted」に、「いいね件数の表示機能」を追加しただけ!

BLoCパターン」と「RxDart」をテーマに実装しました。1

なお追加機能は単純ですがテーマのもと実装したので、チュートリアルとは全然違うコードになってます。

また、Dartには標準でStreamがある ため、RxDartの使用は部分的になっています。(RxDartの登場はここ)


備考


そもそもRxDartを使うモチベは?

Google I/O '18 | Build reactive mobile apps with Flutterを見ると、図とともに以下のような説明がある。

「UIプログラミングで状態の変更を伝えるにあたっては、DartStreamsとRxDartを使うのがオススメ」(意訳)

screenshot.png

上記のGoogle I/Oの動画は、Flutter状態管理(Rx等)をわかりやすく説明していて必見だと感じました。

このデモのソースコードめちゃくちゃ参考にしました!


RxDart


導入方法

Dart Package | rxdartに記載のある通り。

導入したら、import 'package:rxdart/rxdart.dart';だけを追加してデバッグが成功することを確認しましょう。

なお、シミュレータを起動するのは以下のような手順。

[iOS]

open -a Simulator.app

[Android]

1. Android Studioを起動し、Tools > AVD Managerで起動しておく。

2. VSCodeからRunすると、それを認識してAndroidで起動してくれる


使い方

本記事では、WordBlocクラスでRxDartを使用しています。(具体的にはBehaviorSubject)

実践的なサンプルはここ等が参考になります。

余談ですが、READMEコナミコマンドが登場してて面白いです。


実装

ディレクトリ構成は以下の通り。 今回関係あるのは、lib以下のみ。

スクリーンショット 2018-05-26 17.34.28.png


Modelを定義


WordItem

文字列をnameとして持つだけのクラス


word_item.dart

class WordItem {

final String name;
const WordItem(this.name);

@override
String toString() => "$name";
}



Word

今回はWordを表示するアプリであり、それをモデル化したもの


word.dart

import 'package:sample/models/word_item.dart';

import 'dart:collection';

class Word {
final List<WordItem> _items = <WordItem>[];

Word();

Word.clone(Word word) {
_items.addAll(word._items);
}

int get itemCount => _items.length;

// 変更不可なリスト
UnmodifiableListView<WordItem> get items => UnmodifiableListView(_items);

void add (String name) {
_updateCountAdd(name);
}

void remove (String name) {
_updateCountRemove(name);
}

@override
String toString() => "$items";

void _updateCountAdd(String name) {
for (int i = 0; i < _items.length; i++) {
final item = _items[i];
// 既に追加済みなら無視
if (name == item.name) { return; }
}
_items.add(WordItem(name));
}

void _updateCountRemove(String name) {
for (int i = 0; i < _items.length; i++) {
final item = _items[i];
if (name == item.name) {
_items.removeAt(i);
}
}
}
}



Suggestion

今回のアプリは、ランダムな単語を生成する外部パッケージを使用しているわけだが、その生成による単語の候補をモデル化したもの。

WordPairはチュートリアルで説明がある通り、english_wordsパッケージのもの。


suggestion.dart

import 'dart:collection';

import 'package:english_words/english_words.dart';

final Suggestion suggestion = Suggestion();

class Suggestion {
final List<WordPair> _suggestedWords = <WordPair>[]; // 生成された単語を保持

Suggestion();

Suggestion.clone(Suggestion suggestion) {
_suggestedWords.addAll(suggestion._suggestedWords);
}

int get suggestionCount => _suggestedWords.length;

UnmodifiableListView<WordPair> get suggestedWords => UnmodifiableListView(_suggestedWords);

void add (WordPair wordPair) {
_updateCount(wordPair);
}

// WordPairを一気に生成して、一気に追加するために定義
void addMulti (List<WordPair> wordPairs) {
for (int i = 0; i < wordPairs.length; ++i) {
_updateCount(wordPairs[i]);
}
}

@override
String toString() => "$suggestedWords";

void _updateCount(WordPair wordPair) {
_suggestedWords.add(wordPair);
}
}



Widgetを定義


CountLabel

いいね件数を表示するラベルを定義


count_label.dart

import 'package:flutter/material.dart';

import 'package:meta/meta.dart';

class CountLabel extends StatefulWidget {
final int favoriteCount; //表示したいカウント

CountLabel({
Key key,
@required this.favoriteCount,
}) : assert(favoriteCount >= 0),
super(key:key);

@override
CountLabelState createState() {
return CountLabelState();
}
}

class CountLabelState extends State<CountLabel> {
@override
Widget build(BuildContext context) {
return Text(
widget.favoriteCount.toString() ,
style: new TextStyle(
fontWeight: FontWeight.bold,
fontSize: 35.0, //目立つようにでかくしてある
color: Colors.pink),
);
}
}



FavoritePage

いいねした単語を閲覧するページのViewを定義


favorite_page.dart

import 'package:flutter/material.dart';

import 'package:sample/models/word.dart';

class FavoritePage extends StatelessWidget {
FavoritePage(this.word);
final Word word;

static const routeName = "/favorite";

@override
Widget build(BuildContext context) {
final tiles = word.items.map(
(item){
return new ListTile(
title: new Text(item.name)
);
}
);

final divided = ListTile
.divideTiles(
context: context,
tiles: tiles,
)
.toList();

return new Scaffold(
appBar: AppBar(
title: Text("Your Favorite")
),
body: new ListView(children: divided),
);
}
}



Componentを定義


WordProvider

WordBlocのラッパーであり、WordBlocへの参照を持つ。


word_provider.dart

import 'package:flutter/widgets.dart';

import 'package:sample/word_bloc.dart';

class WordProvider extends InheritedWidget {
final WordBloc wordBloc;

WordProvider({
Key key,
WordBloc wordBloc,
Widget child,
}) : wordBloc = wordBloc ?? WordBloc(),
super(key: key, child: child);

@override
bool updateShouldNotify(InheritedWidget oldWidget) => true;

// WordBlocへの参照を提供
static WordBloc of(BuildContext context) =>
(context.inheritFromWidgetOfExactType(WordProvider) as WordProvider)
.wordBloc;
}



WordBloc

今回の要


word_bloc.dart


import 'dart:async';

import 'package:sample/models/word.dart';
import 'package:sample/models/word_item.dart';
import 'package:rxdart/subjects.dart';

class WordAddition {
final String name;
WordAddition(this.name);
}

class WordRemoval {
final String name;
WordRemoval(this.name);
}

class WordBloc {
final Word _word = Word();

final BehaviorSubject<List<WordItem>> _items =
BehaviorSubject<List<WordItem>>(seedValue: []);

final BehaviorSubject<int> _itemCount =
BehaviorSubject<int>(seedValue: 0);

final StreamController<WordAddition> _wordAdditionController =
StreamController<WordAddition>();

final StreamController<WordRemoval> _wordRemovalController =
StreamController<WordRemoval>();

WordBloc() {
_wordAdditionController.stream.listen((addition){
int currentCount = _word.itemCount;
_word.add(addition.name);
_items.add(_word.items);
int updateCount = _word.itemCount;
if (updateCount != currentCount) {
_itemCount.add(updateCount);
}
});

_wordRemovalController.stream.listen((removal){
int currentCount = _word.itemCount;
_word.remove(removal.name);
print(_word.items.toString());
_items.add(_word.items);
int updateCount = _word.itemCount;
if (updateCount != currentCount) {
_itemCount.add(updateCount);
}
});
}

Sink<WordAddition> get wordAddition => _wordAdditionController.sink;

Sink<WordRemoval> get wordRemoval => _wordRemovalController.sink;

Stream<int> get itemCount => _itemCount.stream;

Stream<List<WordItem>> get items => _items.stream;

void dispose() {
_items.close();
_itemCount.close();
_wordAdditionController.close();
_wordRemovalController.close();
}
}



BehaviorSubjectを使ってる

今回登場する、RxDartはココだけ

BehaviorSubjectは、StreamControllerの一種。

役割としては、「最新の値が追加されたら、それを購読者に発行する」ことであり、最後に発行した値をキャッシュしている。

今回は、「いいねされた単語のリスト」と「その数」をBehaviorSubjectに追加することで、購読者にそれを知らせるようにしている。


BlocFavoritePage

いいねした単語の表示


bloc_favorite_page.dart

import 'package:flutter/material.dart';

import 'package:sample/models/word_item.dart';
import 'package:sample/word_provider.dart';

class BlocFavoritePage extends StatelessWidget{
BlocFavoritePage();

static const routeName = "/favorite";

@override
Widget build(BuildContext context) {
final word = WordProvider.of(context);
return Scaffold(
appBar: AppBar(
title: Text("Your Favorite"),
),
body: StreamBuilder<List<WordItem>>(
stream: word.items,
builder: (context, snapshot) {
if (snapshot.data == null || snapshot.data.isEmpty) {
return Center(child: Text('Empty'));
}
final tiles = snapshot.data.map(
(item) {
return new ListTile(
title: new Text(item.name)
);
}
);

final divided = ListTile
.divideTiles(
context: context,
tiles: tiles,
)
.toList();

return new ListView(children: divided);
},
),
);
}
}


StreamBuilderで、bodyを生成している。

stream: word.itemsbuilder: (context, snapshot) {}の指定により、word.itemsというStreamに応じてViewを作っているということ。


Main_


main.dart

import 'package:flutter/material.dart';

import 'package:english_words/english_words.dart';
import 'package:sample/bloc_favorite_page.dart';
import 'package:sample/models/word_item.dart';
import 'package:sample/word_bloc.dart';
import 'package:sample/word_provider.dart';
import 'package:sample/models/suggestion.dart';
import 'package:sample/widgets/count_label.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return WordProvider(
child: MaterialApp(
title: 'Startup Name Generator',
theme: new ThemeData(primaryColor: Colors.white),
home: RandomWordsHomePage(),
routes: <String, WidgetBuilder> {
// 「./favorite」に、BlocFavoritePageを登録
BlocFavoritePage.routeName: (context) => BlocFavoritePage()
},
),
);
}
}

class RandomWordsHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final wordBloc = WordProvider.of(context);
return Scaffold(
appBar: AppBar(
title: Text("Startup Name Generator"),
actions: <Widget>[
StreamBuilder<int>(
stream: wordBloc.itemCount,
initialData: 0,
builder: (context, snapshot) => CountLabel(
favoriteCount: snapshot.data,
)
),
new IconButton(
icon: const Icon(Icons.list),
onPressed: (){
Navigator.of(context).pushNamed(BlocFavoritePage.routeName);
}
)
],
),
body: WordList(),
);
}
}

class WordList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new ListView.builder(
padding: const EdgeInsets.all(16.0),

itemBuilder: (context, i) {
if (i.isOdd) return new Divider();

final index = i ~/ 2;
if (index >= suggestion.suggestionCount) {
const addNum = 10;
suggestion.addMulti(generateWordPairs().take(addNum).toList());
}
return _buildRow(WordProvider.of(context), suggestion.suggestedWords[index]);
}
);
}
}

// Favorite済み、すなわちword.itemsに含まれていれば、赤いハートアイコンにする
Widget _buildRow(WordBloc word, WordPair pair) {
return new StreamBuilder<List<WordItem>>(
stream: word.items,
builder: (_, snapshot) {
if (snapshot.data == null || snapshot.data.isEmpty) {
return _createWordListTile(word, false, pair.asPascalCase);
} else {
final addedWord = snapshot.data.map(
(item) {
return item.name;
}
);
final alreadyAdded = addedWord.toString().contains(pair.asPascalCase);
return _createWordListTile(word, alreadyAdded, pair.asPascalCase);
}
}
);
}

ListTile _createWordListTile(WordBloc word, bool isFavorited, String title) {
return new ListTile(
title: new Text(title),
trailing: new Icon(
isFavorited ? Icons.favorite : Icons.favorite_border,
color: isFavorited ? Colors.red : null,
),
onTap: () {
if (isFavorited) {
word.wordRemoval.add(WordRemoval(title));
} else {
word.wordAddition.add(WordAddition(title));
}
},
);
}


【いいね件数】

StreamBuilder<int>で、wordBlocのitemCountに応じてCountLabelを生成するようにしている。

これで、いいねの追加に応じてCountLabelの表示も変えられる。

【いいね済みかどうかの表示】

StreamBuilder<List<WordItem>>で、word.itemsに応じてアイコンを生成するようにしている。

これで、いいね済みかどうかに合わせてアイコンを表示できる。


おわりに

Flutterにおける、状態管理「DartStreams + RxDart」をやってみました。

Flutter、慣れれば結構早く開発できそうな気がしたので、積極的に採用を検討していきたい:raised_hand: