Flutterにて設計アーキテクチャ考慮しつつ、見出し単位でコピー可能なMarkdownメモを作った話
業務・プライベート両方でFlutter製のアプリを見ることが増えたこともあり、勉強のためMarkdownメモアプリを作りました。なるべくモジュール間を疎結合にするため、オニオンアーキテクチャを参考にしつつ[1]実装を試みたので備忘録をまとめます。
注記
- 本稿は備忘録として掲載しており、使用ライブラリのインストール方法や使用方法、実装の詳細については割愛しています。
- コードスニペットは、説明のために実装したコードから一部変更している箇所があります。
作ったアプリ
以下URLからお試しいただけます。
ローカルストレージにメモを保存しているので、メモは外部サービス上に公開されません。画像も表示できますが、外部URLを指定して貼り付けする必要があります[2]。お使いのブラウザ、OSの環境に応じて ライト/ダークモード切り替えや日本語/英語の切り替えを行います。
メモをプレビューしたとき、見出し横にコピーボタンがあります。ボタンを押すと、その見出し以下のメモがすべてコピーされます。例えば、メモの内容が
## 大見出し [copy1]
### 中見出し1 [copy2]
1234
#### 小見出し [copy3]
5678
### 中見出し2 [copy4]
9012
の場合、
- [copy1] →
## 大見出し...### 中見出し2\n9012\n
まですべてをコピー - [copy2] →
### 中見出し1\n1234\n#### 小見出し\n5678\n\n
をコピー - [copy3] →
#### 小見出し\n5678\n\n
をコピー - [copy4] →
### 中見出し2\n9012\n
をコピー
となります。
機能要件
以下の要件としました。
- メモをMarkdown(GFM準拠)で記載できる
- アプリ内でメモを保存・読み込みできる
- 誤削除を防ぐため、メモは削除リクエストから30日後に完全削除する
- OS環境に合わせ、ライト/ダークモードを切り替える
- ブラウザ環境に合わせ、日本語/英語を切り替える
Markdown対応については、flutter_markdownライブラリを利用しています。
また、メモをローカルに保存するにあたってhiveライブラリを利用しています。
システム設計
以下のシステム構成を想定して実装しました。
本アプリでは、下表の通りview層 - store層 - usecase層 - repository層 - infra層と責務を分けています。これにより、開発上以下のメリットがあります。
- データフローを一方向に保ち、処理フローを簡潔に保つ
- UI・アプリケーション機能・低レイヤー部の実装を独立に行えるようにする
- 各層は隣り合った層とのみinterfaceを介してやり取りし、テストしやすくする
役割 | レイヤー名 | 使用ライブラリ・環境 |
---|---|---|
画面表示・ユーザによる操作イベント発信 | view層 | Flutter |
状態管理・ユーザによる操作イベント処理 | store層 | Riverpod |
ドメインレベルのデータモデル定義 | entity | Dart |
アプリケーション機能の提供 | usecase層 | Dart |
データモデルのCRUD処理 | repository層 | Dart |
外部リソース操作※ | infra層 | 外部リソースの操作に必要なライブラリ・API全般(本アプリではhiveのみ) |
※ データベース、外部APIやハードウェア(カメラ、GPS、etc...)など、アプリがアクセスし得る具象的なリソースを指しています
実装についての概要
1. entity層: ドメインレベルのデータモデル定義
メモのデータモデルを以下の通り定義しました。データをimmutableに扱うため、freezedパッケージを利用しています。本モデルでは、titleにメモ名、contentにメモの内容を入れていて、そのほか作成日・最終更新日・最後に開いた日・削除予定日を格納しています。
import 'package:freezed_annotation/freezed_annotation.dart';
part 'memo.freezed.dart';
class Memo with _$Memo{
const factory Memo(
{
required String id,
required DateTime createdAt,
required DateTime lastModifiedAt,
required DateTime lastOpenedAt,
required DateTime? willDeleteAt,
required String title,
required String content,
}) = _Memo;
}
2. infra層: メモのCRUD処理
本アプリでは、hiveパッケージを使ってメモの永続化を行っています。hiveでカスタムクラスを格納するにあたり、シリアライズ/デシリアライズするためのアダプタが必要です。このため、
- データ種別:
HiveType(typeId: someTypeId)
# someTypeId は int - データID:
HiveField(someFieldId)
# someFieldId は int
の2つを定義したデータクラスを定義して、アダプタを自動生成しています。
import 'package:hive/hive.dart';
import 'package:private_markdown_writer/infra/persistence/entities/persistent_template.dart';
part 'persistent_memo.g.dart';
(typeId: PersistenceTypeId.memoTypeId)
class PersistentMemo implements DataWithId {
(0)
String id;
...
PersistentMemo({
required this.id,
...
});
String getId() => id;
}
PersistentMemoですが、以下のDataWithIdクラスを継承しています。
abstract class DataWithId {
String getId();
}
DataWithIdクラスを継承した理由ですが、infra層内部の処理を汎用化するためです。これにより、MemoとPersistentMemo間を互いに変換する必要があるものの、以下のように書くことができます。
インタフェース定義
※ Flutterではclassに対し暗黙的にinterfaceが定義されますが、後から別のクラスを同じinterfaceに依存させたいです。そこで、明示的に抽象クラスでinterfaceを作成しています。
abstract class PersistenceDatabase {
Future<void> store<T>(T entity);
Future<void> storeAll<T>(Iterable<T> entities);
Future<T?> load<T>(String id);
Future<Iterable<T>> loadAll<T>();
Future<void> delete<T>(T entity);
}
実装(一部のみ)
class PersistentHiveDatabase implements PersistenceDatabase {
Box? box;
Future<Box> _getBox() async {
if (box == null) {
try {
/* 単体テスト時、hive_testパッケージを使うためinitFlutterしない */
if (!Platform.environment.containsKey('FLUTTER_TEST')) {
await Hive.initFlutter();
}
} catch(e) {
await Hive.initFlutter();
}
Hive.registerAdapter(PersistentMemoAdapter());
box = await Hive.openBox("database");
}
return Future<Box>.value(box);
}
dynamic _fromPersistent(DataWithId dataWithId) {
if (dataWithId is PersistentMemo) {
return Memo(...)
} else {
throw Exception("unsupported type ${dataWithId.runtimeType}!");
}
}
DataWithId _toPersistent<T>(T dataWithId) {
if (dataWithId is Memo) {
return PersistentMemo(...);
} else {
throw Exception("unsupported type ${dataWithId.runtimeType}!");
}
}
Future<void> store<T>(T entity) async {
final persistent = _toPersistent<T>(entity);
final box = await _getBox();
final id = persistent.getId();
box.put(id, persistent);
}
Future<void> storeAll<T>(Iterable<T> entities) async {
for (final entity in entities) {
store<T>(entity);
}
}
Future<T?> load<T>(String id) async {
final box = await _getBox();
final persistent = box.get(id);
if (persistent == null) {
return null;
}
return _fromPersistent(persistent);
}
Future<Iterable<T>> loadAll<T>() async {
final box = await _getBox();
return box.values.map((b) => _fromPersistent(b)).whereType<T>();
}
}
3. repository層: データモデルの操作
repository層では、メモのデータモデルの操作を行っています。本アプリでは、
- infra層から得たメモ一覧をフィルタする
- メモを新規作成する
- メモの最終更新日をアップデートする
などの操作を提供しています。
インタフェース定義
import '../../entities/memo/memo.dart';
abstract class MemoRepository {
Future<Memo?> loadMemo(String memoId);
Future<List<Memo>> loadDeletedMemos();
Future<Memo> createMemo();
Future<Memo> updateOpenedAt(Memo memo);
Future<Memo> updateModifiedAt(Memo memo);
Future<Memo> makeDeleteMemo(Memo memo);
Future<Memo> restoreMemo(Memo memo);
Future<void> deleteMemo(Memo memo);
Future<void> storeMemo(Memo memo);
Future<void> deleteExpiredMemos();
}
実装(一部のみ)
Future<List<Memo>> loadUndeletedMemos() async {
final memos = await database.loadAll<Memo>();
final undeletedMemos = memos.where((m) => m.willDeleteAt == null).toList();
undeletedMemos.sort(
(a, b) => a.lastModifiedAt.millisecondsSinceEpoch > b.lastModifiedAt.millisecondsSinceEpoch ? -1 : 1
);
return undeletedMemos;
}
...
Future<Memo> createMemo() {
final locale = Platform.localeName;
final title = (() {
if (locale.contains("en")) {
return "Sample memo";
}
return "サンプルメモ";
})();
final content = (() {
if (locale.contains("en")) {
return '''(英語でのメモ内容テンプレ)''';
}
return '''(日本語でのメモ内容テンプレ)''';
})();
final now = DateTime.now();
return Future<Memo>.value(Memo(
id: idGenerator.generate(), // 自作モジュール。ランダムなUUIDを生成する
title: title,
content: content,
createdAt: now,
lastModifiedAt: now,
lastOpenedAt: now,
willDeleteAt: null,
));
}
...
return Future<Memo>.value(memo.copyWith(
lastModifiedAt: DateTime.now(),
));
4. usecase層: アプリケーション機能の提供
repository層を操作しつつ、一塊のアプリケーション機能を提供します。例えば、
- メモを更新する
- メモ一覧を読み出す
- メモを保存する
といった、抽象度の高いアプリケーション機能を提供します。
本アプリではrepositoryの呼び出しのみになっています。より大規模なアプリであれば、複数のrepositoryから複数のデータモデルを参照し、それらデータを基にアプリケーション機能を構築することになるかと思います。
usecase層の一部実装
class MemoUseCase {
final MemoRepository memoRepository;
MemoUseCase({required this.memoRepository});
Future<Memo?> loadMemo(String memoId) {
return memoRepository.loadMemo(memoId);
}
...
Future<Memo> updateModifiedAt(Memo memo) async {
/*
注: updateModifiedAtはMemoデータクラス側で記載した方が望ましい。
理由は"作って見ての課題点"を参照のこと
*/
final newMemo = await memoRepository.updateModifiedAt(memo);
storeMemo(newMemo);
return newMemo;
}
...
Future<void> storeMemo(Memo memo) {
return memoRepository.storeMemo(memo);
}
}
5. store層: 状態管理・ユーザによる操作イベント処理
RiverpodのStateNotifierを介して、
- a. stateの管理
- b. ユーザ操作イベントの処理(本稿ではscenarioと呼称)
- c. state更新時にview層へ通知
の3つを提供します。cについてはRiverpod側で提供されるので、aとbについて記載します。
5.a. stateの管理
freezedパッケージを使用して、以下のimmutableなstateを定義しています。本アプリでは、メモ一覧・削除済みメモ一覧、読み込み状態、編集中のメモを使うため、これらをstate中で定義しています。
MemoState定義
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/memo/memo.dart';
part 'memo_state.freezed.dart';
enum LoadingState{
initial,
loading,
failed,
ready,
}
class MemoState with _$MemoState {
const factory MemoState(
{
required LoadingState loadingState,
required Memo? memo,
required List<Memo> memoList,
required List<Memo> deletedMemoList,
}) = _MemoState;
factory MemoState.initial() {
return const MemoState(
loadingState: LoadingState.initial,
memo: null,
memoList: [],
deletedMemoList: []
);
}
}
5.b. ユーザ操作イベントの処理
MemoStateを使って、StateProviderを以下の通り定義しています。StateProviderの中で、Widgetからの操作イベントを受け付ける関数群(本稿ではscenarioと暫定的に呼称)を実装しています。scenarioは渡されたパラメータを基に、適宜stateの更新とusecase層のアプリケーション機能の呼び出しを行います。
この構成により、画面表示をほぼリアルタイムに更新しつつ、内部データを更新できます。例えば、アプリ起動時にメモ一覧をロードしているのですが、以下の処理フローで実現しています。
処理手順 | 画面更新の発生 |
---|---|
i. stateについて、"ロード中"にする | yes |
ii. 削除期限を迎えたメモを完全削除する(usecase層) | no |
iii. メモ一覧を読み出す(usecase層) | no |
iv. 得られたメモ一覧をstateに保存する | yes |
v. 編集するメモの取得 | - |
v.i. メモ一覧が空なら、新たにメモを作成する(usecase層)。作成したメモをstateに保存する | yes |
v.ii. メモ一覧が空なら、メモ一覧のうち最新のメモをstateに保存する | yes |
vi. 削除済みメモ一覧を読み出す(usecase層) | no |
vii. 削除済みメモ一覧をstateに保存する | yes |
viii. stateについて、"ロード完了"にする | yes |
*. (i~viiiで例外が発生すると実行される) stateについて、"ロード失敗"にする | yes |
/*
view層からは、ref.watch(memoStateProvider).memo などによりメモを取得する。
*/
final memoStateProvider =
StateNotifierProvider<EditorNotifier, MemoState>(
(ref) => EditorNotifier(memoUseCase),
);
**StateNotifier例**
...
class EditorNotifier extends StateNotifier<MemoState> {
final MemoUseCase memoUseCase;
EditorNotifier(this.memoUseCase) : super(MemoState.initial()) {
// 起動直後、メモ一覧・削除済みメモ一覧をロードする
loadMemoListAndDeletedMemoList();
}
/*
以下、scenario群。
view層でref.read(memoStateProvider.notifier).storeMemo(...)とすることで呼びだす。
途中でstateの更新処理を複数呼ぶことも可。
*/
void loadMemoListAndDeletedMemoList() async {
_setLoadingState(LoadingState.loading);
try {
await memoUseCase.deleteExpiredMemos();
final memoList = await memoUseCase.loadUndeletedMemos();
_setMemoList(memoList);
if (memoList.isEmpty) {
createMemo(memoList);
} else {
_setMemo(memoList.first);
}
final deletedMemoList = await memoUseCase.loadDeletedMemos();
_setDeletedMemoList(deletedMemoList);
_setLoadingState(LoadingState.ready);
} catch (e) {
_setLoadingState(LoadingState.failed);
}
}
...
/* stateの更新処理 */
void _setLoadingState(LoadingState loadingState) => state = state.copyWith(
loadingState: loadingState,
);
void _setMemo(Memo memo) => state = state.copyWith(
memo: memo,
);
void _setDeletedMemoList(List<Memo> deletedMemoList) => state = state.copyWith(
deletedMemoList: deletedMemoList,
);
void _setMemoList(List<Memo> memoList) => state = state.copyWith(
memoList: memoList,
);
6. view層: 状態更新時のデータ取得、画面表示
テストのしやすさから、Widgetsを以下2つに分けています。これにより、component側では画面表示に集中することができます。
- container: store層のstateNotifierからデータ・処理関数群(scenario)をref.watch/ref.readで受信し、componentに渡す。ConsumerWidgetで定義する
- component: containerから受け取ったデータを基にUIを構築する。StatelessWidgetで定義する。このため、Riverpodはcomponentからは使用せず、状態に依存せずテストができる
container例
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../store/memo/memo_provider.dart';
import '../../../component/memo/parts/editor.dart';
class EditorWidgetContainer extends ConsumerWidget {
const EditorWidgetContainer({Key? key}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
final memo = ref.watch(memoProvider);
final loadingState = ref.watch(memoStateProvider).loadingState;
void _onTitleChanged (String text) {
final memo = ref.read(memoStateProvider).memo;
if (memo == null) {
return;
}
ref.read(memoStateProvider.notifier).updateTitle(memo, text);
}
void _onContentChanged (String text) {
final memo = ref.read(memoStateProvider).memo;
if (memo == null) {
return;
}
ref.read(memoStateProvider.notifier).updateContent(memo, text);
}
return EditorWidget(
title: memo?.title,
content: memo?.content,
loadingState: loadingState,
onTitleChanged: _onTitleChanged,
onContentChanged: _onContentChanged,
);
}
}
工夫した点
メモの見出し単位でのコピーを実現するにあたり、flutter_markdownパッケージで今どの見出しかを処理しているかを知る方法がありませんでした。そこで、以下の方法で実現しました[3]。詳細を知りたい方は、実装したコード中のMarkdownBodyHeaderCopiableContainer内部をご覧ください。
- Markdownプレビュー用のWidgetをラッパーするStalelessWidgetをMarkdownBodyHeaderCopiableContainerとして用意
- 本container内で見出しの番号をカウントアップ&returnする関数を用意
- MarkdownElementBuilderを継承したCustomHeaderBuilderクラスを定義する
3.1. visitElementBeforeをオーバライドし、2.で定義した関数を呼ぶようにする。このとき、関数から得た番号を保持する
3.2. コピーボタンタップ時、メモ内容から見出し番号を基に見出しの位置を検出する。ここから、より上位の見出しが見つかるか、メモの終端までをコピーすべきテキストとして返す - MarkdownBody用に、見出し1~6までのビルダークラスを定義する。クラスはCustomHeaderBuilderを継承し、見出しレベルに応じて文字サイズを調整する
- 各ビルダークラスについて、MarkdownBodyのbuildersオプションで渡す
作って見ての課題点
- HiveでPersistentなデータモデルとドメインレベルのデータモデルを相互変換しています。これについては、別途Adapterクラスを渡しておき、そこで処理する方がメンテナンスしやすいかもしれません。
- アプリ起動時のメモ一覧ロード処理ですが、メモ・メモ一覧・削除済みメモ一覧のstateへの保存はまとめて実施した方が望ましいです。stateを更新する度にref.watchしていると再レンダリングが走るためです。
- usecase層, repository層にあるupdateModifiedAtあたりは、データモデルに対するパラメータ変更です。各層の責務を考えると、entity側に実装する方が望ましいです。こうすることで、データモデルの操作をentity上にのみ書かれ、コード上で重複しなくなります。
実装したコード
-
やや独自性が入っているので大まかになりますが、Domain Model <-> entity, Domain Service <-> repositoryのinterface, Application Service <-> usecase, Infrastructure/Tests/User Interface <-> その他と対応付けています ↩︎
-
base64に変換にして貼り付けることもできなくはないのですが、Web版だとブラウザの制限で5MBより大きいデータを保存できない可能性があるため推奨されません ↩︎
-
ややhackが入っているので、今後ライブラリのアップデートにより使えなくなる可能性があります ↩︎
Discussion