🙆

Flutter Riverpod+Flutter hooks+StataNotifier+freezedを使ってQiitaアプリを作ってみた

2021/06/17に公開

なんぞや

普段バックエンドを担当しているエンジニアが、Flutterを触らせてもらうことになったので、勉強がてら作成してみました。

最初にどうしようかなとなったのが状態管理。
状態管理にも、BLoCやらProviderやら色々あるっぽいですが、最近流行り?よく見かける構成の
Riverpod + Flutter hooks+ StateNotifier + freezedを使うことにしました。

作ったもの

QiitaAPIを使ったアプリ(https://qiita.com/api/v2/docs)。
記事一覧・記事詳細(WebView)・検索ページがあります。
あとは無限スクロールと下に引っ張って更新も実装しています。


記事一覧


検索(本文検索)

https://github.com/yumiba109/qiita_library

クローン後、rootディレクトリで
flutter pub run build_runner build --delete-conflicting-outputs
flutter run
これでアプリが起動します。

内容

パッケージ

導入したパッケージはこんな感じ。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
    
  flutter_hooks: ^0.16.0
  hooks_riverpod: ^0.13.0

  state_notifier: ^0.7.0

  dio: ^4.0.0-beta4

  json_serializable: ^4.0.2
  freezed_annotation: ^0.14.2

  shared_preferences: ^2.0.3

  intl: ^0.17.0

  webview_flutter: ^2.0.8

  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter

  build_runner: ^1.11.1
  freezed: ^0.14.0

flutter pub getでインストールします。

ディレクトリ構成

ディレクトリ構成とファイル名はこちら。

ディレクトリ構成

MVVM + Repositoryパターンで実装しています。

アーキテクチャ図

ViewModel

MVVMの中核となる部分。記事一覧・検索キーワードなどの状態を保持して、Viewに渡す役割を担います。

View

見た目の部分。ViewModelから値を受け取って表示します。

Repository

ViewModelでデータを取得するとき、その取得先がサーバーなのかローカルなのかを意識させないためのもの(?)

モデルの作成

記事モデル(QiitaArticle)と記事の投稿者(QiitaUser)モデルを作成します。

freezedを使って、immutable(不変)にしています。
こうすることで、どのコードがアクセスしても同じ内容であることを保証することができます。

https://pub.dev/packages/freezed

models/qiita_article.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:qiita_library/models/qiita_user.dart';

part 'qiita_article.freezed.dart';
part 'qiita_article.g.dart';


abstract class QiitaArticle with _$QiitaArticle {
  factory QiitaArticle({
    String? title,
    String? url,
    QiitaUser? user,
    List? tags,
    (name: 'created_at') String? createdAt,
  }) = _QiitaArticle;

  factory QiitaArticle.fromJson(Map<String, dynamic> json) =>
      _$QiitaArticleFromJson(json);
}
models/qiita_user.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'qiita_user.freezed.dart';
part 'qiita_user.g.dart';


abstract class QiitaUser with _$QiitaUser {
  factory QiitaUser({
    String? id,
    (name: 'profile_image_url') String? profileImageUrl,
  }) = _QiitaUser;

  factory QiitaUser.fromJson(Map<String, dynamic> json) =>
      _$QiitaUserFromJson(json);
}

コード生成

rootディレクトリで、
flutter pub run build_runner build --delete-conflicting-outputs
を実行し、freezedのコードを生成します。

--delete-conflictiong-outputsオプションは、既に生成されたfreezedのコードとコンフリクトを起こさないために、既に生成されたコードを一度削除して生成し直すためのオプションです。

QiitaAPIの呼び出し

以下のコードでQiitaAPIを呼び出し、記事一覧を取得します。
呼び出しにはdioを使用しています。
https://pub.dev/packages/dio

apis/qiita_api_client.dart
import 'package:dio/dio.dart';
import 'package:qiita_library/models/qiita_article.dart';

class QiitaApiClient {
  dynamic fetchArticles(int page, String keyword) async {
    final response = await Dio().get(
      'https://qiita.com/api/v2/items?per_page=20',
      queryParameters: {
        'page': page,
        'per_page': 20,
        if (keyword != '') 'query': 'body:$keyword or tag:$keyword',
      },
      options: Options(
        headers: {
          "Content-Type": "application/json",
          "Authorization": " Bearer 9b71d2f82f8fa8577cdb22c6f2d556b0e590168b",
        },
      ),
    );

    var articles = response.data
        .map((dynamic i) => QiitaArticle.fromJson(i as Map<String, dynamic>))
        .toList();

    return articles;
  }
}

そして、上記のメソッドをrepositoryから呼び出します。
後述するViewModelからrepositoryを呼び出す→APIを呼び出す、こういった流れになります。

repositories/article_repository.dart
import 'package:qiita_library/apis/qiita_api_client.dart';

class ArticleRepository {
  final _api = QiitaApiClient();

  dynamic fetchArticles(int page, String keyword) async {
    return await _api.fetchArticles(page, keyword);
  }
}

ViewModel

viewModels/article_view_model.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:qiita_library/repositories/article_repository.dart';
import 'package:qiita_library/states/articles_state.dart';

final articleViewModel = StateNotifierProvider(
  (_) => ArticleViewModel(
    ArticleRepository(),
  ),
);

class ArticleViewModel extends StateNotifier<ArticlesState> {
  ArticleViewModel(this.repository) : super(ArticlesState()) {
    getArticles();
  }

  final ArticleRepository repository;

  int _page = 1;
  bool _isLoading = false;

  Future<void> getArticles() async {
    if (_isLoading || !state.hasNext) {
      return;
    }

    _isLoading = true;

    final articles = await repository.fetchArticles(_page, state.keyword);
    final newArticles = [...state.articles, ...articles];

    if (articles.length % 20 != 0 || articles.length == 0) {
      state = state.copyWith(
        hasNext: false,
      );
    }

    state = state.copyWith(
      articles: newArticles,
    );

    _page++;
    _isLoading = false;
  }
  
  Future<void> setQuery(String keyword) async {
    state = state.copyWith(
      articles: [],
      keyword: keyword,
      hasNext: true,
    );

    _page = 1;
  }

  Future<void> refreshArticles() async {
    state = state.copyWith(
      articles: [],
      hasNext: true,
    );

    _page = 1;

    this.getArticles();
  }
}
final articles = await repository.fetchArticles(_page, state.keyword);
final newArticles = [...state.articles, ...articles];

state = state.copyWith(
      articles: newArticles,
    );

repositoryを呼び出して記事を取得し、newArticlesに取得した記事を挿入。
state.copyWithで状態を更新します。

状態の保持(State)

取得した記事一覧・無限スクロールで次の記事があるか・検索キーワードを保持しておくためのStateクラスを作成します。
こちらもfreezedを使用してimmutable(不変)にしています。

states/articles_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'articles_state.freezed.dart';
part 'articles_state.g.dart';


abstract class ArticlesState with _$ArticlesState {
  const factory ArticlesState({
    ([]) dynamic articles,
    (true) bool hasNext,
    ('') String keyword,
  }) = _ArticlesState;

  factory ArticlesState.fromJson(Map<String, dynamic> json) =>
      _$ArticlesStateFromJson(json);
}

記事一覧ページ

article_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:qiita_library/viewModels/article_view_model.dart';
import 'package:intl/intl.dart';
import 'package:qiita_library/views/article_datail_page.dart';
import 'package:qiita_library/views/article_search_setting_page.dart';

class ArticlesPage extends HookWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        actions: <Widget>[
          IconButton(
            onPressed: () {
              Navigator.of(context).push(
                MaterialPageRoute(
                  builder: (context) => ArticleSearchSettingPage(),
                  fullscreenDialog: true,
                ),
              );
            },
            icon: Icon(Icons.search),
          ),
        ],
      ),
      body: _Articles(),
    );
  }
}

class _Articles extends HookWidget {
  
  Widget build(BuildContext context) {
    final viewModel = useProvider(articleViewModel);
    final state = useProvider(articleViewModel.state);

    if (state.articles.length == 0) {
      if (!state.hasNext) return Text('検索結果なし');
      return const LinearProgressIndicator();
    }

    return RefreshIndicator(
      child: ListView.builder(
        itemCount: state.articles.length,
        itemBuilder: (context, int index) {
          if (index == (state.articles.length - 1) && state.hasNext) {
            viewModel.getArticles();
            return const LinearProgressIndicator();
          }
          return _articleItem(context, state.articles[index]);
        },
      ),
      onRefresh: () async {
        viewModel.refreshArticles();
      },
    );
  }

  Widget _articleItem(context, article) {
    return GestureDetector(
      child: Container(
        padding: EdgeInsets.all(15.0),
        decoration: BoxDecoration(
          border: const Border(
            bottom: const BorderSide(
              color: const Color(0x1e333333),
              width: 1,
            ),
          ),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            _articleUser(article.user),
            SizedBox(
              height: 10.0,
            ),
            Text(article.title),
            SizedBox(
              height: 10.0,
            ),
            Wrap(
              spacing: 7.5,
              children: <Widget>[
                for (int i = 0; i < article.tags.length; i++)
                  _articleTag(article.tags[i])
              ],
            ),
            SizedBox(
              height: 5.0,
            ),
            _articleCreatedAt(article.createdAt),
          ],
        ),
      ),
      onTap: () {
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => ArticleDetailPage(
              article: article,
            ),
          ),
        );
      },
    );
  }

  Widget _articleUser(user) {
    final userId = user.id;
    return Row(
      children: [
        CircleAvatar(
          backgroundImage: NetworkImage(user.profileImageUrl),
          radius: 12.0,
          child: Text(''),
        ),
        SizedBox(width: 8.0),
        Text('@$userId'),
      ],
    );
  }

  Widget _articleTag(tag) {
    return GestureDetector(
      child: Container(
        child: Text(
          tag['name'],
          style: TextStyle(
            decoration: TextDecoration.underline,
          ),
        ),
      ),
      onTap: () {
        print(tag['name']);
      },
    );
  }

  Widget _articleCreatedAt(createdAt) {
    DateFormat format = DateFormat('yyyy-MM-dd');
    String date = format.format(DateTime.parse(createdAt).toLocal());

    return Container(
      width: double.infinity,
      child: Text(
        '$dateに投稿',
        textAlign: TextAlign.right,
      ),
    );
  }
}
final viewModel = useProvider(articleViewModel);
viewModel.getArticles();

上記で、articleViewModelのメソッドを呼び出すことができます。

final state = useProvider(articleViewModel.state);
state.articles; 
state.keyword;

上記で、記事一覧やキーワードを取得することができます。

記事詳細ページ

webview_flutterを使って記事詳細を表示。
https://pub.dev/packages/webview_flutter

views/article_detail_page.dart
import 'package:flutter/material.dart';
import 'package:qiita_library/models/qiita_article.dart';
import 'package:webview_flutter/webview_flutter.dart';

class ArticleDetailPage extends StatelessWidget {
  ArticleDetailPage({ this.article});

  final QiitaArticle? article;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(
          article!.title ?? '',
          overflow: TextOverflow.ellipsis,
          style: TextStyle(
            fontSize: 13,
          ),
        ),
      ),
      body: Column(
        children: [
          Expanded(
            child: WebView(
              initialUrl: article?.url,
              javascriptMode: JavascriptMode.unrestricted,
            ),
          ),
        ],
      ),
    );
  }
}

検索ページ

views/article_search_setting_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:qiita_library/viewModels/article_view_model.dart';

class ArticleSearchSettingPage extends HookWidget {
  
  Widget build(BuildContext context) {
    final viewModel = useProvider(articleViewModel);
    final state = useProvider(articleViewModel.state);

    return Scaffold(
      appBar: AppBar(
        title: Text('検索'),
      ),
      body: Container(
        padding: EdgeInsets.all(15.0),
        child: Column(
          children: <Widget>[
            TextFormField(
              controller: TextEditingController(text: state.keyword),
              textInputAction: TextInputAction.search,
              decoration: InputDecoration(
                hintText: 'キーワード',
              ),
              onFieldSubmitted: (value) async {
                await viewModel.setQuery(value);
                viewModel.getArticles();
                Navigator.of(context).pop();
              },
            ),
          ],
        ),
      ),
    );
  }
}

終わりに

今回は、Riverpod + Flutter hooks + StateNotifier + freezedを使って、簡単なQiitaアプリを作成しました。

API呼び出して表示、っていうことができるようになったので、QiitaAPIに限らず、他のAPIや自分でAPIを作って呼び出してアプリ作るのも面白そう。

まだまだ初心者なので、おいお前間違ってんぞそれ、っていう部分があったらご指摘ください!

参考にさせていただいた記事

https://qiita.com/toda-axiaworks/items/fa2f77562bb2c0b7a158
https://zuma-lab.com/posts/flutter-todo-list-riverpod-use-provider-state-notifier-freezed
https://wasabeef.medium.com/flutter-を-mvvm-で実装する-861c5dbcc565

GitHub

https://github.com/yumiba109/qiita_library

クローン後、rootディレクトリで
flutter pub run build_runner build --delete-conflicting-outputs
flutter run

Discussion