Хабрахабр

Передача данных через анимированные QR на Gomobile и GopherJS

Проект написан на Go, с использованием Gomobile и Gopherjs – последний для веб-приложения для автоматического замера скорости передачи данных. В данной статье я хочу рассказать о небольшом и забавном проекте выходного дня по передаче файлов через анимированные QR коды. Если вам интересна идея передачи данных через визуальные коды, разработка веб-приложений не на JS или настоящая кроссплатформенность Go — велкам под кат.

txqr demo

Первой мыслью было использовать Bluetooth, но это не так удобно, как кажется – относительно долгий и не всегда работающий процесс обнаружения и спаривания устройств слишком затрудняет задачу. Идея проекта родилась из конкретной задачи для мобильного приложения – как наиболее просто и быстро передать небольшую порцию данных (~15КБ) в другое устройство, в условиях блокировок сети. Нужно было что-то проще и доступнее. Неплохая идея была бы использовать NFC (Near Field Communication), но до сих пор слишком много устройств, в которых поддержка NFC ограничена или отсутствует вообще.

Как насчёт QR кодов?

Он позволяет кодировать до 3КБ произвольных данных и имеет различные уровни коррекции ошибок, позволяя уверенно читать даже на треть закрытый или загрязнённый код. QR (Quick Response) код – это самый популярный в мире вид визуальных кодов.

Но с QR кодами две проблемы:

  • 3КБ недостаточно
  • чем больше данных закодировано, тем выше требования к качеству картинки для сканирования

Вот так выглядит QR код 40-й версии (самая высокая плотность записи) с 1276 байтами:

qrv40

Для моей задачи нужно было научиться передавать ~15KB данных, на стандартных устройствах (смартфонах/планшетах), поэтому сам собой возник вопрос – а почему бы не анимировать последовательность QR кодов и передать данные кусками?

Но учитывая большую популярность QR кодов и низкую техническую сложность идеи, было решено написать с нуля на Go — кросс-платформенном, читабельном и быстром языке. Быстрый поиск по уже готовым реализациям навёл на несколько таких проектов – в основном проекты на хакатонах (хотя встретилась и дипломная работа) – но все были написаны на Java, Python или JavaScript, что, к сожалению, делало код практически непортируемым и неиспользуемым. Go даёт всё это из коробки с минимальными затратами. Обычно под кросс-платформенностью подразумевают возможность собрать бинарный код под Windows, Mac и Linux, но в моём случае тут была важна ещё и сборка под веб (gopherjs) и под мобильные системы (iOS/Android).

Круговые QR коды (shotcodes), и их аналоги, используемые в Facebook, Kik и Snapchat позволяют закодировать намного меньше информации, а невероятно крутой патентованный подход Apple для спаривания Apple Watch и iPhone — анимированное облако разноцветных частиц — также оптимизирован под wow-эффект, а не под максимальную пропускную способность. Я рассматривал также альтернативные варианты визуальных кодов – такие как HCCB или JAB Code, но для них бы пришлось писать OpenCV-сканер, имплементировать с нуля кодер/декодер и это было чересчур для проекта на одни выходные. QR коды же интегрированы в нативные SDK камер мобильных OS, что сильно облегчает работу с ними.

Так родился проект txqr (от Tx — transfer, и QR), реализующий библиотеку для кодирования/декодирования QR на чистом Go и протокол для передачи данных.

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

Бинарные данные пока что кодируются в Base64, но это на самом деле не обязательно – QR спецификация позволяет не только кодировать данные как бинарные, но и оптимизировать различные части данных под разные кодировки (например, префикс с небольшими изменениями можно закодировать как alphanumeric, а остальное содержимое – как binary), но для простоты Base64 отлично выполнял свою функцию. Это достигается следующим образом – когда файл разбивается на куски (фреймы далее), к каждому фрейму в начало добавляется префикс с информацией о смещении относительно всех данных и общая длина — OFFSET/TOTAL|(где OFFSET и TOTAL — целочисленные значения смещения и длины соответственно).

Более того, размер фреймов и частоту можно даже менять динамически, подстраиваясь под возможности получателя.

protocol

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

Самым интересным моментом было написать мобильное приложение, которое может использовать этот протокол.

Gomobile

Если вы не слышали о gomobile, то это проект, который позволяет использовать Go библиотеки в iOS и Android проектах и делает это до не приличия простой процедурой.

Стандартный процесс таков:

  • вы пишете обычный Go код
  • запускаете gomobile bind ...
  • копируете получившиеся артифакт(ы) (yourpackage.framework. или yourpackage.aar) в ваш мобильный проект
  • импортируете yourpackage и работаете с ним, как с обычной библиотекой

Можете сами попробовать насколько это просто.

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

Не поймите меня не правильно, Swift во многом замечательный язык, но, как и большинство остальных языков программирования, он даёт слишком много способов сделать одно и то же, и уже имеет приличную историю обратно-несовместимых изменений. Будучи новичком в Swift (хоть я и прочёл книгу по Swift 4), было немало моментов, когда я застревал на чём-то простом, пытаясь понять, как это правильно делать и, в итоге, наилучшим решением было реализовать этот функционал на Go и использовать через Gomobile. Поиск в Google и StackOverflow приводил к массе различных, противоречивых и, зачастую, устаревших решений, ни одно из которых, в итоге не выглядело не красивым для меня, ни корректным для компилятора. Например, мне нужно было делать простую вещь – замерять продолжительность события с миллисекундной точностью. Since(start) / time. После 40 минут потраченного времени, я просто сделал ещё один метод в Go пакете, который вызывал time. Millisecond и использовал его результат из Swift напрямую.

Она кодирует файл и анимирует QR коды в терминале. Я также написал консольную утилиту txqr-ascii для быстрого тестирования приложения. Всё вместе это работало на удивление хорошо – я мог отправить небольшую картинку за несколько секунд, но, как только я начал тестировать различные значения частоты кадров, количества байт в каждом QR фрейме и уровень коррекции ошибок в QR кодировщике, стало понятно, что терминальное решение не сильно справляется с высокой частотой (больше 10) анимации, и что тестировать и замерять результаты вручную это гиблое дело.

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

Изначально я планировать использовать x/exp/shiny — экспериментальный UI фреймворк для нативных desktop-приложений на Go, но, похоже, он заброшен. Тут и появилась идея следующего приложения — txqr-tester. Но сегодня master-ветка даже не скомпилировалась. Около года назад я его пробовал, и впечатление было неплохое – для низкоуровневых вещей он подходил идеально. Похоже, стимулов вкладываться в развитие desktop-фреймворков – сложной и громоздкой задачи, с почти нулевым нынче спросом – уже нет, все UI решения перешли давно в веб.

Конечно, есть ещё JavaScript и надстройки, но друзья не позволяют друзьям писать приложения на JavaScript, поэтому я решил использовать недавнее своё открытие – фреймворк Vecty, который позволяет писать фронтенды на чистом Go, которые автомагически конвертируются в JavaScript с помощью очень взрослого и удивительно хорошо работающего проекта GopherJS. В веб-программирование, как известно, языки программирования только-только начали заходить, благодаря WebAssembly, но это совсем пока детские первые шаги.

vecty

Я в жизни такого удовольствия не получал от разработки фронтенд интерфейсов.

Я могу писать достаточно симпатичные фронтенды за короткое время и при этом не писать ни одной строки на JavaScript! Чуть позже я планирую написать ещё пару статей про свой опыт разработки фронтендов на Vecty, в том числе и WebGL приложений, но суть в том, что после нескольких проектов на React, Ангулярах и Ember, писать фронтенд на продуманном и простом языке программирования это глоток свежего воздуха!

Для затравки, вот как вы начинаете новый проект на Vecty (никаких кодогенераторов "начального проекта", создающих тонны файлов и папок) — просто main.go:

ackage main import ( "github.com/gopherjs/vecty"
) func main() { app := NewApp() vecty.SetTitle("My App") vecty.AddStylesheet(/* ... add your css... */) vecty.RenderBody(app)
}

Core и должна реализовать интерфейс vecty. Приложение, как и любой UI компонент — это всего лишь тип: структура, которая включает тип vecty. И это всё! Component (состоящий из одного метода Render()). Вот упрощённый код главной странички: Дальше вы оперируете с типами, методами, фунциями, библиотеками для работы DOM и так далее – никакой скрытой магии и новых терминов и концепций.

/ App is a top-level app component.
type App struct { vecty.Core session *Session settings *Settings // any other stuff you need, // it's just a struct
} // Render implements the vecty.Component interface.
func (a *App) Render() vecty.ComponentOrHTML { return elem.Body( a.header(), elem.Div( vecty.Markup( vecty.Class("columns"), ), // Left half elem.Div( vecty.Markup( vecty.Class("column", "is-half"), ), elem.Div(a.QR()), // QR display zone ), // Right half elem.Div( vecty.Markup( vecty.Class("column", "is-half"), ), vecty.If(!a.session.Started(), elem.Div( a.settings, )), vecty.If(a.session.Started(), elem.Div( a.resultsTable, )), ), ), vecty.Markup( event.KeyDown(a.KeyListener), ), )
}

Я тоже так сначала подумал, но, как только начал работать, осознал, насколько это удобно: Вы, наверняка, сейчас смотрите на код и думаете – насколько же это голословная работа с DOM!

  1. Нет магии – каждый блок (Markup или HTML) это лишь переменная нужного типа, с чёткими лимитами куда что можно поставить, благодаря статической типизации.
  2. Нет открывающих/закрывающих тэгов, которые нужно либо не забывать менять при рефакторинге, либо использовать IDE, которая делает это за вас.
  3. Структура вдруг становится понятной – я никогда, например, не понимал, почему в React до 16-й версии нельзя было вернуть несколько тегов из компонента – это же "просто строка". Увидев, как это делается в Vecty, вдруг стало понятно, откуда корни росли у того ограничения в React. Всё равно не понятно, правда, почему после React 16 стало можно, но и не нужно.

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

Для React есть нативная GopherJS библиотека – myitcv.io/react, но я не думаю, что это хорошая идея повторять архитектурные решения React для Go. Vecty называют React-подобным фреймворком, но это не совсем так. Вдруг становится лишней вся эта скрытая магия и новые термины и концепции, которые каждый JavaScript фреймворк изобретает – они просто добавочная сложность, ничего более. Когда вы пишете фронтенд на Vecty, вдруг становится ясно, насколько всё на самом деле проще. Всё что нужно – это ясно и чётко описывать компоненты, их поведение, и связывать их между собой — типы, методы и функции, вот и всё.

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

Это очень пугающе звучит, но, на самом деле, вы просто вызываете gopherjs build и менее чем через секунду, у вас готов автосгенерированный JavaScript файл, готовый чтобы включать в вашу базовую HTML страницу (обычное приложение состоит только из пустого body-тега и включения этого JS-скрипта). Настоящая магия, впрочем, начинается, когда компилируешь Go код в JavaScript. Но ещё круче было видеть ошибки в консоле браузера, со стектрейсами, указывающими на .go файлы и правильную строку! Когда я впервые запускал эту команду, то ожидал видеть массу сообщений, предупреждений и ошибок, но нет – она отрабатывает фантастически быстро и молча, в консоль выводит только однострочники в случае ошибок компиляции, которые сгенерированы Go компилятором, поэтому очень понятны. Это очень круто.

Тестирование параметров QR анимации

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

  • FPS — частоту кадров
  • QR Frame Size — сколько байт должно быть в каждом фрейме
  • QR Recovery Level — уровень корректировки ошибок QR

и запускать тест автоматически.

app

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

Веб-приложение могло быть только клиентом. Загвоздка была в том, что веб-приложение, будучи запущенным в песочнице браузера, не может создавать новые соединения, и, если я не ошибаюсь, единственная возможность настоящего peer-to-peer соединения с браузером есть только через WebRTC (NAT мне пробивать не нужно), но это было чересчур громоздко.

Как только к нему присоединяются два клиента – он прозрачно отправляет сообщения из одного соединения в другое, позволяя клиентам (веб-приложению и мобильному клиенту) общаться напрямую. Решение было простое – веб-сервис на Go, который отдавал веб- приложение (и запускал браузер на нужный URL), так же запускал WebSocket-прокси для двух клиентов. Они, должны быть для этого, в одной WIFI-сети, конечно же.

Оставалась проблема того, как сказать мобильному устройству, куда, собственно, подключаться, и она была решена с помощью… QR кода!

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

  • мобильное приложение ищёт QR код со стартовым маркером и ссылке на WebSocket-прокси
  • как только маркер считан, приложение подключается к данному WebSocket-прокси
  • веб-приложение (будучи уже подключенным к прокси) понимает, что мобильное приложение готово и показывает QR код с маркером "готов к следующему раунду?"
  • мобильное приложение распознает сигнал, обнуляет декодер, и отправляет через WebSocket сообщение "угу".
  • веб-приложение, получив подтверждение, генерирует новую QR анимацию и крутит её, пока не получит результаты или таймаут.
  • результаты складываются в табличку рядом, которую можно тут же скачать в виде CSV

design

В итоге, всё что мне оставалось – просто поставить телефон на штатив, запустить приложение и дальше две программы сами делали всю грязную работу, вежливо общаясь через QR-коды и WebSocket 🙂

tester demo

В конце я скачивал CSV файл с результатами, загонял его в RStudio и в Plotly Online Chart Maker и анализировал результаты.

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

  • FPS — от 3 до 12
  • Размер QR фрейма — от 100 до 1000 байт (с шагом в 50)
  • Все 4 уровня коррекции ошибок QR (Low, Medium, High, Highest)
  • Размер передаваемого файла — 13КБ рандомно сгенерированных байт

Через несколько часов я скачал CSV и стал анализировать результаты.

Вот такая визуализация полученных результатов: Картинка важнее тысячи слов, но интерактивные 3D-визуализации важнее тысячи картинок.

qr_scan_results

4 секунды, что примерно равно 9КБ/с! Наилучший полученный результат был 1. В большинстве случаев, правда, на такой скорости декодер камеры пропускал некоторые кадры, и приходилось ждать следующего повтора пропущенного фрейма, что сильно негативно сказывалось на результатах – вместо двух секунд легко могло получиться 15, или таймаут, который был выставлен в 30 секунд. Этот результат был записан на частоте 11 кадров в секунду, размере фрейма 850 байт и среднем (medium) уровне коррекции ошибок.

Вот графики зависимости результатов от меняемых переменных:

Время / размер фрейма

Time vs Size

Некий локальный минимум есть в 500-600 байт на фрейм, но значения рядом всё равно приводят к потерянным кадрам. Как видно, при низких значениях количества байт в каждом фрейме, избыток кодирования слишком велик и общее время считывания, соответственно, тоже. Наилучший результат наблюдался на 900 байт, но 1000 и выше это почти гарантированная потеря кадров.

Время / FPS

Time vs FPS

Оптимальное значение, судя по этим тестам, находится в районе 6-7 кадров в секунду для тех устройств, на которых я тестировал. Значение количества кадров в секунду, к моему удивлению, сильно большого эффекта не имело – маленькие значения слишком увеличивали время общей передачи, а большие повышали вероятность пропущенного кадра.

Время / Уровень коррекции ошибок

Time vs Lvl

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

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

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

Это очень зрелые проекты, с отличной скоростью работы, и, в большинстве случаев дающие опыт "оно просто работает". Я также получил настоящее удовольствие, используя Gomobile и GopherJS с Vecty в качестве уже обыденного инструмента для решения проблем.

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

Это заберёт час вашего времени, но, возможно, откроет вам целый новый пласт возможностей в веб или мобильной разработке. Так что если вы никогда не пробовали Gomobile или GopherJS – я рекомендую вам попробовать при следующей возможности. Смело пробуйте!

Показать больше

Похожие публикации

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

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

Кнопка «Наверх»