BLoCとSQLite
Flutter開発環境構築の後、何から始めようかと考えた結果、ローカルDBでデータを保持できるようにしようと思ったので、BLoCを使ってDatabaseとのやりとりしようと思い立ったので、まずは簡単なメモアプリを作ってみる。(主流はFirebaseなんだろうけど、できることから一つずつということで…)
BLoCとSQLiteの準備
BLoCおよびSQLiteに必要なライブラリを導入。
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^2.1.1
equatable: ^1.0.0
sqflite: ^1.2.0
path_provider: ^1.5.1
今回DBに登録するメモのオブジェクトを定義。
(オブジェクトはシンプルにIDとメモ内容のみ)
class Memo {
final int id;
final String memo;
Memo({this.id, this.memo})
: assert(memo != null);
factory Memo.fromDatabaseJson(Map<String, dynamic> data) =>
Memo(id: data['id'], memo: data['memo']);
Map<String, dynamic> toDatabaseJson() => {
'id': this.id,
'memo': this.memo,
};
}
メモ一覧のリスト表示機能の実装
ListViewでメモの一覧を画面に表示し、その画面上でメモの作成、登録、削除をする画面を今回のサンプルとする。
メモ一覧画面のイベント定義
メモ一覧画面のイベントを定義。
import 'package:equatable/equatable.dart';
import 'package:flutter/cupertino.dart';
import 'package:bloc_database_sample/models/memo.dart';
abstract class MemoListEvent extends Equatable {
MemoListEvent([List props = const []]) : super();
@override
List<Object> get props => [];
}
class MemoListLoad extends MemoListEvent {
@override
String toString() => 'MemoListLoad';
}
class AddMemo extends MemoListEvent {
final Memo memo;
AddMemo({@required this.memo}) : assert(memo != null);
@override
String toString() => 'AddMemo';
}
class UpdateMemo extends MemoListEvent {
final Memo memo;
UpdateMemo({@required this.memo}) : assert(memo != null);
@override
String toString() => 'UpdateMemo';
}
class DeleteMemo extends MemoListEvent {
final int id;
DeleteMemo({@required this.id}) : assert(id != null);
@override
String toString() => 'DeleteMemo';
}
class DeleteAllMemo extends MemoListEvent {
@override
String toString() => 'DeleteMemo';
}
メモ一覧イベントに対する状態定義
各イベント発生後の結果の状態を定義。
import 'package:equatable/equatable.dart';
import 'package:flutter/cupertino.dart';
import 'package:bloc_database_sample/models/memo.dart';
abstract class MemoListState extends Equatable {
MemoListState([List props = const []]) : super();
@override
List<Object> get props => [];
}
class MemoListEmpty extends MemoListState {
@override
String toString() => 'MemoListEmpty';
}
class MemoListInProgress extends MemoListState {
@override
String toString() => 'MemoListInProgress';
}
class MemoListSuccess extends MemoListState {
final Stream<List<Memo>> memoList;
MemoListSuccess({@required this.memoList})
: assert(memoList != null),
super([memoList]);
@override
String toString() => 'MemoListSuccess';
}
class MemoListFailure extends MemoListState {
final Error error;
MemoListFailure({@required this.error})
: assert(error != null),
super([error]);
@override
String toString() => 'MemoListFailure';
}
BLoCの実装
BLoCクラスには初期状態を表すプロパティと指定されたイベントから新たな状態を作り出すメソッドmapEventToState
を実装する必要がある。
import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';
import 'package:bloc_database_sample/models/memo.dart';
import 'package:bloc_database_sample/blocs/memo_list_event.dart';
import 'package:bloc_database_sample/blocs/memo_list_repository.dart';
import 'package:bloc_database_sample/blocs/memo_list_state.dart';
class MemoListBloc extends Bloc<MemoListEvent, MemoListState> {
final MemoListRepository _memoListRepository;
MemoListBloc({@required MemoListRepository memoListRepository})
: assert(memoListRepository != null),
_memoListRepository = memoListRepository;
@override
MemoListState get initialState => MemoListEmpty();
Stream<MemoListState> mapEventToState(MemoListEvent event) async* {
if (event is MemoListLoad) {
yield* _mapMemoLoadToState();
} else if (event is AddMemo) {
yield* _mapAddMemoToState(event.memo);
} else if (event is UpdateMemo) {
yield* _mapUpdateMemoToState(event.memo);
} else if (event is DeleteMemo) {
yield* _mapDeleteMemoToState(event.id);
} else if (event is DeleteAllMemo) {
yield* _mapDeleteAllMemoToState();
}
}
Stream<MemoListState> _mapMemoLoadToState() async* {
yield MemoListInProgress();
try {
yield MemoListSuccess(memoList: _memoListRepository.getAllMemos());
} catch (_) {
yield MemoListFailure(error: _);
}
}
Stream<MemoListState> _mapAddMemoToState(Memo memo) async* {
yield MemoListInProgress();
try {
yield MemoListSuccess(
memoList: _memoListRepository.insertMemo(memo: memo));
} catch (_) {
yield MemoListFailure(error: _);
}
}
Stream<MemoListState> _mapUpdateMemoToState(Memo memo) async* {
yield MemoListInProgress();
try {
yield MemoListSuccess(
memoList: _memoListRepository.updateMemo(memo: memo));
} catch (_) {
yield MemoListFailure(error: _);
}
}
Stream<MemoListState> _mapDeleteMemoToState(int id) async* {
yield MemoListInProgress();
try {
yield MemoListSuccess(memoList: _memoListRepository.deleteMemoById(id));
} catch (_) {
yield MemoListFailure(error: _);
}
}
Stream<MemoListState> _mapDeleteAllMemoToState() async* {
yield MemoListInProgress();
try {
yield MemoListSuccess(memoList: _memoListRepository.deleteAllMemos());
} catch (_) {
yield MemoListFailure(error: _);
}
}
}
イベントの種類が増えた場合、mapEventToState
に新たなイベントを定義して対応するのがよいそう。
Database Repositoryの定義
BLoCクラスから各イベント状態に応じてコールされるRepositoryを定義。
import 'package:bloc_database_sample/models/memo.dart';
import 'package:bloc_database_sample/dao/memo_dao.dart';
import 'package:bloc_database_sample/blocs/memo_list_repository.dart';
class MemoRepository extends MemoListRepository {
final memoDao = MemoDao();
MemoRepository();
Stream<List<Memo>> getAllMemos({String query}) =>
memoDao.getMemos(query: query);
Stream<List<Memo>> insertMemo({Memo memo}) {
memoDao.createMemo(memo);
return memoDao.getMemos();
}
Stream<List<Memo>> updateMemo({Memo memo}) {
memoDao.updateMemo(memo);
return memoDao.getMemos();
}
Stream<List<Memo>> deleteMemoById(int id) {
memoDao.deleteMemo(id);
return memoDao.getMemos();
}
Stream<List<Memo>> deleteAllMemos() {
memoDao.deleteAllMemos();
return memoDao.getMemos();
}
}
Data Access Objectの定義
Repositoryクラスからコールされる実際にDatabaseにアクセスするクラスを定義。
import 'dart:async';
import 'package:bloc_database_sample/database/database.dart';
import 'package:bloc_database_sample/models/memo.dart';
class MemoDao {
final dbProvider = DatabaseProvider.dbProvider;
Future<int> createMemo(Memo memo) async {
final db = await dbProvider.database;
int result = await db.insert(memoTable, memo.toDatabaseJson());
return result;
}
Stream<List<Memo>> getMemos({List<String> columns, String query}) async* {
final db = await dbProvider.database;
List<Map<String, dynamic>> result;
if (query != null) {
if (query.isNotEmpty) {
result = await db.query(memoTable,
columns: columns, where: 'memo LIKE ?', whereArgs: ['%$query']);
}
} else {
result = await db.query(memoTable, columns: columns);
}
List<Memo> memos = result.isNotEmpty
? result.map((item) => Memo.fromDatabaseJson(item)).toList()
: [];
yield memos;
}
Future<int> updateMemo(Memo memo) async {
final db = await dbProvider.database;
int result = await db.update(memoTable, memo.toDatabaseJson(),
where: 'id = ?', whereArgs: [memo.id]);
return result;
}
Future<int> deleteMemo(int id) async {
final db = await dbProvider.database;
int result = await db.delete(memoTable, where: 'id = ?', whereArgs: [id]);
return result;
}
Stream<int> deleteAllMemos() async* {
final db = await dbProvider.database;
int result = await db.delete(
memoTable,
);
yield result;
}
}
Databaseクラスの定義
データベースの作成、更新を行うクラスを定義
import 'dart:async';
import 'dart:io';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
final memoTable = 'Memo';
class DatabaseProvider {
static final DatabaseProvider dbProvider = DatabaseProvider();
Database _database;
Future<Database> get database async {
if (_database != null) return _database;
_database = await createDatabase();
return _database;
}
createDatabase() async {
Directory documentsDirectory = await getApplicationDocumentsDirectory();
String path;
try {
path = join(documentsDirectory.path, "MemoData.db");
} catch(_) {
print(_.toString());
}
var database = await openDatabase(path,
version: 1, onCreate: initDB, onUpgrade: onUpgrade);
return database;
}
void onUpgrade(Database database, int oldVersion, int newVersion) async{
if (newVersion > oldVersion) {
await database.execute("DROP TABLE $memoTable");
}
}
void initDB(Database database, int version) async {
await database.execute("CREATE TABLE $memoTable ("
"id INTEGER PRIMARY KEY, "
"memo TEXT "
")");
}
}
メモ一覧のUI部分の実装
import 'package:bloc_database_sample/blocs/memo_list_bloc.dart';
import 'package:flutter/material.dart';
import 'package:bloc_database_sample/ui/home_page.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:bloc_database_sample/blocs/memo_list_repository.dart';
import 'package:bloc_database_sample/repository/memo_repository.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter bloc database demo',
theme: ThemeData(
primarySwatch: Colors.indigo, canvasColor: Colors.transparent),
home: BlocProvider<MemoListBloc>(
create: (context) =>
MemoListBloc(memoListRepository: MemoRepository()),
child: HomePage()),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:bloc_database_sample/blocs/memo_list_event.dart';
import 'package:bloc_database_sample/blocs/memo_list_state.dart';
import 'package:bloc_database_sample/blocs/memo_list_bloc.dart';
import 'package:bloc_database_sample/models/memo.dart';
class HomePage extends StatefulWidget {
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
BlocProvider.of<MemoListBloc>(context).add(MemoListLoad());
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: Text('Memo List'),
),
body: SafeArea(
child: Container(
color: Colors.white,
padding:
const EdgeInsets.only(left: 2.0, right: 2.0, bottom: 2.0),
child: Container(child: getMemosWidget(context))),
),
floatingActionButtonLocation: FloatingActionButtonLocation.endTop,
floatingActionButton: Padding(
padding: EdgeInsets.only(bottom: 25),
child: FloatingActionButton(
elevation: 5.0,
onPressed: () {
_showAddMemoSheet(context);
},
backgroundColor: Colors.white,
child: Icon(
Icons.add,
size: 32,
color: Colors.indigoAccent,
),
),
));
}
Widget getMemosWidget(BuildContext context) {
return BlocBuilder<MemoListBloc, MemoListState>(
bloc: BlocProvider.of<MemoListBloc>(context),
builder: (context, state) {
if (state is MemoListInProgress) {
return Center(
child: CircularProgressIndicator(),
);
}
if (state is MemoListSuccess) {
return StreamBuilder(
stream: state.memoList,
builder:
(BuildContext context, AsyncSnapshot<List<Memo>> snapshot) {
if (!snapshot.hasData) {
return Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[Text('Failure')],
),
);
}
return ListView.builder(
itemCount: snapshot.data.length,
itemBuilder: (BuildContext context, int index) {
final memo = snapshot.data[index];
final Widget dismissibleCard = new Dismissible(
background: Container(
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Align(alignment: Alignment.centerLeft,
child: Text("Deleting",
style: TextStyle(color: Colors.white),
),
),
),
color: Colors.redAccent,
),
onDismissed: (direction) {
setState(() {
snapshot.data.removeAt(index);
BlocProvider.of<MemoListBloc>(context).add(DeleteMemo(id: memo.id));
});
},
key: new ObjectKey(memo),
child: Card(
child: Column(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
ListTile(
title: Text(memo.memo,
style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
)
);
return dismissibleCard;
});
},
);
}
if (state is MemoListFailure) {
return Center(
child: Text('Failure'),
);
}
return Container();
},
);
}
void _showAddMemoSheet(BuildContext context) {
final _memoDescriptionFormController = TextEditingController();
showModalBottomSheet(
context: context,
builder: (builder) {
return new Padding(
padding: EdgeInsets.only(
bottom: MediaQuery
.of(context)
.viewInsets
.bottom),
child: new Container(
color: Colors.transparent,
child: new Container(
height: 230,
decoration: new BoxDecoration(
color: Colors.white,
borderRadius: new BorderRadius.only(
topLeft: const Radius.circular(10.0),
topRight: const Radius.circular(10.0))),
child: Padding(
padding: EdgeInsets.only(
left: 15, top: 25.0, right: 15, bottom: 30),
child: ListView(
children: <Widget>[
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Expanded(
child: TextFormField(
controller: _memoDescriptionFormController,
textInputAction: TextInputAction.newline,
maxLines: 4,
style: TextStyle(
fontSize: 21, fontWeight: FontWeight.w400),
autofocus: true,
decoration: const InputDecoration(
hintText: 'take down note...',
labelText: 'New Memo',
labelStyle: TextStyle(
color: Colors.indigoAccent,
fontWeight: FontWeight.w500)),
validator: (String value) {
if (value.isEmpty) {
return 'Empty memo!';
}
return value.contains('')
? 'Do not use the @ char.'
: null;
},
),
),
Padding(
padding: EdgeInsets.only(left: 5, top: 15),
child: CircleAvatar(
backgroundColor: Colors.indigoAccent,
radius: 18,
child: IconButton(
icon: Icon(
Icons.save,
size: 22,
color: Colors.white,
),
onPressed: () {
final newMemo = Memo(
memo: _memoDescriptionFormController
.value.text);
if (newMemo.memo.isNotEmpty) {
BlocProvider.of<MemoListBloc>(context).add(
AddMemo(memo: newMemo));
Navigator.pop(context);
}
},
),
),
)
],
),
],
),
),
),
),
);
});
}
}
BLoCを使うにあたってBLoC Provider
を使うのがよいらしいんだけど、使い方がよくわからない。BLoCで非同期処理を行うにあたってメモリリークの発生を防ぐためにStreamを閉じる必要があるらしいんだけど、参考にした書籍にはそのあたりには言及されてなかった。たまたま書籍のサンプルが今回と同じようにmainのwidgetのbuildメソッド内でBlocProviderを使用しているケースだから特に意識する必要がないということ・・?
[参考書籍]
[参考サイト]