実践Flutter

Flutterでスマホを中心にアプリ制作していきます。

Flutterアプリのデータ保存1・Hiveでお手軽にデータ永続化

はじめに

本記事ではFlutterにおけるデータの永続化のポイントを説明していきます。

永続化というのは、端末にデータを記録して、アプリを終了したり端末の電源を切ったりしてもデータが存続するようにすることです。ゲームでいうところのセーブですね。

ここで永続化のためのパッケージとしてHiveを使用します。GetXと同様、Hive採用の一番の理由は使い方が簡単なところです。しかもほとんどのスマホアプリケーションで十分な機能を有していると思います。

永続化のためのデータベースというとSQL系が定番で、SQLiteの実装クローンを使うことが一般的かも知れませんが、ちょっとデータを永続化したいくらいで準備がたくさんあって大変、というのが個人の感想です。

準備がながければ、その分初心者としてハマる罠も多く、まずは短いほうがいいですよね。アプリ開発が自在にできてから、複雑なパッケージにも必要に応じて手を出せば良いと思います。ただし当分Hiveで十分ではないかと思います。





Hiveとは

Hiveとはいわゆるキー・バリュー・ストア(KVS)型データベースです。KVSとはキー(鍵)とバリュー(値)の組を保存する仕組みをいいます。

SQL系に代表されるリレーショナル・データベースは「テーブル」という単位で、例えるならエクセルの表のようなものでデータの集まりを表現します。これは強力な表現力を有する一方で、取り扱いが複雑になる部分もあります。

KVSは複雑な関係性を扱うのには向いていませんが、特に追記型のデータを単純に蓄積していくことに向いていて発達しました。基本的にはある値に対して鍵を設定してデータを蓄積するのみです。その鍵を指定すると、対応する値が取り出せます


f:id:linkedsort:20211031121902p:plain:w400

上図のようにキーに対して値がくっついている形です。実際にはキーは図のように単純なリストではなく、高速に検索するためツリー構造、あるいはハッシュテーブルとして実装されています。

ここで部分的にコードで使い方の雰囲気を示します。例えばキーを「prius」値を「hybrid」とする組み合わせをbox(後ほど説明)に蓄積しようとするときは:

box.put("prius", "hybrid");

このようなコードで実現できます。値をこのboxから取り出すときは:

box.get("prius");

これだけでOKです。要するにMap(写像)と同じですよね。

ただ値の部分について、実際のプログラムでは自分で定義したクラスを直接扱いたいのが普通です。これがやりやすいように工夫されているのがHiveライブラリです。


HiveはKVSとしてもキーに対するクエリとして有用なものはほとんど用意されておらず、キーを工夫してDBとしてうまく使うという用途は想定されていません。一括でデータを取り出して、オンメモリで必要なデータを探します。

なのでシンプルに純粋な永続化ライブラリとして捉えると良いと思います。そしてキーを工夫することによるメリットがあまりないので、基本の仕組みがわかった後は、もうキーは自動で割り付けられるものに任せれば十分かと思います。


基本的な使い方

Hiveを使う下準備としてpubspec.yamlに必要な依存関係を追加します。下記のドキュメントにならって依存関係を追記すればOKです。


docs.hivedb.dev

コマンドラインで依存性追記を行う場合は、プロジェクトのフォルダでターミナルを開いて(IDEにターミナルを開くメニューがあります。Android Studioであればデフォルトレイアウトだと左下の「Terminal」)、下記コマンドを打ちます:

flutter pub add hive
flutter pub add hive_flutter 
flutter pub add hive_generator
flutter pub add build_runner

そうすると下記のようにYAMLファイルに以下に示す4行が追加されます(実行するタイミングでバージョンが変わっていきます):

# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2
  hive: ^2.0.4
  hive_flutter: ^1.1.0
  hive_generator: ^1.1.1
  build_runner: ^2.1.4

コマンド打つの面倒くさいよ、という場合はYAMLファイルのdependenciesに最後の4行をコピペするだけでOKです。ただその時々で旬のバージョンが変わりますので、公式ドキュメントで推奨バージョンをチェックするのがおすすめです。コマンドラインでやると推奨バージョンが勝手に入ります。

公式ドキュメントでは下の2行はYAMLファイルのdev_dependenciesの方に入れていますね。ターミナルで「flutter pub add~」でやると上記のように単にdependenciesに入ります。実際hive_generatorとbuild_runnerは後述するTypeAdapterを自動生成するための開発補助ツールですのでdev_dependenciesの方が可読性を考えると正しいかもしれません。ただ、どちらでも問題なく機能します。4行まとめておくとコピペが楽ですよね。

依存性を記述したら最後に「flutter pub get」してパッケージを読み込んでおきます。これをお忘れなく。



次にサンプルコードを示します。まずは文字列のキーと文字列の値の対応を永続化するだけの例で最小限のパートを見ていきます:

import 'package:flutter/material.dart';
//(1) hive_flutterパッケージをインポート
import 'package:hive_flutter/hive_flutter.dart';

const boxName = "aBox";

void main() async {
  //(2) Hiveの初期化
  await Hive.initFlutter();

  //(3) 使用するボックスをオープン
  await Hive.openBox(boxName);

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return  MaterialApp(
      debugShowCheckedModeBanner: false,
      initialRoute: '/',
      home:  Home(),
    );
  }
}

class Home extends StatelessWidget {
  Home({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //(4) ボックスを取得
    var box = Hive.box(boxName);

    //(5) 値を記録
    box.put("Prius", "Hybrid");
    box.put("Yaris", "Normal");

    //(6) 値を取得
    String val = box.get("Prius", defaultValue: "No info");

    return Scaffold(
      appBar: AppBar(
        title: const Text("ホーム"),
      ),
      body: Center(
        child: Text("Prius engine is $val")
      )
    );
  }
}

スクリーンショットは省略します。画面中央に「Prius engine is Hybrid」と表示されます。

コードのポイント

(1) パッケージのインポート
//(1) hive_flutterパッケージをインポート
import 'package:hive_flutter/hive_flutter.dart';

HiveをFlutterで使うためにパッケージをインポートします。
ここで公式のドキュメントは不親切だと思うのですが、importはhive/hive.dartではなくhive_flutterの方です。

公式のQuick Startではhive/hive.dartをインポートしていますが、公式のサンプルはFlutterでの利用ではないからですので注意して下さい。

(2) Hiveの初期化
  //(2) Hiveの初期化
  await Hive.initFlutter();

Hiveを初期化します。Flutterで使う場合はHive.initFlutter()、FlutterでなければHive.init()です。
ここで上記のhive_flutter.dartをインポートしていないとHive.initFlutter()ができないのですよね。
公式ドキュメントから入った人はいきなり挫折しかねないポイントなので注意です。親切にしてほしいところですよね。

(3) ボックスのオープン
  //(3) 使用するボックスをオープン
  await Hive.openBox(boxName);

ボックスはデータの入れ物です。引数で与えている名前はファイル名のようなものだと考えてOKです。複数個オープンして使い分けることが可能です。型を指定してオープンすることも可能で、その型のデータを出し入れできるようになります。

型と用途で分けてボックスを用意することが多いかと思います。

時間がかかる処理ですのでasync / awaitを使います。

(4) 一度オープンしたボックスを取得
    //(4) ボックスを取得
    var box = Hive.box(boxName);

一度Hive.openBoxでオープンしたボックスはHive.boxで取得可能です。今度は瞬時に行える処理ですのでasync / awaitの必要はありません

最初の初期化処理でHive.openBoxしておいて、ウィジェットでの処理ではHive.boxで取得して使うということで当面はOKです。

よほど大きなデータを扱ってオンメモリに一括展開するとまずい場合は必要に応じて対応することを考えます。古いデータは古いデータ用のボックスに退避させて、必要なとき以外はオープンしない、などですね。

(5) 値の記録
    //(5) 値を記録
    box.put("Prius", "Hybrid");
    box.put("Yaris", "Normal");

キーとバリューの対を記録します。写像(Map)と同じ記法です。

box.putAll({"Prius":"Hybrid", "Yaris":"Normal"});

という書き方もOKです。

キーは255文字までのアスキー文字列、あるいは符号なし32ビット整数という制約があるので注意してください。ただ上にも書きましたがキーを使ったクエリを書けないので、実際上キーは自動的に付けられるものに任せていい場面がほとんどかと思います。

(6) 値の取得
    //(6) 値を取得
    String val = box.get("Prius", defaultValue: "No info");

box内の値はget(キー)で取得可能です。キーに対応する値がないとnullになりますが、defaultValueを設定することで対応する値が存在しない場合にgetが返す値を指定できます。



自前のクラスをストアする

上記例のように単純に一つの値だけの永続化であればShared Preferencesを使えばよいわけですが、ちょっとまとまったデータになるとそれではきつくなります。

しかしSQLなんて面倒くさいよっていう方にぴったりなのがHiveです。自作クラスを簡単ポンで永続化できますからね。まさに丁度いい感じの狙い所です。

ということで次に、自分で作ったクラスを読み書きしていきます。

ここで永続化したいクラスのコードを下記に示します。こちらのコードはいつもの「lib/main.dart」ではなくお隣に新規ファイルを作って「lib/car.dat」として保存するものとします:

//(1) インポート・パート
import 'package:hive/hive.dart';
part 'car.g.dart';

//(2) Hiveタイプアノテーション
@HiveType(typeId : 0)
class Car {
  //(3) Hiveフィールドアノテーション
  @HiveField(0)
  String name;

  @HiveField(1)
  String engine;

  Car(this.name, this.engine);

  @override
  String toString(){
    return name+" "+engine;
  }
}

コードのポイント

サンプルコードのポイントを解説していきます。

(1) インポートとパート
//(1) インポート・パート
import 'package:hive/hive.dart';
part 'car.g.dart';

Hiveのライブラリを使いますのでhive.dartをインポートします。

その次のpart文はあまり見かけないと思いますが、ファイルの一部を外に切り出ているということを意味しています。

後ほどcar.dartのタイプアダプタをジェネレータで生成します。するとcar.g.dartというファイル名になります。このファイルの内容を取り込むということを宣言しているわけです。タイプアダプタとは、自前で作ったタイプをHiveで扱える形にする(具体的にはバイト列にシリアライズ・デシリアライズする)ためのものです。後ほどアダプタを生成した後「car.g.dart」を示しますので、ざっと見てみて下さい。簡単な仕組みです。

この時点でまだcar.g.dartは生成されていませんので、エラーの波線がIDE上に表示されて若干気持ち悪いところですが、ここは無視して次に進みます

(2) HiveTypeのアノテーションをつける
//(2) Hiveタイプアノテーション
@HiveType(typeId : 0)
class Car {

タイプアダプタを生成するため、クラス定義のところにHiveTypeアノテーションを付ける必要があります。

ここでtypeIdの付け方に注意が必要です。番号を他のクラスと重ならないように手動で付番していく必要があります。

しかも更に注意したいのは「typeIdは0から233までの整数」のみが使用可能です。234番以降はintなどのプリミティブ型や予約番号として排除されています。上限が233とそこそこ少ないので注意です。

233というとそんなに使わないよと思われるかも知れませんが、例えばクラスの整理のために系統ごとに10飛ばしでIdを降っていって、などとするとあっという間に上限を超えますよね。


余談です。

公式ドキュメントの最初に示されるサンプルコードのtypeIdが1になっている影響か、様々なブログで書かれるサンプルコードのtypeIdが1から始まっていますが、0オリジンで大丈夫です。次に出てくるフィールドのIDを0から始めるのにtypeIdは1から始めると気持ち悪くないですか。しかも値域が234個しかないんですよ。

また、この時点でHiveObjectを継承していないことに注意してください。HiveObjectを継承すると更に便利になりますが、必須ではなく、HiveObjectの役割を明示するためにここでは使っていません。HiveObject版については後述します。結局使うんですけどもね。

(3) HiveFieldのアノテーションをつける
  //(3) Hiveフィールドアノテーション
  @HiveField(0)
  String name;

  @HiveField(1)
  String engine;

各フィールドにも番号を手動で付番していきます。フィールド番号は0から255の範囲です。

インスタンスのデータをバイナリ列に変換(シリアライズ)するときに何番のタイプの何番のフィールドのデータがここからはじまる、というような形式で数列化されて永続化されます。そのときに使う情報です。

なのでクラスを改変するとき、一度書き出したデータについて、HiveFieldの番号やHiveTypeのTypeIdを変えてはいけません。クラス定義をアプリ運用中にアップデートする際の注意は後ほど述べます。

新しいフィールドについては番号を足していく。削除したフィールドの番号を再利用しない、というのが原則だと覚えておけばOKです。

タイプジェネレータの生成

Hiveに記録するクラスの定義を終えたところで、ターミナルを起動して

flutter packages pub run build_runner build

というコマンドを打ちます。呪文みたいですね。もう少し短くならなかったのでしょうか…

それはさておき、コマンドを実行すると先程のpart文で書いていた「cart.g.dart」が生成されます

以下がその内容です。サラッと見ていきましょう:

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'car.dart';

// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************

class CarAdapter extends TypeAdapter<Car> {
  @override
  final int typeId = 0;

  @override
  Car read(BinaryReader reader) {
    final numOfFields = reader.readByte();
    final fields = <int, dynamic>{
      for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
    };
    return Car(
      fields[0] as String,
      fields[1] as String,
    );
  }

  @override
  void write(BinaryWriter writer, Car obj) {
    writer
      ..writeByte(2)
      ..writeByte(0)
      ..write(obj.name)
      ..writeByte(1)
      ..write(obj.engine);
  }

  @override
  int get hashCode => typeId.hashCode;

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is CarAdapter &&
          runtimeType == other.runtimeType &&
          typeId == other.typeId;
}

このコードは自動生成コードですので参考まで、ですが非常にスッキリしていて勉強になります。

実行時にしか決まらない型としてのdynamic型の使い所、コレクションforでマップを作っているところなど、すっきりしていて参考になります。

クラスを更新してフィールドを追加した場合などは、そのたびに冒頭のコマンドラインでタイプジェネレータクラスを更新する必要があります。

クラスのアップデート対応

アプリをアップデートする際、クラスを更新して新しい情報を付加することはよくあると思います。このときは下記ルールに従う必要があります:

  • フィールドの番号を変えない
  • フィールドの名前やpublic/privateの変更はフィールド番号を変えない限り影響なし
  • フィールドを削除する場合、そのフィールド番号を再利用しない
  • フィールドの型の変更はできない

要するにフィールドの番号を変えてはいけないということですね。タイプアダプタの生成コードを見ても分かると思いますが、Hiveのデータのなかではフィールドの名前は無視されていて番号だけで管理されています。

フィールドの型は変更できないため、そうしたい場合は新しいフィールド番号を付番したフィールドを作って移行する必要があります。

このルールに従っている場合、下記のことが成り立ちます:

  • 新しいフィールドを追加しても、古いアダプタで書き込まれたデータを読み込むことは可能
  • 新しいアダプタで書き込まれたデータを古いアダプタで読み込むことは可能

ということです。古いアダプタには存在していない新しいフィールドはすべて無視されるのみです。なのでフィールド番号をキープしている限り、クラス定義とアダプタをアップデートしても、データが壊れてしまう、読めなくなるということはありません。

データを読み書きするサンプル

では上で挙げたCarクラスとそのタイプアダプタが生成されている状況のもと、Carクラスのデータを永続化する、読み込むサンプルを示します:

import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'car.dart';

const carBoxName = "carBox";

void main() async {
  await Hive.initFlutter();
  //(1) タイプアダプタを登録
  Hive.registerAdapter(CarAdapter());
  //(2) 型指定してボックスをオープン
  await Hive.openBox<Car>(carBoxName);

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      initialRoute: '/',
      home: Home(),
    );
  }
}

class Home extends StatelessWidget {
  const Home({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //(3) 型指定でboxを取得
    var box = Hive.box<Car>(carBoxName);

    //(4) 値を記録
    box.put("car1", Car("Prius", "Hybrid"));
    box.put("car2", Car("Yaris", "Normal"));

    //(5) 値を取得
    Car? val = box.get("car1", defaultValue: Car("No name", "No engine"));

    return Scaffold(
        appBar: AppBar(
          title: const Text("ホーム"),
        ),
        body: Center(child: Text("The car is ${val.toString()}")));
  }
}

コードのポイント

(1)(2) Hiveの初期化
void main() async {
  await Hive.initFlutter();
  //(1) タイプアダプタを登録
  Hive.registerAdapter(CarAdapter());
  //(2) 型指定してボックスをオープン
  await Hive.openBox<Car>(carBoxName);

  runApp(const MyApp());
}

Hiveの初期化をしていきます。Hive.initFlutter()して、タイプアダプタを登録し、ボックスをオープンします。

タイプアダプタはCar型のアダプタを生成したのでCarAdapterになっています。

そしてその後、Hive.openBoxするのですが、Carクラスに限定ということで型を指定しています。

ここで注意なのですが、タイプアダプタを登録するのはopenBoxの前です。openBoxという響きのニュアンスからこちらが前のような直感があるかもしれませんが、ここは注意です。openするためにタイプアダプタを使うので、順番を間違ってはいけません。

(3) 型指定でボックス取得
    //(3) 型指定でboxを取得
    var box = Hive.box<Car>(carBoxName);

ボックスを使用する際、こちらも型を指定します。

(4) Carクラスの値を記録
    //(4) 値を記録
    box.put("car1", Car("Prius", "Hybrid"));
    box.put("car2", Car("Yaris", "Normal"));

値の取得は、前出のものと同様です。キーと対になる値を組みにしてputします。まとめてputAllも可能です。

(5) 値の取得
    //(5) 値を取得
    Car? val = box.get("car1", defaultValue: Car("No name", "No engine"));

値の取得も前出と同様、getです。getはキーに対応する値が存在しないときはnullを返すので、ここではCar?型を返すと決まっています。ですのでCar?と宣言しています。

とはいえdefaultValueを指定していますので実際にはnullになりえません。となると下の書き方のほうがスッキリするかもしれません:

   Car val = box.get("car1") ?? Car("No name", "No engine");

パラメタ指定をごちゃごちゃするのも嫌ですので、こちらのほうがよいかもですね。





おわりに

今回はHiveパッケージによるデータの永続化の基本編についてポイントを説明しました。

この時点で永続化はもうできるわけですが、まだキーの扱いが面倒など不便な部分があります。これをカバーするHiveObjectとHiveListを次回やっていきます。

そこまで分かってしまえば簡単に自作のクラスのデータを端末に記録させることができますので、永続化には当分困らないと思います。

途中いくつか罠がありましたが、そこさえ引っかからなければ割とすんなり動くと思いますので、ここはどんどん進んでいきましょう。


f:id:linkedsort:20211112154424j:plain