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 — дает возможность использовать обработчики путей в виде функций вместо роутов;

  • navigation_manager — есть поддержка uri-паттернов, используется концепция поддеревьев (sub-tree) и Duplicate strategies, как в Android Activity (в разработке).

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

Теперь на примере использования 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, пишите в комментариях — мы поделимся экспертизой!

Комментарии (15)


  1. pgd404
    19.10.2021 20:03
    +1

    А в каком из рассмотренных вариантов из коробки работает возврат на предыдущую вкладку нижней навигации по нажатию кнопки "назад"?


    1. Sorokinzzz Автор
      20.10.2021 10:38
      +1

      В случае с Navigator для этого можно использовать WIllPopScope. Им оборачивается все приложение и мы всегда получаем событие нажатия кнопки назад. Тогда сами можем решать - будет ли это возврат внутри вкладки, смена вкладки на предыдущую, либо возврат назад в рамках самого верхнего уровня навигации.

      Когда используем Router, мы переопределяем событие onPopPage у Navigator-а, который возвращается из RouterDelegate, и также сами решаем, что делать, нужно ли спускать это событие вниз по дереву навигации. В том числе можем вернуться на предыдущую вкладку. Из готовых примеров можно посмотреть на Routemaster - там реализовано такое поведение, причем его можно менять через конфигурацию роута.


      1. pgd404
        20.10.2021 23:40

        Спасибо.


        1. thiNTR
          07.11.2021 20:19
          +1

          Такая же штука есть из коробки у библиотеки navigation_manager :)


      1. Losmki
        07.11.2021 21:21

        А WillPopScope все еще ломает возвращение на предыдущую страницу свайпом в ios?


        1. Sorokinzzz Автор
          09.11.2021 11:11
          +1

          Когда оборачиваешь приложение целиком не ломает и не ломал. Честно говоря, я не сталкивался с ситуацией, когда это поведение ломалось, нашел только это issue.


  1. Mitai
    19.10.2021 20:50
    +3

    Раза два читал их статью на медиуме про навигатор 2.0 и так не вкурил, как его готовить. У вас это получилось лучше, чем у команды флаттера!


    1. Sorokinzzz Автор
      20.10.2021 10:12
      +1

      Спасибо за позитивный фидбэк! Очень рад, что материл оказался для вас полезным, у самого были те же трудности) Для полного понимания очень советую посмотреть на код библиотек из статьи, можно найти интересные детали реализации.


  1. undersunich
    20.10.2021 12:37

    Как то все это сложно.А Вы смотрели реализацию роутинга у GetX ?Там как раз все просто и понятно.В примерах что Вы привели настолько все запутано что отпадает желание вообще на флаттере работать


    1. Sorokinzzz Автор
      20.10.2021 13:16
      +1

      Сложность есть, не спорю, но она дает гибкость.

      Если у вас условный стартап, с небольшой командой, либо просто нет необходимости в проекте делать больше, чем дает Navigator, то его может быть достаточно. Тогда нет смысла тратить ресурсы на другие решения, все-таки инструмент должен выбираться исхода из задач. В нашем случае проект рассчитан на долгосрочную поддержку и участие большого количества команд, поэтому гибкость была необходима.

      А GetX также под капотом использует Navigator 2.0, так что его можно отнести к списку библиотек из статьи. Сложность осталась, но ее забрали на себя разработчики этих библиотек.


  1. hawkkiller
    21.10.2021 11:17

    Хотя бы одна из этих библиотек поддерживает neglect ?


    1. Sorokinzzz Автор
      21.10.2021 11:36
      +1

      Если под neglect имеете ввиду переход, без добавления записи в бэкстек браузера, то любая из библиотек это поддерживает при использовании встроенного метода Router.neglect. Так как мы не используем flutter for web, я такое не пробовал делать, но должно работать:

      Пример кода
      Router.neglect(context, () {
        WhatEverNavigationLib.navigate(somewhere);
      });


  1. thiNTR
    07.11.2021 20:23
    +1

    Хотел бы предложить вам посмотреть и оценить библиотечку navigation_manager)
    В ней пока мало функций, но возможно есть потенциал. Было бы хорошо узнать чье-нибудь мнение)


    1. Sorokinzzz Автор
      09.11.2021 11:41
      +1

      Приятная библиотека! Мне в целом нравится концепция поддеревьев, хорошая идея с duplicate strategy, у некоторых проектов может быть потребность в такой фиче.

      Сейчас не хватает документации, чтобы было проще понять используемые концепции. Будет интересно посмотреть за развитием. Добавил в список библиотек ????


  1. ljonya
    06.12.2021 15:07
    +1

    Если нужно разобраться с Navigator 2.0, то не статья на medium для вас будет главным источником и точкой входа, а вот этот документ. Обзор и небольшое объяснение того как из этого что-то слепить без сторонних библиотек на русском: https://www.youtube.com/watch?v=PsEk0aBosmw

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