Как я делал desktop-приложение на Flutter (+ bonus)
Недавно попалась на глаза новость, что вышел очередной релиз Flutter (1.9), который обещает разные вкусности и, в том числе, раннюю поддержку веб-приложений.
На работе я занимаюсь разработкой мобильных приложений на React Native, но с любопытством поглядываю на Flutter. Для тех, кто не в курсе: на Flutter уже сейчас можно создавать приложения для Android и iOS, готовится к релизу поддержка веб-приложений, а ещё в планах поддержка десктопа.
Такое вот «одно кольцо, чтобы править всеми».
Покрутив пару дней в голове мысли о том, какое приложение можно попробовать сделать, я решил выбрать задачу со звёздочкой — что нам эти проторенные дорожки? Замахнёмся на десктоп и будем героически преодолевать трудности! Забегая вперёд, скажу, что трудностей почти не возникло.
Под катом — рассказ о том как я решал привычные для React Native программиста задачи средствами Flutter, плюс общее впечатление от технологии.
Размышляя о том, какие возможности Flutter хотелось бы «пощупать», я решил, что в моём приложении должны быть:
- запросы к удаленному API;
- переходы между экранами;
- анимация переходов;
- менеджер состояния — redux или что-то подобное.
Я не умею в бэкенд, поэтому решил поискать стороннее открытое API. В итоге остановился на этом ресурсе — Курсы ЦБ РФ в XML и JSON, API. Ну и тут уже окончательно определился с функциональностью приложения: будет два экрана, на главном — список валют по курсу ЦБР, при клике на элемент списка открываем экран с детальной информацией.
Подготовка
Поскольку команда flutter create
пока не умеет создавать проект для Windows/Linux (на данный момент поддерживается только Mac, для этого используйте флаг --macos
), приходится использовать этот репозиторий, где имеется подготовленный пример. Клонируем репозиторий, забираем оттуда папку example
, если нужно — переименовываем и дальше работаем в ней.
Так как поддержка десктоп-платформ пока находится в разработке, то нужно выполнить ещё ряд манипуляций. Чтобы получить доступ к возможностям, находящимся в разработке, выполните в терминале:
flutter channel master
flutter upgrade
Кроме того, нужно указать Flutter, что он может использовать вашу платформу:
flutter config --enable-linux-desktop
или
flutter config --enable-macos-desktop
или
flutter config --enable-windows-desktop
Если всё прошло хорошо, то выполнив команду flutter doctor
вы должны увидеть похожий вывод:
Итак, декорации готовы, зрители в зале — можем начинать.
Вёрстка
Первое, что бросается в глаза после React Native — это отсутствие специального языка разметки, а ля JSX. Flutter заставляет вас писать и разметку, и бизнес-логику на языке Dart. Поначалу это раздражает: взгляду не за что зацепиться, код кажется громоздким, да ещё эти скобочки в конце компонента!
Например, такие:
И это ещё не предел! Стоит удалить одну не в том месте и приятное (нет) времяпрепровождение вам гарантировано.
К тому же, из-за особенностей стилизации компонентов в Flutter, для больших компонентов отступ от левого края редактора достаточно быстро увеличивается, а с ним и количество закрываемых скобок.
Особенность же эта заключается в том, что в Flutter стили — это такие же компоненты (если быть точнее — виджеты).
Если в React Native для расположения трёх кнопок в ряд внутри View
так, чтобы они равномерно распределили пространство контейнера, мне достаточно для View
в стилях указать flexDirection: 'row'
, а для кнопок добавить в стили flex: 1
, то в Flutter есть отдельный компонент Row
для расположения элементов в ряд и отдельный — для «расширяемости» элемента на всё доступное пространство: Expanded
.
В результате, вместо
нам приходится писать так:
Container(
height: 100,
width: 300,
child: Row(
children: [
Expanded(
child: RaisedButton(
onPressed: () {},
child: Text('A'),
),
),
Expanded(
child: RaisedButton(
onPressed: () {},
child: Text('B'),
),
),
Expanded(
child: RaisedButton(
onPressed: () {},
child: Text('C'),
),
),
],
),
)
Более многословно, не правда ли?
Или, скажем, вы захотите добавить рамочку с закруглёнными краями к этому контейнеру. В React Native мы просто добавим к стилям:
borderRadius: 5, borderWidth: 1, borderColor: '#ccc'
В Flutter нам придётся добавить в аргументы контейнера что-то такое:
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(5)),
border: Border.all(width: 1, color: Color(0xffcccccc))
),
В общем, поначалу моя разметка превратилась в огромные простыни кода, в которых чёрт ногу сломит. Однако не всё так плохо.
Во-первых, большие компоненты нужно конечно же разбивать — выносить в отдельные виджеты или хотя бы в методы вашего класса-виджета.
Во-вторых, очень сильно помогает плагин Flutter в VS Code — на картинке выше комменты к скобкам подписывает сам плагин (и они неудаляемые), что помогает не запутаться в скобках. Плюс средства автоформатирования — через полчаса привыкаешь периодически нажимать Ctrl+Shift+I
, чтобы отформатировать код.
К тому же, синтаксис языка Dart во второй редакции стал гораздо приятнее, так что к концу дня я уже получал удовольствие от его использования. Непривычно? Да. Но не неприятно.
Запросы к API
В React Native для получения данных с какого-нибудь API мы обычно используем метод fetch
, который возвращает нам Promise
.
В Flutter ситуация похожая. Посмотрев примеры в документации, я добавил в pubspec.yaml
(аналог package.json
из мира JS) пакет http и написал примерно такую функцию:
Future getAnything() {
return http.get(URL);
}
Объект Future
по смыслу очень похож на Promise, поэтому здесь всё довольно прозрачно. Ну, а для сериализаци/десериализации json объектов можно использовать концепцию классов-моделей со специальными методами fromJSON
/toJSON
. Подробнее об этом можно прочитать в документации.
Переход между экранами
Несмотря на то, что я делал десктоп-приложение, с точки зрения Flutter нет никакой разницы на какой платформе он крутится. Ну то есть, в моём случае это так, в общем — не знаю. Фактически, системное окно, в котором запускается flutter приложение — это такой же экран смартфона.
Переход между экранами выполняется достаточно тривиально: создаем класс-виджет экрана, а затем пользуемся стандартным классом Navigator
.
В простейшем случае это может выглядеть как-то так:
RaisedButton(
child: Text('Go to Detail'),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(builder: (context) => DetailScreen()));
},
)
Если в вашем приложении несколько экранов, то более разумно сначала подготовить словарь роутов, а затем пользоваться методом pushNamed
. Небольшой пример из документации:
class NavigationApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
...
routes: {
'/a': (BuildContext context) => usualNavscreen(),
'/b': (BuildContext context) => drawerNavscreen(),
}
...
);
}
}
// AnyWidget
...
onPressed: () {
Navigator.of(context).pushNamed('/a');
},
...
Кроме того, вы можете подготовить специальную анимацию для перехода между экранами и написать что-то такое:
Navigator.of(context).push(ScaleRoute(page: DetailScreen()));
Здесь ScaleRoute
— это специальный класс для создания анимаций перехода. Неплохие примеры таких анимаций можно найти здесь.
State managment
Бывает, что нам нужно иметь доступ к каким-нибудь данным из любой части нашего приложения. В React Native для этих целей часто (если не чаще всего) используют redux
.
Для Flutter есть репозиторий, в котором приведены примеры использования различных архитектур приложения — есть там и Redux, и MVC, и MVU, и даже такие, о которых я раньше не слышал.
Покопавшись немного в этих примерах, я решил остановиться на Provider
.
В целом идея достаточно проста: мы создаем специальный класс-наследник класса ChangeNotifier
, в котором будем хранить наши данные, обновлять их с помощью методов этого класса и забирать их оттуда при необходимости. Подробнее — в документации пакета.
Для этого добавляем в pubspec.yaml
пакет provider
и готовим класс Provider. В моём случае он выглядит так:
import 'package:flutter/material.dart';
import 'package:rates_app/models/rate.dart';
class RateProvider extends ChangeNotifier {
Rate currentrate;
void setCurrentRate(Rate rate) {
this.currentrate = rate;
notifyListeners();
}
}
Здесь Rate
— это мой класс-модель валюты (с полями name
, code
, value
и т.п.), currentrate
— поле, в котором будет хранится выбранная валюта, а setCurrentRate
— метод, с помощью которого обновляется значение currentrate
.
Чтобы присоединить наш провайдер к приложению, изменим код класса приложения:
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
builder: (context) => RateProvider(), // присоединяем провайдер
child: MaterialApp(
...
),
home: HomeScreen(),
),
);
}
Всё, теперь если мы хотим сохранить выбранную валюту, то пишем что-то такое:
Provider.of(context).setCurrentRate(rate);
А если хотим получить сохраненное значение, то такое:
var rate = Provider.of(context).currentrate;
Всё достаточно прозрачно и никакого бойлерплейта (в отличие от Redux). Само собой, может быть для более сложных приложений всё окажется не так гладко, но для таких как мой пример — чистый вин.
Сборка приложения
В теории, для сборки приложения используется команда flutter build
. На практике при выполнении команды flutter build linux
я получил такое сообщение:
«Не больно-то и хотелось», подумал я, ужаснулся весу папки build
— 287,5 МБ — и по простоте душевной удалил эту папку безвозвратно. Как оказалось — зря.
После удаления директории build
проект перестал запускаться. Восстановить я её не мог, поэтому скопировал из исходного примера. Не помогло — сборщик ругался на недостающие файлы.
После проведения небольшого исследования выяснилось, что в этой папке есть файл snapshot_blob.bin.d
, в котором, судя по всему, прописаны пути ко всем файлам, используемым в проекте. Я дописал недостающие пути и всё заработало.
Таким образом, на данный момент Flutter не умеет готовить релизные сборки под десктоп. Во всяком случае, для линуксов.
В целом, если закрыть глаза на этот минус, приложение получилось таким, как я и хотел и выглядит
Бонус
Переходим к обещанному бонусу.
Ещё на этапе написания приложения у меня возникло желание проверить, насколько трудно будет портировать его на другие платформы. Начнём с мобилки.
Наверняка есть менее варварский способ, но я решил, что самый короткий путь — прямой. Поэтому просто создал новый проект Flutter, перенёс в него файл pubspec.yaml
, директории assets
, fonts
и lib
и добавил в AndroidManifest.xml
строку:
Приложение запустилось с полпинка и я получил такую
С вебом поначалу пришлось повозиться. Я не знал как создавать веб-проект, поэтому воспользовался инструкциями из интернета, которые почему-то не работали. Хотел было уже плюнуть, но наткнулся на этот мануал.
В итоге, всё оказалось проще простого — нужно было всего-то включить поддержку веб-приложений. Выжимка из мануала:
flutter channel master
flutter upgrade
flutter config --enable-web
cd
flutter create .
flutter run -d chrome
Затем я таким же варварским способом перенёс нужные файлы в этот проект и получил такой
Общие впечатления
Поначалу работать с Flutter было непривычно, я постоянно пытался применять привычные подходы из React Native и это мешало. Кроме того, немного раздражала некоторая избыточность кода на dart.
После того как немного набил руку (и шишек), мне стали видны преимущества Flutter перед React Native. Перечислю некоторые.
Язык. Dart вполне понятный и приятный язык со строгой статической типизацией. После JavaScript он был как глоток свежего воздуха. Я перестал бояться, что мой код сломается в рантайме и это было приятное ощущение. Кто-то может сказать, что есть Flow и TypeScript, но это всё не то — в наших проектах мы использовали и то, и другое и всегда где-нибудь что-нибудь ломалось. Когда я пишу на React Native, то не могу отделаться от ощущения, что мой код стоит на подпорках из спичек, которые могут сломать в любой момент. С Flutter я забыл про это ощущение, и если цена — избыточность кода, то я готов её заплатить.
Платформа. В React Native вы используете нативные компоненты и это в целом хорошо. Но из-за этого вам иногда приходится писать код, специфичный для платформы, а также отлавливать баги, специфичные для каждой из платформ. Это может быть невероятно утомительно. С Flutter вы можете забыть эти проблемы как страшный сон (хотя может быть, что в крупных приложениях всё не так гладко).
Окружение. С окружением в React Native всё грустно. Плагины vscode постоянно отваливаются, отладчик может сожрать 16 гигов оперативы и 70 гигов свопа, намертво повесив систему (из личного опыта), а самый частый сценарий исправления ошибок: «удали node_modules, установи пакеты заново и попробуй перезапустить несколько раз». Обычно это помогает, но блджад! Не так должно быть, не так.
Кроме того, вам периодически придётся запускать AndroidStudio и XCode, потому что некоторые либы ставятся только так (справедливости ради, с выходом RN 0.60 с этим стало получше).
На этом фоне официальный плагин Flutter для vscode выглядит очень недурно. Подсказки для кода позволяют знакомиться с платформой не заглядывая в документацию, автоформатирование решает проблему со стилем кодирования, нормальный отладчик и т.д.
В целом это выглядит как более зрелый инструмент.
Кроссплатформенность. React Native исповедует принцип «Learn once, write everywhere» — однажды научившись, вы сможете писать под разные платформы. Правда, под каждой платформой вы столкнётесь со специфичными для неё проблемами. Но возможно это лишь следствие незрелости React Native — на текущий момент последней стабильной версией является 0.61. Может быть с выходом версии 1.0 большинство этих проблем уйдёт.
Подход Flutter больше похож на «Write once, compile everywhere». И пусть на текущий момент десктоп не готов к продакшену, веб пока тоже в альфе, но всё идёт к этому. А возможность иметь единую кодовую базу для всех платформ — это сильный аргумент.
Само собой, Flutter тоже не лишён недостатков, но малый опыт его использования не позволяет мне их выявить. Так что если хотите более объективной оценки — смело делайте скидку на эффект новизны.
В целом, следует заметить, что Flutter оставил в основном положительные ощущения, хотя ему и есть куда расти. И следующий проект я с большей бы охотой начал на нём, а не на React Native.
Исходный код проекта можно найти на GitHub.
P.S. Пользуясь случаем, хочу поздравить всех причастных с прошедшим Днём учителя.