Как создать архитектуру для Flutter без привязки к BuildContext и сторонних библиотек? Подробно рассказываем в статье Евгения Ефанова, мобильного разработчика в Red Collar.
177 открытий2К показов
Введение
Всем привет! Меня зовут Евгений Ефанов, я мобильный разработчик в Red Collar.
На протяжении последних пяти лет я занимаюсь разработкой приложений на Flutter. За это время я успел поработать над несколькими большими проектами, включая мессенджеры и видео-сервисы, где занимался архитектурой.
Когда я начинал, существовало несколько архитектурных подходов, среди которых можно выделить BLoC и Redux. Позднее появился Provider, а также ряд других, менее распространённых архитектур.
Эти библиотеки были относительно небольшими, и мне не нравилось их использовать, поскольку в каждой из них отсутствовали многие удобные механизмы, которые были в других библиотеках. Некоторые зависели от BuildContext, что затрудняло тестирование, некоторые требовали слишком много кодогенерации, а библиотеки для подключения зависимостей не поддерживали скоупы и другие привычные функции, что замедляло разработку.
Поэтому, параллельно с работой над одним из проектов, я начал создавать собственный набор утилитарных классов для архитектуры приложения.
В итоге хотелось бы сделать архитектурный компонент, который будет способен реагировать и подписываться на события в системе, а также хранить и предоставлять доступ к определённым данным. Этот компонент должен позволять нам изменять эти данные и отслеживать их изменения (BLoC).
Кроме того, хотелось бы реализовать механизм хранения этих компонентов и обеспечить возможность их подключения друг к другу. Это позволит нам заменять компоненты моками при тестировании. По сути, это будет простой DI-контейнер, разделенный на скоупы.
Также мы хотим иметь доступ к компонентам и их состоянию через глобальный объект во всех частях приложения аналогично тому, как это реализовано в Redux.
При этом, в отличие от существующих подходов, получение и использование компонентов должно осуществляться без использования BuildContext.
И наконец по аналогии с виджетами хотелось бы иметь простой цикл жизни компонента: инициализация и деинициализация.
Суммарно это бы заключало все механизмы из существующих библиотек, которые, на мой взгляд, были самыми удобными. Используя такой компонент, можно разделить бизнес-логику проекта на составляющие с заменяемыми зависимостями, что позволит гарантировать полное покрытие unit-тестами. События в системе позволят предотвратить излишнее количество связей, поэтому мы сможем минимизировать количество багов в конечном коде проектов.
Дополнительным преимуществом является то, что описанный подход может быть применён к любой из платформ, позволяя использовать идентичные механизмы как во Flutter, так и в SwiftUI и Compose. Это облегчает переключение между платформами, так как мы используем одинаковые компоненты бизнес логики. В следующих статьях я расскажу, как реализовать эти механизмы для других платформ.
Сейчас я продолжаю использовать разработанные мной инструменты и решил написать статью о результатах моей работы, которая, возможно, будет полезна и интересна другим разработчикам и поможет им в реализации и понимании типовых механизмов.
В завершение будет информация о том, как провести тестирование всех механизмов, описанных в статье, а также как организовать архитектурные компоненты в соответствии со слоями архитектуры.
Создаем базовый класс
Для начала рассмотрим базовый класс 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;
}
}
}
Здесь мы также определяем конфигурацию объекта, которая на данный момент включает только один флаг, указывающий, требуется ли асинхронная инициализация для этого объекта. В дальнейшем мы сможем добавить сюда и другие параметры, например, список зависимостей для нашей сущности.
Теперь мы можем сразу подключить наш объект к состоянию какого-либо виджета и связать его жизненный цикл.
С такой структурой мы можем начать добавлять в наш класс дополнительные функции, такие как обработка событий, работа с состоянием (стейт) и другие, будучи уверенными, что все ресурсы класса будут инициализированы и освобождены вместе с виджетом или с другим логическим элементом, в котором мы используем наш класс.
Подключим события
События представляют собой один из ключевых элементов во многих архитектурах. Например, во 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, которые мы описали на первом шаге.
Теперь мы можем подписаться на события внутри нашего класса и отправлять их из любого места в коде.
Используя только этот механизм, можно реализовать множество логических процессов внутри приложения, например, поставить лайк какому-либо объекту, обработать ошибку сети и т. д.
Теперь мы можем вызывать экземпляр 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
}
Подключаем стейт
Далее необходимо хранить в экземпляре некоторые данные, чтобы была возможность их обновлять и получать в рамках архитектурной структуры проекта.
Мне нравится механизм 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. Для этого достаточно абстрагировать логику работы с хранилищем и подписаться на обновления состояния экземпляра.
Подключение зависимостей является важным аспектом архитектуры, поскольку позволяет заменять зависимости при написании тестов.
Для реализации этого механизма необходимо создать интерфейс для подключения класса к нашему 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, мы можем также взять их из словаря инстансов при инициализации.
Теперь имея класс с подключенными событиями, стейтом и зависимостями мы можем разделить нашу архитектуру на слои.
Каждая сущность будет подключена к событиям
В слое domain я выделил interactor — это сущность, которая хранит стейт. Она удобна для изоляции логического компонента, например, для работы со списком постов. Здесь мы можем получать список постов с сервера, а также подписаться на событие лайка на конкретном посте, чтобы обновить коллекцию в состоянии (стейте) объекта.
Также я выделяю wrapper — этот элемент не хранит состояние (стейт) и служит обёрткой для каких-либо сторонних библиотек. Например, мы можем подключить библиотеку для проверки текущего состояния сети. Это позволяет абстрагироваться от библиотеки и легко заменять ее методы при тестировании.
На уровне presentation я выделяю view model — по сути, это interactor, входными данными для которого является view, к которому подключена эта view model. Здесь мы можем подключить интеракторы с нужными данными и прописать binding’и, которые нужны для отображения.
В итоговом варианте мы получаем следующую структуру для загрузки постов:
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 контейнере на заранее созданные, передав тестовые данные в стейт.
В библиотеке так же есть методы для проверки отправки событий и циклических зависимостей у созданных нами сущностей, однако здесь я не буду приводить реализацию — её можно посмотреть в репозитории с кодом.
Ниже представлены примеры, как можно протестировать каждую сущность.
В итоге мы получили набор логических элементов для того, чтобы реализовать каждый слой архитектуры и возможность всё это протестировать. Отдельно стоит отметить, что для внедрения зависимостей и работы с http мы можем применять и другие решения, пользуясь лишь структурой архитектуры.
В своей работе над реальными проектами я активно применяю все компоненты данной архитектуры. Бизнес-логика представляет собой некое количество интеракторов, количество которых даже в крупном проекте позволяет быстро внедрять зависимости в компоненты (например, view models), предотвращая любые возможные зависания при инициализации. Тестировать эти компоненты удобно, поскольку бизнес-логика полностью покрыта unit-тестами.
Благодаря механизму событий снижается связанность между компонентами, а основной глобальный app-компонент приложения дает возможность использования компонентов в любом месте кода, предоставляя свободу реализации функционала без ущерба для его тестируемости, а значит будет меньше багов в итоговом проекте.
Как реализовать аналогичные механизмы на swiftui и compose , я расскажу в следующих статьях.
Рассмотрим 10 распространённых ошибок в архитектуре, способных негативно повлиять на проект. Узнаем о ключевых рисках и даем рекомендации по их предотвращению, чтобы сделать разработку надежной и эффективной.
Лучшие онлайн-курсы по Flutter. Список школ, осуществляющих обучение на бесплатной или платной основе, а так же цены на курсы для Flutter-разработчиков