🍃

MVVM + Repository で CRUD 機能(READ のみ)の実装(Flutter × Supabase)

2024/07/28に公開
1

はじめに

この記事では、Flutter 用いたアプリケーション開発において、 MVVM (Model-View-ViewModel)アーキテクチャと Repository パターンを組み合わせて、 CRUD (Create, Read, Update, Delete) 機能を実装する方法について学んだので、それをアウトプットしようと思います! ただ、全 CRUD 機能の実装を説明をすると記事の文量が多くなりすぎそうな予感がするので、 Supabase にデータを入れて、それを Read するまでを解説しようと思います。

今回は特に、現在の Flutter アプリ開発において主流になっている Riverpod + StateNotifier + Freezed を使用した MVVM アーキテクチャの実装を行います。

完成イメージ

前提

一回簡単に MVVM アーキテクチャと Repository パターンの概要をまとめました。
以下の記事も参考になったので、見てみるといいかもしれないです。

https://zenn.dev/itakahiro/articles/29ba92485a0df6

https://qiita.com/homio/items/80ce1636a5da7c5b83fc

MVVMアーキテクチャとは?

MVVMは、アプリケーションのロジックとUIを分離するためのデザインパターンです。以下の3つのコンポーネントから構成されています。

  • Model: データやビジネスロジックを管理を行う。
  • View: ユーザーインターフェースを担当を行う。ViewModelの情報を使用してUIに表示、ViewModelにアクションを送信します。
  • ViewModel: ViewとModelの間の調整役で、データの変換、取得、保存やUIへの反映を行う。

MVVMの大きな利点は、各コンポーネントの役割が明確に分かれていることです。これにより、依存関係が少なくなり、互いの独立性が高くなります。
じゃあ、独立性が高いとはどのような良い点があるの?ってなると思うので、簡単に主なメリットをまとめました。

カテゴリ メリット
開発者側のメリット - コードの分離: クラスの役割が明確であるため、コードの保守性と可読性、可搬性(データの参照先を変更するときなど、コードを書き換える際の工数が大幅に減る)が上がる。
- テストがしやすくなる: それぞれが疎結合であるため、それぞれのクラスでテストを行うことができる。
ユーザー側のメリット - データバインディング: 双方向データバインディングをサポートしているため、入力内容の変更が即座にUIに反映される。

Repositoryパターンとは?

Reppositoryパターンは、データの取得や保存を一言管理するためのデザインパターンです。データソースへのアクセス方法を隠蔽し、データ操作を抽象化します。

簡単にいうと、データソースからデータを引っ張ってくる処理は抽象的なレイヤ (Repository) に任せよう!というパターン。

これの大きな利点として、基本的にデータを扱う側はそのデータがどこから取得してきたかはどうでもいいので、それを任せる抽象的なレイヤをかませるだけで、それだけ依存関係が少なくなり互いの独立性が高まるわけです。(「データくださいな」といってデータ貰えればそれでよくない? とか MVVM をさらに疎結合にするやつ! みたいなイメージで OK だと思います。)

これによって、データアクセス部分を独立したレイヤとして扱うことができ、保守性と可読性、可搬性が向上します。

事前準備

Flutter と Supabase のセットアップを行います。基本的に公式ドキュメントに従えばなんの問題もないです。

Flutter のセットアップ

Flutter のセットアップは以下の公式ドキュメントを参考にしました!
https://docs.flutter.dev/get-started/install

今回は、libディレクトリ内が、以下のような構成になるように開発を行いました!

your_project_name/
├── lib/
│   ├── main.dart
│   ├── models/
│   │   └── item.dart
│   ├── viewmodels/
│   │   └── item_viewmodel.dart
│   ├── repositories/
│   │   └── item_repository.dart
│   └── views/
│       └── item_list_screen.dart
├── pubspec.yaml

Supabase のセットアップ

Supabaseは Firebase 代替となるオープンソースの BaaS(Backend as a Service) です。アプリケーションに必要な様々なバックエンドサービス (DB、ストレージ、認証、etc) を提供し、個人であっても簡単に降るスタックなサービス開発を可能にします。

Postgres をベースとした RDB (Relational DataBase) を強い特徴としつつ、リアルタイム更新や認証、ストレージ、サーバーレス関数など Firebase にも引けを取らない様々な機能を提供しています。

Firebase との最も大きな違いは Firebase が NoSQL ベースの DB であるのに対し、 RDB ベースの DB であることかと思います。商業用の多くのサービスが RDB をベースとしている中、 Firebase 採用には NoSQL という新しいパラダイムを学ぶコストがありましたが、 Supabase の登場で慣れ親しんだ RDB のパラダイムで簡単にインフラを構築できるのは大きなメリットです。

Supabase のセットアップは以下の公式ドキュメントを参考にしました!
https://supabase.com/database

詰まった方は以下の記事を参考にするといいかもです。
https://zenn.dev/heyhey1028/books/flutter-supa-gpt/viewer/getting_started3

データは、以下のようなアニメ情報に関わるデータを ChatGPT に生成させて Supabase に挿入しました。

[
    {
        "id": 1,
        "title": "進撃の巨人",
        "description": "巨人が支配する世界で人類が生き残るために戦う姿を描いたアクションアニメ。",
        "coverImage": "https://example.com/shingeki.jpg",
        "genre": "アクション, ファンタジー",
        "storyline": "壁に囲まれた世界で、エレン・イェーガーと仲間たちは巨人との戦いに挑む。",
        "cast": "梶裕貴, 石川由依, 井上麻里奈"
    },
    {
        "id": 2,
        "title": "鬼滅の刃",
        "description": "家族を鬼に殺された少年が鬼狩りとなり、妹を救うために戦う冒険アニメ。",
        "coverImage": "https://example.com/kimetsu.jpg",
        "genre": "アクション, ダークファンタジー",
        "storyline": "炭治郎は鬼となった妹・禰豆子を人間に戻すため、鬼との戦いを繰り広げる。",
        "cast": "花江夏樹, 鬼頭明里, 下野紘"
    },
    {
        "id": 3,
        "title": "僕のヒーローアカデミア",
        "description": "ヒーローを目指す少年たちが能力を磨きながら成長していく姿を描くアクションアニメ。",
        "coverImage": "https://example.com/heroaca.jpg",
        "genre": "アクション, スーパーヒーロー",
        "storyline": "無個性の少年・緑谷出久がヒーローを目指し、個性を持つ仲間たちと共に成長する。",
        "cast": "山下大輝, 岡本信彦, 佐倉綾音"
    },
    {
        "id": 4,
        "title": "ソードアート・オンライン",
        "description": "仮想現実MMORPGの世界に閉じ込められたプレイヤーたちの戦いと冒険を描くアニメ。",
        "coverImage": "https://example.com/sao.jpg",
        "genre": "アクション, 冒険, ファンタジー",
        "storyline": "キリトは仮想現実の世界で仲間と共にゲームをクリアするために戦う。",
        "cast": "松岡禎丞, 戸松遥, 伊藤かな恵"
    },
    {
        "id": 5,
        "title": "東京喰種",
        "description": "人間と喰種が共存する東京を舞台に、喰種となった少年の苦悩と戦いを描くダークファンタジー。",
        "coverImage": "https://example.com/tokyoghoul.jpg",
        "genre": "ダークファンタジー, ホラー",
        "storyline": "金木研は事故により喰種となり、苦悩しながらも新たな生活に適応していく。",
        "cast": "花江夏樹, 雨宮天, 宮野真守"
    },
    {
        "id": 6,
        "title": "Re:ゼロから始める異世界生活",
        "description": "異世界に召喚された少年が、死に戻りの能力を使って運命に立ち向かうファンタジーアニメ。",
        "coverImage": "https://example.com/rezero.jpg",
        "genre": "ファンタジー, ドラマ",
        "storyline": "ナツキ・スバルは異世界で死に戻りの能力を使い、エミリアを守るために奮闘する。",
        "cast": "小林裕介, 高橋李依, 内山夕実"
    },
    {
        "id": 7,
        "title": "ワンピース",
        "description": "海賊王を目指す少年と仲間たちの冒険を描くアクションアニメ。",
        "coverImage": "https://example.com/onepiece.jpg",
        "genre": "アクション, 冒険",
        "storyline": "ルフィは仲間たちと共にグランドラインを目指し、海賊王になるための冒険を続ける。",
        "cast": "田中真弓, 中井和哉, 岡村明美"
    },
    {
        "id": 8,
        "title": "ナルト",
        "description": "忍者を目指す少年の成長と戦いを描くアクションアニメ。",
        "coverImage": "https://example.com/naruto.jpg",
        "genre": "アクション, 冒険",
        "storyline": "ナルトは火影を目指し、仲間たちと共に数々の試練に立ち向かう。",
        "cast": "竹内順子, 杉山紀彰, 中村千絵"
    },
    {
        "id": 9,
        "title": "デスノート",
        "description": "死神のノートを手に入れた天才高校生の心理戦と犯罪を描くサスペンスアニメ。",
        "coverImage": "https://example.com/deathnote.jpg",
        "genre": "サスペンス, ミステリー",
        "releaseDate": "2006-10-04",
        "storyline": "夜神月はデスノートを使って犯罪者を裁き、理想の世界を作ろうとする。",
        "cast": "宮野真守, 山口勝平, 中村獅童"
    },
    {
        "id": 10,
        "title": "涼宮ハルヒの憂鬱",
        "description": "不思議な出来事を求める少女とその仲間たちの日常を描くSFアニメ。",
        "coverImage": "https://example.com/haruhi.jpg",
        "genre": "SF, コメディ",
        "storyline": "涼宮ハルヒはSOS団を結成し、宇宙人や未来人といった不思議な存在を探し続ける。",
        "cast": "平野綾, 杉田智和, 茅原実里"
    }
]


実装

以下の Model 、 Repository 、 ViewModel の実装を書きます!
View に関しては、若干説明が長くなるので、今回は行いません!
もし反響が良ければ別の記事でアップしようと思います。

Model

まずは、 Model の実装です。
簡単に以下のコードを説明すると、 freezedパッケージを使ってアニメの情報を管理するデータクラスを定義し、 json_serializableを使って JSON との変換を簡単にしていあす。freezedを使うことで、データクラスの作成が簡単になり、イミュータブルなクラスのメリットを享受することができます。

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

// part キーワードで生成されたコードをファイルに分割する。
part 'anime.freezed.dart';
part 'anime.g.dart';

// freezed アノテーションを使用して、 Anime クラスを定義

class Anime with _$Anime {
  const Anime._();

  // @JsonKey アノテーションを使用して、JSON キーと Dart フィールド名をマッピング
  factory Anime({
    (name: 'anime_id') required int animeId,
    required String title,
    required String description,
    (name: 'cover_image') required String coverImage,
    required String genre,
    required String storyline,
    required String cast,
    (name: 'created_at') required DateTime createdAt,
    (name: 'updated_at') DateTime? updatedAt,
  }) = _Anime; // プライベートコンストラクタ

  // デフォルト値を持つ空の Anime インスタンスを生成するためのファクトリメソッド
  factory Anime.empty() => Anime(
        animeId: 0,
        title: '',
        description: '',
        coverImage: '',
        genre: '',
        storyline: '',
        cast: '',
        createdAt: DateTime.now(),
        updatedAt: null,
      );
  // fromRow と toRow は、データベースの行との変換を簡単に行うためのメソッド
  factory Anime.fromRow(Map<String, dynamic> row) {
    return Anime.fromJson(row);
  }

  Map<String, dynamic> toRow() => toJson(this);

  // JSONのシリアライズを行うメソッド
  factory Anime.fromJson(Map<String, dynamic> json) => _$AnimeFromJson(json);
}

Repository

続いて、Repository の実装です。
このコードは、アニメ情報のリポジトリを定義し、Supabase データベースと連携するためのものです。BaseAnimeRepositoryという抽象クラスを定義し、AnimeRepositoryで具体的な実装を行います。さらに、ライブラリとして使用しているRiverpodは、実装された Provider を使って、他のクラスからこのリポジトリを簡単に取得し、データベース操作を行うことができます。

import 'package:your_project_name/models/anime.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

// override で値を書き換えられる abstract class
abstract class BaseAnimeRepository {
  Future<List<Anime>> getAnimeList();
}

// AnimeRepository インスタンスを提供するための Provider
final animeRepositoryProvider = Provider<BaseAnimeRepository>((ref) {
  return AnimeRepository(ref);
});

// Supabase クライアントのインスタンスを提供するための Provider
final supabaseDBProvider =
    Provider<SupabaseClient>((ref) => Supabase.instance.client);

class AnimeRepository implements BaseAnimeRepository {
  // 外部からProviderを取得可能にする
  final Ref _ref;
  const AnimeRepository(this._ref);

  // 取得
  
  Future<List<Anime>> getAnimeList() async {
    try {
      final response =
          await _ref.read(supabaseDBProvider).from('Anime').select();
      if (response.isNotEmpty) {
        final error = response.first['error'];
        if (error != null) {
          throw Exception(error.toString());
        }
      }
      return response.map((e) => Anime.fromRow(e)).toList();
    } catch (e) {
      debugPrint('Error about getAnimeList: $e');
      throw Exception(e.toString());
    }
  }

ViewModel

続いて、ViewModel の実装です。
Flutter の状態管理ライブラリである Riverpodを使用して、アニメのリストを管理するための仕組みを実装しています。このライブラリをすようすることで、アニメのリストを非同期に取得し、その状態を管理できます。

import 'package:your_project_name/models/anime.dart';
import 'package:your_project_name/repositories/anime_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// animeListProvider は StateNotifierProvider を使用して、アニメのリストの状態を管理するためのプロバイダーを定義している。
// StateNotifirerProvider は StateNotifier を使用して状態管理を行う。ここでは、 AnimeListNotifier クラスがその役割を担っている。
// AsyncValue<List<Anime>> は、非同期操作の結果を表す型で、ロード中、成功、失敗の状態を持つことができる。
final animeListProvider =
    StateNotifierProvider<AnimeListNotifier, AsyncValue<List<Anime>>>((ref) {
  return AnimeListNotifier(ref);
});

// AnimeListNotifier は StateNotifier を継承し、アニメのリストを管理するクラス
// コンストラクタでは、 Ref オブジェクトを受け取り、初期状態を AsyncValue.loading() に設定
// コンストラクタ内で getAnimeList メソッドを呼び出し、アニメのリストを非同期に取得
class AnimeListNotifier extends StateNotifier<AsyncValue<List<Anime>>> {
  final Ref _ref;

  AnimeListNotifier(this._ref) : super(const AsyncValue.loading()) {
    getAnimeList();
  }

  // 取得
  Future<void> getAnimeList({bool isRefreshing = false}) async {
    if (isRefreshing) state = const AsyncValue.loading();
    try {
      final animes = await _ref.read(animeRepositoryProvider).getAnimeList();
      if (mounted) {
        state = AsyncValue.data(animes);
      }
    } catch (e) {
      throw e.toString();
    }
  }

View

上記に記載した Model、 ViewModel、 Repository 実装を View で画面に表示していこうと思います。
ただ、今回アウトプットで出力する画面の View は少々複雑な構成になっているので、今回は一旦簡単な実装の表示用画面のコードを提供します。 Read できたかを確認する際には、以下で十分だと思います。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_project_name/models/anime.dart';
import 'package:your_project_name/viewmodels/anime_view_model.dart';

class AnimeListView extends ConsumerWidget {
  const AnimeListView({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final animeListState = ref.watch(animeListProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Anime List'),
      ),
      body: animeListState.when(
        data: (animes) {
          return ListView.builder(
            itemCount: animes.length,
            itemBuilder: (context, index) {
              final anime = animes[index];
              return ListTile(
                leading: Image.network(anime.coverImage),
                title: Text(anime.title),
                subtitle: Text(anime.description),
              );
            },
          );
        },
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stack) => Center(child: Text('Error: $error')),
      ),
    );
  }
}

main

以下も View と同様、簡易版をコードとして残します。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:your_project_name/view/check_view_list_animes.dart'; // AnimeListViewが定義されているファイルをインポート

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await dotenv.load(fileName: ".env");
  await Supabase.initialize(
    url: dotenv.env['SUPABASE_URL']!,
    anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
  );
  runApp(const ProviderScope(child: MyApp()));
  debugPrint('App started');
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Anime List App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const AnimeListView(),
    );
  }
}

まとめ

MVVM (Model-View-ViewModel)アーキテクチャと Repository パターンを組み合わせて、 Supabase に入れたデータを Read するまでを解説しました!
なにかわかりにくい部分などあればコメントなど頂ければ幸いです!

Discussion

JboyHashimotoJboyHashimoto

他の人でもRepository class にロジック書く人いた。ロジックは、Service class, Dto classなるものに分けた方がいいという人もいますね。

ViewModelは専用のフォルダ作るんですね。