Разработка приложения на Flutter с нуля до релиза: Идея + Базовая инфраструктура
В данном цикле статей я хотел бы показать, как может происходить создание приложений с использованием Flutter. Я использую данную технологию в работе, а также своих собственных проектах на постоянной основе. У меня есть несколько Open Source решений (популярных и не очень), которые будут применены и в данном приложении (не ради галочки, а в качестве решения возникающих проблем). В процессе работы над этим приложением я затрону почти все аспекты разработки с Flutter, за исключением явного взаимодействия с нативной частью (когда нативный код придется писать самому), но если у вас будет желание увидеть и это -- то прошу в комментарии. Ну и самое главное -- верхнеуровневая идея приложения у меня в голове уже есть, и код на эту статью и следующую уже написан, но если у вас будут возникать идеи, которые можно было бы реализовать в данном приложении в рамках закона коридора первоначальной идеи -- прошу высказывать их в комментариях.
Идея приложения
Изначально я не знал, о чем будет это приложение, когда у меня появилась идея написать эти статьи. Все что я хотел -- показать весь процесс от начала и до конца, а также, чего греха таить? -- показать использование своих Open Source решений на таком, полу-реальном проекте, чтобы иметь возможность ссылаться на него, а не только на примеры в этих пакетах. Собственно, идея пришла совсем недавно -- приложение будет отображать котировки в виде списка, а также график каждой из котировок, если перейти в конкретный тикер.
К сожалению, в ходе более чем полуторамесячного, прокрастинационного и крайне непростого срока я решил отказаться от первоначальной идеи реализовывать именно биржевые котировки, так как проанализировав (и даже уже использовав в приложении) множество ресурсов пришел к выводу, что ни один из них не предоставляет все необходимую информацию в виде, пригодном для данного проекта без необходимости его переусложнения и с достаточными ограничениями в бесплатной версии. Поэтому выбор пал на криптовалютные API, благо, с ними все намного лучше.
Для интерфейса я нашел такой макет:
В качестве основы я возьму из этого макета цвета, компоновку экрана и стиль графиков. Экранов, как я пока думаю, будет два.
Первый -- список позиций, которые есть на бирже, а также поиск по ним. Основой будет выступать этот фрагмент дизайна:
Второй экран -- переход на страницу самой позиции. Он будет самым интересным -- я планирую реализовать график котировок позиции, отображение текущей цены, и небольшой игровой элемент -- две кнопки 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" } } }
Происходит это преобразование урла в конфигурацию в методе
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, ); } }
Это только основа делегата, но из интересного тут -- метод
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.
Заключение
На текущий момент реализована самая базовая логика, которая нужна для дальнейшей разработки бизнес-функционала. Исходный код текущей части можно посмотреть здесь.
Продолжение здесь.
Больше материалов смотрите в моем блоге на Хабре.