FlutterとAngularDartでコード共有するためにBLoCを活用する方法

公開: 2022-03-11

昨年半ば、AndroidアプリをiOSとWebに移植したかった。 フラッターはモバイルプラットフォームの選択であり、私はWeb側で何を選択するかを考えていました。

一目でFlutterに夢中になりましたが、まだいくつかの予約がありました。ウィジェットツリーに状態を伝播している間、FlutterのInheritedWidgetまたはReduxは、そのすべてのバリエーションで機能しますが、Flutterのような新しいフレームワークを使用すると、ビューレイヤーがもう少し反応することを期待します。つまり、ウィジェット自体はステートレスであり、外部から供給される状態に応じて変化しますが、そうではありません。 Aslo、FlutterはAndroidとiOSのみをサポートしていますが、私はWebに公開したかったのです。 私はすでにアプリにビジネスロジックをたくさん持っているので、それを可能な限り再利用したいと思っていました。ビジネスロジックを1回変更するだけで、少なくとも2か所でコードを変更するというアイデアは受け入れられませんでした。

私はこれを乗り越える方法を探し始め、BLoCに出くわしました。 簡単な紹介として、 Flutter / AngularDart –コード共有を、時間があるときに一緒に(DartConf 2018)視聴することをお勧めします。

BLoCパターン

ビュー、BLoC、リポジトリ、およびデータレイヤーの通信フローの図

BLoCは、Googleが考案した、「ビジネス上のコンポーネント」を意味するファンシーな言葉です。 BLoCパターンの考え方は、他のプラットフォームで再利用できるように、できるだけ多くのビジネスロジックを純粋なDartコードに格納することです。 これを達成するには、従わなければならないルールがあります。

  • レイヤーで通信します。 ビューは、リポジトリと通信するBLoCレイヤーと通信し、リポジトリはデータレイヤーと通信します。 通信中にレイヤーをスキップしないでください。
  • インターフェイスを介して通信します。 インターフェイスは、プラットフォームに依存しない純粋なDartコードで作成する必要があります。 詳細については、暗黙的なインターフェイスに関するドキュメントを参照してください。
  • BLoCは、ストリームとシンクのみを公開します。 BLoCのI/Oについては後で説明します。
  • ビューをシンプルに保ちます。 ビジネスロジックを視野に入れないでください。 データを表示し、ユーザーの操作に応答するだけです。
  • BLoCプラットフォームを不可知論にします。 BLoCは純粋なDartコードであるため、プラットフォーム固有のロジックや依存関係を含めることはできません。 プラットフォームの条件付きコードに分岐しないでください。 BLoCは、純粋なDartで実装されたロジックであり、基本プラットフォームを上回っています。
  • プラットフォーム固有の依存関係を挿入します。 これは上記のルールと矛盾しているように聞こえるかもしれませんが、私に聞いてください。 BLoC自体はプラットフォームに依存しませんが、プラットフォーム固有のリポジ​​トリと通信する必要がある場合はどうなりますか? それを注入します。 インターフェイスを介した通信とこれらのリポジトリの挿入を確実にすることで、リポジトリがFlutter用に作成されているかAngularDart用に作成されているかに関係なく、BLoCは気にしないことを確認できます。

最後に覚えておくべきことは、BLoCの入力はシンクであり、出力はストリームを経由する必要があるということです。 これらは両方ともStreamControllerの一部です。

Web(またはモバイル!)アプリを作成する際にこれらのルールを厳密に遵守する場合、モバイル(またはWeb!)バージョンの作成は、ビューとプラットフォーム固有のインターフェイスを作成するのと同じくらい簡単です。 AngularDartまたはFlutterを使い始めたばかりの場合でも、基本的なプラットフォームの知識でビューを作成するのは簡単です。 コードベースの半分以上を再利用することになりかねません。 BLoCパターンは、すべてを構造化して維持しやすくします。

AngularDartとFlutterBLoCTodoアプリの構築

FlutterとAngularDartで簡単なtodoアプリを作成しました。 このアプリは、Firecloudをバックエンドとして使用し、ビューを作成するためのリアクティブなアプローチです。 アプリには3つの部分があります。

  • bloc
  • todo_app_flutter
  • todoapp_dart_angular

データインターフェイス、ローカリゼーションインターフェイスなど、より多くのパーツを選択できます。覚えておくべきことは、各レイヤーがインターフェイスを介して他のレイヤーと通信する必要があるということです。

BLoCコード

bloc/ディレクトリ内:

  • lib/src/bloc block:BloCモジュールは、ビジネスロジックを含む純粋なDartライブラリとしてここに保存されます。
  • lib/src/repository :データへのインターフェースはディレクトリに保存されます。
  • lib/src/repository/firestore :リポジトリには、データへのFireCloudインターフェースとそのモデルが含まれています。これはサンプルアプリであるため、データモデルtodo.dartとデータtodo_repository.dartへのインターフェースは1つだけです。 ただし、実際のアプリでは、より多くのモデルとリポジトリインターフェースがあります。
  • lib/src/repository/preferencesにはpreferences_interface.dartが含まれています。これは、正常にサインインしたユーザー名をWeb上のローカルストレージまたはモバイルデバイス上の共有設定に保存するシンプルなインターフェイスです。
 //BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }

Webおよびモバイルの実装では、これをストアに実装し、ローカルストレージ/設定からデフォルトのユーザー名を取得する必要があります。 これのAngularDart実装は次のようになります。

 // ANGULAR DART class PreferencesInterfaceImpl extends PreferencesInterface { SharedPreferences _prefs; @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance(); @override void setDefaultUsername(String username) => _prefs.setString(DEFAULT_USERNAME, username); @override String get defaultUsername => _prefs.getString(DEFAULT_USERNAME); }

ここでは見事なことは何もありません。必要なものを実装しています。 nullを返すinitPreferences()非同期メソッドに気付くかもしれません。 モバイルでのSharedPreferencesインスタンスの取得は非同期であるため、このメソッドはFlutter側で実装する必要があります。

 //FLUTTER @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance();

lib / src/blocディレクトリに少し固執しましょう。 一部のビジネスロジックを処理するビューには、BLoCコンポーネントが必要です。 このディレクトリには、ビューBLoC base_bloc.dartendpoints.dart 、およびsession.dartが表示されます。 最後の1つは、ユーザーのサインインとサインアウト、およびリポジトリインターフェイスのエンドポイントの提供を担当します。 セッションインターフェースが存在する理由は、 firebaseパッケージとfirecloudパッケージがウェブとモバイルで同じではなく、プラットフォームに基づいて実装する必要があるためです。

 // BLOC abstract class Session implements Endpoints { //Collections. @protected final String userCollectionName = "users"; @protected final String todoCollectionName = "todos"; String userId; Session(){ _isSignedIn.stream.listen((signedIn) { if(!signedIn) _logout(); }); } final BehaviorSubject<bool> _isSignedIn = BehaviorSubject<bool>(); Stream<bool> get isSignedIn => _isSignedIn.stream; Sink<bool> get signedIn => _isSignedIn.sink; Future<String> signIn(String username, String password); @protected void logout(); void _logout() { logout(); userId = null; } }

アイデアは、セッションクラスをグローバル(シングルトン)に保つことです。 _isSignedIn.streamゲッターに基づいて、login / todo-listビュー間のアプリの切り替えを処理し、userIdが存在する場合(つまり、ユーザーがサインインしている場合)にリポジトリ実装にエンドポイントを提供します。

base_bloc.dartは、すべてのBLoCのベースです。 この例では、必要に応じて負荷インジケータとエラーダイアログの表示を処理します。

ビジネスロジックの例として、 todo_add_edit_bloc.dartを見てみましょう。 ファイルの長い名前は、その目的を説明しています。 プライベートvoidメソッド_addUpdateTodo(bool addUpdate)があります。

 // BLOC void _addUpdateTodo(bool addUpdate) { if(!addUpdate) return; //Check required. if(_title.value.isEmpty) _todoError.sink.add(0); else if(_description.value.isEmpty) _todoError.sink.add(1); else _todoError.sink.add(-1); if(_todoError.value >= 0) return; final TodoBloc todoBloc = _todo.value == null ? TodoBloc("", false, DateTime.now(), null, null, null) : _todo.value; todoBloc.title = _title.value; todoBloc.description = _description.value; showProgress.add(true); _toDoRepository.addUpdateToDo(todoBloc) .doOnDone( () => showProgress.add(false) ) .listen((_) => _closeDetail.add(true) , onError: (err) => error.add( err.toString()) ); }

このメソッドの入力はbool addUpdateであり、 final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>()のリスナーです。 ユーザーがアプリの保存ボタンをクリックすると、イベントはこのサブジェクトシンクに真の値を送信し、このBLoC関数をトリガーします。 このフラッターコードは、ビュー側で魔法をかけます。

 // FLUTTER IconButton(icon: Icon(Icons.done), onPressed: () => _todoAddEditBloc.addUpdateSink.add(true),),

_addUpdateTodoは、タイトルと説明の両方が空でないことを確認し、この条件に基づいて_todoErrorの値を変更します。 _todoErrorエラーは、値が指定されていない場合に入力フィールドのビューエラー表示をトリガーする役割を果たします。 すべてが正常であれば、 TodoBlocを作成するか更新するかをチェックし、最後に_toDoRepositoryがFireCloudへの書き込みを行います。

ビジネスロジックはここにありますが、注意してください:

  • BLoCではストリームとシンクのみが公開されています。 _addUpdateTodoはプライベートであり、ビューからアクセスすることはできません。
  • _title.value_description.valueは、ユーザーがテキスト入力に値を入力することで入力されます。 テキスト変更イベントでのテキスト入力は、その値をそれぞれのシンクに送信します。 このようにして、BLoCの値がリアクティブに変更され、ビューに表示されます。
  • _toDoRepositoryはプラットフォームに依存し、インジェクションによって提供されます。

todo_list.dart BLoC _getTodos()メソッドのコードを確認してください。 todoコレクションのスナップショットをリッスンし、コレクションデータをストリーミングしてビューに一覧表示します。 ビューリストは、コレクションストリームの変更に基づいて再描画されます。

 // BLOC void _getTodos(){ showProgress.add(true); _toDoRepository.getToDos() .listen((todosList) { todosSink.add(todosList); showProgress.add(false); }, onError: (err) { showProgress.add(false); error.add(err.toString()); }); }

ストリームまたは同等のrxを使用するときに注意すべき重要なことは、ストリームを閉じる必要があるということです。 これは、各BLoCのdispose()メソッドで行います。 各ビューのBLoCは、dispose/destroyメソッドで破棄します。

 // FLUTTER @override void dispose() { widget.baseBloc.dispose(); super.dispose(); }

またはAngularDartプロジェクトの場合:

 // ANGULAR DART @override void ngOnDestroy() { todoListBloc.dispose(); }

プラットフォーム固有のリポジ​​トリの注入

BLoCパターン、todoリポジトリなどの間の関係の図

BLoCに含まれるものはすべて、単純なDartであり、プラットフォームに依存しないものでなければならないことを前に述べました。 TodoAddEditBlocは、Firestoreに書き込むためにToDoRepositoryを必要とします。 Firebaseにはプラットフォームに依存するパッケージがあり、 ToDoRepositoryインターフェースを個別に実装する必要があります。 これらの実装はアプリに注入されます。 Flutterの場合、 flutter_simple_dependency_injectionパッケージを使用しましたが、次のようになります。

 // FLUTTER class Injection { static Firestore _firestore = Firestore.instance; static FirebaseAuth _auth = FirebaseAuth.instance; static PreferencesInterface _preferencesInterface = PreferencesInterfaceImpl(); static Injector injector; static Future initInjection() async { await _preferencesInterface.initPreferences(); injector = Injector.getInjector(); //Session injector.map<Session>((i) => SessionImpl(_auth, _firestore), isSingleton: true); //Repository injector.map<ToDoRepository>((i) => ToDoRepositoryImpl(injector.get<Session>()), isSingleton: false); //Bloc injector.map<LoginBloc>((i) => LoginBloc(_preferencesInterface, injector.get<Session>()), isSingleton: false); injector.map<TodoListBloc>((i) => TodoListBloc(injector.get<ToDoRepository>(), injector.get<Session>()), isSingleton: false); injector.map<TodoAddEditBloc>((i) => TodoAddEditBloc(injector.get<ToDoRepository>()), isSingleton: false); } }

次のようなウィジェットでこれを使用します。

 // FLUTTER TodoAddEditBloc _todoAddEditBloc = Injection.injector.get<TodoAddEditBloc>();

AngularDartには、プロバイダーを介したインジェクションが組み込まれています。

 // ANGULAR DART @GenerateInjector([ ClassProvider(PreferencesInterface, useClass: PreferencesInterfaceImpl), ClassProvider(Session, useClass: SessionImpl), ExistingProvider(Endpoints, Session) ])

そして、コンポーネントで:

 // ANGULAR DART providers: [ overlayBindings, ClassProvider(ToDoRepository, useClass: ToDoRepositoryImpl), ClassProvider(TodoAddEditBloc), ExistingProvider(BaseBloc, TodoAddEditBloc) ],

Sessionがグローバルであることがわかります。 ToDoRepositoryおよびBLoCで使用されるサインイン/アウト機能とエンドポイントを提供します。 ToDoRepositoryには、 SessionImplなどに実装されているエンドポイントインターフェイスが必要です。 ビューには、BLoCのみが表示され、それ以上は表示されません。

ビュー

BLoCとビューの間で相互作用するシンクとストリームの図

ビューはできるだけシンプルにする必要があります。 BLoCからの情報のみを表示し、ユーザーの入力をBLoCに送信します。 FlutterのTodoAddEditウィジェットとそれに相当するWebのTodoDetailComponentを使用して説明します。 選択したToDoのタイトルと説明が表示され、ユーザーはToDoを追加または更新できます。

フラッター:

 // FLUTTER _todoAddEditBloc.todoStream.first.then((todo) { _titleController.text = todo.title; _descriptionController.text = todo.description; });

そして後でコードで…

 // FLUTTER StreamBuilder<int>( stream: _todoAddEditBloc.todoErrorStream, builder: (BuildContext context, AsyncSnapshot errorSnapshot) { return TextField( onChanged: (text) => _todoAddEditBloc.titleSink.add(text), decoration: InputDecoration(hintText: Localization.of(context).title, labelText: Localization.of(context).title, errorText: errorSnapshot.data == 0 ? Localization.of(context).titleEmpty : null), controller: _titleController, ); }, ),

エラー(何も挿入されていない)がある場合、 StreamBuilderウィジェットはそれ自体を再構築します。 これは、 _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink 。これは、タイトルを保持するBLoCのシンクであり、ユーザーがテキストフィールドにテキストを入力すると更新されます。

この入力フィールドの初期値(1つのtodoが選択されている場合)は、選択されたtodoを保持する_todoAddEditBloc.todoStreamをリッスンするか、新しいTodoを追加する場合は空のtodoをリッスンすることによって入力されます。

テキストフィールドへの値の割り当ては、そのコントローラーによって行われます_titleController.text = todo.title;

ユーザーがtodoを保存することを決定すると、アプリバーのチェックアイコンが押され、 _todoAddEditBloc.addUpdateSink.add(true)がトリガーされます。 これにより、前のBLoCセクションで説明した_addUpdateTodo(bool addUpdate)が呼び出され、エラーを追加、更新、またはユーザーに表示するというすべてのビジネスロジックが実行されます。

すべてがリアクティブであり、ウィジェットの状態を処理する必要はありません。

AngularDartコードはさらに単純です。 コンポーネントにBLoCを提供した後、プロバイダーを使用して、 todo_detail.htmlファイルコードは、データを表示し、ユーザーインタラクションをBLoCに送り返す役割を果たします。

 // AngularDart <material-input #title label="{{titleStr}}" ngModel="{{(todoAddEditBloc.titleStream | async) == null ? '' : (todoAddEditBloc.titleStream | async)}}" (inputKeyPress)="todoAddEditBloc.titleSink.add($event)" [error]="(todoAddEditBloc.todoErrorStream | async) == 0 ? titleErrString : ''" autoFocus floatingLabel type="text" useNativeValidation="false" autocomplete="off"> </material-input> <material-input #description label="{{descriptionStr}}" ngModel="{{(todoAddEditBloc.descriptionStream | async) == null ? '' : (todoAddEditBloc.descriptionStream | async)}}" (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)" [error]="(todoAddEditBloc.todoErrorStream | async) == 1 ? descriptionErrString : ''" autoFocus floatingLabel type="text" useNativeValidation="false" autocomplete="off"> </material-input> <material-button animated raised role="button" class="blue" (trigger)="todoAddEditBloc.addUpdateSink.add(true)"> {{saveStr}} </material-button> <base-bloc></base-bloc>

Flutterと同様に、 ngModel=にタイトルストリームの値を割り当てます。これは初期値です。

 // AngularDart (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)"

inputKeyPress出力イベントは、ユーザーがテキスト入力に入力した文字をBLoCの説明に送り返します。 マテリアルボタン(trigger)="todoAddEditBloc.addUpdateSink.add(true)"イベントは、BLoC add / updateイベントを送信します。このイベントは、BLoCで同じ_addUpdateTodo(bool addUpdate)関数を再度トリガーします。 コンポーネントのtodo_detail.dartコードを見ると、ビューに表示されている文字列以外にはほとんど何もないことがわかります。 ここで実行できるローカリゼーションの可能性があるため、HTMLではなくそこに配置しました。

他のすべてのコンポーネントについても同じことが言えます。コンポーネントとウィジェットのビジネスロジックはゼロです。

もう1つのシナリオは言及する価値があります。 複雑なデータプレゼンテーションロジックを備えたビュー、またはフォーマットする必要のある値(日付、通貨など)を含むテーブルのようなものがあるとします。 誰かがBLoCから値を取得し、それらをビューでフォーマットしたくなる可能性があります。 それは間違っている! ビューに表示される値は、すでにフォーマットされたビュー(文字列)に到達する必要があります。 その理由は、フォーマット自体もビジネスロジックであるためです。 もう1つの例は、表示値のフォーマットが実行時に変更できるアプリパラメーターに依存している場合です。 そのパラメータをBLoCに提供し、リアクティブなアプローチを使用して表示を表示することにより、ビジネスロジックは値をフォーマットし、必要な部分のみを再描画します。 この例のBLoCモデルであるTodoBlocは非常に単純です。 FireCloudモデルからBLoCモデルへの変換はリポジトリで行われますが、必要に応じて、モデル値を表示できるようにBLoCで行うことができます。

まとめ

この簡単な記事では、BLoCパターンの実装の主な概念について説明します。 FlutterとAngularDartの間でコード共有が可能であり、ネイティブおよびクロスプラットフォームの開発が可能であることを証明しています。

例を見てみると、正しく実装されている場合、BLoCはモバイル/ウェブアプリの作成にかかる時間を大幅に短縮することがわかります。 例として、 ToDoRepositoryとその実装があります。 実装コードはほとんど同じであり、ビュー構成ロジックも同様です。 いくつかのウィジェット/コンポーネントの後、すぐに大量生産を開始できます。

この記事で、Flutter/AngularDartとBLoCパターンを使用してWeb/モバイルアプリを作成するときの楽しさと熱意を一目で理解できることを願っています。 JavaScriptでクロスプラットフォームのデスクトップアプリケーションを構築することを検討している場合は、仲間のToptaler Stephane P. PericatによるElectron:Cross-platform Desktop AppsMadeEasyをお読みください。

関連: Dart言語:JavaとC#が十分にシャープでない場合