はじめに
アプリ開発をするにあたって、ローカルへのデータ保存は必須と言えます。
公式サイトにあった SQLite を利用した永続化を試してみます。
手順には従いつつ、データモデルの中身など、一部アレンジしています。
sqflite を利用しています。
iOS/Android/macOS でしか動きません。
macOS で動かす場合は cocoapods
のインストールが必要です。
brew install cocoapods
手順
dependencies の追加
以下のコマンドを実行して追加します。
flutter pub add sqflite path
以下のimportを追加してみて、エラーにならないことを確認します。
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
利用してないので、警告が出ますが問題なし。
データモデルの定義
DVD や Blu-ray 、デジタル など、持っているソフトの管理をイメージして、Mediaモデルを作ってみます。
(簡易的なモデルですが、練習用なのでご了承ください)
// ./lib/models/media.dart
enum MediaType {
dvd,
bluray,
digital,
}
class Media {
final int id;
final MediaType type;
final String title;
final DateTime releasedAt;
final DateTime addedAt;
const Media({
required this.id,
required this.type,
required this.title,
required this.releasedAt,
required this.addedAt,
});
}
データベースを開く
main を async にして、データベースを開く処理を追加する。
- void main() {
+ void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ // データベースを開く
+ final database = openDatabase(
+ join(await getDatabasesPath(), 'media_manager_database.db'),
+ );
runApp(MyApp());
}
一度データベースの中身を見てみます。
macOS で起動した場合、以下のようなパスにファイルが保存されていました。
(getDatabasesPath().then((val) => print(val));
でパスを確認しました)
~/Library/Containers/com.example.mediaManagerApp/Data/Documents/media_manager_database.db
media_manager_database.db
をDBブラウザで開くと、無事にテーブルが追加されていることが分かります。
使用したソフトはこちら
データの投入
モデルを修正して、DB投入用のMap生成を追加。
// ./lib/models/media.dart
enum MediaType {
dvd("DVD"),
bluray("Blu-ray"),
digital("Digital"),
;
+ @override
+ String toString() => displayName;
+
+ final String displayName;
+
+ const MediaType(this.displayName);
}
class Media {
final int id;
final MediaType type;
final String title;
final DateTime releasedAt;
final DateTime addedAt;
const Media({
required this.id,
required this.type,
required this.title,
required this.releasedAt,
required this.addedAt,
});
+ // DB投入用にtoMapを追加
+ Map<String, Object?> toMap() {
+ return {
+ 'id': id,
+ 'type': type.toString(),
+ 'title': title,
+ 'released_at': releasedAt.toUtc().toIso8601String(),
+ 'added_at': addedAt.toUtc().toIso8601String(),
+ };
+ }
+
+ // 情報確認用にtoStringも実装
+ @override
+ String toString() {
+ return 'Media{id: $id, type: $type, title: $title, releasedAt: $releasedAt, addedAt: $addedAt}';
+ }
}
DBへの投入関数を追加
// ./lib/models/main.dart
// media テーブルに insert する
Future<void> insertMedia(Media media) async {
// Get a reference to the database.
final db = await database;
// Conflict が発生した場合、置き換える
await db.insert(
'media',
media.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
insertMedia の呼び出しを追加
// ./lib/models/main.dart
// media テーブルへ insert するデータ
var media = Media(
id: 0,
type: MediaType.dvd,
title: 'Flutterの冒険',
releasedAt: DateTime(1999, 3, 6),
addedAt: DateTime.now(),
);
insertMedia(media);
ちゃんと insert 出来てますね
データの取得
全件取得を追加します。
まず、MediaType にDBから取得したデータ(type)を MediaType に変換するための処理を追加しておきます。
// ./lib/models/main.dart
enum MediaType {
dvd("DVD"),
bluray("Blu-ray"),
digital("Digital"),
;
@override
String toString() => displayName;
final String displayName;
const MediaType(this.displayName);
+ static MediaType from(String displayName) {
+ return MediaType.values.firstWhere(
+ (type) => type.displayName == displayName,
+ orElse: () => throw ArgumentError('Invalid MediaType: $displayName'),
+ );
+ }
}
// ...
取得処理を追加
// ./lib/models/main.dart
// media テーブルから全データを取得する
Future<List<Media>> listMedia() async {
// Get a reference to the database.
final db = await database;
// media から全件取得するクエリ.
final List<Map<String, Object?>> maps = await db.query('media');
// 取得したデータをMediaに変換する
return maps
.map((v) => Media(
id: v['id'] as int,
type: MediaType.from(v['type'] as String),
title: v['title'] as String,
releasedAt: DateTime.parse(v['released_at'] as String),
addedAt: DateTime.parse(v['added_at'] as String),
))
.toList();
}
listMedia の呼び出しを追加
// ./lib/models/main.dart
listMedia().then((v) => {v.forEach(print)});
あらかじめデータを2件に増やして実行してみます。
出力結果
flutter: Media{id: 0, type: DVD, title: Flutterの冒険, releasedAt: 1999-03-05 15:00:00.000Z, addedAt: 2024-12-18 11:08:21.595752Z}
flutter: Media{id: 1, type: DVD, title: Flutterの冒険2, releasedAt: 2000-05-08 15:00:00.000Z, addedAt: 2024-12-18 12:59:00.913387Z}
正しく取得できました。
データの更新
更新処理を追加
// ./lib/models/main.dart
Future<void> updateMedia(Media media) async {
// Get a reference to the database.
final db = await database;
// 引数のmediaで更新する
await db.update(
'media',
media.toMap(),
// IDで更新対象を特定する
where: 'id = ?',
whereArgs: [media.id],
);
}
updateMedia の呼び出しを追加
var media = Media(
id: 1,
type: MediaType.bluray,
title: 'Flutterの冒険3',
releasedAt: DateTime(2012, 2, 6),
addedAt: DateTime.now(),
);
updateMedia(media);
更新前後でデータ取得を呼び出して diff を取ってみます。
正しく更新されています。
データの削除
やっとラスト。削除です。
削除処理を追加。
// ./lib/models/main.dart
Future<void> deleteMedia(int id) async {
// Get a reference to the database.
final db = await database;
// 引数idのmediaを削除する
await db.delete(
'media',
// IDで削除対象を特定する
where: 'id = ?',
whereArgs: [id],
);
}
deleteMedia の呼び出しを追加
(idを指定して呼び出すだけ)
// ./lib/models/main.dart
deleteMedia(1);
削除前後でデータ取得を呼び出して diff を取ってみます。
削除されました。
少しアレンジ
CUI だと味気ないので、GUI で確認できるようにしてみました。
コードを載せておきます。
./lib/models/media.dart
enum MediaType {
dvd("DVD"),
bluray("Blu-ray"),
digital("Digital"),
;
@override
String toString() => displayName;
final String displayName;
const MediaType(this.displayName);
static MediaType from(String displayName) {
return MediaType.values.firstWhere(
(type) => type.displayName == displayName,
orElse: () => throw ArgumentError('Invalid MediaType: $displayName'),
);
}
}
class Media {
final int id;
final MediaType type;
final String title;
final DateTime releasedAt;
final DateTime addedAt;
const Media({
required this.id,
required this.type,
required this.title,
required this.releasedAt,
required this.addedAt,
});
// DB投入用にtoMapを追加
Map<String, Object?> toMap() {
return {
'id': id,
'type': type.toString(),
'title': title,
'released_at': releasedAt.toUtc().toIso8601String(),
'added_at': addedAt.toUtc().toIso8601String(),
};
}
// 情報確認用にtoStringも実装
@override
String toString() {
return 'Media{id: $id, type: $type, title: $title, releasedAt: $releasedAt, addedAt: $addedAt}';
}
}
./main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:media_manager_app/models/media.dart';
import 'dart:async';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
late final Future<Database> database;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// データベースを開く
database = openDatabase(
// Set the path to the database. Note: Using the `join` function from the
// `path` package is best practice to ensure the path is correctly
// constructed for each platform.
join(await getDatabasesPath(), 'media_manager_database.db'),
// When the database is first created, create a table to store dogs.
onCreate: (db, version) {
// Run the CREATE TABLE statement on the database.
return db.execute(
'CREATE TABLE media(id INTEGER PRIMARY KEY, type TEXT, title TEXT, released_at DATE, added_at DATE)',
);
},
// Set the version. This executes the onCreate function and provides a
// path to perform database upgrades and downgrades.
version: 1,
);
runApp(MyApp());
}
// media テーブルに insert する
Future<void> insertMedia(Media media) async {
// Get a reference to the database.
final db = await database;
// Conflict が発生した場合、置き換える
await db.insert(
'media',
media.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// media テーブルから全データを取得する
Future<List<Media>> listMedia() async {
// Get a reference to the database.
final db = await database;
// media からrefetchMyMediazぜんけ取得するクエリ.
final List<Map<String, Object?>> maps = await db.query('media');
// 取得したデータをMediaに変換する
return maps
.map((v) => Media(
id: v['id'] as int,
type: MediaType.from(v['type'] as String),
title: v['title'] as String,
releasedAt: DateTime.parse(v['released_at'] as String),
addedAt: DateTime.parse(v['added_at'] as String),
))
.toList();
}
Future<void> updateMedia(Media media) async {
// Get a reference to the database.
final db = await database;
// 引数のmediaで更新する
await db.update(
'media',
media.toMap(),
// IDで更新対象を特定する
where: 'id = ?',
whereArgs: [media.id],
);
}
Future<void> deleteMedia(int id) async {
// Get a reference to the database.
final db = await database;
// 引数idのmediaを削除する
await db.delete(
'media',
// IDで削除対象を特定する
where: 'id = ?',
whereArgs: [id],
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MyAppState(),
child: MaterialApp(
title: 'Media Manager App',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
),
home: MyHomePage(),
),
);
}
}
class MyAppState extends ChangeNotifier {
var mediaList = <Media>[];
void refetchMyMedia() async {
listMedia().then((v) => mediaList = v);
notifyListeners();
}
void clearMediaList() {
mediaList.clear();
notifyListeners();
}
}
class MyHomePage extends StatefulWidget {
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
var selectedIndex = 0;
@override
Widget build(BuildContext context) {
Widget page;
switch (selectedIndex) {
case 0:
page = GeneratorPage();
break;
case 1:
page = MyMediaPage();
break;
default:
throw UnimplementedError('no widget for $selectedIndex');
}
return LayoutBuilder(builder: (context, constraints) {
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: constraints.maxWidth >= 600,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('My Media'),
),
],
selectedIndex: selectedIndex,
onDestinationSelected: (value) {
setState(() {
selectedIndex = value;
});
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: page,
),
),
],
),
);
});
}
}
class GeneratorPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
// media テーブルへ insert するデータ
var media = Media(
id: 0,
type: MediaType.dvd,
title: 'Flutterの冒険',
releasedAt: DateTime(1999, 3, 6),
addedAt: DateTime.now(),
);
insertMedia(media);
appState.refetchMyMedia();
},
child: Text('Insert Flutterの冒険'),
),
IconButton(
onPressed: () {
// 0 のデータを削除
deleteMedia(0);
appState.refetchMyMedia();
},
icon: Icon(Icons.delete),
),
],
),
SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
var media = Media(
id: 1,
type: MediaType.dvd,
title: 'Flutterの冒険2',
releasedAt: DateTime(2000, 5, 9),
addedAt: DateTime.now(),
);
insertMedia(media);
appState.refetchMyMedia();
},
child: Text('Insert Flutterの冒険2'),
),
IconButton(
onPressed: () {
// 1 のデータを削除
deleteMedia(1);
appState.refetchMyMedia();
},
icon: Icon(Icons.delete),
),
],
),
SizedBox(height: 10),
ElevatedButton(
onPressed: () {
// updateするデータ
var media = Media(
id: 1,
type: MediaType.bluray,
title: 'Flutterの冒険3',
releasedAt: DateTime(2012, 2, 6),
addedAt: DateTime.now(),
);
updateMedia(media);
appState.refetchMyMedia();
},
child: Text('Update Flutterの冒険2 title'),
),
],
),
);
}
}
class MyMediaPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
if (appState.mediaList.isEmpty) {
return Center(
child: Text('No MyMedia yet.'),
);
}
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(20),
child: Text('You have '
'${appState.mediaList.length} mediaList:'),
),
...appState.mediaList.map((v) => ListTile(
leading: Icon(Icons.favorite),
title: Text(v.title),
subtitle: Text('''
ID: ${v.id}
MediaType: ${v.type.displayName}
ReleaseDate: ${v.releasedAt.toLocal().toIso8601String()}
AddedDate: ${v.addedAt.toLocal().toIso8601String()}
'''),
))
],
);
}
}
コードが冗長だったり、例外処理をあまり入れてなかったりしますが、あくまで確認用コードということで、ご容赦ください。
最後に
今回はデータの永続化(CRUD) をやってみました。
これができると、アプリが作れそうな気になってきますね(笑)
まだまだ、書き方がいけてなかったりすると思うので勉強続けていきたいと思います。
ご拝読ありがとうございました。