Flutter で作っている漫画管理アプリ「ほんだな」の開発記、第4回です。今回から第2章で、漫画を管理するデータベース(DB)の設計の話を書きます。
第1回で、「巻ごとの管理を外した経緯はデータベースの回で書きます」と予告していました。今回はその話です。
先に結論を書くと、漫画の所持は巻ごとのフラグではなく、「何巻まで持っているか」の整数1つで持つことにしました。その理由と、割り切って表せなくなったものを書きます。
最初の設計メモは「巻ごとのフラグ」だった
最初に書いた設計メモでは、漫画を2つの表で持つ形でした。
series(作品): 作品名と総巻数volumes(巻): 巻ごとに「持っているか」のフラグ(is_owned)と ISBN
巻ごとにフラグを持てば、「1巻と2巻と5巻を持っている」といった飛び飛びの所持もそのまま表せます。巻の一覧画面で、1冊ずつ持っているかを付けていく想定でした。
今は「何巻まで持っているか」の数字1つ
今のコードでは、所持はこうなっています。
// 今のコード(lib/data/database.dart)
class MangaTable extends Table
{
@override
String get tableName => 'manga';
IntColumn get id => integer().autoIncrement()();
IntColumn get seriesId => integer().nullable()();
IntColumn get ownedVolumes => integer()();
IntColumn get groupId => integer().nullable()();
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
}
今の表は、作品(series)・本棚の1件(manga)・巻(volumes)・本棚のグループ(bookshelf_groups)の4つです。所持に関わるのは、このうち作品・本棚の1件・巻の3つです。所持の数字は作品の表ではなく、本棚に登録した1件ごとの manga の表に持たせ、seriesId で作品につないでいます。groupId と sortOrder は、第3回で書いたグループ分けと並び順の列です。
所持を表すのは ownedVolumes の整数1つだけです。巻ごとのフラグはありません。
ownedVolumes は、持っている冊数ではなく、何巻まで持っているかです。「3巻まで持っている」なら 3 が入ります。アプリの中で使うモデルのクラス(lib/model/manga.dart)でも、ownedVolumes のコメントは「所持している最新巻」(=自分が持っている一番新しい巻)です。
数字1つに切り替えた理由
切り替えたのは、プロトタイプを作りながらでした。理由は2つあります。
- 使う人がどう使うかを想像して、抜け巻は出ないと見た
- ISBN から巻を紐付けるなら、いずれ1巻ごとの情報が要るかもしれない。それでも、まずは小さく始めるほうを選んだ
少なくとも自分の本棚には、飛び飛びの巻はありませんでした。
数字1つでは表せないもの
製品の方針を書いた文書には、そのときの判断として、やらないことに入れてあります。
巻ごとの個別管理(飛び所持・重複所持)— 「所持最新巻」整数で十分
飛び所持は1・2・5巻のように飛び飛びに持つこと、重複所持は同じ巻を2冊持つことです。数字1つでは、どちらも表せません。
バーコードで巻を読み込んだときも、読み込んだ巻番号は所持の数字には入りません。所持巻数は、読み込み後の画面にある「+」ボタンで 1 ずつ増やします。
// 今のコード(lib/ui/add_manga/barcode_scan_page.dart)
final updated = manga.copyWith(ownedVolumes: manga.ownedVolumes + 1);
読み込んだ巻番号は、所持ではなく最新巻のほうに使われます。今の最新巻より大きければ、最新巻がその番号に書き換わります。
たとえば3巻まで持っている作品で5巻を読み込むと、最新巻が 5 になります。そこで「+」を押すと所持は 4 になり、画面には「未購入 1巻」と出ます。読み込んだ5巻の ISBN は巻の表に残りますが、5巻を持っていることはどこにも残りません。
抜け巻が出ない前提なので、こういう読み込みを区別する処理は入れていません。区別の仕方は、設計書に「検討中(未実装)」として残してあります。
| 場面 | 内容 |
|---|---|
| 重複スキャン | すでに持っている巻を読み込んだ場合を区別していない |
| 前の巻のスキャン | 持っている数字より前の巻を読み込んだ場合を区別していない |
| 飛び番スキャン | 2巻以上先を読み込んだ場合(警告を出すか検討中) |
(設計書の表から抜粋して言い換えたもの)
ほかに、文庫や特装版などの別版や、0巻・外伝のような整数でない巻番号の扱いも、未定のまま残っています。
最新巻と完結フラグは、あとから足した
最初から必須だったのは、何巻まで持っているかだけです。いまの作品の表には、ほかの列も並んでいます。
// 今のコード(lib/data/database.dart)
class SeriesTable extends Table
{
@override
String get tableName => 'series';
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
TextColumn get author => text().nullable()();
TextColumn get publisher => text().nullable()();
BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
IntColumn get latestVolume => integer().nullable()();
TextColumn get coverUrl => text().nullable()();
}
このうち latestVolume(発売されている最新巻)と isCompleted(完結したか)は、あとから足した列です。
- 最新巻は、AI に機能を強化する案を出してもらう中で出てきたものを採用しました
- 完結フラグは、AI に似たアプリを調べてもらう中で出てきたものを採用しました
完結フラグは、一覧の絞り込みの「完結済み」「連載中」や、最新巻を確かめる画面で完結した作品を外すのに使っています。
分からない最新巻は NULL にする
最新巻は、分からないときは NULL(値が入っていない状態)にしています。0 とは違い、「分からない」を表せます。
最新巻が入ると、所持の数字と比べて「まだ買っていない巻がある」が分かります。一覧の絞り込み「未購入あり」はこの比較です。
// 今のコード(lib/ui/manga_list/manga_list_filter.dart)
FilterOption.needsBuy => manga.latestVolume != null && manga.latestVolume! > manga.ownedVolumes,
ここでの manga は、manga の表の行に作品の情報を合わせて読み込んだものです。
最新巻が NULL の作品は、「未購入あり」に入りません。分からないものを 0 とみなさず、比べる対象から外しています。
所持の数字が「何巻まで」なので、最新巻と比べるだけで済んでいます。比べるだけで済むのは、抜け巻が出ない前提だからです。最新巻を足したのは割り切りよりあとなので、これは結果としてそうなった、という話です。
ISBN は巻の表に持つ
最初の設計メモの volumes は、所持のフラグを抜いて、巻ごとの ISBN を持つ表として残っています。ISBN を巻ごとに持つ理由は、第1回に書いたとおりです。今は、所持は manga の表の数字1つで、ISBN は巻の表で持っています。
振り返って
後悔はありません。所持を数字1つにしたことで、設計がシンプルになりました。あとから足した「未購入あり」の絞り込みも、最新巻と比べるだけで済んでいます。
表せないものは、設計書の表に並んでいます。必要になれば、そのときに改修して足せばいいと思っています。
次回は、データを読み書きする部分を Repository パターンで組んだ話を書きます。