How to Create a Flutter Architecture from Scratch: The Second Part
This is the second article in our Flutter architecture series. If you missed the first part, you can find it here .
In this piece, Eugene Efanov, a mobile development specialist at Red Collar, shares his insights on connecting events and states, along with other helpful tips. Let's get started!
Connect events
Events are a core element in many architectures. In Flutter BloC, for example, you can transmit events and respond to them within the respective bloc to update the state.
One of the most efficient mechanisms for dispatching events is the Event Bus. This is extensively used in the 'Signal' application on Android, as seen in their source code repository.
Implementing an Event Bus in Flutter is straightforward, thanks to the built-in stream mechanism.
class EventBus {
late final StreamController _streamController;
EventBus._internal() {
_streamController = StreamController.broadcast();
}
static final EventBus _singletonEventBus = EventBus._internal();
static EventBus get instance {
return _singletonEventBus;
}
Stream<T> streamOf<T>() {
return _streamController.stream.where((event) => event is T).map((event) => event as T);
}
Stream streamOfCollection(List<Type> events) {
return _streamController.stream.where((event) => events.contains(event.runtimeType));
}
void send(dynamic event) {
_streamController.add(event);
}
void dispose() {
_streamController.close();
}
}
With this class, we can connect the previously developed MvvmInstance to receive events. The Event Bus, being a singleton, is accessible from anywhere in the code.
For subscribing to and unsubscribing from events, we can use the initialize and dispose methods we outlined earlier.
Now, we can subscribe to events within our class and dispatch them from anywhere in the code.
typedef EventBusSubscriber<T> = void Function(T event);
abstract class EventBusReceiver {
List<EventBusSubscriber> subscribe() => [];
final Map<Type, EventBusSubscriber> _subscribers = {};
StreamSubscription? _eventsSubscription;
@protected
void _subscribeToEvents() {
subscribe();
if (_subscribers.isEmpty) {
return;
}
_eventsSubscription = EventBus.instance.streamOfCollection(_subscribers.keys.toList()).listen((event) {
_subscribers[event.runtimeType]?.call(event);
});
}
@mustCallSuper
void initializeSub() {
_subscribeToEvents();
}
@mustCallSuper
void disposeSub() {
_eventsSubscription?.cancel();
}
@mustCallSuper
EventBusSubscriber on<T>(EventBusSubscriber<T> processor) {
void dynamicProcessor(event) {
processor(event as T);
}
_subscribers[T] = dynamicProcessor;
return dynamicProcessor;
}
}
Using this mechanism alone, we can implement various logical processes within the application, such as liking an object or handling network errors.
Now, we can invoke the MvvmInstance instance both directly and through events.
abstract class MvvmInstanceWithEvents<T> extends EventBusReceiver {}
class TestEvent {
final int value;
TestEvent({required this.value});
}
class TestInstanceWithEvents extends MvvmInstanceWithEvents {
void printValue(int value) {
print('value is $value');
}
@override
List<EventBusSubscriber> subscribe() => [
on<TestEvent>((event) {
printValue(event.value);
}),
];
}
void test() {
final instance = TestInstanceWithEvents();
instance.printValue(1); // prints 1
EventBus.instance.send(TestEvent(value: 2)); // prints 2
}
Connect state
Next, we need to store data in an instance so it can be updated and accessed within the project's architectural structure.
I prefer the Redux mechanism, which features a store we can subscribe to for updates. The difference here is that our storage will be contained within each instance and will only hold data relevant to that particular element. To implement this mechanism, we first need to create an object that stores the value, allows for updates, and supports change subscriptions. Here, Dart's built-in mechanisms are particularly useful.
class ObservableChange<T> {
final T? next;
final T? previous;
ObservableChange(
this.next,
this.previous,
);
}
class Observable<T> {
late StreamController<ObservableChange<T>> _controller;
T? _current;
bool _isDisposed = false;
bool get isDisposed => _isDisposed;
Observable() {
_controller = StreamController<ObservableChange<T>>.broadcast();
}
T? get current => _current;
Stream<ObservableChange<T>> get stream => _controller.stream.asBroadcastStream();
void update(T data) {
final change = ObservableChange(data, _current);
_current = data;
if (!_controller.isClosed) {
_controller.add(change);
}
}
void dispose() {
_controller.close();
_isDisposed = true;
}
}
Once again, we use a broadcast stream to encapsulate the current data value.
With this class, we can create a data store for our MvvmInstance.
This store can hold an Observable and provide updates for specific fields in the state.
typedef StateUpdater<State> = void Function(State state);
typedef StoreMapper<Value, State> = Value Function(State state);
class StoreChange<Value> {
final Value? previous;
final Value next;
StoreChange(
this.previous,
this.next,
);
}
class Store<State> {
late Observable<State> _state;
bool _isDisposed = false;
State get state => _state.current!;
bool get isDisposed => _isDisposed;
Stream<State> get stream => _state.stream.map((event) => event.next!);
void updateState(State update) {
_state.update(update);
}
void initialize(State state) {
_state = Observable<State>();
}
void dispose() {
_state.dispose();
_isDisposed = true;
}
Stream<Value> updates<Value>(StoreMapper<Value, State> mapper) {
return _state.stream.where((element) {
return mapper(element.previous ?? element.next!) != mapper(element.next as State);
}).map((event) => mapper(event.next as State));
}
}
The store is initialized and destroyed with a single method, making it easy to integrate with our MvvmInstance.
领英推荐
abstract class StatefulMvvmInstance<State, Input> extends MvvmInstance<Input> {
late Store<State> _store;
State get state => _store.state;
Stream<Value> updates<Value>(Value Function(State state) mapper) => _store.updates(mapper);
void updateState(State state) {
_store.updateState(state);
}
void initializeStore() {
_store = Store<State>();
_store.initialize(initialState);
}
void disposeStore() {
_store.dispose();
}
@override
void initialize(Input input) {
super.initialize(input);
initializeStore();
}
@override
void dispose() {
super.dispose();
disposeStore();
}
Stream<State> get stateStream => _store.stream;
State get initialState;
}
Using this approach, we can implement additional functions like saving state to SharedPreferences or SecureStorage. By abstracting the storage logic and subscribing to state updates, we can seamlessly integrate these capabilities.
Dependency injection
Dependency injection is crucial in architecture as it allows us to replace dependencies during testing.
To implement this, we need to create an interface for connecting classes to our MvvmInstance and generate code for object creation. Since we use default constructors in our implementation, generating this code is straightforward.
To store instances, we need a singleton that contains a dictionary of ready-to-use instances.
A simplified implementation of such a class might look like this:
mixin SavableStatefulMvvmInstance<State, Input> on StatefulMvvmInstance<State, Input> {
StreamSubscription<State>? _storeSaveSubscription;
Map<String, dynamic> get savedStateObject => {};
@protected
void restoreCachedStateSync() {
if (!stateFullInstanceSettings.isRestores) {
return;
}
final stateFromCacheJsonString = UMvvmApp.cacheGetDelegate(
stateFullInstanceSettings.stateId,
);
if (stateFromCacheJsonString == null || stateFromCacheJsonString.isEmpty) {
return;
}
final restoredMap = json.decode(stateFromCacheJsonString);
onRestore(restoredMap);
}
void onRestore(Map<String, dynamic> savedStateObject) {}
@override
void initializeStore() {
_subscribeToStoreUpdates();
}
void initializeStatefullInstance() {
initializeStore();
restoreCachedStateSync();
}
void _subscribeToStoreUpdates() {
if (!stateFullInstanceSettings.isRestores) {
return;
}
_storeSaveSubscription = _store.stream.listen((_) async {
final stateId = state.runtimeType.toString();
await UMvvmApp.cachePutDelegate(stateId, json.encode(savedStateObject));
});
}
@override
void disposeStore() {
_storeSaveSubscription?.cancel();
}
StateFullInstanceSettings get stateFullInstanceSettings => StateFullInstanceSettings(
stateId: state.runtimeType.toString(),
);
}
Here, we store a dictionary of builders, which we will generate, and a container of already created objects to retrieve instances if they already exist.
We will also divide the dictionary of objects into scopes to separate our instances. As default scopes, we can specify the global scope, where singletons are stored and initialized upon the final application's initialization. We can also add a unique scope where a new instance is always created. Additionally, a weak scope can be added—global objects are stored here, but unlike the global scope, they are destroyed when all dependent instances are destroyed. Other user-defined scopes will function similarly to the weak scope; when all dependent instances are destroyed, all objects in the scope will also be destroyed.
class InstanceCollection {
final container = ScopedContainer<MvvmInstance>();
final builders = HashMap<String, Function>();
static final InstanceCollection _singletonInstanceCollection = InstanceCollection._internal();
static InstanceCollection get instance {
return _singletonInstanceCollection;
}
InstanceCollection._internal();
void addBuilder<Instance extends MvvmInstance>(Function builder) {
final id = Instance.toString();
builders[id] = builder;
}
Instance get<Instance extends MvvmInstance>({
DefaultInputType? params,
int? index,
String scope = BaseScopes.global,
}) {
return getWithParams<Instance, DefaultInputType?>(
params: params,
index: index,
scope: scope,
);
}
Instance getWithParams<Instance extends MvvmInstance, InputState>({
InputState? params,
int? index,
String scope = BaseScopes.global,
}) {
final runtimeType = Instance.toString();
return getInstanceFromCache<Instance>(
runtimeType,
params: params,
index: index,
scopeId: scope,
);
}
void addWithParams<InputState>({
required String type,
InputState? params,
int? index,
String? scope,
}) {
final id = type;
final scopeId = scope ?? BaseScopes.global;
if (container.contains(scopeId, id, index) && index == null) {
return;
}
final builder = builders[id];
final newInstance = builder!() as MvvmInstance;
container.addObjectInScope(
object: newInstance,
type: type,
scopeId: scopeId,
);
if (!newInstance.isInitialized) {
newInstance.initialize(params);
}
}
Instance constructAndInitializeInstance<Instance extends MvvmInstance>(
String id, {
dynamic params,
bool withNoConnections = false,
}) {
final builder = builders[id];
final instance = builder!() as Instance;
instance.initialize(params);
return instance;
}
Instance getInstanceFromCache<Instance extends MvvmInstance>(
String id, {
dynamic params,
int? index,
String scopeId = BaseScopes.global,
bool withoutConnections = false,
}) {
final scope = scopeId;
final instance = container.getObjectInScope(
type: id,
scopeId: scope,
index: index ?? 0,
) as Instance;
if (!instance.isInitialized) {
instance.initialize(params);
}
return instance;
}
}
If the required instance is not yet created in our dictionary, we construct it, call initialize, and return the initialized instance. If it already exists, we simply return the existing object.
You can use source_gen to generate a dictionary of builders.
First, create annotations to mark our instances. Then, retrieve them in the generator and generate the corresponding builder. We can start with two annotations: one for regular objects and one for singletons. Singletons will automatically be placed in the global scope.
class TestInstance1 extends MvvmInstance {}
class TestInstance2 extends MvvmInstance {}
void testInstanceCollection() {
// singleton instance
final singletonInstance = InstanceCollection.instance.get<TestInstance1>(scope: BaseScopes.global);
// weak instance
final weakInstance = InstanceCollection.instance.get<TestInstance2>(scope: BaseScopes.weak);
final weakInstance2 = InstanceCollection.instance.get<TestInstance2>(scope: BaseScopes.weak); // same instance
// unique instance
final uniqueInstance = InstanceCollection.instance.get<TestInstance2>(scope: BaseScopes.unique);
final uniqueInstance2 = InstanceCollection.instance.get<TestInstance2>(scope: BaseScopes.unique); // new instance
}
After obtaining a dictionary of objects, we can connect them to our MvvmInstance.
To do this, we'll add our dependencies to the instance configuration. The configuration will include a list of "connectors," each containing parameters such as input data and the scope from which the connected entity should be retrieved.
class Instance {
final Type inputType;
final bool singleton;
final bool isAsync;
const Instance({
this.inputType = Map<String, dynamic>,
this.singleton = false,
this.isAsync = false,
});
}
const basicInstance = Instance();
const singleton = Instance(singleton: true);
class MainAppGenerator extends GeneratorForAnnotation<MainApp> {
@override
FutureOr<String> generateForAnnotatedElement(
sg.Element element,
ConstantReader annotation,
BuildStep buildStep,
) async {
const className = 'AppGen';
final classBuffer = StringBuffer();
final instanceJsons = Glob('lib/**.mvvm.json');
final jsonData = <Map>[];
await for (final id in buildStep.findAssets(instanceJsons)) {
final json = jsonDecode(await buildStep.readAsString(id));
jsonData.addAll([...json]);
}
// ...
classBuffer
..writeln('@override')
..writeln('void registerInstances() {');
classBuffer.writeln('instances');
for (final element in instances) {
classBuffer.writeln('..addBuilder<${element.name}>(() => ${element.name}())');
}
classBuffer.writeln(';');
// ...
return classBuffer.toString();
}
}
Now, with the list of dependencies for our MvvmInstance, we can retrieve them from the instances dictionary during initialization.
class Connector {
final Type type;
final dynamic input;
final String scope;
final bool isAsync;
const Connector({
required this.type,
this.input,
this.scope = BaseScopes.weak,
this.isAsync = false,
});
}
class DependentMvvmInstanceConfiguration extends MvvmInstanceConfiguration {
const DependentMvvmInstanceConfiguration({
super.isAsync,
this.dependencies = const [],
});
final List<Connector> dependencies;
}
Stay tuned for the final part of our Flutter series!
Subscribe to our newsletter!
?? Get an estimate of your digital idea ?? [email protected]