Приключения в отдельном потоке. Доклад Яндекса +28


Как работать с изображениями на клиенте, сохраняя плавность UI? Разработчик интерфейсов Павел Смирнов рассказал об этом на основе опыта разработки поиска по фотографиям на Маркете. Из доклада можно узнать, как правильно использовать Web Workers и OffscreenCanvas.



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

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

Давайте сначала еще раз представлюсь. Меня зовут Паша, я разработчик интерфейсов в команде Маркета.



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

У хороших интерфейсов есть разные характеристики. Во-первых, он удобен, во-вторых, красив, в-третьих, доступен. Но одна из характеристик, о которых я хочу поговорить сегодня, — скорость. И скорость часто проявляется в плавности его работы. Даже небольшие фризы могут сильно изменить опыт пользователя наших интерфейсов.



Перейдем к плану моего сегодняшнего разговора. Сначала мы поговорим о задаче, которую я делал: поиске картинки на Маркете. Далее расскажу, какие проблемы мне пришлось решить, чтобы реализовать эту функциональность. Тут мы немного вспомним, как работает ваш скрипт в браузере, и посмотрим на технологии, которые мне помогли. Небольшой спойлер: это Web Workers и OffscreenCanvas.

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



Например, «красный iPhone X купить в Самаре». И мы что-то найдем. Или можем воспользоваться деревом каталога. В этом каталоге у нас есть категории и подкатегории.

Но что если я хочу что-то найти на Маркете, не зная, как это называется, но либо у меня есть фотография этой штуки, либо я ее вижу у кого-то в гостях?



Расскажу реальный случай. Я как-то пошел с моими друзьями в кафе. Мы там заказали лимонад, знаете, в таком кувшине, и у этого кувшина была такая странная штука. У меня даже сохранилась фотка. Она предназначалась для того, чтобы, когда ты наливаешь лимонад в стакан, в него не попадал лед. Мы подумали — вещь-то крутая, но у нас разошлись мнения, как эта штука называется и, вообще, для чего она предназначалась. Поэтому мы ее нашли на Яндекс.Картинках.

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

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

Смотреть первое демо

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

Мы нашли какие-то товары и конкретно эту штуку. Эта штука называется стрейнером. Чтобы еще что-то поискать, я вчера у коллеги на столе сфотографировал одну книжку, давайте поищем ее. Вот такая книжка, возможно, кто-то ее читал. Называется «Совершенный код». Тоже как-то находит, и почему-то с ограничением 18+. Это, наверное, немножко странно.

Вернемся к нашему докладу. Какие проблемы мне встретились? Первая проблема — пользователь начинает загружать все что угодно, в том числе огромные картинки. Например, мой телефон делает фотки размером три-четыре мегабайта, это достаточно много. Такие фотки отправлять на бэкенд неэффективно. Это долго, их долго анализировать, поэтому нужно что-то с этим делать. Но тут все просто — будем эту фотку на клиенте обрезать, сжимать, как-то ресайзить.



Как мы это будем делать? У нас есть файл. И файл этот мы как-то прочитаем. Читать будем с помощью API FileReader. Я коротко расскажу, что это такое.



Это такой браузерный API, который нам позволяет читать загруженный файл и что-то с ним делать. Читать можно по-разному, мы сейчас на это посмотрим. Вот его возможности, и у нас есть какой-то объект, который нам вернулся из input по событию change. Попробуем его прочитать.



Код будет выглядеть так. Здесь пока ничего сложного нет. У нас есть объект Reader, созданный из конструктора FileReader, на который мы навешиваем разработчик события load. Далее мы этот файл прочитаем как DataURL. DataURL — строка, которая представляет собой содержимое файла, закодированное через Base64. Вроде мы прочитали, надо как-то его обрезать. Сначала давайте загрузим это все в картинку. У нас есть тег или элемент img, и мы прямо туда это загрузим.



Код будет выглядеть примерно так. Мы создаем элемент img, по событию load Reader в атрибут src загружаем нашу строку и все дальнейшее будем выполнять по окончании загрузки нашей строки в img.

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



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

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

Дальше мы нарисуем нашу картинку на этом Canvas.



Возьмем контекст 2d, нам нужно 2d-изображение, и попробуем нарисовать с помощью метода drawImage. DrawImage — интересный метод, который принимает, если я не ошибаюсь, девять параметров. Но они не все обязательны, мы воспользуемся только пятью. Мы возьмем Image и те два нуля, это offset или отступ картинки. Нам нужна левая верхняя точка. Нарисуем с нужными нам размерами.

Далее мы из этого Canvas точно так же возьмем нашу DataURL, кодированную Base64-строку, и превратим ее в blob — специальный объект, который нам удобно отправлять на сервер. Вроде бы все. Все работает. Картинка обрезается, картинка отправляется, картинка распознается.

Но тут я стал кое-что замечать. Когда я тестировал это решение, то при загрузке картинки, особенно на слабых устройствах, у меня чуть-чуть подтормаживал интерфейс. То кнопка не нажималась, то элемент не так скроллился. Возникало ли у вас ощущение, что ваш код работает в 99% случаев и работает хорошо, но иногда чуть-чуть не работает? И можно отдать на тестирование, и, наверное, никто не заметит. Да и пользователи, наверно, не заметят, тем более на слабых устройствах.

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

Сначала я разобрался, почему так происходит. Тут стоит чуть-чуть вспомнить, как работает JavaScript в браузере. Я не буду вдаваться в детали, это тема для большого доклада. Просто вспомним некоторые моменты.



У нас JavaScript работает в одном потоке, назовем его основным. И у нас есть такая штука в браузере, как event loop. Мы здесь сразу скажем, что это модель. В некоторых браузерах event loop организован иначе, но как видно из названия, в целом это цикл. Он обрабатывает некие задачи в очереди строго по порядку.

Неприятный момент: пока он одну задачу не обработает, к следующей не перейдет. Я покажу демку, которую я запилил, она это демонстрирует. Она классическая.

Смотреть второе демо

У меня есть GIF-изображение и CSS-анимация, сделанная по-разному: одна с помощью translatex, другая с помощью position: relative left, третья с помощью JavaScript, а именно requestAnimationFrame. Это где еж крутится. Что я буду делать?

Я заблокирую основной поток на пять секунд. Знаете, обычно крутые парни вычисляют энное число Фибоначчи, но я написал бесконечный цикл с брейком через пять секунд.

Что будет происходить? Вы сразу заметили, что еж перестал крутиться, а нижняя кошка, которая анимирована с помощью translatex, тоже перестала ездить. Но давайте посмотрим ту же самую демку в другом браузере, например Safari. Кошка на GIF перестала бегать.

К чему я это все показываю? Во-первых, браузеры разные, надо это учитывать. Во-вторых, когда наш поток чем-то блокируется, некоторые вещи перестанут работать. Например — JavaScript-анимация. Или давайте покажу, что у нас перестанет выделяться текст, перестанут нажиматься кнопки.

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

Смотреть третье демо

У меня тут достаточно мощный MacBook, и чтобы все выглядело более убедительно, мы замедлим процессор в шесть раз. Это позволяет делать DevTools. Загрузим нашу фотку. Нам опять поможет «Совершенный код». Как мы видим, происходит то же самое, что и при блокировании основного потока.

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



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

Перейдем к решению. Я сразу скажу, что я использовал и что делал, а потом мы все эти штуки разберем. Во-первых, я использовал Web Workers. Они позволяют нам вынести некоторые задачи в отдельный поток. И во-вторых, в контексте Web Workers нам недоступен DOM. Чтобы справиться с этой ситуацией, мы будем использовать другие инструменты. Нам не будет доступен Image, доступен классический Canvas, и поэтому мы используем Canvas и некоторые другие ухищрения.



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

У нас существует инструмент, который позволяет передавать что-то в Workers и что-то возвращать из Workers. Давайте посмотрим пример.



Так мы создаем наш Worker с помощью конструктора. Туда нужно передать путь до файла. Можем даже передать blob. И у нас есть обработчик события Message. В данном случае он просто будет выводить что-то на экран. Далее мы можем отправить какие-нибудь данные в наш Worker.



Что с поддержкой? Здесь все хорошо. Workers — инструмент достаточно известный, не новый, но многие мои знакомые думают, что они не везде поддерживаются. Это не так.



Теперь посмотрим на OffscreenCanvas. Как мы уже убедились, Canvas очень мощный инструмент, но, к сожалению, в контексте Web Workers он нам недоступен, поэтому будем использовать альтернативу. Это уже достаточно новая вещь, которая называется OffscreenCanvas. Она позволяет делать примерно те же самые вещи, что и Canvas, только уже вне экрана, то есть в контексте Web Workers. Мы, конечно, можем это делать и в контексте window, но сейчас не будем.



Что здесь с поддержкой? Как видите, здесь много красного. По-нормальному OffscreenCanvas поддерживается только в Chrome. Также есть вариант с Firefox, но там пока под флагом, и Canvas работает только с контекстом WebGL. Тут вы можете спросить — зачем я рассказываю про такую крутую вещь, как OffscreenCanvas, которая нигде не работает?



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

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



Вот схема того, что мы будем делать. У нас даже останутся файлы, которые мы будем читать через FileReader. Но в основном потоке мы его отправим в Web Workers, где он будет обрезаться, сжиматься и вернется обратно к нам, а мы уже отправим его на сервер.



Давайте посмотрим код нашего Worker. Первое — мы создаем экземпляр OffscreenCanvas с нужными нами шириной и высотой.

Далее, как я уже говорил, нам недоступен элемент Image в контексте Workers, поэтому здесь мы используем метод createImageBitmap, который сделает нам структуру данных, характеризующую нашу картинку.

Из интересного: мы видим здесь self. Кто не знаком с Web Workers, эта штука указывает на контекст выполнения. Нам здесь не важно, window или this, используем self. Этот метод асинхронный, я тут и для компактности, и для удобства использовал await, почему бы нет?

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

Из простого. Раньше мы брали DataURL и конвертили все в blob. Но здесь нам сразу доступен метод convertToBlob. Почему я не использовал его раньше? Потому что поддержка была хуже. Но раз уж мы здесь пошли во все тяжкие и используем OffscreenCanvas, что нам мешает использовать convertToBlob?



Этот blob мы вернем в основном поток, откуда отправим его на сервер. Или, как в демках, нарисуем его.

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

Давайте вернемся к нашей демке.

Смотреть четвертое демо

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

Но можно ли это решение улучшить?



Вот, кстати, профилировщик. Мы здесь не видим огромные Microtasks по пять секунд, которые видели раньше.

Улучшить — можно. С помощью Transferable objects. Здесь стоит опять вернуться назад. Когда мы передавали нашу DataURL или blob через механизм postMessage, мы эти данные копировали. Наверное, это не очень эффективно. Было бы круто этого избежать. Поэтому у нас есть механизм, который позволяет передавать данные в Web Workers как бы в посылке.

Почему я говорю «как бы»? Когда мы передаем эти данные в Workers, мы теряем над ними контроль в основном потоке — не можем никак с ними взаимодействовать. Здесь же есть второе ограничение. Мы не все типы данных можем передать в Web Workers. Со строкой такое сделать не получится, мы будем делать иначе.



Давайте посмотрим на код. Во-первых, мы немножко по-другому передаем данные. Вот наш postMessage. Видите, есть такой массив с loadEvent.target.result. Такой интерфейс позволяет нам передать наши данные как Transferable objects, потеряв над ними контроль.

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



Вернемся в наш ImageWorkers. Тут стало намного интереснее. Первое — мы берем наш буфер и делаем такую страшную вещь, как Uint8ClampedArray. Это типизированный массив. Как ясно из названия, данные в нем — это числа знака, то есть числа от нуля до 255, которые будут представлять пиксель нашего изображения.

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

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

Перейдем к выводам. Я вам сегодня рассказал об одной задаче, которую делал не так давно. Что я в ней подметил?



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

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

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

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

Несколько ссылок по теме:


Большое спасибо.




К сожалению, не доступен сервер mySQL