Привет! В данном цикле статей я хотел бы показать, как может происходить создание приложений с использованием Flutter. Я использую данную технологию в работе, а также своих собственных проектах на постоянной основе. У меня есть несколько Open Source решений (популярных и не очень), которые будут применены и в данном приложении (не ради галочки, а в качестве решения возникающих проблем). В процессе работы над этим приложением я затрону почти все аспекты разработки с Flutter, за исключением явного взаимодействия с нативной частью (когда нативный код придется писать самому), но если у вас будет желание увидеть и это - то прошу в комментарии. Ну и самое главное - верхнеуровневая идея приложения у меня в голове уже есть, и код на эту статью и следующую уже написан, но если у вас будут возникать идеи, которые можно было бы реализовать в данном приложении в рамках закона коридора первоначальной идеи - прошу высказывать их в комментариях.
Ссылки на статьи цикла
Часть 1: Идея + Базовая инфраструктура
Идея приложения
Изначально я не знал, о чем будет это приложение, когда у меня появилась идея написать эти статьи. Все что я хотел - показать весь процесс от начала и до конца, а также, чего греха таить? - показать использование своих Open Source решений на таком, полу-реальном проекте, чтобы иметь возможность ссылаться на него, а не только на примеры в этих пакетах. Собственно, идея пришла совсем недавно - приложение будет отображать котировки в виде списка, а также график каждой из котировок, если перейти в конкретный тикер.
К сожалению, в ходе более чем полуторамесячного, прокрастинационного и крайне непростого срока я решил отказаться от первоначальной идеи реализовывать именно биржевые котировки, так как проанализировав (и даже уже использовав в приложении) множество ресурсов пришел к выводу, что ни один из них не предоставляет все необходимую информацию в виде, пригодном для данного проекта без необходимости его переусложнения и с достаточными ограничениями в бесплатной версии. Поэтому выбор пал на криптовалютные API, благо, с ними все намного лучше.
Для интерфейса я нашел такой макет:
В качестве основы я возьму из этого макета цвета, компоновку экрана и стиль графиков. Экранов, как я пока думаю, будет два.
Первый - список позиций, которые есть на бирже, а также поиск по ним. Основой будет выступать этот фрагмент дизайна:
Единственное - я бегло поискал какое-нибудь API, чтобы доставать картинки тикеров, как они отрисованы тут для SPOT и MSFT, но ничего не нашел, поэтому в моей реализации картинок не будет. Это было одной из, но единственной причиной перехода на крипту. После этого перехода будут и картинки. Но если вы знаете что-то, что позволит решить эту задачу, не прибегая к ручному поиску картинок - прошу поделиться этим (P.S.: я, все таки, нашел определенные ресурсы, позволяющие получить логотипы компаний по тикерам, но, чтобы их использовать - пришлось бы потратить чрезмерно много времени и усилий).
Второй экран - переход на страницу самой позиции. Он будет самым интересным - я планирую реализовать график котировок позиции, отображение текущей цены, и небольшой игровой элемент - две кнопки Up / Down (как в бинарных опционах, только без реальных денег, обмана и для пользы и интереса). Получится такое мини-игровое приложение, где можно будет не только смотреть котировки, но и "играть" - введу счетчик побед и что-нибудь с этим связанное (детально этот аспект я пока не прорабатывал - пишите идеи).
Реализация
Ну вот, идею описал, пора переходить к делу. Исходя из всего вышеописанного я могу описать структуру проекта примерно следующим образом:
/root
/service
/routing
/di
/...
/domain
/main
/dto
/model
/logic
/ui
/position
/dto
/model
/logic
/ui
Начнем мы с реализации сервисного слоя:
DI
Тут на помощь разработчику может придти большое количество различных пакетов, решающих эту задачу - с кодогенерацией и без, с большим количеством бойлерплейта и нет, но мне кажется, что это тривиальная задача, и решить её самостоятельно очень просто. Так и сделаем! Вся логика умещается в двух файлах - сам контейнер, и логика добавления зависимостей в него:
import 'package:flutter/cupertino.dart';
class Di {
static final Map<String, dynamic> _dependencies = <String, dynamic>{};
static final Map<String, ValueGetter<dynamic>> _builders = <String, ValueGetter<dynamic>>{};
static String _generateDiCode<T>([String name = '']) {
return '$T$name';
}
static void reg<T>(ValueGetter<T> builder, {String name = '', bool asBuilder = false}) {
final String code = _generateDiCode<T>(name);
if (asBuilder) {
_builders[code] = builder;
} else {
_dependencies[code] = builder();
}
}
static T get<T>({String name = ''}) {
final String code = _generateDiCode<T>(name);
late T value;
if (!_dependencies.containsKey(code) && !_builders.containsKey(code)) {
throw Exception('Dependency for type $T with code $code not registered');
} else if (_dependencies.containsKey(code)) {
value = _dependencies[code];
} else {
value = _builders[code]!();
}
return value;
}
}
Как и в других решениях мы можем внедрять идентичные сущности, добавляя к ним свои префиксы, и создавать синглтоны, так и постоянно новые инстансы классов.
Второй файл: добавление зависимостей в сам контейнер, чтобы ему было что создавать и возвращать:
import 'package:flutter/cupertino.dart';
import 'package:high_low/service/di/di.dart';
import 'package:high_low/service/routing/default_router_information_parser.dart';
import 'package:high_low/service/routing/page_builder.dart';
import 'package:high_low/service/routing/root_router_delegate.dart';
void initDependencies() {
Di.reg<BackButtonDispatcher>(() => RootBackButtonDispatcher());
Di.reg<RouteInformationParser<Object>>(() => DefaultRouterInformationParser());
Di.reg<RouterDelegate<Object>>(() => RootRouterDelegate());
Di.reg(() => PageBuilder());
}
В будущем, чтобы добавить новые зависимости будет достаточно регистрировать в этой функции их фабрики и все будет работать как надо.
Routing
Второй аспект, один из самых сложных в любом приложении. Я буду использовать подход Navigator 2.0 (вот прекрасная статья о нем, если вы еще не использовали его).
На самом деле все не сильно сложно, и согласно этой схеме
нам нужно реализовать следующие классы:
RouteInformationProvider
RouteInformationParser
RouterDelegate
Router
Их внедрение в контейнер DI я уже проспойлерил, давайте посмотрим что там внутри.
RouteInformationProvider
Представляет собой провайдер дополнительной информации, которая будет добавлена к урлу, по которому осуществляется переход, и передана дальше в RouteInformationParser. В целом, это не обязательный фрагмент логики навигации в нашем случае, поэтому пока его реализация остается под вопросом.
RouteInformationParser
Должен парсить урл, вытаскивать из него нужные параметры, и передавать их дальше - в RouterDelegate. Вот код нашей реализации (на текущий момент):
import 'package:flutter/cupertino.dart';
import 'package:high_low/service/routing/route_configuration.dart';
import 'package:high_low/service/routing/routes.dart';
class DefaultRouterInformationParser extends RouteInformationParser<RouteConfiguration> {
@override
Future<RouteConfiguration> parseRouteInformation(RouteInformation routeInformation) {
return Future.sync(() => Routes.getRouteConfiguration(routeInformation.location ?? Routes.root()));
}
}
Также нам интересен класс RouteConfiguration
, вот он:
import 'package:flutter/cupertino.dart';
import 'package:high_low/service/logs/logs.dart';
import 'package:high_low/service/routing/routes.dart';
import 'package:high_low/service/types/types.dart';
import 'package:json_annotation/json_annotation.dart';
part 'route_configuration.g.dart';
@immutable
@JsonSerializable()
class RouteConfiguration {
const RouteConfiguration({
required this.initialPath,
required this.routeName,
required this.routeParams,
});
const RouteConfiguration.empty({
required this.initialPath,
required this.routeName,
}) : routeParams = const RouteParams(params: <String, String>{}, query: <String, String>{});
factory RouteConfiguration.unknown() => RouteConfiguration.empty(initialPath: Routes.unknown(), routeName: Routes.unknown());
factory RouteConfiguration.fromJson(Json json) => _$RouteConfigurationFromJson(json);
final String initialPath;
final String routeName;
final RouteParams routeParams;
Json toJson() => _$RouteConfigurationToJson(this);
@override
String toString() => prettyJson(toJson());
}
@immutable
@JsonSerializable()
class RouteParams {
const RouteParams({
required this.params,
required this.query,
});
factory RouteParams.fromJson(Json json) => _$RouteParamsFromJson(json);
final Json params;
final Json query;
Json toJson() => _$RouteParamsToJson(this);
}
Тут вы можете заметить появление еще одного пакета - json_annotation
, он нужен для генерации конструкторов и методов классов для сериализации в JSON и десереализации из JSON. Его необходимо устанавливать совместно еще с парочкой:
dependencies:
json_annotation: ^4.3.0
#...
dev_dependencies:
build_runner: ^2.1.4
json_serializable: ^6.0.1
#...
Если же говорить о функциональности самого класса - в него преобразуется любой входящий урл и из него мы будем брать интересующие нас параметры для дальнейшей логики RouterDelegate
. Например для такого входящего deep link flutter run --route="/item/AAPL?interval=day"
мы получим следующий RouteConfiguration
:
{
"initialPath": "/item/AAPL?interval=day",
"routeName": "/item/:itemCode",
"routeParams": {
"params": {
"itemCode": "AAPL"
},
"query": {
"interval": "day"
}
}
}
Происходит это преобразование урла в конфигурацию в методе Routes.getRouteConfiguration(...)
:
import 'package:high_low/service/routing/route_configuration.dart';
typedef RouteParamName = String;
typedef RouteParamValue = String;
const String itemCode = 'itemCode';
abstract class Routes {
static String root() => '/';
static String item(String itemCode) => '/item/$itemCode';
static String unknown() => '/404';
static List<String> names = [
Routes.root(),
Routes.item(':$itemCode'),
Routes.unknown(),
];
static RouteConfiguration getRouteConfiguration(String route) {
if (route == Routes.root()) {
return RouteConfiguration.empty(initialPath: route, routeName: Routes.root());
}
final Uri routeUri = Uri.parse(route);
final List<String> routeSubPaths = routeUri.pathSegments;
if (routeSubPaths.isEmpty) {
return RouteConfiguration.empty(initialPath: route, routeName: Routes.unknown());
}
for (final String routeName in names) {
final List<String> routeNameSubPaths = routeName.split('/').where((String segment) => segment.isNotEmpty).toList();
if (routeNameSubPaths.length != routeSubPaths.length) {
continue;
}
bool isTargetName = true;
final Map<RouteParamName, RouteParamValue> params = {};
for (int i = 0; i < routeSubPaths.length; i++) {
final String routeSubPath = routeSubPaths[i];
final String routeNameSubPath = routeNameSubPaths[i];
final bool isDynamicSubPath = routeNameSubPath.contains(':');
if (routeSubPath != routeNameSubPath && !isDynamicSubPath) {
isTargetName = false;
break;
} else if (isDynamicSubPath) {
params[routeNameSubPath.replaceFirst(':', '')] = routeSubPath;
}
}
if (isTargetName) {
return RouteConfiguration(initialPath: route, routeName: routeName, routeParams: RouteParams(params: params, query: routeUri.queryParameters));
}
}
return RouteConfiguration.empty(initialPath: route, routeName: Routes.unknown());
}
}
Эту логику можно расширить. Например - сейчас этот код не обработает query-параметры массивы, вроде /item/AAPL?interval=month,day
, а на другом способе указания параметров массивов: /item/AAPL?interval=month&interval=day
- Flutter вообще не запускается со следующей ошибкой:
ProcessException: Process exited abnormally:
Starting: Intent { act=android.intent.action.RUN flg=0x20000000 (has extras) }
/system/bin/sh: --ez: inaccessible or not found
Error: Activity not started, unable to resolve Intent { act=android.intent.action.RUN flg=0x30000000 (has extras) }
Command: C:\\Users\\Mikle\\AppData\\Local\\Android\\sdk\\platform-tools\\adb.exe -s emulator-5554 shell am start -a android.intent.action.RUN -f 0x20000000 --ez enable-background-compilation true --ez enable-dart-profiling true --es route /item/AAPL?interval=month&interval=day --ez enable-checked-mode true --ez verify-entry-points true --ez start-paused true com.alphamikle.high_low/com.alphamikle.high_low.MainActivity
В общем - брать за основу этот код можно смело, но под специфичные урлы своего проекта еще нужно будет дорабатывать.
RouterDelegate
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:high_low/domain/main/ui/main_view.dart';
import 'package:high_low/service/di/di.dart';
import 'package:high_low/service/logs/logs.dart';
import 'package:high_low/service/routing/page_builder.dart';
import 'package:high_low/service/routing/route_configuration.dart';
import 'package:high_low/service/routing/routes.dart';
class RootRouterDelegate extends RouterDelegate<RouteConfiguration> with ChangeNotifier, PopNavigatorRouterDelegateMixin<RouteConfiguration> {
RootRouterDelegate() : navigatorKey = GlobalKey();
@override
final GlobalKey<NavigatorState> navigatorKey;
PageBuilder get pageBuilder => Di.get();
final List<Page> pages = [];
@override
RouteConfiguration currentConfiguration = RouteConfiguration.empty(initialPath: Routes.root(), routeName: Routes.root());
bool onPopRoute(Route<dynamic> route, dynamic data) {
if (route.didPop(data) == false) {
return false;
}
pages.removeLast();
notifyListeners();
return true;
}
Future<void> mapRouteConfigurationToRouterState(RouteConfiguration configuration) async {
final String name = configuration.routeName;
pages.clear();
if (name == Routes.unknown()) {
// openUnknownView();
Logs.warn('TODO: Open Unknown View');
}
}
@override
Future<void> setNewRoutePath(RouteConfiguration configuration) async {
Logs.debug('setNewRoutePath: $configuration');
currentConfiguration = configuration;
await mapRouteConfigurationToRouterState(configuration);
notifyListeners();
}
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
pageBuilder.buildUnAnimatedPage(const MainView(), name: Routes.root()),
...pages,
],
onPopPage: onPopRoute,
);
}
}
Это только основа делегата, но из интересного тут - метод mapRouteConfigurationToRouterState
, который вызывается из метода setNewRoutePath
- который, в свою очередь, и обрабатывает конфигурации роутинга, поступающие сюда из RouteInformationParser
. В будущем мы будем писать здесь методы навигации.
Logging
Последний пункт - логирование. Тут все совсем просто - я сделал небольшую обертку поверх библиотеки logging, которая, как по мне - дает одни из лучших возможностей по логированию. Теперь мы можем передавать любые аргументы в методы логирования.
import 'dart:convert';
import 'package:logger/logger.dart' as logger;
String _getJoinedArguments(dynamic p1, [dynamic p2, dynamic p3]) {
String result = p1.toString();
result += p2 == null ? '' : ' ${p2.toString()}';
result += p3 == null ? '' : ' ${p3.toString()}';
return result;
}
String prettyJson(Map<String, dynamic> json) {
const JsonEncoder jsonEncoder = JsonEncoder.withIndent(' ');
return jsonEncoder.convert(json);
}
final _logger = logger.Logger(
printer: logger.PrefixPrinter(
logger.PrettyPrinter(
colors: true,
printEmojis: false,
methodCount: 0,
errorMethodCount: 3,
stackTraceBeginIndex: 0,
),
),
);
abstract class Logs {
static void debug(dynamic p1, [dynamic p2, dynamic p3]) {
_logger.d(_getJoinedArguments(p1, p2, p3));
}
static void info(dynamic p1, [dynamic p2, dynamic p3]) {
_logger.i(_getJoinedArguments(p1, p2, p3));
}
static void warn(dynamic p1, [dynamic p2, dynamic p3]) {
_logger.w(_getJoinedArguments(p1, p2, p3));
}
static void error(dynamic p1, [dynamic p2, dynamic p3]) {
_logger.e(_getJoinedArguments(p1, p2, p3));
}
static void fatal(dynamic p1, [dynamic p2, dynamic p3]) {
_logger.wtf(_getJoinedArguments(p1, p2, p3));
}
static void trace(dynamic p1, [dynamic p2, dynamic p3]) {
_logger.v(_getJoinedArguments(p1, p2, p3));
}
static void pad(dynamic p1, [dynamic p2, dynamic p3]) {
print(_getJoinedArguments(p1, p2, p3));
}
}
Другое
Еще вы могли заметить тип Json
- это алиас, располагаемый в файле types.dart
. В этот файл мы будем писать и другие алиасы, которые будут использоваться в приложении:
typedef Json = Map<String, dynamic>;
Для использования алиасов не только для функций необходимо повысить минимальную версию Dart в pubspec.yaml
до >= 2.14.0
.
Заключение
На текущий момент реализована самая базовая логика, которая нужна для дальнейшей разработки бизнес-функционала. Исходный код текущей части можно посмотреть здесь.
P.S. Писать статьи - не код плодить, ко дню опубликования этого текста вся логика, связанная с первым экраном полностью готова, но статья об этом все еще пишется. Её спойлер вы сможете изучить в ветке master
репозитория по ссылке выше.
Комментарии (5)
kksudo
12.12.2021 15:19Шикарный гайд, от идеи к реализации )
> использование своих Open Source решений на таком, полу-реальном проекте
А почему не захотели сделать реальный проект с публикацией в app store ?alphamikle Автор
12.12.2021 17:51Я думаю, он дойдет до публикации в стопах, пока все ещё в процессе написания
Slavenin999
13.12.2021 19:48Просто интересно. Зачем свой велосипед для DI? Почему бы просто не взять get_it?
alphamikle Автор
13.12.2021 23:41Нет каких-то особых причин, кроме желания написать велосипед. Хотя можно было бы и обосновать это тем, что написав 50 строк кода я избавился от одной зависимости.
comerc
Жирный плюс в карму! :) Логотипы по тикерам тут https://storage.googleapis.com/iexcloud-hl37opg/api/logos/TSLA.png