Разработка приложения на Flutter с нуля до релиза: Идея + Базовая инфраструктура
Привет! В данном цикле статей я хотел бы показать, как может происходить создание приложений с использованием 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 _dependencies = {};
static final Map> _builders = >{};
static String _generateDiCode([String name = '']) {
return '$T$name';
}
static void reg(ValueGetter builder, {String name = '', bool asBuilder = false}) {
final String code = _generateDiCode(name);
if (asBuilder) {
_builders[code] = builder;
} else {
_dependencies[code] = builder();
}
}
static T get({String name = ''}) {
final String code = _generateDiCode(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(() => RootBackButtonDispatcher());
Di.reg>(() => DefaultRouterInformationParser());
Di.reg>(() => 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 {
@override
Future 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: {}, query: {});
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 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 routeSubPaths = routeUri.pathSegments;
if (routeSubPaths.isEmpty) {
return RouteConfiguration.empty(initialPath: route, routeName: Routes.unknown());
}
for (final String routeName in names) {
final List routeNameSubPaths = routeName.split('/').where((String segment) => segment.isNotEmpty).toList();
if (routeNameSubPaths.length != routeSubPaths.length) {
continue;
}
bool isTargetName = true;
final Map 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 with ChangeNotifier, PopNavigatorRouterDelegateMixin {
RootRouterDelegate() : navigatorKey = GlobalKey();
@override
final GlobalKey navigatorKey;
PageBuilder get pageBuilder => Di.get();
final List pages = [];
@override
RouteConfiguration currentConfiguration = RouteConfiguration.empty(initialPath: Routes.root(), routeName: Routes.root());
bool onPopRoute(Route route, dynamic data) {
if (route.didPop(data) == false) {
return false;
}
pages.removeLast();
notifyListeners();
return true;
}
Future mapRouteConfigurationToRouterState(RouteConfiguration configuration) async {
final String name = configuration.routeName;
pages.clear();
if (name == Routes.unknown()) {
// openUnknownView();
Logs.warn('TODO: Open Unknown View');
}
}
@override
Future 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 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;
Для использования алиасов не только для функций необходимо повысить минимальную версию Dart в pubspec.yaml
до >= 2.14.0
.
Заключение
На текущий момент реализована самая базовая логика, которая нужна для дальнейшей разработки бизнес-функционала. Исходный код текущей части можно посмотреть здесь.
P.S. Писать статьи — не код плодить, ко дню опубликования этого текста вся логика, связанная с первым экраном полностью готова, но статья об этом все еще пишется. Её спойлер вы сможете изучить в ветке master
репозитория по ссылке выше.