Русский
Русский
English
Статистика
Реклама

Блог компании headhunter

Оптимизация производительности фронтенда. Часть 1. Critical Render Path

05.08.2020 16:23:28 | Автор: admin

Здравствуйте. Меня зовут Ник, я фронтенд разработчик (жидкие аплодисменты). Кроме того, что я пишу код, я преподаю в Школе программистов hh.ru.


Записи наших лекций от 2018-2019 учебного года можно посмотреть на youtube


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



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


Лонгридом можно пользоваться как справочником, чтобы не читать за один присест. Вот список тем, которые мы затронем:


  1. Зачем думать о производительности
  2. FMP, TTI + подробнее в докладе
  3. Critical render path, DOM, CSSOM, RenderTree
  4. Шаги по улучшению производительности первой загрузки + подробнее в докладе

Для удобства восприятия я решил разделить статью на две части. Вторая часть будет о таких операциях, как layout, repaint, composite и их оптимизации.


Зачем вообще думать о производительности? Мотивационная часть


0.1 секунда это тот gap, который позволяет пользователю осознать, что именно его клик мышки, удар по клавиатуре побудил эти изменения в приложении\интерфейсе.
Кажется, у всех было то неловкое чувство, когда ты сочиняешь письмо\код\любой другой текст, а интерфейс "за тобой не успевает". Ты уже пишешь второе слово, а на экране всё еще песочные часы (если мы про windows) и еле-еле набирается первое. Аналогично и с кликами на кнопки. Я хочу, чтобы интерфейс мне подсказывал, мол, "окей, я тебя услышал, ща все будет".
За примером далеко ходить не нужно. Я пользуюсь веб-версией одного российского почтовика (не будем называть имен) и когда выделяю письма для их удаления, то бывают большие задержки. И я не понимаю: то ли я не попал по кнопке", то ли сайт тормознутый. И обычно верно второе.
Почему 0.1 секунда? Дело в том, что мы замечаем и успеваем обработать даже куда более ограниченные по времени изменения и наш мозг находится "в контексте".
В качестве яркого примера посмотрите клип 30 seconds to mars hurricane. Там есть вставки на кадр-два с текстом не 9:30. Глаз успевает не только осознать вставку, но и частично определить контент.


1 секунда идеальное время для загрузки сайта. В этому случае серфинг для пользователя воспринимается как что-то очень органичное. Если ваш ресурс готов к работе за 1 секунду вы большой молодец. Как правило, реальность другая.


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


Поэтому обычно 1 секунда это некий идеал, к которому нужно и можно стремиться, но и голову терять не нужно.


10 секунд если погуглить, на всякую аналитику вроде "среднее время пользователя на сайте" мы увидем число: 30 секунд. Сайт загружающийся 5 секунд убивает 1/6 времени пользователя. 10 секунд треть.


Дальше идут два числа 1 минута и 10 минут. первое идеальное время для того, чтобы пользователь выполнил небольшую задачу на сайте прочитал описание товара, зарегистрировался на сайте и т.д. Почему минута? В наши дни мы не так много тратим время на концентрацию на одной вещи. Как правило наше внимание очень быстро перескакивает с одного на другое. Открыл статью, прочитал десятую часть, дальше коллега мем в телегу отправил, тут триггер зажегся, новости про короновирус, верните мне мой 2007, вот это все. В общем к статье получится вернуться только через час.


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


Большие компании даже хорошую аналитику для таких целей имеют:


  • Walmart: 1 секунда ускорения + 2% конверсии
  • Amazon: 0,1 секунды увеличивает выручку на 1%
  • Что-то аналогичное есть у Яндекса (киньте в комментариях ссылку, я потерял)

И последний мотивационный пост в статье от википедии:



Однако достаточно вводной, пора двигаться дальше.


Два извечных вопроса


Давайте запустим lighthouse на hh.ru. Выглядит всё очень не очень (запуск на mobile, на desktop все сильно лучше):



Появляется два традиционных вопроса:


  1. Кто в этом виноват?
  2. Что с этим делать?

Хотя первый вопрос я бы заменил на "Как это расшифровать".
Сразу спойлер: картинки "как круто стало в конце не будет. Как сделать лучше мы знаем. Но есть определенные ограничения.


Давайте разбираться


В первом приближении у нас есть 3 потенциальных сценария:


  1. Отрисовка страницы (с html от сервера)
  2. Работа загруженной страницы (клики пользователя и т.д.)
  3. SPA переходы между страницами без перезагрузки

У пользователя существует два этапа при отрисовке страницы, когда он может частично взаимодействовать с сайтом. И полноценно: FMP (First Meaningful Paint) и TTI (Time to interactive), когда ресурсы загружены, а скрипты проинициализированы:



Если судить о значении для пользователя: FMP == текст, есть верстка и пользователь может начать потреблять контент (конечно, если вы не инстаграмм). TTI == сайт готов к работе. Скрипты загружены, проинициализированны, ресурсы загружены.


Подробнее о расчетах FMP\TTI и как мониторить эту информацию на своем сайте, снимая данные с реальных людей, я рассказывал на РИТ++. В этом же докладе я говорил и про эксперименты, которые мы проводили по ускорению и замедлению скорости работы сайта.


Наиболее важный показатель для нас FMP. Соискатели открывают поиск, затем накликивают "открыть в новой вкладке" большое количество вакансий, а затем читают и принимают решение об отклике. C некоторыми оговорками (нюансы рендера браузера) FMP можно воспринимать как одну из основных метрик, которая описывает Critical render path. Critical render path это набор действий и ресурсов, которые браузер должен совершить, загрузить и обработать, чтобы пользователь получил свой первый результат, пригодный для работы. То есть это минимальный набор html, css и блокирующие скрипты (если так еще кто-то делает), без которых сайт не отобразится пользователю.


Critical render path или что браузер делает для того, чтобы пользователь увидел тот самый текст?


TL&DR;


  1. Сделать запрос (DNS resolve, TCP поход и т.п.);
  2. Получить HTML-документ;
  3. Провести парсинг HTML на предмет включенных ресурсов;
  4. Построить DOM tree (document object model);
  5. Отправить запросы критических ресурсов. CSS, блокирующий JS (параллельно с предыдущим пунктом);
  6. Получить весь CSS-код (также запускаем запросы на JS-файлы);
  7. Построить CSSOM tree;
  8. Выполнить весь полученный JS-код. Здесь могут вызываться layout, если из js кода происходит форсирование reflow;
  9. Перестроить DOM tree (при необходимости);
  10. Построить Render tree;
  11. Отрисовать страницу (layout paint Composite).

Теперь пройдемся по пунктам отдельно:


Подробно:


Request



Формируем запрос, резолвим DNS, IP, TCP поход, передача запроса и т.п. Байтики бегают по сокетам, сервер получил запрос.


Response


Бекенды зашумели вентиляторами, обработали запрос, записали обратно данные в сокет и нам пришел ответ. Например, вот такой:



Мы получили байты, сформировали из нее строку, основываясь на данных text/html, и после пометки нашего запрос как "navigate" (кому интересно это можно посмотреть в ServiceWorker) браузер должен сформировать из этого html DOM.


Сказано, сделано:


Обработка DOM


DOM


Мы получаем строку или поток данных. На этом этапе браузер парсит и превращает полученную строку в объект:



Это только каркас. Сейчас браузер ничего не знает про стили и про то, каким образом всё это рендерить.


Загрузка блокирующих ресурсов


Браузер будет последовательно обрабатывать полученный html и каждый ресурс. CSS, JS может быть загружен как синхронно, блокируя дальнейшую обработку DOM, так и асинхронно (для css это способ с preload + сменой rel после загрузки на stylesheet). Поэтому каждый раз, когда браузер будет встречать блокирующую загрузку стилей или JS, он будет формировать запрос за этим ресурсом.
Для каждого такого ресурса повторяем путь с запросом, ответом, парсингом ответа. Здесь появляются ограничения, например, количество одновременных запросов на домен. Предположим, что все блокирующие запросы были описаны внутри тега head, браузер сделал запросы, получил нужные для рендера стили. После формирования DOM переходим к следующему этапу:


CSSOM


Предположим, что помимо meta и title был тег style (либо link). На данном этапе браузер берет DOM, берет CSS, собирает соответствия, и на выходе мы получаем объектную модель для CSS. Выглядит это примерно так:



Левая часть (head) для CSSOM практически не интересна, она не отображается пользователю. А для остальных узлов мы определили стили, которые браузеру нужно применить.


CSSOM важен, так как его понимание позволяет браузеру сформировать RenderTree.


RenderTree


Последний шаг на этапе от формирования деревьев к рендеру.


На этом этапе мы формируем дерево, которое будет рендериться. В нашем примере левая часть дерева, которая включает head, рендериться не будет. Так что эту часть можно опустить:



То есть, именно это дерево мы и отрендерим. Почему его? Если мы зайдем в DevTools там отображается DOM". Дело в том, что, хоть в DevTools и присутствуют все DOM элементы, все расчеты и вычисленные свойства уже основаны на RenderTree.


Проверить крайне легко:



Здесь мы выделили кнопку во вкладке Elements. Мы получили всю "вычисленную" информацию. Её размеры, положение, стили, наследование стилей и т.д.
Когда мы получили RenderTree, наша следующая задача выполнить Layout Paint Composite нашего приложения. После этих трех этапов пользователь и увидит наш сайт.
Layout Paint Composite могут быть болью не только во время первого рендера, но и при работе пользователя с сайтом. Поэтому мы разберем их во второй части статьи.


Что можно сделать, чтобы улучшить метрики FMP и TTI?


TL&DR;


1) Работа с ресурсами:


1.1) Разнести блокирующие ресурсы по страницам. Как js, так и css. Хранить реиспользуемый между страницами код либо в отдельных бандлах, либо в отдельных небольших модулях.


1.2) Грузить то, что пользователю нужно в начале работы со страницей (очень спорный момент!)


1.3) Вынести third-party скрипты


1.4) Грузить картинки лениво


2) HTTP2.0 / HTTP3.0:


2.1) мультиплексинг


2.2) сжатие заголовков


2.3) Server push


3) Brotli


4) Кэш, ETag + Service worker


Подробно:


Работа с ресурсами


Разносим блокирующие ресурсы. JS


Основной болью являются 2 вещи: блокирующие ресурсы и размер этих ресурсов.
Самый главный совет для больших сайтов это разнести блокирующие стили, ресурсы по страницам. Реиспользумый код выносить в отдельные бандлы или модули. Для этого можно воспользоваться условным loadable-components или react-imported-component для реакта и похожими решениями для vue и т.д. Если наши компоненты импортируют стили, то мы сможем также и разбить стили на отдельные страницы.
На выходе мы получаем:


  1. бандлы с реиспользуемыми JS модулями
  2. страничные бандлы.

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


Изначальная расстановка:



Стратегия 1: делаем зависимость модуль страницы, которые используют модуль



Так, чтобы загрузить главную страницу (index.html), нам нужно будет загрузить 2 бандла: Common.JS + applicant+index.JS, а для страницы /applicant нужно загрузить все 4 бандла. На больших сайтах таких модулей может быть очень много. В этом случае нам помогает решить эту проблему использование HTTP2.0.


Итого:
+: Данные распределены между страницами, мы грузим всегда только необходимое
+: Модули легко кэшируются, после релиза не обязательно обновлять все бандлы у пользователя
-: Много сетевых издержек для получения отдельных ресурсов. Фиксим с помощью мультиплексирования HTTP2.0.


Стратегия 2: реиспользуемые модули хранятся отдельно:



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


+: Во время релизов большая часть модулей останется в кэше у пользователя
-: Еще больший рост сетевых издержек, если у пользователя нет HTTP2.0
-: Кэши могут не работать, так как файлы будут меньше 1 Кб. Здесь нас может спасти Service worker. О нем будет ниже.


Эта стратегия имеет право на жизнь, так как минусы решаемы.


Стратегия 3: Иметь большой бандл реиспользуемого кода:



+: малое количество бандлов. Для загрузки страницы нужен страничный JS + Common.JS
-: При первой загрузке мы получаем очень много unused JS
-: При релизе скорее всего пользователю придется скачивать общий бандл заново, преимущество кэшей теряется.


Я бы рекомендовал либо не использовать этот вариант вообще, либо использовать его для небольших сайтов.


Анти-Стратегия 1: Каждая страница хранит весь список зависимостей, выносим только common:



В данном случае мы получаем большой оверхед. При переходе с одного ресурса на другой пользователь закачивает себе модули, которые уже у него были несколько раз. Например, пользователь заходит на главную и скачивает 2 бандла: Common.JS и Index.JS затем авторизуется и попадает на страницу соискателя. Итого, код для Dropdown.JS и Graph.JS будет скачан дважды.


Пожалуйста, не делайте так :)


Итого


Для больших сайтов я бы рекомендовал использовать одну из первых двух стратегий. Скорее всего это улучшение положительно скажется на вашей TTI метрике. Если же у вас блокирующий JS, то стоит задаться вопросом почему. Должен ли он быть таким? Старайтесь держать как можно меньше блокирующего JS на странице.


Оффтоп. Почему 30 Кб JS это больнее, чем 30 Кб картинки


Предположим у нас есть JS код, который анимирует страницу, добавляет какие-то выпадающие элементы, и есть картинка того же размера.


Для того, чтобы запустить JS нам нужно скачать код, распарсить его, сериализовать в код для компилятора, исполнить.


Итого, на обработку JS кода мы тратим больше времени, чем на ту же картинку.


Разносим блокирующие ресурсы. CSS


Данное улучшение напрямую влияет на FMP, если у вас не асинхронный CSS.
Если вы используете react\vue\angular, то для стилей стоит сделать то же, что и в предыдущем пункте. Как правило, в том же react-коде мы имеем импорты вида:


import './styles.css'

Это значит, что во время бандлинга JS-кода мы можем разделить и CSS, воспользовавшись одной из стратегий выше. Это поможет вебпаку или другому бандлеру получить аналогичные common.css, applicant-page.css и applicant+employer.css.
Если разбить используемый CSS не получается, можно посмотреть в сторону used-styles и статью на эту тему: "optimising css delivery". kashey спасибо за клевые инструменты :)


Это поможет ускорить загрузку, например в случае с hh.ru почти на секунду по эстимейтам lighthouse:



Грузим то, что увидит пользователь, а не всю страницу.


Скорее всего на странице пользователь увидит несколько экранов. Я имею в виду, что далеко не всё влезет в первый. Кроме того, часть возможностей будет скрыта под кликами на кнопки.


Идея данной оптимизации в том, чтобы управлять загрузкой ваших ресурсов. В начале загрузить блокирующим способом тот CSS, который жизненно необходим для открытия страницы. Весь CSS, который относится к всплывающим элементам, popup-ам, которые спрятаны под JS кодом можно будет грузить асинхронно, например, добавив rel=stylesheet уже из JS кода, либо воспользовавшись prefetch с onload колбеком.


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


Выносим third-party скрипты


У нас в hh их много. Очень много!
Например? здесь в ТОП-10 самых тяжелых скриптов 7 third-party.



Что мы можем с этим сделать?


  1. Убедиться, что все ресурсы грузятся асинхронно и не влияют на FMP.
  2. Для рекламы и прочих вещей (излишней аналитики, popup-ов поддержки) снизить их влияние на основной "полезный" код. Это можно сделать, начав их инициализировать из нашего кода по requestIdleCallback. Эта функция запланирует вызов колбека с низшим приоритетом, когда наш браузер будет простаивать.

Такой подход позволяет нам экономить на FMP, хотя в TTI мы по-прежнему будем наблюдать проблемы. Это также поможет нам отложить работу прожорливых third-part скриптов.


Грузим картинки лениво


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


  1. Картинки попадающие на первый экран, грузим как обычно
  2. Оставшимся картинкам проставляем специальные атрибуты, чтобы изображение грузилось только по мере того, как пользователь проскроллит к нему
  3. Используем любую библиотеку\велосипед для ленивой загрузки картинок. Вот здесь достаточно хорошо описан подход.

HTTP2.0


Многие из пунктов HTTP2.0 могут не сэкономить заметного количества времени, но разобрать их стоит.


HTTP2.0 Мультиплексинг


В случае если сайт загружает большое количество ресурсов, HTTP2.0 с мультиплексингом может сильно помочь.
Предположим, у нас есть 6 блокирующих ресурсов на домене, которые нужно скачать, чтобы отобразить сайт. Это могут быть стили или блокирующий JS. Считаем, что пользователь уже загрузил HTML:



Для отображения контента браузер будет производить параллельные запросы к каждому ресурсу:



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


Кроме того, каждый из ресурсов будет проходить времязатратные этапы на TCP handshake и прочие издержки. Они небольшие, но все-таки присутствуют.


Именно поэтому совет нужен бандлинг и отдавать пользователю один бандл с нужным ему на этой странице CSS \ JS картинками работает.


Что такое мультиплексирование?


Мультиплексирование позволяет нам грузить ресурсы внутри одного HTTP запроса:



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


HTTP2.0 Сжатие заголовков


До http2.0 сжатия заголовков не существовало. В HTTP2.0 был анонсирован HPACK, которые отвечает за сжатие. Здесь можно почитать подробнее.


Иногда заголовки могут занимать значительный объем запроса. О том, как работает сжатие заголовков HPACK вкратце:


Используется два словаря:


  1. Статический для базовых заголовков
  2. Динамический для кастомных

Для префиксного кодирования используется Huffman coding. На практике эта экономия выходит не сильно высокой и малозаметной.


HTTP2.0 Server push


В базовом варианте server push реализовать несложно. Проще всего сделать такую штуку для статических сайтов. Идея простая: вместе с запросом html страницы, наш веб-сервер заранее знает, что пользователю нужно отдать такой-то css, такую-то картинку и такой-то JS.


Реализуется достаточно просто (nginx):


location = /index.html {    http2_push /style.css;    http2_push /bundle.JS;    http2_push /image.jpg;  }

Проверить, что все работает несложно:



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


Сжатие данных


Наиболее популярными решениями сейчас являются gzip и brotli. Вот сайт, где есть хорошее сравнение степени сжатия двух алгоритмов:



Примерно полтора года назад мы в hh.ru перешли с gzip на бротли. Размер нашего основного бандла уменьшился с 736 КБ до 657. Выиграли почти 12%.


Главным недостатком Brotli можно назвать затраты на "упаковку" данных. В среднем она тяжелее, чем gzip. Поэтому на nginx можно указать правило, чтобы он паковал ресурс и складывал его рядом, дабы не делать сжатие на каждый запрос. Ну или при сборке сразу класть сжатый вариант. Аналогично можно делать и для gzip.


Но в любом случае Brotli ваш бро! На сайтах среднего размера он позволяет сэкономить секунду-полторы загрузки на слабых 3G сетях, что сильно улучшает как пользовательские ощущения, так и метрики в том же lighthouse.


КЭШ


Примечание: описанный здесь способ не поможет вам получить дополнительные баллы у lighthouse, но поможет при работе с реальными пользователями. Этот способ положительно сказывается как на FMP, так и на TTI.
Сам кэш можно включить либо с помощью заголовков у статики, либо с помощью Service Worker.
Если мы говорим о заголовках, для этого служит 3 основных параметра:


  1. last-modified или expires
  2. ETag
  3. Cache-control

Первые два (last-modified и expires) работают по дате, второй ETag это ключ, который используется при запросе и если ключи совпадают, сервер ответит 304 кодом. Если не совпадут, то сервер отправит нужный ресурс. Включается на Nginx очень просто:


location ~* ^.+\.(js|css)$ {            ...    etag on;}

Disk cache проверяется очень просто через dev tools:



Cache-control это стратегия того, как мы будем кэшировать информацию. Мы можем или отключить его вовсе, установив cache-control: no-cache, что может быть полезным для html запросов, которые часто меняются. Либо мы можем указать очень большой max-age, чтобы данные хранились как можно дольше. Для нашей статики, мы устанавливаем вот такой Cache-control:


cache-control: max-age=315360000, public

У нас частые релизы (несколько раз в день релизим каждый сервис), поэтому код в основном бандле меняется часто. Это приводит к тому, что пользователям приходится часто скачивать наши бандлы, парсить код и т.д.
Чтобы больше разобраться в том, как современные браузеры исполняют код и используют кэши, можно почитать прекрасную статью в блоге v8
Нам интересна вот эта иллюстрация:



У нас есть "три способа" запуска нашего приложения: cold\warm и hot run. Идеально, если мы будем запускать приложение в режиме hot run, потому что на этом этапе мы не тратим время на компиляцию нашего года. Его достаточно только десериализовать.


Для того, чтобы получить hot run, пользователю нужно зайти на сайт в третий раз (за одними и теми же ресурсами) в таймслоте в 72 часа. В третий потому что во второй раз будет выполнен warm run, который будет компилировать и сериализовать данные в дисковый кэш.


Мы на самом деле можем зафорсить hot run, использовав Service Worker. Механизм следующий:


  1. Устанавливаем пользователю Service Worker;
  2. Service worker подписывается на fetch;
  3. Если происходит fetch за статикой, то сохраняем результат в кэш;
  4. Добавляем перед отправкой запроса проверку на наличие ресурса в кэше.

Этот подход позволяет нам забустить дисковый кэш и использовать hot run уже со второй загрузки сайта. Дополнительное улучшение перфоманса мы получаем для мобильных устройств, которые чаще сбрасывают свой обычный кэш.


Минимальный вариант кода для данного случая:


self.addEventListener('fetch', function(event) {        // Кешируем статику, но не картинки    if (event.request.url.indexOf(staticHost) !== -1 && event.request.url.search(/\.(svg|png|jpeg|jpg|gif)/) === -1) {        return event.respondWith(                        // проверяем наличие данных в кеше            caches.match(event.request).then(function(response) {                if (response) {                    return response;                }                                // Если данных в кеше нет, делаем запрос и сохраняем данные в кеш, который наызываем cacheStatic                return fetch(event.request).then(function(response) {                    caches.open(cacheStatic).then(function(cache) {                        cache.add(event.request.url);                    });                    return response;                });            })        );    }});

Итого


Мы рассмотрели наш Critical render path с точки зрения клиента (не углубляясь в такие вещи, как резолв DNS, handshake, запросы в БД и т.п.). Определили те шаги, которые делает браузер, чтобы сформировать первую страницу для пользователя. Поверхностно посмотрели на сложные способы оптимизации (разделения контента и т.д.)/ И разобрали более простые способы оптимизации: бандлинг, кэш, сжатие, транспорт.


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


Небольшое уточнение: здесь рассмотрены не все способы оптимизации, их заметно больше и появляются новые. Например, в chrome 85 появится свойство content-visibility, которое позволит сократить время на рендеринг за счет отложенных вычислений.

Подробнее..

В диких условиях. Итоги проектов Школы программистов в эпоху самоизоляции

12.08.2020 10:04:09 | Автор: admin
За четыре месяца занятий были прочитаны 54 лекции на двух потоках бекэнд и фронтенд, проведены несколько крутых практикумов с live-codingом. Проверены сотни заданий, на все вопросы получены две сотни ответов. Тут пришел 2020 год и сразу после того как мы сняли с елок гирлянды, всем нам самим пришлось нарядиться в маски и надеть перчатки. А теперь по порядку:



Десятая Школа Программистов hh.ru стала особенной. Юбилей, огромное количество планов, неиссякаемый поток учеников, неугасающая мотивация наших преподавателей и организаторов. Мы приложили максимум усилий, чтобы этот выпуск стал образцовым.

Десятая Школа Программистов hh.ru стала особенной. Юбилей, огромное количество планов, неиссякаемый поток учеников, неугасающая мотивация наших преподавателей и организаторов. Мы приложили максимум усилий, чтобы этот выпуск стал образцовым.

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

В этот же момент наше обучение перешло в фазу проектов, а мир IT, который мы знаем, переобулся на ходу и перешёл в режим тотальной удаленки. На Школе это тоже отразилось: нам срочно пришлось пересмотреть планы, все наши проекты и процессы обучения перевести в онлайн. И тут хочется поблагодарить наших организаторов и менторов за то, что смогли в экстренном порядке перевести всё обучение в удаленный режим, школьников, которые дошли до этого этапа, смогли сориентироваться и эффективно учиться даже из дома, и всех тех, кто оказывал нам помощь и поддержку.

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

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

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

Вот эти темы:

  • Сервис по формированию коммерческих предложений для работодателей сервис поддержки наших salesов, который позволит работать эффективнее, а нашим клиентам получать действительно индивидуальные предложения;
  • Внутренний сервис для разработчиков, позволяющий геймифицировать процесс написания кода и создавать рейтинги разработчиков по различным критериям приложение должно общаться с нашим GitHub-аккаунтом и показывать данные о тех разработчиках, которые работают быстрее, выше и сильнее;
  • Сервис для оценки качества поисковой выдачи. Наверняка вы слышали /что в интернете кто-то не прав:)/, как кто-то жалуется на то, что в результатах поиска в интернете нашлась какая-то нерелевантная информация. Так вот, у нас на сайте hh.ru тоже так бывает. Чтобы это исправить, нужен сервис, который будет позволять оценивать, насколько поиск был успешным и насколько результаты соответствуют запросу;
  • Внутренний сервис для тимлидов и разработчиков по оценке навыков мы, как и многие технологические компании, поощряем развитие сотрудников и для рекомендаций и помощи тимлидам используем систему оценки навыков. Её MVP был реализован через google forms, но функциональности очень не хватало, поэтому решили сделать свою кастомную систему;
  • Сервис для тегирования вакансий. Сейчас в нашем приложении для вакансии и резюме можно указать ключевые навыки, которые являются приоритетными метками для поиска и сравнения. Их нужно проставлять вручную и не всегда это делают правильно. Цель проекта автоматически вычислять теги на основании других полей вакансии.

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

А пока вы думаете, пара слов о том, какие технологии были использованы в проектах, и какими получились итоги работы команд над проектами:

Сервис опроса компетенций тимлида


Это полноценное веб-приложение, которое работает независимо от нашего основного hh.ru.

На фронтенде использовались:

  • react
  • react final form
  • redux
  • material-ui-kit для ускорения прототипирования интерфейса

На бекенде:


Все части приложения завернуты в Docker.

Команда проекта выделила следующие пункты как итоги своей работы: разработчики создали сервис с нуля, реализовав основу для дальнейшего развития корпоративной системы оценки навыков. Набрались опыта в работе по agile-методологиям и код-ревью. Создали сплоченную команду и хорошо повеселились.

Сервис рейтинга разработчиков


Это веб-сервис, который работает независимо от нашего основного приложения и полагается только на доступ к аккаунту GitHub.

На фронтенде использовались:

  • react
  • redux
  • final-form
  • date-fns
  • less как препроцессор для стилей

На бекенде:

  • nuts-and-bolts (NaB)
  • jersey
  • hibernate
  • PostgreSQL

Все приложение было поделено на несколько частей: крон, который обновляет статистику с GitHub, бекэнд для подсчета и анализа статистики, фронтенд для отображения графиков, информации и самого рейтинга.

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

Сервис по формированию коммерческих предложений для работодателей


Это приложение реализовывалось как отдельный сервис внутри нашей экосистемы микросервисов, относящихся к hh.ru

На фронтенде использовались:

  • react
  • react final form
  • redux
  • material-ui-kit для ускорения прототипирования интерфейса

На бекенде:

  • nuts-and-bolts (NaB)
  • jersey
  • hibernate
  • kafka как технология для передачи событий от систем бизнес-аналитики и веб-приложения к новому сервису
  • PostgreSQL

Одним из основных вызовов для этой команды стала необходимость также поднять у себя копию базы MSSQL Server, так как именно там хранятся данные бизнес-процессов, аналитики и клиентов. Отдельную анонимизированную копию с данными использовали в дальнейшем для демонстрации и тестирования.

Сервис для улучшения качества поисковой выдачи


На фронтенде использовались:

  • react
  • redux
  • less, как препроцессор для стилей

На бекенде:

  • nuts-and-bolts (NaB)
  • jersey
  • hibernate
  • PostgreSQL

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

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

Сервис для тегирования вакансий


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

На фронтенде для реализации админки использовались:

  • react
  • redux
  • less

На бекенде для сбора и анализа данных:

  • nuts-and-bolts (NaB)
  • jersey
  • hibernate
  • PostgreSQL
  • Apache Lucene
  • Яндекс.Танк для нагрузочного тестирования

Основным челленджем стало погружение в ML, изучение метрик TF-IDF, PMI и их производных. На финальном демо команда поделилась тем, что основными трудностями при реализации алгоритма стали: отсутствие достаточного времени для анализа данных, отсутствие метрик качества, чтобы сравнивать алгоритмы и очень большая вариативность в параметрах модели.

Мы уверены, что идеи и их реализации получились достойными нашей юбилейной Школы. А 22 выпускника станут отличными программистами да чего скромничать, они уже стали. 11 из них мы позвали работать к нам, а остальным предоставим рекомендации и приложили усилия, чтобы ребята попали в хорошую компанию!

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

Кстати, набор в одиннадцатую Школу Программистов в самом разгаре. Более того, в этом году мы полностью переходим в онлайн, и в связи с этим мы увеличили количество мест до 40, а заявки теперь можно подавать из любого города нашей необъятной! Если этот опыт окажется успешным, то мы приложим все усилия, чтобы Школа осталась online навсегда.

Обучение полностью бесплатное.

Алгоритм поступления проще, чем сортировка пузырьком:

1. заполни анкету на сайте

2. выполни тестовое задание

3. пройди online-собеседование с нашими сотрудниками

Мы ждем тебя в нашей Школе!


Хорошего вам дня и вспоминая Мольера, подведем итог: Как приятно знать, что ты что-то узнал!
Подробнее..

Оптимизация производительности фронтенда. Часть 2. Event loop, layout, paint, composite

03.09.2020 14:17:53 | Автор: admin

Ночь. Стук в дверь. Открыть. Стоят двое. "Верите ли вы в Event loop, нашу главную браузерную цепочку?" Вздохнуть. Закрыть дверь. Лечь досыпать. До начала рабочего дня еще 4 часа. А там уже ивент лупы, лейауты и прочая радость


В первой части мы говорили о первой загрузке и работе с ресурсами. Сегодня я расскажу о второй части оптимизации производительности фронтенда. О том, что происходит с нашей страницей, когда она загружена, на что уходит процессорное время и что с этим делать. Ключевые слова: event loop, paint \ repaint, layout \ reflow, composite.



Завязка. Вопросы для самопроверки


Если хочется сразу начать поглощать контент статьи, пропустите этот раздел


Event loop популярен не только в ежедневной работе, но и на собеседованиях. И пойди разберись, где он популярнее. Существует три вопроса, которые можно задать себе, чтобы проверить, понимаете ли вы этот браузерный механизм. Два из них завсегдатаи собеседований, а вот третий показывает понимание event loop для своей практики. Они довольно простые, но будут расставлены по возрастанию сложности:


  1. Будет ли выведено "1" в консоль? Почему?


    function loop() {Promise.resolve().then(loop);   }setTimeout(() => {console.log(1)}, 0);loop();
    

  2. Есть сайт, а на сайте ссылка, у которой при наведении cursor: pointer ставится через :hover стиль CSS и кнопка, у которой также по :hover меняется background-color c серого на синий. Добавляем скрипт:


    while (true);
    

    Вопрос: Что будет если навести мышку на ссылку? А на кнопку? Почему?


  3. Как анимировать выпадающий элемент по height с 0 до auto? Здесь важно обсудить способы c помощью JS и/или CSS. Кстати, если гуглить этот вопрос, то stackoverflow вначале предлагает неверный ответ. Суть понимания event loop и работы браузеров сводится к тому, как замерить то самое height = auto с помощью JS.



Наша цель


Прийти к достаточно глубокому пониманию вот этой схемы:


Идти к ней будем постепенно, с подробными остановками на каждом из этапах


Event Loop


В старых операционных системах была похожая штука. Выглядела она условно вот так:


while (true) {  if (execQueue.isNotEmpty()) {     execQueue.pop().exec();    }}

Такой код всегда забивал ЦПУ процессоров в 100%. Что и было в старых версиях windows. Сейчас планировщики операционных систем очень сложные. Там есть и приоритизация, и исполнение, и различные очереди.


Итак. Чтобы начать, нам понадобится какой-то бесконечный цикл, который будет опрашивать, есть ли у нас задачи. Например такой:



Теперь нам нужно откуда-то эти задачи получать.
Зададим себе вопрос: что является триггером, чтобы наш JS код начал выполняться?
Это может быть:


  1. Браузер загрузил тег <script>
  2. Отложенные задачи: setTimeout, setInterval, requestIdleCallback
  3. Ответ от сервера через XmlHttpRequest, fetch и т.п.
  4. События и вызовы подписчиков через браузерное API: click, mousedown, input, blur, visibilitychange, message и так далее, множество их. Часть из них вызывается пользователем (кликнул на кнопку, альт-табнулся и т.д.).
  5. Изменение состояния промисов. В некоторых случаях, это происходит за пределами нашего JS кода
  6. Обзерверы, такие как: DOMMutationObserver, IntersectionObserver
  7. RequestAnimationFrame
  8. Что-то еще? :)

Почти все эти вызовы планируются через WebAPI (его еще иногда называют браузерным API). То есть:


  1. Мы вызываем setTimeout(function a(){}, 100)
  2. WebAPI откладывает задачу на 100мс
  3. Через 100мс, WebAPI кладет function a() в очередь (TaskQueue)
  4. Event Loop на нужный виток подбирает задачу

Нашему JS коду нужно как-то взаимодействовать с DOM. Считывать размеры элементов, добавлять свойства, рисовать какие-нибудь выпадающие элементы. Делать интерфейс живым.


Это накладывает ограничения на отрисовку элементов. Мы не можем просто так взять и запустить 2 потока, чтобы в одном выполнялся JS, а в другом CSS и отрисовка. Это потребовало бы большого количества синхронизаций кода, либо могло бы привести к неконсистентому исполнению. Поэтому и JS, и расчет отображения элементов (расположения, цвета и т.д.) работают в одном потоке. Значит в нашем Event Loop кроме JS есть "отрисовка". Давайте поместим ее в отдельную очередь и назовем render queue:



Итак, у нас есть две точки входа. Одна для большинства JS операций, другая для отрисовки. Теперь нам нужно разобраться, что же такое SomeJSTasks и как оно работает.


Для исполнения большей части JS кода у нас есть 2 очереди:


  • TaskQueue здесь почти все ивенты, отложенные задачи и т.п. Задача из этой очереди Task
  • MicroTaskQueue здесь обработка промисов и MutationObserver. Из этой очереди: MicroTask

Обновление экрана


Event loop неразрывно связан с обновлением экрана. В нем исполняется не только JS код, но и рассчитываются кадры страницы.


Браузеры стараются отображать изменения на странице максимально быстро. Здесь существует ряд ограничений:


  1. Хардварные ограничения: частота обновления экрана
  2. Софтверные ограничения: настройки ОС, браузера, настройки энергосбережения и т.д.

Большинство современных устройств и настроек поддерживает 60FPS. Это число будет и у подавляющего количества браузеров. Поэтому далее в статье будем пользоваться 60FPS, но в голове держим, что это число может быть другим.


Для нашего Event Loop это означает, что на исполнение тасков отводится примерно 16.6мс (при 60FPS)


Что такое TaskQueue


Как только задачи набиваются в очередь Task, на каждом витке event loop мы достаем верхний таск и исполняем его. Если у нас остается достаточно времени, т.е. в render queue не появилось тасков, мы можем достать еще одни таск и т.д., пока не придет время для отрисовки нового кадра пользователю.


Разберем пару примеров:



У нас в очереди лежит 3 таска TaskA, TaskB, TaskC. Event Loop берет первый таск и исполняет. Он занимает 4 мс. Затем Event Loop проверяет другие очереди (Microtask queue и render queue) они пустые. Поэтому EventLoop исполняет второй таск. Второй таск занимает еще 12 мс. Итого 16 мс. Браузер добавляет в Render queue таски на отрисовку нового кадра, Event Loop берет эти таски. Они занимают примерно 1 мс. После этого Event Loop переходит обратно исполнять таски из TaskQueue.


Event Loop не умеет предсказывать, сколько времени будет исполняться тот или иной таск. И он не умеет приостанавливать таск для того, чтобы отрендерить страницу пользователя, так как браузерный движок не может знать, можно ли отрисовать то, что изменил JS или это только подготовка элементов для отображения их пользователю. То есть: Пока исполняется синхронный JS код, изменения не будут отображены пользователю, но могут быть посчитаны.


Поэтому вот другой пример:



Здесь у нас только два таска в нашей очереди. Первый таск выполнялся 240 мс, так как 60FPS предполагает рендер кадра каждые 16.6 мс. А это означает, что мы пропустили примерно 14 кадров в эту секунду. Поэтому как только таск закончится, event loop начнет выполнять ожидающие таски из render queue, чтобы нарисовать кадр. Важный момент: то, что мы пропустили 14 кадров НЕ означает, что мы 15 раз будет рисовать кадры без остановки.


Прежде чем перейти к микротаскам, разберем такую штуку, как стек вызовов:


Стек вызовов


Стек вызовов это список, который отражает, какие функции сейчас вызываются и куда будет совершен переход, после окончания исполнения текущей функции.


Разберем пример:


function findJinny() {  debugger;  console.log('It seems you get confused with universe');}function goToTheCave() {  findJinny();}function becomeAPrince() {  goToTheCave();  }function findAFriend() {   // \_()_/}function startDndGame() {    const friends = [];  while (friends.length < 2) {    friends.push(findAFriend());  }  becomeAPrince();}console.log(startDndGame());

Мы запускаем этот код в консоли браузера и останавливаемся на дебаггере. Как будет выглядеть наш стек вызовов?


Мы начали наш стек из inline кода, значит самым верхом будет эта строчка. Как правило, в хром она будет указана просто ссылкой на нее. Обозначим ее inline. Дальше мы падаем в startDndGame, и вызываем несколько раз findAFriend. Однако он не попадет в итоговый callStack, потому что из всех findAFriend мы вышли. Итого наш стек вызовов будет выглядеть вот так:



Что же такое микротаски?


Микротаски ограничены. Это либо колбеки для promise, либо mutationObserver. Сама идея появления микротасок довольно костыльна, но она дает нам некоторые преимущества и недостатки по сравнению с TaskQueue.


Главная особенность микротасков: они будут исполнены сразу же, как только освобождается стек вызовов.


Например, в случае с этим стеком вызовов:



Если у нас в любой момент исполнения этого кода появляется fulfilled или rejected promise, то он будет исполнен сразу же, как только закончатся элементы в нашем стеке вызовов.


Любой JS код записывается в стек вызовов, что логично. Окончание стека вызовов, по сути, и есть окончание таска или микротаска. Интересный факт: Микротаски могут порождать другие микротаски, которые будут вызываться сразу же по освобождению стека вызовов. То есть рендер страницы может откладываться бесконечно. Это главный минус микротасков:



Если у нас 4 микротаски в очереди MicrotaskQueue, то они будут исполнены друг за другом, откладывая ререндер.


Недостаток микротасок также и их преимущество. Например, момент срабатывания MutationObserver это когда изменения на странице произошли, но пользователь их не увидит, пока колбек mutation observer не исполнится. Таким образом, мы можем эффективно управлять контентом, который видит пользователь.


Итого, наша картина event loop:



Мы уже разобрали, что таски могут следовать друг за другом, пропуская RenderQueue (если в ней нет тасок).


Но что исполняется внутри RenderQueue?


Рендер кадра можно поделить на несколько основных этапов. Каждый этап внутри может быть разделен на другие подэтапы (мы посмотрим это на примере Layout):



Остановимся на каждом этапе подробнее:


RequestAnimationFrame (raf)



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


Позволяет нам подписаться и запланировать изменения в DOM прямо перед отрисовкой страницы. Несколько важных фактов:


  1. Колбек для raf принимает аргумент: DOMHighResTimeStamp количество миллисекунд прошедших с начала жизни документа. Поэтому внутри колбека можно не брать время через perfomance.now, нужное время уже присутствует


  2. Аналогично setTimeout, raf возвращает дескриптор (id), поэтому запланированный raf можно отменить через cancelAnimationFrame.


  3. Если пользователь переключился на другой таб, свернул браузер перерисовки страницы не происходит. Raf тоже не вызывается


  4. JS код, который изменяет размеры элементов, считывает свойства, может зафорсить requestAnimationFrame


  5. Как посмотреть, как часто браузер обновляет кадр? Вот так:


    const checkRequestAnimationDiff = () => {let prev;function call() {    requestAnimationFrame((timestamp) => {        if (prev) {            console.log(timestamp - prev); // Должно быть в районе 16.6 мс, при 60FPS        }        prev = timestamp;        call();    });}call();}checkRequestAnimationDiff();
    

    Вот например, мой эксперимент (запускал на hh.ru):



  6. Сафари вызывает(ал) raf после отображения кадра, а не до. Пруф: https://github.com/whatwg/html/issues/2569#issuecomment-332150901



Style (recalculation)



Браузер пересчитывает стили, которые должны примениться из-за изменений, запланированных JS. Здесь же происходит вычисление активных media queries


Пересчет включает в себя как прямые изменения a.styles.left = '10px' так и те, которые описываются через CSS файлы, например element.classList.add('my-styles-class') Все они будут пересчитаны с точки зрения работы CSSOM и получения Render tree.


Если запустить профилировщик и открыть сайт hh.ru, вот тут можно найти время, потраченное на Style:



Layout



Вычисление слоев, расчет положения элементов на странице, их размеров, взаимного влияния друг на друга. Чем больше DOM элементов на странице, тем тяжелее эта операция.
Современные браузеры могут организовывать рендер и layout дерева по-разному. Например, в Хроме, кроме Layout в профилировщике вы увидите такие процессы как update layer tree и layout shift, который и отвечает за сдвиг элементов относительно друг друга.
Здесь на графике выделенная строка тот самый Layout сайта hh.ru при первом открытии приложения.



Layout это очень болезненная операция для современных веб-сайтов. Болезненна она потому, что наиболее тяжелый style recalculation происходит при первом рендере, а вот Layout происходит при:


  1. Чтении свойств влияющих на размер и положение элементов (offsetWidth, offsetLeft, getBoundingClientRect, и т.д.)
  2. При записи свойств, влияющих на размер и положение элементов, за исключением некоторых свойств, вроде transform и will-change. Для transform браузер задействует composition процесс, а в случае с will-change браузер попытается использовать composition. Вот здесь список актуальных причин.

Layout отвечает за:


  • Вычисление слоев
  • Расчет взаиморасположения элементов на слое
  • Расчет влияния одних элементов на другие

Layout (а вместе с ним raf и Style) может происходить не в свою очередь, когда нужно отрендерить страницу и применить изменения, а тогда, когда JS изменил размеры элементов или считал данные. Такой процесс называется force layout. Вот полный список свойств, который приводит браузер к остановке исполнения JS и вызову Layout.


div1.style.height = "200px"; // Изменили размер элементаvar height1 = div1.clientHeight; // Считываем его размер

Браузер не сможет рассчитать clientHeight нашего div1 без пересчета его реальных размеров. В этом случае, браузер приостановит исполнение JS кода (совсем) и выполнит по очереди: Style (чтобы определить, что изменять), Layout (чтобы определить, как изменилось). Layout должен рассчитать не только элементы, которые находятся перед, но и после div1. Современные браузеры оптимизируют расчет так, чтобы не пересчитывать абсолютно все дерево. Но в худшем случае этого не избежать. Процесс пересчета элементов называется Layout Shift. Вот так его можно посмотреть (справа список всех элементов, которые были сдвинуты и модифицированы во время Layout):



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


div1.style.height = "200px";var height1 = div1.clientHeight; // <-- layout 1div2.style.margin = "300px";var height2 = div2.clientHeight; // <-- layout 2 

В этом коде браузер запланировал изменение высоты div1 до 200px, но затем получил задачу на считывание. Пришлось сделать layout. Затем ситуация повторилась. Обратите внимание, браузер не произвел layout на операциях записи. Потому что в этот момент нужные данные у него уже были.


Давайте сгруппируем чтение и запись:


div1.style.height = "200px";div2.style.margin = "300px";var height1 = div1.clientHeight; // <-- layout 1var height2 = div2.clientHeight;

Такая группировка позволяет нам избежать второго layout, потому что браузер уже посчитал расположение элементов.


Layout работает со "слоями" в нашем потоке. Посмотреть на то, как браузер выделяет слои можно в chrome devtools -> More tools -> layers:



Таким образом, наш event loop превращается из одного витка в несколько, потому что и на этапе tasks, и на этапе microtasks мы можем запустить force layout:



Базовые советы для оптимизации layout:


  1. Уменьшать количество DOM нод
  2. По возможности избегать force layout
  3. Компоновать чтение и запись свойств

Paint



На этом шаге отрисовываем элементы, применяем стили color, background и т.д. Во время первого рендера сайта мы потратим на это достаточно времени:



В целом, эта операция не критична, так как в общем случае она не занимает много времени. Особенно после первого рендера.


После того, как пиксели нашей страницы обзавелись цветом, мы приступаем к следующему шагу Composition.


Composition



Это единственная операция, которая в классическом веб-сайте исполняется с помощью GPU. На этом этапе браузер исполняет специфические CSS стили, например transform.


Задача этой операции: совместить слои и получить готовый кадр.


Важное дополнение: само по себе свойство transform: translate не включает рендер элемента на видеокарте. То есть сделав transform: translateZ(0) вы не "перенесете элемент на видеокарту", это заблуждение.


Однако современные браузеры могут сами переносить часть операций на GPU. Лучше всего с актуальной информацией по этому вопросу знакомиться из исходников.



Именно с помощью transform советуют создавать сложные анимации. Секрет довольно прост:


  1. Анимация на transform позволяет нам не вызывать layout каждый кадр, мы экономим время
  2. Она позволяет нам избавиться от артефактов "мыльца" при анимациях, которые иногда бывают при анимировании left, rig ht, top, bottom

В то же время, верстать весь сайт только через transform не рекомендуется, так как это приводит к своим тормозам. Важно совмещать правильно верстку.


Как оптимизировать рендер?


Самая тяжелая операция для рендера кадра (для большинства сайтов) layout. Сверху на графиках был layout для рендера главной страницы hh.ru. При сложных анимациях каждый кадр может пересчитываться все элементы в DOM, а это означает, что каждый кадр вы будете тратить по 13-20 мс впустую. Это ведет к пропуску кадров и проблемам с производительностью вашего сайта.


Несколько примеров:



Мы можем пропустить Layout, если изменяем цвета, фоновое изображение и т.д.



Мы можем пропустить layout и paint, если изменения основаны на стиле transform и не затрагивают чтение свойств.


Итого, советы по оптимизации можно сгруппировать так:


  1. Выносите анимации на CSS. Исполнение JS кода не бесплатно
  2. Изменяйте transform свойство для перемещения объектов
  3. Используйте will-change свойство это свойство, которое позволяет браузерам "подготовить" дом элемент к изменениям определенных стилей. Важно это свойство помогает браузеру понять, что разработчик запланировал изменить. Это свойство нельзя применять к большому количеству элементов, иначе вы получите тормоза.
  4. Используйте батчевые изменения в DOM
  5. Используйте requestAnimationFrame для планирования изменений в следующем кадре
  6. Комбинируйте задачи на запись \ чтение свойств элементов. Обращайте внимание на вызовы свойств, которые форсят layout.
  7. При возникновении сложных ситуаций, лучше всего запустить профилировщик и посмотреть на частоту и время вызовов. Это даст вам информацию о том, какой этап тормозит, оптимизируйте каждый этап отдельно.

Вместо заключения


В этой части мы разобрали каким образом работает Runtime в нашем браузере, его сильные и слабые стороны. Это позволяет нам:


  1. Понимать, как лучше писать код
  2. Знать в какую сторону смотреть при появлении проблем

Также мы получили достаточно глубокое понимание event loop и его составляющих:


Подробнее..

Перфоманс фронтенда как современное искусство графики, код, кулстори

17.09.2020 12:16:31 | Автор: admin

Всем привет. В предыдущих статьях мы говорили о базовых вещах оптимизации: раз и два. Сегодня я предлагаю с разбега окунуться в одну часть из тех задач, которыми занимается команда архитектуры фронтенда в hh.ru.


Я работаю в команде архитектуры. Мы не только перекладываем файлики из одной папки в другую, но и занимаемся кучей других вещей:


  • Перфоманс приложения
  • Инфраструктура: сборка, тесты, пайплайны, раскатка на продакшене, инструменты для разработчика (например бабель-плагины, кастомные eslint правила)
  • Дизайн-система (UIKit)
  • Переезд на новые технологии

Если покопаться, можно найти много интересного.


Поэтому, давайте поговорим о перфомансе. Команда фронтенд архитектуры ответственна как за клиентскую часть, так и серверную (SSR).


Я предлагаю посмотреть на метрики и разобраться, как мы реагируем на различные триггеры. Статья будет разбита на 2 составляющие. Серверную и клиентскую. Графики, код и кулстори прилагаются.



В этой статье мы поговорим о том, как трекаем, какие (интересные) результаты есть. Для теории и что-почем лучше обратиться к первой статье.


Клиент


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


Процесс построения правильных графиков был не единоразовым событием. Мы построили первую версию метрик. Осознали, что они не работают, провели работу над ошибками, перестроили. Повторили несколько раз для части метрик. В итоге сейчас мы трекаем:


FMP (first meaningful paint)



FMP трекается для двух частей сайта: меню и конец контента. Каждая линия отдельная страница. На графики выводим TOP самых тяжелых страниц. Практически все наши графики отображают 95 перцентиль. Эти не стали исключением.


Тот же график, но с отображением только одной страницы:


Для нас FMP наиболее важный график и вот почему:


  • Сайт hh.ru с точки зрения соискателя текстовый. Открыть поиск, кликнуть на вакансию, прочитать, решить ок или нет, откликнуться.
  • С точки зрения работодателя сайт частично текстовый. Открыть поиск или разобрать отклики это работа с резюме кандидата.

Вопрос правильного измерения FMP один из наиболее важных для нас. Что мы понимаем под FMP? Это количество и время загрузки критических для рендера ресурсов.


Кажется, что FMP может считаться как-то так:


requestAnimationFrame(function() {   // Перед первым рендером взяли время когда renderTree было сформировано  var renderTreeFormed = performance.now();  requestAnimationFrame(function() {    // Здесь данные отрендерены пользователю    var fmp = performance.now();    // Сохраняем для дальнейшей отправки на сервер    window.globalVars.performance.fmp.push({      renderTreeFormed: renderTreeFormed,      fmp: fmp    })  });});

Здесь есть несколько интересных моментов:


  1. Если вставить этот код после меню и перед закрытием body, то получаемые данные могут и будут отличаться (при условии, что у вас вся страница не умещается в 1 экран). Дело в том, что браузеры будут пытаться оптимизировать рендер.
  2. Это решение не работает ()/


Дело в том, что браузер не будет вызывать raf и будет сильно замедлять вызовы setTimeout\interval когда вкладка не является активной. Поэтому мы получим некорректные данные.


Это означает, что в текущем решении нам нужно как-то обрабатывать этот случай. Здесь на помощь приходит PageVisibility API:


window.globalVars = window.globalVars || {};window.globalVars.performance = window.globalVars.performance || {};// Помечаем, была ли страница активна в момент загрузкиwindow.globalVars.performance.pageWasActive = document.visibilityState === "visible";document.addEventListener("visibilitychange", function(e) {    // Если что-то изменилось  реагируем    if (document.visibilityState !== "visible") {        window.globalVars.performance.pageWasActive = false;    }});

Используем полученные знания в FMP:


requestAnimationFrame(function() {   // Перед первым рендером взяли время когда renderTree было сформировано  var renderTreeFormed = performance.now();  requestAnimationFrame(function() {    // Здесь данные отрендерены пользователю    var fmp = performance.now();    // Сохраняем для дальнейшей отправки на сервер,     // только в случае, если страница была все время активной    if (window.globalVars.performance.pageWasActive) {        window.globalVars.performance.fmp.push({          renderTreeFormed: renderTreeFormed,          fmp: fmp        });        }  });});

Окей, теперь мы не отправляем заведомо некорректные данные. Качество данных стало лучше.
Следующая проблема: один из популярнейших паттернов для наших пользователей зайти на страницу поиска, нащелкать "открыть в новой вкладке" вакансий, а затем переключаться по вкладкам. Мы теряем очень много аналитики.


Нужна ли нам такая сложная структура, учитывая, что она теряет данные? На самом деле нет, нам важно определить, когда renderTree (почти) готова, чтобы понимать, загружены ли ресурсы и дошел ли браузер до конца меню, контента.


Поэтому мы решили задачу изящнее. В нужные места мы стали вставлять вот такие метки (и fmp_menu для меню):


<script>window.performance.mark('fmp_body')</script>

На их основе мы и строим графики:


Такой способ удобен: мы всегда можем добавить новую контрольную точку и оптимизировать отображение вакансии, резюме и поисковой выдачи.


Несколько интересностей:


  1. На FMP у нас настроен триггер. Чтобы реагировать на массовые проблемы, он настроен на 3 минуты бесперебойных проблем. Поэтому "одиночные" выбросы просто игнорирует.
  2. Критический FMP: 10 секунд. В эти моменты мы смотрим на проблемные урлы и на выдаваемые нами данные.
  3. У нас было несколько интересных историй, когда FMP начинал зашкаливать. Часто эта метрика может коррелировать с массовыми проблемами с сетью у пользователей, а также с проблемами на наших бекендах. Метрика получилась очень чувствительной
  4. Если брать статистику, то мобильные телефоны получаются производительней настольных машин! Вот пример, на котором я взял время с большой нагрузкой в рабочий день и построил графики по одному url-у. Слева мобильники, справа десктопы, 95 перцентиль:

Разница незначительная, но она есть. Я лично склонен полагать, что телефоны люди обновляют чаще, чем компьютеры.


Вторая метрика TTI


В целом, для работы с TTI более чем достаточно взять готовый код у гугла


У нас для вычисления TTI свой велосипед. Он нам нужен, потому что мы завязываемся внутри на Page Visibility API, о котором я писал выше. К сожалению, TTI полностью завязан на longtask и у нас нет опции "посчитать его как-нибудь по-другому", поэтому мы вырезаем пласт метрик, когда пользователь уходит со страницы.


Посмотреть код TTI
function timeToInteractive() {    // Ожидаемое время TTI    const LONG_TASK_TIME = 2000;    // Максимально ожидаемое время TTI, если не произошло лонгтасок    const MAX_LONG_TASK_TIME = 30000;    const metrics = {        report: 'TTI_WITH_VISIBILITY_API',        mobile: Supports.mobile(),    };    if ('PerformanceObserver' in window && 'PerformanceLongTaskTiming' in window) {        let timeoutIdCheckTTI;        const longTask = [];        const observer = new window.PerformanceObserver((list) => {            for (const entry of list.getEntries()) {                longTask.push(Math.round(entry.startTime + entry.duration));            }        });        observer.observe({ entryTypes: ['longtask'] });        const checkTTI = () => {            if (longTask.length === 0 && performance.now() > MAX_LONG_TASK_TIME) {                clearTimeout(timeoutIdCheckTTI);            }            const eventTime = longTask[longTask.length - 1];            if (eventTime && performance.now() - eventTime >= LONG_TASK_TIME) {                if (window.globalVars?.performance?.pageWasActive) {                    StatsSender.sendMetrics({ ...metrics, tti: eventTime });                }            } else {                timeoutIdCheckTTI = setTimeout(checkTTI, LONG_TASK_TIME);            }        };        checkTTI();    }}export default timeToInteractive;

Выглядит TTI вот так (95", TOP тяжелых урлов):


Может появиться вопрос: почему TTI такой большой? Дело в:


  1. Рекламе, которая грузится по requestIdleCallback
  2. Аналитике
  3. 3d party скриптах

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


Время инита приложения без гидрейта (рендера)


95" TOP тяжеленьких:


Зачем? Мы понимаем, как много JS кода мы грузим и сколько времени нужно, чтобы он проинитился.
По этому графику мы понимаем, какие страницы наиболее загружены js кодом, при больших выбросах во время релизов, можно сказать, где сломались чанки приложения.


Но самым показательным для нас на клиенте в плане JS рантайма является


Гидрейт


Замеряем время по окончанию инита без рендера, делаем hydrate, который принимает третьим аргументом колбек, в колбеке замеряем еще раз время и сохраняем разницу:



Если совместить график инита и гидрейта, можем сделать несколько выводов:


  1. Если самая тяжелая страница в гидрейте и ините одинаковая, то её тяжесть обоснована огромным количеством разных компонентов и логики, а не данных (большим списком резюме \ вакансий, чем-то еще)
  2. Если график гидрейта не коррелирует с графиком инита проблема в количестве данных для рендера. Здесь выделяются графики поисковых страниц, они сильнее зависят от тяжести данных для рендера:

Чем помогают эти графики?


  1. Как только видим всплеск в FMP, идем в остальные графики клиента и смотрим, увеличилось ли время гидрейта или инита. Они дают понять, была ли проблема с клиентом или нужно смотреть на сеть, SSR и бэкенды.
  2. Триггеры позволяют понимать, когда были проблемы во время релизов. На моей памяти это было лишь однажды. Статика крайне редко ломает релизы настолько сильно.

LongTasks


PerformanceObserver позволяет трекать тяжелые таски у пользователей:


История появления данного графика занятна:


Весна, поют птички, приходят разработчики в офис (да, это не 2020!). Прилетает сообщение от техподдержки: сайт не работает! Разработчики быстро просыпаются и пытаются воспроизвести проблему. Количество обращений растет.


Выясняется довольно занятная штука: поставщик рекламы добавил новый баннер с кормом для собак, где блокирующий js постоянно вызывал reflow, который надежно убивал event loop ровно на 30 секунд.


В общем, владельцы собак пострадали. К сожалению, в то время в команде архитектуры владельцев собак не было. Сейчас не проверяли, не довелось ;)


Из этой истории мы вынесли 2 урока:


  1. Решить, что сделать с рекламой
  2. Трекать Longtasks. Сказано сделано.

Что еще?


Это не все графики, которые мы строим для клиента. У нас есть время инициализации не реакт-компонентов, на старых страницах, rate ошибок в сентри + триггер, чтобы быстро реагировать при проблемах, FID. Но они практически не использовались нами в аналитике.


С клиентом разобрались, переходим к серверу.


Сервер и кулстори


Здесь все намного проще. Сервер это наше игровое поле. Мы его контролируем, мы знаем окружение и что на него может повлиять.


Что самое важное для серверов? Нагрузка, время ответа и понимание на что это время потратили.


У нас огромное количество графиков, которые трекают память, CPU, диски массу всего. Остановимся на специфичном и наиболее часто используемом нашей командой для SSR. Вот так выглядят наши серверные графики:


График запросов и ошибок


График времени ответа http клиента


Каждая линия отдельный урл, здесь TOP наиболее проблемных урлов. Все триггеры настроены на 95 перцентиль. На графике мы видим, что был некий всплеск в 12:10 и затем одному урлу стало не очень хорошо в 12:40. На этих графиках "криминала нет", но как только потолок в 400мс пробивается, в это время зажигается триггер и один человек из команды бодро марширует во внутренние сервисы с логами, кибану и разбирает "что это было". Также локализовать проблему помогают дополнительные графики:


Время рендера и парcинг



Здесь уже видно, что первая проблема коррелирует с увеличением parse time.


Копаем дальше и видим график утилизации CPU. Здесь дискотека:



Это поведение характерно, когда происходит релиз сервиса. Каждый сервис у нас релизится как минимум 1 раз в день. Обычно чаще.


Причина первого скачка на графике становится довольно понятной: у нас был релиз. В 12 часов нагрузка на сервис достаточно высокая, поэтому на часть инстансов прилетело больше запросов. Но все равно это порядка 150мс, до 400мс еще далеко.


Бывает другая ситуация. Собственно тот случай и сподвиг меня написать эту статью. Пятница, утро, вместо кофе видишь в slack зажегшийся триггер:



Одному из урлов надоело жить.


В то же время render и parse time чувствуют себя отлично:



На графике видно, что количество ошибок увеличивается:



Грепаем логи, из них извлекаем ошибку


TypeError: Cannot read property 'map' of undefined    at Social (at path/to/module)

Кажется, сервер стал асоциальным.


Проблема локализована, хотфикс выпущен, графики стабилизируются, кофе остыл:



И еще один пример, когда parse time имеет значение:



Видим постепенно растущий график времени ответа сервиса. Но время рендера совсем не растет. А время парсинга наоборот крайне подозрительно коррелирует с временем ответа:



У нас SSR работает as a service. То есть у нас BFF, которая ходит в наш node.js сервис, для рендера данных. Сама BFF написана на питоне.


Подобная корреляция между временем ответа, парсингом и полным отсутствием влияния на время рендера возможна, как мне кажется, только в одном случае: BFF посылает с каждым разом все больше и больше данных, которые никак не используются node.js. По простому BFF дала течь. Сервис правится, ситуация разруливается.


Сама протечка получилась небольшой и на графиках используемой памяти в BFF это было практически незаметно. А вот на времени ответов \ парсинге это сказалось отрицательно.


Мораль


Сей басни такова:


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


Чем больше информации вы трекаете, тем проще понимать что происходит.


С первого взгляда, на клиенте должно работать тоже самое. Но там все сильно сложнее. Чего стоит только пример с FMP. Кроме этого почти все клиенские графики, имеют выбросы, достаточно сложно поддаются аналитике, мы не знаем всех условий, в котором находится наш пользователь. Однако аналитика по-прежнему возможна. Мы можем локализовать проблему: загрузка, инит до гидрейта, рендер, реклама и\или аналитика, лонгтаски.


Все это позволяет нам обрести глаза и своевременно реагировать на проблемы.

Подробнее..

Разбор вступительных задач Школы Программистов hh.ru

26.10.2020 10:06:40 | Автор: admin
20 октября закончился набор в Школу программистов hh. Он длился два с половиной месяца. Мы благодарим всех участников, уделивших время попытке поступить к нам. Надеемся, вам понравились задания и вы получили удовольствие от их решения!

Приглашаем посмотреть задания, которые мы использовали для онлайн-этапа отбора, а также решения для них.
image

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

CheckUp


После заполнения анкеты проходит первый этап отбора на автоматизированной платформе тестирования CheckUp. Это собственный продукт, прототип которого был разработан учениками нашей Школы пару лет назад. С тех пор он постоянно поддерживается, обрастает новыми функциями и помогает нам организовывать отбор в Школу.

Интерфейс отправки решений
Часть интерфейса CheckUp

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

Числа


Только факты: из 3700 учетных записей лишь 1318 отправили на проверку хотя бы одно решение. С одним заданием справились 483 участника, а с обеими задачами 283.
Из людей, которые отправили хотя бы одно решение для первого задания справились с ним 35% участников, для второго задания этот процент гораздо выше почти 60%. Возможно это связано с тем, что первое задание кажется легче, и попробовать решить его проще.
В общей сложности системой было проверено 8986 решений, а нами и участниками написано 3720 сообщений в чате.

То, ради чего все пришли


Как мы и обещали в чате поддержки CheckUp, в этой статье я подробно разберу задания этого года и дам ссылки на тесты, которые мы использовали для проверки.

Преобразования слов


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


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

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

1. Наш алфавит конечен
Во втором примере мы использовали дополнительную букву. Дело в том, что в нашем алфавите их всего 33, и если все они используются и в первой, и во второй подстроке нам негде взять букву для замены. Самый простой пример такого:
абвгдеёжзийклмнопрстуфхцчшщьыъэюя бавгдеёжзийклмнопрстуфхцчшщьыъэюя
Здесь используются все 33 буквы и мы не можем поменять местами б и а.

2. Необходимо преобразовать слово только в одну сторону
Было несколько решений, которые использовали проверку слов на изоморфизм, но она избыточна третий пример преобразовать нельзя. А вот если поменять местами слова добр бобр, тут преобразование возможно.

3. Для замены хватит всего одной буквы
Из первого пункта может сложиться ощущение, что если в левой подстроке есть весь алфавит, то преобразование всегда невозможно, однако это неверное заключение. Если во второй строке используется не весь алфавит, можно использовать ту букву, которой там нет:
абвгдеёжзийклмнопрстуфхцчшщьыъэюя бабгдеёжзийклмнопрстуфхцчшщьыъэюя
Здесь во второй подстроке, вместо буквы в стоит буква б, позволяющая сначала заменить а на в, а затем использовать освободившуюся а. (а в: вбв..., б а: вав..., в а: баб...)

4. Что делать со строками разной длины
На этот и следующий вопрос мы отвечали в чате, но их тоже укажу, на случай, если кому-то интересно.
Многие сообразили, что как ни преобразовывай буквы в таких строках, одинаковыми они не станут. Так и есть, для этих строк можно было сделать отдельную проверку и сразу возвращать 0. Фраза про строки разной длины в дополнительной информации к условию была добавлена для того, чтобы решения участников не падали с ошибкой на таких данных.

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

Если учесть всё это и вынести в отдельные условия, останется проверить только то, что каждой букве первой подстроки соответствует только одна буква второй.

Пример кода решения на Python:
def check_conversion(str_from, str_to):    if str_from == str_to:        # Если подстроки уже равны        return 1    if len(str_from) != len(str_to) or len(set(str_from)) == len(set(str_to)) == 33:        # Если длина подстрок не равна        # Или количество уникальных букв в обеих подстроках равно 33        return 0    symbols_map = {}    for symbol_from, symbol_to in zip(str_from, str_to):        if symbols_map.get(symbol_from, symbol_to) != symbol_to:            # Если мы пытаемся заменить одну букву на две разных            return 0        symbols_map.update({ symbol_from: symbol_to })    return 1str_from, str_to = input().split()print(check_conversion(str_from, str_to))


Активные вакансии


Петя решил узнать, когда программисту выгоднее всего искать работу на hh.ru. Конечно, когда открыто больше всего вакансий.
Он выгрузил в текстовый файл время открытия и закрытия всех подходящих вакансий за 2019 год.
Теперь нужно определить период времени, когда открытых вакансий было больше всего.
Считаем, что:
  • начальное и конечное время всегда присутствуют;
  • начальное время всегда меньше или равно конечному;
  • начальное и конечное время включены в интервал.

Например:

1
1 5

Здесь всего одна вакансия, соответственно, период, когда вакансий было больше всего тоже один, и занимает он все время жизни вакансии 5 секунд, ответ 1 5.

2
1 3
2 4

Здесь чуть посложнее, с 2 по 3 секунду были активны обе вакансии, такой интервал один, его длина 2 секунды, ответ 1 2.

2
1 2
3 4

Здесь вакансии не пересекались, то есть максимальное количество вакансий одна, однако интервалов, в которые была активна одна вакансия два. Несмотря на то, что в дискретном понимании, все 4 секунды вакансии существовали, непрерывным такой интервал не является, ответ 2 4.

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

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

Из тонкостей этого задания можно выделить две:

1. Данные на входе могут быть не отсортированными.
Условия не гарантируют сортировку входных данных, об этом нужно было позаботиться в решении, и это является, по сути, ключом к нему.

2. Данных может быть много, а потому лишние действия могут привести к превышению лимита времени или памяти.
В нашем большом тесте для этой задачи 100 000 вакансий, то есть 200 000 временных точек, что требует от решения быть максимально оптимальным и не использовать лишнего.

Пример кода решения на Python:
vacancies_count = int(input())time_points = []for moment in range(vacancies_count):    start, end = input().split()    # Добавляем информацию о начале и конце активности вакансии, и флаг,     # свидетельствующий о том, является ли этот момент концом активности.    # Флаг понадобится для сортировки и выяснения максимального количества вакансий    time_points.append([int(start), False])    time_points.append([int(end), True])# Учитывая особенности сортировки Python  для совпадающих по времени # моментов первым будет начало интервала, а вторым конец (False < True)time_line = sorted(time_points)max_vacancy_count = 0current_vacancy_count = 0for point_index in range(len(time_line)):    # Если текущий момент - это начало активности вакансии, добавляем,     # если конец - отнимаем    current_vacancy_count += -1 if time_line[point_index][1] else 1    if current_vacancy_count > max_vacancy_count:        max_vacancy_count = current_vacancy_count        # Предыдущий список максимальных, если он был, заменяется новым        max_vacancies_points = [point_index]    elif current_vacancy_count == max_vacancy_count:        # Если количество вакансий снижалось, а затем снова выросло,         # интервалов с максимальным количеством вакансий        # будет больше, чем 1, их индекс добавляется в массив        max_vacancies_points.append(point_index)total_time = 0for point_index in max_vacancies_points:    # Для интервалов с максимальным количеством вакансий  между открытием     # и закрытием не будет других моментов, то есть    # time_line[point_index + 1] - это конец интервала    # Добавляем 1, потому что начальное и конечное время включены в интервал    total_time += 1 + time_line[point_index + 1][0] - time_line[point_index][0]print(len(max_vacancies_points), total_time)


Ссылка на репозиторий, в котором лежат решения для всех трёх языков и наши закрытые тесты: github.com/gooverdian/school-2020-tasks

Мы знаем, что вы сделали...


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

Если вдруг в этом разделе вы узнали себя: пожалуйста, знайте, что после онлайн-этапа с автоматической проверкой всегда будет оффлайн с живым интервью. Используйте свои навыки программирования, а не поиска решений в интернете. Подготовьтесь лучше, и вы справитесь самостоятельно.

Всем ещё раз большое спасибо за участие!
Подробнее..

Магическая шаблонизация для Android-проектов

26.11.2020 10:08:34 | Автор: admin


Начиная с Android Studio 4.1, Google прекратил поддержку кастомных FreeMarker-ных шаблонов. Теперь вы не можете просто взять и написать свои ftl-файлы и сложить их в определённую папку, чтобы Android Studio самостоятельно добавила их в меню New Other. В качестве альтернативы нам предлагают разбираться в плагиностроении и создавать шаблоны изнутри плагинов IDEA. Нас в hh такая ситуация не очень устраивает, так как есть несколько полезных FreeMarker-ных шаблонов, которые мы постоянно используем и которые иногда нуждаются в обновлениях. Лезть в плагины, чтобы поправить какой-то шаблон? Нет уж, увольте.


Всё это привело к тому, что мы разработали специальный плагин для Android Studio, который поможет решить эти проблемы. Встречайте Geminio.


Про то, как работает плагин и что требуется для его настройки вы можете подробнее почитать в его README, а вот про то, как он устроен изнутри только здесь. А ещё я расскажу, как теперь можно из плагинов создавать свои шаблоны.


*Geminio заклинание удвоения предметов во вселенной Гарри Поттера


Немного терминологии


Чтобы меньше путаться и синхронизировать понимание того, о чём мы говорим, введём немного терминологии.


Я буду называть шаблоном набор метаданных, который необходим в построении диалога для ввода пользовательских параметров. Рецептом назовём набор инструкций для исполнения, который отработает после того, как пользователь введёт данные. Когда я буду говорить про шаблонный текст генерируемого кода, я буду называть это ftl-шаблонами или FreeMarker-ными шаблонами.


Чем заменили FreeMarker?


Google уже давно объявил Kotlin предпочитаемым языком для разработки под Android. Все новые библиотеки, новые приложения в Google постепенно переписываются именно на Kotlin. И плагин android-а в Android Studio не стал исключением.


Как механизм шаблонов работал до Android Studio 4.1? Вы создавали папку для описания шаблона, заводили в нём несколько файлов globals.xml.ftl, template.xml, recipe.xml.ftl для описания параметров и инструкций выполнения шаблона, а ещё вы помещали туда ftl-шаблоны, служившие каркасом генерируемого кода. Затем все эти файлы перемещали в папку Android Studio/plugins/android/lib/templates/<category>. После запуска проекта Android Studio парсила содержимое папки /templates, добавляла в интерфейс меню New > дополнительные action-ы, а при вызове action-а читала содержимое template.xml, строила UI и так далее.


В целом понятно, почему в Google отказались от этого механизма. Создание нового шаблона на основе FreeMarker-ных recipe-ов раньше напоминало русскую рулетку: до запуска ты никогда не мог точно сказать, правильно ли его описал, все ли требуемые параметры заполнил. А потом, по реакции Android Studio, ты пытался определить, в какой конкретной букве ошибся. Находил ошибку, менял шаблон, и всё шло на новый круг. А число шаблонов растёт, растёт и количество мест в интерфейсе, куда хочется добавлять эти шаблоны. Раньше для добавления одного и того же шаблона в несколько мест интерфейса приходилось создавать дополнительные action-ы плагины. Нужно было упрощать.


Вот так и появился удобный Kotlin DSL для описания шаблонов. Сравните два подхода:


FreeMarker-ный подход

Вот так выглядел файл template.xml:


<?xml version="1.0"?><template    format="4"    revision="1"    name="HeadHunter BaseFragment"    description="Creates HeadHunter BaseFragment"    minApi="7"    minBuildApi="8">    <category value="HeadHunter" />    <!-- параметры фрагмента -->    <parameter        id="className"        name="Fragment Name"        type="string"        constraints="class|nonempty|unique"        default="BlankFragment"        help="The name of the fragment class to create" />    <parameter        id="fragmentName"        name="Fragment Layout Name"        type="string"        constraints="layout|nonempty|unique"        default="fragment_blank"        suggest="fragment_${classToResource(className)}"        help="The name of the layout to create" />    <parameter        id="includeFactory"        name="Include fragment factory method?"        type="boolean"        default="true"        help="Generate static fragment factory method for easy instantiation" />    <!-- доп параметры  -->    <parameter        id="includeModule"        name="Include Toothpick Module class?"        type="boolean"        default="true"        help="Generate fragment Toothpick Module for easy instantiation" />    <parameter        id="moduleName"        name="Fragment Toothpick Module"        type="string"        constraints="class|nonempty|unique"        default="BlankModule"        visibility="includeModule"        suggest="${underscoreToCamelCase(classToResource(className))}Module"        help="The name of the Fragment Toothpick Module to create" />    <thumbs>        <thumb>template_base_fragment.png</thumb>    </thumbs>    <globals file="globals.xml.ftl" />    <execute file="recipe.xml.ftl" /></template>

А ещё был файл recipe.xml.ftl:


<?xml version="1.0"?><recipe>    <#if useSupport>    <dependency mavenUrl="com.android.support:support-v4:19.+"/>    </#if>    <instantiate        from="res/layout/fragment_blank.xml.ftl"        to="${escapeXmlAttribute(resOut)}/layout/${escapeXmlAttribute(fragmentName)}.xml" />    <open file="${escapeXmlAttribute(resOut)}/layout/${escapeXmlAttribute(fragmentName)}.xml" />    <instantiate        from="src/app_package/BlankFragment.kt.ftl"        to="${srcOutRRR}/${className}.kt" />    <open file="${srcOutRRR}/${className}.kt" />    <#if includeModule>        <instantiate            from="src/app_package/BlankModule.kt.ftl"            to="${srcOutRRR}/di/${moduleName}.kt" />        <open file="${srcOutRRR}/di/${moduleName}.kt" />    </#if></recipe>

То же самое, но в Kotlin DSL

Сначала мы создаём описание шаблона с помощью специального TemplateBuilder-а:


val baseFragmentTemplate: Template    get() = template {        revision = 1        name = "HeadHunter BaseFragment"        description = "Creates HeadHunter BaseFragment"        minApi = 7        minBuildApi = 8        formFactor = FormFactor.Mobile        category = Category.Fragment        screens = listOf(            WizardUiContext.FragmentGallery,            WizardUiContext.MenuEntry        )        // параметры        val className = stringParameter {            name = "Fragment Name"            constraints = listOf(                Constraint.CLASS,                Constraint.NONEMPTY,                Constraint.UNIQUE            )            default = "BlankFragment"            help = "The name of the fragment class to create"        }        val fragmentName = stringParameter {            name = "Fragment Layout Name"            constraints = listOf(                Constraint.LAYOUT,                Constraint.NONEMPTY,                Constraint.UNIQUE            )            default = "fragment_blank"            suggest = { "fragment_${classToResource(className.value)}" }            help = "The name of the layout to create"        }        val includeFactory = booleanParameter {            name = "Include fragment factory method?"            default = true            help = "Generate static fragment factory method for easy instantiation"        }        // доп. параметры        val includeModule = booleanParameter {            name = "Include Toothpick Module class?"            default = true            help = "Generate fragment Toothpick Module for easy instantiation"        }        val moduleName = stringParameter {            name = "Fragment Toothpick Module"            constraints = listOf(                Constraint.CLASS,                Constraint.NONEMPTY,                Constraint.UNIQUE            )            visible = { includeModule.value }            suggest = { "${underscoreToCamelCase(classToResource(className.value))}Module" }            help = "The name of the Fragment Toothpick Module to create"            default = "BlankFragmentModule"        }        thumb { File("template_base_fragment.png") }        recipe = { templateData ->            baseFragmentRecipe(                moduleData = templateData as ModuleTemplateData,                className = className.value,                fragmentName = fragmentName.value,                includeFactory = includeFactory.value,                includeModule = includeModule.value,                moduleName = moduleName.value            )        }    }

Затем описываем рецепт в отдельной функции:


fun RecipeExecutor.baseFragmentRecipe(    moduleData: ModuleTemplateData,    className: String,    fragmentName: String,    includeFactory: Boolean,    includeModule: Boolean,    moduleName: String) {    val (projectData, srcOut, resOut, _) = moduleData    if (projectData.androidXSupport.not()) {        addDependency("com.android.support:support-v4:19.+")    }    save(getFragmentBlankLayoutText(), resOut.resolve("/layout/${fragmentName}.xml"))    open(resOut.resolve("/layout/${fragmentName}.xml"))    save(getFragmentBlankClassText(className, includeFactory), srcOut.resolve("${className}.kt"))    open(srcOut.resolve("${className}.kt"))    if (includeModule) {        save(getFragmentModuleClassText(moduleName), srcOut.resolve("/di/${moduleName}.kt"))        open(srcOut.resolve("/di/${moduleName}.kt"))    }}private fun getFragmentBlankClassText(className: String, includeFactory: Boolean): String {    return "..."}private fun getFragmentBlankLayoutText(): String {    return "..."}private fun getFragmentModuleClassText(moduleName: String): String {    return "..."}

Текст шаблонов перекочевал из FreeMarker-ных ftl-файлов в Kotlin-овские строчки.


По количеству кода получается примерно то же самое, но вот наличие подсказок IDE при описании шаблона помогает не ошибаться в значениях enum-ов и функциях. Добавьте к этому валидацию при создании объекта шаблона (например, покажется исключение, если вы забыли указать один из необходимых параметров), возможность вызова шаблона из разных меню в Android Studio и, кажется, у нас есть победитель.


Добавление шаблона через extension point


Чтобы новые шаблоны попали в существующие галереи новых объектов в Android Studio, нужно добавить созданный с помощью DSL шаблон в новую точку расширения (extension point) WizardTemplateProvider.


Для этого мы сначала создаём класс provider-а, наследуясь от абстрактного класса WizardTemplateProvider:


class MyWizardTemplateProvider : WizardTemplateProvider() {    override fun getTemplates(): List<Template> {        return listOf(            baseFragmentTemplate        )    }}

А затем добавляем созданный provider в качестве extension-а в plugin.xml файле:


<extensions defaultExtensionNs="com.android.tools.idea.wizard.template">    <wizardTemplateProvider implementation="ru.hh.plugins.geminio.actions.MyWizardTemplateProvider" /></extensions>

Запустив Android Studio, мы увидим шаблон baseFragmentTemplate в меню New->Fragment и в галерее нового фрагмента.


Покажи картинки!

Вот наш шаблон в меню New -> Fragments:



А вот он же в галерее нового фрагмента:



Если вы захотите самостоятельно пройти весь этот путь по добавлению нового шаблона из кода плагина, можете, во-первых, посмотреть на актуальный список готовых шаблонов в исходном коде Android Studio (который совсем недавно наконец-то добавили в cs.android.com), а во-вторых почитать вот эту статью на Medium (там хорошо описана последовательность действий по созданию нового шаблона, но показан не очень правильный хак с получением инстанса Project-а так лучше не делать).


А чем ещё можно заменить FreeMarker?


Кроме того, добавить шаблоны кода из плагинов можно с помощью File templates. Это очень просто: добавляете его в папку resources/fileTemplates и Вы восхитительны!


А можно поподробнее?

В папку /resources/fileTemplates вашего плагина нужно добавить шаблон нужного вам кода, например, /resources/fileTemplates/Toothpick Module.kt.ft .


package ${PACKAGE_NAME}.diimport toothpick.config.Moduleinternal class ${NAME}: Module() {    init {            // TODO    }}

Шаблоны кода работают на движке Velocity, поэтому можно добавлять в код шаблона условия и циклы. File template-ы имеют ряд встроенных параметров, например, PACKAGE_NAME (подставит package name, в зависимости от выбранного в Project View файла), MONTH (текущий месяц) и так далее. Каждый "неизвестный" параметр будет преобразован в поле ввода для пользователя.


После запуска Android Studio в меню New вы увидите новый пункт с названием вашего шаблона:



Нажав на элемент меню, вы увидите диалог, который построился на основе шаблона.



Примеры таких шаблонов вы можете подсмотреть в репозитории MviCore коллег из Badoo.


В чём минус таких шаблонов они не позволяют вам одновременно добавить несколько файлов. Поэтому мы в hh их обычно не создаём.


Что не так с новым механизмом


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


Мы же хотим оперативно обновлять содержимое ftl-файлов, добавлять новые шаблоны и желательно без вмешательства в плагин, потому что отладка шаблонов из плагина тот ещё квест =) А ещё мы очень не хотим выбрасывать готовые шаблоны, которые заточены под использование FreeMarker-а.


Механизм рендеринга шаблонов


Почему бы не разобраться в том, как вообще происходит рендеринг новых шаблонов в Android Studio? И на основе этого механизма сделать обёртку, которая сможет пробросить созданные шаблоны на рендер.


Разобрались. Делимся.


Чтобы заставить Android Studio построить UI и сгенерировать код на основе нужного шаблона, придётся написать довольно много кода. Допустим, вы уже создалисобственный плагин, объявили зависимости от android-плагина, который лежит в Android Studio 4.1, добавили новый action, который будет отвечать за рендеринг. Тогда метод actionPerformed будет выглядеть вот так:


Обработка actionPerformed
override fun actionPerformed(e: AnActionEvent) {    val dataContext = e.dataContext    val module = LangDataKeys.MODULE.getData(dataContext)!!    var targetDirectory = CommonDataKeys.VIRTUAL_FILE.getData(dataContext)    if (targetDirectory != null && targetDirectory.isDirectory.not()) {       // If the user selected a simulated folder entry (eg "Manifests"), there will be no target directory        targetDirectory = targetDirectory.parent    }    targetDirectory!!    val facet = AndroidFacet.getInstance(module)    val moduleTemplates = facet.getModuleTemplates(targetDirectory)    assert(moduleTemplates.isNotEmpty())    val initialPackageSuggestion = facet.getPackageForPath(moduleTemplates, targetDirectory).orEmpty()    val renderModel = RenderTemplateModel.fromFacet(        facet,        initialPackageSuggestion,        moduleTemplates[0],        "MyActionCommandName",        ProjectSyncInvoker.DefaultProjectSyncInvoker(),        true,    ).apply {        newTemplate = template { ... } // build your template     }     val configureTemplateStep = ConfigureTemplateParametersStep(         model = renderModel,         title = "Template name",         templates = moduleTemplates     )     val wizard = ModelWizard.Builder()                    .addStep(configureTemplateStep).build().apply {          val resultListener = object : ModelWizard.WizardListener {          override fun onWizardFinished(result: ModelWizard.WizardResult) {              super.onWizardFinished(result)              if (result.isFinished) {                  // TODO do some stuff after creating files                  //   (renderTemplateModel.createdFiles)              }          }       }    }     val dialog = StudioWizardDialogBuilder(wizard, "Template wizard")            .setProject(e.project!!)            .build()     dialog.show()}

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


По логике программы, пользователь плагина нажимает Cmd + N на каком-то файле или package-е внутри какого-то модуля. Именно там мы и создадим пачку файлов, которые нам нужны. Поэтому необходимо определить, внутри какого же модуля и какой папки работаем.


Чтобы это сделать, воспользуемся возможностями AnActionEvent-а.


val dataContext = e.dataContextval module = LangDataKeys.MODULE.getData(dataContext)!!var targetDirectory = CommonDataKeys.VIRTUAL_FILE.getData(dataContext)if (targetDirectory != null && targetDirectory.isDirectory.not()) {    // If the user selected a simulated folder entry (eg "Manifests"), there will be no target directory    targetDirectory = targetDirectory.parent}targetDirectory!!

Как я уже рассказывал в своей статье с теорией плагиностроения, AnActionEvent представляет собой контекст исполнения вашего Action-а. Внутри этого класса есть свойство dataContext, из которого при помощи специальных ключей мы можем доставать нужные данные. Чтобы посмотреть, какие ещё ключи есть, обратите внимание на классы PlatformDataKeys, LangDataKeys и другие. Ключ LangDataKeys.MODULE возвращает нам текущий модуль, а CommonDataKeys.VIRTUAL_FILE выбранный пользователем в Project View файл. Немного преобразований и мы получаем директорию, внутрь которой нужно добавлять файлы.


val facet = AndroidFacet.getInstance(module)

Чтобы двигаться дальше, нам требуется объект AndroidFacet. Facet это, по сути, свойства модуля, которые специфичны для того или иного фреймворка. В данном случае мы получаем специфичное для Android описание нашего модуля. Из facet-а можно достать, например, package name, указанный в AndroidManifest.xml вашего android-модуля.


val moduleTemplates = facet.getModuleTemplates(targetDirectory)assert(moduleTemplates.isNotEmpty())val initialPackageSuggestion = facet.getPackageForPath(moduleTemplates, targetDirectory).orEmpty()

Из facet-а мы достаём объект NamedModuleTemplate контейнер для основных путей android-модуля: путь до папки с исходным кодом, папки с ресурсами, тестами и т.д. Благодаря этому объекту можно найти и package name для подстановки в будущие шаблоны кода.


val renderModel = RenderTemplateModel.fromFacet(    facet,    initialPackageSuggestion,    moduleTemplates[0],    "MyActionCommandName",    ProjectSyncInvoker.DefaultProjectSyncInvoker(),    true,).apply {    newTemplate = template { ... } // build your template}

Все предыдущие элементы были нужны для того, чтобы сформировать главный компонент будущего диалога его модель, представленную классом RenderTemplateModel. Конструктор этого класса принимает в себя:


  • AndroidFacet модуля, в котором мы создаем файлы;
  • первый предлагаемый пользователю package name (его можно будет использовать в параметрах шаблона);
  • объект, хранящий пути к основным папкам модуля, NamedModuleTemplate;
  • строковую константу для идентификации WriteCommandAction (внутренний объект IDEA, предназначенный для операций модификации кода) она нужна для того, чтобы у вас сработал Undo;
  • объект, отвечающий за синхронизацию проекта после создания файлов, ProjectSyncInvoker;
  • и, наконец, флаг true или false, который отвечает за то, можно ли открывать все созданные файлы в редакторе кода или нет.

val configureTemplateStep = ConfigureTemplateParametersStep(    model = renderModel,    title = "Template name",    templates = moduleTemplates)val wizard = ModelWizard.Builder()    .addStep(configureTemplateStep)    .build().apply {        val resultListener = object : ModelWizard.WizardListener {               override fun onWizardFinished(result: ModelWizard.WizardResult) {                       super.onWizardFinished(result)                       if (result.isFinished) {                               // TODO do some stuff after creating files                   //   (renderTemplateModel.createdFiles)                       }               }     }}val dialog = StudioWizardDialogBuilder(wizard, "Template wizard")            .setProject(e.project!!)            .build()dialog.show()

Финал!


Для начала создаем ConfigureTemplateParametersStep, который прочитает переданный объект template-а и сформирует UI страницы wizard-диалога, потом пробрасываем step в модель Wizard-диалога и наконец-то показываем сам диалог.


А ещё мы добавили специальный listener на событие завершения диалога, так что после создания файлов можем ещё и как-то их модифицировать. Достучаться до созданных файлов можно через renderTemplateModel.createdFiles.


Самое сложное позади! Мы показали диалог, который взял на себя работу по построению UI из модели шаблона и обработку рецепта внутри шаблона.


Остаётся только откуда-то получить сам шаблон. И рецепт.


Откуда взять модель шаблона


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


Мне показалось, что самый простой формат это yaml-конфиг. Почему именно yaml? Потому что: а) выглядит проще XML, и б) внутри IDEA уже есть подключенная библиотечка для его парсинга SnakeYaml, позволяющая в одну строчку прочитать весь файл в Map<String, Any>, который можно дальше крутить как угодно.


В данный момент конфиг шаблона выглядит так:


yaml-конфиг шаблона
requiredParams:  name: HeadHunter BaseFragment  description: Creates HeadHunter BaseFragmentoptionalParams:  revision: 1  category: fragment  formFactor: mobile  constraints:    - kotlin  screens:    - fragment_gallery    - menu_entry  minApi: 7  minBuildApi: 8widgets:  - stringParameter:      id: className      name: Fragment Name      help: The name of the fragment class to create      constraints:        - class        - nonempty        - unique      default: BlankFragment  - stringParameter:      id: fragmentName      name: Fragment Layout Name      help: The name of the layout to create      constraints:        - layout        - nonempty        - unique      default: fragment_blank      suggest: fragment_${className.classToResource()}  - booleanParameter:      id: includeFactory      name: Include fragment factory method?      help: Generate static fragment factory method for easy instantiation      default: true  - booleanParameter:      id: includeModule      name: Include Toothpick Module class?      help: Generate fragment Toothpick Module for easy instantiation      default: true  - stringParameter:      id: moduleName      name: Fragment Toothpick Module      help: The name of the Fragment Toothpick Module to create      constraints:        - class        - nonempty        - unique      default: BlankModule      visibility: ${includeModule}      suggest: ${className.classToResource().underlinesToCamelCase()}Modulerecipe:  - instantiateAndOpen:      from: root/src/app_package/BlankFragment.kt.ftl      to: ${srcOut}/${className}.kt  - instantiateAndOpen:      from: root/res/layout/fragment_blank.xml.ftl      to: ${resOut}/layout/${fragmentName}.xml  - predicate:      validIf: ${includeModule}      commands:        - instantiateAndOpen:            from: root/src/app_package/BlankModule.kt.ftl            to: ${srcOut}/di/${moduleName}.kt

Вся конфигурация шаблона делится на 4 секции:


  • requiredParams параметры, обязательные для каждого шаблона;
  • optionalParams параметры, которые можно спокойно опустить при описании шаблона. В данный момент эти параметры ни на что не влияют, потому что мы не подключаем созданный на основе конфига шаблон через extension point.
  • widgets набор параметров шаблона, которые зависят от пользовательского ввода. Каждый из этих параметров в конечном итоге превратится в виджет на UI диалога (textField-ы, checkbox-ы и т.п.);
  • recipe набор инструкций, которые выполняются после того, как пользователь заполнит все параметры шаблона.

Написанный мною плагин парсит этот конфиг, конвертирует его в объект шаблона Android Studio и пробрасывает в RenderTemplateModel.


В самой конвертации практически не было ничего интересного кроме парсинга выражений. Я имею в виду строчки вот такого вида:


suggest: ${className.classToResource().underlinesToCamelCase()}Module

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


sealed class Command {    data class Fixed(        val value: String    ) : Command()    data class Dynamic(        val parameterId: String,        val modifiers: List<GeminioRecipeExpressionModifier>    ) : Command()    data class SrcOut(        val modifiers: List<GeminioRecipeExpressionModifier>    ) : Command()    data class ResOut(        val modifiers: List<GeminioRecipeExpressionModifier>    ) : Command()    object ReturnTrue : Command()    object ReturnFalse : Command()}

Каждая команда знает, как себя вычислить, какой она внесёт вклад в итоговый результат, требуемый в том или ином параметре. Над парсингом выражений пришлось немного посидеть: сначала я хотел выцепить отдельные кусочки ${...} с помощью регулярок, но вы же знаете, если вы хотите решить какую-то проблему с помощью регулярных выражений, то у вас появляется ещё одна проблема. В итоге я распарсил строчку посимвольно.


Что ещё хорошо в своём собственном формате конфига можно добавлять новые ключи и строить на них свою дополнительную логику. Так, например, появилась новая команда для рецептов instantiateAndOpen, которая сначала создаёт файл из текста ftl-шаблона, а потом открывает созданный файл в редакторе кода. Да-да, в FreeMarker-ных шаблонах уже были команды instantiate и open, но это были отдельные команды.


recipe:  # Можно писать вот так  - instantiate:      from: root/src/app_package/BlankFragment.kt.ftl      to: ${srcOut}/${className}.kt  - open:      file: ${srcOut}/${className}.kt  # А можно одной командой:  - instantiateAndOpen:      from: root/src/app_package/BlankFragment.kt.ftl      to: ${srcOut}/${className}.kt

Какие ещё есть плюсы в Geminio


Основной плюс после того, как вы создали папку для шаблона с рецептом внутри, и Android Studio создала для этого шаблона Action, вы можете как угодно менять ваш рецепт и файлы с шаблонами кода. Все изменения применятся сразу же, вам не нужно будет перезапускать IDE для того, чтобы проверить шаблон. То есть цикл проверки шаблона стал в разы короче.


Если бы вы создавали шаблон из плагина, то вы бы не избежали этой проблемы с перезапуском IDE в случае ошибки ваш шаблон бы просто не работал.


Roadmap


Я был бы рад сказать, что уже сейчас плагин поддерживает все возможности, которые были у FreeMarker-ных шаблонов, но нет. Далеко не все возможности нужны прямо сейчас, а до некоторых мы обязательно доберёмся в рамках улучшения других плагинов. Например:


  • нет поддержки enum-параметров, которые бы отображались на UI в виде combobox-ов;
  • не все команды из FreeMarker-ных шаблонов поддерживаются в рецептах например, нет автоматического добавления зависимостей в build.gradle, merge-а XML-ресурсов;
  • новые шаблоны страдают от той же проблемы, что и FreeMarker-ные шаблоны нет адекватной валидации, которая бы точно сказала, где именно случилась ошибка;
  • и нет никаких подсказок IDE при описании шаблона.

Заключение


Заканчивать нужно на позитивной ноте. Поэтому вот немного позитива:


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

Всем успешной автоматизации.


Полезные ссылки


Подробнее..

Swift Копируй-изменяй

22.07.2020 10:16:05 | Автор: admin


Часто бывает так, что нам нужно скопировать объект, изменив некоторые его свойства, но сохранив остальные неизменными. Для этой задачи существует функция copy().

Это отрывок описания метода copy() из документации Kotlin. На нашем родном языке Swift это означает примерно такую возможность:


struct User {    let id: Int    let name: String    let age: Int}let steve = User(id: 1, name: "Steve", age: 21)// Копируем экземпляр, изменив свойства `name` и `age`let steveJobs = steve.changing { newUser in    newUser.name = "Steve Jobs"    newUser.age = 41}

Выглядит вкусно, не так ли?


Увы, в Swift отсутствует подобный функционал "из коробки". Это небольшое руководство поможет реализовать его самостоятельно.


В чемпроблема


Почему бы просто не делать свойства изменяемыми, объявляя их ключевым словом var вместо let?


struct User {    let id: Int    var name: String    var age: Int}let steve = User(id: 1, name: "Steve", age: 21)...var steveJobs = stevesteveJobs.name = "Steve Jobs"steveJobs.age = 41

Тут есть несколько проблем:


  • Изменение таких полей будет невозможным, если не объявить мутабельным и новый экземпляр структуры, а это лишает гарантии, что он не модифицируется где-то еще.
  • Сложнее сделать изменения "атомарными". К примеру, в случае наблюдаемых свойств блоки willSet и didSet вызываются при изменении каждого поля.
  • Субъективно, но такой код нельзя назвать лаконичным и изящным.

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


// Создаем новый экземпляр, изменяя свойство `name`let steveJobs = User(    id: steve.id,     name: "Steve Jobs",    age: steve.age)

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


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


Как реализовать


План довольно прост:


  • Сначала напишем универсальную обертку для копии, все свойства которой будут мутабельными и повторять контент копируемого типа.
  • Далее добавим протокол Changeable с реализацией по-умолчанию, который позволит копировать экземпляры с измененными свойствами, используя универсальную обертку.
  • В итоге останется подписать типы под этот протокол, реализовав инициализацию из копии.


Структура изменяемой обертки


Так как обертка должна быть универсальной, а поля конкретного типа нам неизвестны, то потребуется некоторая интроспекция. С этим поможет динамический доступ к свойствам через Key-Path выражения, а фича Key-Path Dynamic Member Lookup из Swift 5.1 сделает все красивым и удобным.


Используя эти синтаксические возможности, получаем небольшую generic-структуру:


@dynamicMemberLookupstruct ChangeableWrapper<Wrapped> {    private let wrapped: Wrapped    private var changes: [PartialKeyPath<Wrapped>: Any] = [:]    init(_ wrapped: Wrapped) {        self.wrapped = wrapped    }    subscript<T>(dynamicMember keyPath: KeyPath<Wrapped, T>) -> T {        get {             changes[keyPath].flatMap { $0 as? T } ?? wrapped[keyPath: keyPath]         }        set {            changes[keyPath] = newValue        }    }}

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


При извлечении значения из словаря недостаточно просто написать changes[keyPath] as? T, потому что в случае опционального типа T мы получим уже двойную опциональность. Тогда геттер будет возвращать nil, даже если свойство не менялось, и в оригинальном экземпляре у него есть значение. Чтобы этого избежать, достаточно приводить тип с помощью метода flatMap(:), который выполнится, только если в словаре changes есть значение для ключа.

Сигнатура нашего сабскрипта и атрибут @dynamicMemberLookup позволяют работать с оберткой так, будто это оригинальная структура, в которой все свойства объявлены переменными через var.



При этом для обертки доступны все блага Xcode в виде автодополнения и документации свойств. А строгость типов и проверки на этапе компиляции гарантируют корректность обращений к свойствам: неверные значения и опечатки в названиях не пройдут.


Протокол Changeable


Теперь, чтобы легко копировать экземпляры с измененными свойствами, напишем простой протокол Changeable с реализацией метода копирования:


protocol Changeable {    init(copy: ChangeableWrapper<Self>)}extension Changeable {    func changing(_ change: (inout ChangeableWrapper<Self>) -> Void) -> Self {        var copy = ChangeableWrapper<Self>(self)        change(&copy)        return Self(copy: copy)    }}

Метод changing(:) получает в параметрах замыкание, которое вызывается со ссылкой на изменяемую копию, далее из модифицированной копии создается новый экземпляр оригинального типа.


Кроме метода копирования с изменениями, протокол объявляет требование для инициализатора из копии, который должен быть реализован в каждом типе для соответствия протоколу Changeable:


extension User: Changeable {    init(copy: ChangeableWrapper<Self>) {        self.init(            id: copy.id,            name: copy.name,            age: copy.age        )    }}

Подписав тип под протокол и реализовав этот инициализатор, мы получаем то, что хотели копирование измененных экземпляров:


let steve = User(id: 1, name: "Steve", age: 21)let steveJobs = steve.changing { newUser in    newUser.name = "Steve Jobs"    newUser.age = 30}

Но это еще не все, есть один момент, который требует маленькой доработки



Вложенные свойства


Сейчас метод changing(:) удобен, когда изменяются свойства первого уровня, но часто хочется копировать экземпляры с изменениями в более глубокой иерархии, например:


struct Company {    let name: String    let country: String}struct User {    let id: Int    let company: Company}let user = User(    id: 1,     company: Company(        name: "NeXT",         country: "USA"    ))

Чтобы в этом примере скопировать экземпляр user, изменив поле company.name, придется написать не самый приятный код:


let appleUser = user.changing { newUser in    newUser.company = newUser.company.changing { newCompany in        newCompany.name = "Apple"    }}

И чем глубже находится изменяемое свойство, тем больше уровней и строк займет его изменение.


Спокойно. Решение есть и очень простое-необходимо лишь добавить перегрузку сабскрипта в структуру ChangeableWrapper:


subscript<T: Changeable>(    dynamicMember keyPath: KeyPath<Wrapped, T>) -> ChangeableWrapper<T> {    get {        ChangeableWrapper<T>(self[dynamicMember: keyPath])    }    set {         self[dynamicMember: keyPath] = T(copy: newValue)    }}

Этот дополнительный сабскрипт вызывается только для свойств, тип которых соответствуют протоколу Changeable. Swift достаточно умен и в нашем случае не потребует каких-либо уточнений для выбора перегрузки. Поэтому мы получаем значение свойства через основной сабскрипт и возвращаем его, завернув в изменяемую обертку.


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


let appleUser = user.changing { newUser in    newUser.company.name = "Apple"}

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



Подводя итог


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


Единственным неудобством является ручная реализация инициализатора из копии. И если моделей в проекте много, их структура постоянно меняется, то имеет смысл автоматизировать этот труд. На этот случай есть готовый Stencil-шаблон для Sourcery, который доступен по ссылке.


Финальный код представленного решения, шаблон для кодогенерации и другие полезные вещи собраны в репозитории фреймворка, который легко интегрируется в любой проект, на Swift 5.1 и выше.


На этомвсе. Буду рад обратной связи в комментариях. Пока!

Подробнее..

Navigation Component-дзюцу, vol. 3 Corner-кейсы

23.09.2020 10:08:04 | Автор: admin


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


Это третья и заключительная статья в цикле про различные кейсы навигации с Navigation Component-ом. Вы также можете ознакомиться с первой и второй частями



Если вы работаете с большим приложением, вероятно, вы уже разбили его на модули. Неважно, как именно. Может быть, вы создаёте отдельные модули для логики и UI, а может храните всю логику фичи (от взаимодействия с API до логики presentation-слоя) в одном модуле. Главное у вас могут быть кейсы, когда требуется осуществить навигацию между двумя независимыми модулями.


Где на схеме приложения кейсы с навигацией?


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


Существует три способа как это сделать, разберём их один за другим.


App-модуль + интерфейсы


Первый способ использовать ваш application-модуль в качестве хранилища всего графа навигации и определить в feature-модулях специальные интерфейсы для роутинга.


Структура вашего приложения в этом способе


Структура приложения будет стандартной: есть app-модуль, который знает обо всех feature-модулях, есть feature-модули, которые не знают друг о друге. В этом способе ваши feature-модули пребывают в священном неведении о Navigation Component, и для навигации они будут определять интерфейсы примерно вот такого вида:


// ::vacancy moduleinterface VacancyRouterSource {    fun openNextVacancy(vacancyId: String)    // For navigation to another module    fun openCompanyFlow()}

А ваш app-модуль будет реализовывать эти интерфейсы, потому что он знает обо всех action-ах и навигации:


fun initVacancyDI(navController: NavController) {  VacancyDI.vacancyRouterSource = object : VacancyRouterSource {      override fun openNextVacancy(vacancyId: String) {          navController.navigate(              VacancyFragmentDirections                .actionVacancyFragmentToVacancyFragment(vacancyId = vacancyId)          )      }      override fun openCompanyFlow() {          initCompanyDI(navController)          navController.navigate(R.id.action__VacancyFragment__to__CompanyFlow)      }  }}

Плюс этого способа отсутствие дополнительных модулей, которые могут повлиять на скорость сборки приложения. Однако в минусах вы получаете:


  • дополнительную работу в виде определения интерфейсов, реализаций, организации DI для проброса этих интерфейсов в ваши feature-модули;
  • отсутствие возможности использовать использовать Safe Args плагин, делегат navArgs, сгенерированные Directions, и другие фишки Navigation Component-а в feature-модулях, потому что эти модули ничего не знают про библиотеку.

Сомнительный, в общем, способ.


Графы навигации в feature-модулях + диплинки


Второй способ вынести отдельные графы навигации в feature-модули и использовать поддержку навигации по диплинкам (она же навигация по URI, которую добавили в Navigation Component 2.1).


Структура вашего приложения в этом способе


Структура приложения будет ровно такой же, как и в первом способе: есть app-модуль, который зависит от всех feature-модулей, и есть feature-модули, которые друг о друге ничего не знают.


Но теперь ваш app-модуль не обязан содержать весь граф навигации приложения, он может содержать только его часть. А остальные кусочки будут содержать именно feature-модули.


<navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:id="@+id/company_flow__nav_graph"    app:startDestination="@id/CompanyFragment">    <fragment        android:id="@+id/CompanyFragment"        android:name="company.CompanyFragment">        <deepLink app:uri="companyflow://company" />        <!-- Or with arguments -->        <argument android:name="company_id" app:argType="long" />        <deepLink app:uri="companyflow://company" />        <action            android:id="@+id/action__CompanyFragment__to__CompanyDetailsFragment"            app:destination="@id/CompanyDetailsFragment" />    </fragment>    <fragment        android:id="@+id/CompanyDetailsFragment"        android:name="company.CompanyDetailsFragment" /></navigation>

Feature-модули будут определять свои собственные графы навигации для роутинга между экранами, о которых они знают. А ещё они будут объявлять диплинки для тех экранов, на которые можно попасть из других модулей. В примере выше мы добавили тэг deepLink, чтобы на экран CompanyFragment можно было попасть из другого модуля.


После этого мы можем использовать этот диплинк для открытия экрана CompanyFragment из модуля :vacancy :


// ::vacancy modulefragment_vacancy__button__open_company_flow.setOnClickListener {  // Navigation through deep link  val companyFlowUri = "companyflow://company".toUri()  findNavController().navigate(companyFlowUri)}

Плюс этого метода в том, что это самый простой способ навигации между двумя независимыми модулями. А минус что вы не сможете использовать Safe Args, или сложные типы аргументов (Enum, Serializable, Parcelable) при навигации между фичами.


P.S. Есть, конечно, вариант сериализовать ваши сложные структуры в JSON и передавать их в качестве String-аргументов в диплинк, но это как-то Странно.


Общий модуль со всем графом навигации


Третий способ ввести для хранения всего графа навигации специальный модуль, который будет подключаться к каждому feature-модулю.


Структура вашего приложения в этом способе


У нас по-прежнему есть app-модуль, но теперь его задача просто подсоединить к себе все feature-модули; он больше не хранит в себе граф навигации. Весь граф навигации теперь располагается в специальном модуле, который ничего не знает о feature-модулях. Зато каждый feature-модуль знает про common navigation.


В чём соль? Несмотря на то, что common-модуль не знает о реализациях ваших destination-ов (фрагментах, диалогах, activity), он всё равно способен объявить граф навигации в XML-файлах! Да, Android Studio начинает сходить с ума: все имена классов в XML-е горят красным, но, несмотря на это, все нужные классы генерируются, Safe Args плагин работает как нужно. И так как ваши feature-модули подключают к себе common-модуль, они могут свободно использовать все сгенерированные классы и пользоваться любыми action-ами вашего графа навигации.


Плюс этого способа наконец-то можно пользоваться всеми возможностями Navigation Component-а в любом feature-модуле. Из минусов:


  • добавился ещё один модуль в critical path каждого feature-модуля, которому потребовалась навигация;
  • отсутствует автоматический рефакторинг имён: если вы поменяете имя класса какого-нибудь destination-а, вам нужно будет не забыть, что надо поправить его в common-модуле.

Выводы по навигации в многомодульных приложениях


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

Работа с диплинками


Практически каждое большое приложение должно уметь поддерживать диплинки. И практически каждый Android-разработчик мечтал о простом способе работы с этими глубокими ссылками. Окей, я мечтал. И казалось, что Navigation Component ровно то, что нужно.


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


Какую именно часть?


У меня было три кейса с диплинками, которые я хотел реализовать с помощью Navigation Component.


  • Открытие определённой вкладки нижней навигации допустим, я хочу через диплинк открыть вторую вкладку на главном экране после Splash-экрана

Посмотреть на картинке

Допустим, я хочу через диплинк открыть вкладку Favorites нижней навигации на главном экране после Splash-экрана:



  • Открытие определённого экрана ViewPager-а внутри конкретной вкладки нижней навигации

Посмотреть на картинке

Пусть я хочу открыть определённую вкладку ViewPager-а внутри вкладки Responses:



  • Открытие экрана, который требует авторизации пользователя пусть теперь для корректной работы вкладки нижней навигации требуется авторизация. И с помощью диплинка я хочу сначала показать Splash-экран, затем открыть флоу авторизации, а когда пользователь его пройдёт, открыть нужную вкладку

Посмотреть на картинке

Пусть вкладка Profile теперь требует авторизации. И с помощью диплинка я хочу сначала показать Splash-экран, затем открыть флоу авторизации, а когда пользователь его пройдёт открыть вкладку Profile.



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



Прежде чем делать диплинк для второй вкладки, я решил настроить ссылку хотя бы для первой. Я решил, что для этого будет достаточно просто добавить диплинк на экран контейнера нижней навигации (не самой вкладки, а контейнера):


<navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:id="@+id/app_nav_graph"    app:startDestination="@id/SplashFragment">    <fragment        android:id="@+id/SplashFragment"        android:name="ui.splash.SplashFragment" />    <fragment        android:id="@+id/MainFragment"        android:name="ui.main.MainFragment">        <deepLink app:uri="www.example.com/main" />    </fragment></navigation>

Затем я, следуя документации, добавил граф навигации с диплинком в Android Manifest:


<manifest xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    package="com.aaglobal.jnc_playground">    <application android:name=".App">        <activity android:name=".ui.root.RootActivity">            <nav-graph android:value="@navigation/app_nav_graph"/>            <intent-filter>                <action android:name="android.intent.action.MAIN" />                <category android:name="android.intent.category.LAUNCHER" />            </intent-filter>        </activity>    </application></manifest>

А потом решил проверить, работает ли то, что я настроил при помощи простой adb-команды:


adb shell am start \  -a android.intent.action.VIEW \  -d "https://www.example.com/main" com.aaglobal.jnc_playground

И-и-и нет. Ничего не завелось. Я получил краш приложения с уже знакомым исключением IllegalStateException: FragmentManager is already executing transactions. Дебаггер указывал на код, связанный с настройкой нижней навигации, поэтому я решил просто обернуть эту настройку в очередной Handler.post:


// MainFragment.kt  fragment with BottomNavigationViewoverride fun onViewCreated(view: View, savedInstanceState: Bundle?) {    super.onViewCreated(view, savedInstanceState)    if (savedInstanceState == null) {        safeSetupBottomNavigationBar()    }}private fun safeSetupBottomNavigationBar() {    Handler().post {        setupBottomNavigationBar()    }}

Это исправило краш, но приложение всё равно работало неправильно: запустив диплинк, мы пропустили Splash-экран, он просто не запускался. А это означает, что не отрабатывал код, который отвечал за инициализацию моего приложения.


Это произошло, потому что в нашем случае путь диплинка был таким: мы запустили приложение, запустилась его единственная Activity. В вёрстке этой activity мы инициализировали первый граф навигации. В этом графе оказался элемент, который удовлетворял URI, мы отправили его через adb-команду вуаля, он сразу и открылся, проигнорировав указанный в графе startDestination.


Тогда я решил перенести диплинк в другой граф внутрь вкладки нижней навигации.


<navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:id="@+id/menu__search"    app:startDestination="@id/SearchContainerFragment">    <fragment        android:id="@+id/SearchContainerFragment"        android:name="tabs.search.SearchContainerFragment">        <deepLink app:uri="www.example.com/main" />        <action            android:id="@+id/action__SearchContainerFragment__to__CompanyFlow"            app:destination="@id/company_flow__nav_graph" />        <action            android:id="@+id/action__SearchContainerFragment__to__VacancyFragment"            app:destination="@id/vacancy_nav_graph" />    </fragment></navigation>

И, запустив приложение, я получил ЭТО:


Посмотреть на ЭТО


На гифке видно, как приложение запустилось, и мы увидели Splash-экран. После этого на мгновение показался экран с нижней навигацией, а затем приложение словно запустилось заново! Мы снова увидели Splash-экран, и только после его повторного прохождения появилась нужная вкладка нижней навигации.


И что самое неприятное во всей этой истории это не баг, а фича.


Если почитать внимательно документацию про работу с диплинками в Navigation Component, можно найти следующий кусочек:


When a user opens your app via an explicit deep link, the task back stack is cleared and replaced with the deep link destination.

То есть наш back stack специально очищается, чтобы Navigation Component-у было удобнее работать с диплинками. Говорят, что когда-то давно, в бета-версии библиотеки всё работало адекватнее.


Мы можем это исправить. Корень проблемы в методе handleDeepLink NavController-а:


Кусочек handleDeepLink
public void handleDeepLink(@Nullable Intent intent) {    // ...    if ((flags & Intent.FLAG_ACTIVITY_NEW_TASK) != 0) {        // Start with a cleared task starting at our root when we're on our own task        if (!mBackStack.isEmpty()) {            popBackStackInternal(mGraph.getId(), true);        }        int index = 0;        while (index < deepLink.length) {            int destinationId = deepLink[index++];            NavDestination node = findDestination(destinationId);            if (node == null) {                final String dest = NavDestination.getDisplayName(mContext, destinationId);                throw new IllegalStateException("Deep Linking failed:"                        + " destination " + dest                        + " cannot be found from the current destination "                        + getCurrentDestination());            }            navigate(node, bundle,                    new NavOptions.Builder().setEnterAnim(0).setExitAnim(0).build(), null);        }        return true;    }}

Чтобы переопределить это поведение, нам потребуется:


  • почти полностью скопировать к себе исходный код Navigation Component;
  • добавить свой собственный NavController с исправленной логикой (добавление исходного кода библиотеки необходимо, так как от NavController-а зависят практически все элементы библиотеки) назовём его FixedNavController;
  • заменить все использования исходного NavController-а на FixedNavController.

Несложно, правда? Но кажется, что это уже слишком. Подобные исправления заставляют нас в будущем тратить всё больше и больше времени на поддержку нашего кастомного решения, ведь нужно будет обновлять свой фикс при каждом обновлении библиотеки. Около года назад коллеги из Яндекса столкнулись с похожей проблемой, и им пришлось делать примерно то же самое. Сочувствую.


В этот невесёлый момент я заметил ещё один баг, который был добавлен при попытке исправить краш с диплинками: сломалась обратная навигация из auth-флоу.


Покажи гифку


На гифке видно, как мы переключаемся на вкладку Profile, затем переходим во флоу авторизации. Потом мы нажимаем на кнопку Back и получаем просто белый экран. Если переключиться на какую-нибудь другую вкладку и обратно, то мы снова увидим контент вкладки Profile.


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



Если у вас будет свой собственный NavController, корректно обрабатывающий диплинки, реализовать этот кейс будет просто.


В NavController-е есть специальный булевский флажок isDeepLinkHandled, который говорит нам, что текущий NavController успешно обработал диплинк. Вы могли бы добавить диплинк, ведущий на фрагмент, который содержит в себе ViewPager, затем написать примерно вот такой код, чтобы перейти на нужную вкладку:


if (findMyNavController().isDeepLinkHandled && requireActivity().intent.data != null) {    val uriString = requireActivity().intent.data?.toString()    val selectedPosition = when {        uriString == null -> 0        uriString.endsWith("favorites") -> 0        uriString.endsWith("subscribes") -> 1        else -> 2    }    fragment_favorites_container__view_pager.setCurrentItem(selectedPosition, true)}

Но, опять же, это будет доступно только в случае, если вы уже добавили к себе в кодовую базу свою реализацию NavController-а, ведь флаг isDeepLinkHandled является private-полем. Ок, можно достучаться до него через механизм reflection-а, но это уже другая история.



Navigation Component не поддерживает диплинки с условием из коробки. Если вы хотите поддержать такое поведение, Google предлагает действовать следующим образом:


  • через диплинк открыть экран, который требует авторизацию;
  • на этом экране проверить, авторизован ли пользователь, если нет открыть флоу авторизации поверх нужного экрана;
  • пройти auth flow, вернуть результат из вложенного графа и т.д., и т.п.

Возможности глобально решить мою задачу средствами Navigation Component-а я не нашёл.


Выводы по работе с диплинками в Navigation Component


  • Работать с ними больно, если требуется добавлять дополнительные действия или условия.
  • Объявлять диплинки ближе к месту их назначения классная идея, в разы удобнее AndroidManifest-а со списком поддерживаемых ссылок.

Бонус-секция кейсы БЕЗ проблем


Чтобы разбавить негатив от предыдущей секции, я покажу пару кейсов, с которыми вообще не было проблем. А я их ждал. Но их не было.



Допустим, у вас есть экран вакансий, с которого вы можете перейти на другую вакансию.


Где на схеме приложения этот кейс?


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


<fragment  android:id="@+id/VacancyFragment"  android:name="com.aaglobal.jnc_playground.ui.vacancy.VacancyFragment"  android:label="Fragment vacancy"  tools:layout="@layout/fragment_vacancy">  <argument      android:name="vacancyId"      app:argType="string"      app:nullable="false" />  <action      android:id="@+id/action__VacancyFragment__to__VacancyFragment"      app:destination="@id/VacancyFragment" /></fragment>

И этого оказалось достаточно новый экран открывался поверх старого, при нажатии на кнопку Back навигация была корректной. Если бы я захотел, чтобы каждый новый экран открывался вместо текущего, было бы достаточно добавить атрибут popUpTo к моему action-у.



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


Где на схеме приложения этот кейс?


Я добавил контейнер для будущего фрагмента со списком в вёрстку вкладки нижней навигации:


<LinearLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:layout_width="match_parent"    android:layout_height="match_parent"    android:orientation="vertical">    <TextView        android:id="@+id/fragment_favorites_container__text__title"        style="@style/LargeTitle"        android:text="Favorites container" />    <androidx.fragment.app.FragmentContainerView        android:id="@+id/fragment_favorites_container__container__recommend_vacancies"        android:layout_width="match_parent"        android:layout_height="match_parent" /></LinearLayout>

А затем в runtime-е добавил нужный мне фрагмент в этот контейнер:


class FavoritesContainerFragment : Fragment(R.layout.fragment_favorites_container) {    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {        super.onViewCreated(view, savedInstanceState)        childFragmentManager.attachFragmentInto(          containerId = R.id.fragment_container_view,          fragment = createVacancyListFragment()        )    }}

Метод attachFragmentInfo на childFragmentManager это extension-метод, который просто оборачивает всю работу с транзакциями, не более того.


А вот как я создал фрагмент:


class FavoritesContainerFragment : Fragment(R.layout.fragment_favorites_container) {    // ...    private fun createVacancyListFragment(): Fragment {        return VacancyListFragment.newInstance(          vacancyType = "favorites_container",          vacancyListRouterSource = object : VacancyListRouterSource {              override fun navigateToVacancyScreen(item: VacancyItem) {                  findNavController().navigate(                      R.id.action__FavoritesContainerFragment__to__VacancyFragment,                      VacancyFragmentArgs(vacancyId = "${item.name}|${item.id}").toBundle()                  )              }        }     }}

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



Пусть у меня есть несколько BottomSheetDialog-ов, между которыми я хочу перемещаться с помощью Navigation Component.


Где на схеме приложения этот кейс?


Год назад с таким кейсом были какие-то проблемы, но сейчас всё работает как надо. Можно легко объявить какой-то dialog в качестве destination-а в вашем графе навигации, можно добавить action для открытия диалога из другого диалога.


<navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:id="@+id/menu__favorites"    app:startDestination="@id/FavoritesContainerFragment">   <dialog        android:id="@+id/ABottomSheet"        android:name="ui.dialogs.dialog_a.ABottomSheetDialog">        <action            android:id="@+id/action__ABottomSheet__to__BBottomSheet"            app:destination="@id/BBottomSheet"            app:popUpTo="@id/ABottomSheet"            app:popUpToInclusive="true" />    </dialog>    <dialog        android:id="@+id/BBottomSheet"        android:name="ui.dialogs.dialog_b.BBottomSheetDialog">        <action            android:id="@+id/action__BBottomSheet__to__ABottomSheet"            app:destination="@id/ABottomSheet"            app:popUpTo="@id/BBottomSheet"            app:popUpToInclusive="true" />    </dialog></navigation>

Диалоги создавались как нужно, закрывались вовремя, навигация по кнопке Back отрабатывала как ожидалось.


Выводы по бонус-секции


Кейсы без проблем существуют.


Подведём итоги


На данный момент нет никакой причины переводить большое приложение на Navigation Component. Слишком много проблем, слишком много костылей, постоянно нужно выдумывать что-то для осуществления не самых сложных кейсов навигации. Сам факт, что я ухитрился написать так много текста про проблемы с Navigation Component-ом, что-то да говорит.


Если у вас маленькое приложение, надо внимательно посмотреть на то, какие кейсы навигации вы хотите поддержать. Если задумываетесь про нижнюю навигацию, про диплинки пожалуй, лучше реализовать всё по-старинке: либо руками, либо на Cicerone. В остальных случаях, если вас не пугает необходимость постоянно искать фиксы, можно воспользоваться Navigation Component-ом.


Пример приложения на Github-е лежит здесь.


Полезные ссылки по теме


Подробнее..

Работа с толстофичами как разобрать слона на части и собрать обратно

28.12.2020 10:22:36 | Автор: admin

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


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



Цель статьи: поделиться нашим текущим опытом на тернистом пути к безболезненному росту и переиспользованию фич приложения, а так же рассмотреть и опробовать на реальном кейсе общий принцип к проектированию архитектуры, описанный в докладе The immense benefits of not thinking in screens.


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


В hh мы крепко подсели на MVICore для управления состоянием наших фич. По именованию сущностей в этой статье может показаться, что статья имеет смысл только в контексте MVI, но на самом деле мы не будем затрагивать внутренние особенности этого паттерна. Вместо этого мы воспользуемся общим принципом построения архитектуры на основе black box компонентов.


Black box компоненты


Перед тем, как перейти к практическому кейсу, разберем, что такое black box компоненты. Основы этой концепции отлично описаны в статьях от коллег из Badoo (особенно в этой).


В общем виде black box это система, внутреннее устройство которой неизвестно за ее пределами. Все, что мы знаем о black box, это то, что с ним можно взаимодействовать через некоторый известный Input и наблюдать его реакции через Output.


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


Схема Feature


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


  • Изменить свое текущее состояние (State)
  • Отправить одно или несколько событий (News)

Приведем пример: представьте фичу форма заявки от пользователя в виде black box. Форма может содержать правила валидации, автозамены, предзаполнения etc., но всё это является внутренним устройством фичи, которое не имеет значения в контексте интеграции с другими компонентами приложениями. Для пользователей формы Wish-ами станут события ввода данных, State текущее содержимое полей формы, а News ошибки заполнения формы, которые, например, будут нарисованы на UI одноразовыми сообщениями в Snackbar.


Схема разницы между State и News


В терминах реактивных фреймворков (RxJava, Reaktive) Feature реализует интерфейсы Consumer<Wish>, ObservableSource<State> и ObservableSource<News>.


class Feature : Consumer<Wish>, ObservableSource<State> {    override fun accept(wish: Wish) { ... }    override fun subscribe(observer: Observer<State>) { ... }    val news = ObservableSource<News> { ... }}

Мы можем подписывать Consumer<B> на ObservableSource<A>, если нам известно преобразование между типами (A) -> B. Это позволяет нам научить наши фичи общаться друг с другом. Так мы получаем инструмент для создания систем реактивных компонентов, который попробуем применить для описания фич приложения и их взаимодействия друг с другом.


Толстофича и ее проблемы


Перейдем к конкретному примеру. В приложении hh есть экран со списками откликов на вакансии (negotiations). На этом экране пользователь должен видеть различные списки сделанных им откликов, разбитых по статусам. Каждый список поддерживает пагинацию, обновление по свайпу, показ дополнительных сообщений и многое другое.



Скриншоты специально подобраны так, чтобы затронуть как можно больше функциональных особенностей этого экрана.


Давайте попробуем подумать об этом экране, как об изолированном black box компоненте.


Чтобы однозначно описать фичу в виде black box, достаточно описать 3 типа Wish, State и News. Если попробовать описать типы "в лоб" и проектировать экран при помощи подхода "фича == экран" (как мы и сделали изначально), то получится нечто подобное:


Black box экрана откликов
State экрана

Wish экрана

News экрана

Вот несколько наблюдений, которые можно сделать по этим сниппетам:


  • Фича имеет довольно много "внешних ручек" (Wish). При обработке такого количества вариантов Wish в when-выражении мы регулярно сталкиваемся с ворнингами статического анализатора о том, что метод слишком сложный. У фичи слишком сложный внешний интерфейс.
  • Как на уровне состояния фичи, так и на уровне ее Wish и News можно выделить отдельные ответственности экрана. И есть предположение, что разные Wish независимо друг от друга влияют на разные части состояния. Явный намек на то, что плоская иерархия Wish и полей в State может быть как-то сгруппирована.
  • Из-за настолько объёмного контракта фичи практически невозможно отделить специфичные функциональные особенности экрана откликов от более общих, которые мы бы хотели использовать на других экранах. Например, логику показа баннера с рекомендацией ("Откликайтесь чаще!") можно добавить и в другие списки внутри приложения, но она уже привязана к логике экрана откликов. Низкая переиспользуемость.
  • Если приоткрыть ящик Пандоры дверцу black box-а и посмотреть на детали внутренней реализации фичи, то мы найдём там ещё одну sealed-иерархию из множества data-классов, предназначенных для изменения состояния фичи (Effect-ы в терминах MVICore), и набор сущностей, специфичных для библиотеки MVICore: Actor, Reducer, Bootstraper, PostProcessor, реализация которых может насчитывать несколько сотен строк. Я не буду вдаваться в подробности о том, что именно происходит внутри этих сущностей, просто отмечу сложность и масштабы содержимого black box, который мы получили с таким подходом. Сложная реализация.

Кроме того:


  • В контексте интеграции фичи с остальным приложением неважно, насколько сложно фича устроена внутри, если рассматривать её как "чёрный ящик", потому что есть чёткий контракт входов и выходов. С другой стороны, если вся логика работы экрана сосредоточена внутри одного black box, то при добавлении новой функциональности придётся заново изучать все детали его реализации. Только так мы сможем гарантировать, что новый код будет правильно дружить с написанным ранее. Сложная поддержка.
  • Ещё отмечу, что хоть зачастую мы и можем поделить экран на набор обособленных кусочков функциональности, не всегда получается сделать их полностью независимыми друг от друга. Например, на приведенном выше экране откликов есть панель с табами-статусами. У каждого таба есть счетчик непрочитанных откликов, который периодически обновляется. По бизнес-правилам экрана если счётчик при обновлении получит новое значение, необходимо выполнить обновление соответствующего списка откликов. При использовании подхода "фича = экран" такие бизнес-правила (которые описывают связи между разными кусочками функциональности) будут растворяться во внутренней реализации фичи, и порой тяжело узнать об их существовании, не говоря о том, чтобы чётко сформулировать. Неочевидно как одна функциональность может затрагивать другую.

Декомпозиция фичи списка откликов


Но что будет, если мы попробуем разбить один большой "чёрный ящик" на несколько маленьких, разделяя их по функциональности?


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


Авторизация



Экран списка откликов (и некоторые другие экраны приложения) доступны как авторизованным, так и неавторизованным пользователям. Если пользователь не авторизован, логика экрана предельно простая: мы показываем заглушку, которая предлагает пройти авторизацию.


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


Получился black box авторизации с простейшим контрактом:


AuthFeature

У AuthFeature нет ни одного Wish, так как эта фича самостоятельно подписывается на источник информации о смене активного пользователя. Если нам потребуется знать больше информации о текущем пользователе, помимо индикатора авторизации, мы расширим состояние и внутреннюю логику именно этой фичи.


Статусы откликов



Статусы откликов визуально представлены как панель с табами. Помимо отображения счётчиков непросмотренных изменений, каждый таб является фильтром по статусу откликов. Мы смоделировали эту фичу следующим образом:


StatusFeature

Фича описывает выбранный пользователем статус (NegotiationStatusPage) и значения счётчиков для каждого статуса. При изменении значений счётчиков или выборе нового статуса мы должны сообщать об этом наружу, чтобы узнать о необходимости обновления соответствующего списка откликов. Мы описали это через News, так как снаружи нас интересует единоразовая реакция на событие перехода между конкретными состояниями.


Рекомендация Откликайтесь чаще



Баннер "Откликайтесь чаще" по бизнесовым правилам должен отображаться только если среди статусов откликов выбран фильтр "Все". При этом, если пользователь скроет баннер нажатием на крестик, данное сообщение больше не должно всплывать.


RecommendationFeature

Wish CheckVisibility и ClearVisibility нужны для проверки состояния и сброса флага о показе баннера при смене состояния авторизации.


Помните я говорил, что не хотелось бы, чтобы другие фичи экрана что-то знали про авторизацию? На самом деле мы можем избавиться от этих двух Wish, если не создавать экземпляр фичи до логина пользователя и уничтожать её сразу после логаута. Но к этой теме мы вернёмся позже, когда закончим декомпозицию фич и перейдем к их связыванию, заодно обсудим, причём тут декларативный UI.


Список откликов с пагинацией



То, ради чего и создавался экран отображение списка откликов. Это типичный списочный компонент с пагинацией, завязанной на скролл. Пагинация не самая тривиальная фича, поэтому реализовывать её на каждом конкретном экране с нуля не очень хочется, учитывая количество возможных краевых случаев. Поэтому мы решили выделить её в отдельный black box, параметризованный типом элементов списка и функцией для подгрузки очередной страницы:


PaginationFeature

Статистика работодателя



Каждый элемент списка откликов содержит индикатор индекса вежливости работодателя (отображает процент просмотренных работодателем откликов). По клику на этот индикатор нужно развернуть более подробное описание статистики.


EmployerStatsFeature

Действия с откликами



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


NegotiationActionsFeature



Некоторые фичи (например, статистика работодателя EmployerStatsFeature), получились очень простыми и могут показаться излишними, ведь они управляют всего лишь UI-логикой отображения элементов списка. Однако, сформулировать чёткие критерии и границы, где логика перестаёт быть UI-only и превращается в чистую бизнес-логику, довольно сложно. На выходе мы хотели получить набор однородных компонентов, которые будут отделены друг от друга не по границам искусственно введённых слоёв, а по границам той функциональности, за которую они отвечают.


Промежуточный итог декомпозиции


Итак, мы получили семейство "чёрных ящиков" (фич) экрана, которые пока что никак не связаны друг с другом. Прежде чем начать связывать их в коде, давайте построим диаграмму связей между фичами, которые диктуются нам бизнес-требованиями:



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


Я уже упоминал выше, что связи между событиями авторизации и реакциями других фич в стиле "Сбрасываем состояние" выглядят не очень красиво, и их можно избежать, если создавать фичи только в авторизованной зоне, гарантированно уничтожая их при логауте. Чтобы лучше раскрыть эту тему, следует взглянуть еще разок на экран откликов и оценить его уже с точки зрения иерархии фрагментов.



Корень иерархии фрагмент NegotiationsContainerFragment, который нужен для показа содержимого вкладки нижней навигации, а заодно в нем мы можем показать bottom sheet-диалог для действий с откликами. В контейнер кладётся фрагмент NegotiationPagerFragment, отображающий состояние экрана списка откликов в авторизованной и неавторизованной зоне, а заодно содержащий ViewPager для списков откликов. Списки откликов, разбитые по статусам, находятся в отдельных StatusPageFragment.


За каждым фрагментом мы закрепляем автоматически создаваемый DI-скоуп, в котором поддерживаем экземпляры наших фич. В идеале нужно было вынести экран-заглушку (который показывается в случае отсутствия авторизации) в отдельный фрагмент и не прикреплять фрагмент для авторизованной зоны (NegotiationsPagerFragment) до тех пор, пока фича авторизации не сообщит нам, что пользователь авторизовался. Тогда мы бы получили автоматическое пересоздание всех нужных фич при логине/логауте пользователя.


Однако в реалиях работы с фрагментами синхронизация состояния FragmentManager с состоянием бизнес-логики приложения не самая приятная задача и потенциальный источник багов, связанных с неконсистентностью отображения экрана. В рамках рефакторинга логики экрана меньше всего хотелось трогать уже существующий код отображения состояний на View. Поэтому пока что мы решили оставить уже имеющуюся иерархию фрагментов, что заставило нас добавить специальные Wish для сброса состояния фич из авторизованной зоны.


Фреймворки для декларативного UI должны решить проблему гибкого управления жизненным циклом экземпляров фич, так как изменив флажок в состоянии фичи, мы сможем автоматически перестроить иерархию UI-компонентов, а значит и создать/уничтожить привязанные к ним фичи.


Как связать фичи в коде



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


Каждая фича реализует интерфейсы Consumer<Wish>, ObservableSource<State>, ObservableSource<News>. И по сути, связывание фич это реактивные подписки с маппингом State/News одних фич в Wish для других фич.


Важное наблюдение. Black box очень общая концепция, которой можно описывать не только stateful бизнес-логику фич. Следовательно, и связывание можно делать не только между фичами, а между произвольными ObservableSource<A> и Consumer<B>. Например, UI тоже можно рассматривать как black box с контрактами Consumer<UiState> и ObservableSource<UiEvent>, а любые внешние события, которые происходят за рамками данного фрагмента, как ObservableSource<ExternalEvent>.


Для создания таких подписок мы использовали Binder из MVICore, но концептуально подход применим и для других фреймворков: можно использовать корутины, подписывать Rx-потоки напрямую или использовать обычные коллбэки. Главная идея описать связи между фичами в явном виде под нужды конкретного контекста использования, вытащив эти связи из внутренней реализации фич наружу.


По рекомендации от Badoo связывание фич удобно вынести в отдельный класс Bindings. Мы решили разделить связи между фичами экрана по структурным компонентам (в нашем случае это обычные фрагменты).


В DI-скоупе самого верхнего уровня (который привязан к жизненному циклу NegotiationsPagerFragment) создается экземпляр фичи авторизации (AuthFeature), действий с откликами (NegotiationActionsFeature) и фича статусов откликов (StatusFeature). Также на этом уровне мы слушаем внешние события, которые могут произойти на других экранах приложения и повлиять на состояния этих фич.


NegotiationsPagerBindings

Binder автоматически выполнит отписку связей между фичами, когда переданный ему жизненный цикл (объект Lifecycle) перейдет в уничтоженное состояние. Отмечу, что здесь мы используем два lifecycle. featureLifecycle соответствует жизненному циклу DI-скоупа, связанного с фрагментом, а viewLifecycle соответствует жизненному циклу View этого же фрагмента.


Трансформации между типами событий описываются несложными чистыми функциями, вот пример:


authToStatus, actionToStatus

Трансформации событий между фичами могут описывать бизнес-правила их взаимодействия. При необходимости эти правила можно легко протестировать.


На уровень ниже находится StatusPageFragment, в котором создается экземпляр фичи пагинации (PaginationFeature) и показа баннера (RecommendationFeature). При этом для каждой страницы ViewPager создаются свои экземпляры этих фич.


NegotiationsStatusPageBindings

Примеры трансформаций для этого уровня:


statusToNegotiationsList, externalEventsToNegotiationsList

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


Преимущества подхода



"Look around we live in a perfect world where everything fits together and no one gets hurt."
Homer Jay Simpson


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

Пример теста на фичу для списков с пагинацией

Проблемы подхода


Binding hell: связи между фичами могут выходить из-под контроля


Один из вариантов решения проблемы делать иерархию структурных компонентов менее плоской. Под структурными компонентами здесь мы понимаем сущности типа Fragments, RIBs, Controller из Conductor и т.п., в контексте которых мы связываем фичи.


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



Нарушение целостности системы


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


Приведу пример. Допустим, у нас есть фича, которая управляет состоянием вакансии, и отдельная фича для управления процессом отклика на вакансию (отклик это самостоятельная процедура, включающая несколько этапов). Один из инвариантов, который мы хотим поддержать между этими фичами, заключается в том, что если процесс отклика перешёл в состояние "отклик сделан", то вакансия должна иметь состояние "текущий пользователь откликался". Фиче вакансии необходимо знание о наличии отклика, чтобы открывать пользователю дополнительные действия.



Таким образом, разделив состояние связанных друг с другом фич на отдельные сущности, мы создали возможность нарушения инварианта в нашей реактивной системе, пусть и на очень малый отрезок времени. Когда процесс отклика перейдет в конечное состояние, фича вакансии может еще не успеть среагировать на этот факт в силу чисто технических причин (вроде затрат времени на переключение потоков). И не исключено, что Consumer состояния вакансии успеет сделать что-то невалидное, предполагая, что отклика на вакансию всё ещё нет.


На практике это свойство систем с распределенным состоянием не всегда является критичным. Например, если "очень малый отрезок времени" несогласованности данных не ломает дальнейшую бизнес логику. Или если он влияет только на UI, но происходит в пределах отрисовки одного кадра и не вызывает резких мерцаний. Тем не менее, всегда стоит иметь это в виду и ознакомиться с опытом коллег из других областей разработки. Один из способов частично избежать проблем с несогласованностью данных при их расщеплении проектировать распределенные состояния так, как если бы мы проектировали реляционную базу данных, стараясь избегать дублирования и противоречивости в модели данных.


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


Заключение и альтернативные способы композиции фич


В этой статье я рассказал про наш боевой тест-драйв подхода к изоляции фич в реактивные black box компоненты. Неизбежным последствием стало расщепление состояния между компонентами и их связывание с использованием реактивного фреймворка. Основное преимущество, которое мы получили облегчили переиспользование и тестирование фич. Также мы получили возможность масштабировать сложные фичи, конструируя их из простых компонентов. В нашем случае мы больше всего были рады тому, что теперь у нас есть универсальный и хорошо протестированный компонент для работы со списочной пагинацией, для подключения которого не нужно затрагивать реализацию других компонентов экрана. Будем продолжать экспериментировать с этим подходом и для других фич приложения.


Как я отметил в предыдущем разделе, фичи можно композировать в рамках одного состояния. Например, при использовании подходов с централизованным управлением состоянием (в духе TEA или Redux) композиция логики может быть реализована на уровне чистых функций. Главный плюс подхода к композиции на уровне единого хранилища состояния сохранение возможности более консистентного контроля состояния и отсутствие сильной зависимости от реактивных фреймворков. Но для подробного описания композиции с единым состоянием, кажется, потребуется отдельная большая статья.


Дабы избежать холиваров, сразу отмечу, что целью статьи является не сравнение различных подходов в стиле лучше/хуже, а лишь практическая демонстрация особенностей подхода к декомпозиции составных фич на реактивную систему black box компонентов. Выбор подходящего инструмента под нужды конкретно вашего проекта is your own. Однако нам всегда интересно поделиться с вами нашим опытом.


Отдельное спасибо за помощь при подготовке текста статьи: Ztrel, alaershov, Xanderblinov

Подробнее..

Мобилка hh.ru теперь и в Беларуси как жить, когда команду раскидало

12.05.2021 10:23:58 | Автор: admin

Долгие годы наша разработка изобретала и создавала новые решения в HR-tech строго из московского офиса. Но последний год многое изменил: мы не только научились работать удаленно по всей России, но и обзавелись крутой командой в нашей белорусской сестрице rabota.by.

Мы пообщались с лидом всея мобилки hh.ru Сашей Блиновым и разработчиками из Беларуси, чтобы в этой статье рассказать вам, с какими вызовами сталкивается IT в Синеокой, почему в Беларуси отдельное мобильное приложение и как жить, когда твоя команда располагается в разных странах.

Кто такой этот ваш rabota.by?

Сервис rabota.by входит в HeadHunter Group и на протяжении вот уже 11 лет отвечает за работу на главной странице крупнейшего белорусского информационного портала. Но в связи с вступлением в Парк Высоких Технологий и развитием IT-направления в компании, было принято решение о трансформации.

27 октября 2020 года белорусский сервис РАБОТА.TUT.BY перевел своих пользователей на новый сайт rabota.by и провел глобальный ребрендинг. Изменился не только адрес сайта, но и логотип, фирменные цвета и другие визуальные элементы. Новый логотип разработан в близких цветах к прошлому в компании сохранили идею красного цвета.

Переезд на новый домен был осуществлен силами Технического департамента hh.ru. Это был весьма трудоемкий процесс. В тысяче мест нашего кода существовали условия формата если пришел смотреть jobs.tut.by, сделай следующее. Приходилось отлавливать такие фрагменты вручную и переписывать.

Как мы стали набирать разработчиков в Беларуси

Сейчас команда разработки в Беларуси включает в себя двух iOS-разработчиков, одного бекендера, мобильного тестировщика и биллингового разработчика. Все они часть Технического департамента hh.ru. Но почему именно в Беларуси? Рассказываем.

Случилось следующее: было принято решение полностью переделать приложение HR Мобайл. Беларусь уже зарекомендовала себя как IT-страна. А с учетом того, что на протяжении первых 5 лет работы компании в минском офисе был IT-отдел, то решение о "перезагрузке" было принято быстро.

При найме мы столкнулись с рядом проблем. Если раньше нам было проще находить людей, то сейчас с этим явные напряги. К тому же, последние пару лет наблюдается тенденция на релокейт IT-специалистов в соседние (и не очень) европейские страны.

Задачу усложняет также не совсем привычная для российского рекрутмента система: очень многие заключают с работодателем контракт на год и обязаны соблюдать строгие обязательства до момента его истечения. С джунами все тоже неочевидно: после окончания вуза студенты-бюджетники обязаны отработать 2 года по распределению, прежде чем смогут начать строить карьеру по собственному желанию.

Кроме того, залогом бодрого найма в любую компанию является и крепкий технобренд: его узнаваемость, трастовость и статус. Ясное дело: хорошие специалисты охотнее пойдут туда, где не только лучшие условия, но и наиболее известные продукты на рынке. Словом, новичкам всегда приходится трудно, даже если ты уже узнаваемая в других странах IT-компания.

Чем занимается разработка в Беларуси

Свежесобранная команда практически сразу занялась запланированной разработкой приложения HR Мобайл. Кодовая база старого аналогичного приложения была максимально неудобной: работать с ней в перспективе виделось практически нереальным. Решили, что полное переписывание приложения лучше соответствует нашим планам, да и окупится оно гораздо быстрее. Также мы решили полностью переделать дизайн: он тоже нуждался в обновлении.

Вариант приложения для Беларуси не сильно отличается от общей сборки для СНГ. Да и различия в основном обусловлены требованиями правовой системы: в Беларуси при регистрации пользователь обязан указать номер телефона, когда как в российском варианте приложения достаточно ввести только email. По сути команда разрабатывает одно приложение для всех, но с небольшими отличиями под каждую страну.

На удаленке почти не ощущается территориальная разобщенность: команды работают над общим делом. Если игнорировать юридическую принадлежность к другому офису, специалисты из Беларуси часть большой и дружной банды из главного офиса hh.ru.

Что говорят разработчики

Чтобы узнать о впечатлениях наших новичков из Беларуси, аффтар (sic!) статьи решил поговорить с ними лично: узнать, как работается, не мешает ли удаленка и как вообще обстоят дела с IT в стране. Знакомьтесь:

В профессии я девять с копейками лет. Последние полгода занимаюсь продуктовой разработкой мобильного приложения в rabota.by и hh.ru. До того, как я сюда попал, честно говоря, были мысли о том, чтобы уехать жить и работать в другую страну. Что поделать: таково общее настроение в IT-секторе Беларуси сегодня.

Но совершенно внезапно мой друг посоветовал hh.ru. Поначалу я отнесся скептически: опыт работы с российскими компаниями у меня был довольно травматичный. У тех, с кем мне довелось взаимодействовать, было абсолютной нормой лезть во все процессы, диктовать и навязывать свои решения.

Здесь, к счастью, всего этого нет. В rabota.by я пока ни разу не столкнулся ни с чем из того, что меня обычно напрягало. А работал я и в стартапах с адскими переработками, и на суровом аутсорсе, и в больших компаниях Беларуси. С высоты моего опыта в IT, кажется, что здесь лучшее место из всех, где я побывал.

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

За то время, пока я здесь, мы уже успели полностью переписать мобильное приложение, а сейчас занимаемся его продуктовым развитием: создаем новые фичи и внедряем разные полезные штуки. Я занимаюсь списками вакансий и откликов, а мой коллега, Саша Цыбулько, работает над поиском.

Я занимаюсь iOS-разработкой с 2011 года. До hh пять лет проработал в небольшой продуктовой компании. Со временем понял, что не происходит ничего нового, работа превратилась в рутину. Присматривать новую работу начал еще за три месяца до планируемого ухода. Искал место, где я смогу развиваться и пробовать что-то новое и интересное.

Пообщался со многими компаниями и в конце концов дошел до hh.ru: мне понравились проекты, вызовы и подходы. Все это идеально совпало с тем, что мне хотелось найти.

Я пришел сюда в декабре. Первое, что меня впечатлило, люди. Максимально легкие коммуникации, я абсолютно безболезненно влился в команду. Отношения между сотрудниками в hh просто потрясающие. Каждый готов поддержать, рассказать и вкатиться в твою инициативу. Второе это, конечно, процессы и подходы. Я смотрю на все это и думаю: Это же так просто, почему я раньше так делал? Почему в других компаниях так не работает?

В hh.ru существует деление на продуктовые и core-команды. Так вышло, что сначала я попал в core, а через пару месяцев перешел в продуктовую, где мы переделали мобильное приложение практически целиком. Нужно понимать, что приложение hh.ru и rabota.by по сути одно и то же приложение с небольшими различиями.

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

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

В заключение

Да, найти и нанять IT-специалиста в Беларуси сейчас тот ещё квест. Но мы уверены: крутые специалисты всегда будут, пусть их сейчас и меньше, чем обычно. Мы в hh.ru и rabota.by невероятно рады, что наша мобильная разработка стала больше и раскинулась аж на две страны.

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

Подробнее..

Navigation Component-дзюцу, vol. 1 BottomNavigationView

09.09.2020 12:15:30 | Автор: admin


Два года назад на Google I/O Android-разработчикам представили новое решение для навигации в приложениях библиотеку Jetpack Navigation Component. Про маленькие приложения уже было сказано достаточно, а вот о том, с какими проблемами можно столкнуться при переводе большого приложения на Navigation Component, информации практически нет.


В этой и следующих двух статьях я расскажу о кейсах, с которыми может встретиться разработчик, желающий опробовать Navigation Component в большом Android-приложении.


Это текстовая версия моего выступления в рамках серии митапов по Android 11 в Android Academy. Само выступление было на английском, статью пишу на русском. Кому удобнее смотреть велкам.


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


Disclaimer


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


Схема моего тестового приложения выглядит так:



В цикле статей мы разберём каждый переход, который описан на этой схеме, а также несколько кейсов, которые не поместились на картинку.


Кейсы с BottomNavigationView


Когда я только-только услышал про Navigation Component, мне стало интересно: как будет работать BottomNavigationView и как Google подружит несколько отдельных back stack-ов в разных вкладках. Два года назад с этим кейсом были некоторые проблемы, и я решил проверить, как там обстоят дела сегодня.


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


Где на схеме приложения кейсы с навигацией?


Первый опыт


Я установил Android Studio 4.1 Beta (последнюю более-менее стабильную версию на тот момент) и попробовал шаблон приложения с нижней навигацией. Начало было многообещающим.


  • Мне сгенерировали Activity в качестве контейнера для хоста навигации и нижней навигации

Вёрстка Activity из шаблона
<androidx.constraintlayout.widget.ConstraintLayout    android:id="@+id/container">    <com.google.android.material.bottomnavigation.BottomNavigationView        android:id="@+id/nav_view"        app:menu="@menu/bottom_nav_menu" />    <fragment        android:id="@+id/nav_host_fragment"        android:name="androidx.navigation.fragment.NavHostFragment"        app:defaultNavHost="true"        app:navGraph="@navigation/mobile_navigation" /></androidx.constraintlayout.widget.ConstraintLayout>

Я убрал шумовые атрибуты, чтобы было проще читать.


Стандартный ConstraintLayout, в который добавили BottomNavigationView и тэг <fragment> для инициализации NavHostFragment-а (Android Studio, кстати, подсвечивает, что вместо фрагмента лучше использовать FragmentContainerView).


  • Для каждой вкладки BottomNavigationView был создан отдельный фрагмент

Граф навигации из шаблона
<navigation    android:id="@+id/mobile_navigation"    app:startDestination="@+id/navigation_home">    <fragment        android:id="@+id/navigation_home"        android:name="com.aaglobal.graph_example.ui.home.HomeFragment"/>    <fragment        android:id="@+id/navigation_dashboard"        android:name="com.aaglobal.graph_example.ui.dashboard.DashboardFragment"/>    <fragment        android:id="@+id/navigation_notifications"        android:name="com.aaglobal.graph_example.ui.notifications.NotificationsFragment"/></navigation>

Все фрагменты были добавлены в качестве отдельных destination-ов в общий граф навигации.


  • А ещё в проект был добавлен файл-ресурс для описания меню BottomNavigationView

@menu-ресурс для описания табов
<menu xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android">    <item        android:id="@+id/navigation_home"        android:icon="@drawable/ic_home_black_24dp"        android:title="@string/title_home" />    <item        android:id="@+id/navigation_dashboard"        android:icon="@drawable/ic_dashboard_black_24dp"        android:title="@string/title_dashboard" />    <item        android:id="@+id/navigation_notifications"        android:icon="@drawable/ic_notifications_black_24dp"        android:title="@string/title_notifications" /></menu>

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


Пора запускать приложение


После создания приложения из шаблона я запустил его и сразу столкнулся с двумя проблемами.


Первая проблема: при переключении между вкладками их состояние не сохранялось.


А ну-ка покажи


Для проверки я добавил во вкладку Dashboard простенькую ViewModel со счётчиком. На гифке видно, как я переключаюсь со вкладки Home на вкладку Dashboard, увеличиваю счётчик до четырёх. После этого я переключился обратно на вкладку Home и вновь вернулся на Dashboard. Счётчик сбросился.


Баг с описанием этой проблемы уже два года висит в Issue Tracker-е. Чтобы решить её, Google-у потребовалось серьёзно переработать внутренности фреймворка Fragment-ов, чтобы поддержать возможность работать с несколькими back stack-ами одному FragmentManager-у. Недавно на Medium вышла статья Ian Lake, в которой он рассказывает, что Google серьёзно продвинулись в этом вопросе, так что, возможно, фикс проблемы с BottomNavigationView не за горами.


Вторая проблема следствие первой. По принятым принципам навигации, когда вы нажимаете на уже выбранную вкладку BottomNavigationView, вы должны вернуться на первый фрагмент в стеке текущей вкладки. Но когда это происходит, состояние этого первого фрагмента сбрасывается, так как сам фрагмент пересоздаётся.


А ну-ка покажи


Для демонстрации этой проблемы я добавил на вкладку Dashboard кнопку, которая ведёт на следующий экран. На гифке видно, как я переключаюсь на вкладку Dashboard, увеличиваю счётчик до трёх, а затем перехожу на экран Graphic. Если я нажимаю на кнопку Back то всё работает как надо, состояние вкладки не сбрасывается. Но если, находясь на экране Graphic, я ещё раз нажму на вкладку Dashboard, то после возврата на первый экран в стеке увижу, что его состояние сброшено.


Не самое лучшее первое впечатление, подумал я. И начал искать фикс.


У нас есть workaround


Решение этих проблем живёт в специальном репозитории Google-а с примерами работы с Architecture Components, в проекте NavigationAdvancedSample.


Большая часть фикса расположена в файле NavigationExtensions.kt. В самом проекте довольно много кода, поэтому я не буду его разбирать подробно, а вместо этого подсвечу основные моменты, которые относятся к решению проблем.


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

Граф навигации для одной из вкладок
<?xml version="1.0" encoding="utf-8"?><navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools"    android:id="@+id/navigation_home"    app:startDestination="@id/HomeFragment">    <fragment        android:id="@+id/HomeFragment"        android:name="com.aaglobal.jnc_playground.ui.home.HomeFragment"        android:label="@string/title_home"        tools:layout="@layout/fragment_home" /></navigation>

Соответственно, для примера BottomNavigationView с тремя вкладками у нас получится три отдельных файла навигации XML, в которых в качестве startDestination будут указаны первые фрагменты вкладок.


  • Во-вторых, для каждой вкладки под капотом создаётся отдельный NavHostFragment, который будет связан с графом навигации этой вкладки

Создание NavHostFragment-а для графа вкладки BottomNavigationView
private fun obtainNavHostFragment(    fragmentManager: FragmentManager,    fragmentTag: String,    navGraphId: Int,    containerId: Int): NavHostFragment {    // If the Nav Host fragment exists, return itval existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment?    existingFragment?.let { return it }    // Otherwise, create it and return it.    val navHostFragment = NavHostFragment.create(navGraphId)    fragmentManager.beginTransaction()        .add(containerId, navHostFragment, fragmentTag)        .commitNow()    return navHostFragment}

FragmentManager пока что не поддерживает работу с множеством back stack-ов одновременно, поэтому пришлось придумать альтернативное решение, которое позволило ассоциировать с каждым графом свой back stack. Им стало создание отдельного NavHostFragment-а для каждого графа. Из этого следует, что с каждой вкладкой BottomNavigationView у нас будет связан отдельный NavController.


  • В-третьих, мы устанавливаем в BottomNavigationView специальный listener, который будет заниматься переключением между back stack-ами фрагментов

Listener для переключения между вкладками BottomNavigationView
setOnNavigationItemSelectedListener { item ->  val newlySelectedItemTag = graphIdToTagMap[item.itemId]  if (selectedItemTag != newlySelectedItemTag) {    fragmentManager.popBackStack(firstFragmentTag, FragmentManager.POP_BACK_STACK_INCLUSIVE)    val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag)        as NavHostFragment    if (firstFragmentTag != newlySelectedItemTag) {      fragmentManager.beginTransaction()        .attach(selectedFragment)        .setPrimaryNavigationFragment(selectedFragment).apply {          graphIdToTagMap.forEach { _, fragmentTagIter ->            if (fragmentTagIter != newlySelectedItemTag) {              detach(fragmentManager.findFragmentByTag(firstFragmentTag)!!)            }          }        }        .addToBackStack(firstFragmentTag)        .setReorderingAllowed(true)        .commit()    }    selectedNavController.value = selectedFragment.navController    true  } else {    false  }}

В прикреплённом кусочке кода мы видим, как при переключении между вкладками BottomNavigationView выполняется специальная транзакция в FragmentManager-е, которая прикрепляет фрагмент выбранной вкладки и отцепляет все остальные фрагменты. По сути, так мы и переключаемся между различными back stack-ами.


  • В итоге метод настройки BottomNavigationView возвращает разработчику специальную LiveData, которая содержит в себе NavController выбранной вкладки. Этот NavController можно использовать, например, для обновления надписи на ActionBar

Настраиваем BottomNavigationView в Activity
class RootActivity : AppCompatActivity(R.layout.activity_root) {  private var currentNavController: LiveData<NavController>? = null  private fun setupBottomNavigationBar() {      // Setup the bottom navigation view with a list of navigation graphs      val liveData = bottom_nav.setupWithNavController(          navGraphIds = listOf(            R.navigation.home_nav_graph,            R.navigation.dashboard_nav_graph,            R.navigation.notifications_nav_graph          ),          fragmentManager = supportFragmentManager,          containerId = R.id.nav_host_container,          intent = intent      )      // Whenever the selected controller changes, setup the action bar.      liveData.observe(this, Observer { ctrl -> setupActionBarWithNavController(ctrl) })      currentNavController = liveData  }}

Метод для настройки BottomNavigationView вызывают в onCreate-е, когда Activity создаётся в первый раз, затем в методе onRestoreInstanceState, когда Activity пересоздаётся с помощью сохранённого состояния.


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


Посмотреть, как это выглядит в коде


Опять же, не самая очевидная связь между этими элементами, зато работает.


После применения этого workaround-а первые две проблемы исчезли теперь состояние вкладки сохраняется между переключениями вкладок.


А ну-ка покажи

Первая проблема решилась:



И вторая тоже:



Адаптация workaround-а для фрагментов


Очень здорово, что workaround помог решить основную проблему с сохранением состояний, но этих фиксов мне было недостаточно. Пример из проекта NavigationAdvancedSample использовал в качестве контейнера нижней навигации Activity, мне же нужен был фрагмент.


Почему тебе нужен фрагмент?

Посмотрите внимательно на эту схему:



На ней можно увидеть, что пользователь начинает свой путь в приложении со Splash-экрана:



Google говорит, что Splash-экраны зло, ухудшающее UX приложения. Тем не менее, Splash-экраны суровая реальность большинства крупных Android-приложений. И если мы хотим использовать в нашем приложении Single Activity-архитектуру, то в качестве контейнера нижней навигации придётся использовать Fragment, а не Activity:



Я добавил вёрстку для фрагмента с нижней навигацией и перенёс настройку BottomNavigationView во фрагмент:


Посмотреть код
class MainFragment : Fragment(R.layout.fragment_main) {    private var currentNavController: LiveData<NavController>? = null    override fun onViewStateRestored(savedInstanceState: Bundle?) {        super.onViewStateRestored(savedInstanceState)        setupBottomNavigationBar()    }    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {        super.onViewCreated(view, savedInstanceState)        if (savedInstanceState == null) {            setupBottomNavigationBar()        }    }}

Я добавил в свой пример Splash-экран и дополнительную вкладку для BottomNavigationView. А чтобы пример стал ещё более походить на приложение для соискателей hh.ru, я также убрал из него ActionBar.


Для этого я поменял тему приложения с Theme.MaterialComponents.DayNight.DarkActionBar на Theme.MaterialComponents.DayNight.NoActionBar и убрал код для связки NavController-а с ActionBar-ом:


Код настройки BottomNavigationView выглядел так
class MainFragment : Fragment(R.layout.fragment_main) {    private var currentNavController: LiveData<NavController>? = null    private fun setupBottomNavigationBar() {        val navGraphIds = listOf(            R.navigation.search__nav_graph,            R.navigation.favorites__nav_graph,            R.navigation.responses__nav_graph,            R.navigation.profile__nav_graph        )        val controller = bottom_navigation.setupWithNavController(            navGraphIds = navGraphIds,            fragmentManager = requireActivity().supportFragmentManager,            containerId = R.id.fragment_main__nav_host_container,            intent = requireActivity().intent        )        currentNavController = controller    }}

После всех манипуляций я включил режим Don't keep activities, запустил свой пример и получил краш при сворачивании приложения.


А ну-ка покажи


На гифке видно, как я запустил приложение, и после Splash-экрана показывается экран с нижней навигацией. После этого мы сворачиваем приложение и получаем краш.


В чём была причина? При вызове onDestroyView активный NavHostFragment пытается отвязаться от NavController-а. Так как мой фрагмент-контейнер с нижней навигацией никак не привязывал к себе NavController, который он получил из LiveData, метод Navigation.findNavController из onDestroyView крашил приложение.


Добавляем привязку NavController-а к фрагменту с нижней навигацией (для этого в Navigation Component-е есть утилитный метод Navigation.setViewNavController), и проблема исчезает.


Кусочек кода с фиксом
class MainFragment : Fragment(R.layout.fragment_main) {    private var currentNavController: LiveData<NavController>? = null    private fun setupBottomNavigationBar() {        ...        currentNavController?.observe(            viewLifecycleOwner,            Observer { liveDataController ->                Navigation.setViewNavController(requireView(), liveDataController)            }        )    }}

Но это ещё не всё. Не выключая режим Don't keep activities, я попробовал свернуть, а затем развернуть приложение. Оно снова упало, но с другим неприятным исключением IllegalStateException в FragmentManager FragmentManager already executing transactions.


А ну-ка покажи


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


Краш происходит в методах, которые прикрепляют NavHostFragment к FragmentManager-у после их создания. Это исключение можно исправить при помощи костыля: обернуть методы attach-detach в Handler.post {}.


Фиксим IllegalStateException
// NavigationExtensions.ktprivate fun attachNavHostFragment(    fragmentManager: FragmentManager,    navHostFragment: NavHostFragment,    isPrimaryNavFragment: Boolean) {  Handler().post {    fragmentManager.beginTransaction()    .attach(navHostFragment)    .apply {      if (isPrimaryNavFragment) {        setPrimaryNavigationFragment(navHostFragment)      }    }    .commitNow()  }}

После добавления Handler.post приложение заработало, как надо.


Выводы по работе с BottomNavigationView


  • Использовать BottomNavigationView в связке с Navigation Component можно, если знать, где искать workaround-ы.
  • Если вы захотите иметь фрагмент в качестве контейнера нижней навигации BottomNavigationView, будьте готовы искать дополнительные фиксы для ваших проблем, так как скорее всего я поймал не все возможные краши.

На этом с BottomNavigationView всё, на следующей неделе расскажу про кейсы с вложенными графами навигации.

Подробнее..

Navigation Component-дзюцу, vol. 2 вложенные графы навигации

16.09.2020 12:14:05 | Автор: admin


Каждое большое приложение содержит множество способов навигации между экранами. А хорошая библиотека навигации должна помогать разработчику их реализовывать. Именно с такой мыслью я подошёл к исследованию кейсов со вложенными графами навигации.


Это вторая из трёх статей про реализацию кейсов навигации при помощи Navigation Component-а.


Первая статья про BottomNavigationView.


Где на схеме приложения кейсы со вложенными графами?



В этом кейсе мы говорим про следующую часть общей схемы навигации:



Представим такую ситуацию: у нас есть 4 экрана A, B, C и D. Пусть с экранов A и B вы можете перейти на экран C, с экрана C в экран D, а после D вернуться на тот экран, который начал флоу C->D.


А можно нагляднее?

В тестовом приложении, которое я приготовил для разбора Navigation Component-а, есть две вкладки BottomNavigationView (на схеме это Search и Responses но пусть они будут экранами A и B):



С обеих этих вкладок мы можем перейти на некоторый вложенный флоу, который состоит из двух экранов (C и D):



Если мы перейдём на экран C с вкладки Search (экрана A), то после экрана D мы должны вернуться на вкладку Search:



А если мы стартуем экран C со вкладки Responses, то после завершения внутреннего флоу C->D мы должны вернуться на вкладку Responses:



Этот кейс описывает старт последовательности экранов из разных мест приложения, а после её завершения возврат на тот экран, который начал эту последовательность.


Как это реализовать? Для начала вы объявляете вашу вложенную последовательность экранов в отдельном XML-файле навигации, чтобы можно было вкладывать её в другие графы:


Объявление графа вложенной навигации
<!-- company_flow__nav_graph.xml --><navigation    android:id="@+id/company_flow__nav_graph"    app:startDestination="@id/CompanyFragment">    <fragment        android:id="@+id/CompanyFragment"        android:name="ui.company.CompanyFragment">        <action            android:id="@+id/action__CompanyFragment__to__CompanyDetailsFragment"            app:destination="@id/CompanyDetailsFragment" />    </fragment>    <fragment        android:id="@+id/CompanyDetailsFragment"        android:name="ui.company.CompanyDetailsFragment"/></navigation>

Затем следует вложить созданный граф навигации в уже существующий граф и использовать идентификатор вложенного графа для описания action-ов:


Добавление графа навигации в другой граф
<navigation    android:id="@+id/menu__search"    app:startDestination="@id/SearchContainerFragment">    <fragment        android:id="@+id/SearchContainerFragment"        android:name="ui.tabs.search.SearchContainerFragment">        <action            android:id="@+id/action__SearchContainerFragment__to__CompanyFlow"            app:destination="@id/company_flow__nav_graph" />    </fragment>    <include app:graph="@navigation/company_flow__nav_graph" /></navigation>

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


Проблема в том, что Navigation Component не позволяет нормально описывать навигацию НАЗАД, только навигацию ВПЕРЁД. Но при этом даёт возможность описывать удаление экранов из back stack-а при помощи атрибутов popBackUp и popBackUpInclusive в XML, а также при помощи функции popBackStack в NavController-е.


Пока размышлял над этим, заметил интересную вещь: я подключился дебаггером перед переходом с экрана Splash на экран с нижней навигацией и обнаружил, что поле mBackStack внутри NavController-а Splash-экрана содержит два объекта NavBackStackEntry.


А можно на картинке?


Честно говоря, я не ожидал увидеть там два объекта, поскольку в back stack-е фрагментов точно был только один SplashFragment. Откуда взялась вторая сущность? Оказалось, что первый объект представляет собой NavGraph, который запустился в моей корневой Activity, а второй объект мой SplashFragment, который представлен классом FragmentNavigator.Destination.


И тут у меня появилась идея а что если вызвать на NavController-е функцию popBackStack и передать туда идентификатор графа? Коль скоро граф находится в back stack-е NavController-а, это должно удалить все экраны, которые были добавлены в рамках этого графа.


И эта идея сработала.


Возврат из flow при помощи popBackStack
class CompanyDetailsFragment : Fragment(R.layout.fragment_company_details) {    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {        super.onViewCreated(view, savedInstanceState)        finish_flow_button.setOnClickListener {            findNavController().popBackStack(R.id.company_flow__nav_graph, true)        }    }}

Минус такого подхода к определению обратной навигации очевиден: эта навигация не отобразится в визуальном редакторе. Конечно, можно определить action в XML-е вот таким образом:


Определение action-а для закрытия графа навигации
<fragment  android:id="@+id/CompanyDetailsFragment"  android:name="ui.company.CompanyDetailsFragment"  android:label="@string/fragment_company_details__title"  tools:layout="@layout/fragment_company_details">  <action      android:id="@+id/action__finishCompanyFlow"      app:popUpTo="@id/company_flow__nav_graph"      app:popUpToInclusive="true" /></fragment>

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


findNavController().navigate(R.id.action__finishCompanyFlow)

Но есть в этом что-то семантически неправильное: странно использовать слово navigate для закрытия экранов и обратной навигации.


Возврат результата из вложенного флоу


Что ж, мы получили некоторое подобие обратной навигации. Но возникает ещё один вопрос: есть ли способ вернуть из вложенного флоу какой-нибудь результат?


Да, есть. В Navigation Component 2.3 Google представил нам специальное key-value хранилище для проброса результатов с других экранов SavedStateHandle. К этому хранилищу можно получить доступ через свойства NavControllerpreviousBackStackEntry и currentBackStackEntry. Но в своих примерах Google почему-то считает, что ваш вложенный флоу всегда состоит только из одного экрана.


Типичный пример работы с SavedStateHandle
// Flow screenfindNavController().previousBackStackEntry    ?.savedStateHandle    ?.set("some_key", "value")// Screen that waits resultval result = findNavController().currentBackStackEntry    ?.savedStateHandle    ?.remove<String>("some_key")

Что делать, если вложенный флоу состоит из нескольких экранов? Вы не можете использовать previousBackStackEntry для доступа к SavedStateHandle, потому что в этом случае вы положите данные в один из экранов вашего вложенного флоу. Для решения этой проблемы можно воспользоваться следующим фиксом:


Посмотреть на фикс
fragment_company_details__button.setOnClickListener {    // Here we are inside nested navigation flow    findNavController().popBackStack(R.id.company_flow__nav_graph, true)    // At this line, "findNavController().currentBackStackEntry" means    // screen that STARTED current nested flow.    // So we can send the result!    findNavController().currentBackStackEntry      ?.savedStateHandle      ?.set(COMPANY_FLOW_RESULT_FLAG, true)}

Суть в следующем: до вызова findNavController().popBackStack вы находитесь ВНУТРИ вашего флоу экранов, а вот сразу после вызова popBackStack уже на экране, который НАЧАЛ ваш флоу! И это означает, что вы можете использовать для доступа к SavedStateHandle свойство currentBackStackEntry. Этот entry будет означать ваш стартовый экран, которому нужен результат из флоу.


В свою очередь, на на экране, который начал вложенный флоу, вы тоже используете currentBackStackEntry для доступа к SavedStateHandle. И, следовательно, читаете правильные данные:


Читаем данные из SavedStateHandle
// Read result from nested navigation flowval companyFlowResult = findNavController().currentBackStackEntry    ?.savedStateHandle    ?.remove<Boolean>(CompanyDetailsFragment.COMPANY_FLOW_RESULT_FLAG)text__company_flow_result.text = "${companyFlowResult}"

Выводы по работе с вложенным флоу


  • Для обратной навигации из вложенного флоу, состоящего из нескольких экранов, можно использовать функцию NavController.popBackStack, передав туда идентификатор графа навигации вашего флоу.
  • Для проброса какого-либо результата из вложенного флоу можно использовать SavedStateHandle.


Сейчас будет немного терминологии, чтобы синхронизировать наше понимание по поводу графов навигации.


Пусть у нас два графа навигации граф A и граф B. Я буду называть граф B вложенным в граф A, если мы вкладываем его через include. И, наоборот, я буду называть граф A внешним по отношению к графу B, если граф А включает в себя граф B.


Ещё немного картинок

Граф B вложенный в граф A:



Граф А внешний по отношению к графу B:



А теперь давайте разберём кейс навигации из вложенного графа во внешний граф.



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


Что? В смысле, это тот самый первый кейс, который ты уже разобрал? Разве вы не заметили, что у этой последовательности НЕТ нижней навигации?


Приблизить картинку

Смотрите, вот экран с нижней навигацией:



А вот последовательность экранов без неё:



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


Неправильный подход к такой навигации

Пусть мы вставили граф auth flow-навигации в наш граф вкладки нижней навигации и добавили action для перехода в него:


<navigation xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:id="@+id/menu__profile"    app:startDestination="@id/ProfileContainerFragment">    <fragment        android:id="@+id/ProfileContainerFragment"        android:name="ui.tabs.profile.ProfileContainerFragment">        <action            android:id="@+id/action__ProfileContainerFragment__to__AuthFlow"            app:destination="@id/auth__nav_graph" />    </fragment>    <include app:graph="@navigation/auth__nav_graph" /></navigation>

В этом случае первый экран auth-флоу появится в контейнере с нижней навигацией, а мы этого не хотели:



При этом не хочется переносить логику работы с нижней навигацией в Activity, писать какие-то методы по скрытию / демонстрации этого BottomNavigationView. Не зря же я адаптировал фикс из NavigationAdvancedSample для фрагмента, в конце концов.


В каком случае мы получим желаемый результат? Если каким-то образом осуществим навигацию из контейнера с BottomNavigationView (не из самой вкладки, а из контейнера, который является Host-ом для всех этих вкладок), то Auth-граф откроется без нижней навигации.


А на картинке можно?

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



Давайте введём action для навигации между MainFragment-ом и флоу авторизации:


Описание навигации
<! app_nav_graph.xml ><fragment  android:id="@+id/SplashFragment"  android:name="com.aaglobal.jnc_playground.ui.splash.SplashFragment"/><fragment  android:id="@+id/MainFragment"  android:name="com.aaglobal.jnc_playground.ui.main.MainFragment">  <action      android:id="@+id/action__MainFragment__to__AuthFlow"      app:destination="@id/auth__nav_graph" /></fragment><include app:graph="@navigation/auth__nav_graph" />

Но проблема в том, что если мы попытаемся использовать этот action прямо из вкладки нижней навигации, вот так:


fragment_profile_container__button__open_auth_flow.setOnClickListener {    findNavController().navigate(R.id.action__MainFragment__to__AuthFlow)}

то приложение упадёт с IllegalArgumentException, потому что NavController текущей вкладки ничего не знает о навигации вне своего Host-а навигации.


Ищем правильный NavController


Оказалось, что проблему можно решить, если найти правильный NavController, знающий про описанный вами action и привязанный к внешнему (с точки зрения фрагмента вкладки) для нас хосту. Если мы запустим action через него, навигация пройдёт ровно так, как нам нужно.


В Navigation Component есть специальная утилитная функция для поиска NavController-а, который привязан к нужному вам контейнеру, Navigation.findNavController:


Открываем флоу авторизации правильно
fragment_profile_container__button__open_auth_flow.setOnClickListener {  Navigation.findNavController(    requireActivity(),    R.id.activity_root__fragment__nav_host  ).navigate(R.id.action__MainFragment__to__AuthFlow)}


Проблемы с навигацией по кнопке Back


Итак, мы смогли открыть флоу авторизации поверх открытого фрагмента с нижней навигацией. Но появилась новая проблема: если пользователь нажмёт кнопку Back, находясь на первом экране графа авторизации, приложение упадёт. Снова с IllegalArgumentException на этот раз NavController не может найти контейнер, с которого мы только что пришли, как будто мы используем неправильный NavController для обратной навигации.


Покажи гифку


Исключение, которое мы получаем:


java.lang.IllegalArgumentException: No view found for id 0x7f08009a (com.aaglobal.jnc_playground:id/fragment_main__nav_host_container) for fragment NavHostFragment{5150965} (e58fc3a2-b046-4c80-9def-9ca40957502d) id=0x7f08009a bottomNavigation#0}

Эту проблему можно решить, переопределив поведение кнопки Back. В одной из новых версий AndroidX появился удобный OnBackPressedCallback. Раз мы используем неправильный NavController по умолчанию, значит, мы можем подменить его на правильный:


Переопределяем back-навигацию для первого экрана auth-графа
class StartAuthFragment : Fragment(R.layout.fragment_start_auth) {    private var callback: OnBackPressedCallback? = null    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {        super.onViewCreated(view, savedInstanceState)        callback = object : OnBackPressedCallback(true) {            override fun handleOnBackPressed() {                Navigation.findNavController(                    requireActivity(),                    R.id.activity_root__fragment__nav_host                ).popBackStack()            }        }.also {            requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, it)        }    }}

В нашем callback-е мы делаем ровно то же, что и в прошлый раз, когда пытались построить навигацию от вкладки нижней навигации в auth-граф: находим правильный NavController, который привёл нас на первый экран внешнего графа навигации, чтобы именно с его помощью вернуться назад.


И это работает! Но есть одно но: чтобы это продолжало работать на протяжении всего auth-флоу, нам надо добавить точно такой же OnBackPressedCallback в каждый экран этого флоу =(


И, конечно же, придётся поправить закрытие всего auth-флоу там мы тоже должны добавить получение правильного NavController-а:


Как это выглядит?
class FinishAuthFragment : Fragment(R.layout.fragment_finish_auth) {  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {      super.onViewCreated(view, savedInstanceState)      fragment_finish_auth__button.setOnClickListener {          Navigation.findNavController(              requireActivity(),              R.id.activity_root__fragment__nav_host          ).popBackStack(R.id.auth__nav_graph, true)          findNavController().currentBackStackEntry            ?.savedStateHandle            ?.set(AUTH_FLOW_RESULT_KEY, true)      }  }}

Подведём итоги


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


Допустим, на старте приложения мы показываем пользователю экран Splash-а. На нём мы выполняем действия, связанные с инициализацией приложения. Потом, если пользователь не авторизован, мы хотим перевести его во флоу авторизации, в противном случае сразу покажем экран с нижней навигацией. При этом, когда пользователь завершит флоу авторизации (неважно, как именно), мы должны показать ему главный экран с нижней навигацией.


Покажи на картинке


У этого кейса есть пара особенностей, которые отличают его от первого рассмотренного случая.


  • Когда пользователь нажмёт на кнопку Back на первом экране флоу авторизации, мы хотим не вернуться назад (потому что зачем нам второй раз показывать Splash), а закрыть приложение.
  • После завершения флоу авторизации мы не просто закрываем открытый нами граф, но и двигаемся вперёд.

С первым пунктом проблем быть не должно, мы можем просто пробросить флажок в флоу авторизации, который будет говорить в OnBackPressedCallback, когда надо закрывать приложение, а когда просто двигаться назад:


Покажи код

Определяем флажок для StartAuthFragment:


<fragment  android:id="@+id/StartAuthFragment"  android:name="com.aaglobal.jnc_playground.ui.auth.StartAuthFragment"  android:label="Start auth"  tools:layout="@layout/fragment_start_auth">  <argument      android:name="isFromSplashScreen"      android:defaultValue="false"      app:argType="boolean"      app:nullable="false" />  <action      android:id="@+id/action__StartAuthFragment__to__FinishAuthFragment"      app:destination="@id/FinishAuthFragment" /></fragment>

А теперь используем этот флажок в OnBackPressedCallback:


class StartAuthFragment : Fragment(R.layout.fragment_start_auth) {    private val args: StartAuthFragmentArgs by navArgs()    private var callback: OnBackPressedCallback? = null    private fun getOnBackPressedCallback(): OnBackPressedCallback {      return object : OnBackPressedCallback(true) {          override fun handleOnBackPressed() {              if (args.isFromSplashScreen) {                  requireActivity().finish()              } else {                  Navigation.findNavController(                    requireActivity(),                    R.id.activity_root__fragment__nav_host                  ).popBackStack()              }          }      }    }}

Поскольку у нас Single Activity, requireActivity().finish() будет достаточно, чтобы закрыть наше приложение.


Со вторым пунктом чуть интереснее. Я вижу два способа реализовать такую пост-навигацию.


  • Первый способ: Navigation Component позволяет в runtime-е менять граф навигации, мы могли бы где-нибудь сохранить @id будущего destination-а и добавить немного логики при завершении авторизации.
  • Второй способ закрывать флоу авторизации как и раньше, а логику движения вперёд дописать в экран, который стартовал экраны авторизации, то есть в Splash.

Первый способ мне не нравится тем, что если появятся дополнительные destination-ы, которые надо открывать после экранов авторизации, появится и много лишней логики внутри флоу авторизации. Да и модифицировать граф навигации в runtime-е то ещё удовольствие.


Второй способ тоже не ахти потребуется сохранить предыдущий экран в back stack-е, чтобы, вернувшись на него и прочитав результат после авторизации, мы могли двигаться дальше. Но это всё равно приемлемый вариант: вложенный флоу будет отвечать только за свою собственную логику, а экран, который начинает подобную условную навигацию (выбор между main и auth на Splash-е, например), и так знает, как двигаться вперёд.


И реализовать это просто мы знаем, как закрыть auth-флоу, знаем, как прокинуть из него результат на экран, который стартовал экраны авторизации. Останется только поймать результат на SplashFragment-е.


Покажи код

Пробрасываем результат из auth-флоу:


// FinishAuthFragment.ktfragment_finish_auth__button.setOnClickListener {    // Save hasAuthData flag in prefs    GlobalDI.getAuthRepository().putHasAuthDataFlag(true)    // Navigate back from auth flow    Navigation.findNavController(        requireActivity(),        R.id.activity_root__fragment__nav_host    ).popBackStack(R.id.auth__nav_graph, true)    // Send signal about finishing flow    findNavController().currentBackStackEntry      ?.savedStateHandle      ?.set(AUTH_FLOW_RESULT_KEY, true)}

И ловим его на стороне SplashFragment-а:


// SplashFragment.ktoverride fun onViewCreated(view: View, savedInstanceState: Bundle?) {    super.onViewCreated(view, savedInstanceState)    val authResult = findNavController().currentBackStackEntry        ?.savedStateHandle        ?.remove<Boolean>(FinishAuthFragment.AUTH_FLOW_RESULT_KEY) == true    if (authResult) {        navigateToMainScreen()        return    }}

Выводы по кейсам вложенной навигации


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

Через неделю, в заключительной статье о Navigation Component, я расскажу, как можно организовать навигацию в многомодульных приложениях и как работается с дип линками, а также покажу кейсы со встраиваемыми фрагментами и диалогами.

Подробнее..

Navigation Component-дзюцу, vol. 4 Переоценка

30.12.2020 10:17:29 | Автор: admin

Спустя два месяца после написания цикла статей Navigation Component-дзюцу я задумался: неужели всё действительно так плохо? Может быть я поддался волне критики гугловых разработок и просто пропустил тревожный звоночек, принявшись исправлять баг за багом, проблему за проблемой с помощью костылей и палок?

Оказалось, во многом так оно и есть: в этой статье-дополнении я хочу рассказать, в чём была проблема, как её исправить и как это поменяло моё мнение о Navigation Component.

Кейс с BottomNavigationView

Первая статья начиналась с примера использования BottomNavigationView в приложении с Navigation Component: я описывал тернистый путь от использования стандартного шаблона Android Studio с нижней навигацией до применения специальной extension-функции из репозитория Navigation Advanced Sample.

Напомни схему тестового приложения

Стандартный шаблон Android Studio с нижней навигацией, который использует Navigation Component, реализует нижнюю навигацию в полном соответствии с гайдлайнами Material Design то есть при переключении между вкладками стек экранов сбрасывается. Чтобы реализовать сохранение состояния вкладок можно воспользоваться специальной extension-функцией, которая под капотом создаёт для каждой вкладки нижней навигации отдельный NavHostFragment. К нему и будет привязан отдельный граф навигации со своим back stack-ом.

Оказалось, что при адаптации этой extension-функции для фрагментов я допустил серьёзную ошибку: использовал не тот FragmentManager. Так как мы строим навигацию внутри фрагмента, а не Activity, мне следовало использовать childFragmentManager, привязанный к фрагменту-контейнеру нижней навигации, а не supportFragmentManager, который был привязан к Activity.

Правильный вариант выглядит так:

Код настройки BottomNavigationView внутри фрагмента
/** * Main fragment -- container for bottom navigation */class MainFragment : Fragment(R.layout.fragment_main) {    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {        super.onViewCreated(view, savedInstanceState)        if (savedInstanceState == null) {            setupBottomNavigationBar()        }    }    override fun onViewStateRestored(savedInstanceState: Bundle?) {        super.onViewStateRestored(savedInstanceState)        // Now that BottomNavigationBar has restored its instance state        // and its selectedItemId, we can proceed with setting up the        // BottomNavigationBar with Navigation        setupBottomNavigationBar()    }    /**     * Called on first creation and when restoring state.     */    private fun setupBottomNavigationBar() {        val navGraphIds = listOf(            R.navigation.search__nav_graph,            R.navigation.favorites__nav_graph,            R.navigation.responses__nav_graph,            R.navigation.profile__nav_graph        )        // Setup the bottom navigation view with a list of navigation graphs        fragment_main__bottom_navigation.setupWithNavController(            navGraphIds = navGraphIds,            fragmentManager = childFragmentManager, // Самая важная строка            containerId = R.id.fragment_main__nav_host_container,            intent = requireActivity().intent        )    }}

Эта ошибка повлекла за собой описанные мной проблемы: краши в неожиданных местах, костыли для обратной навигации, странную привязку NavController-а.

Как так не заметили, что используете parentFragmentManager?

У меня есть несколько версий:

  • часто меняя код для описания большого примера, я мог не заметить разницы между поведением приложения при использовании supportFragmentManager-а и childFragmentManager-а;

  • мог подвести эмулятор;

  • а может быть, имела место банальная невнимательность при переносе кода с Advanced navigation sample с Activity на фрагменты; в исходном коде примера с Activity по понятным причинам использовался supportFragmentManager.

Как видите, нам больше не нужны никакие Handler.post для фиксов крашей IllegalStateException: FragmentManager already execute transaction. Кроме того, исчезает необходимость привязывать NavController, полученный из extension-а setupWithNavController, ко View нашего фрагмента. Плюс ко всему, у нас нет никаких крашей при сворачивании и разворачивании приложения ура.

С учётом этого фикса кейс с BottomNavigationView делается по щелчку. Из коробки вам может не хватить только одного: иногда приложению требуется запоминать порядок выбора табов нижней навигации и при нажатии на кнопку Back возвращаться не на первый таб, а на предыдущий выбранный.

Навигация из вложенного графа во внешний граф

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

Напомни схему

Речь идёт об этой части схемы:

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

Правильно инициализировав BottomNavigationView, мы избавились от необходимости руками привязывать NavController ко View контейнера с нижней навигацией (в коде это MainFragment). Это было неочевидно, но привело к проблемам, связанным с обратной навигацией во флоу авторизации, когда мы были вынуждены искать там правильный NavController:

В коде StartAuthFragment было вот так
callback = object : OnBackPressedCallback(true) {    override fun handleOnBackPressed() {            Navigation.findNavController(                requireActivity(),                R.id.activity_root__fragment__nav_host            ).popBackStack()    }}.also {            requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, it)}

Теперь мы можем спокойно избавиться от этих переопределений OnBackPressedCallback-ов. Всё стало гораздо проще.

Навигация по условию

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

Покажи на картинке

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

Первый способ заключался в пробрасывании флажка об открытии флоу авторизации со Splash-экрана и дальнейшей обработке этого флага в OnBackPressedCallback-е. Второй способ сводится к модификации текущего графа навигации: мы можем в runtime-е поменять startDestination графа на нужный нам первый фрагмент.

Ещё один вариант реализации навигации по условию
splashViewModel.splashNavCommand.observe(viewLifecycleOwner, Observer { splashNavCommand ->    val navController = Navigation.findNavController(requireActivity(), R.id.activity_root__fragment__nav_host)    val mainGraph = navController.navInflater.inflate(R.navigation.app_nav_graph)    // Way to change the first screen at runtime.    mainGraph.startDestination = when (splashNavCommand) {        SplashNavCommand.NAVIGATE_TO_MAIN -> R.id.MainFragment        SplashNavCommand.NAVIGATE_TO_AUTH -> R.id.auth__nav_graph        null -> throw IllegalArgumentException("Illegal splash navigation command")    }    navController.graph = mainGraph})

Мы по-прежнему выбираем начальный экран в SplashViewModel, но теперь в observer-е перестраиваем граф навигации и устанавливаем его в рутовый NavController, который получаем из Activity.

При таком способе навигации экран Splash-а больше не находится в back stack-е, и нажатие на кнопку Back на первом экране авторизации сразу закроет приложение без необходимости добавлять OnBackPressedCallback, завязанный на аргумент.

Что ещё нужно сделать: поправить способ перехода с последнего экрана флоу авторизации на главный экран. Раньше мы закрывали флоу авторизации с помощью findNavController().popBackStack и пробрасывали результат о пройденной авторизации через SavedStateHandle, чтобы заново открывшийся Splash-экран перевёл нас на главный экран. Теперь можно поступить проще:

Навигация с последнего экрана авторизации
// Navigate back from auth flowval result = findNavController().popBackStack(R.id.auth__nav_graph, true)if (result.not()) {    // we can't open new destination with this action    // --> we opened Auth flow from splash    // --> need to open main graph    findNavController().navigate(R.id.MainFragment)}

Метод popBackStack возвращает true, если стек был извлечён хотя бы один раз и пользователь был перемещён в какой-то другой destination, а false в противном случае. Если граф авторизации был первым открытым destination-ом после Splash-экрана (а так и будет, поскольку мы изменили startDestination), этот метод вернёт нам false.

Убрав из back stack-а все экраны авторизации, мы вернулись в рутовый граф, где в качестве start destination-а выбран именно граф авторизации. При этом, если открыть граф авторизации, например, с главного экрана, вызов popBackStack уже вернёт true, и мы не выполним ещё один переход на главный экран.

Работа с диплинками

С исправленной инициализацией BottomNavigationView при запуске команды на открытие диплинка через ADB больше не происходит никаких крашей это прекрасно. Но никуда не делась особенность со сбросом стека: приложение по-прежнему целиком перезапускается, и нужно придумывать свои собственные способы обработки диплинков.

И как же это повлияло на мнение о Navigation Component

Найденная ошибка, разумеется, резко улучшила моё первоначальное мнение о Navigation Component. Библиотека действительно позволяет решить множество кейсов навигации довольно простым способом.

  • Нижняя навигация через BottomNavigationView Navigation Component из коробки соответствует гайдам Material design-а (не сохраняется стек при переходе между вкладками), но если вам требуется поведение а-ля iOS (когда стек вкладок должен сохраняться), можно использовать extension-функцию, которая даст нужное поведение.

  • Навигация во вложенные графы и обратно всё работает корректно, навигацию обратно можно реализовать через NavController.popBackStack(R.id.nestednavgraph), никаких костылей.

  • Навигация из вложенного контейнера во внешний (например, из контейнера с нижней навигацией в контейнер без неё) реализуется через поиск правильного NavController-а и не вызывает никаких проблем.

  • Навигацию на старте приложения по условию можно реализовывать разными способами либо через аргументы, либо через модификацию стартового графа в runtime-е. Модификация графа может сильно упростить этот кейс;

  • Навигация между модулями из трёх предложенных способов надёжно работают два: описание графа в app-модуле + навигация через интерфейсы и описание графа в отдельном модуле, который подсоединяется ко всем остальным.

  • Кейс с пробросом результата из вложенного графа, как правильно заметили в комментариях к одной из статей, проще сделать через какую-нибудь реактивную шину или Result API и не использовать никакой SavedStateHandle.

Что может оттолкнуть вас в Navigation Component:

  • Навигация через deep link-и потому что есть особенность со сбросом back stack-а, а это поведение подойдёт не всем приложениям;

  • Зависимость от тулинга и (опционально) кодогенерация пока редактор графа навигации не выделили в отдельный плагин Android Studio, чтобы получить какие-то обновления редактора, нужно ожидать обновления Android Studio + опционально, с помощью gradle-плагинов вы можете сгенерировать много кода, а это может замедлить сборку;

  • Зависимость от платформы Navigation Component крепко завязан на Fragments и их жизненный цикл. Если фрагмент временно находится в уничтоженном состоянии, то буферизацию команд навигации придется реализовывать самостоятельно.

Спасибо @shipa_oблагодаря которому я нашел эту ошибку.

Полезные ссылки

Подробнее..

Как мы переходили на Java 15, или история одного бага в jvm длинной в 6 лет

12.02.2021 10:14:08 | Автор: admin

Мы готовились к выходу Java 15 ради некоторых её новых возможностей. В частности текстовых блоков. Да, они появились в Java 14 (о новых функциях в Java 14 можно посмотреть здесь), но только как превью-фича, а, начиная с Java 15, она стала доступна в виде окончательно готовой функции.

Мы в hh.ru привыкли внедрять и использовать самые современные технологии в разработке ПО. Пробовать что-то новое одна из ключевых задач команды Архитектура. Пока многие пишут на Java 8, мы уже близки к тому, чтобы отправить на свалку истории Java 11.

Как известно, Java стала выходить куда как чаще, поэтому работы по обновлению версий у нас прибавилось. С одной стороны, пришлось адаптироваться к новым реалиям, менять привычки, что не всегда комфортно. С другой можно заранее целиться в функции, которые появятся в новой версии языка и не ждать релиза 3 года.

Переезд с Java 14 на Java 15. Что-то пошло не так

Дождавшись выхода новый Java, мы приступили к переезду. Не мудрствуя лукаво, выбрали один из нагруженных сервисов, который уже крутился на Java 14. В теории никаких сложностей при переходе не должно было возникнуть, на практике так и получилось. Обновление Java 14 на Java 15 не тоже самое, что обновление Java 8 на Java 11.

hh и в продакшн сервис обновлён, работа выполнена. Что дальше? А дальше мониторинг работы. Для сбора метрик мы используем okmeter. С его помощью мы наблюдали за поведением обновленного сервиса. Никаких аномалий по сравнению с предыдущей версией Java не было, кроме одной нативная память. В частности, зона Code Cache выросла почти в 2 раза!

До конца 17 ноября Java 14, после Java 15До конца 17 ноября Java 14, после Java 15

Для сервиса с большим количеством инстансов каждый мегабайт памяти на счету, и, помимо её резкого роста, на графике просматривается возрастающий тренд. Кажется мы имеем дело с утечкой нативной памяти в Code Cache.

Что такое вообще этот ваш Code Cache?

Code Cache область нативной памяти, где хранится интерпретатор байткода Java, JIT-компиляторы C1 и C2, и оптимизированный ими код. Основным пользователем является JIT. Весь перекомпилированный им код будет сохранятся в Code Cache.

Начиная с Java 9 Code Cache поделен на три отдельных сегмента, в каждом из которых хранится свой тип оптимизированного кода (JEP 197). Но на графике выше видно только одну выделенную область, несмотря на то что там Java 14 и Java 15. Почему одну?

Дело в том, что мы тонко настраивали размеры памяти при переводе сервисов в Docker (о том, как это было, можно почитать тут) и умышленно установили флаг размера Code Cache (ReservedCodeCacheSize) равным 72МБ в этом сервисе.

Три сегмента можно получить двумя путями: оставить значение ReservedCodeCacheSize по умолчанию (256Мб) или использовать ключ SegmentedCodeCache. Вот как эти зоны выглядят на графике с другого нашего сервиса:

Поиск утечки нативной памяти в Code Cache

С чего начать расследование? Первое что приходит на ум использовать Native Memory Tracking, функцию виртуальной машины HotSpot, позволяющую отслеживать изменение нативной памяти по конкретным зонам. В нашем случае использовать Native Memory Tracking нет необходимости, так как благодаря собранным метрикам, мы уже выяснили, что проблема в Code Cache. Поэтому мы решаем сделать следующее запустить инстансы сервиса с Java 14 и Java 15 вместе. Так как у нас уже три дня сервис работает на "пятнашке", добавляем один инстанс на 14-ой.

Мы решаем продолжить поиск утечки с помощью утилит Java. Начнем с jcmd. Так как мы знаем, что "течет" у нас Code Cache, мы обращаемся к нему. Если сервис запущен в Docker, можно выполнить команду таким образом для каждого инстанса:

docker exec <container_id> jcmd 1 Compiler.CodeHeap_Analytics

Получаем два очень длинных и подробных отчета о состоянии Code Cache. Кропотливо сравнив их, мы обратили внимание на следующий интересный факт, связанный с очисткой Code Cache:

// Java 14Code cache sweeper statistics:Total sweep time: 9999 msTotal number of full sweeps: 17833Total number of flushed methods: 10681 (thereof 1017 C2 methods)Total size of flushed methods: 20180 kB// Java 15Code cache sweeper statistics:Total sweep time: 5592 msTotal number of full sweeps: 236 Total number of flushed methods: 11925 (thereof 1146 C2 methods)Total size of flushed methods: 44598 kB

Обратите внимание на количество циклов полной очистки Total number of full sweeps. Вспомним, что сервис на Java 15 работает 3 дня, а на Java 14 всего 20 минут. Но количество полных очисток Code Cache поразительно разнится почти 18 тысяч за 20 минут, против 236 за трое суток.

Как работает очистка Code Cache

Пришло время углубиться в детали. За очистку Code Cache отвечает отдельный поток jvm CodeCacheSweeperThread, который вызывается с определенной эвристикой. Поток реализован как бесконечный цикл while, внутри которого он блокируется, пока не истечет 24-часовой таймаут, либо не будет снята блокировка вызовом:

CodeSweeper_lock->notify();

После того, как блокировка снята, поток проверяет, истек ли таймаут и имеет ли хотя бы один из двух флагов, запускающих очистку Code Cache, значение true. Только при выполнении этих условий, поток вызовет очистку Code Cache методом sweep(). Давайте подробнее разберем флаги:

should_sweep. Этот флаг отвечает за две стратегии очистки Code Cache нормальную и агрессивную. О стратегиях поговорим дальше.

force_sweep. Этот флаг устанавливается в true при необходимости принудительно очистить Code Cache без выполнения условий нормальной и агрессивной стратегий очистки. Используется в тестовых классах jdk.

Нормальная очистка

  1. Во время вызова GC хранящиеся в Code Cache методы могут изменить свое состояние по следующему сценарию: alive -> notentrant -> zombie. Методы не-alive помечаются как "должны быть удалены из Code Cache при следующем запуске потока очистки".

  2. В конце своей работы GC передает ссылку на все не-alive объекты в метод report_state_change.

  3. Далее в специальную переменную bytes_changed инкрементируется суммарный размер объектов, помеченных как не-alive в этом проходе GC.

  4. Когда bytes_changed достигает порога, задаваемого в переменной sweep_threshold_bytes, флаг should_sweep помечается как true и блокировка потока очистки снимается.

  5. Запускается алгоритм очистки Code Cache, в начале которого значение bytes_changed сбрасывается. Сам он состоит из двух фаз: сканирование стека на наличие активных методов, удаление из Code Cache неактивных. На этом нормальная очистка завершена.

Начиная с Java 15 пороговым значением можно управлять с помощью флага jvm SweeperThreshold он принимает значение в процентах от общего количества памяти Code Cache, заданном флагом ReservedCodeCacheSize.

Агрессивная очистка

Этот тип очистки появился еще в Java 9, как один из способов борьбы с переполнением Code Cache. Выполняется в тот момент, когда свободного места в памяти Code Cache становится меньше заранее установленного процента. Этот процент можно установить самостоятельно, используя ключ StartAggressiveSweepingAt, по умолчанию он равен 10.

В отличие от нормальной очистки, где мы ждем наполнения буфера "мертвыми" методами, проверка на старт агрессивной очистки выполняется при каждой попытке аллокации памяти в Code Cache. Другими словами, когда JIT-компилятор хочет положить новые оптимизированные методы в Code Cache, запускается проверка на необходимость запуска очистки перед аллокацией. Проверка эта довольно простая, если свободного места меньше, чем указано в StartAggressiveSweepingAt, очистка запускается принудительно. Алгоритм очистки такой же, как и при нормальной стратегии. И только после выполнения очистки, JIT сможет положить новые методы в Code Cache.

Что у нас?

В нашем случае размер Code Cache был ограничен 72 МБ, а флаг StartAggressiveSweepingAt мы не задавали, значит по умолчанию он равен 10. Если взглянуть на статистику очистки Code Cache, может показаться, что на Java 14 работает именно агрессивная стратегия. Дополнительно убедиться в этом нам помог тот же график, но с увеличенным масштабом:

Java 14Java 14

Он имеет зубчатую структуру, которая говорит о том, что очистка происходит часто, и, вероятно, методы по кругу выгружаются из Code Cache, в следующей итерации JIT-компиляции вновь попадают обратно, после снова удаляются etc.

Но как это возможно? Почему работает агрессивная стратегия очистки? По умолчанию она должна запускаться в тот момент, когда свободного места в Code Cache менее 10%, в нашем случаем только при достижении 65 мегабайт, но мы видим, что она происходит и при 30-35 мегабайтах занятой памяти.

Для сравнения, график с запущенной Java 15 выглядит иначе:

Java 15Java 15

Зубчатая структура отсутствует, есть плавный рост, затем очистка и снова рост. Разгадка где-то рядом.

Утечка не утечка

Так как работой Code Cache управляет jvm, мы отправились искать ответы в исходниках openJDK, сравнивая версии Java 14 и Java 15. В процессе поисков мы обнаружили интересный баг. Там сказано, что агрессивная очистка Code Cache работает неправильно с того момента, как ее внедрили в Java 9. Вместо старта агрессивной очистки при 10% свободного места, она вызывалась при 90% свободного места, то есть почти всегда. Другими словами, оставляя опцию StartAggressiveSweepingAt = 10, на деле мы оставляли StartAggressiveSweepingAt = 90. Баг был исправлен 3 июля 2020 года. А все дело было в одной строчке:

Этот фикс вошел во все версии Java после 9-ки. Но почему тогда его нет в нашей Java 14? Оказывается, наш docker-образ Java 14 был собран 15 апреля 2020 года, и тогда становится понятно, почему фикс туда не вошел:

Так значит и утечки нативной памяти в Code Cache нет? Просто всё время очистка работала неправильно, впустую потребляя ресурсы cpu. Понаблюдав еще несколько дней за сервисом на Java 15, мы сделали вывод, что так и есть. Общий график нативной памяти вышел на плато и перестал показывать тренд к росту:

скачок на графике - это переход на java 15скачок на графике - это переход на java 15

Выводы

  1. Как можно чаще обновляйте свою Java. Это касается не только мажорных версий, но и патчевых. Там могут содержаться важные фиксы

  2. Разумное использование метрик помогает обнаружить потенциальные проблемы и аномалии

  3. Переходите на Java 15, оно того стоит. Вот тут список всех фич, которые появились в пятнашке

  4. Если вы используете Java 8, то у вас проблемы агрессивной очистки Code Cache нет, за отсутствием этого функционала как такового. Однако существует риск, что Code Cache может переполниться и JIT-компиляция будет принудительно отключена

Подробнее..

Портрет российского специалиста Data Science от MADE и hh.ru

16.07.2020 10:19:49 | Автор: admin
16 июля 2020 г. Академия больших данных MADE от Mail.ru Group и hh.ru составили портреты российских специалистов по анализу данных (Data Science) и машинному обучению (Machine Learning). Аналитики выяснили, где они живут и что умеют а также чего ждут от них работодатели и как меняется спрос на таких профессионалов.

Академия MADE и HeadHunter проводят исследование уже второй год подряд. На этот раз эксперты проанализировали 10 500 резюме и 8100 вакансий.





Спрос на специалистов Data Science постоянно растет


Специалисты по анализу данных одни из самых востребованных на рынке. В 2019 году вакансий в области анализа данных стало больше в 9,6 раза, а в области машинного обучения в 7,2 раза, чем в 2015 году. Если сравнивать с 2018 годом, количество вакансий специалистов по анализу данных увеличилось в 1,4 раза, по машинному обучению в 1,3 раза.



IT, финансы, B2B три главных сферы для специалистов по анализу данных


Активнее других специалистов по большим данным ищут IT-компании (на их долю приходится больше трети 38% открытых вакансий), компании из финансового сектора (29% вакансий), а также из сферы услуг для бизнеса (9% вакансий).



Такая же ситуация и в сфере машинного обучения. Но здесь перевес в пользу IT-компаний еще очевиднее они публикуют 55% вакансий на рынке. Каждую десятую вакансию размещают компании из финансового сектора (10% вакансий) и сферы услуг для бизнеса (9%).



С июля 2019 года по апрель 2020 года резюме специалистов по анализу данных и машинному обучению стало больше на 33%. Первые в среднем размещают 246 резюме в месяц, вторые 47.



Чего ждут работодатели


Самый популярный навык владение Python. Это требование встречается в 45% вакансий специалистов по анализу данных и в половине (51%) вакансий в области машинного обучения.
Также работодатели хотят, чтобы специалисты по анализу данных знали SQL (23%), владели интеллектуальным анализом данных (Data Mining) (19%), математической статистикой (11%) и умели работать с большими данными (10%).



Работодатели, которые ищут специалистов по машинному обучению, наряду со знанием Python ожидают, что кандидат будет владеть C++ (18%), SQL (15%), алгоритмами машинного обучения (13%) и Linux (11%).



Что умеют соискатели


В целом, предложение на рынке Data Science соответствует спросу. Среди самых распространенных навыков специалистов по анализу данных владение Python (77%), SQL (48%), анализом данных (45%), Git (28%) и Linux (21%). При этом владение Python, SQL и Git навыки, которые практически одинаково часто встречаются в резюме специалистов любого уровня. Опытных специалистов отличают развитые навыки анализа данных, в том числе интеллектуального (Data Analysis и Data Mining).

Подробнее..

Коротко рынок труда в разработке после Covid

18.08.2020 10:04:18 | Автор: admin
Что происходит на рынке труда спустя полгода с начала локдауна, какие отрасли восстанавливаются, сколько вакансий открыто для разработчиков? Под катом посмотрим на реакцию ИТ-сферы на ковидную весну, сравним июль прошлого года с нынешним по количеству вакансий и узнаем о зарплатах. Обзор максимально сжатый, в 10 минут уложимся.



Сперва расскажу про общий рынок, для фона. Когда все пошло вниз мы понимали, что скоро окажемся там же. И оказались. Ниже график, где 6-я неделя года принята за 0% (3.02.2020). Заканчивается на 16-ой неделе (04.05.2020).

Динамика спроса на рынке труда в первые месяцы самоизоляции




Число публикуемых вакансий в Москве упало более чем наполовину к середине мая. Для примера, в начале февраля за неделю публиковалось ~ 125К вакансий, на 19-й неделе мая было опубликовано всего 50К. График наглядно показывает, как рынок труда реагирует на майские праздники вместе с самоизоляцией.

Теперь про ИТ. Здесь посчитал ТОП-10 запросов от соискателей для поиска вакансий. Только тех соискателей, у которых есть резюме в профобласти Информационные технологии, интернет, телеком. В этой профобласти резюме размещают не только разработчики, но также проджекты, аналитики, контент-менеджеры, smm-специалисты (в резюме можно указывать до трех профобластей).Что еще важно знать про график:
  • доля каждого запроса подсчитана доля от общего количества запросов от ИТ-специалистов за месяц;
  • запросы по синтаксису не объединял. Для примера, руководитель и руководитель проекта здесь разные запросы. Первый попал в топ-10 в апреле 2019, второй на 20-м месте
  • считал не фактическое кол-во запросов, а кол-во уникальных залогиненных пользователей, которые эти запросы делали.

Апрель 2019 года мы прекрасно проводили время, о вирусах говорил только Гейтс, а я планировал поездки по миру на год вперед. Ребята из ИТ искали вакансии с Java, Python, что-то для программиста. От апреля к июню 2019 изменения в топе в рамках погрешности. Но в апреле 2020 бам! курьер на первом месте. А вместе с ним удаленная работа, работа на дому и работа с ежедневной оплатой вытеснили из топа даже php. К июню 2020 все довольно быстро вернулось в норму.

Популярные поисковые запросы от специалистов сферы ИТ




Для большего понимания общей картины скажу, что количество запросов к поиску в апреле 2020 упало на 44% YoY (year-over-year), а июль 2020 к тому же месяцу предыдущего года вырос 18%. V-образный отскок, скажете вы? И окажетесь правы! Ниже рынок в целом в 2019 году в сравнении с текущим. 6-я неделя снова за 0%.

Спрос на общем рынке, вся Россия




Чтобы вас не сильно пугало падение на первом графике, привожу сравнение по годам на нем видим, что в мае спрос немного падает по естественным причинам (праздники, отпуска). Итак, вроде все ок, и регионы с Петербургом уже вышли на уровень на 2019, но Москва по-прежнему отстает. Думаю, это вопрос времени, если все пойдет, как идет.

Спрос в ИТ, вся Россия




На следующем графике количество вакансий по языкам программирования.
Список языков под спойлером
1C
Assembler
Bash
C
C#
C++
Delphi
Erlang /Elixir
Golang
Groovy
Java
JavaScript
Kotlin
Lua
Matlab
Objective-C
OpenGL
Pascal
Perl
PHP
PL/SQL
Python
R
Ruby
Rust
Scala
SQL
Swift
TypeScript
Visual Basic
Visual Basic.NET
Angular
Ember
JQuery
React
Vue
Искал упоминания по всему тексту вакансий: в названиях, описаниях, ключевых навыках. Этот подход не срабатывает, когда встречаются описания вида: Мы перешли с php на Java и теперь ищем разработчика на Java. Вакансия с таким описанием попадет в статистику и по php, и по java. Но, к счастью, таких кейсов не слишком много, чтобы исказить общую картину.Смотрел по июлю 2019 и по июлю 2020, по всей России. Топ-20 языков по количеству вакансий.

Количество вакансий по языкам программирования




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

Изменение количества вакансий по отношению к предыдущему году




По приросту все стандартно Kotlin и Go прирастают каждый год. Matlab растет на фоне общего спроса на работу с данными, а все остальное, видимо, действительно приросло за счет отложенного спроса и наших маркетинговых активностей.
Отдельно по фреймворкам JS метода та же самая, количество вакансий с упоминанием. Июль к июлю по всей России.

Количество вакансий с упоминанием фреймворков JS




Vue и React приросли на 58% и 41% соответственно. Jquery упал в рамках погрешности. Суммарно можно сказать, что вакансий сейчас точно не меньше, чем летом прошлого года. Если вы думали сменить работу, то, возможно, сейчас оптимальное время для этого, т.к. что будет осенью мы не знаем.

Далее зарплаты по тем же вакансиям. Нужно иметь в виду, что за одно лишь знание SQL платить 98к будут не всегда, т.к. в выборку по SQL также попадали вакансии с описанием вида SQL на хорошем уровне; Python: Pandas, Numpy, Matplotlib,....

Предлагаемые зарплаты по языкам программирования




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

Количество резюме по языкам программирования (упоминания в любом поле)




Топ-20 по количеству. Упоминания 1С искал только со словами разработчик или синонимами. Go не вошел в первую двадцатку, если считать упоминания в любом поле резюме, но далее график с упоминаниями только в названиях.

Количество резюме по языкам программирования (упоминания в названии)




Здесь Golang в первой десятке.
И далее ожидаемые зарплаты. На графике добавляю медиану, т.к. само по себе неустойчивое среднее еще более неустойчиво на данных из резюме. Считал по резюме, собранным по упоминаниям в любом поле. По всей России.

Ожидаемые зарплаты по языкам программирования




Здесь также топ-20. JS и php не перевалили за сотню, python не дотянул до топа совсем чуть-чуть.

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

P.S. Будет круто, если вы посмотрите наш опрос для специалистов ИТ-сферы.

Следующей статьей опубликую результаты опроса, когда наберем 1000 ответов с Хабра)
Подробнее..

Исследование технобренда hh.ru

14.10.2020 08:21:27 | Автор: admin
Всем привет! Недавно мы провели исследование технобренда hh.ru и решили поделиться его результатами. У нас стояла задача выяснить, насколько популярен hh среди аудитории ИТ-специалистов как потенциальный работодатель. Но поскольку себя нужно с чем-то сравнивать в исследовании также спрашивали про ряд других компаний на рынке. Под катом получился некий helicopter view на рынок труда в ИТ.




Что мы сделали


Опросили 1665 ИТ-специалистов, чтобы узнать:

  • на что обращают внимание при выборе работодателя;
  • условия работы в каких компаниях известны;
  • в каких компаниях хотят работать.

Основные поинты исследования


Методология: онлайн-опрос (CAWI).

Самый простой и привычный для аудитории вариант сбора мнений. Вопросы, варианты ответов, снова вопросы и так примерно 2-4 минуты на весь опрос. Облегченную версию анкеты можно посмотреть здесь.

Площадки опроса: hh.ru и habr.com

hh.ru основной источник респондентов (1352 завершенных ответа). Опрос был доступен на странице откликов. Блок с опросом показывался только тем соискателям, чье резюме размещено в профобласти Информационные технологии, интернет, телеком.

habr.com ответов получили существенно меньше (304), но такая диверсификация источников нам необходима ввиду того, что респонденты с hh.ru, используя наш сервис, уже в какой-то степени лояльны к бренду и их ответы могут быть смещены в пользу выбора hh. Респондентов в опрос приглашали в этой статье.

Еще 9 респондентов пришли из fb, отдельно их выносить не будем.

Как формировался список компаний

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

Данные в динамике

Да, мы проводим это исследование с 2018 года. Но каждый год есть пара отличий:

  • в 2018 году использовали только hh, поэтому данных по habr за этот год в статье не будет.
  • каждый год мы обновляем список компаний, поэтому на некоторых графиках в динамике у компаний может быть 0 (ноль) это означает, что их в году исследования не было.

Все правила соблюдены

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

Зачем мы это сделали

Наверное, это главный вопрос.

Каждый год, каждый месяц коллеги в HR-департаменте hh проделывают существенную работу, чтобы рассказать аудитории потенциальных сотрудников о том, как классно у нас работать. Год от года ребята пробуют разные способы, подходы и бюджеты эффект от всех активностей нужно измерять для правильных выводов и дальнейшего планирования.

Более глобально чем лучше наш технобренд, выше узнаваемость и желание работать в компании, тем дешевле обходится наём, быстрее закрываются вакансии, сотрудники дольше работают в компании. Эта статья тоже в какой-то степени работает на технобренд, именно поэтому она здесь. А также потому, что исследование действительно кажется полезным.

Чего нет в этой статье

Помимо тех вопросов, которые вы найдете в анкете и в статье, мы также задавали предметные уточнения о себе и остальных компаниях. Кроме этого, смотрели на ответы респондентов в разрезе их должности, специальности, возраста и прочих параметров, которые узнавали в опросе или из резюме. Суммарно это больше 50 слайдов, поэтому здесь облегченная версия.

Также в статье мы воздержались от размышлений и выводов. Только описательные сентенции, чтобы оставаться объективными.

Портрет аудитории


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

Распределение респондентов по регионам




Графики в основной части статьи будут по всей России без деления на регионы.

Распределение по уровню позиции




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

По специализациям




Здесь нужно заметить, что респонденты, отметившие вариант Моя деятельность не связана с ИТ не отвечали на вопросы анкеты, а сразу переходили к завершению опроса, т.к. не являются целевой аудиторией. Сами специализации брали из опроса stack overflow 2018 года, добавив к ним project manager.

По языкам программирования




Вопрос по языкам программирования задавался только тем респондентам, которые отметили, что занимаются разработкой (backend / frontend / fullstack / mobile)

Портрет QA




Эти вопросы задавали только тем, кто занимается тестированием. Варианты ответов собирали отталкиваясь от рынка и нашей специфики.

Портрет системного администратора




И здесь, аналогично предыдущим графикам, вопрос задавался только системным администраторам. Такие вопросы задаем, чтобы точнее определить свою ЦА и смотреть только на неё.

Как устроились на последнее место работы




Для общего представления о различиях в подходах к поиску работы смотрим на лояльность к hh среди пользователей Хабра и заодно смотрим на рейт по использованию hh.

Идеальный работодатель


Что предлагает ваш идеальный работодатель:




Зарплата, атмосфера и команда профессионалов рецепт успешного бренда, это можно сразу забирать на свои карьерные страницы. Здесь любопытно, что вариант востребованность продукта / популярность компании не входит в первый десяток качеств идеального работодателя.
Также стоит заметить, что этот вопрос задавался всем респондентам ИТ-специальностей, поэтому пункт Обязательное ревью кода проигрывает из-за неприменимости к большей части респондентов.

Идеальный работодатель в разрезе hh и Хабра




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

Известность условий работы


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

Какие из перечисленных ниже компаний вам известны с точки зрения условий работы?




Наконец-то мы подобрались к самым интересным слайдам. Яндекс традиционно в топе, а вот Сбербанк (опрос проводили еще до ребрендинга, конечно же, это Сбер) обошел Mail.ru и Тинькофф со значительным отрывом, что, наверное, можно объяснить величиной штата.

Известность в разрезе hh и Хабра




Пришло время рассказать, что означает подпись Кроме ИТ-специализаций, не представленных в hh.ru из анализа в таких вопросах исключены специализации, которых нет в hh (как в компании-работодателе), а также на представителей которых намеренно не распространяется наша технобрендовая активность. К таким относятся:

  • Desktop or enterprise applications developer
  • Educator or academic researcher
  • Embedded applications or devices developer
  • Game or graphics developer

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

Известность в динамике Хабр




В 2019 году мы собрали гораздо больше ответов с Хабра, т.к. особенно не парились и просто сделали статью, где не было ничего, кроме ссылки на опрос. Это хорошо сказалось на количестве ответов, но плохо на нашей карме, поэтому в этот раз первым решили пожертвовать.

Известность в динамике hh




Aliexpress, Joom, Работа.ру, Revolut и ВТБ добавились только в 2020 году, эти объясняются их нулевые значения. В то же время нам пришлось исключить 2GIS, IBS и роботов, чтобы все поместились.

Желание работать в компаниях


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

В каких компаниях вы хотели бы работать?




Первые позиции практически не изменились, но в топ-4 появляется JetBrains. Об условиях работы у ребят знал только каждый десятый, а вот работать хочет почти каждый третий. Видимо, востребованность продукта компании-работодателя все-таки важный поинт, хотя вероятно не он один.

Желание работать в компаниях в разрезе hh и Хабра




Просто посмотрите сами, очень интересно. И отличия респондентов hh и Хабра, и само ранжирование.

Желание работать в динамике Хабр




Стало значительно меньше респондентов, не желающих работать ни в одной компании.

Желание в динамике hh




Это последний слайд нашего марафона. Надеюсь, получилось рассмотреть графики и цифры на мобилках.

Заключение


Если появятся вопросы по методологии или интерпретации велкам.
Подробнее..

Зачем тимлиду участвовать в подборе? Потому что ошибки найма упадут на него

09.06.2021 08:18:01 | Автор: admin

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

Про ошибку найма и ответственность

Что я понял на своем опыте: ошибка в найме это большой урон для компании и команды.

Только подумайте: целых две недели уходят на поиски рекрутер тратит свои часы, руководитель и команда отвлекаются от задач, смотрят резюме и ходят на собеседования, команда к нему привыкает, а через месяц оказывается, что вы друг другу не подходите. Или проходят недели и месяцы, а человек все не находится. Это плохо влияет на деньги (зарплата рекрутера, стоимость рекламы для привлечения и пр.), на HR-бренд работодателя, на настроение в команде и нагрузки: задачи, которые пока не выполняются, ложатся на других ребят. Вот тут пора понять, что наём это всегда командный проект.

Ключевой персонаж в этом проекте не рекрутер, а тимлид. Именно он изначально формирует задачу, на нем ответственность за окончательно принятое решение, даже если при этом голосует вся команда.

Для себя я это формулирую так: ответственный за подбор я, а рекрутер это мои руки в найме.

Когда рекрутер пинает тимлида и команду разработки это нормально

Воронка подбора это зеркало найма, то, как он проходит у вас в компании. Поэтому воронка бывает разной: у кого-то шесть-семь этапов, а у кого-то все двадцать. В разработке hh.ru воронка выглядит так:

Заявка на подбор скрининг резюме рекрутером согласование кандидатов техническими специалистами интервью с командой тестовое задание (опционально) повторное интервью оффер.

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

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

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

  • как идет процесс

  • кто за что отвечает

  • какие максимальные сроки у нас заложены под каждый этап


Решили, что если накапливается определенное количество нерассмотренных кандидатов, то рекрутер пишет в общий чат и не стесняется нас пинать. Если кандидат нереально хорош, то может отправить прямо ссылкой кандидат горящий, важно не потерять!.

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

А еще от зависаний нас спасает сервис из экосистемы hh.ru облачная CRM для рекрутмента Talantix. В ней можно прописать сроки под каждый этап воронки, и, если кандидат начинает подвисать, система сама напомнит. Это удобно не только разработчикам, рекрутер тоже доволен ведь у него куча задач, и иногда можно в суматохе забыть нас дернуть. А тут CRM сама подсказывает, освобождая от части рутины.

Кандидаты у нас долго не залеживаются, поэтому приложил скриншот из соседнего отдела:

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

Выбрасываем все лишнее

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

Что может сделать тимлид? Как минимум разумно подойти к тестовым и количеству интервью. Обязательное тестовое у нас только для QA-инженеров, в остальных случаях мы прощупываем уровень кандидата на собеседовании предлагаем кейсы, спрашиваем, как человек решил бы ту или иную задачу. И это здоровый подход, потому что репрезентативное тестовое займет несколько часов, а вы у кандидата не одни. Скорее всего, он выберет компанию, в которой тестовое не нужно. Если после собеседования остались сомнения по кандидату и при этом вы уверены в его мотивации, тестовое можно дать, только по максимуму его сократить.

Еще я не вижу смысла в трех и больше этапах интервью. Как показывает опыт, одной-двух встреч достаточно, чтобы принять решение. А еще помните: чем больше интервью, тем выше шансы растерять к финалу всех кандидатов.

У рекрутеров в ходу есть такой мем;)

Заявка на подбор и текст вакансии это не одно и то же

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

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

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

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

Также важно, чтобы правильно был прописан текст вакансии он должен быть простым, четким, информативным. Помните: это визитная карточка, то, на что клюет кандидат. Поэтому постарайтесь, чтобы текст был дружелюбным и показывал вас как работодателя с наилучшей стороны.

Слышал истории о том, что тексты вакансий составляются по схеме: нашли что-то похожее на hh.ru, немного подправили, дописали побольше технологий, опубликовали. При этом список требований у джуниора как для мидла со стажем. Не делайте так! Обязательно возле некритичных требований указывайте в скобках будет плюсом, иначе вы будете очень долго искать человека, и от этого будет нехорошо не только рекрутеру, но и самому отделу.

История из жизни Как мы искали мидл-разработчика

Как-то искали iOS мидла в команду. Откликов мало и находятся вообще не те люди. Сели за круглый стол, все обсудили и поняли: то, что мы готовы предложить, нерелевантно требуемому опыту. Поменяли всего одну цифру (указали 3 года), и процесс пошел. Мой совет: будьте внимательны к каждой строке вакансии одна цифра или фраза могут в несколько раз сузить воронку.

Также полезно анализировать воронку на продвинутом уровне. Я регулярно смотрю в Talantix, все ли у нас хорошо. Сразу видно, на каких этапах срезаются кандидаты. Например, выясняется, что входящих откликов много, а техническое интервью проходят единицы. Что тут может быть? Например, плохо прописали заявку с рекрутером, и он не тех ищет/не те откликаются. Тогда нужно вместе пересмотреть требования и переписать текст вакансии.

Идеальные вопросы для интервью какие они?

Спойлер: идеальных вопросов нет. Не существует универсального списка, благодаря которому вы все узнаете о человеке и не промахнетесь. Я провожу групповые собеседования: рекрутер, несколько ребят из отдела разработки (чтобы поделились впечатлениями + смогли меня заменить, когда я буду, например, в отпуске) и я. Рекрутер оценивает софтскилы, а мы проводим техническое интервью разбираем кейсы или спрашиваем, а что будет, если сделать вот так. Все проходит в формате непринужденной беседы, что позитивно влияет на кандидата: ему спокойнее и он меньше волнуется.

Совет: идите в ногу со временем и не задавайте вопросы типа Кем вы видите себя через 5 лет или Собираетесь ли вы в декрет. Вряд ли вам ответят правду, зато такие вопросы негативно влияют на ваш HR-бренд. Спросите лучше о мотивации или о том, в каком направлении кандидат хочет развиваться. Это будет корректней и информативней для вас.

История из жизни Как мы проворонили хорошего кандидата

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

Лайфхаки

После интервью мы обычно созваниваемся с командой, делимся впечатлениями и вместе принимаем решение.

Вот несколько наших секретов:

  • У нас есть общий чат, где прямо во время интервью можно написать Мне кандидат нравится, давайте еще вот это спросим.

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

  • Кандидату по результатам отвечаем не сразу, а на следующий день, чтобы можно было поспать со своим выбором.

  • Попробуйте провести ретроспективу по процессу найма.

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

Вместо заключения

Этой статьей я хотел продемонстрировать, что подбор это не дело рекрутера, а командный проект. Если руководитель и ребята из команды разработки не просто отсиживают рабочие часы, а любят свое дело и болеют за компанию, то они понимают, что наём новичков это важно, и в нем просто необходимо принимать активное участие.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru