Flutter: как мы выбирали навигацию для мобильного приложения?

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Flutter вышел в стабильной версии в 2018 году. Все это время он активно развивался: появилась поддержка Null safety, расширились возможности по темизации и локализации приложений, добавилось огромное количество новых виджетов. Одно из таких нововведений – Navigator 2.0, выпущенный Flutter осенью 2020 года. Это гибкий инструмент для решения непростой задачи навигации в мобильных приложениях. Разработчики начали применять Navigator 2.0, но столкнулись с трудностями и проблемами, о которых говорили команде Flutter в официальном репозитории, предлагая упростить использование инструмента. Самым подробным материалом по новому подходу является статья в блоге Flutter, но и ее мало для того, чтобы начать работать с Navigator 2.0 в продакшен-приложениях.

С чего все началось?

Об этом расскажу я, Вова, Flutter-разработчик. Мы с командой делаем банковское мобильное приложение для юридических лиц. Это не единственное приложение Россельхозбанка, для создания которого используется Flutter. Фреймворк уже хорошо себя показал ранее, поэтому было принято решение использовать его и для этого проекта. Сразу обозначим результаты, к которым пришли с помощью своего варианта навигации в мобильном приложении:

  • реализовали 100% хотелок бизнеса;

  • сократили сроки разработки по интеграции одного банковского продукта в другой;

  • повысили эффективность коммуникаций между отдельными командами (кредиты, депозиты, зарплатные проекты и т.д.), разрабатывающими свои разделы в рамках отдельных репозиториев.

Дополнительно решили несколько технических задач:

  • добились того, что навигация поддерживает многослойность, чтобы можно было реализовать текущие и будущие потребности бизнеса по функциональности и дизайну;

  • оставили точки расширения для использования новых методов навигации в дальнейшем, потому что не знаем, какие еще возможности могут появиться в приложении. То есть спустя какое-то время, нам не придется менять технологию, на которой написан продукт;

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

  • реализовали поддержку deeplinks: приложение должно уметь открываться из сторонних источников. Например, пользователь может перейти по ссылке и у него в приложении сразу откроется страница для оплаты счета.

Мы получили опыт, о котором нигде не читали до этого. В этой статье я расскажу, какие варианты по навигации мы рассматривали и как пришли к нашему решению.

Navigator - быстрый старт

Когда в проекте мы дошли до создания навигации, то задали себе вопросы:

  • Какие есть для этого возможности во Flutter?

  • Какой подход выбрать?

  • Какие есть подводные камни у разных вариантов?

  • Нужен ли нам Navigator 2.0?

  • Как все эти подходы соотносятся с нашими бизнес-требованиями?

Естественно, мы вспомнили про Navigator – самое доступное решение во Flutter для навигации, с которым прежде всего знакомятся начинающие разработчики. Navigator – это stateful-виджет, создаваемый внутри MaterialApp/CupertinoApp. State данного виджета содержит текущий стек навигации и предоставляет методы для изменения этого состояния.

Простой переход на новую страницу выглядит следующим образом:

Navigator.push(
	context, 
	MaterialPageRoute(builder: (context) => SecondPage()),
);

В примере используется MaterialPageRoute, который реализует интерфейс Route. Эта сущность связывает Navigator и виджет страницы. Она определяет некоторые визуальные особенности страницы: анимацию появления и удаления, отображение на весь экран или в виде диалога, а также некоторые поведенческие особенности – например, за жест свайпа назад на iOS отвечает Route.

Пример графа навигации мобильного приложения. Источник: pcnews.ru
Пример графа навигации мобильного приложения. Источник: pcnews.ru

Кроме простого push, Navigator предоставляет обширный набор методов, которыми можно изменять стек навигации:

  • push - добавление новой страницы

  • pop - возврат назад (в том числе с возвратом значения)

  • popUntil - возврат назад, пока не выполнится переданное условие

  • pushReplacement - замена текущей страницы на другую

  • pushAndRemoveUntil - добавление новой страницы и удаление из стека навигации предыдущих страниц, пока не выполнится условие.

Есть и другие методы, с которыми можно ознакомиться в официальной документации.

Можно ли расширить Navigator?

Легко заметить, что все эти методы императивные: мы говорим навигатору, как хотим изменить его состояние – добавить или удалить какой-то экран. При этом остальной фреймворк использует декларативный API для построения пользовательского интерфейса. Мы задаем некоторое дерево виджетов, на основе которого всё отображается. Не хотим сказать, что императивный подход для навигации – это плохо, наоборот, в этом нет ничего дурного – просто по-другому.

Мы обращаем ваше внимание на иное – набор методов для навигации и их поведение фиксированные: нельзя добавить свой метод или изменить реализацию имеющегося. Значит Navigator нерасширяемый – это намного важнее того, что он использует другой подход.

Z-index мобильного приложения
Z-index мобильного приложения

Например, для реализации уже не нового, но популярного подхода к навигации с bottom navigation bar придется сделать свою сущность, отвечающую за смену табов. Эта сущность будет совсем отдельно от Navigator, таким образом теряется единая точка входа в навигацию.

Navigator & Deeplinking

Приложение не живет в вакууме, поэтому навигация в нем может происходить и по некоторым внешним событиям. Сейчас почти все приложения используют Deeplinking: пользователь переходит по ссылке, а вместо страницы в браузере у него открывается приложение с нужным разделом. Кроме этого, Google активно продвигает Flutter for web и там тоже нужна навигация. Пункты про deeplinking и web объединены неслучайно – в целом им нужен одинаковый механизм для навигации: надо получить ссылку, взять из нее нужную информацию и отобразить необходимую страницу. Именно такую возможность и дает Navigator. При переходе по ссылке в мобильное приложение или при смене URL в адресной строке в web происходит следующее:

  • у Navigator вызывается pushNamed со ссылкой в качестве параметра;

  • Navigator вызывает onGenerateRoute, чтобы получить из него Route;

  • полученный Route добавляется в стек навигации.

На первый взгляд проблема решена, но есть и нюанс – при переходе по ссылке в стек навигации всегда будет добавлен только один экран, потому что onGenerateRoute может вернуть только один Route. Не предполагается, что deeplink может инициировать добавление сразу нескольких страниц. И ведь кейс довольно частый, например, deeplink такого вида: /users/123/post/456. Как правило, такая ссылка открывает страницу с постом и дает возможность навигации назад на страницу пользователя, который написал этот паблик. Чтобы реализовать такое поведение, придется использовать секретные техники костыль-development :)

Знакомимся с Router (Navigator 2.0)

Прошлой осенью разработчики Flutter выкатили новую версию компонента навигации – Navigator 2.0, который позже переименовали в Router. И это более правильное обозначение, так как Navigator 2.0 вносил путаницу. Казалось, он предназначен, чтобы заменить первый Navigator, что первая версия скоро станет deprecated и нужно срочно переезжать на новую версию. Это не так, Router – альтернативный подход, имеющийся теперь у разработчиков; он дает совершенно другие возможности.

Для начала попробуем разобраться, что такое Router. И первое, что можно найти в официальной документации – схему:

Схема взаимодействия компонентов в Navigator 2.0. Источник: flutter.dev
Схема взаимодействия компонентов в Navigator 2.0. Источник: flutter.dev

Используя эту схему, очень сложно понять, как этим пользоваться, какие компоненты нужно реализовать и что они должны делать. Да и примеров кода, как банально перейти на новый экран, сразу не видно. Попробуем разобраться.

Router - просто о сложном

Все не так уж и сложно, как кажется на первый взгляд. В новом подходе есть три основные сущности:

  • Router

  • RouterDelegate

  • RouteInformationParser

Рассмотрим каждый из них. Router – виджет, который связывает RouterDelegate, RouteInformationParser и пользовательский интерфейс. Нам нужно реализовать RouterDelegate. Он сообщает Router о том, что изменилось состояние навигации. Когда это происходит, Router вызывает build у RouterDelegate, и пользователь видит изменение состояния приложения, то есть открывается новый экран либо происходит переход назад.

RouteInformationParser должен реализовывать два метода:

  • parseRouteInformation – парсит ссылку и возвращает новое состояние навигации (в случае изменения адреса в браузере или перехода по deeplink);

  • restoreRouteInformation – преобразовывает текущее состояние навигации в ссылку, которая отображается в адресной строке браузера. Его необязательно реализовывать в мобильном приложении.

Упрощенная схема взаимодействия компонентов по шагам выглядит так:

Пошаговая схема взаимодействия компонентов при использовании Router
Пошаговая схема взаимодействия компонентов при использовании Router

Вы спросите, а где же методы для изменения состояния навигации, как отобразить новый экран и что это за "update" и "T" на схеме? Чтобы это понять, проще всего привести аналогию со StatefulWidget. Router – это как StatefulWidget, а RouterDelegate – как State. У него также реализован build и он также сообщает об изменениях (как при setState).

Router не дает никаких методов для отображения экрана из коробки, их просто нет. Мы должны сами определить, как выглядит состояние навигации (Т на схеме) и как обновлять это состояние (update на схеме). Таким образом получаем огромную гибкость и возможность подстроить систему навигации под любые бизнес-задачи, но при этом оказываемся один на один с низкоуровневым API, без возможности сразу же начать использовать Router.

Router и полная декларативность = утопия

Разработчики Flutter предлагают использовать Router в полностью декларативном стиле, когда все состояние приложения хранится в рамках некоторого State и на основе него формируется список страниц для отображения. Примерно так:

Возьмем пример из документации, где используется такой декларативный подход, и посмотрим на него:

Код из примера от команды Flutter
class BookRouterDelegate extends RouterDelegate<BookRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {

  Book _selectedBook;
  bool show404 = false;

  List<Book> books = [
    Book('Left Hand of Darkness', 'Ursula K. Le Guin'),
    Book('Too Like the Lightning', 'Ada Palmer'),
    Book('Kindred', 'Octavia E. Butler'),
  ];

  BookRouterDelegate();

  BookRoutePath get currentConfiguration {
    if (show404) {
      return BookRoutePath.unknown();
    }
    return _selectedBook == null
        ? BookRoutePath.home()
        : BookRoutePath.details(books.indexOf(_selectedBook));
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        MaterialPage(
          key: ValueKey('BooksListPage'),
          child: BooksListScreen(
            books: books,
            onTapped: _handleBookTapped,
          ),
        ),
        if (show404)
          MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
        else if (_selectedBook != null)
          BookDetailsPage(book: _selectedBook)
      ],
      onPopPage: (route, result) {
        if (!route.didPop(result)) {
          return false;
        }

        // Update the list of pages by setting _selectedBook to null
        _selectedBook = null;
        show404 = false;
        notifyListeners();

        return true;
      },
    );
  }

  @override
  Future<void> setNewRoutePath(BookRoutePath path) async {
    if (path.isUnknown) {
      _selectedBook = null;
      show404 = true;
      return;
    }

    if (path.isDetailsPage) {
      if (path.id < 0 || path.id > books.length - 1) {
        show404 = true;
        return;
      }

      _selectedBook = books[path.id];
    } else {
      _selectedBook = null;
    }

    show404 = false;
  }

  void _handleBookTapped(Book book) {
    _selectedBook = book;
    notifyListeners();
  }
}

Если кратко, что происходит в примере: у нас есть некоторый State навигации, состоящий из полей _selectedBook и show404. При нажатии на книгу изменяется значение _selectedBook; при переходе по deeplink происходит парсинг id книги и отображение нужной страницы либо 404. 

Выглядит красиво – применили такой же подход, как при построении UI: стек навигации полностью зависит от состояния, изменяется при возникновении некоторых событий. Прямо как StatefulWidget и setState! Вау! А теперь представьте, что приложение оперирует не тремя, а десятками или сотней экранов, как это обычно бывает. Как в таком случае будет выглядеть RouterDelegate? Как управлять всем этим состоянием? Такого примера, к сожалению, нет. И нет, команда Flutter не забила, они работают над этим вопросом, просто пока он не решен. Есть даже репозиторий на тему юзабилити компонентов фреймворка, в том числе там есть раздел про Router: https://github.com/flutter/uxr/wiki/Navigator-2.0-API-Usability-Research

Кажется, это тупик. С одной стороны, Navigator – прост в использовании, но не гибкий, с другой стороны Router – низкоуровневый, сложный, но полностью кастомизируемый. А есть что-то среднее?

Библиотеки спасают Router!

Для того, чтобы получить все лучшее от Router, при этом получить простой конечный API, нужна библиотека, которая использует Router. Это может быть свое решение, на него придется потратить много времени и сил, чтобы учесть все потребности и точки расширения, а может быть и готовое решение. Плюс готового решения в том, что его можно начать использовать также просто, как Navigator, при этом оно может иметь дополнительную функциональность от Router. Существует множество пакетов на просторах pub.dev, вот часть из них с кратким описанием особенностей каждого:

  • auto_route – позволяет использовать кодогенерацию для конфигурации набора роутов, поддерживает вложенную навигацию; можно использовать как именованную навигацию через пути, так и через классы роутов;

  • Beamer – предлагает интересную концепцию с разделением навигации по приложению на отдельные "разделы", у каждого из которых свой обработчик;

  • Routemaster – небольшая аккуратная библиотека для навигации по URL. Можно посмотреть реализацию, чтобы понять концепцию работы с новыми компонентами;

  • qlevar_router – умеет работать с многослойной навигацией, предоставляет из коробки методы для отображения диалогов и overlay;

  • yeet – позволяет использовать паттерны в URL для описания параметров, предоставляет свой взгляд на вложенную навигацию; 

  • fluro – дает возможность использовать обработчики путей в виде функций вместо роутов.

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

Теперь на примере использования Routemaster рассмотрим, что нам дает такой подход:

final routes = RouteMap(
  routes: {
	  '/': (_) => CupertinoTabPage(
      child: HomePage(),
      paths: ['/feed', '/settings'],
    ),
    '/feed': (_) => MaterialPage(child: FeedPage()),
    '/settings': (_) => MaterialPage(child: SettingsPage()),
    '/feed/profile/:id': (info) => MaterialPage(
      child: ProfilePage(id: info.pathParameters['id'])
    ),
  }
);

void main() {
  runApp(MaterialApp.router(
	  routerDelegate: RoutemasterDelegate(routesBuilder: (context) => routes),
    routeInformationParser: RoutemasterParser(),
  ));
}

Здесь разработчики сразу показывают пример использования многоуровневой навигации. При помощи CupertinoTabPage можно обозначить, что страница содержит вкладки, и при переходе на страницы "/feed", "/settings" фактически будет выполнена смена вкладки в рамках главного экрана:

routemaster.push('/feed');

Таким образом мы абстрагируем потребителей навигации от знания о том, как именно происходит навигация – открывается ли новый экран либо просто сменяется вкладка.

На примере этого роута можно заметить поддержку Path-параметров из коробки:

'/feed/profile/:id': (info) => MaterialPage(
  child: ProfilePage(id: info.pathParameters['id'])
),

Также есть поддержка редиректов, которые довольно часто используются в web:

RouteMap(routes: {
  '/one': (routeData) => MaterialPage(child: PageOne()),
  '/two': (routeData) => Redirect('/one'),
})

При этом Routemaster старается быть похожим на подход именованной навигации из Navigator 1.0, но с дополнительной функциональностью.

В качестве примера другого подхода можем привести Beamer с его парадигмой BeamLocation. С ним становится возможным использовать декларативный подход, не сваливая в кучу состояние навигации всего приложения, как в примере от команды Flutter, а разделив его на разные BeamLocation, получив некоторые блоки, из которых строится все приложение и где каждый отвечает за свою часть.

И напоследок...

Рассмотрев все подходы к навигации, которые предоставляет Flutter, делаем вывод – нет универсального решения и каждый инструмент хорош для своей задачи:

  • Navigator – нужно быстро и просто реализовать переходы между экранами;

  • Router – хотим сами все контролировать и определять методы навигации, нужна полная гибкость;

  • Пакет на основе Router – хотим большей функциональности, чем у Navigator, но не желаем работать с низкоуровневым API.

Сравнительная таблица доступных подходов к навигации во Flutter
Сравнительная таблица доступных подходов к навигации во Flutter

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

Отметим, что не так много банков рискуют связываться с новой технологией Flutter для создания мобильных приложений, когда еще не накоплен достаточный опыт в разработке и эксплуатации. Мы сталкивались с разными кейсами в своем проекте и удачно их решали. Если у вас есть вопросы по Flutter, пишите в комментариях — мы поделимся экспертизой!

Источник: https://habr.com/ru/company/rshb/blog/584348/


Интересные статьи

Интересные статьи

В этом дайджесте — обновления Android, собственный движок для игр, симуляция волос и создание VHS-эффектов, No-Code платформы машинного обучения и доходы приложений. Приятного чтения!...
В новом дайджесте — локализация и гиперкубы, библиотеки и декларативные фреймворки, приложения, чтобы побороть зависимость от приложений, Flutter, Unity, подписки, AI для поиска уязвимостей в код...
Бизнес-смыслы появились в Битриксе в начале 2016 года, но мало кто понимает, как их правильно использовать для удобной настройки интернет-магазинов.
В этой подборке (не)настоящее восстание машин в Google Play, чистый Swift и настоящие компоненты, новый Android 10 и старый геймдев, удобные интерфейсы и сомнительный маркетинг для инди-игр. ...
Если вы последние лет десять следите за обновлениями «коробочной версии» Битрикса (не 24), то давно уже заметили, что обновляется только модуль магазина и его окружение. Все остальные модули как ...