未来の自分へ知識貯金

ゆーきのエンジニアブログ

約6分 アプリ開発

漫画のグループ分けを、多対多にせず「1対多」で作った —— ほんだな開発記 3

漫画のグループ分けを、多対多にせず「1対多」で作った —— ほんだな開発記 3

Flutter で作っている漫画管理アプリ「ほんだな」の開発記、第3回です。

前回は、最初の版が動いたあとに、次に作るものを14件のタスクリストにまとめた話でした。

そのリストの中で、本棚グループだけはその日のうちに設計まで決めていました。今回はその中身です。漫画をグループに分けて、一覧をタブで切り替える機能です。形はかなり単純にしましたが、あとから足した並び替えでは不具合が2つ出ました。そこまでを書きます。

1つの漫画は、1つのグループにだけ入る

設計のメモには、こう書いてあります。

  • 1作品につき1棚(多対多ではなく1対多)
  • manga テーブルに shelf_id カラムを足す形(中間テーブル不要)
  • 棚にはユーザーが自由にイメージカラーを設定できる

当時は「棚」と呼んでいましたが、アプリの中では「グループ」なので、ここからはグループと書きます。メモの「テーブル」はデータを入れる表、「カラム」はその表の欄のことです。

1つの漫画は、同時に1つのグループにしか入れられない、という形です。1つのグループには漫画がいくつも入るけれど、1つの漫画は1つのグループにしか入らない。これを「1対多」と呼びます。ある漫画を「少年漫画」に入れることはできても、「少年漫画」と「読みかけ」の両方に入れることはできません。

反対に、漫画もグループも、お互いにいくつでも結びつけられる形を「多対多」と呼びます。

2つの表の形の比較。左の「ほんだなの形(1対多)」では、漫画の表の各行が group_id を1つだけ持ち、グループの表を指している。右の「作らなかった形(多対多)」では、漫画の表とグループの表のあいだに、組み合わせを1行ずつ持つ中間の表がもう1つ挟まっている

ここからは今のコードを貼ります。データベースには、Flutter から SQLite を扱うライブラリの Drift を使っていて、表の形を Dart のコードで書きます。なお、メモでは shelf_id だった名前は、今のコードでは group_id です。改名した経緯は記録に残っていません。// の説明コメントは、記事用に足したものです。

漫画の表には、「どのグループか」を書く欄が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()();   // グループは1つだけ。null なら未分類
  IntColumn get sortOrder => integer().withDefault(const Constant(0))();
}

SQL で書けば、manga の表に group_id INTEGER の列が1つあるだけです。null は「値が入っていない」という印で、どのグループにも入っていない漫画(未分類)はここが null になります。

グループのほうは別の表で、名前と色を持ちます。

class BookshelfGroupTable extends Table
{
  @override
  String get tableName => 'bookshelf_groups';

  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();
  TextColumn get colorHex => text().withDefault(const Constant('#607D8B'))();
  IntColumn get sortOrder => integer().withDefault(const Constant(0))();
  BoolColumn get isSecret => boolean().withDefault(const Constant(false))();
  TextColumn get iconName => text().nullable()();
}

多対多にするなら、「どの漫画がどのグループに入っているか」の組み合わせを1行ずつ持つ表がもう1つ要ります。これを中間テーブルと呼びます。タグ型も同じ仕組みです。メモの「中間テーブル不要」は、この表を作らずに済ませたという意味です。

// 作らなかった形(例)。ほんだなには存在しない
class MangaGroupTable extends Table
{
  IntColumn get mangaId => integer()();
  IntColumn get groupId => integer()();
  @override
  Set<Column> get primaryKey => {mangaId, groupId};
}

なぜ多対多にしなかったか

当時、多対多やタグ型と比べた記録はありません。はっきり覚えてもいないので、ここに書くのは今振り返っての見立てです。

ひとつは、テーブル設計が複雑になると保守性が下がるのを避けたかったのだと思います。メモの「中間テーブル不要」という括弧書きとも向きが合っています。

もうひとつは使う人のほうです。1つの漫画を複数のグループに入れられたら便利ではあります。ただ同時に、操作が煩雑になって、かえって使いにくく感じる人も出てくると思いました。

作ったもの

画面には、グループの管理ページ、一覧の上のタブ(「全て」+各グループ)、カードを長押しすると画面の下からせり出すメニュー(ボトムシート)でグループを変える機能、カードの色バッジが入りました。

選んでいるグループは ID 1つで持っています。「全て」のタブのときは null です。一覧の絞り込みは、その ID と漫画の欄を比べるだけです。

// lib/ui/manga_list/manga_list_page.dart
final displayList = selectedGroupId == null
    ? secretFiltered
    : secretFiltered.where((m) => m.groupId == selectedGroupId).toList();

最初の版では、これと同じ形の分岐が抜けていました(当時は別のファイルで、名前も selectedShelf でした)。選択の null は「全て」、漫画の欄の null は「未分類」で、同じ null が2つの意味を持っています。「全て」のときに全件を返す分岐が無かったのを、ビルド時の型エラーで見つけ、分岐を足して直しました。

グループを消すときは、先に入っていた漫画の欄を null に書き換えてから、グループを消しています。DB の外部キー制約(表どうしのつながりを DB に守らせる仕組み)は付けていないので、この「未分類に戻す」はアプリ側の処理です。削除の確認ダイアログにも、そう書いてあります。

// lib/data/manga_repository.dart
Future<void> clearGroupFromManga(int groupId) async
{
  await (_db.update(_db.mangaTable)
    ..where((t) => t.groupId.equals(groupId)))
    .write(const MangaTableCompanion(groupId: Value(null)));
}

SQL なら UPDATE manga SET group_id = NULL WHERE group_id = ? です。

多対多なら、絞り込みは中間テーブルとの結合になり、削除では中間テーブルの行も消すことになります(外部キーの CASCADE で DB に任せる手もあります)。1対多の今の形では、上の比較と書き換えがそれにあたります。

グループにつながっていった機能

その後、グループはほかの機能とつながっていきました。

  • 本棚ビュー(背表紙が並ぶ表示)を、グループごとのセクションに分けた。どこにも入っていない漫画は「未分類」のセクションに出る
  • JSON 形式のバックアップファイルに、グループも書き出すようにした
  • グループが1つしかないときは、タブを出さないようにした
  • グループの中で、漫画を好きな順に並べ替えられるようにした

「未分類」のセクションも、欄が null かどうかを見るだけです。

// lib/ui/manga_list/shelf_view.dart
final ungrouped = widget.allManga.where((m) => m.groupId == null).toList();

グループ内の並び替えで出た不具合

グループの中で並べ替えられるようにしたので、「グループ順」で並べると、一覧は2段の基準(キー)で並びます。まずグループの順、同じグループの中では漫画の順です。どこにも入っていない漫画は最後に回ります。

// lib/ui/manga_list/manga_list_filter.dart
filtered.sort((a, b)
{
  final aOrder = groupSortMap[a.groupId] ?? 0x7fffffff;   // 未分類は最後
  final bOrder = groupSortMap[b.groupId] ?? 0x7fffffff;
  final cmp    = aOrder.compareTo(bOrder);
  return cmp != 0 ? cmp : a.sortOrder.compareTo(b.sortOrder);
});

この並び替えで、不具合が2つ出ました。どちらも最初の修正版(v1.0.1)で直しています。

  • ドラッグで並べ替えると、位置がずれた。 画面は「グループの順 → グループの中の順」の2段で並んでいるのに、ドラッグしたときの処理(onReorder)は漫画の順だけの1段で並べ直していたためです。onReorder の中の並べ方を、画面と同じ2段のキーにそろえて直しました
  • 漫画を編集して保存すると、その漫画がグループの先頭へ飛んだ。 保存のときに並び順(sortOrder)を指定しておらず、既定値の 0 が書き込まれていたためです。保存するときに、元の並び順を引き継ぐようにして直しました

どちらも実装のミスです。使う人が整えた並び順が、操作するだけで崩れる不具合でした。

振り返って

1対多にしたことで、欄は1つ、絞り込みは ID の比較1つ、グループを消したときの行き先は null の1通りになっています。

グループの中で並べ替えられるようにしたところで並びが2段になり、そこで実装のミスが2つ出ました。

次回から第2章です。漫画を管理する DB の設計の話を書きます。

この記事をシェアする

ゆーき
ゆーき

プログラマー経験10年以上。好きなプログラム言語はC#。使える言語はC++, Python他。好きな開発環境はVisual Studio。

プロフィールを見る →