DEV Community

Red Collar
Red Collar

Posted on

How to Create a Flutter Architecture from Scratch

Image description

Introduction

Hi! I’m Eugene Efanov and over the past five years, I’ve specialized in developing applications using Flutter. Initially, the landscape was populated with several popular architecture libraries like BloC, Redux, and Provider.

However, these libraries were relatively small and lacked many useful mechanics found in other libraries, which made them less appealing to me.

During one particular project, I started developing my own set of utility classes to enhance the architecture.

My ultimate goal is to create a component capable of responding to events, storing specific data, and providing access to it. This component should allow us to modify stored data and track changes, similar to BloC.

Additionally, I aim to implement a mechanism for storing these components and enabling them to connect with one another. This would allow us to replace real components with mocks during testing, effectively creating a simple DI container.

We also want to access components and their states globally throughout the app, akin to Redux.

Furthermore, deviating from existing methodologies, the retrieval and usage of components should be achieved without relying on BuildContext. This approach ensures comprehensive test coverage for all business logic components.

Finally, similar to widgets, I aim for a straightforward component lifecycle: initialization and deinitialization. This synthesis would combine the most user-friendly and efficient mechanisms from existing libraries.

Image description
An added benefit of this approach is its cross-platform compatibility, enabling the same mechanisms to be used in Flutter, SwiftUI, and Compose. This makes switching between platforms seamless as the same business logic components are utilized. In future articles, I will demonstrate how to implement these mechanisms on different platforms.

After consistently using this method, I felt compelled to share my experiences in an article. I believe it will be both useful and intriguing for other developers, helping them understand and implement these common mechanisms.

Lastly, I will include examples for testing these components and provide tips on organizing them according to architectural layers.
Create base class

Image description
Let’s start by examining the base class, MvvmInstance, which will serve as the foundation for all our mechanisms. We’ll establish two key methods: initialize and dispose, mirroring the lifecycle of standard widgets. Additionally, we’ll incorporate an asynchronous initialization method, initializeAsync, allowing us to pass input to the instance.

By doing this, we abstract away from the constructor and gain control over the object initialization process. This keeps constructors standard and simplifies code generation for object creation while enabling asynchronous operations during initialization. Here is the class we currently have:

class MvvmInstanceConfiguration {
  const MvvmInstanceConfiguration({
    this.isAsync,
  });

  final bool? isAsync;
}

abstract class MvvmInstance<T> {
  bool isInitialized = false;
  bool isDisposed = false;
  late final T input;

  MvvmInstanceConfiguration get configuration => const MvvmInstanceConfiguration();

  bool get isAsync {
    return configuration.isAsync ?? false;
  }

  @mustCallSuper
  void initialize(T input) {
    this.input = input;

    if (!isAsync) {
      isInitialized = true;
    }
  }

  @mustCallSuper
  void dispose() {
    isDisposed = true;
  }

  @mustCallSuper
  Future<void> initializeAsync() async {
    if (isAsync) {
      isInitialized = true;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, we also configure the object with a single flag indicating whether it requires asynchronous initialization. In the future, we can expand this to include additional parameters, such as a dependency list for the instance.

Now, we can seamlessly link our object to any widget’s state and bind it to its lifecycle.

class TestInstance extends MvvmInstance {}

class TestWidget extends StatefulWidget {
  const TestWidget({super.key});

  @override
  State<TestWidget> createState() => _TestWidgetState();
}

class _TestWidgetState extends State<TestWidget> {
  final testInstance = TestInstance();

  @override
  void initState() {
    super.initState();

    testInstance.initialize(1);
  }

  @override
  void dispose() {
    super.dispose();

    testInstance.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}
Enter fullscreen mode Exit fullscreen mode

With this structure in place, we can now integrate additional functions into our class, such as event handling and state management. This ensures that all class resources are initialized and disposed of in sync with the widget or any other business logic component that uses our class.

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

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(),
      );
}
Enter fullscreen mode Exit fullscreen mode

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:

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 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
}
Enter fullscreen mode Exit fullscreen mode

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 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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 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;
}
Enter fullscreen mode Exit fullscreen mode

Now, with the list of dependencies for our MvvmInstance, we can retrieve them from the instances dictionary during initialization.

mixin DependentMvvmInstance<Input> on MvvmInstance<Input> {
  final _instances = HashMap<Type, List<MvvmInstance?>>();

  @override
  DependentMvvmInstanceConfiguration get configuration => const DependentMvvmInstanceConfiguration();

  @mustCallSuper
  void initializeDependencies() {
    _addInstancesSync();
  }

  @mustCallSuper
  void disposeDependencies() {
    _disposeUniqueInstances();

    _instances.clear();
  }

  void _addInstancesSync() {
    final connectors = configuration.dependencies;

    connectors.where((element) => !element.isAsync).forEach((element) {
      if (element.scope == BaseScopes.unique) {
        _instances[element.type] = [_getUniqueInstance(element)];
      } else {
        _instances[element.type] = [_getInstance(element)];
      }
    });
  }

  dynamic _getInstance(Connector connector) {
    return InstanceCollection.instance.getByTypeStringWithParams(
      type: connector.type.toString(),
      params: connector.input,
      scope: connector.scope,
    );
  }

  dynamic _getUniqueInstance(Connector connector) {
    return InstanceCollection.instance.getUniqueByTypeStringWithParams(
      type: connector.type.toString(),
      params: connector.input,
    );
  }

  /// Disposes unique instances
  void _disposeUniqueInstances() {
    for (final element in configuration.dependencies) {
      if (element.scope != BaseScopes.unique) {
        continue;
      }

      _instances[element.type]?.forEach((element) {
        element?.dispose();
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Architecture components

Image description
Now, with a class that handles events, state, and dependencies, we can structure our architecture into layers.

Image description
Each MvvmInstance is connected to events

In the domain layer, I focus on the interactor, an entity that holds the state. This entity is useful for isolating logical components, such as managing a list of posts. Here, we can retrieve posts from the server and subscribe to like events on specific posts to update the collection in the object’s state.

abstract class BaseInteractor<State, Input> extends MvvmInstance<Input?>
    with StatefulMvvmInstance<State, Input?>, DependentMvvmInstance<Input?> {
  @mustCallSuper
  @override
  void initialize(Input? input) {
    super.initialize(input);

    initializeDependencies();
    initializeStatefullInstance();
  }

  @mustCallSuper
  @override
  void dispose() {
    super.dispose();

    disposeStore();
    disposeDependencies();
  }

  @mustCallSuper
  @override
  Future<void> initializeAsync() async {
    await super.initializeAsync();
  }
}
Enter fullscreen mode Exit fullscreen mode

I also introduce the wrapper — this element doesn’t store state and serves as an interface for third-party libraries. For example, we can use it to check the current network status. This abstraction allows us to easily replace library methods during testing.

Image description

abstract class BaseStaticWrapper<Input> extends MvvmInstance<Input?>
    with DependentMvvmInstance<Input?> {
  /// Inititalizes wrapper
  @mustCallSuper
  @override
  void initialize(Input? input) {
    super.initialize(input);

    initializeDependencies();
  }

  @override
  void dispose() {
    super.dispose();

    disposeDependencies();
  }

  @mustCallSuper
  @override
  Future<void> initializeAsync() async {
    await super.initializeAsync();
  }
}
Enter fullscreen mode Exit fullscreen mode

Image description
At the presentation level, I introduce the view model. Essentially an interactor, its input data is the view it connects to. Here, we can link interactors with the necessary data and set up bindings for display.

abstract class BaseViewModel<Widget extends StatefulWidget, State> extends MvvmInstance<Widget>
    with StatefulMvvmInstance<State, Widget>, DependentMvvmInstance<Widget> {
  void onLaunch() {}

  void onFirstFrame() {}

  @mustCallSuper
  @override
  void initialize(Widget input) {
    super.initialize(input);

    initializeDependencies();
    initializeStatefullInstance();
  }

  @mustCallSuper
  @override
  void dispose() {
    super.dispose();

    disposeStore();
    disposeDependencies();
  }

  @mustCallSuper
  @override
  Future<void> initializeAsync() async {
    await super.initializeAsync();
  }
}
Enter fullscreen mode Exit fullscreen mode

In the final version, our structure for loading posts looks like this:

part 'main.mvvm.dart';
part 'main.mapper.dart';

class PostLikedEvent {
  final int id;

  const PostLikedEvent({
    required this.id,
  });
}

@MappableClass()
class Post with PostMappable {
  const Post({
    required this.title,
    required this.body,
    required this.id,
    this.isLiked = false,
  });

  final String? title;
  final String? body;
  final int? id;
  final bool isLiked;

  static const fromMap = PostMapper.fromMap;
}

@MappableClass()
class PostsState with PostsStateMappable {
  const PostsState({
    this.posts,
    this.active,
  });

  final StatefulData<List<Post>>? posts;
  final bool? active;
}

@mainApi
class Apis with ApisGen {}

@mainApp
class App extends UMvvmApp with AppGen {
  final apis = Apis();

  @override
  Future<void> initialize() async {
    await super.initialize();
  }
}

final app = App();

// ...

@basicInstance
class PostsInteractor extends BaseInteractor<PostsState, Map<String, dynamic>?> {
  Future<void> loadPosts(int offset, int limit, {bool refresh = false}) async {
    updateState(state.copyWith(posts: const LoadingData()));

    late Response<List<Post>> response;

    if (refresh) {
      response = await executeAndCancelOnDispose(
        app.apis.posts.getPosts(0, limit),
      );
    } else {
      response = await executeAndCancelOnDispose(
        app.apis.posts.getPosts(offset, limit),
      );
    }

    if (response.isSuccessful) {
      updateState(
        state.copyWith(posts: SuccessData(result: response.result ?? [])),
      );
    } else {
      updateState(state.copyWith(posts: ErrorData(error: response.error)));
    }
  }

  @override
  List<EventBusSubscriber> subscribe() => [
        on<PostLikedEvent>(
          (event) {
            // update state
          },
        ),
      ];

  @override
  PostsState get initialState => const PostsState();
}

class PostsListViewState {}

class PostsListViewModel extends BaseViewModel<PostsListView, PostsListViewState> {
  @override
  DependentMvvmInstanceConfiguration get configuration => DependentMvvmInstanceConfiguration(
        dependencies: [
          app.connectors.postsInteractorConnector(),
        ],
      );

  late final postsInteractor = getLocalInstance<PostsInteractor>();

  @override
  void onLaunch() {
    postsInteractor.loadPosts(0, 30, refresh: true);
  }

  void like(int id) {
    app.eventBus.send(PostLikedEvent(id: id));
  }

  Stream<StatefulData<List<Post>>?> get postsStream => postsInteractor.updates((state) => state.posts);

  @override
  PostsListViewState get initialState => PostsListViewState();
}

class PostsListView extends BaseWidget {
  const PostsListView({
    super.key,
    super.viewModel,
  });

  @override
  State<StatefulWidget> createState() {
    return _PostsListViewWidgetState();
  }
}

class _PostsListViewWidgetState extends BaseView<PostsListView, PostsListViewState, PostsListViewModel> {
  @override
  Widget buildView(BuildContext context) {
    // ...
  }
} 
Enter fullscreen mode Exit fullscreen mode

Testing

To test the logic we’ve created, we can replace elements in our DI container with pre-created ones and pass test data to the state.

The library also includes methods for checking event dispatching and detecting cyclic dependencies in the entities we’ve created. While I won’t provide the implementation details here, you can view them in the code repository.
Below are examples of how to test each entity.

class MockPostsApi extends PostsApi {
  @override
  HttpRequest<List<Post>> getPosts(int offset, int limit) => super.getPosts(offset, limit)
    ..simulateResult = Response(code: 200, result: [
      Post(
        title: '',
        body: '',
        id: 1,
      )
    ]);
}

void main() {
  test('PostsInteractorTest', () async {
    await initApp(testMode: true);

    app.apis.posts = MockPostsApi();

    final postsInteractor = PostsInteractor();

    postsInteractor.initialize(null);

    await postsInteractor.loadPosts(0, 30);

    expect((postsInteractor.state.posts! as SuccessData).result[0].id, 1);
  });
}

// ...

class PostInteractorMock extends PostInteractor {
  @override
  Future<void> loadPost(int id, {bool refresh = false}) async {
    updateState(state.copyWith(
      post: SuccessData(result: Post(id: 1)),
    ));
  }
}

void main() {
  test('PostViewModelTest', () async {
    await initApp(testMode: true);

    app.registerInstances();
    await app.createSingletons();

    final postInteractor = PostInteractorMock();
    app.instances.addBuilder<PostInteractor>(() => postInteractor);

    final postViewModel = PostViewModel();
    const mockWidget = PostView(id: 1);

    postViewModel
      ..initialize(mockWidget)
      ..onLaunch();

    expect((postViewModel.currentPost as SuccessData).result.id, 1);
  });
}

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('PostsListViewTest', () {
    testWidgets('PostsListViewTest InitialLoadTest', (tester) async {
      await initApp(testMode: true);

      app.registerInstances();
      await app.createSingletons();

      app.apis.posts = MockPostsApi();

      await tester.pumpAndSettle();

      await tester.pumpWidget(const MaterialApp(
        home: Material(child: PostsListView()),
      ));

      await Future.delayed(const Duration(seconds: 3), () {});

      await tester.pumpAndSettle();

      final titleFinder = find.text('TestTitle');

      expect(titleFinder, findsOneWidget);
    });
  });
} 
Enter fullscreen mode Exit fullscreen mode

Conclusion

We’ve created a set of logical components to implement each layer of the architecture, along with the capability to test everything. Notably, for dependency injection and HTTP operations, we can use other solutions, relying solely on the architecture’s structure.

In my work on real projects, I actively use all components of this architecture. Testing these components is convenient because the business logic is fully covered by unit tests.

The business logic consists of a specific number of interactors, allowing quick dependency injection even in large projects, ensuring smooth initialization without any lags.

Thanks to the event mechanism, the coupling between components is reduced. The main global app component allows the use of these components anywhere in the code, providing the freedom to implement functionality without compromising testability.

I will explain how to implement similar mechanisms in SwiftUI and Compose in upcoming articles. Subscribe to our newsletter and enjoy more useful insights!

Top comments (0)