Игра Яндекс Практикума
Игра Яндекс Практикума
Игра Яндекс Практикума

Архитектура для Flutter без привязки к BuildContext и сторонних библиотек

Как создать архитектуру для Flutter без привязки к BuildContext и сторонних библиотек? Подробно рассказываем в статье Евгения Ефанова, мобильного разработчика в Red Collar.

177 открытий2К показов
Архитектура для Flutter без привязки к BuildContext и сторонних библиотек

Введение

Всем привет! Меня зовут Евгений Ефанов, я мобильный разработчик в Red Collar. 

На протяжении последних пяти лет я занимаюсь разработкой приложений на Flutter. За это время я успел поработать над несколькими большими проектами, включая мессенджеры и видео-сервисы, где занимался архитектурой.

Когда я начинал, существовало несколько архитектурных подходов, среди которых можно выделить BLoC и Redux. Позднее появился Provider, а также ряд других, менее распространённых архитектур.

Эти библиотеки были относительно небольшими, и мне не нравилось их использовать, поскольку в каждой из них отсутствовали многие удобные механизмы, которые были в других библиотеках. Некоторые зависели от BuildContext, что затрудняло тестирование, некоторые требовали слишком много кодогенерации, а библиотеки для подключения зависимостей не поддерживали скоупы и другие привычные функции, что замедляло разработку.

Поэтому, параллельно с работой над одним из проектов, я начал создавать собственный набор утилитарных классов для архитектуры приложения.

В итоге хотелось бы сделать архитектурный компонент, который будет способен реагировать и подписываться на события в системе, а также хранить и предоставлять доступ к определённым данным. Этот компонент должен позволять нам изменять эти данные и отслеживать их изменения (BLoC).

Кроме того, хотелось бы реализовать механизм хранения этих компонентов и обеспечить возможность их подключения друг к другу. Это позволит нам заменять компоненты моками при тестировании. По сути, это будет простой DI-контейнер, разделенный на скоупы.

Также мы хотим иметь доступ к компонентам и их состоянию через глобальный объект во всех частях приложения аналогично тому, как это реализовано в Redux. 

При этом, в отличие от существующих подходов, получение и использование компонентов должно осуществляться без использования BuildContext.

И наконец по аналогии с виджетами хотелось бы иметь простой цикл жизни компонента: инициализация и деинициализация. 

Суммарно это бы заключало все механизмы из существующих библиотек, которые, на мой взгляд, были самыми удобными. Используя такой компонент, можно разделить бизнес-логику проекта на составляющие с заменяемыми зависимостями, что позволит гарантировать полное покрытие unit-тестами. События в системе позволят предотвратить излишнее количество связей, поэтому мы сможем минимизировать количество багов в конечном коде проектов.

Архитектура для Flutter без привязки к BuildContext и сторонних библиотек 1

Дополнительным преимуществом является то, что описанный подход может быть применён к любой из платформ, позволяя использовать идентичные механизмы как во Flutter, так и в SwiftUI и Compose. Это облегчает переключение между платформами, так как мы используем одинаковые компоненты бизнес логики. В следующих статьях я расскажу, как реализовать эти механизмы для других платформ.

Сейчас я продолжаю использовать разработанные мной инструменты и решил написать статью о результатах моей работы, которая, возможно, будет полезна и интересна другим разработчикам и поможет им в реализации и понимании типовых механизмов.

В завершение будет информация о том, как провести тестирование всех механизмов, описанных в статье, а также как организовать архитектурные компоненты в соответствии со слоями архитектуры.

Создаем базовый класс

Архитектура для Flutter без привязки к BuildContext и сторонних библиотек 2

Для начала рассмотрим базовый класс MvvmInstance, который станет основой для всех механизмов. В первую очередь определим в нём два ключевых метода: initialize и dispose, аналогично стандартным виджетам. При инициализации также будет передаваться input для данного объекта. Здесь же можно сразу добавить асинхронный метод для инициализации — initializeAsync.

В этом случае мы абстрагируемся от конструктора и берём процесс инициализации объекта под свой контроль. В итоге конструкторы будут стандартными, что позволяет легко генерировать код для создания объектов, а также добавляет возможность асинхронных операций при инициализации. На данный момент мы получаем такой класс:

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

		

Здесь мы также определяем конфигурацию объекта, которая на данный момент включает только один флаг, указывающий, требуется ли асинхронная инициализация для этого объекта. В дальнейшем мы сможем добавить сюда и другие параметры, например, список зависимостей для нашей сущности.

Теперь мы можем сразу подключить наш объект к состоянию какого-либо виджета и связать его жизненный цикл.

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

С такой структурой мы можем начать добавлять в наш класс дополнительные функции, такие как обработка событий, работа с состоянием (стейт) и другие, будучи уверенными, что все ресурсы класса будут инициализированы и освобождены вместе с виджетом или с другим логическим элементом, в котором мы используем наш класс.

Подключим события

События представляют собой один из ключевых элементов во многих архитектурах. Например, во Flutter BloС можно отправлять события и реагировать на них внутри соответствующего блока, обновляя состояние.

Один из наиболее удобных механизмов для отправки событий — это Event Bus. Он, например, активно используется в приложении Signal на платформе Android, что можно увидеть в репозитории с исходным кодом.

Реализовать Event Bus во Flutter очень просто, используя встроенный механизм стримов.

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

Имея такой класс, мы можем подключить ранее созданный MvvmInstance к получению событий. Сам Event Bus при этом является синглтоном и доступен из любого места в коде.

Для того чтобы подписаться и отписаться от событий, мы можем использовать методы initialize и dispose, которые мы описали на первом шаге.

Теперь мы можем подписаться на события внутри нашего класса и отправлять их из любого места в коде.

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

Используя только этот механизм, можно реализовать множество логических процессов внутри приложения, например, поставить лайк какому-либо объекту, обработать ошибку сети и т. д.

Теперь мы можем вызывать экземпляр MvvmInstance как напрямую, так и через событие.

			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
}
		

Подключаем стейт&nbsp;

Далее необходимо хранить в экземпляре некоторые данные, чтобы была возможность их обновлять и получать в рамках архитектурной структуры проекта.

Мне нравится механизм Redux, в котором есть хранилище, и мы можем подписываться на его обновления. Разница лишь в том, что само хранилище будет находиться внутри каждого экземпляра и содержать только те данные, которые нужны для этого элемента. Чтобы реализовать такой механизм, необходимо прежде всего создать объект, который будет хранить значение, которое мы сможем обновлять, а также подписываться на изменения. Здесь снова на помощь приходят встроенные механизмы Dart.

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

		

Здесь мы снова используем broadcast stream и инкапсулируем текущее значение данных.

Имея такой класс, мы можем создать хранилище для данных нашего MvvmInstance.

Стор способен хранить Observable и выдавать обновление значения для конкретного поля 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));
  }
}

		

Стор также инициализируется и уничтожается по вызову одного метода, поэтому мы можем легко подключить его к нашему 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;
}
		

Используя этот подход, можно реализовать дополнительные функции, например, сохранение состояния в SharedPreferences или SecureStorage. Для этого достаточно абстрагировать логику работы с хранилищем и подписаться на обновления состояния экземпляра.

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

Подключаем зависимости

Подключение зависимостей является важным аспектом архитектуры, поскольку позволяет заменять зависимости при написании тестов.

Для реализации этого механизма необходимо создать интерфейс для подключения класса к нашему MvvmInstance, а также сгенерировать код для создания объектов.

Поскольку в нашей реализации мы используем дефолтные конструкторы, мы можем легко сгенерировать код для их создания.

Для хранения экземпляров нам потребуется синглтон, который будет содержать словарь с готовыми экземплярами.

Упрощённая реализация такого класса может выглядеть следующим образом:

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

Здесь мы храним словарь билдеров, которые будем генерировать, а также контейнер уже созданных объектов, чтобы получить их, если инстанс уже создан. 

Словарь объектов также разделим на скоупы, чтобы мы могли разделить наши инстансы. В качестве дефолтных скоупов можно сразу прописать глобальный скоуп, где мы будем хранить синглетоны — они будут инициализироваться сразу при открытии конечного приложения. Так же можно добавить unique скоуп — в таком случае всегда будет создаваться новый экземпляр. Также можно добавить weak скоуп — в этом скоупе будут храниться глобальные объекты, однако в отличие от global скоупа они будут уничтожаться когда все зависимые от них инстансы уничтожены. Остальные скоупы, которые определяет пользователь, будут работать аналогично weak скоупу — когда все зависимые от скоупа экземпляры будут уничтожены, все объекты в скоупе также будут уничтожаться.

			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
}
		

Таким образом, если в нашем словаре ещё не создан нужный нам инстанс, мы его конструируем, вызываем initialize для него и возвращаем инициализированный инстанс. Если же он уже существует, то сразу возвращаем этот объект.

Для генерации словаря билдеров будем использовать source_gen. Мы можем сгенерировать метод, который будет добавлять билдеры в коллекцию и вызвать его при запуске приложения.

Для этого сначала нужно создать аннотации для маркировки наших инстансов и после получить их в генераторе и сгенерировать соответствующий билдер. Для начала можно создать две аннотации — для обычных объектов и для синглтонов, — синглтоны будем автоматически помещать в глобальный скоуп.

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

После того как мы получили словарь объектов, мы можем подключить их к нашему MvvmInstance.

Для этого добавим наши зависимости в конфигурацию инстанса. Мы можем включить в конфигурацию список «коннекторов», которые будут содержать параметры, такие как входные данные и скоуп, из которого нужно взять подключаемую сущность.

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

Зная список зависимостей для нашего MvvmInstance, мы можем также взять их из словаря инстансов при инициализации.

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

Описываем архитектурные сущности

Архитектура для Flutter без привязки к BuildContext и сторонних библиотек 3

Теперь имея класс с подключенными событиями, стейтом и зависимостями мы можем разделить нашу архитектуру на слои.

Каждая сущность будет подключена к событиям

Архитектура для Flutter без привязки к BuildContext и сторонних библиотек 4

В слое domain я выделил interactor — это сущность, которая хранит стейт. Она удобна для изоляции логического компонента, например, для работы со списком постов. Здесь мы можем получать список постов с сервера, а также подписаться на событие лайка на конкретном посте, чтобы обновить коллекцию в состоянии (стейте) объекта.

			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();
  }
}
		
Архитектура для Flutter без привязки к BuildContext и сторонних библиотек 5

Также я выделяю wrapper — этот элемент не хранит состояние (стейт) и служит обёрткой для каких-либо сторонних библиотек. Например, мы можем подключить библиотеку для проверки текущего состояния сети. Это позволяет абстрагироваться от библиотеки и легко заменять ее методы при тестировании.

			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();
  }
}
		
Архитектура для Flutter без привязки к BuildContext и сторонних библиотек 6

На уровне presentation я выделяю view model — по сути, это interactor, входными данными для которого является view, к которому подключена эта view model. Здесь мы можем подключить интеракторы с нужными данными и прописать binding’и, которые нужны для отображения.

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

В итоговом варианте мы получаем следующую структуру для загрузки постов:

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

		

Как все это протестировать

Для того чтобы протестировать полученную логику, мы можем заменить элементы в нашем DI контейнере на заранее созданные, передав тестовые данные в стейт.

В библиотеке так же есть методы для проверки отправки событий и циклических зависимостей у созданных нами сущностей, однако здесь я не буду приводить реализацию — её можно посмотреть в репозитории с кодом.

Ниже представлены примеры, как можно протестировать каждую сущность.

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

		

Вывод

В итоге мы получили набор логических элементов для того, чтобы реализовать каждый слой архитектуры и возможность всё это протестировать. Отдельно стоит отметить, что для внедрения зависимостей и работы с http мы можем применять и другие решения, пользуясь лишь структурой архитектуры.

В своей работе над реальными проектами я активно применяю все компоненты данной архитектуры. Бизнес-логика представляет собой некое количество интеракторов, количество которых даже в крупном проекте позволяет быстро внедрять зависимости в компоненты (например, view models), предотвращая любые возможные зависания при инициализации. Тестировать эти компоненты удобно, поскольку бизнес-логика полностью покрыта unit-тестами.

Благодаря механизму событий снижается связанность между компонентами, а основной глобальный app-компонент приложения дает возможность использования компонентов в любом месте кода, предоставляя свободу реализации функционала без ущерба для его тестируемости, а значит будет меньше багов в итоговом проекте.

Как реализовать аналогичные механизмы на swiftui и compose , я расскажу в следующих статьях.

Следите за новыми постами
Следите за новыми постами по любимым темам
177 открытий2К показов