31.10.2026 796 материалов

PunchSheet: как собрать полностью офлайновое приложение на Expo, Skia и pdf-lib

Разбор архитектуры PunchSheet — мобильного приложения для приёмки дефектов, которое работает без сети, не требует аккаунта и собирает PDF-отчёт прямо на телефоне. В центре внимания — инженерные решения с жёсткими ограничениями по памяти и размеру файла, а также цифры, которые их обосновывают.

PunchSheet: как собрать полностью офлайновое приложение на Expo, Skia и pdf-lib

«Работает офлайн» — это не обещание в описании приложения, а свойство сборки, которое можно проверить: у релизного APK физически нет разрешения на доступ в интернет, а всё тяжёлое происходит в момент добавления фото, а не в момент экспорта отчёта.

Что вообще такое snag list и зачем под это приложение

Snag list (в американской терминологии — punch list) — это обход объекта по завершении работ: специалист фотографирует каждый дефект, размечает снимок, отмечает расположение и отдаёт заказчику один пронумерованный отчёт. Так делают электрики для клиентов, прорабы при сдаче объекта, а в Великобритании — и покупатели новых домов, пока не истёк гарантийный срок застройщика.

Рынок приложений под эту задачу делится на два лагеря. Первый — командные платформы с аккаунтом, облачным проектом и месячной платой с пользователя. Второй — бесплатные утилиты, которые берут деньги за единственную действительно нужную вещь, то есть за экспорт в PDF. Muhammed Said Cakir, автор PunchSheet, решил пойти третьим путём: заплатить один раз, не заводить аккаунт и гарантированно работать в подвале без сигнала.

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

Офлайн, который нельзя сломать случайно

Легко написать в описании «работает без интернета» и ещё легче потерять это свойство через полгода, когда в проект добавят аналитику или SDK рекламы. Поэтому ограничение сделано механическим, на уровне сборки:

  • У релизной сборки для Android вообще нет разрешения INTERNET. Config plugin удаляет его только из source set release, чтобы debug-сборка могла подключаться к Metro — дев-серверу React Native, без которого отладка невозможна.
  • Скрипт сборки выгружает список разрешений APK и сверяет его с эталонным списком. Любое лишнее разрешение валит сборку.
  • Правило в репозитории запрещает fetch, XMLHttpRequest и WebSocket в исходниках приложения.

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

Данные живут в SQLite через expo-sqlite, фотографии — в собственных файлах приложения. Синхронизации нет, входа нет, и политика конфиденциальности получается короткой по определению: приложение ничего не отправляет наружу.

Практическое следствие, о котором обычно забывают: разработчик тоже не получает телеметрии. Никакой автоматической отчётности о падениях и никаких метрик использования — все цифры ниже получены локальным тестированием. Это честный обмен, но его стоит держать в голове при оценке статистики.

Разметка как данные, а не как изменённое фото

Каждый дефект может содержать несколько фотографий. Исходный файл приложение никогда не изменяет: стрелки, круги, прямоугольники, линии, свободное рисование и текст хранятся как векторные фигуры и отрисовываются через React Native Skia — движок 2D-графики, работающий на GPU и доступный из React Native.

В PDF исходник не попадает вообще. В момент добавления фото приложение сразу создаёт «копию для отчёта»: длинная сторона 1600 px, JPEG с качеством 0.8, разметка «запекается» в растр через тот же Skia. Отчёт строится уже из этих копий.

Решение выглядит растратой места, но оно продиктовано замером памяти, сделанным до написания первого экрана. При сборке отчёта на 100 фотографий из 12-мегапиксельных тестовых снимков, когда уменьшенные JPEG держались в памяти, пик потребления составил 1,1 ГБ. Потоковая обработка по одному изображению снизила пик до 764 МБ — и это всё равно катастрофа для среднего Android-смартфона, у которого под кучу Java (heap) отведено обычно 256–512 МБ.

Выход — перенести дорогую операцию декодирования в момент добавления фото, когда пользователь всё равно ждёт, и не повторять её при экспорте. Это классический инженерный размен: чуть больше места на диске и чуть более долгий шаг импорта в обмен на предсказуемый и быстрый экспорт. Для инструмента, которым пользуются на объекте в перчатках и на морозе, размен выглядит правильным.

Бюджет PDF: 15 000 000 байт

Отчёт, который не уходит по почте, на стройке бесполезен. Поэтому у PDF есть жёсткий бюджет размера: 15 000 000 байт. Это десятичное чтение «15 МБ» — то есть то, что имеют в виду лимиты вложений в популярных почтовых сервисах, а не бинарные 15 × 1024 × 1024 ≈ 15 728 640. Разница в 728 КБ, которые здесь играют против пользователя, если пересчитать неверно.

Сборка PDF идёт на самом телефоне на pdf-lib — чистой JS-библиотеке без нативных зависимостей. Каждое фото проходит «лестницу» кодирований: от 1600 px вниз до 1200 px и от качества 0.7 вниз до 0.5. Наивный подход — идти по лестнице шаг за шагом для каждого изображения — означает многократное перекодирование одних и тех же файлов.

Вместо этого приложение предсказывает стартовую ступень по коэффициентам размера, измеренным на реальных фотографиях, а бюджет 15 МБ распределяется на весь отчёт целиком, а не на каждое фото отдельно. Последнее важно: одна «тяжёлая» фотография не съедает квоту у остальных, но и общий предел остаётся нерушимым.

Цифры на наборе из 100 скачанных тестовых фото: сборка отчёта упала с 32 до 7 секунд, количество операций кодирования — с 596 до 89. На Nothing Phone (2) проект из 50 дефектов и 100 снятых камерой фотографий даёт 26-страничный отчёт размером 14,08 МБ примерно за 11 секунд.

Здесь стоит оговориться. Nothing Phone (2) — это Snapdragon 8+ Gen 1, то есть однокристальная система уровня флагмана 2022 года, а не типичный бюджетный смартфон прораба. 11 секунд — корректная нижняя оценка, но не гарантия для устройства за 150 долларов. Кроме того, не уточняется, блокирует ли сборка PDF интерфейс: при 11 секундах без прогресс-бара это уже заметно. И тест в 100 изображений — честный, но узкий срез: реальные снимки с камер с разным шумом и детализацией дают иной разброс коэффициентов, чем скачанный датасет.

Чистое ядро и тесты без устройства

Всё, что не является интерфейсом, написано на чистом TypeScript: нумерация дефектов, модель отчёта, бюджет размера, формат резервной копии, ориентация из EXIF, геометрия разметки. Это запускается под node --test --experimental-strip-types — встроенным тест-раннером Node с поддержкой TypeScript без этапа компиляции.

Слой хранения работает через маленький интерфейс Db: на телефоне его реализует expo-sqlite, в тестах — встроенный node:sqlite. То есть тесты репозитория гоняются на настоящей базе SQLite без эмулятора и без железа.

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

Резервные копии, которые можно перенести в кармане

При отсутствии облака бэкап — единственная страховка пользователя. Проект экспортируется в один файл .punchsheet (zip-архив, собранный библиотекой fflate) со всеми фотографиями и всей разметкой и импортируется на другом телефоне — Android или iPhone.

Первая версия импорта читала файл целиком. С резервной копией на 211 МБ вызов File.bytes() упёрся в лимит Java heap в 256 МБ и приложение получило OutOfMemoryError. Сейчас импорт читает файл потоково, по чанкам в 1 МБ, и держит в памяти не более одной фотографии.

Обратите внимание на масштаб: бэкап в 211 МБ всего на 50 дефектов — это ~4 МБ на дефект с фото. Цифра подтверждает, что оригиналы снимаются с разрешением камеры и действительно не пережимаются, и одновременно объясняет, почему потоковое чтение — не оптимизация, а требование выживания.

Мелочи, каждая из которых стоила вечера

Отдельный интерес представляют баги, которые в документации не описаны:

  • R8 переименовывает классы исключений. Expo формирует код ошибки из имени Kotlin-класса, а в минифицированной релизной сборке это имя меняется. Отмена выбора папки стала показывать «не удалось сохранить». Теперь приложение дополнительно проверяет и текст сообщения («cancelled by the user»).
  • Android дописывает «.zip». При записи бэкапа через storage access framework с типом application/zip система переименовывала файл. Решение — писать как application/octet-stream, то есть отдать расширение на откуп пользователю.
  • iOS сливает подписанные Pressable. Диалог, где затемнение фона было Pressable с accessibility-меткой, обёрнутым вокруг карточки, превращался в VoiceOver в один элемент «Cancel», и кнопка «Delete» становилась недоступной. Теперь фон — соседний элемент, а не обёртка.
  • Защита Google Play работает офлайн. Проверка лицензии, которую Play добавляет к защищённым приложениям, проходила на телефоне в авиарежиме с остановленным Play Store даже при холодном старте.

Последний пункт — приятный сюрприз, который, впрочем, стоило бы проверить на более широкой выборке устройств и версий Play Services: поведение лицензирования Google меняется от версии к версии, и полагаться на один успешный холодный старт рискованно.

Цена и что дальше

PunchSheet стоит 9,99 доллара один раз (каждый магазин ставит локальную цену): без подписки, без внутриигровых покупок, без рекламы. Интерфейс англоязычный, терминологию можно выбрать — snags, punch items или defects. Приложение уже есть в Google Play, версия для iOS находится на рассмотрении в App Store.

Итоговая оценка сдержанная, но уважительная. Перед нами не «новая платформа для стройки», а узкий инструмент с честной бизнес-моделью и, что важнее, с архитектурой, где ограничения (нет сети, нет облака, ограниченная память) не обходятся, а оформляются в явные инженерные решения с измеримыми результатами. Главные цифры — 1,1 ГБ против 764 МБ и в итоге предсказуемый пик, 32 секунды против 7, 596 перекодирований против 89 — получены на узких тестовых наборах и на устройстве заметно мощнее бюджетного сегмента. Это не обесценивает их, но и не стоит принимать их как гарантию поведения на любом смартфоне. Для приложения, которое обещает «просто работает в подвале», именно предсказуемость и есть продукт — и её придётся проверять на каждом следующем поколении Android.