🐶

MVVM+Repositoryパターンを採用したFlutterアプリを構築する

56 min read

この記事について

wasabeefさんが作成しているリポジトリを参考に、カスタマイズしてテンプレートを作成してみたという自分用メモ的な記事です。導入手順をかなり丁寧に書いたつもりなので、めちゃくちゃ文字数が多くなっちゃっています💦(脅威の50000文字over)
wasabeefさんのテンプレートでは、ChangeNotifierを採用していますが、この記事では、StateNotifierを採用しています。また、SizerFimberDevice Previewなども新たに導入しています。逆に、Dioの導入はしていません。これは、httpというパッケージを使った方が良いケースもありますし、そもそもFirebaseを使用していて、バックエンドAPIを叩く必要がないケースもあるかと思いますので、必要に応じて、下記のリポジトリを参考にしてみてください。

https://github.com/wasabeef/flutter-architecture-blueprints

MVVM+Repositoryパターンとは?

MVVMとは、Model・View・ViewModelで構成されたアーキテクチャのことです。MVVMは、Androidアプリ開発では、定番?のようです。この記事では、MVVMに加えて、Repositoryパターンも採用しています。Repositoryパターンは、ローカルストレージや外部APIのようなデータソースへのアクセスを抽象化するためのデザインパターンです。詳しい解説については、以下の記事を見た方がわかりやすいです。

https://wasabeef.medium.com/flutter-を-mvvm-で実装する-861c5dbcc565

今回採用している主なパッケージ

  • hooks_riverpod
  • flutter_hooks
  • build_runner
  • freezed
  • auto_route
  • device_preview

Riverpod

Providerで状態を定義し、StatelessWidgetのような状態を持たないウィジェットに対して、状態を注入できるようにしたDIパッケージのことです。StatefullWidgetは、状態とViewが結合しているので、コードが冗長になるという欠点を持っていますが(他にも色々欠点があります)、RiverpodのようなDIパッケージを使用すれば、Viewと状態を分離でき、保守性の向上につながります。

https://riverpod.dev/

Build Runner

コードの生成とかをコマンド一つでできるようにしたプラグインです。今回は、Freezedauto_routeflutter_genなどで、コード生成を行うため使用しています。

https://pub.dev/packages/build_runner

Freezed

immutableなオブジェクトを生成してくれるプラグインです。また、内部でjson_serializableを使っているので、オブジェクトとjsonの相互変換も対応しています。

https://pub.dev/packages/freezed

Auto Route

ルーティング周りをいい感じにしてくれるプラグインです。

https://pub.dev/packages/auto_route

Device Preview

一つのエミュレータ上で、さまざまな端末のサイズを確認したり、端末の設定(言語・ダークテーマなど)をいじれるようにしたプラグインです。今までは複数のエミュレータを用意して確認していましたが、このプラグインを使えば、エミュレータの切り替えが必要ないので、開発スピードが格段に上がります。

https://pub.dev/packages/device_preview

本題

Flutter SDKのバージョンは、stableであるv2.8.1(2021年12月19日現在)を使用します。

Flutterの雛形アプリを作成

$ flutter create flapp
$ cd flapp
$ flutter run

エディタの設定

コードの統一性を高めるために、EditorConfigを設定しておきます。これを設定しておくことで、インデントのサイズ・スタイルや文字コードなど、人それぞれになりそうなものを早めに設定しておくことで、統一感のあるコードを書けるようになります。これは是非とも設定しておきましょう。

https://editorconfig.org/
$ touch .editorconfig
.editorconfig
root = true

[*]

indent_style = space
indent_size = 2

end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

analysis_optionsの設定

Flutter2.5.0から、デフォルトでflutter_lintsが入っており、特に難しい設定いらずで静的解析ができるようになっています。

https://pub.dev/packages/flutter_lints
# コードチェック
$ fvm flutter analyze

# libフォルダ以下をコード整形
$ fvm flutter format lib/

デフォルトの設定のままでも良いのですが、一応自分がいつも使っているものを設定しておきます。

analysis_options.yaml
include: package:flutter_lints/flutter.yaml

analyzer:
  strong-mode:
    implicit-casts: true
    implicit-dynamic: true
  exclude:
    - "**/generated_plugin_registrant.dart"
    - "**/build/**"
    - "**/generated_*.dart"
    - "**/*.g.dart"
    - "**/*.freezed.dart"
    - "**/*.gr.dart"
    - "**/l10n*.dart"
    - "**/*.gen.dart"

linter:
  rules:
    - avoid_types_on_closure_parameters
    - avoid_void_async
    - await_only_futures
    - camel_case_types
    - cancel_subscriptions
    - close_sinks
    - constant_identifier_names
    - control_flow_in_finally
    - directives_ordering
    - empty_statements
    - hash_and_equals
    - implementation_imports
    - non_constant_identifier_names
    - package_api_docs
    - package_names
    - package_prefixed_library_names
    - test_types_in_equals
    - throw_in_finally
    - unnecessary_brace_in_string_interps
    - unnecessary_getters_setters
    - unnecessary_new
    - unnecessary_statements
    - prefer_const_constructors

Prettierの導入

Prettierは、いい感じにコード整形をしてくれるツールです。dartファイルは(多分)Prettierを使えないのですが、今回のプロジェクトには、jsonファイルなどがあり、これらのコードがぐちゃぐちゃなのは個人的には考えられないので、設定しておきます。

https://prettier.io/
$ yarn init -y
$ yarn add -D prettier
$ touch .prettierrc .prettierignore
.prettierrc
{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "useTabs": false
}
.prettierignore
.dart_tool
.fvm
build
ios
android

node_modulesフォルダが作成されるので、.gitignoreに追加します。

.gitignore
+ node_modules

以下のコマンドで、json・html・yamlファイルなどをコード整形してくれるようになります。

$ yarn prettier --write .

Husky・lint-stagedの導入

Huskyとは、コミット時やプッシュ時などに何らかの処理を実行できるようにするツールです。
lint-stagedとは、ステージングしているファイルに対して何らかの処理を実行するようにできるツールです。

https://typicode.github.io/husky/#/

https://www.npmjs.com/package/lint-staged

今回はこれらを合わせて、コミット時にコード整形を実装するようにします。

$ yarn add -D husky lint-staged
$ npx husky-init && yarn install

npx husky-initを実行すると、.husky/pre-commitファイルが自動生成されます。

package.jsonにlint-stagedの設定を追加します。下記は、ステージングされているファイルがdartの場合は、fvm flutter formatを実行し、json・yamlの場合は、prettier --writeを実行している例です。

package.json
{
+  "lint-staged": {
+    "*.dart": [
+      "fvm flutter format"
+    ],
+    "*.@(json|yaml)": [
+      "prettier --write"
+    ]
+  }
}

.husky/pre-commitを以下のように修正して、コミット時にlint-stagedを実行するようにします。

.husky/pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

- npm test
+ yarn lint-staged

FVMの設定

FVM(Flutter Version Management)とは、Flutter SDKのバージョンを管理してくれるツールです。実際の開発では、FlutterのSDKのバージョンによっては動かないといったケースが発生しうるので、FVMの設定は必ずといっていいほどやっておいた方が良いです。

https://fvm.app/

fvmコマンドが使えない方は、まず以下のコマンドを実行してください。

$ dart pub global activate fvm
$ fvm --version
2.2.4

fvmの設定ファイルを作成します。fvm_config.jsonでは、flutter SDKのバージョンを指定します。

$ touch .fvm/fvm_config.json .fvm/.gitignore
$ fvm install
$ fvm flutter pub get

2021年12月20日現在、stableのバージョンである2.8.1を使用すると、auto_routeでエラーが出ることを確認しているので、2.5.3のバージョンを使用しています。

.fvm/fvm_config.json
{
  "flutterSdkVersion": "2.5.3",
  "flavors": {}
}
.gitignore
*
!.gitignore
!fvm_config.json

利用可能なFlutter SDKのバージョンは以下のコマンドで確認できます。

$ fvm releases
..
Oct 15 212.5.3
Oct 20 212.7.0-3.0.pre
Oct 28 212.7.0-3.1.pre
Nov 12 212.8.0-3.1.pre
Nov 18 212.8.0-3.2.pre
Dec 1 212.8.0-3.3.pre
Dec 9 212.8.0
--------------------------------------
Dec 15 212.9.0-0.1.pre     beta
--------------------------------------
--------------------------------------
Dec 15 212.9.0-0.1.pre     dev
--------------------------------------
--------------------------------------
Dec 16 212.8.1             stable
--------------------------------------

main.dartのウィジェットをStatelessWidgetに置き換える

今回、StatefullWidgetを使った状態管理は行わないので、一旦状態を持たないStatelessWidgetに置き換えます。

真ん中に画像を表示したいので、assets/imgフォルダを作って、その中にflutter-icon.pngを配置してください。

pubspec.yaml
flutter:
  uses-material-design: true
+ assets:
+   - assets/img/
lib/main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Image.asset(
              'assets/img/flutter-icon.png',
              width: 200,
            ),
            Text(
              'flapp',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
    );
  }
}

多言語対応(l10n)の導入

多言語対応をするためにintlというパッケージを導入します。

https://pub.dev/packages/intl
$ fvm flutter pub add intl
pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
+  flutter_localizations:
+    sdk: flutter
  cupertino_icons: ^1.0.2
+  intl: ^0.17.0

genarateフラグをtrueにしておきます。

pubspec.yaml
flutter:
+ generate: true
  uses-material-design: true
  assets:
    - assets/img/

l10nの設定ファイルを追加します。

$ touch l10n.yaml
l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_ja.arb
output-localization-file: l10n.dart
output-class: L10n

日本語の言語ファイルを追加します。

$ touch lib/l10n/app_ja.arb
lib/l10n/app_ja.arb
{
  "@@locale": "ja",
  "hello": "こんにちは"
}

以下のコマンドを実行すると、.dart_tool/flutter_gen/gen_l10n/l10n.dartが作成されます。

$ fvm flutter gen-l10n

main.dartのMaterialAppでlocalizationsDelegates・supportedLocalesを追加します。

lib/main.dart
import 'package:flutter/material.dart';
+ import 'package:flutter_gen/gen_l10n/l10n.dart';

void main() {
  runApp(const MyApp());
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
+     localizationsDelegates: L10n.localizationsDelegates,
+     supportedLocales: L10n.supportedLocales,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Image.asset(
              'assets/img/flutter-icon.png',
              width: 200,
            ),
            Text(
-	      'flapp'
+             L10n.of(context)!.hello,
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
    );
  }
}

これで、arbファイルに定義した文言が使用できるようになります。

L10n.of(context)!.hello // こんにちは

flutter_genでアセット管理

先ほど、画像を以下のようにして表示していたかと思います。ただパスによる指定だと、typoが怖いので、これを解決するために、flutter_genを導入します。

https://pub.dev/packages/flutter_gen

build_runnerと併用して使うので、build_runnerも一緒に導入します。また、svgの対応も一応やっておきます。

$ fvm flutter pub add build_runner -d
$ fvm flutter pub add flutter_gen_runner -d
$ fvm flutter pub add flutter_svg
pubspec.yaml
flutter:
  generate: true
  uses-material-design: true
  assets:
    - assets/img/

+ flutter_gen:
+  integrations:
+    flutter_svg: true

以下のコマンドを実行すると、lib/gen/assets.gen.dartが作成されます。

$ fvm flutter packages pub run build_runner build

main.dartのアセットをlib/gen/assets.gen.dartのAssetsクラスから呼び出します。

lib/main.dart
+ import 'package:flapp/gen/assets.gen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
-           Image.asset(
-             'assets/img/flutter-icon.png',
-             width: 200,
-           ),
+           Assets.img.flutterIcon.image(width: 200),
            Text(
              L10n.of(context)!.hello,
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
    );
  }
}

auto_routeの導入

ルーティングの実装には、auto_routeを使用します。

https://pub.dev/packages/auto_route

ルーティングの実装の前に、今のmain.dartが肥大化しているので、main.dartapp.dartxxx_page.dartに分割します。

$ mkdir -p lib/ui/xxx
$ touch lib/app.dart lib/ui/xxx/xxx_page.dart
lib/main.dart
import 'package:flutter/material.dart';
import 'app.dart';

void main() {
  runApp(const MyApp());
}
lib/app.dart
import 'package:flapp/ui/xxx/xxx_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      localizationsDelegates: L10n.localizationsDelegates,
      supportedLocales: L10n.supportedLocales,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const XXXPage(),
    );
  }
}
lib/ui/xxx/xxx_page.dart
import 'package:flapp/gen/assets.gen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Assets.img.flutterIcon.image(width: 200),
            Text(
              L10n.of(context)!.hello,
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
    );
  }
}

auto_routeをインストールします。

$ fvm flutter pub add auto_route
$ fvm flutter pub add auto_route_generator -d

app_route.dartとroute_path.dartを作成します。

$ mkdir -p lib/ui/routes
$ touch lib/ui/routes/app_route.dart lib/ui/routes/route_path.dart
lib/ui/routes/app_route.dart
import 'package:auto_route/auto_route.dart';
import 'package:flapp/ui/routes/route_path.dart';
import 'package:flapp/ui/xxx/xxx_page.dart';

@AdaptiveAutoRouter(
  replaceInRouteName: 'Page,Route',
  routes: [
    AutoRoute(
      path: RoutePath.appRouteXXX,
      page: XXXPage,
      initial: true,
    ),
  ],
)
class $AppRouter {}
lib/ui/routes/route_path.dart
class RoutePath {
  static const appRouteXXX = '/xxx';
}

flutter_genの時と同じで、build_runnerを実行してあげることで、app_route.gr.dartが自動生成されます。

$ fvm flutter packages pub run build_runner build

lib/app.dartを修正します。

lib/app.dart
- import 'package:flapp/ui/xxx/xxx_page.dart';
+ import 'package:flapp/ui/routes/app_route.gr.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';

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

  
  Widget build(BuildContext context) {
+    final appRouter = AppRouter();
-    return MaterialApp(
-      title: 'Flutter Demo',
-      localizationsDelegates: L10n.localizationsDelegates,
-      supportedLocales: L10n.supportedLocales,
-      theme: ThemeData(
-        primarySwatch: Colors.blue,
-      ),
-      home: const XXXPage(),
-    );
+    return MaterialApp.router(
+      debugShowCheckedModeBanner: false,
+      theme: ThemeData(
+        primarySwatch: Colors.blue,
+      ),
+      localizationsDelegates: L10n.localizationsDelegates,
+      supportedLocales: L10n.supportedLocales,
+      routeInformationParser: appRouter.defaultRouteParser(),
+      routerDelegate: appRouter.delegate(),
+    );
  }
}

Riverpod・Flutter Hooksの導入

$ fvm flutter pub add hooks_riverpod
$ fvm flutter pub add flutter_hooks

main.dartのMyApp()ProviderScopeでラップします。

lib/main.dart
import 'package:flutter/material.dart';
+ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'app.dart';

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

Freezedの導入

$ fvm flutter pub add freezed -d
$ fvm flutter pub add json_serializable
$ fvm flutter pub add freezed_annotation

試しにxxxページの状態をfreezedを用いて定義したいと思います。

$ touch lib/ui/xxx/xxx_state.dart
lib/ui/xxx/xxx_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'xxx_state.freezed.dart';


class XXXState with _$XXXState {
  const factory XXXState({
    @Default(0) int count,
  }) = _XXXState;
}

この時、xxx_state.dartでエラーが出ると思いますが、それは正常です。

freezedもbuild_runnerを実行してあげることで、xxx_state.freezed.dartが自動生成されます。

$ fvm flutter packages pub run build_runner build

ViewModelの実装

今回ViewModelは、StateNotifierで実装します。StateNotifierはシングルソースの状態を持ちます。

$ touch lib/ui/xxx/xxx_view_model.dart

以下がViewModelの実装コードです。今後、非同期の値を扱うことを見据えて、シングルソースの状態は、先ほど作成したXXXStateではなく、AsyncValue<XXXState>としています。

lib/ui/xxx/xxx_view_model.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'xxx_state.dart';

final xxxViewModelProvider =
    StateNotifierProvider.autoDispose<XXXViewModel, AsyncValue<XXXState>>(
  (ref) => XXXViewModel(ref: ref),
);

class XXXViewModel extends StateNotifier<AsyncValue<XXXState>> {
  final AutoDisposeStateNotifierProviderRef _ref;
  XXXViewModel({required AutoDisposeStateNotifierProviderRef ref})
      : _ref = ref,
        super(const AsyncLoading()) {
    load();
  }

  // 初期化
  void load() {
    state = const AsyncValue.data(
      XXXState(count: 0),
    );
  }

  // カウントを+1
  void increment() {
    final count = state.value!.count;
    state = AsyncValue.data(
      XXXState(count: count + 1),
    );
  }
}

先ほど作成したxxx_page.dartを修正します。

lib/ui/xxx/xxx_page.dart
import 'package:flapp/gen/assets.gen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'xxx_view_model.dart';

class XXXPage extends HookConsumerWidget {
  const XXXPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(xxxViewModelProvider);
    final viewModel = ref.watch(xxxViewModelProvider.notifier);

    return state.when(
      data: (data) {
        return Scaffold(
          body: SafeArea(
            child: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Assets.img.flutterIcon.image(width: 200),
                  Text(
                    L10n.of(context)!.hello,
                    style: Theme.of(context).textTheme.headline4,
                  ),
                  ElevatedButton(
                    onPressed: viewModel.increment,
                    child: Text(data.count.toString()),
                  ),
                ],
              ),
            ),
          ),
        );
      },
      error: (e, msg) => Text(e.toString()),
      loading: () {
        return const Scaffold(
          body: SafeArea(
            child: Center(
              child: CircularProgressIndicator(),
            ),
          ),
        );
      },
    );
  }
}

XXXPageはStatelessWidgetで実装していましたが、ViewModelを注入(DI)するためにHookConsumerWidgetで置き換えています。

シングルソースの状態へのアクセスは以下のコードになります。

final state = ref.watch(xxxViewModelProvider);

ViewModelのインスタンス自体へのアクセスは以下のコードになります。

final viewModel = ref.watch(xxxViewModelProvider.notifier);

また、状態はAsyncValueなので、そのままデータを取り出すことができません。なので、whenメソッドを使って、正常時・エラー時・ローディング時のそれぞれの場合で実装してあげる必要があります。

state.when(
  data: (data) {
    // 正常時
  },
  error: (e, msg) {
    // エラー時
  },
  loading: () {
    // ローディング時
  }
)

ボタンをクリックすると、カウントアップしてくれるようになりました。

Repositoryの実装

Repositoryは今後テストを書くことを考えて、抽象クラスとImplementクラスで構成します。

Repositoryでは、非同期の値を扱うため、正常時・エラー時のハンドリングがしやすいように、Resultクラスを作成します。

AsyncValueと同じ感じだと思ってもらって大丈夫です。

$ mkdir -p lib/data/model/result
$ touch lib/data/model/result/result.dart
lib/data/model/result/result.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'result.freezed.dart';


abstract class Result<T> with _$Result<T> {
  const Result._();

  const factory Result.success({required T data}) = Success<T>;

  const factory Result.failure({required Exception error}) = Failure<T>;

  static Result<T> guard<T>(T Function() body) {
    try {
      return Result.success(data: body());
    } on Exception catch (e) {
      return Result.failure(error: e);
    }
  }

  static Future<Result<T>> guardFuture<T>(Future<T> Function() future) async {
    try {
      return Result.success(data: await future());
    } on Exception catch (e) {
      return Result.failure(error: e);
    }
  }

  bool get isSuccess => when(success: (data) => true, failure: (e) => false);

  bool get isFailure => !isSuccess;

  T get dataOrThrow {
    return when(
      success: (data) => data,
      failure: (e) => throw e,
    );
  }
}

extension ResultObjectExt<T> on T {
  Result<T> get asSuccess => Result.success(data: this);

  Result<T> asFailure(Exception e) => Result.failure(error: e);
}

fetch関数を呼び出すと、2秒後にランダムな数字を返すxxxRepositoryを実装します。

$ mkdir -p lib/data/repository/xxx
$ touch lib/data/repository/xxx/xxx_repository.dart
$ touch lib/data/repository/xxx/xxx_repository_impl.dart

抽象クラス

lib/data/repository/xxx/xxx_repository.dart
import 'package:flapp/data/model/result/result.dart';

abstract class XXXRepository {
  Future<Result<int>> fetch();
}

実装クラス(Implementクラス)

lib/data/repository/xxx/xxx_repository_impl.dart
import 'dart:math';

import 'package:flapp/data/model/result/result.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'xxx_repository.dart';

final xxxRepositoryProvider =
    Provider<XXXRepository>((ref) => XXXRepositoryImpl(ref.read));

class XXXRepositoryImpl implements XXXRepository {
  XXXRepositoryImpl(this._reader);
  final Reader _reader;

  
  Future<Result<int>> fetch() async {
    return Result.guardFuture(() async {
      await Future.delayed(const Duration(seconds: 2)); // 2秒待機

      final rand = Random();
      return rand.nextInt(100); // 0~100の乱数
    });
  }
}

ViewModel側で、xxxRepositoryを呼び出します。

lib/ui/xxx/xxx_view_model.dart
+ import 'package:flapp/data/repository/xxx/xxx_repository.dart';
+ import 'package:flapp/data/repository/xxx/xxx_repository_impl.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'xxx_state.dart';

final xxxViewModelProvider =
    StateNotifierProvider.autoDispose<XXXViewModel, AsyncValue<XXXState>>(
  (ref) => XXXViewModel(ref: ref),
);

class XXXViewModel extends StateNotifier<AsyncValue<XXXState>> {
  final AutoDisposeStateNotifierProviderRef _ref;
  XXXViewModel({required AutoDisposeStateNotifierProviderRef ref})
      : _ref = ref,
        super(const AsyncLoading()) {
    load();
  }

+  // xxxRepository
+  late final XXXRepository xxxRepository = _ref.read(xxxRepositoryProvider);

  // 初期化
-  void load() {
-    state = const AsyncValue.data(
-      XXXState(count: 0),
-    );
-  }
+  Future<void> load() async {
+    final result = await xxxRepository.fetch();
+    result.when(
+      success: (data) {
+        state = AsyncValue.data(
+          XXXState(count: data),
+        );
+      },
+      failure: (error) {
+        state = AsyncValue.error(error);
+      },
+    );
+  }

  // カウントを+1
  void increment() {
    final count = state.value!.count;
    state = AsyncValue.data(
      XXXState(count: count + 1),
    );
  }
}

2秒間ロード画面が表示された後に、ランダムな数がセットされるようになります。

カスタムテーマの実装

色やフォントなどを詳細に設定していきます。

カラーテーマは、app_colors.dartで定義していきます。

$ mkdir -p lib/ui/theme
$ touch lib/ui/theme/app_colors.dart
lib/ui/theme/app_colors.dart
import 'package:flutter/material.dart';

class AppColors {
  const AppColors({
    required this.primary,
    required this.primaryVariant,
    required this.onPrimary,
    required this.secondary,
    required this.secondaryVariant,
    required this.onSecondary,
    required this.background,
    required this.onBackground,
    required this.surface,
    required this.onSurface,
    required this.error,
    required this.onError,
  });

  factory AppColors.light() {
    return const AppColors(
      primary: Color(0xFF6200EE),
      primaryVariant: Color(0xFF3700B3),
      onPrimary: Color(0xFFFFFFFF),
      secondary: Color(0xFF03DAC6),
      secondaryVariant: Color(0xFF018786),
      onSecondary: Color(0xFF000000),
      background: Color(0xFFFFFFFF),
      onBackground: Color(0xFF000000),
      surface: Color(0xFFFFFFFF),
      onSurface: Color(0xFF000000),
      error: Color(0xFFB00020),
      onError: Color(0xFFFFFFFF),
    );
  }

  factory AppColors.dark() {
    return const AppColors(
      primary: Color(0xFF6200EE),
      primaryVariant: Color(0xFF3700B3),
      onPrimary: Color(0xFFFFFFFF),
      secondary: Color(0xFF03DAC6),
      secondaryVariant: Color(0xFF018786),
      onSecondary: Color(0xFF000000),
      background: Color(0xFFFFFFFF),
      onBackground: Color(0xFF000000),
      surface: Color(0xFFFFFFFF),
      onSurface: Color(0xFF000000),
      error: Color(0xFFB00020),
      onError: Color(0xFFFFFFFF),
    );
  }

  /// Material Colors https://material.io/design/color/the-color-system.html#color-theme-creation
  final Color primary;
  final Color primaryVariant;
  final Color onPrimary;
  final Color secondary;
  final Color secondaryVariant;
  final Color onSecondary;
  final Color background;
  final Color onBackground;
  final Color surface;
  final Color onSurface;
  final Color error;
  final Color onError;
}

色の定義は、Material Designに準拠しています。
https://material.io/design/color/the-color-system.html#color-theme-creation

テキストテーマは、app_text_theme.dartで定義していきます。

$ touch lib/ui/theme/app_text_theme.dart
$ touch lib/ui/theme/font_size.dart
lib/ui/theme/font_size.dart
class FontSize {
  /// 60pt
  static const double pt60 = 60;

  /// 48pt
  static const double pt48 = 48;

  /// 40pt
  static const double pt40 = 40;

  /// 32pt
  static const double pt32 = 32;

  /// 28pt
  static const double pt28 = 28;

  /// 24pt
  static const double pt24 = 24;

  /// 20pt
  static const double pt20 = 20;

  /// 18pt
  static const double pt18 = 18;

  /// 16pt
  static const double pt16 = 16;

  /// 14pt
  static const double pt14 = 14;

  /// 12pt
  static const double pt12 = 12;

  /// 10pt
  static const double pt10 = 10;

  /// 8pt
  static const double pt8 = 8;
}

フォントファミリーの指定は、GoogleFontを使用するので、インストールしていきます。フォントは「NotoSans」を指定しています。

https://pub.dev/packages/google_fonts
$ fvm flutter pub add google_fonts
lib/ui/theme/app_text_theme.dart
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'font_size.dart';

class AppTextTheme {
  const AppTextTheme._({
    required this.h10,
    required this.h20,
    required this.h30,
    required this.h40,
    required this.h50,
    required this.h60,
    required this.h70,
    required this.h80,
    required this.h90,
    required this.h100,
  });

  factory AppTextTheme() {
    final _normalRegular = GoogleFonts.notoSans(
      textStyle: const TextStyle(
        fontWeight: FontWeight.w400,
        height: 1.5,
        leadingDistribution: TextLeadingDistribution.even,
      ),
    );
    return AppTextTheme._(
      h10: const TextStyle(fontSize: FontSize.pt10).merge(_normalRegular),
      h20: const TextStyle(fontSize: FontSize.pt12).merge(_normalRegular),
      h30: const TextStyle(fontSize: FontSize.pt14).merge(_normalRegular),
      h40: const TextStyle(fontSize: FontSize.pt16).merge(_normalRegular),
      h50: const TextStyle(fontSize: FontSize.pt20).merge(_normalRegular),
      h60: const TextStyle(fontSize: FontSize.pt24).merge(_normalRegular),
      h70: const TextStyle(fontSize: FontSize.pt32).merge(_normalRegular),
      h80: const TextStyle(fontSize: FontSize.pt40).merge(_normalRegular),
      h90: const TextStyle(fontSize: FontSize.pt48).merge(_normalRegular),
      h100: const TextStyle(fontSize: FontSize.pt60).merge(_normalRegular),
    );
  }

  /// pt10
  final TextStyle h10;

  /// pt12
  final TextStyle h20;

  /// pt14
  final TextStyle h30;

  /// pt16
  final TextStyle h40;

  /// pt20
  final TextStyle h50;

  /// pt24
  final TextStyle h60;

  /// pt32
  final TextStyle h70;

  /// pt40
  final TextStyle h80;

  /// pt48
  final TextStyle h90;

  /// pt60
  final TextStyle h100;
}

extension TextStyleExt on TextStyle {
  TextStyle bold() => copyWith(fontWeight: FontWeight.w700);

  TextStyle comfort() => copyWith(height: 1.8);

  TextStyle dense() => copyWith(height: 1.2);
}

カラーテーマとテキストテーマを一つにまとめたAppThemeクラスを実装します。

$ touch lib/ui/theme/app_theme.dart
lib/ui/theme/app_theme.dart
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'app_colors.dart';
import 'app_text_theme.dart';

final appThemeModeProvider =
    StateNotifierProvider<StateController<ThemeMode>, ThemeMode>(
  (ref) => StateController(ThemeMode.light),
);

final appThemeProvider = Provider<AppTheme>(
  (ref) {
    final mode = ref.watch(appThemeModeProvider);
    switch (mode) {
      case ThemeMode.dark:
        return AppTheme.dark();
      case ThemeMode.light:
      default:
        return AppTheme.light();
    }
  },
);

class AppTheme {
  AppTheme({
    required this.mode,
    required this.data,
    required this.textTheme,
    required this.appColors,
  });

  factory AppTheme.light() {
    const mode = ThemeMode.light;
    final appColors = AppColors.light();
    final themeData = ThemeData.light().copyWith(
      scaffoldBackgroundColor: appColors.background,
      textTheme: GoogleFonts.notoSansTextTheme(ThemeData.light().textTheme),
      snackBarTheme: SnackBarThemeData(
        backgroundColor: appColors.error,
        behavior: SnackBarBehavior.floating,
      ),
    );
    return AppTheme(
      mode: mode,
      data: themeData,
      textTheme: AppTextTheme(),
      appColors: appColors,
    );
  }

  factory AppTheme.dark() {
    const mode = ThemeMode.dark;
    final appColors = AppColors.dark();
    final themeData = ThemeData.dark().copyWith(
      scaffoldBackgroundColor: appColors.background,
      textTheme: GoogleFonts.notoSansTextTheme(ThemeData.dark().textTheme),
      snackBarTheme: SnackBarThemeData(
        backgroundColor: appColors.error,
        behavior: SnackBarBehavior.floating,
      ),
    );
    return AppTheme(
      mode: mode,
      data: themeData,
      textTheme: AppTextTheme(),
      appColors: appColors,
    );
  }

  final ThemeMode mode;
  final ThemeData data;
  final AppTextTheme textTheme;
  final AppColors appColors;
}

最後に、先ほど作成したカスタムテーマをapp.dartに適用していきます。カスタムテーマはProviderで定義しているので、app.dartのStatelessWidgetHookConsumerWidgetに変更します。

lib/app.dart
import 'package:flapp/ui/routes/app_route.gr.dart';
+ import 'package:flapp/ui/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
+ import 'package:flutter_hooks/flutter_hooks.dart';
+ import 'package:hooks_riverpod/hooks_riverpod.dart';

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

  
-  Widget build(BuildContext context) {
+  Widget build(BuildContext context, WidgetRef ref) {
+    final theme = ref.watch(appThemeProvider);
+    final themeMode = ref.watch(appThemeModeProvider);
-    final appRouter = AppRouter();
+    final appRouter = useMemoized(() => AppRouter());

    return MaterialApp.router(
      debugShowCheckedModeBanner: false,
+      theme: theme.data,
+      darkTheme: AppTheme.dark().data,
+      themeMode: themeMode,
      localizationsDelegates: L10n.localizationsDelegates,
      supportedLocales: L10n.supportedLocales,
      routeInformationParser: appRouter.defaultRouteParser(),
      routerDelegate: appRouter.delegate(),
    );
  }
}

ついでに、AppRouterをuseMemoizedでメモ化しています。

AppThemeを使って、xxxページのスタイルを整えていきます。

lib/ui/xxx/xxx_page.dart
import 'package:flapp/gen/assets.gen.dart';
+ import 'package:flapp/ui/theme/app_text_theme.dart';
+ import 'package:flapp/ui/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'xxx_view_model.dart';

class XXXPage extends HookConsumerWidget {
  const XXXPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
+   final theme = ref.watch(appThemeProvider);
+   final state = ref.watch(xxxViewModelProvider);
    final viewModel = ref.watch(xxxViewModelProvider.notifier);

    return state.when(
      data: (data) {
        return Scaffold(
          body: SafeArea(
            child: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Assets.img.flutterIcon.image(width: 200),
                  Text(
                    L10n.of(context)!.hello,
+                   style: theme.textTheme.h70.bold(),
                  ),
                  ElevatedButton(
+                   style: ElevatedButton.styleFrom(
+                     primary: theme.appColors.primary,
+                     onPrimary: theme.appColors.onPrimary,
+                   ),
                    onPressed: viewModel.increment,
                    child: Text(data.count.toString()),
                  ),
                ],
              ),
            ),
          ),
        );
      },
      error: (e, msg) => Text(e.toString()),
      loading: () {
        return Scaffold(
          body: SafeArea(
            child: Center(
              child: CircularProgressIndicator(
+               color: theme.appColors.primary,
              ),
            ),
          ),
        );
      },
    );
  }
}

テキストのスタイルは以下のように指定しています。

Text(
  L10n.of(context)!.hello,
  style: theme.textTheme.h70.bold(),
),

色とかを変更したい場合は、copyWithを使います。

Text(
  L10n.of(context)!.hello,
  style: theme.textTheme.h70
              .bold()
              .copyWith(color: theme.appColors.onSecondary),
),

カスタムフックの実装

l10nを使用するとき、こんな感じでcontextを渡していたと思います。

L10n.of(context)!.hello // こんにちは

flutter_hooksには、useContextというhook関数が用意されています。今回はuseContextを利用して、l10nにアクセスするhook関数を実装したいと思います。

$ mkdir -p lib/ui/hooks
$ touch lib/ui/hooks/use_l10n.dart
lib/ui/hooks/use_l10n.dart
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

L10n useL10n() {
  final context = useContext();
  return L10n.of(context)!;
}

呼び出し側は、こんな感じでcontextを渡すことなく、l10nにアクセスすることができます。

final l10n = useL10n();
l10n.hello // こんにちは
lib/ui/xxx/xxx_page.dart
import 'package:flapp/gen/assets.gen.dart';
+ import 'package:flapp/ui/hooks/use_l10n.dart';
import 'package:flapp/ui/theme/app_text_theme.dart';
import 'package:flapp/ui/theme/app_theme.dart';
import 'package:flutter/material.dart';
- import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'xxx_view_model.dart';

class XXXPage extends HookConsumerWidget {
  const XXXPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final theme = ref.watch(appThemeProvider);
    final state = ref.watch(xxxViewModelProvider);
    final viewModel = ref.watch(xxxViewModelProvider.notifier);
+   final l10n = useL10n();

    return state.when(
      data: (data) {
        return Scaffold(
          body: SafeArea(
            child: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Assets.img.flutterIcon.image(width: 200),
                  Text(
-		    L10n.of(context)!.hello,
+                   l10n.hello,
                    style: theme.textTheme.h70.bold(),
                  ),
                  ElevatedButton(
                    style: ElevatedButton.styleFrom(
                      primary: theme.appColors.primary,
                      onPrimary: theme.appColors.onPrimary,
                    ),
                    onPressed: viewModel.increment,
                    child: Text(data.count.toString()),
                  ),
                ],
              ),
            ),
          ),
        );
      },
      // 省略
    );
  }
}

hooks関数は、HookConsumerWidgetのbuild関数の直下でしか使えないので、注意が必要です

同様に、RouterとMediaQuery用のhook関数を実装しておきます。

lib/ui/hooks/use_router.dart
import 'package:auto_route/auto_route.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

StackRouter useRouter() {
  final context = useContext();
  return AutoRouter.of(context);
}
lib/ui/hooks/use_media_query.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

MediaQueryData useMediaQuery() {
  final context = useContext();
  return MediaQuery.of(context);
}

Sizerの導入

Flutterで作成したアプリは、スマートフォン・タブレット・PCなど、さまざまな端末で使われる可能性があるので、レスポンシブデザインに対応させる必要があります。そこで、Sizerというプラグインを使用すれば、簡単にレスポンシブ化させることができます。詳しい使い方については、以下をご参照ください。

https://pub.dev/packages/sizer
$ fvm flutter pub add sizer
lib/app.dart
import 'package:flapp/ui/routes/app_route.gr.dart';
import 'package:flapp/ui/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+ import 'package:sizer/sizer.dart';

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final theme = ref.watch(appThemeProvider);
    final themeMode = ref.watch(appThemeModeProvider);
    final appRouter = useMemoized(() => AppRouter());

-    return MaterialApp.router(
-      debugShowCheckedModeBanner: false,
-      theme: theme.data,
-      darkTheme: AppTheme.dark().data,
-      themeMode: themeMode,
-      localizationsDelegates: L10n.localizationsDelegates,
-      supportedLocales: L10n.supportedLocales,
-      routeInformationParser: appRouter.defaultRouteParser(),
-      routerDelegate: appRouter.delegate(),
-    );
+    return Sizer(
+      builder: (context, orientation, deviceType) => MaterialApp.router(
+        debugShowCheckedModeBanner: false,
+        theme: theme.data,
+        darkTheme: AppTheme.dark().data,
+        themeMode: themeMode,
+        localizationsDelegates: L10n.localizationsDelegates,
+        supportedLocales: L10n.supportedLocales,
+        routeInformationParser: appRouter.defaultRouteParser(),
+        routerDelegate: appRouter.delegate(),
+      ),
+    );
  }
}

Fimberの導入

Fimberは、ロギング用のライブラリになります。リリース環境では、ログを垂れ流しにするのは、よくないので、Fimberの設定で、デバッグ環境でのみログを流すようにします。

https://pub.dev/packages/fimber
$ fvm flutter pub add fimber -d
lib/main.dart
+ import 'package:fimber/fimber.dart';
+ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'app.dart';

void main() {
+  // Fimber
+  if (!kReleaseMode) {
+    Fimber.plantTree(DebugTree());
+  } else {
+    debugPrint = (message, {wrapWidth}) {};
+  }

  runApp(const ProviderScope(child: MyApp()));
}

また、runZonedGuardedでアプリ内で発生したエラーを捕捉するようにします。本当は、Firebase Crashlyticsとかを使って、クラッシュレポートを送った方が良いですが、今回はFimberでログを出力するようにしておきます。

lib/main.dart
+ import 'dart:async';

import 'package:fimber/fimber.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'app.dart';

void main() {
  // Fimber
  if (!kReleaseMode) {
    Fimber.plantTree(DebugTree());
  } else {
    debugPrint = (message, {wrapWidth}) {};
  }

-  runApp(const ProviderScope(child: MyApp()));
+  runZonedGuarded(
+    () => runApp(const ProviderScope(child: MyApp())),
+    (error, stackTrace) {
+      Fimber.e(error.toString());
+    },
+  );
}

環境変数の実装

Flutter 1.17から、環境変数を--dart-defineというオプションで渡せるようになりました。今回は、FLAVORという環境変数を渡すようにし、開発環境(dev)と本番環境(prod)でアプリ内の環境変数を切り替えできるようにしたいと思います。

https://itnext.io/flutter-1-17-no-more-flavors-no-more-ios-schemas-command-argument-that-solves-everything-8b145ed4285d

また、enumとStringの相互変換を行うため、enum_to_stringを導入しています。

https://pub.dev/packages/enum_to_string
$ fvm flutter pub add enum_to_string
$ mkdir -p lib/foundation
$ touch lib/foundation/constants.dart
lib/foundation/constants.dart
import 'package:enum_to_string/enum_to_string.dart';

enum Flavor { dev, prod }

class Constants {
  const Constants._({required this.baseUrl});

  factory Constants.of() {
    switch (flavor) {
      case Flavor.dev:
        return Constants._dev();
      case Flavor.prod:
      default:
        return Constants._prod();
    }
  }

  factory Constants._dev() {
    return const Constants._(baseUrl: 'http://dev');
  }

  factory Constants._prod() {
    return const Constants._(baseUrl: 'http://prod');
  }

  /// エンドポイント
  final String baseUrl;

  /// 環境 Flavor
  static Flavor get flavor =>
      EnumToString.fromString(
        Flavor.values,
        const String.fromEnvironment('FLAVOR'),
      ) ??
      Flavor.dev;
}

起動オプションに--dart-define=FLAVOR=を渡すだけで、環境変数の切り替えができるようになりました。指定しない場合は、dev環境になります。

# 開発環境(dev)
$ fvm flutter run --dart-define=FLAVOR=dev --target lib/main.dart

# 本番環境(prod)
$ fvm flutter run --dart-define=FLAVOR=prod --target lib/main.dart

開発環境の時は、わかりやすいようにtoastを表示したいと思います。toast表示は、fluttertoastを導入します。

$ fvm flutter pub add fluttertoast
lib/main.dart
import 'dart:async';

+ import 'package:enum_to_string/enum_to_string.dart';
import 'package:fimber/fimber.dart';
+ import 'package:flapp/foundation/constants.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'app.dart';

- void main() {
+ Future<void> main() async {
+  WidgetsFlutterBinding.ensureInitialized();

  // Fimber
  if (!kReleaseMode) {
    Fimber.plantTree(DebugTree());
  } else {
    debugPrint = (message, {wrapWidth}) {};
  }

+  // Display Toast (Only Dev)
+  if (Constants.flavor == Flavor.dev) {
+    Fluttertoast.showToast(
+      msg: "flavor: ${EnumToString.convertToString(Constants.flavor)}",
+    );
+  }

  runZonedGuarded(
    () => runApp(const ProviderScope(child: MyApp())),
    (error, stackTrace) {
      Fimber.e(error.toString());
    },
  );
}

WidgetsFlutterBinding.ensureInitialized();がないと、Toastがうまく表示されないので、記述しています。
https://api.flutter.dev/flutter/widgets/WidgetsFlutterBinding/ensureInitialized.html

Device Previewの導入

Device Previewは、複数のデバイスの画面サイズを一つのエミュレータで確認できるツールです。デバイスの切り替えだけでなく、言語やダークモードなどの設定ができるので、非常に素晴らしいツールです。

https://pub.dev/packages/device_preview
$ fvm flutter pub add device_preview

Device Previewの設定をします。リリースモード時は、Device Previewを無効にします。

lib/main.dart
import 'dart:async';

+ import 'package:device_preview/device_preview.dart';
import 'package:enum_to_string/enum_to_string.dart';
import 'package:fimber/fimber.dart';
import 'package:flapp/foundation/constants.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'app.dart';

Future<void> main() async {
  // 省略
  runZonedGuarded(
-    () => runApp(const ProviderScope(child: MyApp())),
+    () => runApp(
+      ProviderScope(
+        child: DevicePreview(
+          enabled: !kReleaseMode,
+          builder: (context) {
+            return const MyApp();
+          },
+        ),
+      ),
+    ),
    (error, stackTrace) {
      Fimber.e(error.toString());
    },
  );
}

言語の設定(locale)やMediaQueryをDevicePreviewから引き継ぐ設定もします。

lib/app.dart
+ import 'package:device_preview/device_preview.dart';
import 'package:flapp/ui/routes/app_route.gr.dart';
import 'package:flapp/ui/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final theme = ref.watch(appThemeProvider);
    final themeMode = ref.watch(appThemeModeProvider);
    final appRouter = useMemoized(() => AppRouter());

    return Sizer(
      builder: (context, orientation, deviceType) => MaterialApp.router(
        debugShowCheckedModeBanner: false,
+        useInheritedMediaQuery: true,
        theme: theme.data,
        darkTheme: AppTheme.dark().data,
        themeMode: themeMode,
+        locale: DevicePreview.locale(context),
        localizationsDelegates: L10n.localizationsDelegates,
        supportedLocales: L10n.supportedLocales,
        routeInformationParser: appRouter.defaultRouteParser(),
        routerDelegate: appRouter.delegate(),
      ),
    );
  }
}

タブレット端末で見ると、いい感じに表示されます。

先ほど使用した、--dart-defineでDevice Previewの有効にするかどうかの制御ができるようにします。

lib/foundation/constants.dart
import 'package:enum_to_string/enum_to_string.dart';

enum Flavor { dev, prod }

class Constants {
  // 省略
  
+  /// Device Previewを有効化するかどうか
+  static bool get enablePreview => const bool.fromEnvironment('PREVIEW');
}
lib/main.dart
import 'dart:async';

import 'package:device_preview/device_preview.dart';
import 'package:enum_to_string/enum_to_string.dart';
import 'package:fimber/fimber.dart';
import 'package:flapp/foundation/constants.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'app.dart';

Future<void> main() async {
  // 省略

  runZonedGuarded(
    () => runApp(
      ProviderScope(
        child: DevicePreview(
-          enabled: !kReleaseMode,
+          enabled: !kReleaseMode && Constants.enablePreview,
          builder: (context) {
            return const MyApp();
          },
        ),
      ),
    ),
    (error, stackTrace) {
      Fimber.e(error.toString());
    },
  );
}

--dart-define=PREVIEW=trueを渡すと、Device Previewが有効化になります。

$ fvm flutter run --dart-define=FLAVOR=dev --dart-define=PREVIEW=true --target lib/main.dart

Makefileでよく使うショットカットコマンドを実装

Makefileを作成すると、shellコマンドをmakeコマンドという形で自分で作成できるようになります。例えば、fvm flutter format libmake formatで実行できるようにすることができます。

$ touch Makefile

下記は、自分がFlutterをやるときにいつも使っているMakefileになります。

Makefile
################################################################################################
## 環境変数 flavor
################################################################################################
DEV_FLAVOR := dev
PROD_FLAVOR := prod


################################################################################################
## 基本コマンド
################################################################################################
# setup
.PHONY: setup
setup:
	dart pub global activate fvm
	fvm install
	yarn install

# packages install
.PHONY: dependencies
dependencies:
	fvm flutter pub get

# packages upgrade
.PHONY: upgrade
upgrade:
	fvm flutter packages upgrade

# packages clean
.PHONY: clean
clean:
	fvm flutter clean

# code format
.PHONY: format
format:
	fvm flutter format lib/

# code analyze
.PHONY: analyze
analyze:
	fvm flutter analyze

# l10n generate
.PHONY: l10n
l10n:
	fvm flutter gen-l10n

# code generate
.PHONY: build-runner
build-runner:
	fvm flutter packages pub run build_runner build --delete-conflicting-outputs

# code generate watch
.PHONY: build-runner-watch
build-runner-watch:
	fvm flutter packages pub run build_runner watch --delete-conflicting-outputs

# run test
.PHONY: test
test:
	fvm flutter test


################################################################################################
## 実行・ビルド
################################################################################################
# run dev
.PHONY: run-dev
run-dev:
	fvm flutter run --dart-define=FLAVOR=${DEV_FLAVOR} --target lib/main.dart

# run dev (with Device Preview)
.PHONY: run-dev-preview
run-dev-preview:
	fvm flutter run --dart-define=FLAVOR=${DEV_FLAVOR} --dart-define=PREVIEW=true --target lib/main.dart

# run prod
.PHONY: run-prod
run-prod:
	fvm flutter run --release --dart-define=FLAVOR=${PROD_FLAVOR} --target lib/main.dart

# build APK dev
.PHONY: build-android-dev
build-android-dev:
	fvm flutter build apk --dart-define=FLAVOR=$(DEV_FLAVOR) --target lib/main.dart

# build APK prod
.PHONY: build-android-prod
build-android-prod:
	fvm flutter build apk --release --dart-define=FLAVOR=$(PROD_FLAVOR) --target lib/main.dart

# build IOS dev
.PHONY: build-ios-dev
build-ios-dev:
	fvm flutter build ios --dart-define=FLAVOR=$(DEV_FLAVOR) --target lib/main.dart

# build IOS prod
.PHONY: build-ios-prod
build-ios-prod:
	fvm flutter build ios --release --dart-define=FLAVOR=$(PROD_FLAVOR) --target lib/main.dart

以下のコマンドなどが使えるようになります。

# format
make format

# 実行(dev環境)
make run-dev

# 実行(dev環境・Device Preview有効)
make run-dev-preview

Visual Studio Codeの設定

vscodeでデバッグ構成をいくつか追加しておきます。

$ mkdir -p .vscode
$ touch .vscode/launch.json
.vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "run dev",
      "request": "launch",
      "type": "dart",
      "program": "lib/main.dart",
      "args": ["--dart-define=FLAVOR=dev"]
    },
    {
      "name": "run dev (with Device Preview)",
      "request": "launch",
      "type": "dart",
      "program": "lib/main.dart",
      "args": ["--dart-define=FLAVOR=dev", "--dart-define=PREVIEW=true"]
    },
    {
      "name": "run prod",
      "request": "launch",
      "type": "dart",
      "program": "lib/main.dart",
      "args": ["--dart-define=FLAVOR=prod"]
    }
  ]
}

ファイル非表示の設定もできます。node_modulesとかは基本中を見ることはないと思うので、設定しておきます。

$ touch .vscode/settings.json
.vscode/settings.json
{
  "files.exclude": {
    ".dart_tool": true,
    ".idea": true,
    "build": true,
    "node_modules": true,
    ".flutter-plugins": true,
    ".flutter-plugins-dependencies": true,
    ".metadata": true,
    ".packages": true,
    "yarn.lock": true,
    "pubspec.lock": true
  }
}

終わりに

今回のプロジェクト構成は、同じく学生エンジニアの友達の影響をもろに受けてます😋
備忘録として書いたつもりが、めちゃくちゃ丁寧にまとめてしまって、気づいたら文字数がエグいことになってました笑(卒論はこれで提出したい、、)

ちなみにリポジトリはこちらに公開しておりますので、テンプレートとして使ってもらっても全然OKです!

https://github.com/Alesion30/flapp-mvvm-templete

Discussion

ログインするとコメントできます