はじめに
現在、プロジェクトで開発しているアプリはOEMのような形式で複数展開しています。
元々、Melosを使ったモノレポ構成でのマルチパッケージで実装していましたが、今後のビジネス要件の特徴を考慮していく上でパッケージ構成を見直した話になります。
モバイルアプリ界隈ではマルチモジュールの話が多々ありますが、似たような事例としてFlutterでマルチパッケージの一つの参考例になればと思い記事にしています。
背景
過去、外部に委託していたアプリを1番注力のものからFlutterで内製化を始め、少しずつ他のアプリも毎年内製化をしてきました。
現在は諸事情により、17職種のうち8職種のみ提供する形を取っています。
その段階的な内製化の過程で、Melosを使ったモノレポ構成かつマルチパッケージでアプリ開発を行なっていましたが、ビジネス要件上でアプリによって機能・デザインを変更することがありました。
例 ホーム画面の違い
歯科衛生士 | 歯科医師 |
---|---|
- デザインが異なる
- 歯科衛生士(左側)のアプリだけ独自機能が存在する
- 画面遷移(ボトムナビゲーション)が異なる
などの異なる点があります。
この違いにより、内部設計的に機能・デザインの柔軟性がなく、ファイル内でどのアプリかを分岐するコードによる影響で複雑さを引き起こす点や、継続していくと肥大化し負債になっていく懸念点がありました。
今後より、アプリごとに機能・デザインや画面遷移も変更する可能性があり、柔軟に組むことができるように設計の見直しをしました。
注意事項
- 特定の技術自体の解説はしないです
- Riverpod
- Melos
- GoRouter
- 本記事はRiverpod Generatorを使ったコードではなく、現在プロジェクトでも未導入です
- パッケージ分割の見直しを優先したため未導入です
- 今後また良きタイミングでGeneratorの導入と実装方法の変更はするかも?
- いわゆるベストプラクティスといった記事ではないです
- あくまで似たような事例があれば、参考程度や部分導入の検討になれば・・・ぐらいの温度感です
利用技術や開発体制
以下技術を利用しています。
種類 | 技術 |
---|---|
モノレポ管理 | Melos |
状態管理 | Riverpod |
ルーティング | GoRouter(go_router_builderも含む) |
他 | GraphQL, Firebase |
また開発体制としては以下の歴史があります。
1人 → 2人 → 2人+協力会社 → 2人(に戻る)
設計面の課題
Melos導入期には元々以下のようにパッケージ分割をしてました。
apps/
packages/
それぞれのディレクトリ構成としては以下です。
- apps・・・それぞれのapp群をまとめる
- packages・・・それぞれのパッケージ群をまとめる
また、当初のpackages内は簡易的に以下のようなパッケージ分割を行っていました。
kakomon_features・・・国試アプリ向けの機能群をまとめたもの
data_soutce・・・APIやローカルDBなどのデータ群
common・・・共通処理
機能面
全てのアプリで同じ機能を持つだけなら簡易的なkakomon_features
パッケージで一時的に良かったですが、柔軟な点を考慮するとアプリごとに必要なパッケージを参照する方が、柔軟性やどういう機能パッケージに依存しているかの明示性が高まります。
デザイン面
背景でも述べたデザインが異なっていく点で、当初スクリーンクラスは以下のように実装してました。
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:kakomon_features/features/home/widget/home_dh_body.dart';
import 'package:kakomon_features/features/home/widget/home_other_body.dart';
class HomeScreen extends HookConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final jobType = ref.watch(jobTypeProvider);
// 省略
return jobType == JobType.dh ? const HomeDhBody() : const HomeOtherBody();
}
}
jobTypeという変数はそれぞれの職種(≒アプリ)を示す変数です。
歯科衛生士とそれ以外のアプリだけで分ける程度なら問題ないですが、今後アプリごとにデザインや機能を分けることを考慮すると、1つのViewのファイルに職種固有の分岐をするのは複雑化していく課題があります。
また、別のUIでも職種ごとの画像ファイルがあり
例 ロゴなどのブランディング↓
全ての画像パターン(17職種分)のファイルをパッケージ側で持っていました。
歯科医師アプリなら歯科医師の画像ファイルだけ持てばよく、他のアプリ向けのファイルを含むようになってました。
解決したいこと
複数アプリを展開する上で、個別の機能・デザインとそうでない共通化されたものという特徴があります。
設計面の課題にもあるとおり、以下の柔軟性を達成できるように設計を見直しました。
- 柔軟性
- 機能
- UI
- スクリーンレベルでの共通なもの
- アプリごとの個別UI
- 画面遷移
参考にしたもの
マルチモジュールの内容を色々と見て、特に以下2つが参考になりました。
最初は垂直方向(Feature)にするか、水平方向(Layer)に分割するかで悩んでました。
厳密には異なりますが、Google公式の解説で要件に合わせてFeature✖️Layerという複数の組み合わせで分割するという考え方が参考になりました。
また、ANDPAD社の「複数アプリ間で使い回すために、ある機能をモジュールとして切り出す」という視点で分割していくのも、要件上近しく参考になりました。
移行後のもの
最終的に図のような構成にしました。
common_grinderは共通化されたタスクランナーのパッケージ 本筋とはあまり関係ないため、詳細な説明は割愛
各パッケージの説明
移行前と同様に、appsとpackagesでそれぞれに分けつつ、パッケージは以下の役割で分けています。
なるべく各パッケージ内で利用しているサードパーティパッケージのアップデートで依存沼になりにくいように、上から下へ依存する構成をとってます。(下から上はしない)
core
共通処理やFirebaseなどのplugin群、API・DBなどアプリに必要不可欠なものをまとめているパッケージになります。
feature群
各機能をまとめたパッケージ群になります。
現状は以下があります。
- app_common_feature・・・アプリによくある共通的な機能(お知らせやオンボーディングなど)
- auth_feature・・・認証関連の機能
- exam_feature・・・国試関連の機能
- job_guppy_feature・・・求人関連の機能
また、依存関係が複雑化しないようにfeatureパッケージ同士は依存しないようにしています。
新しい分類としての機能群が必要になったり、既存のfeatureパッケージが肥大化したりするようであれば、新しいfeatureパッケージの追加や分割が可能です。
featureパッケージが多すぎてもかえって複雑化するため、一旦上記の粒度にしています。
common_ui
独特な役割を持つパッケージですが、、、、
アプリで同じUIを共通化したパッケージになります。
featureパッケージを参照しUIの実装、全く同じ画面やコンポーネントの利用する場合はcommon_ui
を使います。
歯科衛生士アプリ以外はほぼ同じデザインで、このパッケージのUIを多く利用します。
本来であれば機能に特化したUI等も、feature側にまとめた方が関心毎にまとめやすく分かりやすいです。
app群
app側では以下を持つようにしてます。
- main.dart
- app.dart
- アプリ独自のassetsファイル
- アプリ独自のView(スクリーンやコンポーネント)ファイル
- routerファイル
例えば、歯科衛生士独自のUIはapp側で持つようにしてます。
イメージ↓
後述もしますが、アプリごとに柔軟な画面遷移を実現できるようにgo_routerを使ったrouterファイルを定義しています。
アーキテクチャ
アーキテクチャの構成としては以下にしています。
- View
- Screen
- 機能専用Viewや共通View
- State・・・状態レイヤー ViewModelライクなもの
- Service・・・プレゼンテーション周りとは関係ないドメインロジックを行う箇所
- Repository・・・データの永続化を行う箇所
パッケージの見直し前からこのアーキテクチャにしており、Viewとデータのコードを分ける構成をとっていました。
移行後は
featureパッケージ側にState・Service・Repositoryのレイヤーを持ち、
common_ui
やapp側でviewレイヤーを持っています。
データフローとしては以下でやりとりし、
View ↔︎ State ↔︎ Service ↔︎ Repository
コードとしては以下のような感じです。
repository
import 'package:hooks_riverpod/hooks_riverpod.dart';
final hogeRepositoryProvider =
Provider.autoDispose<HogeRepository>(
(ref) => HogeRepository(ref),
);
class HogeRepository {
HogeRepository(this._ref);
final Ref _ref;
ApiClient get _apiClient => _ref.read(apiClientProvider);
Future<Hoge> fetchHoge() {
// 省略
}
}
service
import 'package:hooks_riverpod/hooks_riverpod.dart';
// repositoryファイルimportは例のため省略
final hogeServiceProvider =
Provider.autoDispose<HogeService>(
(ref) => HogeService(ref),
);
class HogeService {
HogeService(this._ref);
final Ref _ref;
HogeRepository get _hogeRepository => _ref.read(hogeRepositoryProvider);
Future<Hoge> fetchHoge() {
return _hogeRepository.fetchHoge();
}
}
state
import 'package:hooks_riverpod/hooks_riverpod.dart';
// serviceファイルimportは例のため省略
final hogeNotifierProvider = AsyncNotifierProvider<
HogeNotifier, HogeState>(HogeNotifier.new);
class HogeNotifier
extends AsyncNotifier<HogeState> {
HogeService get _hogeService => ref.read(hogeServiceProvider);
@override
Future<HogeState> build() async {
final result = await _hogeService.fetchHoge();
// 省略
}
// 省略
}
stateレイヤーで単純にデータの取得で済ますぐらいであれば、FutureProviderで済ませる場合もあります。
view
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class HogeScreen extends HookConsumerWidget {
const HogeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
// 省略
body: ref.watch(hogeNotifierProvider).when(
skipLoadingOnRefresh: false,
data: (data) => HogeView(),
loading: () => const LoadingView(),
error: (_, __) => Center(
child: ErrorView(
// 省略
onPressed: () => ref.invalidate(hogeNotifierProvider),
),
),
),
);
}
}
テスタブルにしたいだけなら、RiverpodのDIの仕組みとDartの暗黙的インターフェースを利用すれば可能なので、現状はabstract classを使った抽象に依存する設計にはしてないです。
必要な場面になったら、導入を検討します。
ディレクトリ構成
feature-firstの構成を取っています。
↓
feature-firstなディレクトリ構成に沿って、各パッケージの構成を揃えてます。
featureパッケージ
例 auth_feature
機能ディレクトリ > アーキテクチャのレイヤー毎
├── auth_feature.dart
└── src
├── sign_in
│ ├── repository
│ ├── service
│ ├── sign_in.dart
│ └── state
├── sign_up
│ ├── repository
│ ├── service
│ ├── sign_up.dart
│ └── state
└── withdrawal
├── repository
├── service
├── state
└── withdrawal.dart
common_uiパッケージ
├── common_ui.dart
├── dh_feature.dart
└── src
└── feature
├── announce
│ ├── announce.dart
│ ├── screen
│ ├── util
│ └── view
├── home
│ ├── home.dart
│ ├── screen
│ └── view
└── feature.dart
歯科衛生士(app層)
├── app.dart
├── feature
│ ├── home
│ │ ├── screen
│ │ └── view
│ └── question_settings
│ └── view
├── gen
│ └── assets.gen.dart
├── main.dart
└── router
柔軟な画面遷移
ややチャレンジングな設計ですが、、、
go_routerを使って、柔軟な画面遷移にしています。(別のrouterパッケージや、従来のNavigator 1でもやろうと思えばできるかも・・・?)
アプリ毎にrouterファイルを定義するようにしています。
routerディレクトリとしては以下のディレクトリ構成です。
例
├── app_router.dart
├── app_router.g.dart
└── branch
├── home_branch.dart
├── job_info_branch.dart
└── other_branch.dart
branchディレクトリについて
BottomNavigationBarを使ったネストしたナビゲーションが存在するので、ネストしたナビゲーション分StatefulShellBranchを使ったブランチ毎にファイルを分割しています。
StatefulShellRouteやStatefulShellBranchの細かい解説はこちらの方の記事が非常に分かりやすいです↓
https://zenn.dev/flutteruniv_dev/articles/stateful_shell_route
宣言的なナビゲーションでの定義によって、アプリごとのルート階層がどうなるかを明示的にできます。↓
実ファイルの例
app_router.dart
import 'package:flutter/material.dart';
import 'package:common_ui/common_ui.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:dh/router/branch/home_branch.dart';
import 'package:dh/router/branch/job_info_branch.dart';
import 'package:dh/router/branch/other_branch.dart';
part 'app_router.g.dart';
final rootNavigatorKey = GlobalKey<NavigatorState>();
final appRouterProvider = Provider<GoRouter>(
(ref) {
final router = GoRouter(
initialLocation: LaunchRoute.path,
navigatorKey: rootNavigatorKey,
routes: $appRoutes,
// 省略
);
return router;
},
);
@TypedGoRoute<OnBoardingRoute>(
path: OnBoardingRoute.path,
name: OnBoardingRoute.name,
)
class OnBoardingRoute extends GoRouteData {
const OnBoardingRoute();
static const path = '/onboarding';
static const name = 'onboarding';
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return const NoTransitionPage(
child: OnBoardingScreen(nextRouteName: InitialSignInRoute.name),
);
}
}
@TypedGoRoute<InitialSignInRoute>(
path: InitialSignInRoute.path,
name: InitialSignInRoute.name,
routes: [
TypedGoRoute<SignUpRoute>(
path: SignUpRoute.path,
name: SignUpRoute.name,
),
],
)
class InitialSignInRoute extends GoRouteData {
const InitialSignInRoute();
static const path = '/initial-sign-in';
static const name = 'initialSignIn';
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return NoTransitionPage(
child: InitialSignInScreen(
signUpRouteName: SignUpRoute.name,
nextRouteName: HomeRoute.name,
titleImageName: Assets.title.dhTitleAndChan.path,
),
);
}
}
// 省略
@TypedStatefulShellRoute<MainFeatureShellRoute>(
branches: [
homeBranch,
jobInfoBranch,
otherBranch,
],
)
class MainFeatureShellRoute extends StatefulShellRouteData {
const MainFeatureShellRoute();
@override
Page<void> pageBuilder(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return NoTransitionPage(
child: MainFeatureScreen(
navigationShell: navigationShell,
notificationAnnounceRouteName: NotificationAnnounceRoute.name,
),
);
}
}
home_branch.dart
import 'package:flutter/material.dart';
import 'package:common_ui/common_ui.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:dh/feature/home/screen/home_screen.dart' as dh;
import 'package:dh/gen/assets.gen.dart';
import 'package:dh/router/app_router.dart';
final homeNavigatorKey = GlobalKey<NavigatorState>();
class HomeBranchData extends StatefulShellBranchData {
const HomeBranchData();
static final GlobalKey<NavigatorState> $navigatorKey = homeNavigatorKey;
static const String $initialLocation = HomeRoute.path;
}
const homeBranch = TypedStatefulShellBranch<HomeBranchData>(
routes: [
TypedGoRoute<HomeRoute>(
path: HomeRoute.path,
name: HomeRoute.name,
routes: [
TypedGoRoute<SignInRouteFromHome>(
path: SignInRouteFromHome.path,
name: SignInRouteFromHome.name,
routes: [
TypedGoRoute<SignUpRouteFromHome>(
path: SignUpRouteFromHome.path,
name: SignUpRouteFromHome.name,
),
],
),
// 省略
],
),
],
);
class HomeRoute extends GoRouteData {
const HomeRoute();
static const path = '/home';
static const name = 'home';
@override
Widget build(BuildContext context, GoRouterState state) {
return const dh.HomeScreen();
}
}
// 省略
また柔軟な画面遷移を実現するために、Screenクラスに遷移先のルート名の引数を持つようにしています。
オンボーディングのスクリーンクラスの例
@TypedGoRoute<OnBoardingRoute>(
path: OnBoardingRoute.path,
name: OnBoardingRoute.name,
)
class OnBoardingRoute extends GoRouteData {
const OnBoardingRoute();
static const path = '/onboarding';
static const name = 'onboarding';
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return const NoTransitionPage(
child: OnBoardingScreen(nextRouteName: InitialSignInRoute.name),
);
}
}
スクリーンクラス内では、渡されたルート名を元に画面遷移を実行するようにしています。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:app_common_feature/app_common_feature.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:common_ui/src/common_view/scaffolds.dart';
class OnBoardingScreen extends HookConsumerWidget {
const OnBoardingScreen({super.key, required this.nextRouteName});
final String nextRouteName;
@override
Widget build(BuildContext context, WidgetRef ref) {
// オンボーディングの完了をref.listenで監視している
ref.listen(onboardingProvider, (_, next) async {
if (!next) {
return;
}
// 渡されたRouteNameで画面遷移を実行
context.goNamed(nextRouteName);
});
// Widgetを返す部分は省略
}
}
ルート名での画面遷移を実行するコードなので、例えば他のアプリで異なる画面遷移にしたい場合は、渡すルート名を変更すれば柔軟な画面遷移が実現できます。
例
歯科衛生士 オンボーディング → ログイン画面
別アプリ オンボーディング → ホーム画面
ただ、やや試行錯誤中なため画面遷移を実行するコンポーネント(Widget階層)が深い場合は、ルート名をバケツリレーしてしまうデメリットもあり現状は悩んでます。。。。
また、アプリ独自のスクリーン側ではrouterファイルが同じappパッケージ内にあるので、そのままgo_router_builderで生成された画面遷移実行メソッドを使った方が楽です。
dh専用のHomeScreenのコンポーネント例
class _ExaminationListView extends HookConsumerWidget {
const _ExaminationListView();
@override
Widget build(BuildContext context, WidgetRef ref) {
return Ink(
decoration: ShapeDecoration(
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Column(
children: [
// 省略
const Divider(height: 0, indent: 55),
FeatureListTile(
// 省略
// ↓ ここ
onTap: () => const BookmarkQuestionListRoute().go(context),
),
const Divider(height: 0, indent: 55),
FeatureListTile(
// 省略
// ↓ ここ
onTap: () => const IncorrectQuestionListRoute().go(context),
),
],
),
);
}
}
ルート名を渡すやり方は、common_ui
パッケージ限定のやり方にしています。
まとめ
当初はモノシリックよりの機能+αで色々まとめていたものを、それぞれのfeatureパッケージとして同じジャンル(関心毎)ごとに分割ができました。
また、柔軟な画面遷移の事例が他ではなかなかなくやや独特な設計になっていますが、、、
もう少し運用を進めメリット・デメリットを洗い出し、プロジェクトにとって最適な設計や運用ができればなと思います。
他参考にしたもの