🦊

[Flutter × Firebase]全文検索+検索候補機能を実装してみた

2023/02/12に公開

Firebaseは個人開発にも向いていて、素晴らしいサーバーレスサービスですが、検索機能は優れていないと言われています。
公式のドキュメントでは、「外部サービスを使って検索機能を実装した方がいいよ」と書かれているのですが、私を含めて個人でもアプリを作っている人にとっては、コストがネックとなって現段階で気軽に利用できる物がないのが現状です。
ですが、すごく高度な機能を必要としないかぎり、工夫すればFirebaseだけで全文検索機能が実装で
き、その方法を記事にしてくださってる方がいます。

https://qiita.com/oukayuka/items/d3cee72501a55e8be44a
https://qiita.com/KosukeSaigusa/items/6aaeac529475c03d7a2c#2-firestore-による全文検索の機能

今回は上記の記事の内容も踏まえて、全文検索+検索候補を出して、コストをかけずにUXを向上する方法を試してみました。

今回試したアプリの概要

サンプルアプリの機能は簡単です。
①文字列を入力
②上記文字列をあるパターンによって分割して、cloud firestore に保存
③アプリ上部にあるAppBarから文字で検索する
④検索結果候補が表示される

こんな感じです。

ちなみに①は、いわゆるn-gramというものです。通常はfirebaseに2文字ずつ分割した文字列を格納し、検索の時も検索ワードを2文字ずつ分割して、そのどれかが一致すれば検索結果に上げる、という方法を取ります。
例)うらしまたろうだ
⇨ うら しま たろ うだ
この点は上段の記事がとてもわかりやすいので、ぜひご覧になってみてください。
今回は、違う方法もやってみたいな〜と思ったのと、できるだけ検索候補を漏らさない(一文字だけ打っても、候補が表示される)かつ、検索文字が増えるに応じて検索結果が絞り込まれていく方法を試してみたかったので、ちょっと違う方法を試しています。(後述)

実際の動きは↓↓です。検索の部分だけ写してます。

YouTubeのvideoIDが不正ですhttps://youtube.com/shorts/xCZJDYdOZBw?feature=share

ファイルの構成

-Libフォルダ

  • Main.dart
  • search_delegate.dart

注意点

1.今回はサンプル用の超簡単な構成なので、いわゆるアーキテクチャなどは未考慮
2.riverpodやproviderなどの状態管理のライブラリは未使用
3.そのため、UIとロジックの分離などは未考慮

実装その① main.dart

ここでのメインは、文字列をfirestoreに入れることです。

main
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}

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

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String name = "";

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        automaticallyImplyLeading: false,
        title: InkWell(
          splashColor: Colors.white30,
          onTap: () => _search(context),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: const [
              Icon(Icons.search_rounded),
              Text("検索"),
            ],
          ),
        ),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              TextFormField(
                style: const TextStyle(
                  color: Colors.black54,
                ),
                controller: TextEditingController(text: ""),
                keyboardType: TextInputType.multiline,
                maxLength: 10,
                decoration: const InputDecoration(
                  enabledBorder: OutlineInputBorder(
                    borderSide: BorderSide(
                      color: Colors.black54,
                    ),
                  ),
                ),
                onChanged: (String value) {
                  name = value;
                },
              ),
              TextButton(
                onPressed: () async {
                  await _insertName(name);
                  name = "";
                  setState(() {});
                },
                child: const Text("名前登録"),
              ),
            ],
          ),
        ),
      ),
    );
  }

  _search(BuildContext context) async {
    final name = await showSearch(
      context: context,
      delegate: SearchNameDelegate(),
    );
    if (name != null) {}
  }

  _insertName(String name) async {
    final nameOption = await _createNameOption(name);
    final FirebaseFirestore db = FirebaseFirestore.instance;
    try {
      await db.collection("users").doc(const Uuid().v1()).set({
        "name": name,
        "nameOption": nameOption,
      });
    } catch (e) {
      print("_insertName error ${e.toString()}");
    }
  }

  Future<List<String>> _createNameOption(String value) async {
    var name = value;
    var times = <int>[];
//分割する文字数(かつ回数)を規定(大きい数順で2文字目まで)
    for (int i = name.length; i >= 1; i--) {
      times.add(i);
    }
    var nameList = <String>[];
    for (int time in times) {
//繰り返す回数
      for (int i = name.length; i >= 0; i--) {
//1ずつ数字を減らしていく(1文字以上、名前の文字数以下の分割Gramが生成される)
        if (i + time <= name.length) {
//文字数を超えて分割の後ろを指定できないので、if分で制御
          final getName = name.substring(i, i + time);
          nameList.add(getName);
          name = value;
        }
      }
    }
    return nameList;
  }
}

ここでのポイントは2つ

①_createNameOptionメソッドで、firestoreに書き込む前に文字列を分割する

②SearchDelegateクラスを使う

①については、上述しましたが、
・検索候補をできるだけ漏らさず表示させる
・文字を打つに従って検索候補が絞り込まれる

という2点を達成したかったため、文字列を2文字ずつの分割にするだけではなく、1文字〜文字列の数だけ分割します。説明が難しいですが例えば「うらしまたろう」だったら、

[うらしまたろう, らしまたろう, うらしまたろ, しまたろう, らしまたろ, うらしまた, またろう, しまたろ, らしまた, うらしま, たろう, またろ, しまた, らしま, うらし, ろう, たろ, また, しま, らし, うら,,,,,,,]

このような感じで、1文字から7文字までの様々な分割パターンを作成します。
firestoreにはこのようなデータが追加されます。

この分割の仕方は、例えばユーザー検索など、文字列が長くならない(あるいは、アプリの機能でユーザーに最大数を制限できる)場合に向いているかと思います。
文字列があまりに多いとなると分割パターンが膨大になるので、firebaseのドキュメントの容量制限を超える可能性があります。
また、ユーザーが検索を行うときは、ひらがな、カタカナ、英字、漢字など、様々なパターンが存在します。実際の運用では、ひらがな⇄カタカナ 大文字 ⇄ 小文字 など複数のパターンをfirestore
に入れておくことも考慮が必要になります。(あるいは、フロント側で入力文字種を制限するなど)

②は、flutterで標準的あるもので、検索、検索結果表示に便利な機能を用意してくれています。
AppbarのonTap属性にshowSearch()メソッドを追加することで、appBarをタップしたら検索ボックスを出してくれます。
https://api.flutter.dev/flutter/material/SearchDelegate-class.html

実装その② search_delegate.dart


class SearchNameDelegate extends SearchDelegate<String> {
  @override
  ThemeData appBarTheme(BuildContext context) {
    final theme = Theme.of(context);
    return theme.copyWith(
      brightness: Brightness.light,
    );
  }

  @override
  Widget buildLeading(BuildContext context) {
    return IconButton(
      icon: const Icon(Icons.arrow_back),
      onPressed: () {
        close(context, "");
      },
    );
  }

  @override
  List<Widget> buildActions(BuildContext context) {
    return [
      IconButton(
        icon: const Icon(Icons.clear),
        onPressed: () {
          query = "";
        },
      ),
    ];
  }

  @override
  Widget buildSuggestions(BuildContext context) {
    if (query.isNotEmpty) {
      return StreamBuilder(
          stream: _searchName(query),
          builder: (context, AsyncSnapshot<QuerySnapshot> snapshot) {
            return _results(context, snapshot);
          });
    } else {
      return Container();
    }
  }

  @override
  Widget buildResults(BuildContext context) {
    if (query.isNotEmpty) {
      return StreamBuilder(
          stream: _searchName(query),
          builder: (context, AsyncSnapshot<QuerySnapshot> snapshot) {
            return _results(context, snapshot);
          });
    } else {
      return Container();
    }
  }

  Widget _results(
      BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
    print(snapshot.data);

    return SingleChildScrollView(
      child: !snapshot.hasData
          ? const Center(child: Text("検索結果なし"))
          : ListView.builder(
              shrinkWrap: true,
              physics: const NeverScrollableScrollPhysics(),
              itemCount: snapshot.data!.docs.length,
              itemBuilder: (context, int index) {
                final doc = snapshot.data!.docs[index];
                return ListTile(
                  title: Text(
                    doc["name"],
                  ),
                );
              }),
    );
  }

  Stream<QuerySnapshot> _searchName(String queryString) {
    final FirebaseFirestore db = FirebaseFirestore.instance;
    return db
        .collection("users")
        .where("nameOption", arrayContains: queryString)
        .snapshots();
  }
}

①appBarTheme

②buildLeading

③buildActions

この3つは主にappbarのUIに関わる部分を実装します。

④buildSuggestions

ユーザーが検索フィールドに文字を入力しているときに行う動作を実装します

⑤buildResults

ユーザーが検索フィールドに文字を完了した後に行う動作を実装します

④も⑤も、firestoreからデータを取ってきて、表示させるという同じ動作になりますので_results という同じウィジェットを指定します。
なお、今回はStreamBuilderを使って_searchNameメソッドから、firebaseよりデータを取得しています。
*)状態管理ライブラリを使えばStreamBuilderを使用する必要はありませんが、今回はシンプルな構成とする関係上、StreamBuilderを使いました

検索結果を表示する_results で、ListViewを用いて検索結果を一覧表示します。

いかがでしたでしょうか。文字列の分解と、search_delegateを使用することで、案外簡単に全文検索と検索候補機能を実装することができました。
私がこれまで個人開発で作ってきたアプリでも検索機能を有していますが、このような方法で実装しています。
加筆、修正などありましたら随時コメントいただけるとありがたいです。
ここまで読んでいただき、ありがとうございました!

Discussion