🔍

Flutter / Riverpod / Freezed / Algoliaを用いた検索機能の実装

2023/02/07に公開

はじめに

現在Flutterでのアプリ開発にあたり、データベースはCloud Firestoreを使用中。アプリに検索機能の実装をしようと思うが、Firestoreについては公式にも書かれている通り、全文検索に対応していない。そこでサードパーティのAlgoliaを使用することとした。
また、今後いくつか機能を実装していくにつれて、状態管理が複雑になり、UIとロジックが一緒に書かれることで可読性が低くなるため、状態管理パッケージであるRiverpodを使用する。

Algoliaについて

公式には下記のように書かれている。

Algolia is a hosted search engine, offering full-text, numerical, and faceted search, capable of delivering real-time results from the first keystroke. Algolia's powerful API lets you quickly and seamlessly implement search within your websites and mobile applications. Our search API powers billions of queries for thousands of companies every month, delivering relevant results in under 100ms anywhere in the world.

簡単に言うと、
Algolia は、全文検索、数値検索、ファセット検索をシームレスに提供する検索エンジンとのこと。
色々な検索方法ができるようだが、今回は全文検索を実装していく。

Riverpodを用いた状態管理について

今回は状態管理やDIを行うために、Riverpodを使用する。Riverpodは状態管理を行うパッケージであるProviderの課題を解決したパッケージであり、Flutter開発をする上で使用される頻度は高いと思われる。ただ、なぜRiverpodがすごいのか理解しないままなんとなく使用するのもモヤモヤするので、まずはFlutterで状態管理を行う手法について整理していき、Riverpodでの状態管理について理解を深める。

状態管理の手法について整理

StatefulWidgetによる状態管理とInheritedWidgetによる状態管理

StatefulWidgetによる状態管理を理解するには、まずBuildContextについて理解する必要がある。
BuildContextはStatelessWidgetでもStatefulWidgetでも色々なところで使われ、祖先のWidgetのインスタンスを取得することができる。よくある使い方は、Theme.of(context)Scaffold.of(context)などで親Widgetからインスタンスを取得する場合など。
Scaffold.of(context)内ではancestorStateOfTypeというメソッドが使用されており、StatefulWidgetのStateにアクセスする際に使用できるが、BuildContextを親からその親へと順々に辿っていくので、計算量が多く(O(N))取得に時間がかかる。
この課題を解決するのがInheritedWidgetであり、このWidgetはBuildContext.inheritFromWidgetOfExactTypeを使用して、BuildContextを親から順々にたどらずに直接取得するため計算量が少なくなる(O(1))というメリットがある。
そして、このInheritedWidgetをラップしたパッケージProviderである。このため、Provider(さらにはRiverpod)が登場した今、InheritedWidgetを使用する場面は限られてくると思われる。

上記に関して理解するのに下記記事が大変参考になりました。
https://itome.team/blog/2019/12/flutter-advent-calendar-day6/

より深くBuildContextについて理解したい方は

より深くBuildContextについて理解するには、ElementなどのFlutter内部の動きについて理解する必要があり、ここでは触れませんがこちらの記事が参考になると思います。
https://zenn.dev/sugitlab/articles/606b658825792e

Providerによる状態管理

前述した通り、PrividerはInheritedWidgetのラッパーライブラリである。すなわち、O(1)の計算量で親から子へデータを受け渡すことができる。
<使用例>

  • 親Widgetで Provider<T>.value() を使いデータを渡す
  • 子Widgetで Provider.of<T>() を使いデータを受け取る

このほか、詳しくは割愛するが、例えばChangeNotifierを継承したデータをChangeNotifierProvider を使って渡したり、context.watchcontext.readcontext.select のいずれかを使用して状態クラスにアクセスしたりできる。

Riverpodによる状態管理

前述のProviderには下記のような課題がある。

  • Providerで包まれたツリー以外から、状態にアクセスしようとすると実行時にProviderNotFoundExceptionが発生すること。
  • 同じ型の状態を複数同時に使用できないこと。
  • UIから「状態+ロジック」を分離することはできるが、「状態」と「ロジック」を分離することができない。

これらの課題を解決するために、Riverpodが登場した。裏側の仕組みを理解するのは難しいので、使いこなすには時間がかかるかもしれないが、よりシンプルに状態管理をすることができそうだ。

実際に実装してみる

Algoliaの設定、Firebaseとの連携

Algoliaの設定とFirebaseとの連携についてはこちらの記事を参考に進めた。
https://qiita.com/mogmet/items/943c0450957298f007ac#extensionインストール

pubspec.yamlファイルの編集

pubspec.yamlファイルのdependenciesに以下を追加し、pub getする。

pubspec.yaml
dependencies:
  flutter_riverpod: ^2.1.1
  algolia_helper_flutter: ^0.2.3

Modelの作成

今回は、案件リストを検索できるアプリを想定。Freezedを使用して、immutableなクラスを生成。

import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'issue_model.freezed.dart';
part 'issue_model.g.dart';


class Issue with _$Issue {
  const factory Issue({
    required String cityName,
    required String issueTitle,
    required String updateTime,
    required String detailUrl,
  }) = _Issue;

  factory Issue.fromJson(Map<String, dynamic> json)
    => _$IssueFromJson(json);
}
Freezedの使い方について

freezedの使い方は公式や、下記動画を参照。
https://www.youtube.com/watch?v=RaThk0fiphA

検索ワードの状態管理

検索ワードとして入力された値を状態管理するため、StateNotifierProviderを作成。
StateNotifierにaddSearchWordメソッドを追加し、外部から値を変更できるようにする。

final searchWordProvider = StateNotifierProvider<SearchWordNotifier, String>((ref) {
  return SearchWordNotifier();
});

class SearchWordNotifier extends StateNotifier<String> {
  SearchWordNotifier(): super("");

  void addSearchWord(String searchWord) {
    state = searchWord;
  }
}

TextFieldを使用して検索ボックスを実装。

先ほどStateNotifierに追加したaddSearchWordメソッドを用いて値(検索ワード)を渡す。

class SearchBox extends ConsumerWidget {
  const SearchBox({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final _controller = TextEditingController();
    
    return TextField(
      decoration: const InputDecoration(
        hintText: "キーワード検索",
        fillColor: Colors.white,
        filled: true,
      ),
      controller: _controller,
      onChanged: (value) => ref.read(searchWordProvider.notifier).addSearchWord(value),
    );
  }
}

HitsSearcherコンポーネントの作成

ここからはAlgoliaを使用してデーターベース検索を行うため、まずはエントリーポイントとなるHitsSearcherコンポーネントを作成。ここでは状態管理のためRiverpodのProviderを使用。
HitsSearcherの仕様については公式参照。

final hitsSearcherProvider = Provider<HitsSearcher>((ref) => HitsSearcher(
    applicationID: "YourApplicationID",
    apiKey: "YourSearchOnlyApiKey",
    indexName: "YourIndexName",
));

Algoliaからデータ取得

状態管理にStreamProviderを使用。先ほど作成したsearchWordProviderから、入力された検索ワードを受け取り、検索結果をリストで返す。なお、今回はリスト表示する際に自作WidgetのCardWithButtonを使用。

final hitsIssueListProvider = StreamProvider.autoDispose((ref) async* {
  final _searcher = ref.watch(hitsSearcherProvider);
  final _searchWords = ref.watch(searchWordProvider);
  _searcher.query(_searchWords);
  await for (final res in _searcher.responses) {
    yield res.hits.map((e) => Issue.fromJson(e)).toList();
  }
}, dependencies: [searchWordProvider, hitsSearcherProvider]);

検索結果のリスト表示

上記で作成したhitsIssueListProviderの状態を監視し、ListView.builderを用いてリスト表示する。

class SearchListView extends ConsumerWidget {
  const SearchListView({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final searchIssueList = ref.watch(hitsIssueListProvider);
    return searchIssueList.when(
          loading: () => const Center(child: Text('検索結果が表示されます')),
          error: (error, stack) => Text('Error: $error'),
          data: (searchIssueList) {
            return Column(
              children: <Widget> [
                Expanded(
                  child: ListView.builder(
                    itemCount: searchIssueList.length,
                    itemBuilder: (context, index) {
                      final issue = searchIssueList[index];
                      return CardWithButton(
                        header: issue.cityName,
                        title: issue.issueTitle,
                        footer: '更新日:${issue.updateTime}',
                        buttontext: '詳細をみる',
                        onPressed: () async {
                          if(await canLaunchUrl(Uri.parse(issue.detailUrl))){
                                await launchUrl(Uri.parse(issue.detailUrl));
                          }
                        },
                      );
                    }
                  ),
                ),
              ],
            );
          }
    );
  }
}

あとはこのWidgetをbodyに表示させるなどすれば完成。

Discussion