Binder는 애플리케이션 상태와 비즈니스 로직을 원활하게 연결하는 가볍고 강력한 상태 관리 솔루션입니다. 단순성과 성능을 고려해 설계되었으며, 개발자가 UI 계층에서 데이터와 동작을 직접 바인딩할 수 있게 해 주어 관심사의 명확한 분리를 가능하게 합니다. 최소한의 뼈대 코드로 반응형 업데이트 및 위젯 간 효율적인 상태 전파를 지원합니다. 더 큰 프레임워크의 복잡성을 갖지 않고도 반응성 있고 유지보수 가능한 상태 처리가 필요한 애플리케이션에 이상적입니다.
사용 사례
실시간 데이터 동기화
폼 상태 관리
사용자 인증 흐름
동적 UI 업데이트
위젯 간 상태 공유
비즈니스 로직 통합 간소화
주요 기능
최소한의 보일러플레이트
반응형 상태 업데이트
관심사의 명확한 분리
기존 코드와의 간편한 통합
경량 피트프린트
비동기 작업을 지원합니다
적합한 대상
작은에서 중간 규모의 Flutter 앱
간결함을 추구하는 개발자
복잡한 상태 솔루션을 피하는 팀
UI 상태 변경이 자주 발생하는 앱
유지보수성을 중시하는 프로젝트
고려 사항
주류 패키지에 비해 문서가 제한적
작은 커뮤니티 지원
매우 대규모 응용 프로그램에는 적합하지 않습니다
새 사용자에게는 학습 곡선이 필요할 수 있습니다
내장된 디버깅 도구 없음
원본 README
연결된 소스 저장소의 README 원문 스냅샷입니다. 최신 내용은 저장소에서 확인하세요.
binder
A lightweight, yet powerful way to bind your application state with your business logic.
The vision
As other state management pattern, binder aims to separate the application state from the business logic that updates it:
We can see the whole application state as the agglomeration of a multitude of tiny states. Each state being independent from each other.
A view can be interested in some particular states and has to use a logic component to update them.
In the pubspec.yaml of your flutter project, add the following dependency:
dependencies:binder:<latest_version>
In your library add the following import:
import'package:binder/binder.dart';
Basic usage
Any state has to be declared through a StateRef with its initial value:
final counterRef = StateRef(0);
Note: A state should be immutable, so that the only way to update it, is through methods provided by this package.
Any logic component has to be declared through a LogicRef with a function that will be used to create it:
final counterViewLogicRef = LogicRef((scope) => CounterViewLogic(scope));
The scope argument can then be used by the logic to mutate the state and access other logic components.
Note: You can declare StateRef and LogicRef objects as public global variables if you want them to be accessible from other parts of your app.
If we want our CounterViewLogic to be able to increment our counter state, we might write something like this:
/// A business logic component can apply the [Logic] mixin to have access to/// useful methods, such as `write` and `read`.classCounterViewLogicwithLogic{
constCounterViewLogic(this.scope);
/// This is the object which is able to interact with other components.@overridefinalScope scope;
/// We can use the [write] method to mutate the state referenced by a/// [StateRef] and [read] to obtain its current state.voidincrement() => write(counterRef, read(counterRef) + 1);
}
In order to bind all of this together in a Flutter app, we have to use a dedicated widget called BinderScope.
This widget is responsible for holding a part of the application state and for providing the logic components.
You will typically create this widget above the MaterialApp widget:
In any widget under the BinderScope, you can call extension methods on BuildContext to bind the view to the application state and to the business logic components:
classCounterViewextendsStatelessWidget{
constCounterView({Key? key}) : super(key: key);
@overrideWidgetbuild(BuildContext context) {
/// We call the [watch] extension method on a [StateRef] to rebuild the/// widget when the underlaying state changes.final counter = context.watch(counterRef);
returnScaffold(
appBar: AppBar(title: constText('Binder example')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
constText('You have pushed the button this many times:'),
Text('$counter', style: Theme.of(context).textTheme.headline4),
],
),
),
floatingActionButton: FloatingActionButton(
/// We call the [use] extension method to get a business logic component/// and call the appropriate method.
onPressed: () => context.use(counterViewLogicRef).increment(),
tooltip: 'Increment',
child: constIcon(Icons.add),
),
);
}
}
This is all you need to know for a basic usage.
Note: The whole code for the above snippets is available in the example file.
Intermediate usage
Select
A state can be of a simple type as an int or a String but it can also be more complex, such as the following:
Some views of an application are only interested in some parts of the global state. In these cases, it can be more efficient to select only the part of the state that is useful for these views.
For example, if we have an app bar title which is only responsible for displaying the full name of a User, and we don't want it to rebuild every time the score changes, we will use the select method of the StateRef to watch only a sub part of the state:
If you want to rebuild only a part of your widget tree and don't want to create a new widget, you can use the Consumer widget.
This widget can take a watchable (a StateRef or even a selected state of a StateRef).
If you want to trigger an asynchronous data load of a logic, from the widget side, LogicLoader is the widget you need!
To use it, you have to implement the Loadable interface in the logic which needs to load data.
Then you'll have to override the load method and fetch the data inside it.
final usersRef = StateRef(const <User>[]);
final loadingRef = StateRef(false);
final usersLogicRef = LogicRef((scope) => UsersLogic(scope));
classUsersLogicwithLogicimplementsLoadable{
constUsersLogic(this.scope);
@overridefinalScope scope;
UsersRepositoryget _usersRepository => use(usersRepositoryRef);
@overrideFuture<void> load() async {
write(loadingRef, true);
final users = await _usersRepository.fetchAll();
write(usersRef, users);
write(loadingRef, false);
}
}
From the widget side, you'll have to use the LogicLoader and provide it the logic references you want to load:
You can watch the state in a subtree to display a progress indicator when the data is fetching:
classUsersViewextendsStatelessWidget{
constUsersView({
Key? key,
}) : super(key: key);
@overrideWidgetbuild(BuildContext context) {
final loading = context.watch(loadingRef);
if (loading) {
returnconstCircularProgressIndicator();
}
// Display the users in a list when have been fetched.final users = context.watch(usersRef);
returnListView(...);
}
}
Alternatively, you can use the builder parameter to achieve the same goal:
classHomeextendsStatelessWidget{
constHome({
Key? key,
}) : super(key: key);
@overrideWidgetbuild(BuildContext context) {
returnLogicLoader(
refs: [usersLogicRef],
builder: (context, loading, child) {
if (loading) {
returnconstCircularProgressIndicator();
}
// Display the users in a list when have been fetched.final users = context.watch(usersRef);
returnListView();
},
);
}
}
Overrides
It can be useful to be able to override the initial state of StateRef or the factory of LogicRef in some conditions:
When we want a subtree to have its own state/logic under the same reference.
For mocking values in tests.
Reusing a reference under a different scope.
Let's say we want to create an app where a user can create counters and see the sum of all counters:
We could do this by having a global state being a list of integers, and a business logic component for adding counters and increment them:
We can then use the select extension method in a widget to watch the sum of this list:
final sum = context.watch(countersRef.select(
(counters) => counters.fold<int>(0, (a, b) => a + b),
));
Now, for creating the counter view, we can have an index parameter in the constructor of this view.
This has some drawbacks:
If a child widget needs to access this index, we would need to pass the index for every widget down the tree, up to our child.
We cannot use the const keyword anymore.
A better approach would be to create a BinderScope above each counter widget. We would then configure this BinderScope to override the state of a StateRef for its descendants, with a different initial value.
Any StateRef or LogicRef can be overriden in a BinderScope. When looking for the current state, a descendant will get the state of the first reference overriden in a BinderScope until the root BinderScope.
This can be written like this:
final indexRef = StateRef(0);
classHomeViewextendsStatelessWidget{
@overrideWidgetbuild(BuildContext context) {
final countersCount =
context.watch(countersRef.select((counters) => counters.length));
returnScaffold(
...
child: GridView(
...
children: [
for (int i = 0; i < countersCount; i++)
BinderScope(
overrides: [indexRef.overrideWith(i)],
child: constCounterView(),
),
],
),
...
);
}
}
The BinderScope constructor has an overrides parameter which can be supplied from an overrideWith method on StateRef and LogicRef instances.
Note: The whole code for the above snippets is available in the example file.
Mocking values in tests
Let's say you have an api client in your app:
final apiClientRef = LogicRef((scope) => ApiClient());
If you want to provide a mock instead, while testing, you can do:
testWidgets('Test your view by mocking the api client', (tester) async {
final mockApiClient = MockApiClient();
// Build our app and trigger a frame.await tester.pumpWidget(
BinderScope(
overrides: [apiClientRef.overrideWith((scope) => mockApiClient)],
child: constMyApp(),
),
);
expect(...);
});
Whenever the apiClientRef is used in your app, the MockApiClient instance will be used instead of the real one.
Advanced usage
Computed
You may encounter a situation where different widgets are interested in a derived state which is computed from different sates. In this situation it can be helpful to have a way to define this derived state globally, so that you don't have to copy/paste this logic across your widgets.
Binder comes with a Computed class to help you with that use case.
Let's say you have a list of products referenced by productsRef, each product has a price, and you can filter these products according to a price range (referenced by minPriceRef and maxPriceRef).
You could then define the following Computed instance:
final filteredProductsRef = Computed((watch) {
final products = watch(productsRef);
final minPrice = watch(minPriceRef);
final maxPrice = watch(maxPriceRef);
return products
.where((p) => p.price >= minPrice && p.price <= maxPrice)
.toList();
});
Like StateRef you can watch a Computed in the build method of a widget:
@overrideWidgetbuild(BuildContext context) {
final filteredProducts = context.watch(filteredProductsRef);
...
// Do something with `filteredProducts`.
}
Note: The whole code for the above snippets is available in the example file.
Observers
You may want to observe when the state changed and do some action accordingly (for example, logging state changes).
To do so, you'll need to implement the StateObserver interface (or use a DelegatingStateObserver) and provide an instance to the observers parameter of the BinderScope constructor.
bool onStateUpdated<T>(StateRef<T> ref, T oldState, T newState, Object action) {
logs.add(
'[${ref.key.name}#$action] changed from $oldState to $newState',
);
// Indicates whether this observer handled the changes.// If true, then other observers are not called.returntrue;
}
...
BinderScope(
observers: [DelegatingStateObserver(onStateUpdated)],
child: constSubTree(),
);
Undo/Redo
Binder comes with a built-in way to move in the timeline of the state changes.
To be able to undo/redo a state change, you must add a MementoScope in your tree.
The MementoScope will be able to observe all changes made below it:
Then, in a business logic, stored below the MementoScope, you will be able to call undo/redo methods.
Note: You will get an AssertionError at runtime if you don't provide a MementoScope above the business logic calling undo/redo.
Disposable
In some situation, you'll want to do some action before the BinderScope hosting a business logic component, is disposed. To have the chance to do this, your logic will need to implement the Disposable interface.
classMyLogicwithLogicimplementsDisposable{
voiddispose(){
// Do some stuff before this logic go away.
}
}
StateListener
If you want to navigate to another screen or show a dialog when a state change, you can use the StateListener widget.
For example, in an authentication view, you may want to show an alert dialog when the authentication failed.
To do it, in the logic component you could set a state indicating whether the authentication succeeded or not, and have a StateListener in your view do respond to these state changes:
In the above snippet, each time the state referenced by authenticationResultRef changes, the onStateChanged callback is fired. In this callback we simply verify the type of the state to determine whether we have to show an alert dialog or not.
DartDev Tools
Binder wants to simplify the debugging of your app. By using the DartDev tools, you will be able to inspect the current states hosted by any BinderScope.
Snippets
You can find code snippets for vscode at snippets.
Sponsoring
I'm working on my packages on my free-time, but I don't have as much time as I would. If this package or any other package I created is helping you, please consider to sponsor me so that I can take time to read the issues, fix bugs, merge pull requests and add features to these packages.
Contributions
Feel free to contribute to this project.
If you find a bug or want a feature, but don't know how to fix/implement it, please fill an issue.
If you fixed a bug or implemented a feature, please send a pull request.