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

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

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

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

Изначально я не знал, о чем будет это приложение, когда у меня появилась идея написать эти статьи. Все что я хотел -- показать весь процесс от начала и до конца, а также, чего греха таить? -- показать использование своих Open Source решений на таком, полу-реальном проекте, чтобы иметь возможность ссылаться на него, а не только на примеры в этих пакетах. Собственно, идея пришла совсем недавно -- приложение будет отображать котировки в виде списка, а также график каждой из котировок, если перейти в конкретный тикер.

К сожалению, в ходе более чем полуторамесячного, прокрастинационного и крайне непростого срока я решил отказаться от первоначальной идеи реализовывать именно биржевые котировки, так как проанализировав (и даже уже использовав в приложении) множество ресурсов пришел к выводу, что ни один из них не предоставляет все необходимую информацию в виде, пригодном для данного проекта без необходимости его переусложнения и с достаточными ограничениями в бесплатной версии. Поэтому выбор пал на криптовалютные API, благо, с ними все намного лучше.

Для интерфейса я нашел такой макет:

17e35c5b02dd2ccd8d6468b005cb2f78_1-1801-600d65.png

В качестве основы я возьму из этого макета цвета, компоновку экрана и стиль графиков. Экранов, как я пока думаю, будет два.

Первый -- список позиций, которые есть на бирже, а также поиск по ним. Основой будет выступать этот фрагмент дизайна:

ea3322ae35bef701dedcc6750131a9df_1-1801-36b500.png

Второй экран -- переход на страницу самой позиции. Он будет самым интересным -- я планирую реализовать график котировок позиции, отображение текущей цены, и небольшой игровой элемент -- две кнопки Up / Down (как в бинарных опционах, только без реальных денег, обмана и для пользы и интереса). Получится такое мини-игровое приложение, где можно будет не только смотреть котировки, но и "играть" -- введу счетчик побед и что-нибудь с этим связанное (детально этот аспект я пока не прорабатывал -- пишите идеи).

4936fce1b3efba644f662f57927cab5e_1-1801-455095.png

Реализация

Ну вот, идею описал, пора переходить к делу. Исходя из всего вышеописанного я могу описать структуру проекта примерно следующим образом:

/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.

На самом деле все не сильно сложно, и согласно этой схеме:

91659100c0b8792f454dae5208e56c19_1-1801-edfcd0.png

нам нужно реализовать следующие классы:

  • 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.

Заключение

На текущий момент реализована самая базовая логика, которая нужна для дальнейшей разработки бизнес-функционала. Исходный код текущей части можно посмотреть здесь.

Продолжение здесь.

Больше материалов смотрите в моем блоге на Хабре.

Не пропустите новые полезные статьи!

Спасибо за подписку!

Мы отправили вам письмо для подтверждения вашего email.
С уважением, OTUS!

Автор
0 комментариев
Для комментирования необходимо авторизоваться
Популярное
Сегодня тут пусто