Хабрахабр

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть