15
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

TDCソフト株式会社Advent Calendar 2022

Day 11

私の考えたFlutterのフォルダ構成

Last updated at Posted at 2022-12-10

前提

Mac: Ventura 13.0.1
Flutter: 3.0.5
hooks_riverpod: 2.1.1

考え方

flutterのフォルダ構成ですが、次の図のように考えています。

image.png

この構成は、大きく以下の2つの層に分けて考えています。

presentation ・・・ プレゼンテーション層(見た目を扱う)
domain ・・・ ドメイン層(データを永続的に扱う)

理由としては、私がDDDを学習していた時に作ったというのが実際の所ですが、
層を分けて責務をちゃんと管理した方が不具合が少ないかなと考えこの形にしました。

プレゼンテーション層

プレゼンテーション層は以下の構成で管理しています。

  • feature ・・・ ユーザ管理機能や、通知管理機能など、リリースする機能を格納します。
  • page ・・・ 画面を格納します。
  • widget ・・・ 入力フォームや、ヘッダー、フッターなどを格納します。
  • parts ・・・ 入力フォームの中、氏名入力、誕生日入力などを格納します。

まず、featureですが、トランクベース開発が流行っているなと感じて、
簡単に機能のリリースや削除したいと考え、featureフォルダで管理できるようにしました。

次に、page、widget、partsですが、アトミックデザイン(AtomicDesign)を参考にしてますが、少しだけ次のようにアレンジしています。

  • page ・・・ Page、Templateを格納します。
  • widget ・・・ Organismsを格納します。
  • parts ・・・ Molecules、Atomsを格納します。

理由は、開発していてtemplateは殆ど使わないですし、
同じようにAtomsも外部のライブラリが提供していることが多いので省略しました。

プレゼンテーション層には、1つ開発時のルールを設けていて、
ドメイン層へのアクセスはPageとwidgetからしかできないようにしています。
確かに色々な所からアクセスできた方が便利ですが、修正が入ったりすると影響範囲も大きくなるので制限することとしました。

ドメイン層

ドメイン層は以下の構成で管理しています。

  • UseCase ・・・ UMLのユースケース図にあたる部分を格納します。 ビジネスロジックの認識でも良いかも。
  • Repository ・・・ 永続化層にアクセスする部分。例えばAPIを叩く部分を格納します。
  • Entity ・・・ 取得してきた値を保持する部分を格納します。

DDDでは、エンティティとバリューオブジェクトで違いがありますが、
ここでのEntityは取得した値を保持するという意味で使っています。

最初の図では、Entityから、EntityDetail、EntityStateと線を引いてありますが、
EntityDetailには、APIから取得してきたデータを格納し、EntityStateは、APIの取得状態を格納します。
Riverpodを使う時、取得状態もまとめて管理した方が楽だったのでそうしています。

構成

実際に作成したフォルダ構成です。

image.png

補足しますと、lib/commonには、ユーティリティのようなプログラムを配置します。
また、000_common には、共通的なwidget(ヘッダー、フッター)を配置するようにしています。
あとは、routeing.dartですが・・・ go_routerというライブラリがあるのを知らずに自作してしまったものですが、動くので一旦このままにしています。

プログラム

実際のプログラムの例です。
画面でボタンを押すと、ドメイン層へアクセスし、リストを取ってきて、コンボボックへ反映するものを作りました。

プレゼンテーション層

main.dart

ProviderScopeを定義して、Riverpodを使えるようにしています。
Routingを使って、画面遷移を定義しています。

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      initialRoute: Routing.root,
      routes: <String, WidgetBuilder>{
        ...Routing.getInstance().getRoutes(),
      },
    );
  }
}

myhomepage.dart

Pageの中身です。
ここで、Widget(Sample)を呼び出してます。
ApplicationBarは、共通のwidgetとして定義して、他のページでも利用できるようにしています。

class MyHomePage extends StatelessWidget {
  final String title;

  const MyHomePage({Key? key, required this.title}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      appBar: ApplicationBar(),
      body: Sample(),
    );
  }
}

sample.dart

widgetの中身です。
ここで、SamplePartsを呼び出しています。
コンボボックスはFormBuilderを利用しているので、ここで定義しています。

class Sample extends ConsumerWidget {
  const Sample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final formKey = GlobalKey<FormBuilderState>();
    final samplePartsState = GlobalObjectKey<SamplePartsState>(context);

    ref.listen<SampleState>( // ②
      sampleUsecaseProvider,
      (prev, next) {
        if (next.status == SampleRequestStatus.error) {
          // error
          return;
        }

        if (next.status == SampleRequestStatus.end) {
          samplePartsState.currentState?.attachItems(next.sampleDetails ?? []);
        }
      },
    );

    return Center(
      child: SizedBox(
        width: MediaQuery.of(context).size.width * 0.5,
        child: Column(
          children: <Widget>[
            const SizedBox(height: 24),
            FormBuilder(key: formKey, child: SampleParts(key: samplePartsState)),
            const SizedBox(height: 24),
            ElevatedButton(onPressed: () => ref.read(sampleUsecaseProvider.notifier).getSample(), child: const Text('sample')) // ①
          ],
        ),
      ),
    );
  }
}

ドメイン層へのアクセス。

①の部分で、ドメイン層を呼び出しています。

ElevatedButton(onPressed: () => ref.read(sampleUsecaseProvider.notifier).getSample(), child: const Text('sample')) // ①

②の部分では、UseCaseで保持しているStateに変化があった時に呼び出され、ステータスが正常終了(next.status == SampleRequestStatus.end)だったらコンボボックスへリストを反映しています。

    ref.listen<SampleState>( // ②
      sampleUsecaseProvider,
      (prev, next) {
        if (next.status == SampleRequestStatus.error) {
          // error
          return;
        }

        if (next.status == SampleRequestStatus.end) {
          samplePartsState.currentState?.attachItems(next.sampleDetails ?? []);
        }
      },
    );

sample_parts.dart

partsの実装です。

class SampleParts extends StatefulWidget {
  const SampleParts({Key? key}) : super(key: key);

  @override
  SamplePartsState createState() => SamplePartsState();
}

class SamplePartsState extends State<SampleParts> {
  List<DropdownMenuItem<String>> _itemList = [];

  // コンボボックスのリストを更新する。
  void attachItems(List<SampleDetail> list) { //①
    setState(() {
      _itemList = list.map<DropdownMenuItem<String>>(
        (SampleDetail value) {
          return DropdownMenuItem<String>(
            value: value.sampleString,
            child: Container(
              margin: const EdgeInsets.only(left: 8),
              child: Text(value.sampleString),
            ),
          );
        },
      ).toList();
    });
  }

  @override
  Widget build(BuildContext context) {
    return FormBuilderDropdown<String>(
      name: 'form-drop',
      items: _itemList,
      focusColor: Colors.transparent,
      decoration: InputDecoration(
        hintText: '選択してください',
        hintStyle: Theme.of(context).textTheme.bodyText1,
      ),
    );
  }
}

Domain層の呼び出しが成功した場合は、①が呼び出されます。
あと、ちょっと手抜きをして、setStateを使っています。

ドメイン層

sample.dart

UseCaseの実装です。

class SampleUsecase extends StateNotifier<SampleState> {
  SampleUsecase({
    required this.repository,
    required SampleState state,
  }) : super(state);
  final SampleRepository repository;

  void getSample() {
    state = state.copyWith(status: SampleRequestStatus.start);
    state = state.copyWith(sampleDetails: repository.listSample()); // ①
    state = state.copyWith(status: SampleRequestStatus.end);
  }
}

final sampleRepository = Provider((ref) => SampleRepository()); // ②
final sampleUsecaseProvider = StateNotifierProvider.autoDispose<SampleUsecase, SampleState>(
  (ref) => SampleUsecase(
    repository: ref.read(sampleRepository),
    state: const SampleState(),
  ),
);

①でリポジトリを呼び出しています。 本来だとAPIへのアクセスがあるので、awaitを使ったりすると思います。
②は、単体テストをしやすいように上書きできるようにこうしています。

sample_repo.dart

リポジトリの実装です。
リストを返却します。

class SampleRepository {
  List<SampleDetail> listSample() {
    return [
      SampleDetail(sampleString: 'sample1'),
      SampleDetail(sampleString: 'sample2'),
      SampleDetail(sampleString: 'sample3'),
      SampleDetail(sampleString: 'sample4'),
      SampleDetail(sampleString: 'sample5'),
      SampleDetail(sampleString: 'sample6'),
      SampleDetail(sampleString: 'sample7'),
    ];
  }
}

sample_state.dart

EntityとEntityStateの実装です。
status(SampleRequestStatus)がAPIのアクセス状態を示す、Enumとして定義しています。
sampleDetails(SampleDetail)が、APIから取得したデータを格納するEntitiyとして定義しています。

part 'sample_state.freezed.dart';

@freezed
class SampleState with _$SampleState {
  const factory SampleState({
    @Default(SampleRequestStatus.none) SampleRequestStatus status,
    @Default(null) List<SampleDetail>? sampleDetails,
  }) = _SampleState;

  const SampleState._();
}

enum SampleRequestStatus {
  none,
  start,
  end,
  error,
}

sample_detail.dart

EntityDetailの実装です。
(fromJsonを書いていますが、今回はサンプルプログラムでは使用していません。)

part 'sample_detail.freezed.dart';
part 'sample_detail.g.dart';

@freezed
class SampleDetail with _$SampleDetail {
  factory SampleDetail({@Default('') @JsonKey(name: 'sample_string_key') String sampleString}) = _SampleDetail;

  factory SampleDetail.fromJson(Map<String, dynamic> json) => _$SampleDetailFromJson(json);
}

動作

(Flutter webで動かしています。)

image.png

sampleボタンを押すと、リストが表示されます。

image.png

おまけ

pubspec.yaml

name: helloworld
description: A new Flutter project.
publish_to: 'none'

version: 1.0.0+1

environment:
  sdk: ">=2.17.6 <3.0.0"
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  flutter_form_builder: ^7.6.0
  hooks_riverpod: ^2.1.1
  freezed_annotation: ^2.2.0
  freezed: ^2.3.0
  json_serializable: ^6.3.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  build_runner: ^2.1.7
flutter:
  uses-material-design: true

sample_test.dart

単体テストファイルです。

void main() {
  group('sampleのテスト', () {
    test('正常系', () async {
      final container = override();
      final notifier = container.read(sampleUsecaseProvider.notifier);
      notifier.getSample();
      expect(container.read(sampleUsecaseProvider).sampleDetails![0].sampleString, 'sample100');
    });
  });
}

ProviderContainer override() {
  return ProviderContainer(
    overrides: [
      sampleRepository.overrideWithValue(SampleRepositoryMock()),
    ],
  );
}

class SampleRepositoryMock implements SampleRepository {
  // ignore: annotate_overrides
  List<SampleDetail> listSample() {
    return [
      SampleDetail(sampleString: 'sample100'),
    ];
  }
}

本来だとoverrideのアノテーションをつける必要があるが、以下エラーが対応できなかったのでignoreで対応。:dizzy_face:

Annotation must be either a const variable reference or const constructor invocation.

最後に

一旦、手元では動いていますが、まだまだFlutter初心者なので、お作法がなっていない所もあると思います。
また、サンプルプログラムですので、手を抜いている所もあります、なのでコピペして使う場合(いるのかな?)は、自己責任でお願いします。

15
7
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?