关于 how-to-build-a-graph-database-with-flutter
这篇教程面向 Flutter 开发者,讲解如何使用 SQLite 和 drift 构建图数据库。内容涵盖节点与边的存储、文本搜索、遍历查询及图可视化。
主要功能
- 01在 SQLite 中存储图节点与边
- 02使用 drift 定义数据库
- 03通过 FTS5 搜索节点文本
- 04查询入向和出向边
- 05递归遍历图关系
- 06使用 graphview 可视化图
适用场景
- 01建模社交网络应用中的关系
- 02表示游戏玩家与物品的关联
- 03连接博客文章与评论
- 04在 Flutter 应用中搜索或遍历图数据
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rodydavis/ skills --skill how-to-build-a-graph-database-with-flutter安装后在 Claude Code 中输入/how-to-build-a-graph-database-with-flutter确认已加载。
详细介绍
如何使用 Flutter 构建图数据库
本文将介绍如何使用 Flutter 创建和使用图数据库。
前提条件
已安装并配置 Flutter(如需帮助,请参阅这篇文章)。
具备 SQLite 基础知识。
具备图数据库基础知识(如需深入了解,请参阅此视频)。
概述
首先,既然还有其他存储方案,为什么我们需要图数据库?
为什么不使用键值存储、文档存储或关系数据库?
答案是,这取决于你要解决的问题。
图数据库非常适合对数据之间的关系进行建模。
以下是几个示例:
- 社交网络应用可以对用户和帖子之间的关系进行建模
- 游戏可以对玩家和物品之间的关系进行建模
- 博客可以对帖子和评论之间的关系进行建模
可能性是无穷的。
我们不再为每个集合将数据存储在单独的表中,而是将数据作为图存储在节点表和边表中,并使用 SQLite 中的一些扩展来简化操作。
此页面详细介绍了相关内容,并展示了我们要构建的内容。
开始使用
首先,我们需要创建一个新的 Flutter 项目。
mkdir flutter_graph_database
cd flutter_graph_database
flutter create .
创建项目后,在你喜欢的代码编辑器中打开它。
code .
创建数据库
我们将使用 drift 包来创建数据库。
使用以下内容更新 pubspec.yaml 文件:
name: flutter_graph_database
description: A new Flutter package project.
version: 0.0.1
publish_to: none
environment:
sdk: ">=2.19.0-238.0.dev <3.0.0"
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
drift: ^2.1.0
sqlite3_flutter_libs: ^0.5.5
http: ^0.13.5
path_provider: ^2.0.0
path: ^1.8.2
sqlite3: ^1.7.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
build_runner: ^2.2.0
drift_dev: ^2.1.0
flutter:
数据库连接
接下来,我们需要创建数据库。
新建文件 lib/database/connection/unsupported.dart,并使用以下内容更新该文件:
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
DatabaseConnection connect(
String dbName, {
bool useWebWorker = false,
bool logStatements = false,
}) {
return DatabaseConnection(NativeDatabase.memory(
logStatements: logStatements,
));
}
新建文件 lib/database/connection/native.dart,并使用以下内容更新该文件:
import 'dart:io';
import 'dart:isolate';
import 'package:drift/drift.dart';
import 'package:drift/isolate.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
DatabaseConnection connect(
String dbName, {
bool useWebWorker = false,
bool logStatements = false,
}) {
return DatabaseConnection.delayed(Future.sync(() async {
final appDir = await getApplicationDocumentsDirectory();
final dbPath = p.join(appDir.path, dbName);
final receiveDriftIsolate = ReceivePort();
await Isolate.spawn(_entrypointForDriftIsolate,
_IsolateStartRequest(receiveDriftIsolate.sendPort, dbPath));
final driftIsolate = await receiveDriftIsolate.first as DriftIsolate;
return driftIsolate.connect();
}));
}
class _IsolateStartRequest {
final SendPort talkToMain;
final String databasePath;
_IsolateStartRequest(this.talkToMain, this.databasePath);
}
void _entrypointForDriftIsolate(_IsolateStartRequest request) {
final databaseImpl = NativeDatabase(
File(request.databasePath),
logStatements: false,
);
final driftServer = DriftIsolate.inCurrent(
() => DatabaseConnection(databaseImpl),
);
request.talkToMain.send(driftServer);
}
新建文件 lib/database/connection/web.dart,并使用以下内容更新该文件:
import 'dart:async';
// ignore: avoid_web_libraries_in_flutter
import 'dart:html';
import 'package:drift/drift.dart';
import 'package:drift/remote.dart';
import 'package:drift/web.dart';
import 'package:drift/wasm.dart';
import 'package:http/http.dart' as http;
import 'package:sqlite3/wasm.dart';
DatabaseConnection connect(
String dbName, {
bool useWebWorker = false,
bool logStatements = false,
}) {
if (useWebWorker) {
final worker = SharedWorker('shared_worker.dart.js');
return remote(worker.port!.channel());
} else {
return DatabaseConnection.delayed(Future.sync(() async {
final response = await http.get(Uri.parse('sqlite3.wasm'));
final fs = await IndexedDbFileSystem.open(dbName: '/db/');
final path = '/drift/db/$dbName';
final sqlite3 = await WasmSqlite3.load(
response.bodyBytes,
SqliteEnvironment(fileSystem: fs),
);
final databaseImpl = WasmDatabase(
sqlite3: sqlite3,
path: path,
fileSystem: fs, // <- this is required but not documented
logStatements: logStatements,
);
return DatabaseConnection(databaseImpl);
}));
}
}
新建文件 lib/database/connection/connection.dart,并使用以下内容更新该文件:
export 'unsupported.dart'
if (dart.library.js) 'web.dart'
if (dart.library.ffi) 'native.dart';
数据库 SQL 文件
架构
新建文件 lib/database/sql/schema.drift,并使用以下内容更新该文件:
CREATE TABLE IF NOT EXISTS nodes (
body TEXT,
id TEXT GENERATED ALWAYS AS (json_extract(body, '$.id')) VIRTUAL NOT NULL UNIQUE
);
CREATE INDEX IF NOT EXISTS id_idx ON nodes(id);
CREATE TABLE IF NOT EXISTS edges (
source TEXT,
target TEXT,
properties TEXT,
UNIQUE(source, target, properties) ON CONFLICT REPLACE,
FOREIGN KEY(source) REFERENCES nodes(id),
FOREIGN KEY(target) REFERENCES nodes(id)
);
CREATE INDEX IF NOT EXISTS source_idx ON edges(source);
CREATE INDEX IF NOT EXISTS target_idx ON edges(target);
ID 列是一个虚拟列,由 body 列生成。这样我们就可以按 ID 查询数据库,而不必解析 JSON 格式的 body 列。
查询
新建文件 lib/database/sql/queries.drift,并使用以下内容更新该文件:
import 'schema.drift';
getAllNodes:
SELECT * FROM nodes;
getAllEdges:
SELECT * FROM edges;
删除边
新建文件 lib/database/sql/delete-edge.drift,并使用以下内容更新该文件:
import 'schema.drift';
deleteEdge:
DELETE FROM edges
WHERE source = ? OR target = ?;
删除节点
新建文件 lib/database/sql/delete-node.drift,并使用以下内容更新该文件:
import 'schema.drift';
deleteNode:
DELETE FROM nodes
WHERE id = ?;
插入边
新建文件 lib/database/sql/insert-edge.drift,并使用以下内容更新该文件:
import 'schema.drift';
insertEdge(:source as TEXT, :target as TEXT, :body as TEXT):
INSERT INTO edges VALUES(:source, :target, json(:body));
搜索入站边
新建文件 lib/database/sql/search-edges-inbound.drift,并使用以下内容更新该文件:
import 'schema.drift';
searchEdgesInbound:
SELECT * FROM edges
WHERE source = ?;
搜索出站边
新建文件 lib/database/sql/search-edges-outbound.drift,并使用以下内容更新该文件:
import 'schema.drift';
searchEdgesOutbound:
SELECT * FROM edges
WHERE target = ?;
搜索边
新建文件 lib/database/sql/search-edges.drift,并使用以下内容更新该文件:
import 'schema.drift';
searchEdges:
SELECT * FROM edges WHERE source = ?
UNION
SELECT * FROM edges WHERE target = ?;
按 ID 搜索节点
新建文件 lib/database/sql/search-node-by-id.drift,并使用以下内容更新该文件:
import 'schema.drift';
searchNodeById:
SELECT body FROM nodes
WHERE id = ?;
搜索节点
在 lib/database/sql/search-node.drift 处创建一个新文件,并将其更新为以下内容:
import 'schema.drift';
-- Create a text index of entries, see https://www.sqlite.org/fts5.html#external_content_tables
CREATE VIRTUAL TABLE node_entries USING fts5 (
body,
content=nodes,
content_rowid=id
);
-- Triggers to keep entries and fts5 index in sync.
CREATE TRIGGER nodes_insert AFTER INSERT ON nodes BEGIN
INSERT INTO node_entries(rowid, body) VALUES (new.id, new.body);
END;
CREATE TRIGGER nodes_delete AFTER DELETE ON nodes BEGIN
INSERT INTO node_entries(node_entries, rowid, body) VALUES ('delete', old.id, old.body);
END;
CREATE TRIGGER nodes_update AFTER UPDATE ON nodes BEGIN
INSERT INTO node_entries(node_entries, rowid, body) VALUES ('delete', new.id, new.body);
INSERT INTO node_entries(rowid, body) VALUES (new.id, new.body);
END;
-- Full text search query.
searchNode: SELECT r.** FROM node_entries
INNER JOIN nodes r ON r.id = node_entries.rowid
WHERE node_entries MATCH :query
ORDER BY rank;
此处我们使用 fts5 扩展来创建全文搜索索引。这是一项非常强大的功能,可以让我们根据节点正文文本搜索节点。
遍历入站
在 lib/database/sql/traverse-inbound.drift 处创建一个新文件,并将其更新为以下内容:
import 'schema.drift';
traverseInbound(:source AS TEXT):
WITH RECURSIVE traverse(id) AS (
SELECT :source
UNION
SELECT source FROM edges JOIN traverse ON target = id
) SELECT id FROM traverse;
遍历出站
在 lib/database/sql/traverse-outbound.drift 处创建一个新文件,并将其更新为以下内容:
import 'schema.drift';
traverseOutbound(:source AS TEXT):
WITH RECURSIVE traverse(id) AS (
SELECT :source
UNION
SELECT target FROM edges JOIN traverse ON source = id
) SELECT id FROM traverse;
遍历正文入站
在 lib/database/sql/traverse-with-bodies-inbound.drift 处创建一个新文件,并将其更新为以下内容:
import 'schema.drift';
traverseWithBodiesInbound(:source AS TEXT):
WITH RECURSIVE traverse(x, y, obj) AS (
SELECT :source, '()', '{}'
UNION
SELECT id, '()', body FROM nodes JOIN traverse ON id = x
UNION
SELECT source, '<-', properties FROM edges JOIN traverse ON target = x
) SELECT x, y, obj FROM traverse;
遍历正文出站
在 lib/database/sql/traverse-with-bodies-outbound.drift 处创建一个新文件,并将其更新为以下内容:
import 'schema.drift';
traverseWithBodiesOutbound(:source AS TEXT):
WITH RECURSIVE traverse(x, y, obj) AS (
SELECT :source, '()', '{}'
UNION
SELECT id, '()', body FROM nodes JOIN traverse ON id = x
UNION
SELECT target, '->', properties FROM edges JOIN traverse ON source = x
) SELECT x, y, obj FROM traverse;
遍历正文
在 lib/database/sql/traverse-bodies.drift 处创建一个新文件,并将其更新为以下内容:
import 'schema.drift';
traverseWithBodies(:source AS TEXT):
WITH RECURSIVE traverse(x, y, obj) AS (
SELECT :source, '()', '{}'
UNION
SELECT id, '()', body FROM nodes JOIN traverse ON id = x
UNION
SELECT source, '<-', properties FROM edges JOIN traverse ON target = x
UNION
SELECT target, '->', properties FROM edges JOIN traverse ON source = x
) SELECT x, y, obj FROM traverse;
遍历
在 lib/database/sql/traverse.drift 处创建一个新文件,并将其更新为以下内容:
import 'schema.drift';
traverse(:source AS TEXT):
WITH RECURSIVE traverse(id) AS (
SELECT :source
UNION
SELECT source FROM edges JOIN traverse ON target = id
UNION
SELECT target FROM edges JOIN traverse ON source = id
) SELECT id FROM traverse;
更新节点
在 lib/database/sql/update-node.drift 处创建一个新文件,并将其更新为以下内容:
import 'schema.drift';
updateNode:
UPDATE nodes SET body = json(?)
WHERE id = ?;
数据库设置
在 lib/database/database.dart 处创建一个新文件,并将其更新为以下内容:
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'connection/connection.dart' as impl;
part 'database.g.dart';
@DriftDatabase(include: {
'sql/schema.drift',
'sql/queries.drift',
'sql/delete-edge.drift',
'sql/delete-node.drift',
'sql/insert-edge.drift',
'sql/insert-node.drift',
'sql/search-edges-inbound.drift',
'sql/search-edges-outbound.drift',
'sql/search-edges.drift',
'sql/search-node-by-id.drift',
'sql/search-node.drift',
'sql/traverse-inbound.drift',
'sql/traverse-outbound.drift',
'sql/traverse-with-bodies-inbound.drift',
'sql/traverse-with-bodies-outbound.drift',
'sql/traverse-with-bodies.drift',
'sql/traverse.drift',
'sql/update-node.drift',
})
class GraphDatabase extends _$GraphDatabase {
GraphDatabase({
String dbName = 'graph_db.db',
DatabaseConnection? connection,
bool useWebWorker = false,
bool logStatements = false,
}) : super.connect(
connection ??
impl.connect(
dbName,
useWebWorker: useWebWorker,
logStatements: logStatements,
),
);
@override
int get schemaVersion => 1;
/// Helper method to add graph data from json
Future<void> addGraphData(
Map<String, dynamic> data, {
bool shouldBatch = false,
}) {
return transaction(() async {
try {
final localNodes = data['nodes'] as List<dynamic>;
final localEdges = data['edges'] as List<dynamic>;
// Update nodes
for (final node in localNodes) {
final id = node['id'] as String?;
if (id != null) {
final current = await searchNodeById(id).getSingleOrNull();
final body = jsonEncode(node);
if (current != null) {
await updateNode(id, body);
} else {
await insertNode(body);
}
}
}
// Update edges
for (final edge in localEdges) {
final source = edge['from'] ?? edge['source'] as String?;
final target = edge['to'] ?? edge['target'] as String?;
if (source != null && target != null) {
final body = jsonEncode(edge);
await insertEdge(source, target, body);
}
}
} catch (e) {
debugPrint('Error adding graph data: $e');
}
});
}
Future<void> deleteAll() {
return transaction(() async {
try {
await deleteAllEdges();
await deleteAllNodes();
} catch (e) {
debugPrint('Error clearing graph data: $e');
}
});
}
Future<void> deleteAllEdges() {
return transaction(() async {
final edges = await getAllEdges().get();
for (final edge in edges) {
await deleteEdge(edge.source, edge.target);
}
});
}
Future<void> deleteAllNodes() {
return transaction(() async {
final nodes = await getAllNodes().get();
for (final node in nodes) {
await deleteNode(node.id);
}
});
}
}
在 build.yaml 处创建一个新文件,并将其更新为以下内容:
targets:
$default:
sources:
- lib/**
- web/**
- "tool/**"
- pubspec.yaml
- lib/$lib$
- $package$
builders:
drift_dev:
options:
sql:
dialect: sqlite
options:
version: "3.38"
modules:
- json1
- fts5
generate_connect_constructor: true
apply_converters_on_variables: true
generate_values_in_copy_with: true
scoped_dart_components: true
现在运行以下命令来生成数据库文件:
flutter pub run build_runner build --delete-conflicting-outputs
连接到数据库
在 pubspec.yaml 文件中添加一个新依赖:
flutter pub add graphview
此依赖将用于图可视化。
在 lib/main.dart 处创建一个新文件,并将其更新为以下内容:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_graph_database/flutter_graph_database.dart' as db;
import 'package:graphview/GraphView.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Graph Database',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(),
home: const Example(),
);
}
}
class Example extends StatefulWidget {
const Example({Key? key}) : super(key: key);
@override
State<Example> createState() => _ExampleState();
}
class _ExampleState extends State<Example> {
final database = db.GraphDatabase();
Graph graph = Graph();
Algorithm builder = FruchtermanReingoldAlgorithm();
final nodes = <String, db.Node>{};
bool loaded = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => loadData());
}
@override
void reassemble() {
super.reassemble();
// Needed to reset graph on hot reload
loadData();
}
void setLoadedState(bool value) {
if (mounted) {
setState(() {
loaded = value;
});
}
}
Future<void> addDummyData() async {
// Load example data
try {
// Optionally reset data
await database.deleteAll();
// Add example data to database
await database.addGraphData({
"nodes": [
{"id": '1', "label": 'circle'},
{"id": '2', "label": 'ellipse'},
{"id": '3', "label": 'database'},
{"id": '4', "label": 'box'},
{"id": '5', "label": 'diamond'},
{"id": '6', "label": 'dot'},
{"id": '7', "label": 'square'},
{"id": '8', "label": 'triangle'},
{"id": '9', "label": "star"},
],
"edges": [
{"from": '1', "to": '2'},
{"from": '2', "to": '3'},
{"from": '2', "to": '4'},
{"from": '2', "to": '5'},
{"from": '5', "to": '6'},
{"from": '5', "to": '7'},
{"from": '6', "to": '8'},
{"from": '2', "to": '8'},
{"from": '1', "to": '8'},
{"from": '1', "to": '7'},
{"from": '1', "to": '6'},
{"from": '1', "to": '5'},
{"from": '1', "to": '4'},
{"from": '1', "to": '3'},
{"from": '1', "to": '9'},
{"from": '9', "to": '8'},
{"from": '9', "to": '5'},
{"from": '9', "to": '3'},
]
});
loadData();
} catch (e) {
debugPrint('Error loading example data: $e');
}
}
Future<void> loadData() async {
setLoadedState(false);
final nodeMap = <String, Node>{};
this.nodes.clear();
graph = Graph();
builder = FruchtermanReingoldAlgorithm();
// Load graph data
final nodes = await database.getAllNodes().get();
final edges = await database.getAllEdges().get();
for (final node in nodes) {
final newNode = Node.Id(node.id);
nodeMap[node.id] = newNode;
this.nodes[node.id] = node;
graph.addNode(newNode);
}
for (final edge in edges) {
final source = nodeMap[edge.source];
final target = nodeMap[edge.target];
if (source != null && target != null) {
graph.addEdge(source, target);
}
}
setLoadedState(true);
}
Widget buildNode(Node node) {
final dbNode = nodes[node.key!.value];
final data = jsonDecode(dbNode?.body ?? '{}') as Map<String, dynamic>;
final label = data['label'] ?? '';
return SizedBox(
width: 80,
height: 80,
child: Center(
child: Text(
label,
textAlign: TextAlign.center,
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Flutter Graph Database'),
actions: [
IconButton(
icon: const Icon(Icons.restore),
onPressed: addDummyData,
),
],
),
body: !loaded
? const Center(child: CircularProgressIndicator())
: nodes.isEmpty
? const Center(child: Text('No Data Loaded'))
: LayoutBuilder(builder: (context, dimens) {
return SizedBox.expand(
child: InteractiveViewer(
constrained: false,
boundaryMargin: EdgeInsets.symmetric(
horizontal: dimens.maxWidth * 0.75,
vertical: dimens.maxHeight * 0.75,
),
minScale: 0.01,
maxScale: 5.6,
child: GraphView(
key: UniqueKey(),
graph: graph,
algorithm: builder,
paint: Paint()
..color = Colors.green
..strokeWidth = 1
..style = PaintingStyle.stroke,
builder: buildNode,
),
),
);
}),
);
}
}
运行 Flutter 应用时,你应该会看到以下内容:
![]()
总结
如果你想进一步了解如何在 Flutter 中构建图数据库,请查看源代码。