How to Create a Flutter Architecture from Scratch: The Final Part

How to Create a Flutter Architecture from Scratch: The Final Part

We are happy to share the final article in our Flutter architecture series. If you missed the first part, you can find it here and the second part is here .

In this piece, Eugene Efanov, a mobile development specialist at Red Collar, describes how to structure our architecture into layers and test the logic we've created. So let's roll into it!

Architecture components

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

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();
  }
}        

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.

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();
  }
}        

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();
  }
}        

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) {
    // ...
  }
}
        

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);
    });
  });
}
        

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!

Subscribe to our newsletter!

?? Get an estimate of your digital idea ?? [email protected]

?? Website | LinkedIn | Twitter | Instagram | Behance | Portfolio

要查看或添加评论,请登录