Flutter基于嵌入式 Meilisearch 实例构建的 NoSQL 数据库插件flutter_mimir的使用

Flutter基于嵌入式 Meilisearch 实例构建的 NoSQL 数据库插件flutter_mimir的使用

插件概述

flutter_mimir 是一个基于嵌入式 Meilisearch 实例构建的 NoSQL 数据库,专为 Dart 和 Flutter 设计。它提供了强大的全文搜索、快速读取和反应式查询等功能,并且支持多语言(包括CJK、希伯来语等)。以下是其主要特性:

  • 高容错性:对拼写错误有很高的容忍度。
  • 高性能:由 Rust 编写的搜索引擎确保了极高的检索速度。
  • 易用性:API 简洁明了,易于集成到 Flutter 项目中。
  • 跨平台支持:适用于多种操作系统(Web 版本正在开发中)。

使用方法

安装

在 Flutter 项目中添加 flutter_mimir

flutter pub add mimir flutter_mimir

对于纯 Dart 项目:

dart pub add mimir

注意:macOS 用户需要禁用“App Sandbox”,详情参见 StackOverflow

示例代码

以下是一个完整的 Demo 应用程序示例,展示了如何使用 flutter_mimir 来创建一个电影搜索应用:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mimir/flutter_mimir.dart';
import 'package:flutter_rearch/flutter_rearch.dart';
import 'package:rearch/rearch.dart';

void main() => runApp(const DemoApp());

/// 获取包含电影数据集的 MimirIndex
Future<MimirIndex> indexAsyncCapsule(CapsuleHandle use) async {
  final instance = await Mimir.defaultInstance;
  final index = instance.getIndex('movies');

  // 异步加载并设置文档
  await rootBundle
      .loadString('assets/tmdb_movies.json')
      .then((l) => json.decode(l) as List)
      .then((l) => l.cast<Map<String, dynamic>>())
      .then(index.setDocuments);

  return index;
}

/// 预热 indexAsyncCapsule 以便后续使用
AsyncValue<MimirIndex> indexWarmUpCapsule(CapsuleHandle use) {
  final future = use(indexAsyncCapsule);
  return use.future(future);
}

/// 作为预热后的 indexAsyncCapsule 的代理
MimirIndex indexCapsule(CapsuleHandle use) {
  return use(indexWarmUpCapsule).data.unwrapOrElse(
        () => throw StateError('indexAsyncCapsule was not warmed up!'),
      );
}

/// 当前查询字符串 ('' 表示无查询)
(String, void Function(String)) queryCapsule(CapsuleHandle use) =>
    use.state('');

/// 根据 queryCapsule 中的查询获取搜索结果
AsyncValue<List<Map<String, dynamic>>> searchResultsCapsule(CapsuleHandle use) {
  final index = use(indexCapsule);
  final query = use(queryCapsule);

  // 当查询为空时返回所有文档
  final stream = use.memo(
    () => index.searchStream(query: query.$1),
    [index, query.$1],
  );
  return use.stream(stream);
}

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

  @override
  Widget build(BuildContext context) {
    return RearchBootstrapper(
      child: MaterialApp(
        title: 'Mimir Demo',
        theme: ThemeData.light(useMaterial3: true),
        darkTheme: ThemeData.dark(useMaterial3: true),
        home: const GlobalWarmUps(child: Body()),
      ),
    );
  }
}

final class GlobalWarmUps extends RearchConsumer {
  const GlobalWarmUps({required this.child, super.key});

  final Widget child;

  @override
  Widget build(BuildContext context, WidgetHandle use) {
    return [
      use(indexWarmUpCapsule),
    ].toWarmUpWidget(
      child: child,
      loading: const Center(child: CircularProgressIndicator.adaptive()),
      errorBuilder: (errors) => Column(
        children: [
          for (final AsyncError(:error, :stackTrace) in errors)
            Text('$error\n$stackTrace'),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Mimir Demo'),
        actions: [
          IconButton(
            icon: const Icon(Icons.info),
            onPressed: () => showInfoDialog(context),
          ),
        ],
        bottom: const PreferredSize(
          preferredSize: Size.fromHeight(56),
          child: Padding(
            padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
            child: SearchBar(),
          ),
        ),
      ),
      body: const SearchResults(),
    );
  }
}

class SearchBar extends RearchConsumer {
  const SearchBar({super.key});

  @override
  Widget build(BuildContext context, WidgetHandle use) {
    final isLoading = use(searchResultsCapsule) is AsyncLoading;
    final (_, setQuery) = use(queryCapsule);
    final textController = use.textEditingController();

    return TextField(
      controller: textController,
      onChanged: setQuery,
      decoration: InputDecoration(
        contentPadding: EdgeInsets.zero,
        border: const OutlineInputBorder(
          borderRadius: BorderRadius.all(Radius.circular(100)),
        ),
        prefixIcon: const Icon(Icons.search),
        suffixIcon: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (isLoading) const CircularProgressIndicator.adaptive(),
            IconButton(
              icon: const Icon(Icons.cancel),
              onPressed: () {
                textController.text = '';
                setQuery('');
              },
            ),
          ],
        ),
      ),
    );
  }
}

class SearchResults extends RearchConsumer {
  const SearchResults({super.key});

  @override
  Widget build(BuildContext context, WidgetHandle use) {
    return switch (use(searchResultsCapsule)) {
      AsyncData(data: final movies) => MoviesList(movies: movies),
      AsyncLoading(previousData: None()) =>
        const Center(child: CircularProgressIndicator.adaptive()),
      AsyncLoading(previousData: Some(value: final movies)) =>
        MoviesList(movies: movies),
      AsyncError(:final error, :final stackTrace, :final previousData) =>
        Column(
          children: [
            Text('Error: $error\nStack Trace: $stackTrace'),
            if (previousData case Some(value: final oldMovies))
              Expanded(child: MoviesList(movies: oldMovies)),
          ],
        ),
    };
  }
}

class MoviesList extends StatelessWidget {
  const MoviesList({required this.movies, super.key});

  final List<Map<String, dynamic>> movies;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      padding: const EdgeInsets.only(top: 16, bottom: 8),
      itemCount: movies.length,
      itemBuilder: (_, index) => MovieCard(movie: movies[index]),
    );
  }
}

class MovieCard extends StatelessWidget {
  const MovieCard({required this.movie, super.key});

  final Map<String, dynamic> movie;

  @override
  Widget build(BuildContext context) {
    return Card(
      clipBehavior: Clip.antiAlias,
      margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxHeight: 100),
        child: Row(
          children: [
            AspectRatio(
              aspectRatio: 188.0 / 282.0,
              child: Image.network(
                'https://www.themoviedb.org/t/p/w188_and_h282_bestv2${movie['poster_path']}',
                fit: BoxFit.fill,
                errorBuilder: (_, __, ___) => Center(
                  child: Icon(
                    Icons.cancel,
                    color: Theme.of(context).colorScheme.error,
                  ),
                ),
                loadingBuilder: (_, child, progress) {
                  if (progress == null) return child;
                  return const Center(
                    child: CircularProgressIndicator.adaptive(),
                  );
                },
              ),
            ),
            const SizedBox(width: 8),
            Expanded(
              child: ListTile(
                contentPadding: EdgeInsets.zero,
                title: Text(
                  movie['title'] as String,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),
                subtitle: Text(
                  movie['overview'] as String,
                  maxLines: 3,
                  overflow: TextOverflow.ellipsis,
                ),
              ),
            ),
            const SizedBox(width: 8),
            Text((movie['release_date'] as String).split('-')[0]),
            const SizedBox(width: 12),
          ],
        ),
      ),
    );
  }
}

void showInfoDialog(BuildContext context) {
  showDialog<void>(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('info'),
      content: const Text('Movie data provided by themoviedb.org.'),
      actions: [
        TextButton(
          onPressed: Navigator.of(context).pop,
          child: const Text('Close'),
        ),
      ],
    ),
  );
}

注意事项

  • 主键要求:每个文档必须有一个唯一标识符(如 id 或以 id 结尾的字段),并且该值只能是数字或符合正则表达式 ^[a-zA-Z0-9-_]*$ 的字符串。
  • iOS 限制:目前 iOS 设备上仅能打开一个索引;有关更多信息,请参阅 GitHub Issue #227
  • macOS 沙盒问题:不支持 macOS App Sandbox,因此无法提交至 Mac App Store,但可以自行分发 macOS 应用。
  • 资源消耗:大量详细文档可能会占用较多磁盘空间和内存,特别是在处理数千条记录时。此外,mimir 可能会增加应用程序包大小约几百 MB。

通过上述内容,您可以更好地理解和利用 flutter_mimir 插件的强大功能,在您的 Flutter 应用中实现高效的本地数据库管理和全文搜索。


更多关于Flutter基于嵌入式 Meilisearch 实例构建的 NoSQL 数据库插件flutter_mimir的使用的实战教程也可以访问 https://www.itying.com/category-92-b0.html

回到顶部