Фоторамка на Flutter своими руками
Привет! Меня зовут Игорь, я работаю Frontend-директором в компании Wrike. В этой статье хочу поделиться историей создания моего пет-проекта и рассказать, как я сделал электронную фоторамку и написал для нее софт на Flutter, столкнувшись по пути со всеми возможными сложностями.
Я начал работу над проектом 24 апреля 2019 года. Почему я так хорошо помню дату? Потому что в этот день вышел обзор на IPS-матрицу с драйвером. Там было заявлено 8.9 дюймов и разрешение 2560×1600. Я подумал: «Ничего себе! Девять дюймов и такая плотность пикселей. Можно встроить, куда захочется».
У меня в телефоне копится много снимков, к которым я потом очень редко возвращаюсь и забываю про них. Поэтому я решил, что было бы круто сделать фоторамку. Но я хотел, чтобы рамка получилась не такая, как у всех, а особенная. Я же программист. Тогда мне казалось, что все будет просто: прикрутил, приклеил, и готово. Но в итоге все оказалось не так радужно, как я задумывал.
Идея и первые трудности
Я набросал спецификацию и решил, что рамка должна получать данные из Google Фото и отключаться, когда никого нет рядом.
Недолго думая, заказал Raspberry Pi 3 и дисплей с драйвером. Скачал спецификацию и нашел рамку с нужной глубиной.
В рамку должен был поместится Raspberry Pi и дисплей
Составил план действий — тогда мне казалось, что осталось только написать код.
Мой план на тот момент выглядел так. Все круто: дисплей есть, Raspberry Pi работает, осталось написать код
Но не тут то было. Меня осенило: я купил не FullHD экран с разрешением 1920×1080, а WQXGA с чуть большим разрешением — 2560×1600. А Raspberry Pi —это же не полноценный компьютер. Если сравнить его с Core i7, то у последнего виртуальный коэффициент скорости — 5199.98 BogoMIPS, а у Raspberry Pi — всего 108.00. И тогда я понял, что он вообще, возможно, не способен показывать такие огромные картинки. К тому же по спецификации Raspberry Pi не поддерживает подобные дисплеи.
Но я быстро нашел решение этой проблемы — вьюер feh, который отлично работает: выводит 2К картинки и даже слайд-шоу. Но, конечно, никакой анимации и плохая рандомизация: одну картинку можно было увидеть 5 раз. Но главное — вывести картинки было можно. Это меня успокоило.
Выбор технологии и разочарование
Теперь мне оставалось только выбрать инструмент. Я выделил такие критерии отбора:
Есть на RaPi3
Легко делать визуальные эффекты
Можно делать UI
Быстро работает
Вот так выглядел мой шорт-лист:
C++
Phyton
Go
Java
Chrome + JS
Начал с С++. Вьюер feh лежит в опенсорсе. Почему бы просто не поменять его код? Я скачал, все было круто, но было одно НО: UI, эффекты и еще много всего нужно было делать вручную.
Все, с кем я обсуждал проблему, советовали взять Skia или OpenGL. Но я уже пытался писать трехмерные движки и знал, что совмещать OpenGL с Raspberry Pi — это плохая идея. Raspberry Pi официально поддерживает только OpenGL|ES, и то с большими оговорками.
Тогда я решил пойти дальше.
Рассмотрел Go и Python. Go быстро работает и может компилироваться в native. Но меня удивило, что там нет UI-библиотек. Зато есть биндинги к Flutter Desktop: нужно писать на Go, а UI выводить через Flutter. От языка меня тогда немного мутило: он очень специфичный, хотя действительно простой и быстрый.
В Phyton много библиотек, но нет native. Это сразу меня остановило от его выбора, потому что Raspberry Pi совсем не шустрый. С UI тоже много вопросов: используй биндинги к Skia, и, возможно, будет тебе счастье.
Java же есть везде, попробую. Я нашел много библиотек. UI тоже можно делать легко, он идет из коробки. Со своими спецэффектами, но работает быстро. Java не умеет компилироваться в native на Raspberry Pi, но для работы это и не нужно.
Тогда я сделал прототип jSlideShow. Загрузка картинок работала. Я нашел библиотеку, которая позволяла делать разные эффекты. Даже умудрился прикрепить FPS-метр и выводить текст, если случались ошибки.
Когда я в первый раз запустил слайд-шоу на Raspberry Pi, то увидел черный экран. Две минуты смотрел на него, а потом прочел надпись, что картинка поменялась. Время между сменами картинок должно было составлять 10 секунд.
Оказалось, что Java очень специфично работает с картинками. Чтобы вывести на экран, Java конвертирует их в формат ARGB. Это приводит к резкому скачку памяти и дополнительным расходам на создание временных буферов. Тогда я и решил, что Java тоже не подходит.
Последняя надежда: Chrome + JS. Библиотек много, можно выбрать любую и быстро запрототипировать. Но ось грузится долго, Chrome тоже грузится долго. Создается ощущение, что пытаешься не вывести фотографии на фоторамку, а отправить сообщение в космос. Еще одно ограничение заключалось в том, что Chrome заточен под полноценный OpenGL, а на малине нет OpenGL и аппаратного ускорения в Chrome.
У меня совсем опустились руки, и я не понимал, что делать дальше.
Тогда я решил попробовать Dart и Flutter. Оказалось, что Dart есть в репозиториях Raspberry Pi. Примерно в то же время я начал пробовать Flutter, поэтому мне было легко на нем разрабатывать. По архитектуре это фактически биндинг Skia, все отлично.
Единственная проблема заключалась в том, что Flutter не подходит для embedded, потому что для Raspberry Pi нет скомпилированных исходников. Я начал искать в интернете, и оказалось, что один разработчик в ноябре 2018 года уже делал подобные эксперименты и описал все круги ада, которые ему пришлось пройти.
Я решил, что тоже справлюсь с этой задачей, и зашел в документацию Flutter Engine. Но из-за бурного развития языка она очень быстро устаревает. К тому же Google для сборки использует clang, из-за чего GCC поддерживается криво. А Raspberry Pi — это закрытая система и toolchain только для GCC.
Тогда я попытался настроить все по инструкции. Потратил примерно 8 часов, но зря. Потом я попробовал обхитрить систему и весь Flutter Engine выгрузил в Raspberry Pi. Raspberry Pi — это 2 гигабайта памяти и бесконечный своп на SD-карточку. Он пытался 12 часов что-то загрузить, но в итоге упал.
Потом на просторах интернета я нашел информацию про разработчика из Америки, который уже сделал Docker-контейнер, умеющий все собирать на ноутбуке. И за 10 дней с помощью фиксов мне удалось настроить и запустить это всё на домашней Ubuntu. Мне пришлось законтрибьютить в проект: я добавил Fullscreen и параллельно поломал TouchScreen.
В это время Google активно пилил Linux Support на Flutter, потому что все очень быстро менялось. И я получил очень много «полезных» знаний по этой Ninja build system.
В итоге я запустил галерею.
Так выглядела Flutter Gallery в 2019 году. Все работало очень медленно: около пяти кадров в секунду. И это я даже не говорю о 2К-картинках
Когда я подключил свой дисплей, то выяснилось, что китайский драйвер очень капризный. По краям LCD-дисплея периодически появлялось мерцание. Еще одно ограничение: Raspberry Pi не поддерживает 2К-дисплей.
Я разочаровался и подумал, что проект уже не получится довести до конца. Плохое железо, плохой софт, плохая идея.
Выход Raspberry Pi 4 и новая надежда
В 4 квартале 2019-го года неожиданно вышел анонс Raspberry Pi 4 c поддержкой 4К-дисплеев и более мощным процессором. Тогда мои 2К точно должны были сработать. Я заказал его и через 2 недели получил новый Raspberry Pi с четырьмя гигабайтам памяти.
Тогда я все соединил.
К тому времени у меня уже были все необходимые датчики и кнопки. Мне удалось благополучно соединить все компоненты и запустить дисплей
Получилось даже побороть экран (о, как я тогда ошибался :)). Оказалось, что в апреле 2020 года появился Flutter-pi — проект, устраняющий все преграды, которые раньше мешали делать интерфейсы встраиваемых систем на Flutter. Его можно запустить через консоль, он быстро работает, поддерживает из коробки GPIO, Video, Touch и DRM (Direct Render Manager), позволяющий не инициализировать Х. Но единственный минус — Flutter-pi запускается только в режиме Debug.
Удалось запустить и даже вывести изображения. Это уже мое приложение, которое работало на Raspberry Pi
Я продумал архитектуру.
В моей идеальном мире архитектуру я представлял так: нативный слой, в котором используется GPIO; приложение на Dart, а внутри него Flutter isolate и Hardware isolate, который уже связывается с системой файлов; скрипты, отключающие экран
Борьба с оставшимися блокерами и слом идеальной архитектуры
А что насчет облаков? Оказалось, что Google предоставляет API — драфтовый пакет. И в нем есть буквально все, кроме Google Photo. На официальном сайте компания пишет, что поддерживает REST API, Java и PHP и не использует автогенерацию, потому что API меняется. Тогда из Google Photo только собирались сделать социальную сеть.
Я узнал, что сервисных аккаунтов нет. А это значит, что без консент страницы нельзя получить доступ к библиотеке. Еще есть лимиты на количество запросов с неподтвержденных клиентов и приложений.
Я подумал о том, что в Google же наверняка не пишут API вручную: они генерируются автоматически. А еще оказалось, что Photo API есть в пакете для Go. Да, я не очень люблю Go, но для написания прототипов он подходит идеально.
Я написал прототип, и все сработало: появилась возможность получать доступ к коллекции и качать оттуда фотографии. В Google Photo API есть JSON. Я поместил его в папку, из которой генерируется API. И получил обычный дартовый файл со всеми типизированными вещами, которые касаются Google Photo.
Следующим этапом нужно было подключить датчики. Я хотел, чтобы рамка выключалась самостоятельно и чтобы на рамке было три клавиши: меню, заморозить и выключить экран. Я сделал прототипы на Python, потому что это было достаточно просто: нужно вставить в текстовый файл 4 строчки кода, и все работает.
После этого я сделал маленькое приложение на Flutter, и оно тоже заработало. Это было очень легко, потому что Flutter-pi в примерах содержит работу с GPIO.
Я уже подумал, что никаких блокеров не осталось. Но выяснилась неожиданная вещь. Наверное, все знают о том, что во Flutter используется Native Channel, чтобы связываться с нативным API. Например, на Android Native Channel пишется для того, чтобы вызвать какую-нибудь штуку на стороне Android SDK. И разработчик Flutter-pi тоже использовал Native Channel, который обращается непосредственно в native-слой, и там уже идет работа с GPIO.
Во Flutter есть issue, и там есть целый тред со страданиями разработчиков из-за того, что Native Channel можно сделать только в UI isolate.
Так сломалась моя идеальная архитектура.
Из моего идеального мира пропал Hardware isolate, и вся нагрузка по работе с железом теперь идет только в UI-слое. А это очень плохо: когда UI-поток работает, экран пользователя не обновляется
Flutter считает, сколько времени занимает фрейм. Это усугубляет проблему: если фрейм занимает больше 16-ти миллисекунд, то Flutter начинает хитро пропускать кадры, а пользователь в это время получает отвратительный user experience.
Нужно было как-то решать эту проблему. Во Flutter-pi есть отдельный проект Flutter GPIO. Я нашел там открытый issue с призывом мигрировать на dart: ffi. Сказал себе: «Challenge accepted», и потратил 20 ночей, включая 4 выходных дня, столкнулся со всеми возможными проблемами, но все-таки сделал библиотеку. У нее есть преимущества: возможность удаленной отладки (на Raspberry Pi), запуск из командной строки, синхронная и очень быстрая работа.
Но есть и ограничения: FFI не поддерживает Inline arrays. Это значит, что если вы в C++ хотите создать такой массив, то в Dart придется написать вот это:
Я сделал функцию в JavaScript, которая принтом вывела в консоль этот код. Я его вставил и не парился. Еще FFI не поддерживает bool: его можно представлять как int, ноль переключать в единицу. Но это не такая большая проблема
После того, как я поправил FFI, мой внутренний перфекционист успокоился. Все стало так, как я хотел. И тут мне написал мой коллега, Андрей Смирнов, предложил помощь с эффектами для смены картинок и нашел отличную либу с кучей эффектов. Андрей мигрировал её, чтобы она работала автоматически.
А еще Андрей сделал крутой виджет с информацией о состоянии устройства
Проектирование корпуса фоторамки и сборка
На этом этапе у меня было все, что нужно: железо и софт.
Я купил маленький преобразователь до пяти вольт, потому что Raspberry Pi, драйверы, PIR и все остальное питалось от пяти вольт
Тогда я задумался о том, на чем проектировать корпус. Я хотел, чтобы спроектировать можно было быстро, а еще чтобы в программе была возможность параметризации. Я выбирал между Blender, Sketch и OpenSCAD, потому что у меня был небольшой опыт работы с ними.
Выбрал OpenSCAD — опенсорсный САПР для параметрического создания объектов.
Я полностью сгенерировал печатные платы. В OpenSCAD выходные артефакты пишутся в виде кода, который чем-то похож на JavaScript
Вот так выглядели бесконечные итерации:
Я ускорил весь процесс до 17 кадров
На гифке кажется, что я что-то перемещаю. Но на самом деле я вырезал кусочки в OpenSCAD, печатал их и накладывал свои платы. Потом проверял, совпадают ли дырки: если есть отличие хотя бы на миллиметр, то все ложится криво. То же самое нужно было сделать для Raspberry Pi и проверить, входят ли все отверстия.
Печать занимает достаточно много времени
Через 12 часов ожидания принтер заканчивает работу, и наступает самый ответственный момент — сборка фоторамки.
А после я соединил все с дисплеем.
Вот так выглядит рамка в работе:
Когда ты держишь в руках то, что только что собрал сам, наступает офигенное ощущение радости и удовлетворения.
Финальная доработка
Мой внутренний перфекционист опять стал капать мне на мозг и говорить, что анимацию можно сделать получше: она немного подергивалась.
Я стал искать причину и выяснил, что их три:
Виджеты на экране пересобираются много раз.
Raspberry Pi замедляет CPU/GPU.
Flutter debug mode.
Виджеты на экране пересобираются много раз. Я сделал на экране красную полоску, которая показывала таймер (я ведь тот еще дизайнер!). Эта полоска просто нереально нагружала процессор, потому что ему приходилось каждый раз перерисовывать виджеты. Я пофиксил это, и тогда анимация стала немного плавнее.
Raspberry Pi замедляет CPU/GPU. Оказалось, что когда нагрузки на процессор нет, Raspberry Pi по умолчанию начинает через несколько секунд снижать частоты. Фоторамка переключает картинки раз в 15 секунд, и за это время Raspberry Pi уходит в самые низкие частоты. Когда загрузка повышается, компьютер какое-то время ждет и только потом их поднимает. Это легко фиксится в конфиге /boot/config.txt.
Flutter debug mode. Осталось решить самую интересную проблему. Наверное, не секрет, что debag-режим — это медленный режим для любой программы. Там очень много того, что не нужно на нативном устройстве. Я знал, что Flutter AOT должен работать быстро, но придется пройти все круги ада, чтобы собрать его для Raspberry Pi.
Необходимые шаги выглядели так:
Казалось бы, что может пойти не так? :)
Чтобы собрать snapshot, необходимо сначала собрать Flutter Engine. Важно, чтобы его версия совпадала с версией локального Flutter. Они должны совпадать идеально, только тогда все получится. Из Flutter Engine можно получить frontend-сервер, в который помещается само приложение. После этого сервер выдает snapshot в виде бинарного файла. Потом этот snapshot нужно залить в snapshot-генератор и получить shared object.
Shared object — это, грубо говоря, динамическая библиотека. Она содержит функции, которые можно импортировать.
В данном случае это всего лишь 4 функции для того, чтобы инициализировать snapshot, изоляты и данные для них
В консоли есть команда, которая позволяет из shared object получать информацию. Нужно подключить shared object и при инициализации поместить его во Flutter Engine. Тогда получится не debug-режим, а нативное приложение.
Внезапно выяснилось неожиданное: библиотека не грузится по непонятной причине, и ни одна утилита в Linux не считает файл запускным: распознает ELF-файл, но не более того. dlOpen — это метод в Linux, C++ и в C, который подключает шареный объект и по названию экспортирует методы. Именно он и не работал.
Readelf — это же опенсорс, поэтому я пошел читать документацию по ELF-формату. Написал парсер ELF, получил ссылки на нужные области памяти и передал эту информацию во Flutter Engine.
И получил такой результат:
dlOpen создает области памяти, которые так помечены в операционной системе, что их можно запускать. А я сэмулировал dlOpen немного по-другому. Engine выдал ошибку
Тогда я, как обычно, пошел в интернет. Там мне предложили скинуть логи работы Idd, приложить подорожник и получше разобраться, потому что «вы что-то делаете не так». Но логи были абсолютно чистые: ни ошибок, ни предупреждений.
Я подумал, что дихотомия должна мне помочь. К тому моменту у меня был уже большой багаж знаний по SO и ELF. Я сравнил собранную библиотеку для Raspberry Pi libflutter-engine.so. с той библиотекой, которая не работала.
Оказалось, что есть небольшая разница:
Я заметил, что Linux по-разному оценивает запускаемый и не запускаемый файл. В рабочем варианте Linux переходил на определенную позицию в файле и считывал данные оттуда.
Оказалось, что по этому адресу хранится секция ARM, под которую собран бинарник.
Зачем это сделано? В замечательном мире ARM есть тысячи процессоров, у которых свои виды команд и не только. В Linux решили, что каждый запускной файл должен иметь «паспорт», который описывает, подходит ли он этой системе.
Я решил из работающей библиотеки «пересадить» секцию в неработающую.
Что мне это дало:
Мгновенный старт.
Снижение потребления памяти с 275 Mb до 126 Mb.
Повышение скорости.
Но тут мне пришлось вспомнить правило: «Работает — не трожь!». Я обновил систему на Raspberry Pi. Вместе с обновлением установилась новая прошивка, и после этого отвалился дисплей. И теперь он иногда включается, а иногда — нет. А еще он стал периодически моргать. Я пытался откатить обновление, но попытки пока что идут тяжело.
Итоги и планы на будущее
Проект занял у меня почти год, и я могу с уверенностью сказать, что получил огромное удовольствие, когда держал в руках готовое устройство. Я столкнулся со всеми возможными сложностями, писал на разных языках, но это было круто. А еще я понял, что Flutter — идеальное решение для встраиваемых систем: на нем можно сделать быстрый и отзывчивый интерфейс.
Я заказал новый драйвер от Toshiba для LCD, но он не помог решить проблему с экраном. Видимо, она кроется в самих дисплеях. Нужно подробнее разобраться, что именно перегревается и вызывает проблемы с подсветкой. Если у кого-то из вас есть желание и опыт в этом деле, напишите мне.
Еще я хочу сделать поддержку MQTT и веб-сервер для настройки с телефона.
Ссылки:
И напоследок полезный анонс для всех, кто интересуется разработкой на Flutter. 4–5 декабря мы организуем DartUP — ежегодную конференцию по Dart и Flutter на русском и английском языках. В этот раз в онлайне и с крутыми спикерами из Google, Wrike, Яндекс, EPAM и не только. Если вам интересно узнать о последних новостях и кейсах с использованием этих технологий, в том числе в продакшне, регистрируйтесь до 4 декабря (участие в конференции бесплатное). Увидимся там!