Flutter上で大量のデータを管理・検索しようと思ったため、SQLiteを使ってみます。
生のSQL文を書くより、ORマッパーを使いたいと思ったため、アクティブレコードパターンっぽくレコードを扱える sqfentity ライブラリを使います。
SQLiteのDBファイルの作成、DDL文の発行などはすべてライブラリがやってくれますので、生SQLを一切触らずにアプリで利用できます。
簡単なデモアプリのコード https://github.com/ytyng/todo-sqf-flutter
sqfentity
依存ライブラリに build_runner が入っています。このライブラリは、テーブルの定義を書いて、そこからbuild_runner でモデルコードを生成し、アプリでそれを使います。
最初は、ひと手間かかる感じが少し面倒そうだったのでコードジェネレートに少し抵抗があったのですが、生成済みのコードは機能リファレンスのように使えるため、なかなか使い勝手は良いです。
なお、DBの内容はアプリを再起動しても消えませんが、アプリをアンインストールすると消えます。
セットアップ
新規 Flutterプロジェクトの作成
空のリポジトリを作成し、その中に新たな Flutter プロジェクトを作ります。
mkdir todo-sqf-flutter
cd todo-sqf-flutter
flutter create app
作った todo-sqf-flutter ディレクトリを、Flutter プラグイン組み込み済みの Android Studio で開きます。
右上 Add Configuration
+
→ ○ more items → Flutter
dart entry point は app/lib/main.dart
Dart SDK Not found になる場合は、 Fix → Flutterプラグインの場所を指定 (Dart でなくてよい)
虫マークをクリックして起動
カウントアップのデモアプリが起動する。
SQFEntity の組み込み
pubspeck.yaml に 追加
dependencies:
...
sqfentity: ^1.2.2+10
sqfentity_gen: ^1.2.0+8
dev_dependencies:
...
build_runner: ^1.6.5
build_verify: ^1.1.0
インストール
flutter pub get
モデルの作成
lib/models.dart を作成
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';
import 'package:sqfentity/sqfentity.dart';
import 'package:sqfentity_gen/sqfentity_gen.dart';
part 'models.g.dart';
@SqfEntityBuilder(todoDbModel)
const todoDbModel = SqfEntityModel(
modelName: 'TodoDbModel', // optional
databaseName: 'todo.db',
databaseTables: [todo],
bundledDatabasePath: null
);
const todo = SqfEntityTable(
tableName: 'todo',
primaryKeyName: 'id',
primaryKeyType: PrimaryKeyType.integer_auto_incremental,
useSoftDeleting: true,
fields: [
SqfEntityField('title', DbType.text),
SqfEntityField('active', DbType.bool, defaultValue: true),
]);
フィールドに、id (PK) と title を持つ、単純なモデルです。
(active というフィールドを作りましたがでもアプリ内では結局使いませんでした)
コードの生成
flutter packages pub run build_runner build --delete-conflicting-outputs
このようなモデルを含むコードが生成されます
// BEGIN ENTITIES
// region Todo
class Todo {
Todo({this.id, this.title, this.active, this.isDeleted}) {
_setDefaultValues();
}
Todo.withFields(this.title, this.active, this.isDeleted) {
_setDefaultValues();
}
Todo.withId(this.id, this.title, this.active, this.isDeleted) {
_setDefaultValues();
}
Todo.fromMap(Map<String, dynamic> o) {
id = o['id'] as int;
title = o['title'] as String;
active = o['active'] != null ? o['active'] == 1 : null;
isDeleted = o['isDeleted'] != null ? o['isDeleted'] == 1 : null;
}
...
アプリに組み込む
main.dart を修正
使用前に、initializeDB をコールします。
void main() async {
final bool isInitialized = await MyDbModel().initializeDB();
if (isInitialized == true) {
runApp(MyApp());
}
}
起動します。ログは、起動時に毎回出ます
Syncing files to device iPhone 7...
flutter: init() -> modelname: null, tableName:todo
flutter: >>>>>>>>>>>>>>>>>>>>>>>>>>>> SqfEntityTableBase of [todo](id) init() successfuly
flutter: todo.db created successfully
flutter: SQFENTITIY: Table named [todo] was initialized successfuly (created table)
flutter: SQFENTITIY: The database is ready for use
flutter: SQFENTITIY: Table named [todo] was initialized successfuly (No added new columns)
flutter: SQFENTITIY: The database is ready for use
Insert
Todo(title: textEditingController.text, active: true).save();
リアクティブではないので、実施後手動でウィジェットのリビルドが必要そうです。
Update
Todo(id: widget.todoId, title: textEditingController.text,
active: true).save();
save() メソッドは、UPSERT として機能します
Select
Todo().select().orderByDesc('id').toList()
空引数でクラスインスタンスを作ってから絞り込んでいきます。
戻り値はFuture ですので、単純にウィジェットに表示するだけなら、 FutureBuilder や StreamBuilder 使うと良さそうです。
Delete
Todo().select().id.equals(widget.todoId).delete()
スキーママイグレーション
フィールドの追加であれば、models.dart を修正し、build_runner してアプリを再起動すれば、自動的に ALTER TABLE してくれる。データは消えない。
flutter: init() -> modelname: null, tableName:todo
flutter: >>>>>>>>>>>>>>>>>>>>>>>>>>>> SqfEntityTableBase of [todo](id) init() successfuly
flutter: SQFENTITIY: alterTableQuery => [ALTER TABLE todo ADD COLUMN created datetime]
flutter: SQFENTITIY: Table named [todo] was initialized successfuly (Added new columns)
flutter: SQFENTITIY: The database is ready for use
flutter: SQFENTITIY: Table named [todo] was initialized successfuly (No added new columns)
flutter: SQFENTITIY: The database is ready for use
フィールドの型の変更は例外が出る。サポート外だと思ったほうが良いかも。アプリをアンインストールすればDBファイルが削除されるので、起動できるようになる。
[VERBOSE-2:ui_dart_state.cc(148)] Unhandled Exception: Exception: SQFENTITIY DATABASE INITIALIZE ERROR: The type of column [product_id(DbType.integer)] does not match the exiting column [product_id(DbType.text)]
#0 checkTableColumns (package:sqfentity/sqfentity.dart:680:9)
#1 SqfEntityModelProvider.initializeDB (package:sqfentity/sqfentity.dart:508:15)
<asynchronous suspension>
#2 SqfEntityProvider.db (package:sqfentity/sqfentity.dart:58:22)
<asynchronous suspension>
#3 SqfEntityProvider.execDataTable (package:sqfentity/sqfentity.dart:172:36)
<asynchronous suspension>
#4 SqfEntityModelProvider.initializeDB (package:sqfentity/sqfentity.dart:485:14)
<asynchronous suspension>
#5 run (package:mzv/app.dart:40:49)
<asynchronous suspension>
#6 main (package:mzv/main/local.dart:20:3)
<asynchronous suspension>
#7 _runMainZoned.<anonymous closure>.<anonymous closure> (dart:ui/hooks.dart:229:25)
#8 _rootRun (dart:async/zone.dart:1124:13)
#9 _CustomZone.run (dart<…>
その他、公式APIドキュメントに豊富な機能が紹介されています。
書いたコード
import 'package:flutter/material.dart';
import 'models.dart';
void main() async {
final bool isInitialized = await TodoDbModel().initializeDB();
if (isInitialized == true) {
runApp(MyApp());
}
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Todo App',
theme: ThemeData(
primarySwatch: Colors.green,
),
home: MyScaffold(),
);
}
class MyScaffold extends StatelessWidget {
final todoWidgetKey = GlobalKey<_TodoState>();
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text('Todo'),
),
body: TodoWidget(key: todoWidgetKey),
floatingActionButton: Builder(builder: (BuildContext context) {
return FloatingActionButton(
onPressed: () async {
await showDialog(
context: context,
builder: (BuildContext context) {
return EditDialog(
todoWidgetKey: todoWidgetKey,
);
});
},
child: Icon(Icons.add),
);
}),
);
}
class TodoWidget extends StatefulWidget {
const TodoWidget({
Key key,
}) : super(key: key);
@override
_TodoState createState() => _TodoState();
}
class _TodoState extends State<TodoWidget> {
void update() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Todo().select().orderByDesc('id').toList(),
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
return ListView(
children: snapshot.data.map<Widget>((Todo item) {
return GestureDetector(
onTap: () async {
await showDialog(
context: context,
builder: (BuildContext context) {
return EditDialog(
title: item.title,
todoId: item.id,
todoWidgetKey: widget.key,
);
});
},
child: Container(
decoration: BoxDecoration(
border: Border(
bottom:
BorderSide(width: 1.0, color: Colors.black26)),
),
padding: EdgeInsets.all(16),
child: Text(item.title),
),
);
}).toList(),
);
} else {
return Text('wait');
}
});
}
}
class EditDialog extends StatefulWidget {
final int todoId;
final String title;
final GlobalKey<_TodoState> todoWidgetKey;
final List<Widget> children;
const EditDialog({
Key key,
this.todoId,
this.title,
this.todoWidgetKey,
this.children,
}) : super(key: key);
@override
_EditDialogState createState() => _EditDialogState();
}
class _EditDialogState extends State<EditDialog> {
final textEditingController = TextEditingController();
@override
initState() {
super.initState();
if (widget.title != null) {
textEditingController.text = widget.title;
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
TextField(
controller: textEditingController,
autofocus: widget.todoId == null,
decoration: InputDecoration(
labelText: 'Task?',
),
),
...widget.todoId == null
? buttonsForCreate(context)
: buttonsForUpdate(context)
]));
}
List<Widget> buttonsForCreate(BuildContext context) => <Widget>[
buildButton(
context: context,
labelText: '登録',
onPressed: () {
if (textEditingController.text.length > 0) {
Todo(title: textEditingController.text, active: true).save();
widget.todoWidgetKey.currentState.update();
}
Navigator.of(context).pop();
})
];
List<Widget> buttonsForUpdate(BuildContext context) => <Widget>[
buildButton(
context: context,
labelText: '更新',
onPressed: () {
if (textEditingController.text.length > 0) {
Todo(
id: widget.todoId,
title: textEditingController.text,
active: true)
.save();
widget.todoWidgetKey.currentState.update();
}
Navigator.of(context).pop();
}),
buildButton(
context: context,
labelText: '消去',
buttonColor: Colors.red,
onPressed: () {
Todo().select().id.equals(widget.todoId).delete();
widget.todoWidgetKey.currentState.update();
Navigator.of(context).pop();
})
];
}
Widget buildButton(
{BuildContext context,
String labelText,
VoidCallback onPressed,
Color buttonColor}) {
return Padding(
padding: const EdgeInsets.only(top: 10),
child: ButtonTheme(
minWidth: double.infinity,
height: 50,
child: RaisedButton(
color: buttonColor ?? Theme.of(context).primaryColor,
onPressed: onPressed,
child: Text(labelText, style: TextStyle(color: Colors.white)),
),
),
);
}