Технический директор Борис Горячев рассказывает, как «Медуза» работала над ним целый год и почему оно написано на Flutter


12 мая состоялся релиз новых мобильных приложений «Медузы» (iOS, Android) — почти через два года после того, как мы решили их переписать. Почему так долго? Почему не нативные приложения? Почему именно Flutter? Обо всем этом рассказывает технический директор «Медузы» Борис Горячев.



В отличие от наших старых приложений, новые мы решили не делать нативными. Во-первых, писать два раза один и тот же код утомительно. Во-вторых, никогда не получится сделать так, чтобы в двух разных проектах, написанных разными людьми, все было синхронно и одинаково. Обычно кто-то работает медленнее, кто-то ушел в отпуск, у кого-то технический долг, который надо закрыть. Весь наш опыт создания и поддержки нативных приложений подсказывал нам — либо мы что-то делаем не так, либо это просто не наш путь. И мы начали искать свой. Пробовали React Native и Ionic, думали про подход Basecamp — все в веб + тонкая нативная прослойка, даже порывались пойти в сторону Progressive Web App и остаться веб-онли.

Примерно тогда же я побывал на конференции Google I/O и там познакомился с людьми, делающими Flutter. Сразу попробовал Dart и поработал немного на Flutter, но на тот момент технология еще не была готова для «Медузы»: нельзя было встраивать внутрь наших материалов разный интерактив и эмбеды, а для медиа это критично. Так что я решил подождать, пока Flutter подрастет.

За время ожидания мы много чего сделали на стороне веба: переписали сайт, написали новую версию AMP. Написали и перевели все проекты «Медузы» на ui-kit — единую библиотеку компонентов, которую использует сайт и благодаря которой возможно огромное количество наших игровых механик. Мы разделили десктопные и мобильные версии сайта, так что раздающие страницы (главная и страницы разделов) стали формироваться в двух разных местах по своим правилам.

Параллельно с работой по сайту мы думали над новым приложением — придумывали его навигацию, смыслы, экраны, фичи и так далее.



Параллельно происходили важные изменение в структуре технического отдела. Мы стали активно использовать Google-календарь и другие инструменты планирования совещаний, перешли с Trello на Basecamp. Это настолько очевидно, что даже странно об этом рассказывать. Но потребовалось много времени и сил, чтобы упорядочить хаос. Четкая повестка совещаний, быстрый фоллоу-ап, файлы, которые не теряются, и скоупы хилл чарт позволили в итоге справиться с огромным потоком задач и не умереть.

Почему все-таки Flutter. И немного про Dart


Когда люди узнают про Flutter, они неизбежно узнают и про Dart. Кажется, он считается, самым большим минусом Flutter, но ровно до тех пор, пока не попробуешь на нем писать. Он действительно прикольный. После JavaScript так особенно.

Dart — это язык программирования, который был разработан в Google. Его анонсировали в 2011 году, то есть это еще пока молодой язык. Он не стал major языком программирования (по крайней мере пока), но при этом его очень активно используют в самой компании. Помимо Google, есть и другие большие компании, например, Wrike, которые используют Dart и пишут на нем фуллстек.

Поскольку и Dart, и Flutter поддерживаются одной компанией, это дает возможность менять язык под нужды Flutter. Насколько я знаю, обе команды активно взаимодействуют друг с другом, поэтому в языке постоянно появляются фишки, позволяющие писать более приятный код на Flutter.

Я не буду пытаться объяснить, из чего состоит Flutter, — Википедия справится с этой задачей лучше, но скажу только, что среди авторов есть люди, которые сделали рендеринг в Chromium.

В декабре 2018 года команда Flutter выпустила библиотеку встраивания Webview в Flutter. Она была сырой и, кстати, до сих пор еще не вышла из Developers Preview Status, но этот факт не помешал начать прикидывать структуру будущего приложения. Несколько месяцев я экспериментировал в свободное от работы время, а потом было принято окончательное решение.

Сначала я сел писать новую версию API специально для приложения. Идеологию этого API можно сформулировать так: если что-то можно сделать на бэкенде, значит, надо это делать на бэкенде. И не потому, что сделать что-то на клиенте сложно. Дело в том, что релизы в App Store и Google Play — это трудоемкий и долгий процесс. Нужно подстраиваться под их графики работы и помнить, что не все пользователи сразу обновят приложение. Этого можно избежать, когда основная логика происходит у тебя на сервере. Надо подвинуть заголовок на 10 пикселей вниз? Пожалуйста. Быстро убрать или добавить компонент? Нет проблем!



По этой же причине API для мобильного приложения содержит максимально простые компоненты, из которых рекурсивно собирается почти любой материал. Я говорю «почти», потому что игры мы показываем через WebView. Приложению без разницы, что показывать — карточку, подкаст, новость или «Шапито». Все обрабатывается одним кодом, а задача приложения — взять компонент и отрендерить его (или пойти в список его детей и вызваться рекурсивно).

Еще один важный аргумент в пользу Flutter: разработчик «контролирует все пиксели». Когда нужно сделать так, чтобы везде были правильные тени, как в макете в Sketch, или хочется, чтобы прозрачность менялась по кривым, или нужно, чтобы размеры шрифтов и вся типографика были настраиваемыми, — в Flutter все просто и реалистично.

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

Экосистема


Хотя Flutter и Dart пока не очень популярные технологии, у нас не возникло проблем с поиском библиотек. Большая часть того, что нужно в приложении, есть либо в самом фреймворке, либо на pub.dev. Комьюнити очень приятное и готово помогать. Это общение — настоящий глоток свежего воздуха, очень поддерживало.

Кроме того, сам Google хорошо поддерживает Flutter. Мы используем Firebase для пушей, аналитики, профилей и хранения пользовательских данных (история чтения, позиции эпизодов, закладок), и там все «просто работает». Конечно, мне как человеку, который никогда раньше не писал нативных приложений, иногда дико лезть в gradle файлы или ставить проперти в info.plist. Но обычно все гуглится, и там нет ничего невероятно сложного.

Отличия платформ


Про разницу iOS и Android можно говорить бесконечно. Как правило, сразу вспоминают о том, что паттерны должны быть привычными пользователю и что надо обязательно следовать дизайн-гайдлайнам. Мы с этим согласны, но не совсем. Важно помнить и про свой фирменный стиль, и про здравый смысл, и про логику поведения уже знакомых тебе пользователей.

Если взглянуть на историю самих платформ, то мы видим, что одни паттерны меняются вместе с устройствами, а другие перетекают из системы в систему. Это не скрижали, ничего не стоит на месте. Есть десятки крутых примеров, когда компании отступают от рекомендаций Google и Apple и делают так, как считают правильным.



Самый простой и яркий пример — свайп назад в iOS. Область экрана, которая принимает свайп, по умолчанию супермелкая, около 20 пикселей. Именно так устроено приложение Mail на айфонах, и это очень неудобно. У Twitter эта область немного расширена, а у Telegram она просто огромная! В Telegram на Android свой особенный тип навигации из списка чатов в чат, это нечто среднее между iOS и Android. Да, гайдлайны — это классно, но на iPhone XR тянуться пальцем в верхний левый край устройства очень неудобно. Так что мы решили (конечно, с опорой на данные), что в приложении «Медузы» кнопка «Назад» на iOS будет внизу. А на Android ее не будет вообще. Для этого есть свайпы (как наши собственные, так и новые свайпы Android) и системная кнопка «Назад».

Рассказывает моя коллега, арт-директор «Медузы» Настя Яровая:

У меня маленькие руки и большой телефон. Смешно вроде бы, но это повлияло на два важных решения в процессе разработки. Сначала я предложила значительно увеличить зону свайпа назад (увеличили до 50%) — первое отступление от гайдлайнов. Потом, когда мы работали над навигацией и функциональными кнопками внутри материала, я предложила кнопку «назад» поставить в общее меню внизу — это еще одно отступление от привычного паттерна в интерфейсе. Теперь до нее можно дотянуться большим пальцем, ведь в основном скроллят именно им.


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

Немного практики: как это устроено в Flutter


После React и других реактивных фреймворков собрать прототип на Flutter можно за пару дней. Генеральная идея Flutter — everything is a widget. Flutter дает две (на самом деле три) парадигмы: можно использовать material виджеты, которые следуют концепции material design. Можно использовать Cupertino widgets — виджеты, которые выглядят и ведут себя как в iOS. И можно все писать самому.

Грубо говоря, есть два вида виджетов, с которым взаимодействует разработчик: stateless и stateful.

Stateless виджеты — это плюс-минус виджеты, которые не держат внутри себя логики, которая должна менять состояние виджета.

В stateful виджетах есть функция setState (привет, реакт!). Ты меняешь state, и виджет перерисовывается.

Но конечно, большое приложение на простых setState далеко не уедет, поэтому нужно решение стейт-менеджмента.

Для Flutter есть несколько решений. Например, есть BloC паттерн, в котором используются стримы и события из источников данных, в них лежит бизнес-логика, которая провоцирует рождение новых событий. Эти события слушаются виджетами, и виджеты реагируют на изменения.

Тем, кто приходит после React, может захотеться попробовать Redux. По идее, не должно возникнуть проблем, если вы писали на React, используя этот подход.

Я пробовал оба подхода и в итоге выбрал третий. Приложение «Медузы» использует Provider. Это библиотека, которую написали не в Google, но интересно, что Google, попробовав Provider у себя, начал отказываться от своего BloC и даже решил повторить Provider как отдельную библиотеку. Но в итоге отказался от идеи повторять чужой код и начал использовать и поддерживать то, что родилось как open source.

Устройство Provider довольно простое. Это виджет, внутри которого есть состояние. Это состояние меняется функциями, которые внутри вызывают notifyListeners(). Те виджеты, которые должны реагировать на изменения, находятся в дереве виджетов — они либо прямые дети Provider виджета, либо дети его детей. Когда notifyListeners() вызывается, дети получают через контекст новые значения и происходит rebuild.

Обычно провайдеры объявляются в самом «верху» приложения, и можно использовать много провайдеров сразу — каждый отвечает за свою бизнес-логику.

Сейчас мы используем восемь провайдеров, которые «слушаются» в разных местах приложения. Например, вот как работает провайдер закладок, благодаря которому читатели могут сохранять понравившиеся материалы, читать их офлайн и с одного и того же места на разных устройствах.

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

Через провайдеры также сделано переключение темы приложения. У приложения есть ThemeProvider, который внутри себя содержит два инстанса тем — и, по сути, это просто Map с нашим неймингом цветов и самими цветами.

В итоге правильный цвет приходит в виджет примерно так:

TextSpan(
	text: block['data']['first'],
	style: TextStyle(
		color: Provider.of<ThemeProvider>(context)
        .current
        .bodyFontColor,                            
  fontFamily: 'Proxima Nova',
));

Код провайдера, если его упростить, будет таким:

class ThemeProvider extends ChangeNotifier {
  CustomThemeData current;
  
  CustomThemeData blackTheme = CustomThemeData(
    name: 'black',
    bodyFontColor: Color.fromRGBO(184, 184, 184, 1),
    ...
  );
  
  CustomThemeData lightTheme = CustomThemeData(
    name: 'black',
    bodyFontColor: Color.fromRGBO(184, 184, 184, 1),
    ...
  );
  
  ThemeProvider(withTheme, withFontMultiplier, withAutoTheme) {
    theme = withTheme;
    autoTheme = withAutoTheme;
    current = withTheme == 'light' ? lightTheme : blackTheme;
  }
}

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

Что дальше


В последние недели мы активно тестировали новое приложение на группе из нескольких сотен читателей «Медузы». О том, как проходило тестирование, мы расскажем в отдельном материале, а я только отмечу, что Flutter оправдал все мои надежды. Да, ему есть куда развиваться, да, не все библиотеки feature-rich, но, наблюдая скорость, с которой это все движется, я, по крайней мере для себя, делаю вывод, что Flutter с «Медузой» надолго.