Передача данных через анимированные QR на Gomobile и GopherJS
В данной статье я хочу рассказать о небольшом и забавном проекте выходного дня по передаче файлов через анимированные QR коды. Проект написан на Go, с использованием Gomobile и Gopherjs — последний для веб-приложения для автоматического замера скорости передачи данных. Если вам интересна идея передачи данных через визуальные коды, разработка веб-приложений не на JS или настоящая кроссплатформенность Go — велкам под кат.
Идея проекта родилась из конкретной задачи для мобильного приложения — как наиболее просто и быстро передать небольшую порцию данных (~15КБ) в другое устройство, в условиях блокировок сети. Первой мыслью было использовать Bluetooth, но это не так удобно, как кажется — относительно долгий и не всегда работающий процесс обнаружения и спаривания устройств слишком затрудняет задачу. Неплохая идея была бы использовать NFC (Near Field Communication), но до сих пор слишком много устройств, в которых поддержка NFC ограничена или отсутствует вообще. Нужно было что-то проще и доступнее.
Как насчёт QR кодов?
QR (Quick Response) код — это самый популярный в мире вид визуальных кодов. Он позволяет кодировать до 3КБ произвольных данных и имеет различные уровни коррекции ошибок, позволяя уверенно читать даже на треть закрытый или загрязнённый код.
Но с QR кодами две проблемы:
- 3КБ недостаточно
- чем больше данных закодировано, тем выше требования к качеству картинки для сканирования
Вот так выглядит QR код 40-й версии (самая высокая плотность записи) с 1276 байтами:
Для моей задачи нужно было научиться передавать ~15KB данных, на стандартных устройствах (смартфонах/планшетах), поэтому сам собой возник вопрос –, а почему бы не анимировать последовательность QR кодов и передать данные кусками?
Быстрый поиск по уже готовым реализациям навёл на несколько таких проектов — в основном проекты на хакатонах (хотя встретилась и дипломная работа) –, но все были написаны на Java, Python или JavaScript, что, к сожалению, делало код практически непортируемым и неиспользуемым. Но учитывая большую популярность QR кодов и низкую техническую сложность идеи, было решено написать с нуля на Go — кросс-платформенном, читабельном и быстром языке. Обычно под кросс-платформенностью подразумевают возможность собрать бинарный код под Windows, Mac и Linux, но в моём случае тут была важна ещё и сборка под веб (gopherjs) и под мобильные системы (iOS/Android). Go даёт всё это из коробки с минимальными затратами.
Я рассматривал также альтернативные варианты визуальных кодов — такие как HCCB или JAB Code, но для них бы пришлось писать OpenCV-сканер, имплементировать с нуля кодер/декодер и это было чересчур для проекта на одни выходные. Круговые QR коды (shotcodes), и их аналоги, используемые в Facebook, Kik и Snapchat позволяют закодировать намного меньше информации, а невероятно крутой патентованный подход Apple для спаривания Apple Watch и iPhone — анимированное облако разноцветных частиц — также оптимизирован под wow-эффект, а не под максимальную пропускную способность. QR коды же интегрированы в нативные SDK камер мобильных OS, что сильно облегчает работу с ними.
Так родился проект txqr (от Tx — transfer, и QR), реализующий библиотеку для кодирования/декодирования QR на чистом Go и протокол для передачи данных.
Основная идея в следующем — один клиент выбирает файл или данные для отправки, программа на устройстве разбивает файл на куски, кодирует каждый из них в QR фреймы и показывает их в бесконечном цикле с заданной частотой кадров пока получатель не получит все данные. Протокол сделан таким образом, что получатель может начать с любого кадра, получать QR фреймы в любом порядке — таким образом обходится проблема необходимости синхронизации частоты анимации и частоты сканирования. Получатель может быть старым устройством, мощность которого позволяет декодировать 2 кадра в секунду, а отправитель — новым смартфоном выдающим 120Гц анимацию, и это не будет фундаментальной проблемой для протокола.
Это достигается следующим образом — когда файл разбивается на куски (фреймы далее), к каждому фрейму в начало добавляется префикс с информацией о смещении относительно всех данных и общая длина — OFFSET/TOTAL|
(где OFFSET и TOTAL — целочисленные значения смещения и длины соответственно). Бинарные данные пока что кодируются в Base64, но это на самом деле не обязательно — QR спецификация позволяет не только кодировать данные как бинарные, но и оптимизировать различные части данных под разные кодировки (например, префикс с небольшими изменениями можно закодировать как alphanumeric, а остальное содержимое — как binary), но для простоты Base64 отлично выполнял свою функцию.
Более того, размер фреймов и частоту можно даже менять динамически, подстраиваясь под возможности получателя.
Сам протокол очень прост, и главный его минус в том, что для больших файлов (хотя, это и выходит за рамки задачи, но всё же), один пропущенный при сканировании кадр увеличит время сканирования вдвое — получателю придётся ждать полного цикла снова. В теории кодирования есть решения для таких случаев — фонтанные коды, но это я оставлю для каких-нибудь следующих свободных выходных.
Самым интересным моментом было написать мобильное приложение, которое может использовать этот протокол.
Gomobile
Если вы не слышали о gomobile, то это проект, который позволяет использовать Go библиотеки в iOS и Android проектах и делает это до не приличия простой процедурой.
Стандартный процесс таков:
- вы пишете обычный Go код
- запускаете
gomobile bind ...
- копируете получившиеся артифакт (ы) (
yourpackage.framework.
илиyourpackage.aar
) в ваш мобильный проект - импортируете
yourpackage
и работаете с ним, как с обычной библиотекой
Можете сами попробовать насколько это просто.
Поэтому я довольно быстро написал приложение на Swift, которое сканирует QR коды (благодаря вот этой замечательной статье) и декодирует их, склеивает и, когда весь файл получен, показывает в окошке предпросмотра.
Будучи новичком в Swift (хоть я и прочёл книгу по Swift 4), было немало моментов, когда я застревал на чём-то простом, пытаясь понять, как это правильно делать и, в итоге, наилучшим решением было реализовать этот функционал на Go и использовать через Gomobile. Не поймите меня не правильно, Swift во многом замечательный язык, но, как и большинство остальных языков программирования, он даёт слишком много способов сделать одно и то же, и уже имеет приличную историю обратно-несовместимых изменений. Например, мне нужно было делать простую вещь — замерять продолжительность события с миллисекундной точностью. Поиск в Google и StackOverflow приводил к массе различных, противоречивых и, зачастую, устаревших решений, ни одно из которых, в итоге не выглядело не красивым для меня, ни корректным для компилятора. После 40 минут потраченного времени, я просто сделал ещё один метод в Go пакете, который вызывал time.Since(start) / time.Millisecond
и использовал его результат из Swift напрямую.
Я также написал консольную утилиту txqr-ascii
для быстрого тестирования приложения. Она кодирует файл и анимирует QR коды в терминале. Всё вместе это работало на удивление хорошо — я мог отправить небольшую картинку за несколько секунд, но, как только я начал тестировать различные значения частоты кадров, количества байт в каждом QR фрейме и уровень коррекции ошибок в QR кодировщике, стало понятно, что терминальное решение не сильно справляется с высокой частотой (больше 10) анимации, и что тестировать и замерять результаты вручную это гиблое дело.
Чтобы найти оптимальную комбинацию частоты кадров, размера данных в QR фрейме и уровня коррекции ошибок среди разумных пределов этих значений, мне необходимо было прогнать более 1000 тестов, вручную меняя параметры, ожидая полного цикла с телефоном в руке и записывая результаты в табличку. Конечно, это должно быть автоматизировано!
Тут и появилась идея следующего приложения — txqr-tester
. Изначально я планировать использовать x/exp/shiny — экспериментальный UI фреймворк для нативных desktop-приложений на Go, но, похоже, он заброшен. Около года назад я его пробовал, и впечатление было неплохое — для низкоуровневых вещей он подходил идеально. Но сегодня master-ветка даже не скомпилировалась. Похоже, стимулов вкладываться в развитие desktop-фреймворков — сложной и громоздкой задачи, с почти нулевым нынче спросом — уже нет, все UI решения перешли давно в веб.
В веб-программирование, как известно, языки программирования только-только начали заходить, благодаря WebAssembly, но это совсем пока детские первые шаги. Конечно, есть ещё JavaScript и надстройки, но друзья не позволяют друзьям писать приложения на JavaScript, поэтому я решил использовать недавнее своё открытие — фреймворк Vecty, который позволяет писать фронтенды на чистом Go, которые автомагически конвертируются в JavaScript с помощью очень взрослого и удивительно хорошо работающего проекта GopherJS.
Я в жизни такого удовольствия не получал от разработки фронтенд интерфейсов.
Чуть позже я планирую написать ещё пару статей про свой опыт разработки фронтендов на Vecty, в том числе и WebGL приложений, но суть в том, что после нескольких проектов на React, Ангулярах и Ember, писать фронтенд на продуманном и простом языке программирования это глоток свежего воздуха! Я могу писать достаточно симпатичные фронтенды за короткое время и при этом не писать ни одной строки на JavaScript!
Для затравки, вот как вы начинаете новый проект на 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)
}
Приложение, как и любой UI компонент — это всего лишь тип: структура, которая включает тип vecty.Core
и должна реализовать интерфейс 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! Я тоже так сначала подумал, но, как только начал работать, осознал, насколько это удобно:
- Нет магии — каждый блок (Markup или HTML) это лишь переменная нужного типа, с чёткими лимитами куда что можно поставить, благодаря статической типизации.
- Нет открывающих/закрывающих тэгов, которые нужно либо не забывать менять при рефакторинге, либо использовать IDE, которая делает это за вас.
- Структура вдруг становится понятной — я никогда, например, не понимал, почему в React до 16-й версии нельзя было вернуть несколько тегов из компонента — это же «просто строка». Увидев, как это делается в Vecty, вдруг стало понятно, откуда корни росли у того ограничения в React. Всё равно не понятно, правда, почему после React 16 стало можно, но и не нужно.
В общем, как только вы попробуете такой подход работы с DOM, то его плюсы станут сильно очевидны. Минусы тоже есть, безусловно, но после минусов привычных методов, они незаметны.
Vecty называют React-подобным фреймворком, но это не совсем так. Для React есть нативная GopherJS библиотека — myitcv.io/react, но я не думаю, что это хорошая идея повторять архитектурные решения React для Go. Когда вы пишете фронтенд на Vecty, вдруг становится ясно, насколько всё на самом деле проще. Вдруг становится лишней вся эта скрытая магия и новые термины и концепции, которые каждый JavaScript фреймворк изобретает — они просто добавочная сложность, ничего более. Всё что нужно — это ясно и чётко описывать компоненты, их поведение, и связывать их между собой — типы, методы и функции, вот и всё.
Для CSS я использовал на удивление достойный фреймворк Bulma — у него очень понятное наименование и хорошая структура, и декларативный UI код с его помощью очень читабелен.
Настоящая магия, впрочем, начинается, когда компилируешь Go код в JavaScript. Это очень пугающе звучит, но, на самом деле, вы просто вызываете gopherjs build
и менее чем через секунду, у вас готов автосгенерированный JavaScript файл, готовый чтобы включать в вашу базовую HTML страницу (обычное приложение состоит только из пустого body-тега и включения этого JS-скрипта). Когда я впервые запускал эту команду, то ожидал видеть массу сообщений, предупреждений и ошибок, но нет — она отрабатывает фантастически быстро и молча, в консоль выводит только однострочники в случае ошибок компиляции, которые сгенерированы Go компилятором, поэтому очень понятны. Но ещё круче было видеть ошибки в консоле браузера, со стектрейсами, указывающими на .go файлы и правильную строку! Это очень круто.
Тестирование параметров QR анимации
За несколько часов у меня было готово веб-приложение, которое позволяло мне быстро менять параметры для тестирования:
- FPS — частоту кадров
- QR Frame Size — сколько байт должно быть в каждом фрейме
- QR Recovery Level — уровень корректировки ошибок QR
и запускать тест автоматически.
Мобильное приложение, конечно, тоже должно было быть автоматизировано — оно должно было понимать, когда начинается следующий раунд с новыми параметрами, понимать, когда приём занимает слишком много времени и обрывать раунд, отправлять результаты приложению и так далее.
Загвоздка была в том, что веб-приложение, будучи запущенным в песочнице браузера, не может создавать новые соединения, и, если я не ошибаюсь, единственная возможность настоящего peer-to-peer соединения с браузером есть только через WebRTC (NAT мне пробивать не нужно), но это было чересчур громоздко. Веб-приложение могло быть только клиентом.
Решение было простое — веб-сервис на Go, который отдавал веб- приложение (и запускал браузер на нужный URL), так же запускал WebSocket-прокси для двух клиентов. Как только к нему присоединяются два клиента — он прозрачно отправляет сообщения из одного соединения в другое, позволяя клиентам (веб-приложению и мобильному клиенту) общаться напрямую. Они, должны быть для этого, в одной WIFI-сети, конечно же.
Оставалась проблема того, как сказать мобильному устройству, куда, собственно, подключаться, и она была решена с помощью… QR кода!
Процесс тестирования выглядит так:
- мобильное приложение ищёт QR код со стартовым маркером и ссылке на WebSocket-прокси
- как только маркер считан, приложение подключается к данному WebSocket-прокси
- веб-приложение (будучи уже подключенным к прокси) понимает, что мобильное приложение готово и показывает QR код с маркером «готов к следующему раунду?»
- мобильное приложение распознает сигнал, обнуляет декодер, и отправляет через WebSocket сообщение «угу».
- веб-приложение, получив подтверждение, генерирует новую QR анимацию и крутит её, пока не получит результаты или таймаут.
- результаты складываются в табличку рядом, которую можно тут же скачать в виде CSV
В итоге, всё что мне оставалось — просто поставить телефон на штатив, запустить приложение и дальше две программы сами делали всю грязную работу, вежливо общаясь через QR-коды и WebSocket:)
В конце я скачивал 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-визуализации важнее тысячи картинок. Вот такая визуализация полученных результатов:
Наилучший полученный результат был 1.4 секунды, что примерно равно 9КБ/с! Этот результат был записан на частоте 11 кадров в секунду, размере фрейма 850 байт и среднем (medium) уровне коррекции ошибок. В большинстве случаев, правда, на такой скорости декодер камеры пропускал некоторые кадры, и приходилось ждать следующего повтора пропущенного фрейма, что сильно негативно сказывалось на результатах — вместо двух секунд легко могло получиться 15, или таймаут, который был выставлен в 30 секунд.
Вот графики зависимости результатов от меняемых переменных:
Время / размер фрейма
Как видно, при низких значениях количества байт в каждом фрейме, избыток кодирования слишком велик и общее время считывания, соответственно, тоже. Некий локальный минимум есть в 500–600 байт на фрейм, но значения рядом всё равно приводят к потерянным кадрам. Наилучший результат наблюдался на 900 байт, но 1000 и выше это почти гарантированная потеря кадров.
Время / FPS
Значение количества кадров в секунду, к моему удивлению, сильно большого эффекта не имело — маленькие значения слишком увеличивали время общей передачи, а большие повышали вероятность пропущенного кадра. Оптимальное значение, судя по этим тестам, находится в районе 6–7 кадров в секунду для тех устройств, на которых я тестировал.
Время / Уровень коррекции ошибок
Уровень коррекции ошибок показал чёткую взаимосвязь между временем передачи файла и уровнем избыточности, что неудивительно. Однозначный победитель тут это низкий (L) уровень коррекции — чем меньше избыточных данных, тем более читабелен для сканера QR код при том же размере данных. По факту, для этого эксперимента избыточность не нужна вообще, но стандарт такого варианта не предлагает.
Конечно же, для более объективных данных этот тест должен быть запущен сотни и тысячи раз, на разных устройствах и разных экранах, но для моего эксперимента выходного дня, это был более чем достаточный результат.
Этот забавный проект доказал, что односторонняя передача данных через анимированные коды, безусловно, возможна, и для ситуаций, где нужно передать небольшой объем при отсутствии любых видов сетей, вполне подходит. Хотя мой максимальный результат был около 9КБ/с, в большинстве случаев реальная скорость составляла 1–2КБ/с.
Я также получил настоящее удовольствие, используя Gomobile и GopherJS с Vecty в качестве уже обыденного инструмента для решения проблем. Это очень зрелые проекты, с отличной скоростью работы, и, в большинстве случаев дающие опыт «оно просто работает».
Напоследок, я по прежнему восхищаюсь, насколько продуктивным можно быть с Go, когда чётко знаешь, что хочешь реализовать — экстремально короткий цикл «изменить»-«собрать»-«проверить» позволяет экспериментировать много и часто, простой код и отсутствие классовой иерархии в структуре программ даёт возможность легко и безболезненно их рефакторить на ходу, а фантастическая кроссплатформенность, заложенная в язык с самого начала позволяет написать код один раз и использовать его на сервере, на веб-клиенте и в нативном мобильном приложении. При этом, несмотря на более, чем достаточную производительность из коробки, по прежнему есть масса пространства для оптимизации и ускорения.
Так что если вы никогда не пробовали Gomobile или GopherJS — я рекомендую вам попробовать при следующей возможности. Это заберёт час вашего времени, но, возможно, откроет вам целый новый пласт возможностей в веб или мобильной разработке. Смело пробуйте!