Cara Memanfaatkan BLoC untuk Berbagi Kode di Flutter dan AngularDart
Diterbitkan: 2022-03-11Pertengahan tahun lalu, saya ingin mem-porting aplikasi Android ke iOS dan web. Flutter adalah pilihan untuk platform seluler, dan saya sedang memikirkan apa yang harus dipilih untuk sisi web.
Meskipun saya jatuh cinta dengan Flutter pada pandangan pertama, saya masih memiliki beberapa keraguan: Saat menyebarkan status di pohon widget, InheritedWidget
atau Redux Flutter—dengan semua variasinya—akan berhasil, tetapi dengan kerangka kerja baru seperti Flutter, Anda akan melakukannya berharap bahwa lapisan tampilan akan menjadi sedikit lebih reaktif, yaitu, widget sendiri tidak memiliki kewarganegaraan, dan berubah sesuai dengan status yang mereka berikan dari luar, tetapi sebenarnya tidak. Selain itu, Flutter hanya mendukung Android dan iOS, tetapi saya ingin memublikasikannya ke web. Saya sudah memiliki banyak logika bisnis di aplikasi saya dan saya ingin menggunakannya kembali sebanyak mungkin, dan gagasan untuk mengubah kode di setidaknya dua tempat untuk satu perubahan dalam logika bisnis tidak dapat diterima.
Saya mulai mencari-cari cara mengatasi ini dan menemukan BLoC. Untuk intro singkat, saya sarankan menonton Flutter/AngularDart – Berbagi kode, lebih baik bersama-sama (DartConf 2018) bila Anda punya waktu.
Pola BLoC
BLoC adalah kata mewah yang ditemukan oleh Google yang berarti “ komponen logika bisnis” . Ide pola BLoC adalah untuk menyimpan sebanyak mungkin logika bisnis Anda dalam kode Dart murni sehingga dapat digunakan kembali oleh platform lain. Untuk mencapai ini, ada aturan yang harus Anda ikuti:
- Berkomunikasi berlapis-lapis. Tampilan berkomunikasi dengan lapisan BLoC, yang berkomunikasi dengan repositori, dan repositori berbicara dengan lapisan data. Jangan melewatkan lapisan saat berkomunikasi.
- Berkomunikasi melalui antarmuka. Antarmuka harus ditulis dalam kode Dart murni yang tidak bergantung pada platform. Untuk informasi selengkapnya, lihat dokumentasi tentang antarmuka implisit.
- BLoC hanya mengekspos stream dan sink. I/O dari BLoC akan dibahas kemudian.
- Jaga agar tampilan tetap sederhana. Jauhkan logika bisnis dari pandangan. Mereka seharusnya hanya menampilkan data dan menanggapi interaksi pengguna.
- Jadikan platform BLoC agnostik. BLoC adalah kode Dart murni, sehingga tidak boleh mengandung logika atau dependensi khusus platform. Jangan bercabang menjadi kode bersyarat platform. BLoC adalah logika yang diimplementasikan dalam Dart murni dan di atas berurusan dengan platform dasar.
- Suntikkan dependensi khusus platform. Ini mungkin terdengar bertentangan dengan aturan di atas, tetapi dengarkan saya. BLoC sendiri adalah agnostik platform, tetapi bagaimana jika mereka perlu berkomunikasi dengan repositori khusus platform? Suntikkan. Dengan memastikan komunikasi melalui antarmuka dan memasukkan repositori ini, kami dapat yakin bahwa terlepas dari apakah repositori Anda ditulis untuk Flutter atau AngularDart, BLoC tidak akan peduli.
Satu hal terakhir yang perlu diingat adalah bahwa input untuk BLoC harus sink, sedangkan outputnya melalui stream. Keduanya adalah bagian dari StreamController
.
Jika Anda benar-benar mematuhi aturan ini saat menulis aplikasi web (atau seluler!), membuat versi seluler (atau web!) bisa semudah membuat tampilan dan antarmuka khusus platform. Bahkan jika Anda baru mulai menggunakan AngularDart atau Flutter, masih mudah untuk membuat tampilan dengan pengetahuan platform dasar. Anda mungkin akhirnya menggunakan kembali lebih dari setengah basis kode Anda. Pola BLoC membuat semuanya tetap terstruktur dan mudah dirawat.
Membangun Aplikasi Todo AngularDart dan Flutter BLoC
Saya membuat aplikasi todo sederhana di Flutter dan AngularDart. Aplikasi ini menggunakan Firecloud sebagai back-end dan pendekatan reaktif untuk melihat pembuatan. Aplikasi ini memiliki tiga bagian:
-
bloc
-
todo_app_flutter
-
todoapp_dart_angular
Anda dapat memilih untuk memiliki lebih banyak bagian—misalnya, antarmuka data, antarmuka pelokalan, dll. Hal yang perlu diingat adalah bahwa setiap lapisan harus berkomunikasi satu sama lain melalui antarmuka.
Kode BLoC
Di direktori bloc/
:
-
lib/src/bloc
: Modul BloC disimpan di sini sebagai perpustakaan Dart murni yang berisi logika bisnis. -
lib/src/repository
: Antarmuka ke data disimpan dalam direktori. -
lib/src/repository/firestore
: Repositori berisi antarmuka FireCloud ke data bersama dengan modelnya, dan karena ini adalah aplikasi sampel, kami hanya memiliki satu model datatodo.dart
dan satu antarmuka ke datatodo_repository.dart
; namun, dalam aplikasi dunia nyata, akan ada lebih banyak model dan antarmuka repositori. -
lib/src/repository/preferences
berisipreferences_interface.dart
, antarmuka sederhana yang menyimpan nama pengguna yang berhasil masuk ke penyimpanan lokal di web atau preferensi bersama di perangkat seluler.
//BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }
Implementasi web dan seluler harus menerapkan ini ke toko dan mendapatkan nama pengguna default dari penyimpanan/preferensi lokal. Implementasi AngularDart dari ini terlihat seperti:
// 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); }
Tidak ada yang spektakuler di sini—ini mengimplementasikan apa yang dibutuhkannya. Anda mungkin memperhatikan metode async initPreferences()
yang mengembalikan null
. Metode ini perlu diterapkan di sisi Flutter karena mendapatkan instance SharedPreferences
di seluler tidak sinkron.
//FLUTTER @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance();
Mari kita tetap sedikit dengan direktori lib/src/bloc. Setiap tampilan yang menangani beberapa logika bisnis harus memiliki komponen BLoC. Dalam direktori ini, Anda akan melihat view BLoCs base_bloc.dart
, endpoints.dart
, dan session.dart
. Yang terakhir bertanggung jawab untuk membuat pengguna masuk dan keluar dan menyediakan titik akhir untuk antarmuka repositori. Alasan adanya antarmuka sesi adalah karena paket firebase
dan firecloud
tidak sama untuk web dan seluler dan harus diimplementasikan berdasarkan platform.
// 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; } }
Idenya adalah untuk menjaga kelas sesi global (tunggal). Berdasarkan getter _isSignedIn.stream
-nya, ini menangani peralihan aplikasi antara tampilan login/todo-list dan menyediakan titik akhir untuk implementasi repositori jika userId ada (yaitu, pengguna masuk).
base_bloc.dart
adalah basis untuk semua BLoC. Dalam contoh ini, menangani indikator beban dan tampilan dialog kesalahan sesuai kebutuhan.
Untuk contoh logika bisnis, kita akan melihat todo_add_edit_bloc.dart
. Nama panjang file menjelaskan tujuannya. Ia memiliki metode void pribadi _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()) ); }
Input untuk metode ini adalah bool addUpdate
dan merupakan listener final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>()
. Saat pengguna mengklik tombol simpan di aplikasi, peristiwa tersebut mengirimkan nilai sebenarnya dari subjek ini dan memicu fungsi BLoC ini. Sepotong kode flutter ini melakukan keajaiban di sisi tampilan.
// FLUTTER IconButton(icon: Icon(Icons.done), onPressed: () => _todoAddEditBloc.addUpdateSink.add(true),),
_addUpdateTodo
memeriksa apakah judul dan deskripsi tidak kosong dan mengubah nilai _todoError
BehaviorSubject berdasarkan kondisi ini. Kesalahan _todoError
bertanggung jawab untuk memicu tampilan kesalahan tampilan pada bidang input jika tidak ada nilai yang diberikan. Jika semuanya baik-baik saja, ia akan memeriksa apakah akan membuat atau memperbarui TodoBloc
dan akhirnya _toDoRepository
melakukan penulisan ke FireCloud.
Logika bisnis ada di sini tetapi perhatikan:
- Hanya aliran dan sink yang bersifat publik di BLoC.
_addUpdateTodo
bersifat pribadi dan tidak dapat diakses dari tampilan. -
_title.value
dan_description.value
diisi oleh pengguna yang memasukkan nilai dalam input teks. Input teks pada peristiwa perubahan teks mengirimkan nilainya ke masing-masing sink. Dengan cara ini, kami memiliki perubahan nilai reaktif dalam BLoC dan tampilannya dalam tampilan. -
_toDoRepository
bergantung pada platform dan disediakan melalui injeksi.
Lihat kode metode todo_list.dart
BLoC _getTodos()
. Itu mendengarkan snapshot dari koleksi todo dan mengalirkan data koleksi ke daftar dalam tampilannya. Daftar tampilan digambar ulang berdasarkan perubahan aliran koleksi.

// 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()); }); }
Hal penting yang harus diperhatikan saat menggunakan aliran atau setara rx adalah bahwa aliran harus ditutup. Kami melakukannya dalam metode dispose()
dari setiap BLoC. Buang BLoC dari setiap tampilan dalam metode buang/hancurkan.
// FLUTTER @override void dispose() { widget.baseBloc.dispose(); super.dispose(); }
Atau dalam proyek AngularDart:
// ANGULAR DART @override void ngOnDestroy() { todoListBloc.dispose(); }
Menyuntikkan Repositori khusus Platform
Kami mengatakan sebelumnya bahwa segala sesuatu yang datang dalam BLoC harus Dart sederhana dan tidak ada yang bergantung pada platform. TodoAddEditBloc
membutuhkan ToDoRepository
untuk menulis ke Firestore. Firebase memiliki paket yang bergantung pada platform, dan kita harus memiliki implementasi antarmuka ToDoRepository
yang terpisah. Implementasi ini disuntikkan ke dalam aplikasi. Untuk Flutter, saya menggunakan paket flutter_simple_dependency_injection
dan tampilannya seperti ini:
// 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); } }
Gunakan ini di widget seperti ini:
// FLUTTER TodoAddEditBloc _todoAddEditBloc = Injection.injector.get<TodoAddEditBloc>();
AngularDart memiliki injeksi bawaan melalui penyedia.
// ANGULAR DART @GenerateInjector([ ClassProvider(PreferencesInterface, useClass: PreferencesInterfaceImpl), ClassProvider(Session, useClass: SessionImpl), ExistingProvider(Endpoints, Session) ])
Dan dalam komponen:
// ANGULAR DART providers: [ overlayBindings, ClassProvider(ToDoRepository, useClass: ToDoRepositoryImpl), ClassProvider(TodoAddEditBloc), ExistingProvider(BaseBloc, TodoAddEditBloc) ],
Kita dapat melihat bahwa Session
bersifat global. Ini menyediakan fungsionalitas masuk/keluar dan titik akhir yang digunakan di ToDoRepository
dan BLoC. ToDoRepository
membutuhkan antarmuka titik akhir yang diimplementasikan di SessionImpl
dan seterusnya. Tampilan seharusnya hanya melihat BLoC-nya dan tidak lebih.
Tampilan
Tampilan harus sesederhana mungkin. Mereka hanya menampilkan apa yang berasal dari BLoC dan mengirimkan input pengguna ke BLoC. Kami akan membahasnya dengan widget TodoAddEdit
dari Flutter dan TodoDetailComponent
yang setara dengan webnya. Mereka menampilkan judul dan deskripsi todo yang dipilih dan pengguna dapat menambahkan atau memperbarui todo.
Berdebar:
// FLUTTER _todoAddEditBloc.todoStream.first.then((todo) { _titleController.text = todo.title; _descriptionController.text = todo.description; });
Dan nanti di kode…
// 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, ); }, ),
Widget StreamBuilder
membangun kembali dirinya sendiri jika ada kesalahan (tidak ada yang dimasukkan). Ini terjadi dengan mendengarkan _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
_todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
, yang merupakan sink di BLoC yang memegang judul dan diperbarui saat pengguna memasukkan teks di bidang teks.
Nilai awal bidang input ini (jika satu todo dipilih) diisi dengan mendengarkan _todoAddEditBloc.todoStream
yang menyimpan todo yang dipilih atau yang kosong jika kita menambahkan todo baru.
Menetapkan nilai ke bidang teks dilakukan oleh pengontrolnya _titleController.text = todo.title;
.
Ketika pengguna memutuskan untuk menyimpan todo, itu menekan ikon centang di bilah aplikasi dan memicu _todoAddEditBloc.addUpdateSink.add(true)
. Itu memanggil _addUpdateTodo(bool addUpdate)
yang kita bicarakan di bagian BLoC sebelumnya dan melakukan semua logika bisnis untuk menambahkan, memperbarui, atau menampilkan kesalahan kembali ke pengguna.
Semuanya reaktif dan tidak perlu menangani status widget.
Kode AngularDart bahkan lebih sederhana. Setelah menyediakan komponen BLoC-nya, menggunakan penyedia, kode file todo_detail.html
melakukan bagian menampilkan data dan mengirim interaksi pengguna kembali ke 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>
Mirip dengan Flutter, kami menetapkan ngModel=
nilai dari aliran judul, yang merupakan nilai awalnya.
// AngularDart (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)"
Acara keluaran inputKeyPress
mengirimkan karakter yang diketik pengguna dalam input teks kembali ke deskripsi BLoC. Tombol material (trigger)="todoAddEditBloc.addUpdateSink.add(true)"
mengirimkan peristiwa penambahan/pembaruan BLoC yang kembali memicu fungsi _addUpdateTodo(bool addUpdate)
yang sama di BLoC. Jika Anda melihat kode todo_detail.dart
dari komponen, Anda akan melihat bahwa hampir tidak ada apa-apa kecuali string yang ditampilkan pada tampilan. Saya menempatkannya di sana dan bukan di HTML karena kemungkinan pelokalan yang dapat dilakukan di sini.
Hal yang sama berlaku untuk setiap komponen lainnya—komponen dan widget tidak memiliki logika bisnis.
Satu lagi skenario yang layak disebut. Bayangkan Anda memiliki tampilan dengan logika penyajian data yang kompleks atau sesuatu seperti tabel dengan nilai yang harus diformat (tanggal, mata uang, dll.). Seseorang mungkin tergoda untuk mendapatkan nilai dari BLoC dan memformatnya dalam tampilan. Itu salah! Nilai yang ditampilkan dalam tampilan harus datang ke tampilan yang sudah diformat (string). Alasannya adalah bahwa pemformatan itu sendiri juga merupakan logika bisnis. Satu contoh lagi adalah ketika pemformatan nilai tampilan bergantung pada beberapa parameter aplikasi yang dapat diubah saat runtime. Dengan memberikan parameter tersebut ke BLoC dan menggunakan pendekatan reaktif untuk melihat tampilan, logika bisnis akan memformat nilai dan menggambar ulang hanya bagian yang diperlukan. Model BLoC yang kita miliki dalam contoh ini, TodoBloc
, sangat sederhana. Konversi dari model FireCloud ke model BLoC dilakukan di repositori, tetapi jika diperlukan dapat dilakukan di BLoC sehingga nilai model siap ditampilkan.
Membungkus
Artikel singkat ini mencakup konsep utama implementasi pola BLoC. Ini adalah bukti kerja bahwa berbagi kode antara Flutter dan AngularDart dimungkinkan, memungkinkan untuk pengembangan asli, dan lintas platform.
Menjelajahi contoh, Anda akan melihat bahwa, ketika diterapkan dengan benar, BLoC mempersingkat waktu untuk membuat aplikasi seluler/web secara signifikan. Contohnya adalah ToDoRepository
dan implementasinya. Kode implementasi hampir identik dan bahkan logika pembuatan tampilan serupa. Setelah beberapa widget/komponen, Anda dapat dengan cepat memulai produksi massal.
Saya harap artikel ini akan memberi Anda gambaran tentang kesenangan dan antusiasme saya dalam membuat aplikasi web/seluler menggunakan Flutter/AngularDart dan pola BLoC. Jika Anda ingin membuat aplikasi desktop lintas platform dalam JavaScript, baca Electron: Aplikasi Desktop Lintas Platform yang Dibuat Mudah oleh sesama Toptaler Stephane P. Pericat.