Flutter iOS 音声メモアプリ開発(8) DBの導入 sqflite 

Flutterで永続的データを扱う方法として、localstoreを使っていました。気軽に使えて良かったのですが、機能が増えてくると、localstoreでは面倒になってきたので、思い切ってDatabaseを導入することとしました。

sqfliteはSQLiteをFlutterで使うためのパッケージです。

localstoreを使った記事は以下をご覧ください。

Flutter iOS 音声メモアプリ開発(1)永続的データの保存

Flutterで開発したアプリケーションの公開に挑戦してみたいと思います。 Flutter はAndroidとiOSのどちらも開発可能ではありますが、個人的にiPhoneユーザでありますので…

シングルトンパターン

アプリ全体から一つのDBにアクセスするために、シングルトンパターンを使います。シングルトンパターンとは、ソフトウエアデザインパターンの一つで、あるクラスのインスタンスがアプリケーション内で一つだけ存在することを保証するために使用されます。

シングルトンパターンを適用したクラスは、以下の特徴を持ちます。

  1. クラス内で静的なインスタンスを持つ
  2. コンストラクタがプライベートまたは非公開であるため、外部から新しいインスタンスを作成できない
  3. クラスにアクセスするためのパブリックな静的メソッドが存在し、アプリケーション内のどこからでもそのインスタンスにアクセスできる

コードにすると、以下の通りです。

class Singleton {
  static final Singleton _instance = Singleton._();
  factory Singleton() => _instance;
  Singleton._();

  // シングルトン内で管理されるリソースやプロパティ
}

この実装では、_instanceという静的なインスタンスが作成され、Singleton._()というプライベートなコンストラクタを通じて初期化されます。factory Singleton()コンストラクタは、インスタンスを新たに作成せず、既存の_instanceを返すため、アプリケーション内で一つだけ存在することが保証されます。

以下のように、アプリケーション内の任意の場所からシングルトンクラスにアクセスできます。

Singleton singletonInstance = Singleton();

DBHelperクラス

先ほどのシングルトンパターンを使って、DBHelperクラスを作ります。

事前にsqfliteパッケージと、path_providerパッケージをインストールしておきます。

import 'dart:async';
import 'dart:io' as io;

import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';

class DBHelper {
  static final DBHelper _instance = DBHelper._();
  factory DBHelper() => _instance;
  DBHelper._();

  static Database? _db;

  Future<Database?> get db async {
    if (_db != null) {
      return _db;
    }
    _db = await initDB();
    return _db;
  }

  Future<Database> initDB() async {
    io.Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, "my_database.db");
    var db = await openDatabase(path, version: 1, onCreate: _onCreate);
    return db;
  }

  void _onCreate(Database db, int version) async {
    await db.execute('''
      CREATE TABLE my_todo (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT,
        contents TEXT,
        datetime TEXT,
        sentiment TEXT,
        is_check INTEGER
      )
    ''');
  }
}

次に、データベースにアクセスする基本的な操作を定義しておきます。

 
// my_todoテーブルのすべてのデータをdatetime順にソートしてgetする

  Future<List<Map<String, dynamic>>> getAllTodoItems() async {
    Database? _database = await db;
    List<Map<String, dynamic>> result =
        await _database!.query('my_todo', orderBy: 'datetime');
    return result;
  }

// my_todoテーブルに新しいレコードを追加する

  Future<int> insertTodoItem(Map<String, dynamic> data) async {
    Database? _database = await db;
    int result = await _database!.insert('my_todo', data);
    return result;
  }

// my_todoテーブルの idで示されるレコードを更新する

Future<int> upDateTodoItem(int id, Map<String, dynamic> data) async {
    Database? _database = await db;
    int result = await _database!
        .update('my_todo', data, where: 'id = ?', whereArgs: [id]);
    return result;
  }

// my_todoテーブルの idで示されるレコードを削除する

  Future<int> deleteTodoItem(int id) async {
    Database? _database = await db;
    int result =
        await _database!.delete('my_todo', where: 'id = ?', whereArgs: [id]);
    return result;
  }

データベースの表示

localstoreと同様にデータベースの内容をListViewで表示してみます。基本的な構造は前回とほぼ同等です。DBの使い方を試すことが目的なので、一部簡素化しています。

import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'db_helper.dart';
import 'todo.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Todo List',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: TodoList(),
    );
  }
}

class TodoList extends StatefulWidget {
  @override
  _TodoListState createState() => _TodoListState();
}

class _TodoListState extends State<TodoList> {
  late List<Todo> todoItems;

  DBHelper dbHelper = DBHelper();   //  DB Helperの呼び出し

  @override
  void initState() {
    super.initState();
    todoItems = [];
    fetchTodoItems();
  }

    // DBが変更されるたびに、fetchTodoItemsを呼び出してlistを更新する。
  fetchTodoItems() async {
    DBHelper dbHelper = DBHelper();
    List<Map<String, dynamic>> result = await dbHelper.getAllTodoItems();
    setState(() {
      todoItems = result.map((map) => Todo.fromMap(map)).toList();
    });
  }

    // idを指定して、DBからitemを削除する関数
  void _deleteTodoItem(int id) async {
    DBHelper dbHelper = DBHelper();
    await dbHelper.deleteTodoItem(id);
    fetchTodoItems();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Todo List'),
      ),
      body: ListView.builder(
        itemCount: todoItems.length,
        itemBuilder: (context, index) {
          Todo todo = todoItems[index];

          return Card(
            child: ListTile(
              title: Text(todo.title),
              subtitle: Text(todo.contents),
              leading: IconButton(
                  icon: const Icon(Icons.delete),
                  onPressed: () {
                    _deleteTodoItem(todo.id!);
                  }),
              trailing: Text(todo.sentiment),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          await Navigator.push(
            context,
            MaterialPageRoute(builder: (context) => AddTodoItem()), 
          );
          fetchTodoItems();
        },
        child: Icon(Icons.add),
      ),
    );
  }
}

// Item追加の画面
class AddTodoItem extends StatefulWidget {
  @override
  _AddTodoItemState createState() => _AddTodoItemState();
}

class _AddTodoItemState extends State<AddTodoItem> {
  final _formKey = GlobalKey<FormState>();
  String _title = '';
  String _contents = '';
  DateTime _datetime = DateTime.now();
  String _sentiment = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Add Todo Item'),
      ),
      body: SingleChildScrollView(
        child: Form(
          key: _formKey,
          child: Padding(
            padding: EdgeInsets.all(16.0),
            child: Column(
              children: [
                TextFormField(
                  decoration: InputDecoration(labelText: 'Title'),
                  validator: (value) {
                    if (value == null || value.isEmpty) {
                      return 'Please enter a title';
                    }
                    return null;
                  },
                  onSaved: (value) => _title = value!,
                ),
                TextFormField(
                  decoration: InputDecoration(labelText: 'Contents'),
                  validator: (value) {
                    if (value == null || value.isEmpty) {
                      return 'Please enter contents';
                    }
                    return null;
                  },
                  onSaved: (value) => _contents = value!,
                ),
                TextFormField(
                  decoration: InputDecoration(labelText: 'Sentiment'),
                  validator: (value) {
                    if (value == null || value.isEmpty) {
                      return 'Please enter a sentiment';
                    }
                    return null;
                  },
                  onSaved: (value) => _sentiment = value!,
                ),
                SizedBox(height: 16.0),
                ElevatedButton(
                  onPressed: () async {
                    if (_formKey.currentState!.validate()) {
                      _formKey.currentState!.save();
                      DBHelper dbHelper = DBHelper();     // DBインスタンスへのアクセス
                      Todo newTodoItem = Todo(
                        title: _title,
                        contents: _contents,
                        datetime: _datetime,
                        sentiment: _sentiment,
                        is_check: false,
                      );
                      await dbHelper.insertTodoItem(newTodoItem.toMap()); //  DBへのインサート
                      Navigator.pop(context);
                    }
                  },
                  child: Text('Add Todo Item'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Todoクラスほぼ同じ構造です。

class Todo {
  int? id;
  String title;
  String contents;
  DateTime datetime;
  String sentiment;
  bool is_check;

  Todo({
    this.id,
    required this.title,
    required this.contents,
    required this.datetime,
    required this.sentiment,
    required this.is_check,
  });

  // Convert Todo object to Map
  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'title': title,
      'contents': contents,
      'datetime': datetime.toIso8601String(),
      'sentiment': sentiment,
      'is_check': is_check ? 1 : 0,
    };
  }

  // Convert Map to Todo object
  factory Todo.fromMap(Map<String, dynamic> map) {
    return Todo(
      id: map['id'],
      title: map['title'],
      contents: map['contents'],
      datetime: DateTime.parse(map['datetime']),
      sentiment: map['sentiment'],
      is_check: map['is_check'] == 1,
    );
  }
}

動作させた結果は、以下の通りです。

実際にやってみると、localstoreとさほど変わりはなく、思ったより、シンプルに実装できました。

これで、基本的なSQLが使えるようになったので、新しい機能を追加していきたいと思います。