投稿の経緯
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)以上のアプリの場合に必要になる模様。
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のレスポンス
{
"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
import 'package:news_app/model/news_fetch_response_model.dart';
class NewsFetchResponseModels {
List<NewsFetchResponseModel> datas = <NewsFetchResponseModel>[];
}
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
import 'package:news_app/model/news_fetch_response_models.dart';
abstract class NewsFetchDataStoreInterface {
Future<NewsFetchResponseModels> fetchNewsData();
}
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.');
}
}
}
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final dioProvider = Provider(
(ref) => Dio()
);
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
import 'package:news_app/model/news_fetch_response_models.dart';
abstract class NewsRepositoryInterface {
Future<NewsFetchResponseModels> fetchNewsData();
}
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.');
}
}
}
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
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.');
}
}
}
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
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
Android
おまけ
せっかくRepositoryパターンや、DI(依存性注入)や、DIP(依存関係逆転の原則)を使って開発したので、書ける部分のユニットテストを書いたので紹介。
Datastore
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
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
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の使い方とかテストとか)
もし気になる点がございましたらご教授いただけると幸いです!