フラッターアプリケーションにおける懸念の分離
Engineering at ClickUp

フラッターアプリケーションにおける懸念の分離

最近、私はオンボーディングのウォークスルーを実施しなければならなかった。 /参照 https://clickup.com/ ClickUp /を実装する必要があった。 新規ユーザー!これは本当に重要なタスクだった。 /を発見しようとしていたからだ。 https://www.youtube.com/watch?v=AirnL1NxOAw スーパーボウルで初公開した信じられないほど面白い広告 /%href/ !✨ クリックUp経由 このチュートリアルでは、ClickUpをまだ知らない多くの新規ユーザーが、アプリケーションのいくつかの機能の使い方をすぐに理解できるようになっています。これは、新しいアプリケーションのように、継続的な努力です。 /参照 https://university.clickup.com/。 クリックUp大学 /クリックアップ大学 私たちが追い求めているリソース🚀 幸いなことに、ClickUp Flutterモバイルアプリケーションの背後にあるソフトウェアアーキテクチャのおかげで、アプリケーションの実際のウィジェットを再利用しながらも、この機能を非常に素早く実装することができた!これは、ウォークスルーがダイナミックで、レスポンシブで、アプリの実際のアプリケーション画面と完全に一致することを意味します。 また、懸念事項を適切に分離することで、機能を実装することができました。 どういうことか見てみよう。🤔

関心の分離

ソフトウェアアーキテクチャの設計は、エンジニアリングチームにとって最も複雑なトピックの1つである。全ての責任の中で、将来のソフトウェアの進化を予測することは常に難しい。だからこそ、うまく階層化され、分離されたアーキテクチャを作成することは、あなたやあなたのチームメイトにとって多くの助けとなるのです! 小さな非結合システムを作成する主な利点は、間違いなくテスト可能性です!そしてこれが、アプリの既存画面の代替デモを作成するのに役立った!

ステップバイステップのガイド

さて、これらの原則をFlutterアプリケーションにどのように適用できるだろうか? 私たちがClickUpを作るために使っているいくつかのテクニックを、簡単なウォークスルー例とともに共有しよう。 この例はとてもシンプルなので、その裏にある利点のすべてを悟ることはできないかもしれませんが、複雑なコードベースを持つFlutterアプリケーションをよりメンテナーなものにするのに役立つはずです。💡

アプリケーション

例として、アメリカの人口を年ごとに表示するアプリケーションを作ります。 ClickUp経由 ここには2つの画面があります: * HomeScreen`:2000年から現在までのすべての年をリストアップ。ユーザーが年のタイルを設定すると、選択された年に設定されたナビゲーション引数で`DetailScreen`に移動します。 * DetailScreen` : ナビゲーション引数から年を取得して /を呼び出す。 https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=2018 を呼び出す。 datausa.io APIを呼び出す。 /%href/ を呼び出し、JSONデータを解析して関連する人口の価値を抽出する。データが利用可能な場合は、人口とともにラベルが表示されます。 DetailScreen`の実装に焦点を当てよう。非同期呼び出しで最も利息があるからだ。

ステップ1.ナイーブアプローチ

/img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-5-1400x607.png ナイーブアプローチ ステートフルウィジェット /ウィジェット ClickUp経由 私たちのアプリのための最も明白な実装は、全体のロジックに単一の`StatefulWidget`を使用することである。 /参照 https://dartpad.dev/embed-flutter.html?id=b37071f4386ecb27c29332e788999612&split=80&theme=dark Dartソースコードとデモ /%href/

ナビゲーション引数 `year` へのアクセス

要求された年にアクセスするには、継承したウィジェット `ModalRoute` から `RouteSettings` を読み込む。 ``` void didChangeDependencies() { ``年``のルート設定を変更します。 super.didChangeDependencies(); final year = ModalRoute.of(context)!.settings.arguments as int; // ... } ```

HTTP コール

この例では、`http` パッケージの `get` 関数を呼び出して、データを /からデータを取得する。 https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=2018 からデータを取得する。 datausa.io API /%href/ を実行し、`dart:convert` ライブラリの `jsonDecode` メソッドで結果の JSON をパースし、`_future` という名前のプロパティで `Future` をステートの一部として保持する。 ``` late Future?> _future; void didChangeDependencies() { super.didChangeDependencies(); final year = ModalRoute.of(context)!.settings.arguments as int; if (_year != year) { { _future = _loadMedded() if (_year != year) { _future = _loadMeasure(year); } } Future?> _loadMeasure(int年) async{。 year = year; final uri = Uri.parse( 'https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=$year'); final result = await get(uri); final body = jsonDecode(result.body); final data = body['data'] as List; if (data.isNotEmpty){を返します。 return data.first; } return null; ```

レンダリング

ウィジェットツリーを作成するために、`FutureBuilder` を使用しています。これは、非同期呼び出しの `_future` の現在の状態に応じて自身を再構築します。 ``` オーバーライド ウィジェットをビルドする(BuildContext context) { オーバーライド return Scaffold( appBar:AppBar( タイトルテキスト('年 $_year')、 ), body:FutureBuilder? 未来:_future、 builder: (コンテキスト, スナップショット) { { switch (snapshot.connectionState) switch (snapshot.connectionState) { 以下のようにします。 case ConnectionState.done: final error = snapshot.error; if (error != null) { // "エラー "を返す。 // "エラー "ツリーを返します。 } final data = snapshot.data; if (data != null) { // "結果 "ツリーを返します。 // "結果 "ツリーを返す。 } // "空 "のデータを返す tree.case ConnectionState.none: case ConnectionState.waiting: case ConnectionState.active: // "loading" データ・ツリーを返す。 } }, ), ); } ```

レビュー

さて、この実装は短く、組み込みウィジェットしか使っていませんが、最初の目的である、この画面の代替デモ(またはテスト)を作ることを考えてみましょう。HTTP呼び出しの結果を制御して、アプリケーションを特定の状態でレンダリングさせるのは非常に難しい。 そこで、inversion of controlという概念が役に立ちます。🧐

ステップ2.コントロールの逆転

/img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-6-1400x607.png プロバイダーとAPIクライアントへの制御の逆転 /%img/ ClickUp経由 この原則は、新しい開発者には理解しにくいかもしれません(_また、説明するのも難しいです_)が、全体的な考え方は、コンポーネントの外に関心事を抽出し、コンポーネントが振る舞いを選択する責任を負わないようにし、代わりにそれをデリゲートすることです。 より一般的な状況では、単に抽象化を作成し、コンポーネントに実装を注入します。 しかし心配しないでください、次の例を見ればもっと理解できるようになります!👀 /href/ https://dartpad.dev/embed-flutter.html?id=d68322aa5a6e6bdd4d1fbbf8f86aebd3&split=80&theme=dark Dartソースコードとデモ /%href/

API クライアントオブジェクトの作成

API への HTTP 呼び出しを制御するために、実装を専用の `DataUsaApiClient` クラスに分離した。また、データの操作とメンテナンスを容易にするために `Measure` クラスも作成した。 ``` クラス DataUsaApiClient { const DataUsaApiClient({) this.endpoint = 'https://datausa.io/api/data'、 }); final String endpoint; Future<メジャー?> getMeasure(int year) async { {. final uri = Uri.parse('$endpoint?drilldowns=Nation&measures=Population&year=$year'); final result = await get(uri); final body = jsonDecode(result.body); final data = body['data'] as List; if (data.isNotEmpty) { 以下のようにします。 return Measure.fromJson(data.first as Map); } return null; } ```

APIクライアントの提供

この例では、よく知られている /を使用している。 https://file+.vscode-resource.vscode-webview.net/Users/alois/Desktop/article/clickup-separation_of_concern.md プロバイダー /%href/ パッケージを使って、ツリーのルートに `DataUsaApiClient` インスタンスを注入する。 ``` プロバイダー( create: (context) => const DataUsaApiClient()、 child: const MaterialApp() ホーム:HomePage()、 ), ) ```

APIクライアントの使用

プロバイダーは、任意の子孫ウィジェット _(_ _ 私たちの __ `_DetailScreen_`_ のような _)_ が、ツリーの一番近い `DataUsaApiClient` の上位を読み取ることを可能にします。そして、実際の HTTP 実装の代わりに、その `getMeasure` メソッドを使用して `Future` を開始することができます。 ``` overridevoid didChangeDependencies() { を実行します。 super.didChangeDependencies(); final year = ModalRoute.of(context)!.settings.arguments as int; if (_year != year) { _year = year; if (_year != year) { _year ! if (_year != year.) { _year = year; final api = context.read(); future = api.getMeasure(year); } } ```

デモ API クライアント

さて、これを利用しよう! ご存じない方のために補足しておきます。 /href/ https://dart.dev/samples#interfaces-and-abstract-classes も暗黙的に関連するインターフェイスを定義します。 /href/ .これにより、`getMeasure` メソッド呼び出しから常に同じインスタンスを返す `DataUsaApiClient` の代替実装を提供することができる。 > このメソッドは ``` クラス DemoDataUsaApiClient は DataUsaApiClient を実装する。 const DemoDataUsaApiClient(this.measure); Final Measure measure; OverrideString get endpoint => ''; オーバーライド Future getMeasure(int year) { 将来の値を返す。 return Future.value(measure); } ```

デモページの表示

これで `DetailPage` のデモインスタンスを表示するための鍵がすべて揃った! 代わりに `DemoDataUsaApiClient` インスタンスを生成するプロバイダーで `DetailScreen` をラップして、現在提供されている `DataUsaApiClient` インスタンスをオーバーライドするだけです! これで、`DetailScreen` がこのデモインスタンスを読み込んで、HTTP 呼び出しの代わりに `demoMeasure` データを使用するようになります。 ``` ListTile( タイトル: const Text('Open demo')、 onTap: () { { デモをタップします。 const demoMeasure = Measure( 年2022, 人口:425484, nation: 'United States'、 ); Navigator.push( コンテキスト MaterialPageRoute( 設定:RouteSettings(arguments: demoMeasure.year)、 ビルダー: (コンテキスト) { return Provider( 作成: (コンテキスト) => const DemoDataUsaApiClient(demoMeasure)、 child: const DetailScreen()、 ); }, ), ); }, ) ```

レビュー

これは、_Inversion of control の素晴らしい例です。私たちの `DetailScreen` ウィジェットは、データを取得するロジックを担当しなくなり、代わりに専用のクライアントオブジェクトに委譲します。そして、画面のデモインスタンスを作成したり、画面のウィジェットテストを実装したりできるようになりました!素晴らしい!👏 しかし、さらにやることがあります! 例えば、ローディング状態をシミュレートすることができないので、ウィジェットレベルでの状態変化を完全にコントロールすることはできません。

ステップ 3.状態管理

/img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-7-1400x607.png フラッターアプリケーションにおけるステートメント管理 /%img/ ClickUp経由 これはFlutterでホットな話題だ! Flutterに最適な状態管理ソリューションを選ぼうとする人たちの長いスレッドを読んだことがあると思う。はっきり言って、この記事でやることはそれではない。私たちの意見では、ビジネスロジックとビジュアルロジックを分離していれば問題ありません!このようなレイヤーを作ることは、メンテナーにとって本当に重要です。この例は単純ですが、実際のアプリケーションではロジックはすぐに複雑になり、このように分離することで、純粋なロジックのアルゴリズムを見つけるのがとても簡単になります。このテーマはしばしば「状態管理」として要約されます。 この例では、基本的な `ValueNotifier` を `Provider` と一緒に使っています。しかし /を使うこともできた。 https://pub.dev/packages/flutter_bloc flutter_bloc /%href/ または /または https://pub.dev/packages/riverpod リバーポッド /リバーポッド (または他の解決策)_、それも素晴らしい仕事だっただろう。原則は同じで、ステートとロジックを分離している限り、他のソリューションからコードベースを移植することも可能です。 この分離は、ウィジェットのあらゆるステートを制御するのにも役立つので、ありとあらゆる方法でモックすることができます! /参照 https://dartpad.dev/embed-flutter.html?id=e148980534dae261ef1174a15f7ac5cf&split=80&theme=dark Dartソースコードとデモ /%href/

専用ステートの作成

フレームワークの `AsyncSnapshot` に依存する代わりに、画面の状態を `DetailState` オブジェクトとして表現します。 オブジェクトを値で比較できるようにするために、 `hashCode` メソッドと `operator ==` メソッドを実装することも重要である。これにより、2つのインスタンスが異なると見なされるべきかどうかを検出することができる。 > > 💡 その > > href/ > https://pub.dev/packages/equatable > 等価 > /%href/ > > または > > または > https://pub.dev/packages/freezed > フリーズ > フリーズした > > パッケージは `hashCode` と `operator ==` メソッドを実装するのに役立つ素晴らしいオプションだ! ``` 抽象クラス DetailState { const DetailState(this.year); final int year; オーバーライドブール operator ==(Object other) => 同一(this, other) identical(this, other) || (other is DetailState &&) (他がDetailState &&である runtimeType == other.runtimeType && 年 year == other.year); overrideint get hashCode => runtimeType.hashCode ^ year; ``` 状態は常に`year`に関連付けられているが、ユーザーに見せたいものに関しても4つの異なる状態が考えられる: * NotLoadedDetailState`:データの更新がまだ開始されていない。 * LoadingDetailState`:データは現在ロード中です。 * LoadedDetailState`: データは関連する `measure` と共にロードに成功した。 * NoDataDetailState`: データはロードされたが、利用可能なデータはない。 * UnknownErrorDetailState`: 操作が失敗した。 ``` class NotLoadedDetailState extends DetailState {: データはロードされたが、利用可能なデータがない。 const NotLoadedDetailState(int year) : super(year); } class LoadedDetailState extends DetailState { ロードされた詳細状態 const LoadedDetailState({) 必須 int year、 required this.measure、 }) : super(year); Final Measureメジャー; オーバーライドブール operator ==(Object other) => 同一(this, other) identical(this, other) || (other is LoadDetail State && measure == other.measure) (other is LoadedDetailState && measure == other.measure); この関数を使用すると、この関数を使用する必要があります; } class NoDataDetailState extends DetailState { 以下のようになります。 const NoDataDetailState(int year) : super(year); } class LoadingDetailState extends DetailState { ローディングディテールステート(int年) : super(年); }. const LoadingDetailState(int year) : super(year); } class UnknownErrorDetailState extends DetailState { 以下のようになります。 const UnknownErrorDetailState({) 必須 int year、 required this.error、 }) : super(year); 最終的な動的エラー; オーバーライドブール operator ==(オブジェクト other) => 同一(this, other) identical(this, other) || (other is Unknown Error Detail State && other is Unknown Error Detail State) (他はUnknownErrorDetailState &&です。 year == other.year && エラー エラー == other.error); オーバーライドget hashCode => オブジェクト.ハッシュ(super.hashCode, エラー.has) ``` これらの状態は、`AsyncSnapshot`よりも明確である。そしてまた、これはコードをより保守しやすくする! そしてまた、これによってコードの保守性が向上するのです! > 💡 私たちが強く推奨するのは、次のようなものです。 > > を強くお勧めします。 > https://pub.dev/packages/freezed#unionssealed-classes > フリーズしたパッケージのユニオン型 > /%href/ > > ロジックの状態を表現する!copyWith`メソッドや`マップ`メソッドのような多くのユーティリティが追加される。

ロジックをノーティファイアに置く

さて、ステートの表現ができたので、そのインスタンスをどこかに保存する必要があります - これが `DetailNotifier` の目的です。これは現在の `DetailState` インスタンスを `value` プロパティに保持し、状態を更新するメソッドを提供する。 初期状態として `NotLoadedDetailState` を提供し、`api` からデータをロードして現在の `value` を更新するために `refresh` メソッドを提供する。 ``` class DetailNotifier extends ValueNotifier { {{DetailNotifier({NotLoadedDetailState`) DetailNotifier({ required int year、 required this.api、 }) : super(DetailState.notLoaded(year)); final DataUsaApiClient api; int get year => value.year; Future refresh() async { {. if (価値は! LoadingDetailState) { { if (価値は! LoadingDetailState) 値 = DetailState.loading(年); try { final result = await api.getMeasure(year); if (result != null) { { result = getMeasure(year). 値 = DetailState.loaded( year: year、 measure: 結果、 ); } else { 値 = DetailState.noData(year); } } catch (エラー) { { value = DetailState.unknownError() 値 = DetailState.unknownError( year: year、 error: エラー、 ); } } } ```

ビューの状態を提供する

画面の状態をインスタンス化して観察するために、プロバイダーとその `ChangeNotifierProvider` にも依存する。この種のプロバイダーは、自動的に作成された `ChangeListener` を探し、変更が通知されるたびに `Consumer` から再構築をトリガーします _(ノーティファイアの値が前回と異なる場合)。 ``` class DetailScreen extends StatelessWidget { ステートレスウィジェット const DetailScreen({ キー? }) : super(key: key); オーバーライド ウィジェットbuild(BuildContext context) { ウィジェットbuild(BuildContext context) final year = ModalRoute.of(context)!.settings.arguments as int; return ChangeNotifierProvider( create:(コンテキスト){」を選択します。 final notifier = DetailNotifier( year: 年 api: context.read()、 ); notifier.refresh(); return notifier; }, child:Consumer( builder: (context, notifier, child) { }. final state = notifier.value; // ... }, ), ); } } ```

レビュー

素晴らしい!我々のアプリケーション・アーキテクチャはかなり良くなってきている。すべてが明確に定義されたレイヤーに分けられ、特定の懸念事項があります!🤗 しかし、テストしやすくするために、まだ1つ足りないものがあります。

ステップ4.ビジュアル専用レイアウトウィジェット

/img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-8-1400x607.png flutterアプリケーションのビジュアル専用レイアウトウィジェット /%img/ クリックUp経由 最後のステップでは、`DetailScreen`ウィジェットに少し責任を持たせすぎました。そして、前に見たように、ビュー層でのロジックの責任を避けるようにします! DetailScreen`ウィジェットを2つに分割します: * DetailScreen`ウィジェットを2つに分割します。`DetailScreen`は、現在のアプリケーションの状態(ナビゲーション、通知、状態、サービス、...)からスクリーンの様々な依存関係の設定を担当します、 * DetailLayout`は単純に`DetailState`をウィジェット専用のツリーに変換します。 この2つを組み合わせることで、単純に `DetailLayout` のデモ/テストインスタンスを作成し、アプリケーションで実際に使用する場合は `DetailScreen` を使用することができる。 /参照 https://dartpad.dev/embed-flutter.html?id=6e4075480df18bfe42b932f419a231ac&split=80&theme=dark Dartソースコードとデモ /%href/

専用レイアウト

より良い分離を実現するために、`Consumer` ウィジェットの下にあるすべてを専用の `DetailLayout` ウィジェットに移動しました。この新しいウィジェットはデータを消費するだけで、インスタンスの生成は担当しません。読み込んだ状態を特定のウィジェットツリーに変換するだけです。 ModalRoute.of` の呼び出しと `ChangeNotifierProvider` インスタンスは `DetailScreen` に残り、このウィジェットは単に `DetailLayout` を設定済みの依存関係ツリーと一緒に返します! このマイナーな改善はプロバイダーの使い方に特化したものですが、子孫ウィジェットが `DetailState` を直接消費できるように `ProxyProvider` も追加したことにお気づきでしょう。これにより、モック・データが簡単になります。 ``` class DetailScreen extends StatelessWidget { ディテールスクリーン({) const DetailScreen({ key? }) : super(key: key); オーバーライド ウィジェットbuild(BuildContext context) { ウィジェットbuild(BuildContext context) final year = ModalRoute.of(context)!.settings.arguments as int; return ChangeNotifierProvider( create:(コンテキスト){」を参照してください。 final notifier = DetailNotifier( year: 年 api: context.read()、 ); notifier.refresh(); return notifier; }, child: child:ProxyProvider( update: (context, value, previous) => value.value、 child: const DetailLayout()、 ), ); } } class DetailLayout extends StatelessWidget { ステートレスウィジェット constDetailLayout({キー? キー? }) : super(key: key); オーバーライド ウィジェットbuild(BuildContext context) { @override return Consumer( builder: (コンテキスト, ステート, チャイルド) { }. return Scaffold( appBar:AppBar( タイトルテキスト('年 ${state.year}')、 ), body: () { . // ... }(), ); }, ); } ```

ウィジェットを専用クラスとして取り出す

ウィジェットツリーを専用クラスに展開することをためらってはいけません!そうすれば /href/ /ブログ?p=56354 パフォーマンスが向上します。 /を向上させます。 を向上させ、コードをよりメンテナーにする。 この例では、関連するステート・タイプごとに1つのビジュアル・レイアウト・ウィジェットを作成しました: ``` if (state is NotLoadedDetailState || state is LoadingDetailState) { { (state is NotLoadedDetailState || state is LoadingDetailState) return const LoadingDetailLayout(); } if (state is LoadedDetailState) { { (状態がLoadedDetailStateの場合) return LoadedDetailLayout(state: state); } if (state is UnknownErrorDetailState) { { (state is UnknownErrorDetailState) return UnknownErrorDetailLayout(state: state); } return const NoDataDetailLayout(); ```

デモインスタンス

これで、何をモックして画面に表示するかを完全にコントロールできるようになった! あとは `DetailLayout` を `Provider` でラップして、レイアウトの状態をシミュレートするだけだ。 ``` ListTile( タイトル: const Text('Open "loaded" demo')、 onTap: () { Navigator.push( context、 MaterialPageRoute( builder: (コンテキスト) { { プロバイダ.value() return Provider.value() 値: const DetailState.loaded( 年2022, measure: Measure( 年2022, 人口425484, nation: 'United States'、 ), ), child: const DetailLayout()、 ); }, ), ); }, ), リストタイル( タイトル: const Text('Open "loading" demo')、 onTap: () { Navigator.push( コンテキスト MaterialPageRoute( builder: (コンテキスト) { { プロバイダ.value() return Provider.value( 値: const DetailState.loading(2022)、 child: const DetailLayout()、 ); }, ), ); }, ), ```

結論

メンテナンス可能なソフトウェアアーキテクチャを作るのは簡単ではありません!将来のシナリオを予測することは多くの努力を必要としますが、私が共有したいくつかのヒントが将来あなたの役に立つことを願っています! 例は単純に見えるかもしれません。過剰にエンジニアリングしているようにさえ見えるかもしれませんが、アプリの複雑さが増すにつれて、このような標準を持つことは大いに役立つでしょう!💪 Flutterを楽しんで、ブログをフォローしてこのような技術記事をもっとゲットしよう!お楽しみに