まず作ったアプリについて
私たちが開発したのは、エコーチェンバー現象を解決するための 多角的思考育成アプリ「Critica」 です。SNSによって生まれる思考の偏りに対し、「意見を書く・見る・議論する」体験を通して、多様な視点に自然と触れられることを目指しました。
主な機能は以下の通りです。
・一日一トピック機能
最近話題になっているトピックに対して、1日1回自分の意見を投稿し、他ユーザーのさまざまな意見を見ることができます。
・チャレンジ機能
自分が投稿した意見とは反対の立場の意見をあえて書くことで、AIが内容を審査・スコアリングし、フィードバックを返します。自分の思考の幅を客観的に振り返ることができます。
・リアルタイムディベート機能
同じトピックに興味を持つユーザー同士が、リアルタイムでディベートできる機能です。
・統計・可視化機能
これまでの投稿傾向や思考の偏りを、統計情報として確認できます。
発表資料
学んだ参考書
良いコード悪いコードで学ぶ設計入門
Clean Architecture 達人に学ぶソフトウェアの構造と設計
目次
- 設計思想を考える上での前提条件
-
設計思想の軸
- チームでのわかりやすい設計
- 責務を明確に分離する設計
- 具体的な設計
- 設計時に悩んだ点
- 振り返り、反省
設計思想を考える上での前提条件
まず前提条件として、
- 三ヶ月での開発
- 5人チーム
- Flutterでの開発
- Flutter初学者もわかりやすいもの
上記での条件で設計を考えました。
設計思想の軸
チームでのわかりやすい設計
まず重視したのは、チーム開発における分かりやすさ です。
「どこにどの機能が実装されているのか」「修正したいときにどこを見ればよいのか」が直感的に分かる構成を目指しました。
技術書などでよく紹介されている従来の構成として、
Logic・UI・Data といった層ごとにファイルを分ける Layer-First 構成があります。
しかし今回の開発では、この手法は採用せず、Feature-First(機能単位)構成を採用しました。
Feature-First 構成では、1つの機能ごとに UI・ロジック・データ取得処理をまとめて管理します。
この構成を採用した理由は、「どの機能を触りたいか」からすぐにコードへ辿り着ける点にあります。
チームメンバーが修正や機能追加を行う際にも、
-
対象の機能ディレクトリを開くだけで必要なファイルが揃っている
-
他の機能への影響範囲を把握しやすい
といったメリットがあり、認知コストを下げることができました。
具体的には、以下のようなディレクトリ構成を採用しています。
lib/feature/◯◯/
├── models/ # データモデル (Freezed)
├── repositories/ # インターフェース
├── providers/ # 状態管理、ロジック
├── presentation/
│ ├── pages/ # 画面
│ └── widgets/ # その機能専用の部品
├── services/ # FIrestoreやAIとの通信
└── README.md # 機能ごとの仕様書
責務を明確に分離する設計
まず 責務を分離すること(Separation of Concerns) は、ソフトウェア開発において重要な原則の一つです。
これは、プログラムの各部分がそれぞれ一つの明確な役割を持つように設計する考え方です。
今回の Flutter アプリでは、この考え方をベースにレイヤーごとに責務を分離した構成を採用しました。
具体的な実装は以下の通りです。
Presentation Layer(UI)
-
ユーザーの入力を受け付けることに専念
-
Provider を呼び出すだけで、ロジックは持たない
-
画面を「どのように表示するか」のコードのみを記述
UI 層では、状態の更新やビジネスロジックを直接扱わないことで、
デザイン変更や UI 改修を安全に行えるようにしています。
Provider Layer(Logic)
-
Riverpod を用いた状態管理
-
アプリ内のビジネスロジックを集約
-
UI からのイベントを受け取り、状態を更新
UI とデータ取得処理の間にこの層を挟むことで、
画面とロジックの結合度を下げています。
Repository Layer(Interface)
-
Firestore などの外部データソースを抽象化
-
データ取得・保存のインターフェースを定義
この層を設けることで、将来的にデータソースを変更する場合でも
上位レイヤーへの影響を最小限に抑えられます。
Service Layer(Data)
-
Firestore など外部データソースとの実際の通信処理
-
API 呼び出しやデータ変換処理を担当
Repository 層からのみ呼び出されることで、
外部依存をアプリ全体に広げない構成にしています。
このように責務を明確に分離することで、ファイル間の依存関係が複雑になりにくく
エラー発生時の原因特定が容易で各レイヤー単位でのテストがしやすい
といったメリットが得られました。
具体的な設計
では実際の実装について書いていく。auth(認証)機能の実装を例にします
まずmodel(データの定義)について
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_model.freezed.dart';
part 'user_model.g.dart';
@freezed
class UserModel with _$UserModel {
const factory UserModel({
required String id,
required String nickname,
required String email,
@Default('') String ageRange,
@Default('') String region,
@Default('assets/images/default_avatar.png') String iconUrl,
required DateTime createdAt,
required DateTime updatedAt,
}) = _UserModel;
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
}
Freezedパッケージを使って自動でコードを生成
copyWith()を書かなくて済む
Presentation Layer(UI)- SignUpPage
まずUI側の動作として、
ユーザーはフォームに入力し、登録ボタンを押した時の動作
フォーム定義部分
class _SignUpPageState extends ConsumerState<SignUpPage> {
final _formKey = GlobalKey<FormState>();
final _nicknameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
-
GlobalKey<FormState>でフォーム全体のバリデーションを管理 - 各入力フィールドに対応する
TextEditingControllerを用意
登録ボタンの処理
Future<void> _handleSignUp() async {
// 1. フォームのバリデーションチェック
if (!_formKey.currentState!.validate()) return;
// 2. Providerを通じて登録処理を実行
await ref.read(authControllerProvider.notifier).signUpWithEmail(
email: _emailController.text.trim(), // 前後の空白を削除
password: _passwordController.text,
nickname: _nicknameController.text.trim(),
);
}
-
validate()がfalseを返した場合、処理を中断 -
trim()でメールアドレスとニックネームの前後空白を削除 -
ref.read()でProviderのメソッドを1回だけ実行
状態管理の監視
ref.listen<AuthState>(authControllerProvider, (previous, next) {
next.when(
initial: () {},
loading: () {},
guest: () {},
authenticated: (user) {
// 登録成功時
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('アカウント作成成功')),
);
// プロフィール設定画面に遷移
context.go('/profile-setup');
},
unauthenticated: () {},
error: (message) {
// エラー時
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
},
);
});
-
ref.listen()で状態変化を検知して副作用(画面遷移、通知表示)を実行 -
when()メソッドで状態ごとに処理を分岐 -
authenticated状態になったら自動的にプロフィール設定画面へ遷移
Provider Layer - AuthContorller
Presentaion-Layerから呼び出されるsignUpWithEmailメソッドの実装
final authServiceProvider = Provider<AuthRepository>((ref) {
return AuthService();
})
~~~~~~
class AuthController extends Notifier<AuthState> {
@override
AuthState build() => const AuthState.initial();
AuthRepository get _authRepository => ref.read(authServiceProvider);
Future<void> signUpWithEmail({
required String email,
required String password,
required String nickname,
}) async {
// 1. 状態を「処理中」に変更
state = const AuthState.loading();
try {
// 2. AuthServiceを通じてFirebase Authenticationで認証
final credential = await _authRepository.signUpWithEmail(
email: email,
password: password,
);
// 3. ユーザー作成に失敗した場合
if (credential.user == null) {
state = const AuthState.error('ユーザー作成失敗');
return;
}
// 4. UserModelインスタンスを作成
final now = DateTime.now();
final userModel = UserModel(
id: credential.user!.uid, // Firebase UIDを使用
nickname: nickname,
email: email,
createdAt: now,
updatedAt: now,
// ageRange, region, iconUrlはデフォルト値が設定される
);
// 5. Firestoreにユーザーデータを保存
await _authRepository.saveUserData(userModel);
// 6. ゲストモードフラグをクリア(SharedPreferences)
final prefs = await SharedPreferences.getInstance();
await prefs.remove('is_guest_mode');
// 7. FCMトークンを保存(プッシュ通知用)
await NotificationService().saveFcmToken(userModel.id);
// 8. 状態を「認証済み」に変更
state = AuthState.authenticated(userModel);
} catch (e) {
// 9. エラーが発生した場合、状態を「エラー」に変更
state = AuthState.error(e.toString());
}
}
}
- 状態管理:
stateを更新することで、UIが再描画される - 依存関係:
_authRepositoryはインターフェースで実装はAuthService()で行う
Repository Layer - AuthRepository
抽象インターフェースの定義
abstract class AuthRepository {
// 認証状態のStream
Stream<User?> get authStateChanges;
// メールアドレスで新規登録
Future<UserCredential> signUpWithEmail({
required String email,
required String password,
});
// Firestoreにユーザーデータを保存
Future<void> saveUserData(UserModel user);
// その他のメソッド...
}
- 抽象化を行い、実装の内容を隠蔽している
Service Layer(data) - AuthService
実際にFirebaseと通信する部分
class AuthService implements AuthRepository {
final FirebaseAuth _auth;
final FirebaseFirestore _firestore;
AuthService({
FirebaseAuth? auth,
FirebaseFirestore? firestore,
}) : _auth = auth ?? FirebaseAuth.instance,
_firestore = firestore ?? FirebaseFirestore.instance;
@override
Future<UserCredential> signUpWithEmail({
required String email,
required String password,
}) async {
try {
// Firebase Authenticationでアカウント作成
final credential = await _auth.createUserWithEmailAndPassword(
email: email,
password: password,
);
return credential;
} on FirebaseAuthException catch (e) {
// Firebaseのエラーメッセージ
throw _handleAuthException(e);
}
}
-
createUserWithEmailAndPassword()がFirebase側でアカウントを作成
saveUserDataの実装
@override
Future<void> saveUserData(UserModel user) async {
try {
// Firestoreの'users'コレクションにドキュメントを作成
await _firestore.collection('users').doc(user.id).set(user.toJson());
} on FirebaseException catch (e) {
throw _handleFirestoreException(e);
}
}
-
user.id(Firebase UID)をドキュメントIDとして使用 -
toJson()でUserModelをMapに変換 - Firestoreのパス:
users/{userId}
設計時に悩んだ点
- 一つのファイルにどれだけの責務を持たせるか
参考書などで紹介されている「単一責任の原則」を導入しようとしたが、その結果ファイル数が非常に多くなり、チーム全体の理解コストや管理負担が大きくなるのではないかと感じました。
そのため本プロジェクトでは責務をある程度まとめ、「UIは描画のみを担当し、ロジックはProvider側に寄せる」というルールを定めました。
単一責任の原則自体は重要だが、本アプリの規模やチーム構成を踏まえ、過度に細分化しない現実的な設計を優先しました。
- Providerにロジックを書かずにUsecaseにロジックを置く
クリーンアーキテクチャに従う場合、RepositoryとProvider(Notifier)の間に、ビジネスロジックを担うUseCase層を設けると書いてあった。
しかし本プロジェクトでは、UseCase層の導入は行わなかった。
理由としては、
-
ファイル数の増加による管理コスト
-
本アプリ規模では、データが層を横断するだけの処理が多くなり、構造が冗長になると感じた点
が挙げられる。
そのため、RiverpodのNotifierをUseCaseの代替として扱い、状態管理とビジネスロジックを兼ねる形で実装しました。
小規模アプリにおいては、必ずしもすべての層を分離する必要はなく、可読性と開発速度のバランスを重視した判断しました。
- RiverPodを使うかどうか
開発初期段階では、Riverpodを導入すべきかどうか悩みました
本アプリではそこまで複雑な状態管理を行っていなかったため、setStateだけでも十分ではないかと考えたからです。また、Riverpod特有の概念をチームメンバーがキャッチアップするための時間も懸念点でした。
それでも最終的にRiverpodを採用した理由は、非同期処理の安全性にありました。
今後ディベート機能の追加を想定した際、リアルタイムでのデータ取得(Stream)と、ユーザー操作による更新(Future)が混在することが予想されました。
Riverpodでは AsyncValue を用いることで、
-
ローディング
-
エラー
-
データ取得成功
といった状態を明示的に扱うことができ、非同期処理に起因する不具合を抑えられると判断しました。
正直なところ、他の状態管理手法への理解が十分でなかったという側面もあるが、それも含めてチームとして最も安全に開発を進められる選択がRiverpodでした。
振り返り、反省
本プロジェクトでは、開発初期段階で設計についてしっかり考えることはできていたものの、実装に充てられる時間が圧倒的に足りず、当初想定していた設計を十分に反映した実装ができませんでした。
結果として、「まず動くものを完成させること」を優先してしまい、設計の一部が疎かになってしまった点は反省点です。
具体的には、一つのProviderに多くの責務を持たせてしまったことや、一つのファイルに大量のコードを書いたことでコード行数が肥大化し、可読性や保守性が低下してしまった点が挙げられます。
また、画面遷移に関するロジックの実装にも苦労した。要件定義の段階で、画面構成や遷移フローをより具体的に設計しておけば、実装時の混乱を減らせたと感じています。
一方で、設計について意識的に考えながら開発を進めることができた点は大きな収穫だったと感じています。
時間に余裕があれば、より丁寧に設計手順を踏み、それを実装へ落とし込めたと考えています。今回の経験を通して、設計にかける時間と実装スピードのバランスの重要性を学ぶことができました。
今後の展望
今後は時間を確保し、既存コードのリファクタリングを行うことで、当初想定していた理想的な設計に沿った形へと実装を見直していきたいと考えています。
設計と実装の乖離を一つずつ解消し、可読性・保守性の高いコードへ改善することで、より完成度の高いプロダクトに仕上げていくことを目標としていきたいです。
