【Flutter】BLoCパターンでアプリを作成してみた。~カウンターアプリ、githubリポジトリ検索アプリ~

## BLoCパターンとは
BLoC PatternはFlutterでのアプリケーション開発時に用いる、状態管理手法の1つです。
ビジネスロジックをコンポーネント単位で管理しやすくするためのパターンです。

[こちら](https://qiita.com/tetsukick/items/32d6cab8e176ecea2694)を参考にしてください。

## サンプルアプリの紹介

[Githubはこちら](https://github.com/Tetsukick/flutter_BLoc)

### カウンターアプリ
プラスボタン、マイナスボタンを押下することで画面中央の数字がインクリメント、デクリメントされます。

<img width="350" alt="カウンターアプリ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261934/6347215b-ffd2-b0e7-6971-c76062aa2ad6.png"><img width="350" alt="カウンターアプリ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261934/f2c689e7-e1cd-33c3-a4f8-2d84d700ece1.png">


### githubリポジトリ検索アプリ
TextFieldに検索キーワードを入力して、検索すると対象のGitHubリポジトリの一覧を表示して、要素をタップするとWebViewで表示します。

<img width="350" alt="githubリポジトリ検索アプリ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261934/f8fde4c4-cf9a-b59a-b2e2-d4886e9cb362.png"><img width="350" alt="githubリポジトリ検索アプリ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261934/6004c40f-37f2-e75f-2f32-e21054633bc4.png">

## ソースコード解説

```Dart:main.dart
import 'Model/counter_bloc.dart';
import 'Model/search_bloc.dart';

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'UI/counter_page.dart';
import 'UI/search_page.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MultiProvider(
          providers: [
            Provider<CounterBloc>(
              create: (context) => CounterBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
            Provider<SearchBloc>(
              create: (context) => SearchBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
          ],
          child: MyHomePage(title: 'Flutter BLoC Sample'),
        )
    );
  }
}

class MyHomePage extends StatelessWidget {
  MyHomePage({this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: Text(title),
          bottom: TabBar(
            tabs: <Widget>[
              new Tab(
                text: "Count",
              ),
              new Tab(
                text: "Search",
              ),
            ],
          ),
        ),
        body: TabBarView(
          children: <Widget> [
            CounterPage(),
            SearchPage(),
          ],
        ),
        // This trailing comma makes auto-formatting nicer for build methods.
      ),
    );
  }
}
```

### Providerの利用
Providerを使うことで、**childパラメータに指定したWidget以下全てのWidgetで、同じBLoCインスタンスにアクセス**することができます。

[複数のProviderを設定する場合](https://itome.team/blog/2019/12/flutter-advent-calendar-day7/)は、MultiProviderを設定します。

```Dart
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MultiProvider(
          providers: [
            Provider<CounterBloc>(
              create: (context) => CounterBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
            Provider<SearchBloc>(
              create: (context) => SearchBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
          ],
          child: MyHomePage(title: 'Flutter BLoC Sample'),
        )
    );
  }
}
```

### Counter UIの作成

```Dart:counter_page.dart
import '../Model/counter_bloc.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class CounterPage extends StatelessWidget {
  CounterPage();
  @override
  Widget build(BuildContext context) {
    final counterBloc = Provider.of<CounterBloc>(context);
    return new Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text(
                'You have pushed the button this many times:',
              ),
              StreamBuilder(
                initialData: 0,
                stream: counterBloc.count,
                builder: (context, snapshot) {
                  return Text(
                    '${snapshot.data}',
                    style: Theme.of(context).textTheme.display1,
                  );
                },
              )
            ],
          ),
        ),
        floatingActionButton: Column(
          verticalDirection: VerticalDirection.up, // childrenの先頭を下に配置
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            FloatingActionButton(
              onPressed: () {
                counterBloc.changeCountAction.add(false);
              },
              tooltip: 'Declement',
              child: Icon(Icons.remove),
            ),
            Container( // 余白のためContainerでラップ
              margin: EdgeInsets.only(bottom: 16.0),
              child: FloatingActionButton(
                onPressed: () {
                  counterBloc.changeCountAction.add(true);
                },
                tooltip: 'Increment',
                backgroundColor: Colors.redAccent,
                child: Icon(Icons.add),
              ),
            ),
          ],
        )
    );
  }
}
```

### Counter BLoCの生成

```Dart:counter_bloc.dart
import 'dart:async';
import 'package:rxdart/rxdart.dart';

class CounterBloc {
  // input
  final _actionController = BehaviorSubject<bool>();
  Sink<void> get changeCountAction => _actionController.sink;

  //output
  final _countController = BehaviorSubject<int>();
  Stream<int> get count => _countController.stream;

  int _count = 0;

  CounterBloc() {
    _actionController.stream.listen((isPlus) {
      if (isPlus) {
        _count++;
      } else {
        _count--;
      }
      _countController.sink.add(_count);
    });
  }

  void dispose() {
    _actionController.close();
    _countController.close();
  }
}
```

### BLoCの呼び出し
BLoCは、子Widgetのbuild()メソッドで呼ぶのが定番です。

```Dart
  @override
  Widget build(BuildContext context) {
    final counterBloc = Provider.of<CounterBloc>(context);
    return new Scaffold(
```

### `Sink<T>.add()` でBLoCに値を送る

例では、`counterBloc.changeCountAction`に対して、プラスならTrue,マイナスならFalseのBooleanを渡しています。

```Dart:
              child: FloatingActionButton(
                onPressed: () {
                  counterBloc.changeCountAction.add(true);
                },
```

### `Stream.listen`でinputの値に対して処理を実行

`Stream.listen`で流れてきたBooleanを受け取る。

ちなみにStreamはこんなイメージを持ってもらえば良いと思う。
用意された川に対して、今回だとBooleanの要素をプラスボタンやマイナスボタンが押される度に流されるイメージ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261934/9ae3adeb-a7e0-1004-033b-addd701eb149.png)
引用: https://medium.com/@teivah/reactivewm-a-reactive-framework-for-webmethods-2c91c7de82b3

```Dart
  CounterBloc() {
    _actionController.stream.listen((isPlus) {
      if (isPlus) {
        _count++;
      } else {
        _count--;
      }
      _countController.sink.add(_count);
    });
  }
```

以下のコードで再度、別のStreamに要素（この例ではint）を流している。
```Dart
_countController.sink.add(_count);
```

### `StreamBuilder`で値の受け取り

StreamBuilderを使って、Streamの値を反映します。StreamBuilderを使うことで、**build()メソッドを呼ぶことなくStreamの値に応じてこの箇所だけUIを更新**することができます。

```Dart
              StreamBuilder(
                initialData: 0,
                stream: counterBloc.count,
                builder: (context, snapshot) {
                  return Text(
                    '${snapshot.data}',
                    style: Theme.of(context).textTheme.display1,
                  );
                },
              )
```

これで、カウンターアプリは完成です。

続いて、、、githubのリポジトリ検索アプリを紹介

### 検索画面UIの生成

```Dart:search_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../Model/search_bloc.dart';
import 'package:flutter_inappbrowser/flutter_inappbrowser.dart';

class SearchPage extends StatelessWidget {
  final _formKey = GlobalKey<FormState>();

  TextEditingController queryInputController = TextEditingController(text: '');

  @override
  Widget build(BuildContext context) {
    final searchBloc = Provider.of<SearchBloc>(context);
    return Scaffold(
        body: Center(
            child: Column(children: <Widget>[
              Form(
                  key: _formKey,
                  child: Column(children: <Widget>[
                    Padding(
                        padding: const EdgeInsets.all(16.0),
                        child: Center(
                            child: TextFormField(
                              decoration: InputDecoration(
                                labelText: '検索キーワード',
                              ),
                              controller: queryInputController,
                            ))),
                    RaisedButton(
                        child: const Text('検索'),
                        onPressed: () =>
                            searchBloc.changeQuery.add(queryInputController.text)),
                  ])),
              StreamBuilder(
                  stream: searchBloc.result,
                  builder: (context, snapshot) {
                    if (snapshot.hasError) {
                      print(snapshot.error);
                      // snapshot.error を使ったWidgetを返す
                      // snapshot は AsyncSnapshot<T> で
                    }
                    if (snapshot.data != null) {
                      return Expanded(
                        child: ListView.builder(
                            scrollDirection: Axis.vertical,
                            shrinkWrap: true,
                            itemCount: snapshot.data.length,
                            itemBuilder: (context, int index) {
                              var item = snapshot.data[index];
                              return Container(
                                  decoration: BoxDecoration(
                                    border: Border(
                                      bottom: BorderSide(color: Colors.black38),
                                    ),
                                  ),
                                  child: ListTile(
                                    title: Text(item['full_name']),
                                    subtitle: Text('Star: ' + item['stargazers_count'].toString()),
                                    onTap: () {
                                      this.openBrowser(url: item['html_url']);
                                    },
                                  ));
                            }),
                      );
                    }
                    return Container();
                  })
            ])));
  }

  openBrowser({String url}) {
    ChromeSafariBrowser browser = ChromeSafariBrowser();
    browser.open(url: url, options: ChromeSafariBrowserClassOptions(
      androidChromeCustomTabsOptions: AndroidChromeCustomTabsOptions(
        addShareButton: true,
        toolbarBackgroundColor: "#ff0000",
        enableUrlBarHiding: true,
      ),
      iosSafariOptions: IosSafariOptions(
        barCollapsingEnabled: true,
      )
    ));
  }
}
```

### 検索BLoCの生成

```Dart:search_bloc.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutterbloc/Model/API/api_service.dart';
import 'package:rxdart/rxdart.dart';
import 'API/chopper_client_creater.dart';

import 'package:chopper/chopper.dart';

class SearchBloc {
  final searchApi = SearchApi();

  final searchQueryController = BehaviorSubject<String>();
  Stream<String> get query => searchQueryController.stream;
  StreamSink<String> get changeQuery => searchQueryController.sink;

  // APIの返り値となるSearchResult型を自作したと仮定
  final searchResultController = BehaviorSubject<List<dynamic>>();
  Stream<List<dynamic>> get result => searchResultController.stream;
  StreamSink<List<dynamic>> get changeResult => searchResultController.sink;

  SearchBloc() {
    query.listen((v) async {
      // APIの返り値となるSearchResult型を自作したと仮定
      final List<dynamic> searchResults = await searchApi.fetchApi(query: v);
      print("------");
      print(searchResults);
      print("------");
      if (searchResults.isEmpty) {
        changeResult.addError(searchResults);
      } else {
        changeResult.add(searchResults);
      }
    });
  }

  void dispose() {
    searchResultController.close();
    searchQueryController.close();
  }
}

class SearchApi {
  final ApiService service =
  ApiService.create(ChopperClientCreator.create());

  Future<List<dynamic>> fetchApi({String query}) async {
    final Response response = await service.fetchApi(query: query);
    if (response.isSuccessful) {
      return response.body['items'];
    } else {
      print(response.error);
    }
  }
}
```

### APIモデルの生成
今回は[Chopper](https://pub.dev/packages/chopper)を利用しました。

[こちら参考記事](https://qiita.com/youmeee/items/1ee84add5b6636b96b5b)になります。

```Dart:api_service.dart
import 'package:chopper/chopper.dart';

part 'api_service.chopper.dart';

@ChopperApi(baseUrl: '')
abstract class ApiService extends ChopperService {
  static ApiService create([ChopperClient client]) =>
      _$ApiService(client);

  @Get(path: "/repositories")
  Future<Response> fetchApi({
    @Query('q') String query,
    @Query('sort') String sort = 'stars'
  });
}
```

```Dart:chopper_client_creater.dart
import 'package:chopper/chopper.dart';

class ChopperClientCreator {
  static final String baseUrl = "https://api.github.com/search";

  static ChopperClient create() {
    return ChopperClient(
      baseUrl: ChopperClientCreator.baseUrl,
      converter: JsonConverter(),
    );
  }
}
```

```Dart:api_service.chopper.dart
// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'api_service.dart';

// **************************************************************************
// ChopperGenerator
// **************************************************************************

// ignore_for_file: always_put_control_body_on_new_line, always_specify_types, prefer_const_declarations
class _$ApiService extends ApiService {
  _$ApiService([ChopperClient client]) {
    if (client == null) return;
    this.client = client;
  }

  @override
  final definitionType = ApiService;

  @override
  Future<Response<dynamic>> fetchApi({String query, String sort = 'stars'}) {
    final $url = '/repositories';
    final $params = <String, dynamic>{'q': query, 'sort': sort};
    final $request = Request('GET', $url, client.baseUrl, parameters: $params);
    return client.send<dynamic, dynamic>($request);
  }
}
```



## 参考記事
- [Stream/RxDart初心者のためのBLoC入門（Flutter）](https://qiita.com/tetsufe/items/521014ddc59f8d1df581)
- [Stream/Sinkを使いこなす！ Stream/RxDart初心者のためのBLoC入門 part2](https://qiita.com/tetsufe/items/7b2f8592f5161104d1cd)
- [【Flutter】HTTPクライアントパッケージ「Chopper」でAPI通信をする](https://qiita.com/youmeee/items/1ee84add5b6636b96b5b)
- [【Flutter】JSONをデコードする](https://qiita.com/rkowase/items/f397513f2149a41b6dd2)
- [FlutterのProviderパッケージを使いこなす](https://itome.team/blog/2019/12/flutter-advent-calendar-day7/)