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

# 完成デモ
![2018-05-26 17.04.14.gif](https://qiita-image-store.s3.amazonaws.com/0/159680/cb7aac77-a4ac-ad16-ed8b-463b76f9091c.gif)
GitHub にあげてあります => **https://github.com/sensuikan1973/Flutter_RxDart_GetStarted**

公式チュートリアル「[GetStarted](https://flutter.io/get-started/install/)」に、「**いいね件数の表示機能**」を追加しただけ！
「**<font color="red">BLoCパターン</font>**」と「<font color="red">**RxDart</font>**」をテーマに実装しました。[^1]

なお追加機能は単純ですがテーマのもと実装したので、チュートリアルとは全然違うコードになってます。
また、**Dartには標準でStreamがある** ため、RxDartの使用は部分的になっています。(RxDartの登場は[ここ](#wordbloc))

## 備考
* **[BLoC + Rx で実装するために見るもの](https://qiita.com/sensuikan1973/items/4664864ae3b7a1fdadfa)** という記事に、前提となる知識を書いたのでよろしければそちらも。
* この記事は [Google I/O '18 | Build reactive mobile apps with Flutter](https://www.youtube.com/watch?v=RS36gBEp8OI) の発表直後に書いたものです。
  * [GitHub の Issue](https://github.com/sensuikan1973/Flutter_RxDart_GetStarted/issues) には書きましたが、Flutter や RxDart のアプデなどに追従するとより望ましい書き方ができます。(時間に余裕ができたら push しておきます...)

# そもそも[RxDart](https://github.com/ReactiveX/rxdart)を使うモチベは？
**[Google I/O '18 | Build reactive mobile apps with Flutter](https://www.youtube.com/watch?v=RS36gBEp8OI)**を見ると、図とともに以下のような説明がある。
「UIプログラミングで状態の変更を伝えるにあたっては、DartStreamsとRxDartを使うのがオススメ」(意訳)
<img width="800" alt="screenshot.png" src="https://qiita-image-store.s3.amazonaws.com/0/159680/ddede9ed-37b6-cdb4-0f53-71ec861c72b5.png">

上記のGoogle I/Oの動画は、**Flutter**と**状態管理(Rx等)**をわかりやすく説明していて必見だと感じました。
この**[デモのソースコード](https://github.com/filiph/state_experiments)**、*めちゃくちゃ参考にしました！*

# RxDart
##導入方法
[Dart Package | rxdart](https://pub.dartlang.org/packages/rxdart#-installing-tab-)に記載のある通り。
導入したら、`import 'package:rxdart/rxdart.dart';`だけを追加してデバッグが成功することを確認しましょう。

なお、シミュレータを起動するのは以下のような手順。
[iOS]
`open -a Simulator.app`
[Android]
1. Android Studioを起動し、Tools > AVD Managerで起動しておく。
2. VSCodeからRunすると、それを認識してAndroidで起動してくれる

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

実践的なサンプルは[ここ](https://github.com/ReactiveX/rxdart/tree/master/example/flutter/github_search)等が参考になります。
余談ですが、[README](https://pub.dartlang.org/packages/rxdart#-readme-tab-)に[コナミコマンド](https://ja.wikipedia.org/wiki/%E3%82%B3%E3%83%8A%E3%83%9F%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89#%E5%85%A5%E5%8A%9B%E6%96%B9%E6%B3%95)が登場してて面白いです。

# 実装
ディレクトリ構成は以下の通り。 今回関係あるのは、lib以下のみ。
![スクリーンショット 2018-05-26 17.34.28.png](https://qiita-image-store.s3.amazonaws.com/0/159680/c4e8df0d-7d53-3712-84e1-0e23646e8c5b.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)のラッパーであり、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](https://pub.dartlang.org/documentation/rxdart/latest/rx/BehaviorSubject-class.html)を使ってる
**今回登場する、RxDartはココだけ**。
BehaviorSubjectは、[StreamController](https://api.dartlang.org/stable/1.24.3/dart-async/StreamController-class.html)の一種。
役割としては、「**最新の値が追加されたら、それを購読者に発行する**」ことであり、最後に発行した値をキャッシュしている。
今回は、「いいねされた単語のリスト」と「その数」を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](https://docs.flutter.io/flutter/widgets/StreamBuilder-class.html)で、bodyを生成している。
`stream: word.items`と`builder: (context, snapshot) {}`の指定により、word.itemsという[Stream](https://www.dartlang.org/tutorials/language/streams)に応じて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:

[^1]: 参考「BLocについて」
 ・ [公式の説明動画](https://www.youtube.com/watch?v=PLHln7wHgPE&app=desktop)
 ・ [FlutterのBLoCパターンをAngularで理解する](https://lacolaco.hatenablog.com/entry/2018/05/22/194805)
 ・ [DartからきたBLoCの話](https://slides.com/adwd/bloc-from-dart#/)