Navigation 2.0 API у Flutter для web-проєктів
Привіт! Мене звати Артур, я займаюся розробкою мобільних додатків у Welltech. Наша компанія робить мобільні додатки в категорії Health & Fitness, які завантажили вже понад 160 млн користувачів.
Мій досвід комерційної розробки пов’язаний з Android-додатками, але з моменту виходу Flutter я активно почав цікавитися кросплатформною розробкою. Можливість мати єдину кодову базу та зібрати з неї додаток під кілька платформ, отримавши на виході ідентичну поведінку та однакові помилки, — це дуже вражає. Навесні 2021 року вийшла stable версія Flutter з підтримкою web, тож я зважився зібрати свій перший web-додаток на Flutter, і зіткнувся з неочевидними труднощами.
У статті розповідаю про те, як я розбирався з Navigation 2.0 API, що там незвичного для мобільного розробника, як заощадити час на старті та вивченні різних варіантів рішень.
Буде особливо корисно для тих, хто хоче спробувати Flutter web або робить мобільний додаток на Flutter і замислюється над портуванням його у web-додаток.
Чому Flutter
Реалізувавши кілька pet-проєктів, я пройшов різні стадії:
- повне неприйняття Dart після Kotlin;
- зміна API у багатьох допоміжних пакетах, якими я щойно навчився користуватися;
- перехід на Flutter 2.0 з підтримкою Dart 2.12+, з наступним переписуванням проектів під null-safety;
- безліч відкритих issues на GitHub.
Але пройшовши всі стадії неприйняття, я зазначив собі, що цей фреймворк цілком життєздатний і з ним можна ефективно вирішити 90% повсякденних завдань. Інші 10% теж можна вирішити, але під них може знадобитися експертиза в конкретній OS. Наприклад, якщо ви не знайдете вже написаний плагін, який під капотом взаємодіє через bridge з Android або iOS — можна написати його самостійно. Заповзяті хлопці користуються цим: наприклад, коли мені знадобилося знайти плагін для трекінгу геолокації в бекграунді, я знайшов його платний варіант. В Android-світі я такого не зустрічав.
Загалом у мене з’явилося бажання використовувати Flutter у комерційних цілях, з’явився час на експерименти — і я почав розбиратися, як робити кросплатформні додатки.
Кілька слів про навігацію
Навігація — це одна з основних тем, з якою потрібно визначитися на старті при проєктуванні додатку.
На початку я намагався йти шляхом імперативного опису роутингу в додатку. Цей метод успішно застосовується на проєктах для мобільних платформ, але виявилося, що в такому разі не можна повноцінно працювати з рядком адреси у вебі, а також кнопка back у браузері відпрацьовувала некоректно.
У процесі пошуку вирішення проблеми я дізнався, що під stable-реліз підтримки вебу у Flutter розробники підготували апдейт з роботи з навігацією та реалізували декларативний варіант її опису. На сайті фреймворку знайшлася така сторінка, на якій є корисні посилання на можливі варіанти реалізації. Там нічого прямо не написано про web, але StackOverflow підштовхнув мене до того, що я маю лише один варіант вирішення проблеми — використовувати Router API.
Розробники Flutter підготували статтю на Medium, у якій постаралися вичерпно його описати, чим я і надихався. Очевидно, новий роутинг їм здався громіздким, і вони вирішили не додавати його до загальної документації, а лише поділилися посиланням на Medium, що саме по собі трохи заплутує. Плюс до всього вищеописаного, сам метод опису навігації для мене був незвичним. Якщо розбирати його через код тестових проєктів, можна заплутатися, а API здасться надто складним.
Я навіть натрапив на такий кумедний issue, який викликав спортивний інтерес розібратися в темі:
Через деякий час мені вдалося «перемогти» навігацію, і у статті я постараюся коротко викласти механізм її роботи.
Трохи теорії
Щоб описати навігацію декларативним способом, потрібно розібрати п’ять основних частин:
- RouterDelegate
- Router
- RouteInformationParser
- Page
- AppConfiguration
Підемо від простого до складного.
AppConfiguration — custom data type, за допомогою якого ми будемо визначати поточний state нашого додатку та, відштовхуючись від нього, формувати стек.
class AppConfiguration { AppConfiguration._(); factory AppConfiguration.login() = LoginNavState; factory AppConfiguration.home(int selectedIndex) = HomeNavState; } class LoginNavState extends AppConfiguration { LoginNavState() : super._(); } class HomeNavState extends AppConfiguration { final int selectedIndex; HomeNavState(this.selectedIndex) : super._(); }
Page — це опис конфігурації роуту, за допомогою об’єктів цього класу і формується стек нашого додатку. Не завжди є потреба описувати Page окремим файлом. Можна використовувати MaterialPage, але часто нам може знадобитися кастомізація у вигляді нетипових переходів або додаткової логіки оновлення залежно від вхідних даних.
class AuthPage extends Page<AuthWidget> { const LoginPage() : super(name: "auth", key: const ValueKey<String>("auth")); @override Route<AuthWidget> createRoute(BuildContext context) { return MaterialPageRoute( settings: this, builder: (BuildContext context) { return const AuthWidget(); }); } } class AuthWidget extends StatefulWidget { const AuthWidget({Key? key}) : super(key: key); @override State<StatefulWidget> createState() { return _AuthWidgetState(); } } class _AuthWidgetState extends State<AuthWidget> { @override Widget build(BuildContext context) { return Scaffold( body: Center( child: ElevatedButton( onPressed: () { prefsDataSource.setLoggedInState(true); }, child: const Text("LOGIN")))); } }
Із AuthPage все просто. Цей page не приймає аргументів і не повертає подій. У самому віджеті натисканням кнопки «Login» ми змінюємо loggedInState у системі. Трохи згодом я покажу, як ми на це реагуємо.
Із HomePage трохи складніше. У моєму додатку home — це екран з NavigationRail. Щоразу, вибираючи елемент у NavigationRail, має змінюватися url у рядку адреси, а всередині home має змінюватися контент. Щоб реалізувати подібну поведінку, ідеально підходить StatefulWidget, передача аргументів через Page, використання key
у конструкторі та callback на зміну стану. Тут ключовим є параметр key. Забігаючи наперед, скажу: RouterDelegate — основний об’єкт, де все збирається докупи — коли його просиш перебудувати стек, перевіряє page
елементи на ідентичність. Якщо старий page і новий — ідентичні, він не перемальовуватиме сторінку, а лише прокине до неї аргументи, щоб ми могли змінити стан всередині вже відмальованого віджету.
class HomePage extends Page<HomeWidget> { final Function(HomeAction) _onHomeAction; final bool forceUpdate; const HomePage(selectedIndex, this._onHomeAction, {this.forceUpdate = false}) : super( name: "home", arguments: selectedIndex, key: const ValueKey<String>("home")); @override Route<HomeWidget> createRoute(BuildContext context) { return NoAnimationMaterialPageRoute( settings: this, builder: (BuildContext context) { return HomeWidget(_onHomeAction); }, ); } @override bool canUpdate(Page<dynamic> other) { var canUpdate = super.canUpdate(other) && !forceUpdate; return canUpdate; } } class HomeWidget extends StatefulWidget { final Function(HomeAction) onHomeAction; const HomeWidget(this.onHomeAction, {Key? key}) : super(key: key); @override State<StatefulWidget> createState() { return _HomeWidgetState(); } } class _HomeWidgetState extends State<HomeWidget> { int _selectedIndex = 0; @override Widget build(BuildContext context) { _selectedIndex = ModalRoute.of(context)!.settings.arguments as int; return Scaffold( body: Row( children: [ NavigationRail( extended: true, minExtendedWidth: 200, destinations: const [ NavigationRailDestination( icon: Icon(Icons.home), label: Text("PAGE 1")), NavigationRailDestination( icon: Icon(Icons.emoji_symbols), label: Text("PAGE 2")) ], selectedIndex: _selectedIndex, onDestinationSelected: (value) { widget.onHomeAction(HomeAction.sideMenuClick(value)); }, ), Container(width: 2), Expanded(child: PageWidget(_selectedIndex)) ], )); } }
Так саме може виникнути необхідність примусово перезавантажити home page. У такому випадку у класу Page
є метод canUpdate
, в якому можна імплементувати кастомну логіку. За такої постановки задачі ми виграємо за рахунок того, що описали Page окремим класом.
Рухаємось далі — на черзі у нас RouteInformationParser.
Це делегат, який використовується root Widget’ом Router. Він потрібен для двох цілей:
- Обробити інформацію від OS (у нашому випадку рядок адреси) та показати правильну конфігурацію екранів.
- Повідомити OS поточну конфігурацію, яка була визначена у процесі взаємодії з додатком.
Описується він таким чином:
class AppRouteInfoParser extends RouteInformationParser<AppConfiguration> { @override RouteInformation? restoreRouteInformation(AppConfiguration configuration) { return RouteInformation( location: "/${AppConfiguration.toUri(configuration).toString()}"); } @override Future<AppConfiguration> parseRouteInformation( RouteInformation routeInformation) async { final uri = Uri.parse(routeInformation.location!); return AppConfiguration.fromUri(uri); } }
Метод restoreRouteInformation віддає поточну конфігурацію додатку у вигляді рядка, а метод parseRouteInformation із рядка віддає звичну нам конфігурацію, з якою можна буде працювати в RouterDelegate. Не хвилюйтесь, ми майже досягли мети :)
Ви могли помітити, що в AppConfiguration з’явилися нові методи. Для зручності я додав два методи, які можуть перетворити наш конфіг на Uri і назад. Метод toUri повідомляє OS про те, що ми змінили конфігурацію; а метод fromUri, навпаки, дає нам інформацію про те, яку конфігурацію показати, якщо рядок адреси було змінено вручну.
Оновлений клас AppConfiguration:
class AppConfiguration { static const String loginPath = "login"; static const String page1Path = "page1"; static const String page2Path = "page2"; AppConfiguration._(); factory AppConfiguration.login() = LoginNavState; factory AppConfiguration.home(int selectedIndex) = HomeNavState; static AppConfiguration fromUri(Uri uri) { if (uri.pathSegments.isEmpty) { return AppConfiguration.login(); } else if (uri.pathSegments.length == 1) { String first = uri.pathSegments.first; switch (first) { case loginPath: return AppConfiguration.login(); case page1Path: return AppConfiguration.home(0); case page2Path: return AppConfiguration.home(1); } } return AppConfiguration.login(); } static Uri toUri(AppConfiguration configuration) { String path = ""; if (configuration is LoginNavState) { path = AppConfiguration.loginPath; } else if (configuration is HomeNavState) { switch (configuration.selectedIndex) { case 0: path = page1Path; break; case 1: path = page2Path; break; } } return Uri.parse(path); } } class LoginNavState extends AppConfiguration { LoginNavState() : super._(); } class HomeNavState extends AppConfiguration { final int selectedIndex; HomeNavState(this.selectedIndex) : super._(); }
Залишилася вишенька на торті — ми підійшли до головного класу, який формуватиме стек віджетів, реагуватиме на події в додатку і, в разі неправильної або неможливої конфігурації — визначатиме нову. За це все відповідає RouterDelegate!
class AppRouterDelegate extends RouterDelegate<AppConfiguration> with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppConfiguration> { final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>(); AppConfiguration _myConfiguration = AppConfiguration.login(); bool _isLoggedIn = false; @override GlobalKey<NavigatorState> get navigatorKey => _navigatorKey; @override AppConfiguration? get currentConfiguration { return _myConfiguration; } AppRouterDelegate() { _subscribeOnAppUpdates(); } void _subscribeOnAppUpdates() { prefsDataSource.getLoggedInStateStream().listen((isLoggedIn) { _isLoggedIn = isLoggedIn; if (_isLoggedIn) { _myConfiguration = AppConfiguration.home(0); } else { _myConfiguration = AppConfiguration.login(); } notifyListeners(); }); } @override Widget build(BuildContext context) { List<Page<dynamic>> stack = List.empty(growable: true); var configuration = _myConfiguration; if (configuration is LoginNavState) { stack.add(const AuthPage()); } else if (configuration is HomeNavState) { stack.add(HomePage(configuration.selectedIndex, _processHomeClick)); } return Navigator( key: navigatorKey, pages: stack, onPopPage: _handlePopPage); } bool _handlePopPage(Route<dynamic> route, dynamic result) { return false; } @override @override Future<void> setNewRoutePath(AppConfiguration configuration) { if (configuration is! LoginNavState && !_isLoggedIn) { //Here you can write your own logic to save user intent and open it after login _myConfiguration = AppConfiguration.login(); } else if (configuration is LoginNavState && _isLoggedIn) { //Check if user wants to login page even if he is already logged in and do nothing } else { _myConfiguration = configuration; } return SynchronousFuture<void>(null); } void _processHomeClick(HomeAction action) { if (action is SideMenuClick) { _myConfiguration = AppConfiguration.home(action.selectedIndex); } notifyListeners(); }
«А чи не забагато коду для роботи з навігацією?» — запитаєте ви, і будете абсолютно праві :) Давайте коротко розберемося, що тут відбувається.
Найбільше тут нас цікавлять два методи:
1. Future setNewRoutePath(AppConfiguration configuration) — перевіряє валідність конфігурації, яка була повернена з описаного нами RouteInformationParser’а. Якщо нас щось не влаштовує — перевизначаємо поточну конфігурацію. У нашому випадку, якщо прилетів конфіг, який відповідає не за логін, а _isLoggedIn при цьому дорівнює false — нам обов’язково потрібно показати користувачеві екран авторизації. У цьому ж методі можна зберігати невалідну без логіну конфігурацію, щоб потім, після логіну, показати користувачеві сторінку, яку він мав намір побачити. Це виходить за рамки цього overview з навігації, тому зупинятися на цьому ми не будемо.
Окремо хочу відзначити, що хоч сигнатура методу має на увазі асинхронне його виконання — вкрай рекомендується використовувати його синхронно, для чого ми використовуємо SynchronousFuture. Асинхронне виконання тягне за собою проблеми у вигляді кількох відмальовувань початкового стану додатку.
2. Widget build(BuildContext context) — метод, у якому збирається стек. У ньому потрібно правильно обробити конфігурацію та побудувати послідовність екранів.
PrefsDataSource у цій реалізації — це глобальний об’єкт, який відповідає за запис та зчитування значень у рамках сесії. У реальному житті сюди ідеально підходить робота із local storage. У RouterDelegate ми підписуємося на зміну loggedInState користувача та змінюємо конфігурацію.
Залишилося всього нічого — передати все, що ми описали, в головний віджет Router, щоб наш додаток запрацював. Router зазвичай описується як рутовий віджет додатку.
final PrefsDataSource prefsDataSource = PrefsDataSource(); void main() async { configureApp(); WidgetsFlutterBinding.ensureInitialized(); runApp(NavigationTestApp()); } //ignore: use_key_in_widget_constructors class NavigationTestApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp.router( debugShowCheckedModeBanner: false, title: 'Navigation Test', routerDelegate: AppRouterDelegate(), routeInformationParser: AppRouteInfoParser()); } }
Нарешті всі приготування завершено, і ми можемо запустити наш додаток.
Замість завершення
Коду в нашому прикладі вийшло справді багато. Але це важливо, якщо ви хочете коректно працювати з навігацією на ваших кросплатформних проєктах, що підтримують web.
Якщо ви починаєте писати Flutter-проєкт під мобільні платформи, замислюєтесь над web-рішенням, або ж не хочете згодом повертатися до питання навігації — рекомендую використовувати декларативний підхід, інакше (через певний час) доведеться рефакторити всю роботу з навігацією, що на великому проєкті може викликати фрустрацію.
Цей підхід хороший тим, що:
- Ви повністю контролюєте весь стек екранів під різні фічі в одному місці.
- Deeplinking вже входить у ваш сетап навігації.
- Back press handling (Android) доступний у рамках RouterDelegate.
Щоб добре зрозуміти тему, може знадобитися не одна година, та й стаття вийшла немаленькою — тому всі деталі та повний приклад ви можете знайти на моєму github.
Дякую за увагу та легкої вам Flutter-розробки!
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів