39
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Flutter]MVVM + Repositoryパターンでニュースアプリを開発した(はじめてのRiverPod)

Posted at

投稿の経緯

Flutterの学習をしており、これまで触れてこなかったRiverpodに着手。
簡単なニュースアプリを開発したので記事にしました。

環境

Flutter version:3.7.3
Dart version:2.19.2
IDE:Android Studio

アーキテクチャ

MVVM + Repositoryパターンを採用。各レイヤーの役割を簡単に説明していく。
(知ってる人は読み飛ばしてください)

DataStore

APIとの通信を担当。
今回通信するAPIはNews API。(無料で利用できる)

Repository

DataStoreへのアクセスを抽象化するためのパイプ役。
DIP(依存関係逆転の原則)を用いてDate layer と Presentation layer を切り分けている。

Model

API通信の結果を格納する役割。

ViewModel

Viewで利用するデータを管理するModel。(ViewのModel)
RiverpodのFutureProviderによって監視されており、API通信の結果をViewへ通知する。

View

画面表示やユーザーからのアクションを受け取る役割。
今回は取得したニュースをListView形式で表示し、記事タップで該当するニュースページを開くシンプルな画面。

使用したライブラリ

今回の開発で使用したライブラリを簡単に紹介。

Dio

API通信(HTTPリクエスト)を実行するためのライブラリ。
Dartで書かれており、シンプルな書きかたでAPIとの通信を実現することができる。

flutter_riverpod

Riverpodと呼ばれる状態管理とDI(依存性注入)を簡潔におこなうためのライブラリ。
Flutter開発では中心的な役割。公式ドキュメントが日本語対応なところが素敵。

Riverpodには複数の種類が存在し、私はどれを使えばいいのかで悩んだので要約してまとめておきます。

  • flutter_riverpod:FlutterでRiverpodを利用するための基本的な機能を提供している
  • hooks_riverpod:Riverpodに加えてflutter_hooksも同時利用する場合に指定する
  • riverpod:Dartでのみ動き、Flutterに対応していない

今回はflutter_riverpodを利用しました。

url_launcher

外部のURLをアプリ内で開くために利用するライブラリ。
今回はニュース記事のURLをAPIのレスポンスで取得しており、そのページを開くときに利用する。

注意点

Android版でurl_luncherを利用する場合は、AndroidManifest.xmlに下記のqueriesが必要。
どうやらAndroid11(API30)以上のアプリの場合に必要になる模様。

AndroidManifest.xml
package="com.xxxxx.アプリ名">
// ↓以下の queries が必要
<queries>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="https" />
    </intent>
</queries>

AndroidManifest.xmlは/android/app/src/main/の中にあります。

queriesを書き終えたらflutter cleanを実行。
実行しないと設定が反映されません。(私はここで少し詰まりました)

↓以下参考記事。(url_launcherの公式ドキュメントにも記述あり)

実装

ここからはコードの説明です。

APIのレスポンス

.json
{
  "status": "ok",
  "totalResults": 29,
  "articles": [
    {
      "source": {
      "id": "google-news",
      "name": "Google News"
      },
      "author": "nhk.or.jp",
      "title": "G7農相会合始まる 食料安全保障の強化 一致できるか焦点 | NHK - nhk.or.jp",
      "description": null,
      "url": "https://news.google.com/rss/articles/CBMiPmh0dHBzOi8vd3d3My5uaGsub3IuanAvbmV3cy9odG1sLzIwMjMwNDIyL2sxMDAxNDA0NTk0MTAwMC5odG1s0gEA?oc=5",
      "urlToImage": null,
      "publishedAt": "2023-04-22T04:04:36Z",
      "content": null
    },
    {
      "source": {
      "id": "google-news",
      "name": "Google News"
      },
      "author": "産経ニュース",
       "title": "ジャニーズ事務所が説明文書 性加害訴え受け、取引先に - 産経ニュース",
       "description": null,
      "url": "https://news.google.com/rss/articles/CBMiQ2h0dHBzOi8vd3d3LnNhbmtlaS5jb20vYXJ0aWNsZS8yMDIzMDQyMi1WREpFTzU1MlI1TldOUElaV0xXMktRWUdFWS_SAVJodHRwczovL3d3dy5zYW5rZWkuY29tL2FydGljbGUvMjAyMzA0MjItVkRKRU81NTJSNU5XTlBJWldMVzJLUVlHRVkvP291dHB1dFR5cGU9YW1w?oc=5",
      "urlToImage": null,
      "publishedAt": "2023-04-22T03:32:19Z",
      "content": null
    },
    
    // 以下省略
}

今回はレスポンスから

  • author
  • title
  • url

を取得しModelに格納する。

Model

news_fetch_response_models.dart
import 'package:news_app/model/news_fetch_response_model.dart';

class NewsFetchResponseModels {
  List<NewsFetchResponseModel> datas = <NewsFetchResponseModel>[];
}
news_fetch_response_model.dart
class NewsFetchResponseModel {
  final String author;
  final String title;
  final String url;

  NewsFetchResponseModel({
    required this.author,
    required this.title,
    required this. url
  });

  factory NewsFetchResponseModel.fromData(dynamic data) {
    final author = data['author'];
    final title = data['title'];
    final url = data['url'];

    final model = NewsFetchResponseModel(
        author: author,
        title: title,
        url: url
    );

    return model;
  }
}

APIのレスポンスを配列で所持し、Presentation layer で使う。
fromDataの中でデータを保存している。

DataStore

news_fetch_datastore_interface.dart
import 'package:news_app/model/news_fetch_response_models.dart';

abstract class NewsFetchDataStoreInterface {
  Future<NewsFetchResponseModels> fetchNewsData();
}
news_fetch_datastore.dart
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:news_app/application/secret.dart';
import 'package:news_app/datastore/news_fetch_datastore_interface.dart';
import 'package:news_app/model/news_api_exception.dart';
import 'package:news_app/model/news_fetch_response_model.dart';
import 'package:news_app/model/news_fetch_response_models.dart';

class NewsFetchDataStore implements NewsFetchDataStoreInterface {
  final Dio dio;
  NewsFetchDataStore({required this.dio});

  @override
  Future<NewsFetchResponseModels> fetchNewsData() async {
    const url = 'https://newsapi.org/v2/top-headlines?country=jp&apiKey=$key';

    try {
      final response = await dio.get(url);
      final responseData = response.data;
      final List<dynamic> datas = responseData['articles'];
      final models = NewsFetchResponseModels();

      datas.forEach((data) {
        final model = NewsFetchResponseModel.fromData(data);
        models.datas.add(model);
      });

      return models;
    } on Exception catch(exception) {
      debugPrint('Fail fetchNewsData.');
      throw NewsApiException(exception.toString());
    } finally {
      debugPrint('End fetchNewsData from datastore.');
    }
  }
}
dio_provider.dart
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final dioProvider = Provider(
        (ref) => Dio()
);
news_fetch_datastore_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:news_app/datastore/news_fetch_datastore_interface.dart';
import 'package:news_app/datastore/news_fetch_datastore.dart';
import 'package:news_app/provider/dio_provider.dart';

final newsFetchDataStoreProvider = Provider<NewsFetchDataStoreInterface>(
        (ref) => NewsFetchDataStore(dio: ref.read(dioProvider))
);

Dioを使ってAPI通信を実行するクラス。
APIのレスポンスをModelに格納し、Future型として呼び出し元へ返す。

Interfaceを定義することでモックとの差し替えを容易にしテストを書きやすくしている。

一番最後のファイルではRiverpodのProviderを利用して、DataStoreをインスタンス化しています。いわゆるDI(依存性注入)です。外部から依存関係を注入することで、疎結合な状態を保てます。

Repository

news_repository_interface.dart
import 'package:news_app/model/news_fetch_response_models.dart';

abstract class NewsRepositoryInterface {
  Future<NewsFetchResponseModels> fetchNewsData();
}
news_repository.dart
import 'package:flutter/material.dart';
import 'package:news_app/datastore/news_fetch_datastore_interface.dart';
import 'package:news_app/model/news_fetch_response_models.dart';
import 'package:news_app/repository/news_repository_interface.dart';

class NewsRepository implements NewsRepositoryInterface {
  final NewsFetchDataStoreInterface dataStore;
  NewsRepository({required this.dataStore});

  @override
  Future<NewsFetchResponseModels> fetchNewsData() async {
    try {
      final data = await dataStore.fetchNewsData();
      return data;
    } on Exception catch(exception) {
      rethrow;
    } finally {
      debugPrint('End fetchNewsData from repository.');
    }
  }
}
news_repository_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:news_app/provider/news_fetch_datastore_provider.dart';
import 'package:news_app/repository/news_repository.dart';
import 'package:news_app/repository/news_repository_interface.dart';

final newsRepositoryProvider = Provider<NewsRepositoryInterface>(
        (ref) => NewsRepository(dataStore: ref.read(newsFetchDataStoreProvider))
);

Repositoryパターンにおいて重要なクラス。
ViewModelにどこからデータを取得・更新するのかを意識させないために、Date layer と Presentation layer を切り分けることを目的としている。(ViewModelはRepositoryInterfaceに依存している)

RepositoryもDatStore同様に、Providerを用いてDIしています。
ref.read()でProviderの値を取得しており、RepositoryにDataStoreの依存性を注入しています。

Riverpodを使えばこのようにシンプルな記述でDIできるのも利点のひとつです。

ViewModel

news_view_model.dart
import 'package:flutter/material.dart';
import 'package:news_app/repository/news_repository_interface.dart';
import '../model/news_fetch_response_models.dart';

class NewsViewModel {
  final NewsRepositoryInterface repository;
  NewsViewModel({required this.repository});

  late NewsFetchResponseModels _news;
  NewsFetchResponseModels get news => _news;

  Future fetchNewsData() async {
    try {
      final data = await repository.fetchNewsData();
      _news = data;
    } on Exception catch(exception) {
      rethrow;
    } finally {
      debugPrint('End fetchNewsData from view_model.');
    }
  }
}
news_view_model_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:news_app/model/news_fetch_response_models.dart';
import 'package:news_app/provider/news_repository_provider.dart';
import 'package:news_app/view_model/news_view_model.dart';

final newsViewModelNotifierProvider = FutureProvider<NewsFetchResponseModels>((ref) async {
   final viewModel = NewsViewModel(repository: ref.read(newsRepositoryProvider));
   await viewModel.fetchNewsData();

   return viewModel.news;
});

MVVMアーキテクチャで重要となるクラス。
ViewModelはFutureProviderを利用しており、その中で非同期処理を実行します。
また、FutureProviderはAsyncValueのオブジェクトも生成します。

AsyncValueは非同期処理の

  • 通信状態(Loading)
  • 通信終了(Success)
  • 異常終了(error)

をハンドリングしてくれるRiverPodの機能です。

View側ではこのAsyncValueを利用して、通信状態によりUIを自動で切り替えることができます。

↓公式ドキュメント

View

news_widget.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:news_app/provider/news_view_model_provider.dart';
import 'package:url_launcher/url_launcher.dart';

class NewsWidget extends ConsumerWidget {
  const NewsWidget({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {

    return MaterialApp(
        theme: ThemeData(primarySwatch: Colors.grey),
        home: Scaffold(
            appBar: AppBar(
              title: const Text('News'),
            ),
            body: ref.watch(newsViewModelNotifierProvider).when(
                data: (articles) => ListView.builder(
                    itemCount: articles.datas.length,
                    itemBuilder: (_, index) {
                      final news = articles.datas[index];
                      return _newsItem(news.title, news.author, news.url);
                    }),
                error: (error, _) => const Center(
                    child: Text('通信エラー')
                ),
                loading: () => const Center(
                    child: CircularProgressIndicator()
                )
            )
        )
    );
  }

  Widget _newsItem(String title, String author, String url) {
    return GestureDetector(
      child: Container(
          padding: const EdgeInsets.all(12.0),
          decoration: const BoxDecoration(
              border: Border(bottom: BorderSide(color: Colors.grey, width: 1.0))
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Padding(
                padding: const EdgeInsets.only(bottom: 8.0),
                child: Text(
                  title,
                  style: const TextStyle(color: Colors.black, fontSize: 16.0),
                ),
              ),
              Text(author, style: const TextStyle(color: Colors.grey, fontSize: 12.0),
              ),
            ],
          )),
      onTap: () {
        _launchUrl(url);
      },
    );
  }

  Future _launchUrl(String url) async {
    final uri = Uri.parse(url);
    
    if (await canLaunchUrl(uri)) {
      await launchUrl(uri);
    } else {
      debugPrint('Cloud not launch: $url');
    }
  }
}

ConsumerWidgetを継承しており、ref.watch(newsViewModelNotifierProvider)でFutureProviderでインスタンス化したViewModelを監視しています。

また、.whenで、先述したAsyncValueのハンドリングをおこなっており

  • data:通信処理が正常に終了した場合のレイアウト
  • error:通信処理が異常を検知して終了した場合のレイアウト
  • loading:通信処理中のレイアウト

それぞれが自動で切り替わるようになっています。

_launchUrl(url)でアプリ内ブラウザを起動しニュースの閲覧が実現できています。

実際の挙動

iOS

iOS_AdobeExpress.gif

Android

android_AdobeExpress.gif

おまけ

せっかくRepositoryパターンや、DI(依存性注入)や、DIP(依存関係逆転の原則)を使って開発したので、書ける部分のユニットテストを書いたので紹介。

Datastore

news_fetch_datastore_test.dart
import 'package:dio/dio.dart';
import 'package:test/test.dart';
import 'package:news_app/datastore/news_fetch_datastore.dart';
import 'package:news_app/model/news_api_exception.dart';

  void main() {
    test('APIのレスポンスがnilではない', () async {
      final dataStore = NewsFetchDataStore(dio: Dio());
      final data = await dataStore.fetchNewsData();
      final result = data.datas.isNotEmpty;

      expect(result, true);
    });

    test('NewsApiException型のExceptionが返ってくる', () async {
      final dio = Dio(BaseOptions(validateStatus: (status) => false));
      final dataStore = NewsFetchDataStore(dio: dio);

      expect(() => dataStore.fetchNewsData(), throwsA(const TypeMatcher<NewsApiException>()));
    });
  }

Repository

news_repository_test.dart
import 'package:test/test.dart';
import 'package:news_app/repository/news_repository.dart';
import 'mock/mock_news_fetch_datastore.dart';

void main() {
  test('dataStoreのfetchNewsDataが呼ばれたことが確認できる', () {
    final dataStore = MockNewsFetchDataStore();
    final repository = NewsRepository(dataStore: dataStore);
    repository.fetchNewsData();

    expect(dataStore.isFetchNewsDataCalled, isTrue);
  });
}

ViewModel

news_view_model_test.dart
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:news_app/datastore/news_fetch_datastore.dart';
import 'package:news_app/repository/news_repository.dart';
import 'package:test/test.dart';
import 'package:news_app/view_model/news_view_model.dart';
import 'mock/mock_news_repository.dart';

void main() {
  test('repositoryのfetchNewsDataが呼ばれたことが確認できる', () {
    final repository = MockNewsRepository();
    final viewModel = NewsViewModel(repository: repository);
    viewModel.fetchNewsData();

    expect(repository.isFetchNewsDataCalled, isTrue);
  });

  test('viewModelのnewsに値が格納されている', () async {
    final dataStore = NewsFetchDataStore(dio: Dio());
    final repository = NewsRepository(dataStore: dataStore);
    final viewModel = NewsViewModel(repository: repository);
    await viewModel.fetchNewsData();

    viewModel.news.datas.forEach((data) {
      debugPrint('news:${data.title}');
    });

    expect(viewModel.news.datas, isNotEmpty);
  });
}

どうやら他にもProviderのテストや、Widget Testと言われるテストがあるようなので、そちらは学習しながら追加していこうと思います。(今回の記事では紹介なし)

おわりに

最後までご覧いただきありがとうございます!

まだFlutterの学習を始めたばかりなので至らぬところがあるかもしれません。(Riverpodの使い方とかテストとか)
もし気になる点がございましたらご教授いただけると幸いです!

39
35
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?