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