Разработка приложения на Flutter с нуля до релиза: Идея + Базовая инфраструктура

от автора

Привет! В данном цикле статей я хотел бы показать, как может происходить создание приложений с использованием Flutter. Я использую данную технологию в работе, а также своих собственных проектах на постоянной основе. У меня есть несколько Open Source решений (популярных и не очень), которые будут применены и в данном приложении (не ради галочки, а в качестве решения возникающих проблем). В процессе работы над этим приложением я затрону почти все аспекты разработки с Flutter, за исключением явного взаимодействия с нативной частью (когда нативный код придется писать самому), но если у вас будет желание увидеть и это — то прошу в комментарии. Ну и самое главное — верхнеуровневая идея приложения у меня в голове уже есть, и код на эту статью и следующую уже написан, но если у вас будут возникать идеи, которые можно было бы реализовать в данном приложении в рамках закона коридора первоначальной идеи — прошу высказывать их в комментариях.

Ссылки на статьи цикла

Часть 1: Идея + Базовая инфраструктура

Часть 2: Сеть, локализация, локальный поиск, главный экран

Идея приложения

Изначально я не знал, о чем будет это приложение, когда у меня появилась идея написать эти статьи. Все что я хотел — показать весь процесс от начала и до конца, а также, чего греха таить? — показать использование своих 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 репозитория по ссылке выше.


ссылка на оригинал статьи https://habr.com/ru/articles/594963/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *