【Flutter/Dart】flutter_riverpodでアーキテクチャを意識して開発

Flutterに戻る

目次

概要

開発コストを低くするためにも、そして複数名のエンジニアとチームを組んで開発する上でもアーキテクチャを意識した開発は大切だ。求められるエンジニアになるためにも。

アーキテクチャは様々だが、Flutterではriverpodが使われる。これはFlutterの「状態管理ライブラリ」だ。アーキテクチャを意識した記事はほぼ見かけなかったため記録に残す。

今回はjsonファイルを使ってデータのやり取りを行う。

準備

riverpodのインストール

riverpodはライブラリなのでインストールする必要がある。
まずはターミナルを開こう。AndroidStudioを使っているなら、下の方にあるこのターミナルアイコンを選択しよう。

そこで、以下のコマンドを叩く。

$ flutter pub add flutter_riverpod

そして、pubspec.yamlファイルにが追加されていることを確認する

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8
  flutter_riverpod: ^2.6.1

そうするとflutter_riverpodをimportできるようになる。

import 'package:flutter_riverpod/flutter_riverpod.dart';

jsonファイルを追加

今回はデータの取得して表示するという処理をjsonファイルを用いて行う。
詳しくはこちらに記載する。

ファイル構成

今回はホーム画面(HomeView)のボタンを押下したら住所のデータをjsonファイルから取得して表示するということをやってみる。

ファイル構成は以下のようになっている。

  • images(画像ファイルを格納する)
  • json(jsonファイルを格納する)
  • lib(Flutterのファイル群)
    • Entity(レスポンスデータクラスなどを格納する)
    • Repository
    • Service
    • View
    • ViewModel

図で表すと以下のようなイメージだ。

Flutter公式ドキュメントより引用
  • Repository
  • Service
    API通信やデータベースへの参照を行う。
    同時に、Repositoryへ取得したデータを渡す処理を行う。
  • View
    UIなど画面表示を行うファイル群を保存しておく。
  • ViewModel
    ViewとRepositoryとの橋渡しを行う。

ソースコード

JSONファイル

{
  "message": null,
  "results": [
    {
      "address1": "北海道",
      "address2": "美唄市",
      "address3": "上美唄町協和",
      "kana1": "ホッカイドウ",
      "kana2": "ビバイシ",
      "kana3": "カミビバイチョウキョウワ",
      "prefcode": "1",
      "zipcode": "0790177"
    },
    {
      "address1": "北海道",
      "address2": "美唄市",
      "address3": "上美唄町南",
      "kana1": "ホッカイドウ",
      "kana2": "ビバイシ",
      "kana3": "カミビバイチョウミナミ",
      "prefcode": "1",
      "zipcode": "0790177"
    }
  ],
  "status": 200
}

レスポンスクラス

class ZipCloudEntity {
  final String address1;
  final String address2;
  final String address3;
  final String kana1;
  final String kana2;
  final String kana3;
  final String prefCode;
  final String zipcode;

  ZipCloudEntity({
    required this.address1,
    required this.address2,
    required this.address3,
    required this.kana1,
    required this.kana2,
    required this.kana3,
    required this.prefCode,
    required this.zipcode,
  });

  factory ZipCloudEntity.fromData(dynamic data) {
    final String address1 = data['address1'];
    final String address2 = data['address2'];
    final String address3 = data['address3'];
    final String kana1 = data['kana1'];
    final String kana2 = data['kana2'];
    final String kana3 = data['kana3'];
    final String prefCode = data['prefcode'];
    final String zipcode = data['zipcode'];

    final model = ZipCloudEntity(
      address1: address1,
      address2: address2,
      address3: address3,
      kana1: kana1,
      kana2: kana2,
      kana3: kana3,
      prefCode: prefCode,
      zipcode: zipcode,
    );

    return model;
  }
}

View

メイン部分

import 'package:flutter/material.dart';
import 'package:flutter_demo_application/View/home_view.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Demo Application',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: HomeView(),
    );
  }
}

ホーム画面

import 'package:flutter/material.dart';
import 'package:flutter_demo_application/Entity/zip_cloud_entity.dart';
import 'package:flutter_demo_application/view_model/home_view_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class HomeView extends ConsumerWidget {
  HomeView({super.key});

  List<ZipCloudEntity> zipCloudList = <ZipCloudEntity>[];

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    var button = OutlinedButton(
      onPressed: () {
        Future<List<ZipCloudEntity>> zipCloudStateProvider =
            ref
                .watch(homeViewModelStateNotifierProvider.notifier)
                .getStateZipCloudData();
        zipCloudStateProvider.then((list) {
          zipCloudList = list;
          ref.invalidate(homeViewModelNotifierProvider);
        });
      },
      style: OutlinedButton.styleFrom(
        backgroundColor: Colors.white10,
        foregroundColor: Colors.black,
        disabledBackgroundColor: Colors.black26,
        disabledForegroundColor: Colors.black54,
      ),
      child: Text("データ取得"),
    );

    return MaterialApp(
      theme: ThemeData(primarySwatch: Colors.grey),
      home: Scaffold(
        appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: const Text(''),
        ),
        body: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          children: <Widget>[
            button,
            ref
                .watch(homeViewModelNotifierProvider)
                .when(
                  data:
                      (_) => ListView.builder(
                        scrollDirection: Axis.vertical,
                        shrinkWrap: true,
                        itemCount: zipCloudList.length,
                        itemBuilder: (_, index) {
                          final zipCloud = zipCloudList[index];
                          return zipCloudCell(zipCloud);
                        },
                      ),
                  error: (error, _) => const Center(child: Text('通信エラー')),
                  loading:
                      () => const Center(child: CircularProgressIndicator()),
                ),
          ],
        ),
      ),
    );
  }

  Widget zipCloudCell(ZipCloudEntity entity) {
    return Container(
      padding: const EdgeInsets.all(12.0),
      decoration: const BoxDecoration(
        border: Border(bottom: BorderSide(color: Colors.grey, width: 1.0)),
      ),
      child: Padding(
        padding: const EdgeInsets.only(bottom: 8.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text("〒${entity.zipcode}"),
            Text("${entity.kana1} ${entity.kana2} ${entity.kana3}"),
            Text("${entity.address1} ${entity.address2} ${entity.address3}"),
          ],
        ),
      ),
    );
  }
}

ViewModel

import 'package:flutter_demo_application/Entity/zip_cloud_entity.dart';
import 'package:flutter_demo_application/repository/home_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final homeViewModelNotifierProvider = FutureProvider<List<ZipCloudEntity>>((
  ref,
) async {
  final viewModel = await HomeViewModel(
    repository: ref.read(homeRepositoryProvider),
  );
  await viewModel.getZipCloudData();
  return viewModel.zipCloudData;
});

final homeViewModelStateNotifierProvider =
    StateNotifierProvider<ZipCloudEntityStateNotifier, List<ZipCloudEntity>>((
      ref,
    ) {
      return ZipCloudEntityStateNotifier(ref);
    });

class ZipCloudEntityStateNotifier extends StateNotifier<List<ZipCloudEntity>> {
  ZipCloudEntityStateNotifier(this.ref) : super(<ZipCloudEntity>[]);

  final Ref ref;

  Future<List<ZipCloudEntity>> getStateZipCloudData() {
    final repository = ref.read(homeRepositoryProvider);
    final homeModel = repository.getZipCloudData();
    return homeModel;
  }
}

class HomeViewModel {
  final HomeRepository repository;
  HomeViewModel({required this.repository});

  late List<ZipCloudEntity> _zipCloudData;
  List<ZipCloudEntity> get zipCloudData => _zipCloudData;

  bool isLoading = false;

  Future getZipCloudData() async {
    try {
      final data = await repository.getZipCloudData();
      _zipCloudData = data;
    } on Exception catch (exception) {
      Exception(exception);
    }
  }
}

Repository

import 'package:flutter_demo_application/Entity/zip_cloud_entity.dart';
import 'package:flutter_demo_application/service/home_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final homeRepositoryProvider = Provider<HomeRepository>(
  (ref) => HomeRepository(service: ref.read(homeServiceProvider)),
);

class HomeRepository {
  final HomeService service;
  HomeRepository({required this.service});

  Future<List<ZipCloudEntity>> getZipCloudData() async {
    try {
      final data = await service.getZipCloudDataService();
      return data;
    } on Exception catch (exception) {
      throw Exception(exception);
    }
  }
}

Service

import 'dart:convert';

import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_demo_application/Entity/zip_cloud_entity.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final homeServiceProvider = Provider<HomeService>((ref) {
  return HomeService();
});

class HomeService {
  Future<List<ZipCloudEntity>> getZipCloudDataService() async {
    try {
      String dummy = await rootBundle.loadString("json/dummy.json");
      final response = json.decode(dummy);
      response.forEach((key, value) {
        dummy = '$key: $value \x0A';
        return dummy;
      });

      final List<dynamic> responseData = response['results'];
      List<ZipCloudEntity> zipCloudList = <ZipCloudEntity>[];
      for (dynamic data in responseData) {
        final model = ZipCloudEntity.fromData(data);
        zipCloudList.add(model);
      }

      return zipCloudList;
    } on Exception catch (exception) {
      throw Exception(exception);
    }
  }
}

デモ動画

詳細

まず、JSONファイルとレスポンスクラスに関しては割愛する。

HomeService

ここではjsonファイルから直接する取得する処理を行っている。

まず、jsonをデータにする

String dummy = await rootBundle.loadString("json/dummy.json");
final response = json.decode(dummy);

そのあと、jsonデータを連想配列に変換する

response.forEach((key, value) {
    dummy = '$key: $value \x0A';
    return dummy;
});

それをレスポンスクラスに格納する。

final List<dynamic> responseData = response['results'];
List<ZipCloudEntity> zipCloudList = <ZipCloudEntity>[];
for (dynamic data in responseData) {
    final model = ZipCloudEntity.fromData(data);
    zipCloudList.add(model);
}

return zipCloudList;

そして、上記の実装をしているメソッドを呼び出すために、HomeServiceのインスタンスを返すProviderのオブジェクトを作成する。
こうすることで他のクラスからHomeServiceを参照できるようにする。

final homeServiceProvider = Provider<HomeService>((ref) {
  return HomeService();
});

HomeRepository

まず、メンバ変数としてHomeServiceを定義する。これを使ってHomeServiceで定義したデータを取得するメソッドを呼び出す。
そして、インスタンスメソッドにはHomeServiceをパラメータに設定する。

class HomeRepository {
  final HomeService service;
  HomeRepository({required this.service});

  // 省略
}

そして、このRepositoryを他のクラスから呼び出せるようにProviderのオブジェクトを作成する。
Provider<HomeService>をreadメソッドで読み込むことで初期化に必要なHomeServiceオブジェクトを作成するわけですね。

final homeRepositoryProvider = Provider<HomeRepository>(
  (ref) => HomeRepository(service: ref.read(homeServiceProvider)),
);

HomeViewModel

まず根本はRepositoryと同じ。

class HomeViewModel {
  final HomeRepository repository;
  HomeViewModel({required this.repository});

  // 省略
}

ViewModelを呼び出せるように以下のようにProviderのオブジェクトを作成する。

FutureProviderはViewに表示するためのもの。
ここではzipCloudDataを取得する。取得したものはHomeViewでViewを生成するのに使われている。

final homeViewModelNotifierProvider = FutureProvider<List<ZipCloudEntity>>((
  ref,
) async {
  final viewModel = await HomeViewModel(
    repository: ref.read(homeRepositoryProvider),
  );
  await viewModel.getZipCloudData();
  return viewModel.zipCloudData;
});

StateNotifierProviderは取得したデータを変数として返し、値が更新されたらViewに反映させるような動作を行う。

StateNotifier<List<ZipCloudEntity>>を継承したクラスを作成する。
StateNotifierで値が更新されたら画面に反映させるようにする。
そして、その更新されたら画面に反映されるデータをgetStateZipCloudDataメソッドで取得する。わかるとおり、ここでRepositoryのメソッドを呼び出す。

final homeViewModelStateNotifierProvider =
    StateNotifierProvider<ZipCloudEntityStateNotifier, List<ZipCloudEntity>>((
      ref,
    ) {
      return ZipCloudEntityStateNotifier(ref);
    });

class ZipCloudEntityStateNotifier extends StateNotifier<List<ZipCloudEntity>> {
  ZipCloudEntityStateNotifier(this.ref) : super(<ZipCloudEntity>[]);

  final Ref ref;

  Future<List<ZipCloudEntity>> getStateZipCloudData() {
    final repository = ref.read(homeRepositoryProvider);
    final homeModel = repository.getZipCloudData();
    return homeModel;
  }
}

StateNotifierProviderの二つの型は、一つ目が返すオブジェクトの型、二つ目が継承するStateNotifierクラスの型らしい。
つまり、メソッドで返すデータの型。

ViewModelの処理内容を見ていこう

まずは以下。取得したデータの変数、そしてロード中かどうかを示すフラグだ。

late List<ZipCloudEntity> _zipCloudData;
List<ZipCloudEntity> get zipCloudData => _zipCloudData;

bool isLoading = false;

そして、以下の箇所でRepositoryのメソッドを呼び出す。
取得したデータはViewで使用する。

Future getZipCloudData() async {
  try {
    final data = await repository.getZipCloudData();
    _zipCloudData = data;
  } on Exception catch (exception) {
    Exception(exception);
  }
}

HomeView

取得したデータは以下の変数に格納する。

List<ZipCloudEntity> zipCloudList = <ZipCloudEntity>[];

そして、データの取得、そして

ボタンを押下したら、データを取得する処理を行う。
onPressedはボタンを押下した時の処理。

var button = OutlinedButton(
  onPressed: () {
    Future<List<ZipCloudEntity>> zipCloudStateProvider =
      ref.watch(homeViewModelStateNotifierProvider.notifier).getStateZipCloudData();
        zipCloudStateProvider.then((list) {
          zipCloudList = list;
          ref.invalidate(homeViewModelNotifierProvider);
        });
      },

// 省略

以下でデータの再取得を行う。

ref.watch(homeViewModelStateNotifierProvider.notifier).getStateZipCloudData();

そして、以下で値の更新を行う。
invalidateメソッドは更新をして画面の再読み込みを行う。こうすることでデータを取得したら画面を更新する処理を行う。

zipCloudStateProvider.then((list) {
  zipCloudList = list;
  ref.invalidate(homeViewModelNotifierProvider);
});

取得したデータの表示部分は以下。
watchメソッドで値を監視し、更新されたタイミングでリストの更新を行っている。

ref.watch(homeViewModelNotifierProvider)
  .when(
    data: (_) => ListView.builder(
      scrollDirection: Axis.vertical,
      shrinkWrap: true,
      itemCount: zipCloudList.length,
      itemBuilder: (_, index) {
        final zipCloud = zipCloudList[index];
        return zipCloudCell(zipCloud);
      },
    ),
    error: (error, _) => const Center(child: Text('通信エラー')),
    loading: () => const Center(child: CircularProgressIndicator()),
  ),

参考ページ

Flutterに戻る