Flutter 및 AngularDart에서 코드 공유를 위해 BLoC를 활용하는 방법

게시 됨: 2022-03-11

작년 중순, 저는 Android 앱을 iOS와 웹으로 이식하고 싶었습니다. Flutter는 모바일 플랫폼을 위한 선택이었고 저는 웹 쪽에서 무엇을 선택해야 할지 고민하고 있었습니다.

첫눈에 Flutter와 사랑에 빠졌지만 여전히 몇 가지 유보 사항이 있었습니다. 위젯 트리 아래로 상태를 전파하는 동안 Flutter의 InheritedWidget 또는 Redux(모든 변형 포함)가 작업을 수행하지만 Flutter와 같은 새로운 프레임워크를 사용하면 보기 레이어가 좀 더 반응적일 것으로 예상합니다. 즉, 위젯 자체가 상태 비저장이고 외부에서 공급되는 상태에 따라 변경되지만 그렇지 않습니다. 또한 Flutter는 Android 및 iOS만 지원하지만 웹에 게시하고 싶었습니다. 내 앱에는 이미 많은 비즈니스 로직이 있고 최대한 재사용하고 싶었고, 비즈니스 로직을 한 번만 변경하기 위해 적어도 두 곳에서 코드를 변경한다는 아이디어는 받아들일 수 없었습니다.

이것을 극복하는 방법을 찾기 시작했고 BLoC를 발견했습니다. 빠른 소개를 위해 시간이 있을 때 Flutter/AngularDart – Code sharing, better together(DartConf 2018) 를 시청하는 것이 좋습니다.

블록 패턴

보기, BLoC, 저장소 및 데이터 계층의 통신 흐름 다이어그램

BLoC 는 "비즈니스 논리 구성 요소"를 의미하는 Google에서 발명한 멋진 단어입니다. BLoC 패턴의 아이디어는 가능한 한 많은 비즈니스 로직을 순수한 Dart 코드에 저장하여 다른 플랫폼에서 재사용할 수 있도록 하는 것입니다. 이를 달성하기 위해 따라야 하는 규칙이 있습니다.

  • 레이어에서 통신합니다. 보기는 저장소와 통신하는 BLoC 계층과 통신하고 저장소는 데이터 계층과 통신합니다. 의사 소통하는 동안 레이어를 건너 뛰지 마십시오.
  • 인터페이스를 통해 통신합니다. 인터페이스는 순수하고 플랫폼 독립적인 Dart 코드로 작성되어야 합니다. 자세한 내용은 암시적 인터페이스에 대한 설명서를 참조하십시오.
  • BLoC는 스트림과 싱크만 노출합니다. BLoC의 I/O에 대해서는 나중에 논의할 것이다.
  • 보기를 단순하게 유지하십시오. 보기에서 비즈니스 논리를 유지하십시오. 데이터만 표시하고 사용자 상호 작용에 응답해야 합니다.
  • BLoC 플랫폼을 불가지론자로 만듭니다. BLoC는 순수 Dart 코드이므로 플랫폼별 논리나 종속성을 포함하지 않아야 합니다. 플랫폼 조건부 코드로 분기하지 마십시오. BLoC는 순수 Dart에서 구현된 논리이며 기본 플랫폼을 다루는 것 이상입니다.
  • 플랫폼별 종속성을 주입합니다. 이것은 위의 규칙과 모순되게 들릴 수 있지만 제 말을 들어보십시오. BLoC 자체는 플랫폼에 구애받지 않지만 플랫폼별 저장소와 통신해야 하는 경우 어떻게 해야 할까요? 주입합니다. 인터페이스를 통한 통신을 보장하고 이러한 리포지토리를 주입함으로써 리포지토리가 Flutter 또는 AngularDart용으로 작성되었는지 여부에 관계없이 BLoC가 신경 쓰지 않을 것임을 확신할 수 있습니다.

마지막으로 염두에 두어야 할 것은 BLoC의 입력은 싱크여야 하고 출력은 스트림을 통해서여야 한다는 것입니다. 이들은 모두 StreamController 의 일부입니다.

웹(또는 모바일!) 앱을 작성하는 동안 이러한 규칙을 엄격히 준수한다면 모바일(또는 웹!) 버전을 만드는 것은 보기와 플랫폼별 인터페이스를 만드는 것만큼 간단할 수 있습니다. AngularDart 또는 Flutter를 이제 막 사용하기 시작했더라도 기본 플랫폼 지식으로 보기를 만드는 것은 여전히 ​​쉽습니다. 코드베이스의 절반 이상을 재사용하게 될 수도 있습니다. BLoC 패턴은 모든 것을 구조화하고 유지하기 쉽게 유지합니다.

AngularDart 및 Flutter BLoC Todo 앱 빌드

Flutter와 AngularDart로 간단한 할일 앱을 만들었습니다. 이 앱은 Firecloud를 백엔드로 사용하고 생성을 보기 위한 반응적 접근 방식을 사용합니다. 앱은 세 부분으로 구성되어 있습니다.

  • bloc
  • todo_app_flutter
  • todoapp_dart_angular

데이터 인터페이스, 현지화 인터페이스 등 더 많은 부분을 포함하도록 선택할 수 있습니다. 기억해야 할 점은 각 레이어가 인터페이스를 통해 다른 레이어와 통신해야 한다는 것입니다.

BLoC 코드

bloc/ 디렉토리에서:

  • lib/src/bloc : BloC 모듈은 여기에 비즈니스 로직을 포함하는 순수 Dart 라이브러리로 저장됩니다.
  • lib/src/repository : 데이터에 대한 인터페이스는 디렉토리에 저장됩니다.
  • lib/src/repository/firestore : 저장소에는 해당 모델과 함께 데이터에 대한 FireCloud 인터페이스가 포함되어 있으며 이것은 샘플 앱이므로 우리는 데이터 모델 todo.darttodo_repository.dart 데이터에 대한 인터페이스가 하나만 있습니다. 그러나 실제 앱에는 더 많은 모델과 저장소 인터페이스가 있습니다.
  • lib/src/repository/preferences preferences_interface.dart 웹의 로컬 저장소 또는 모바일 장치의 공유 기본 설정에 성공적으로 로그인한 사용자 이름을 저장하는 간단한 인터페이스인 Preferences_interface.dart가 포함되어 있습니다.
 //BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }

웹 및 모바일 구현은 이것을 저장소에 구현하고 로컬 저장소/기본 설정에서 기본 사용자 이름을 가져와야 합니다. 이것의 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.dart , endpoints.dartsession.dart 를 볼 수 있습니다. 마지막 하나는 사용자를 로그인 및 로그아웃하고 저장소 인터페이스에 대한 끝점을 제공하는 역할을 합니다. 세션 인터페이스가 존재하는 이유는 firebasefirecloud 패키지가 웹과 모바일에서 동일하지 않고 플랫폼 기반으로 구현되어야 하기 때문입니다.

 // 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 getter를 기반으로 로그인/할 일 목록 보기 간의 앱 전환을 처리하고 userId가 존재하는 경우(즉, 사용자가 로그인한 경우) 저장소 구현에 엔드포인트를 제공합니다.

base_bloc.dart 는 모든 BLoC의 기반입니다. 이 예에서는 필요에 따라 로드 표시기 및 오류 대화 상자 표시를 처리합니다.

비즈니스 로직 예제의 경우 todo_add_edit_bloc.dart 를 살펴보겠습니다. 파일의 긴 이름은 목적을 설명합니다. 개인 무효 메소드 _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>() 의 수신기입니다. 사용자가 앱에서 저장 버튼을 클릭하면 이벤트가 이 주제 싱크 true 값을 보내고 이 BLoC 기능을 트리거합니다. 이 플러터 코드는 뷰 측면에서 마술을 부립니다.

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

_addUpdateTodo 는 제목과 설명이 모두 비어 있지 않은지 확인하고 이 조건에 따라 _todoError BehaviorSubject의 값을 변경합니다. _todoError 오류는 값이 제공되지 않은 경우 입력 필드에 보기 오류 표시를 트리거하는 역할을 합니다. 모든 것이 정상이면 TodoBloc 을 만들거나 업데이트할지 여부를 확인하고 마지막으로 _toDoRepository 가 FireCloud에 쓰기를 수행합니다.

비즈니스 로직은 여기에 있지만 주의하세요.

  • 스트림과 싱크만 BLoC에서 공개됩니다. _addUpdateTodo 는 비공개이며 보기에서 액세스할 수 없습니다.
  • _title.value_description.value 는 사용자가 텍스트 입력에 값을 입력하여 채워집니다. 텍스트 변경 이벤트에 대한 텍스트 입력은 해당 값을 해당 싱크로 보냅니다. 이런 식으로 BLoC에서 값을 반응적으로 변경하고 뷰에 표시할 수 있습니다.
  • _toDoRepository 는 플랫폼에 따라 다르며 주입을 통해 제공됩니다.

todo_list.dart BLoC _getTodos() 메소드의 코드를 확인하십시오. 할 일 컬렉션의 스냅샷을 수신 대기하고 컬렉션 데이터를 스트리밍하여 보기에 나열합니다. 컬렉션 스트림 변경에 따라 보기 목록이 다시 그려집니다.

 // 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() 메서드에서 이를 수행합니다. 해당 dispose/destroy 메서드에서 각 뷰의 BLoC를 삭제합니다.

 // 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에서 사용되는 로그인/로그아웃 기능 및 엔드포인트를 제공합니다. ToDoRepositorySessionImpl 등에서 구현되는 엔드포인트 인터페이스가 필요합니다. 보기에는 BLoC만 표시되고 그 이상은 표시되지 않습니다.

견해

BLoC와 뷰 간에 상호 작용하는 싱크 및 스트림의 다이어그램

보기는 가능한 한 단순해야 합니다. 그들은 BLoC에서 오는 것만 표시하고 사용자의 입력을 BLoC로 보냅니다. Flutter의 TodoAddEdit 위젯과 이에 상응하는 웹 TodoDetailComponent 로 이를 살펴보겠습니다. 그들은 선택된 할 일 제목과 설명을 표시하고 사용자는 할 일을 추가하거나 업데이트할 수 있습니다.

실룩 거리다:

 // 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의 싱크이며 사용자가 텍스트 필드에 텍스트를 입력할 때 업데이트됩니다.

이 입력 필드의 초기 값(하나의 todo가 선택된 경우)은 선택된 todo를 보유하는 _todoAddEditBloc.todoStream 을 수신하여 채워집니다.

텍스트 필드에 값을 할당하는 것은 컨트롤러에 의해 수행됩니다 _titleController.text = todo.title; .

사용자가 할 일을 저장하기로 결정하면 앱 바의 체크 아이콘을 누르고 _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의 설명으로 다시 보냅니다. Material button (trigger)="todoAddEditBloc.addUpdateSink.add(true)" 이벤트는 BLoC에서 동일한 _addUpdateTodo(bool addUpdate) 기능을 다시 트리거하는 BLoC 추가/업데이트 이벤트를 보냅니다. 컴포넌트의 todo_detail.dart 코드를 보면 뷰에 표시되는 문자열 외에는 거의 아무것도 없음을 알 수 있습니다. 여기에서 수행할 수 있는 현지화 가능성 때문에 HTML이 아닌 여기에 배치했습니다.

다른 모든 구성 요소도 마찬가지입니다. 구성 요소와 위젯에는 비즈니스 로직이 없습니다.

한 가지 더 언급할 가치가 있는 시나리오입니다. 복잡한 데이터 표시 논리 또는 형식이 지정되어야 하는 값(날짜, 통화 등)이 있는 테이블과 같은 보기가 있다고 상상해 보십시오. 누군가 BLoC에서 값을 가져와 보기에서 형식을 지정하려는 유혹을 받을 수 있습니다. 그건 틀렸어요! 보기에 표시되는 값은 이미 형식이 지정된(문자열) 보기에 표시되어야 합니다. 그 이유는 서식 자체도 비즈니스 논리이기 때문입니다. 또 하나의 예는 표시 값의 형식이 런타임에 변경할 수 있는 일부 앱 매개변수에 따라 달라지는 경우입니다. 해당 매개변수를 BLoC에 제공하고 디스플레이를 보기 위해 반응적 접근 방식을 사용함으로써 비즈니스 로직은 값의 형식을 지정하고 필요한 부분만 다시 그립니다. 이 예제에 있는 BLoC 모델인 TodoBloc 은 매우 간단합니다. FireCloud 모델에서 BLoC 모델로의 변환은 리포지토리에서 수행되지만 필요한 경우 모델 값을 표시할 준비가 되도록 BLoC에서 수행할 수 있습니다.

마무리

이 간략한 기사에서는 BLoC 패턴 구현의 주요 개념을 다룹니다. Flutter와 AngularDart 간의 코드 공유가 가능하여 네이티브 및 크로스 플랫폼 개발이 가능하다는 작동 증거입니다.

예제를 탐색하면 올바르게 구현될 때 BLoC가 모바일/웹 앱을 만드는 시간을 크게 단축한다는 것을 알 수 있습니다. 예는 ToDoRepository 와 그 구현입니다. 구현 코드는 거의 동일하고 뷰를 구성하는 로직도 비슷합니다. 몇 가지 위젯/구성 요소 후에 신속하게 대량 생산을 시작할 수 있습니다.

이 기사가 Flutter/AngularDart와 BLoC 패턴을 사용하여 웹/모바일 앱을 만드는 재미와 열정을 한 눈에 볼 수 있기를 바랍니다. JavaScript로 크로스 플랫폼 데스크톱 애플리케이션을 구축하려는 경우 동료 Toptaler Stephane P. Pericat 의 Electron: 간편한 크로스 플랫폼 데스크톱 앱을 읽어보세요.

관련: Dart 언어: Java 및 C#이 충분히 날카롭지 않을 때