Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Flutterアプリケーションのアーキテクチャ(Studyplusの場合)

こんにちは。 モバイルクライアントチームの若宮(id:D_R_1009)です。 スプラトゥーン3がそろそろですね。アップをしっかりしていきましょう。

さて、モバイルクライアントチームではFlutterをAndroidやiOS、Web向けのフレームワークとして採用しています。 スタディプラス全体で見ると、2019年の2月からFlutterを採用しているので、すでに3年半ほど利用している状況です。

今回は、スタディプラスで採用している設計方針や目指している開発スタイルについて紹介します。

開発に利用する技術について

アプリケーション開発では、Flutterの仕組みそのものに背かないよう、技術選定をしています。 ただ、ProviderではなくRiverpodを採用しているなど、徹底しきっているわけではありません。

チームで取り組んでいるいくつかの工夫について、紹介します。

Architecture

tech.studyplus.co.jp

アプリケーションのアーキテクチャを考える際、ベースとしている理解は前回のブログのまま変わっていません。 小規模なアプリケーションでは、ProviderによるMVVMアーキテクチャは選択肢に入ります。 しかし、スタディプラスでリリースしているようなアプリケーションでは、管理しきれなくなってしまいます。

このため、一般的なモバイルアプリケーションのようなレイヤーを採用し、Model-View-Presenterをベースとしたクラスに分割しています。 Presenterの主な役割は、Model層へView層からアクセスするための処理を持つことです。 この層があることで、どれだけ簡単なAPIリクエストでも、View層が直接APIリクエストを行わないというルールを徹底できます。

状態管理には、state_notifierflutter_hooksを併用しています。 複数のViewにまたがる場合にはStateNotifierを、1つのViewでまとまる場合はuseStateを利用します。 ただ、StateNotifierを扱うために、Presenter層のクラスを運用することも許容しています。

なおflutter_hooksの導入は、useStateよりもuseAnimationControllerなど、便利なhookを活用することを目的にしています。 useMemorizeuseEffectのようなhookがあることで、簡潔に仕様を満たしている箇所もあると考えています。


useStateStateNotifierの使い分けは、Flutterのアプリケーション開発の中でも難しい議論です。 ここではチームの中で「どのように考えているか」を書いていますが、これが正解だとはいまいち自信が持てていません。

コードを眺めていると、Presenter層を3つのパターンに分類できるのでは、と感じるようになりました。 それぞれをクラス名につけることで、クラスの目的などを明確にし、運用しています。

  • Provider
    • ロジックだけ
    • アプリ内でsingletonで良いもの
  • Manager
    • ロジックと状態
    • StateNotifierを利用するもので、autoDisposeかどうかは要件による
  • Controller
    • ロジックと状態を持つ、UIのwidgetを管理するもの
    • ChangeNotifierを利用することもある
    • hooksのuseで管理するもの

Androidの実装におけるRepositoryを適切に実装していれば、サーバーから取得したデータをStateNotifierとして管理する必要はなくなります。 このため、実装をまとめProvidierStateNotifierProviderに処理が集約する状態にすることを目指しています。

スタディプラスではiOS・Androidのモバイルアプリケーションに加えWebアプリケーションへの対応も行っています。 このため、宣言的Navigation(Navigation 2)のサポートが必須となります。 また、宣言的NavigationはDIとも相性がよいので、積極的に採用したいものです。

スタディプラスでは、Flutterアプリケーションの開発にマイクロサービス開発を取り入れています。

fortee.jp

このため、それぞれのPackageにUIパーツが分割されます。 結果として、それぞれのPackageでは、Application全体のRoutingの設定ができません。 もしも設定してしまうと、統合するアプリケーション側で、複雑な対応が必要になります。 これでは、せっかくFlutterを採用しているのに、シンプルな設計や実装ができません。

ただ、この問題は下記の対応をすれば解決できるものとなります。

  • Applicationにおいて、すべてのrouteを定義する
  • Packageにおいて、画面遷移を引き起こす処理を抽象化する
  • Applicationにおいて、Packageで抽象化された処理の実体を定義する

各Packageでは、Packageの内部で必要な画面遷移を、遷移する処理を持つFunctionとして定義します。 このクラスをProviderで配布し、Applicationでoverrideすることで、実体をApplicationに合わせてDIします。

文字列だとわかりにくいので、実装例を紹介します。 なお、実装はサンプルコード用に簡略化してあります。

Package側の実装イメージ。

final recordRouterProvider = Provider<RecordRouter>(
  (_) => throw Exception('Provider was not initialized'),
);

class RecordRouter {
  const RecordRouter({
    required this.navigateToRecordDetail,
  });

  final Function({
    required BuildContext context,
    required String id,
  }) navigateToRecordDetail;
}

class SomeWidget extends ConsumerWidget {
  const SomeWidget({
    super.key,
    required this.id,
  });

  final String id;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ListTile(
      onTap: () {
        ref.read(recordRouterProvider).navigateToRecordDetail.call(
          context: context,
          id: id,
        );
      },
    );
  }
}

Application側の実装イメージ。

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

  @override
  Widget build(BuildContext context) {
    final router = GoRouter();

    return ProviderScope(
      overrides: [
        recordRouterProvider.overrideWithValue(
          RecordRouter(
            navigateToRecordDetail: ({
              required BuildContext context,
              required String id,
            }) {
              context.push('/record/detail/$id');
            }
          ),
        ),
      ],
      child: MaterialApp.router(
        title: 'Flutter App',
        routeInformationProvider: router.routeInformationProvider,
        routeInformationParser: router.routeInformationParser,
        routerDelegate: router.routerDelegate,
      ),
    );
  }
}

GoRouter()は、Application側で必要なroutingを実装します。 このroutingはApplicationごとの実装ができ、従来のNavigatorの採用も可能です。

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

  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      overrides: [
        recordRouterProvider.overrideWithValue(
          RecordRouter(
            navigateToRecordDetail: ({
              required BuildContext context,
              required String id,
            }) {
              Navigator.of(context).push(
                MaterialRoute(
                  builder: (context) => RecordDetailScreen(
                    id: id,
                  ),
                )
              );
            }
          ),
        ),
      ],
      child: MaterialApp(
        title: 'Flutter App',
        home: const MyHomePage(),
      ),
    );
  }
}

Localization

他言語対応は、後から対応しようとすると大変な項目の1つです。 Flutterにはarbファイルを利用した方法が組み込まれ、公式ドキュメントで紹介されています。

docs.flutter.dev

しかし、arbファイルを利用した他言語対応は、下記2つの理由から適していませんでした。

  • Packageの内部でarbファイルによる他言語対応ができない
  • 「日本語」と「やさしい日本語」のような言語切り替えの用意をしたい

このような事情のため、他言語対応にはRiverpodを利用する実装をしています。 まず、ApplicationやPackageで利用する文言の一覧を定義したクラスを記述します。

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

part 'localization_ja.dart';

final localizationProvider = Provider(
  (_) => japanese,
);

@immutable
class Localization {
  const Localization({
    required this.hello,
  });

  final String hello;
}

次に、日本語のローカライズを行うファイルを追加します。

part of 'localization.dart';

const japanese = Localization(
  hello: 'こんにちは',
);

Localeや日本語とやさしい日本語の切り替えフラグをprovideすることで、表示する文字列を切り替えることができます。 また、必要に応じてクラスを分割することで、膨らみがちな文言定義のファイルを分割できます。

開発への取り組み方について

続いて、モバイルクライアントチームが行っている開発の方針を紹介します。

小さな開発

Flutterの採用理由はいくつかありますが、最大の目的は開発の高速化です。 このため、開発を開始してから、PRがマージされるまでの時間を短くする取り組みをしています。

早くPRがマージできるようにするため、「PRのサイズを小さくする」ことに取り組んでいます。 小さなPRは、実装とレビューを早く済ませることができ、負担を下げることにつながります。 結果として、小さな修正を高速にリリースすることとなり、不具合解消の負担も軽微になりました。

こういった小さなPRを支えるために、マイクロサービス化したリポジトリを活用しています。 それぞれのリポジトリでは、小さなFlutter Applicationを用意し、開発や検証をリポジトリ単体で完結させています。 テストやコードのチェックをPRのたびにCIで回す際にも、実行時間が過剰に伸びることもなく、快適な環境を構築できています。


マイクロサービス化することのデメリットは、依存解決の煩雑さです。 現時点ではgitのリポジトリを用意し、git cloneによる依存関係の取得を利用することになります。

dart.dev

なお、将来的には、DartのPrivate Packageとして社内向けに配布したいと考えています。 現時点ではGitHub PackagesがDartに対応していないので、対応がされたら、という状態です。

medium.com

ライブラリを組み合わせる

上述の通り、それぞれのリポジトリはFlutterのライブラリ的な存在になります。 このため、開発するWidgetは通常の開発と少しだけ異なる方針が必要です。

独立して再利用可能である

1つ目の特徴は「独立して再利用可能である」ことです。 利便性の高いライブラリは、外部に開いているAPIを調整することで、その振る舞いを適切に制御できます。 Widgetがサイズを規定することは、基本的にはありません。 ユーザーアイコンを表示するようなケースでは、Packageの内部のWidgetがサイズを決定することがありますが、それ以外は親Widgetから与えられたサイズで表示することを前提とします。

また、ライブラリで呼び出すWidgetのStateは、Widgetの特徴に応じて管理されます。 useStateStateNotifierの使い分けが難しくなりますが、それらはPackageの内部に閉じ込められるため、リファクタリングが容易です。 結果として、手を動かしながら状態管理の知見を深め、実装スキルを上げることを狙っています。

アプリケーションごとの設定の反映

2つ目の特徴は、「アプリケーションごとの設定の反映」です。 開発しているPackageは、複数のサービスとして提供されることとなります。 このため、同じ機能であっても、サービスごとに機能が有効・無効になることがあります。

こういったアプリケーションごとの設定を、Riverpodを利用して実現しています。 切り替えられる機能のフラグを1つにまとめ、必要に応じて有効・無効を切り替えたクラスをPackageから外部に公開しておきます。 あとは、ApplicationでProviderScopeoverridesに、フラグを変更したクラスを指定してあげれば完了です。 この方法が特に有効なのは、APIリクエストの向き先などの制御です。 検証や本番などの状況に応じて、リクエストのヘッダーなどを差し替えることができます。

UIパーツの共通化とコピーコード

最後の特徴は、「UIパーツの共通化とコピーコード」です。 複数のリポジトリに分割すると、どうしても同じようなWidgetを複数のリポジトリで利用したくなります。 しかし、可能な限り、こういった共通パーツをまとめたリポジトリを作成しないこととしています。

FlutterはMaterial Designをベースとしたフレームワークです。 このため、共通化するべきWidgetは、既に十分なサイズで共通化されていると考えています。 「どうしても共通化したい」ケースは共有するPackageを作りつつ、似たコードが複数発生することを許容しながら開発しています。

この実装のメリットとしては、リポジトリとリポジトリの間で、暗黙的な依存が生じにくくなるというものがあります。 複数のリポジトリにまたがるWidgetは便利ですが、そのWidgetの改修時に、複数のリポジトリに影響を与える状態になることも意味します。 高速な開発を前提としているため、共有しないデメリットがあることは把握しつつ、あえてコードをコピーすることも選択しています。

考えることを最小にする

アプリケーション開発は、さまざまな意思決定の積み重ねになります。 このため、可能な限り、考えるべきことだけを考えたいと感じています。

特にコードレビューをしている際に顕著なのですが、複雑なコードは、その把握に思考を割く必要があります。 このため、仕様の複雑さはしょうがないものの、可能な限りコードはシンプルであるべきだと考えています。 幸いなことに、FlutterはシンプルなWidgetの組み合わせで動作するフレームワークです。 フレームワークの特性が活かせるよう、シンプルな実装と、シンプルな実装の組み合わせを指向しています。

仕様の実装について考慮することは許容できますが、他のエンジニアの進捗を気にするのは避けたいと考えています。 このため、複数の開発に取り組むリポジトリを調整し、コンフリクトを構造的に避けています。 また、Model層の作成を先行してマージし、後程別のエンジニアがView層の実装を担当することなども行っています。

これから目指していくところ

Flutterは、一度実装のスキルを習得すると、高速に開発できるフレームワークです。 このため「Flutterによるアプリケーション開発を高速にする」ためには、「Flutterの知見を常に吸収する」ことが必要だと感じています。

Flutterは徐々に安定しつつありますが、まだまだ発展途上のフレームワークです。 さらに、Android・iOS・Webに対応する開発をしていると、まだ知見のない開発をすることになります。 こうした状況を楽しめる、常に新しいことができる状態を維持することを、チームで目指しています。