実践Flutter

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

Flutterアプリのデータ保存2・HiveObjectとHiveList

はじめに

本記事ではFlutterの永続化としてHiveパッケージのポイント解説、第二弾です。

前回は基本の部分を説明しましたが、今回はそれをより便利にしていくためのクラスを2つ紹介します。

これさえあればもう永続化には当分困りません。

HiveListに関しては公式ドキュメントもさらっとした記述にとどまっていて、どう使うのかイマイチ伝わりづらいかと思いますので、少し振る舞いを実験するサンプルを作って中の様子を理解していきたいと思います。





HiveObject

前回記事のところまでの要素で、基本的にデータの永続化はできます。しかしキーの扱いが面倒ではないでしょうか。

世の中のKVS(キー・バリュー・ストア)型DBではキーをうまく設計することで、効率の良いデータの取り出しを可能にするなどの機能を有するものがありますが、Hiveではデータを選別するクエリについてはサポートしない方針です。

なのでキーは単に値を引っ張るための合言葉になっています。

となると、キー周りを自分のオブジェクトに持たせて、各インスタンス自身にキーを管理させて外からは隠蔽するHiveデータ用のクラスを作ろう、という発想に自然になりますよね。それがHiveObjectです。みんなが思うことを素直に実装してくれています。

仕組みは単純で、各インスタンスが所属するボックスとキーを自分で持っておく形にして、そのインスタンス単独で保存や削除をできるようにしているものです。

HiveObjectを使うには、データを表すクラスでHiveObjectを継承します:

import 'package:hive/hive.dart';
part 'car.g.dart';

@HiveType(typeId : 0)
//(1) HiveObjectを継承
class Car extends HiveObject{
  @HiveField(0)
  String name;

  @HiveField(1)
  String engine;

  Car(this.name, this.engine);

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

HiveObjectを継承している以外は、前回述べたアノテーションを付与しているだけの通常のHive用オブジェクト定義です。

HiveObjectの主な属性値とメソッドは以下の通りです。継承すると、これらが使えるようになります:

属性・メソッド 意味
box そのデータが所属するbox
isInBox そのデータがなんらかのboxにストアされているなら真
key そのデータに対応するキー
delete() そのデータをboxから削除する
save() そのデータをboxにストアする

ほぼ、delete()とsave()を抑えておけばOKです。

具体的な使い方の例を次に示します:

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

const carBoxName = "carBox";

void main() async {
  await Hive.initFlutter();
  Hive.registerAdapter(CarAdapter());
  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  MaterialApp(
      debugShowCheckedModeBanner: false,
      initialRoute: '/',
      home:  Home(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    var box = Hive.box<Car>(carBoxName);

    //(1) 値を用意
    var car1 = Car("Prius", "Hybrid");
    var car2 = Car("Yaris", "Normal");
    var car3 = Car("Aqua", "Hybrid");
    for(Car c in box.values){c.delete();}
    box.addAll([car1, car2, car3]);

    //(2) box内部の値を全て取得
    var cars = <Car>[...box.values];

    //(3) 値の更新
    cars[2].engine = "Special Engine";
    cars[2].save();

    //(4) 値の削除
    cars[1].delete();

    return Scaffold(
        appBar: AppBar(
          title: const Text("ホーム"),
        ),
        body: Center(
          child: Column(
            children: [
              const Text("【boxの中】"),
              for(Car c in box.values) Text(c.toString()),
              const Text("\n【carsの中】"),
              for(Car c in cars) Text(c.toString()),
            ],
          )
        )
    );
  }
}

これを実行すると次のように表示されます:

f:id:linkedsort:20211112172140p:plain:w400

コードのポイント

コードのポイントを次に見ていきます。

(1) 値の準備
    //(1) 値を用意
    var car1 = Car("Prius", "Hybrid");
    var car2 = Car("Yaris", "Normal");
    var car3 = Car("Aqua", "Hybrid");
    for(Car c in box.values){c.delete();}
    box.addAll([car1, car2, car3]);

Car型のインスタンスを3つ作りました。

次にboxの中身を一旦全部deleteしています。これは繰り返しサンプル実行したときに、前回のセッションで永続化されたデータを消去して結果を同一にするための初期化です。実際のアプリではこうした初期化を必要はありません。またこの一行をコメントアウトすると、永続化されていることが確認できると思います。

その後、addAllでボックスにデータを入れています。ボックスに入れることでHiveObjectのインスタンスにボックスの情報が格納され、addやdeleteができるようになります。

boxに入れる前の行でdeleteしているように見える? いえいえ、これは前のセッションでboxに入ったものを削除しています。既にボックスに入っているものを取り出していますのでdeleteを呼び出せます。

(2) ボックスの値を取得
    //(2) box内部の値を全て取得
    var cars = <Car>[...box.values];

ここでは一旦carsにリストとしてboxの全値を入れています。「...」はスプレッド演算子で、こういうときに便利です。

(3) データのセーブ
    //(3) 値の更新
    cars[2].engine = "Special Engine";
    cars[2].save();

cars[2](carsの3番目の要素)のengineを書き換え、saveを呼び出して永続化しています。このようにboxやキーを意識しないでデータを永続化できるのが利点です。

(4) データの消去
    //(4) 値の削除
    cars[1].delete();

car[1](carsの2番目の要素)を削除しています。これでboxおよび永続化データから消去されます。ただcars[1]のインスタンス自体は残ります。出力サンプルをみてみると、boxの値一覧からは消えていますが、carsの一覧には残っていますよね。

HiveObjectの基本の使い方は以上の通りです。boxに所属させたら、あとはsaveやdeleteを単体で行うことができ、キー自体を陽に扱う必要のない仕組み、という捉え方でよいかと思います。


HiveList

次にHiveListを見ていきます。

これはインスタンスに別のインスタンスを持たせるときに便利になっています。いわゆるリレーションですね。

HiveObjectのインスタンスに別クラスのインスタンスの参照を持っていて、それを永続化できる、までは自然とできて当然だと思います。下の図の状況ですね。


f:id:linkedsort:20211112183319p:plain

永続化から復帰した後のインスタンス表記が「Car1'」「Part1'」「Part2'」となっているのは、内容は同じですが永続化前とは別のインスタンスですので図中でこう表現しています。一回アプリを閉じて復帰させると、全てのインスタンスは入れ替わりますから中身は不変でも入れ物は新たに生成されています。

ここまでがすんなりできる時点で便利なわけですが、もし同一のインスタンスをもたせていたとき、復帰後のインスタンスの関係はどうなっているか、は気になるところではないでしょうか。

下の図でいくと、例えばCar1とCar2が同一インスタンスのPart1を持っていたとします。そして永続化した後、データを復帰させます。このとき「Part1'」「Part1''」と図中に書いていますが、これが同一の「Part1'」なのか、それとも中身は同一だが外身は別々のインスタンスなのか。


f:id:linkedsort:20211112183403p:plain

ということで、HiveListの使い方を示しつつ、上記のことを確認する操作をしてみて結果を見ます。もし上記のことが全然気にならない、気になる場面が想定できないという方はHiveListの使い方のみに注目して頂いてもOKです。

準備その1として、Carクラスの実装を以下のようにします。ファイル名は「lib/car.dart」としましょう:

import 'part.dart';
import 'package:hive/hive.dart';

part 'car.g.dart';

@HiveType(typeId : 0)
//(1) HiveObjectの継承
class Car extends HiveObject{
  @HiveField(0)
  String name;

  @HiveField(1)
  String engine;

  //(2) HiveListを定義しています
  @HiveField(2)
  HiveList<Part> parts;

  Car(this.name, this.engine, this.parts);

  @override
  String toString(){
    var s = StringBuffer();
    for(Part p in parts){
      s.write(p.toString()+" ");
    }
    return name+" "+engine+" 部品["+s.toString()+"]";
  }
}

コードのポイント

(1) HiveObjectの継承
@HiveType(typeId : 0)
//(1) HiveObjectの継承
class Car extends HiveObject{

HiveObjectを継承します。typeIdは0でPartクラスのtypeIdと重ならないように設定します。コピペでデータクラスの実装の雛形を作るとき、typeIdの設定には特に注意して下さい。

(2) HiveListによりリストの属性値を持たせる
//(2) HiveListを定義しています
  @HiveField(2)
  HiveList<Part> parts;

リスト要素を持たせるためHiveList型の属性を定義します。

HiveListのコンストラクタはBoxを必要とするため、Boxがオープンされてからの初期化になります。なのでこの場で空のHiveListで初期化することができません。

//こんな風にかけると嬉しいが、実際には引数にboxが必要なので✕
HiveList<Part> parts = HiveList<Part>();

なのでコンストラクタの方で、初期化することを宣言しておきます。

  Car(this.name, this.engine, this.parts);


余談です。

ここではList型で宣言しておいて、初期化の段階でHiveListのインスタンスを入れる、という形でもよいのではないかと感じる方もおられるかもしれません。

List<Part> parts;

よくListやSetの実装を決めないで、一旦はListと宣言しておいてあとでArrayListなのかLinkedListなのかの実装はあとで与えるというパターンがありますよね。HiveListはListでもあるので、Listに代入できますし。

ただ、我々はいまHiveListにインスタンス(リレーション)の制御も期待しています。これはListを超える機能ですよね。なのでHiveListと宣言します。


さて次にPartクラスを定義します。ファイルは「lib/part.dart」としておきます。これはtypeIdを1にしているくらいであとはシンプルなものです:

import 'package:hive/hive.dart';
part 'part.g.dart';

@HiveType(typeId : 1)
class Part extends HiveObject{
  @HiveField(0)
  String name;

  @HiveField(1)
  String status;

  Part(this.name, this.status);

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

HiveObjectを継承しています。HiveListにいれる要素はHiveObjectを継承している必要があります。

ここまでできたら下記コマンドでタイプアダプタを生成しておきます。

flutter packages pub run build_runner build

car.g.dartとpart.g.dartがlibフォルダに生成されましたでしょうか。

次に「lib/main.dart」のコードを見ていきます:

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

import 'car.dart';
import 'part.dart';

const carBoxName = "carBox";
const partBoxName = "partBox";

void main() async {
  //(1) Hive初期化・アダプタ登録・Boxオープン (順番注意)
  await Hive.initFlutter();

  Hive.registerAdapter(CarAdapter());
  Hive.registerAdapter(PartAdapter());

  await Hive.openBox<Car>(carBoxName);
  await Hive.openBox<Part>(partBoxName);

  runApp(const MyApp());
}

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

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

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("ホーム"),
      ),
      body: Column(children: [
        const Padding(padding: EdgeInsets.only(top: 50)),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            ElevatedButton(
              onPressed: () => Get.to(() => const Writer()),
              child: const Text("データの書き込み"),
            ),
            ElevatedButton(
              onPressed: () => Get.to(() => const Reader()),
              child: const Text("データの読み込み"),
            ),
          ],
        )
      ]),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    //(2) 書き込むデータを準備
    var partBox = Hive.box<Part>(partBoxName);
    var part1 = Part("タイヤ", "新品");
    var part2 = Part("ハンドル", "中古");
    var part3 = Part("ドア", "新品");
    var part4 = Part("ステレオ", "故障");

    //(3) 繰り返し実行したときに増えないようにすでにBox内部にあるデータを消去
    for (var element in partBox.values) {
      element.delete();
    }
    partBox.addAll([part1, part2, part3, part4]);

    var carBox = Hive.box<Car>(carBoxName);
    var car1 = Car("Prius", "Hybrid", HiveList(partBox));
    var car2 = Car("Yaris", "Normal", HiveList(partBox));
    var car3 = Car("Aqua ", "Hybrid", HiveList(partBox));

    car1.parts.addAll([part1, part2, part4]);
    car2.parts.addAll([part1, part2, part3]);
    car3.parts.addAll([part1]);

    for (var element in carBox.values) {
      element.delete();
    }
    carBox.addAll([car1, car2, car3]);

    return Scaffold(
        appBar: AppBar(
          title: const Text("データの書き込み"),
        ),
        body: Padding(
            padding: const EdgeInsets.all(100),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                for (Car c in carBox.values) Text(c.toString()),
                const Padding(
                  padding: EdgeInsets.only(top: 50),
                ),
                ElevatedButton(
                  onPressed: () {
                    Get.back();
                  },
                  child: const Text("Back"),
                ),
              ],
            )));
  }
}

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

  @override
  Widget build(BuildContext context) {
    //(4) Car, PartのBoxを取得、値をリストに入れる
    var carBox = Hive.box<Car>(carBoxName);
    var cars = <Car>[...carBox.values];

    var partBox = Hive.box<Part>(partBoxName);
    var parts = <Part>[...partBox.values];

    //(5) 画面に表示する文字列の作成
    var texts = <String>[];
    texts.add("【読み込んだデータ】");
    for (var car in cars) {
      texts.add(car.toString());
    }
    texts.add("\n【加工したデータ】");

    //(6) 最初の車の最初のPartsのstatusを変えてみる
    if (cars.length > 1) {
      cars[0].parts[0].status = "リコール";
    }
    //(7) 「ハンドル」という名前のPartsを削除してみる
    parts.where((e) => e.name == "ハンドル").forEach((e) {
      e.delete();
    });

    //加工後のCarのデータを出力文字列に入れる
    for (var car in cars) {
      texts.add(car.toString());
    }

    return Scaffold(
        appBar: AppBar(
          title: const Text("データの読み込み"),
        ),
        body: Padding(
            padding: const EdgeInsets.all(100),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                for (var t in texts) Text(t),
                const Padding(
                  padding: EdgeInsets.only(top: 50),
                ),
                ElevatedButton(
                  onPressed: () {
                    Get.back();
                  },
                  child: const Text("Back"),
                ),
              ],
            )));
  }
}

ファイルが沢山ありますのでおさらいしておきますと、全部作り終わると下記のような状況になります:


f:id:linkedsort:20211112193035p:plain

サンプルですので全部libに入れてあります。画面遷移にGetXを使っていますので、HiveとGetXに必要なパッケージはpub getしておいて下さい(導入の仕方は前回以前の記事を参照して下さい)。

これを実行すると、以下のようになります。Homeの画面は「データの書き込み」「データの読み込み」の二択です:

f:id:linkedsort:20211112193841p:plain:w400

データの書き込みを選択すると、テストデータを生成して永続化します:

f:id:linkedsort:20211112193903p:plain:w400

戻って、あるいはここでアプリを一回閉じて「データの読み込み」を選択すると下記画面になります:

f:id:linkedsort:20211112193937p:plain:w400

書き込み前に「データの読み込み」の画面に行くと、読み込んだデータが空白になります。

以上が見た目です。確認したかった事項の答え合わせはコードのポイントの方で解説していきます。

コードのポイント

(1) Hiveの初期化
//(1) Hive初期化・アダプタ登録・Boxオープン (順番注意)
  await Hive.initFlutter();

  Hive.registerAdapter(CarAdapter());
  Hive.registerAdapter(PartAdapter());

  await Hive.openBox<Car>(carBoxName);
  await Hive.openBox<Part>(partBoxName);

Hiveの初期化を行います。ここではCarとPartのクラスについてそれぞれBoxを用意しています。

ここでopenBoxの前にアダプタを登録する必要がありますので注意してください。

(2) Partデータの用意
    //(2) 書き込むデータを準備
    var partBox = Hive.box<Part>(partBoxName);
    var part1 = Part("タイヤ", "新品");
    var part2 = Part("ハンドル", "中古");
    var part3 = Part("ドア", "新品");
    var part4 = Part("ステレオ", "故障");

PartデータのBox取得と、データの作成を行っています。

(3) Partデータの永続化
    //(3) 繰り返し実行したときに増えないようにすでにBox内部にあるデータを消去
    for (var element in partBox.values) {
      element.delete();
    }
    partBox.addAll([part1, part2, part3, part4]);

作ったPartのデータをBoxに入れて永続化。ただ追加だけだとデータがどんどん増えてしまうので、既存データを一旦削除しています。

(4) 値を操作するため一旦リストに入れます
    //(4) Car, PartのBoxを取得、値をリストに入れる
    var carBox = Hive.box<Car>(carBoxName);
    var cars = <Car>[...carBox.values];

    var partBox = Hive.box<Part>(partBoxName);
    var parts = <Part>[...partBox.values];

ここでスプレッド演算子(...)が便利ですね。

(5) 表示用文字列の作成
    //(5) 画面に表示する文字列の作成
    var texts = <String>[];
    texts.add("【読み込んだデータ】");
    for (var car in cars) {
      texts.add(car.toString());
    }
    texts.add("\n【加工したデータ】");

表示用の文字列を作成します。実験的に行う操作の前後の変化を示すため、まず永続化したデータの読み込んだCarをそのまま文字列にして記録します。

その次に加工後のCarの集合の文字列を入れる準備をしてあります。

(6) Carの最初の要素の最初のPartを変更
    //(6) 最初の車の最初のPartsのstatusを変えてみる
    if (cars.length > 1) {
      cars[0].parts[0].status = "リコール";
    }

ではCarのインスタンスにあるPartを加工してみます。もともとの書き込み前のデータでCarには全て同じインスタンスのpart1を入れてありました:

    car1.parts.addAll([part1, part2, part4]);
    car2.parts.addAll([part1, part2, part3]);
    car3.parts.addAll([part1]);

part1の内容はこちらです:

    var part1 = Part("タイヤ", "新品");

part1のstatusは「新品」でした。これを「リコール」に変えてみるというわけです。リコールに変えているタイミングは、永続化したデータを読みこんだものであることに注意して下さい。

ここで最初のCarの最初のPartのstatusのみが変更されるのであれば、別のCarに含まれるPartはそれぞればらばらのインスタンスだということになります。

もし同時に変更されるのであれば、永続化時に同じPartのインスタンスであれば、復帰させた時も同じインスタンスという関係が保たれているということになりますね。

そしてこの画面をみると:

f:id:linkedsort:20211112193937p:plain:w400

1つしか変更していないのに、全部part1の部分がリコールに書き換わっています。すなわち全て同じインスタンスということですね。

(7) Partの一つをdeleteする
    //(7) 「ハンドル」という名前のPartsを削除してみる
    parts.where((e) => e.name == "ハンドル").forEach((e) {
      e.delete();
    });

ここではすべてのPartの集合から、「ハンドル」という名前を持つ要素についてdeleteを呼び出します。

ここではCarにセットしたPartのインスタンスではなく、Partの集合からインスタンスを選んでいることに注意してください。

加工後の結果をみてみると、全てのCarに含まれている「ハンドル」のパーツが除去されていますね。ワオ。Carから抜いたりしてませんが、勝手に抜けました。

CarのpartsはHiveListで、これがPartのBoxを持っているため、このBoxを介して削除操作が反映されているというわけですね。また(6)のインスタンスの同一性も、このBoxを通じて制御されているというわけです。

結論、HiveListはとても便利にできている!ということです。





おわりに

今回はHive第2弾をやってまいりました。

公式ドキュメントでもかなりあっさりめの解説しかしていないので、少し踏み込んだ実験もしつつ、使い方のサンプルを示したので少し長くなりました。

結果的にはリレーションもかなり直感的に使えるぞ、という感触ですよね。シンプルでよいデザインではないかと思います。

さて状態管理と永続化が一段落すると、いよいよ実践的なアプリを作れるようになってきます。そのなかでHiveやGetXの使い方をみていきつつ、使えるPub devのパッケージを増やしていきましょう。

f:id:linkedsort:20211112212218j:plain