Фоторамка на Flutter своими руками

Привет! Меня зовут Игорь, я работаю Frontend-директором в компании Wrike. В этой статье хочу поделиться историей создания моего пет-проекта и рассказать, как я сделал электронную фоторамку и написал для нее софт на Flutter, столкнувшись по пути со всеми возможными сложностями.

8284f61f0015260c603b444be036eae0

Я начал работу над проектом 24 апреля 2019 года. Почему я так хорошо помню дату? Потому что в этот день вышел обзор на IPS-матрицу с драйвером. Там было заявлено 8.9 дюймов и разрешение 2560×1600. Я подумал: «Ничего себе! Девять дюймов и такая плотность пикселей. Можно встроить, куда захочется».

У меня в телефоне копится много снимков, к которым я потом очень редко возвращаюсь и забываю про них. Поэтому я решил, что было бы круто сделать фоторамку. Но я хотел, чтобы рамка получилась не такая, как у всех, а особенная. Я же программист. Тогда мне казалось, что все будет просто: прикрутил, приклеил, и готово. Но в итоге все оказалось не так радужно, как я задумывал.

Идея и первые трудности

Я набросал спецификацию и решил, что рамка должна получать данные из Google Фото и отключаться, когда никого нет рядом.

Недолго думая, заказал Raspberry Pi 3 и дисплей с драйвером. Скачал спецификацию и нашел рамку с нужной глубиной.

В рамку должен был поместится Raspberry Pi и дисплейВ рамку должен был поместится Raspberry Pi и дисплей

Составил план действий — тогда мне казалось, что осталось только написать код.  

Мой план на тот момент выглядел так. Все круто: дисплей есть, 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К-картинкахТак выглядела 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Удалось запустить и даже вывести изображения. Это уже мое приложение, которое работало на Raspberry Pi

Я продумал архитектуру.

В моей идеальном мире архитектуру я представлял так: нативный слой, в котором используется GPIO; приложение на Dart, а внутри него Flutter isolate и Hardware isolate, который уже связывается с системой файлов; скрипты, отключающие экранВ моей идеальном мире архитектуру я представлял так: нативный слой, в котором используется 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-поток работает, экран пользователя не обновляетсяИз моего идеального мира пропал 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, ноль переключать в единицу. Но это не такая большая проблемаЯ сделал функцию в JavaScript, которая принтом вывела в консоль этот код. Я его вставил и не парился. Еще FFI не поддерживает bool: его можно представлять как int, ноль переключать в единицу. Но это не такая большая проблема

После того, как я поправил FFI, мой внутренний перфекционист успокоился. Все стало так, как я хотел. И тут мне написал мой коллега, Андрей Смирнов, предложил помощь с эффектами для смены картинок и нашел отличную либу с кучей эффектов. Андрей мигрировал её, чтобы она работала автоматически. 

А еще Андрей сделал крутой виджет с информацией о состоянии устройстваА еще Андрей сделал крутой виджет с информацией о состоянии устройства

Проектирование корпуса фоторамки и сборка

На этом этапе у меня было все, что нужно: железо и софт.

Я купил маленький преобразователь до пяти вольт, потому что Raspberry Pi, драйверы, PIR и все остальное питалось от пяти вольтЯ купил маленький преобразователь до пяти вольт, потому что Raspberry Pi, драйверы, PIR и все остальное питалось от пяти вольт

Тогда я задумался о том, на чем проектировать корпус. Я хотел, чтобы спроектировать можно было быстро, а еще чтобы в программе была возможность параметризации. Я выбирал между Blender, Sketch и OpenSCAD, потому что у меня был небольшой опыт работы с ними. 

Выбрал OpenSCAD — опенсорсный САПР для параметрического создания объектов. 

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

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

Я ускорил весь процесс до 17 кадровЯ ускорил весь процесс до 17 кадров

На гифке кажется, что я что-то перемещаю. Но на самом деле я вырезал кусочки в OpenSCAD, печатал их и накладывал свои платы. Потом проверял, совпадают ли дырки: если есть отличие хотя бы на миллиметр, то все ложится криво. То же самое нужно было сделать для Raspberry Pi и проверить, входят ли все отверстия.

88f2b8e09b855c2908db3984ffc0e83ddc3481a1c52e636dbd5e1cc12df30771Печать занимает достаточно много времениПечать занимает достаточно много времени

Через 12 часов ожидания принтер заканчивает работу, и наступает самый ответственный момент — сборка фоторамки.

47ffaf7abecfac18d1da067280d399c6

А после я соединил все с дисплеем.

b1a713d68a9ed942e82054ef9c05206b

Вот так выглядит рамка в работе:

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

9a0686d7ffa8feaae0b651aa9c235b1c.png

Финальная доработка

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

Я стал искать причину и выяснил, что их три:

  1. Виджеты на экране пересобираются много раз.

  2. Raspberry Pi замедляет CPU/GPU.

  3. 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, изоляты и данные для нихВ данном случае это всего лишь 4 функции для того, чтобы инициализировать snapshot, изоляты и данные для них

В консоли есть команда, которая позволяет из shared object получать информацию. Нужно подключить shared object и при инициализации поместить его во Flutter Engine. Тогда получится не debug-режим, а нативное приложение.

Внезапно выяснилось неожиданное: библиотека не грузится по непонятной причине, и ни одна утилита в Linux не считает файл запускным: распознает ELF-файл, но не более того. dlOpen — это метод в Linux, C++ и в C, который подключает шареный объект и по названию экспортирует методы. Именно он и не работал.  

Readelf — это же опенсорс, поэтому я пошел читать документацию по ELF-формату. Написал парсер ELF, получил ссылки на нужные области памяти и передал эту информацию во Flutter Engine.

И получил такой результат:

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

Тогда я, как обычно, пошел в интернет. Там мне предложили скинуть логи работы Idd, приложить подорожник и получше разобраться, потому что «вы что-то делаете не так». Но логи были абсолютно чистые: ни ошибок, ни предупреждений.

Я подумал, что дихотомия должна мне помочь. К тому моменту у меня был уже большой багаж знаний по SO и ELF. Я сравнил собранную библиотеку для Raspberry Pi libflutter-engine.so. с той библиотекой, которая не работала.

Оказалось, что есть небольшая разница:

0b4e0c5f52d1288fa12467e1a65c0319b9d1067739be9e2007302931c7e93ff0

Я заметил, что Linux по-разному оценивает запускаемый и не запускаемый файл. В рабочем варианте Linux переходил на определенную позицию в файле и считывал данные оттуда.

98abacd1e053b3f93f9b2f540eb019dc

Оказалось, что по этому адресу хранится секция ARM, под которую собран бинарник. 

86a278ec343cbf7f17b27a19237d887c

Зачем это сделано? В замечательном мире ARM есть тысячи процессоров, у которых свои виды команд и не только. В Linux решили, что каждый запускной файл должен иметь «паспорт», который описывает, подходит ли он этой системе.

1f2ce083f44613b75e2d908c2f24cb64

Я решил из работающей библиотеки «пересадить» секцию в неработающую.

cc7b317d0ce7d05c8e45cfc1202b4cab.png

Что мне это дало:

  • Мгновенный старт.

  • Снижение потребления памяти с 275 Mb до 126 Mb.

  • Повышение скорости.

Но тут мне пришлось вспомнить правило: «Работает — не трожь!». Я обновил систему на Raspberry Pi. Вместе с обновлением установилась новая прошивка, и после этого отвалился дисплей. И теперь он иногда включается, а иногда — нет. А еще он стал периодически моргать. Я пытался откатить обновление, но попытки пока что идут тяжело.

Итоги и планы на будущее

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

Я заказал новый драйвер от Toshiba для LCD, но он не помог решить проблему с экраном. Видимо, она кроется в самих дисплеях. Нужно подробнее разобраться, что именно перегревается и вызывает проблемы с подсветкой. Если у кого-то из вас есть желание и опыт в этом деле, напишите мне.

Еще я хочу сделать поддержку MQTT и веб-сервер для настройки с телефона.

Ссылки:

И напоследок полезный анонс для всех, кто интересуется разработкой на Flutter. 4–5 декабря мы организуем DartUP — ежегодную конференцию по Dart и Flutter на русском и английском языках. В этот раз в онлайне и с крутыми спикерами из Google, Wrike, Яндекс, EPAM и не только. Если вам интересно узнать о последних новостях и кейсах с использованием этих технологий, в том числе в продакшне, регистрируйтесь до 4 декабря (участие в конференции бесплатное). Увидимся там!

© Habrahabr.ru