OCR с приоритетом приватности на Android: как Subly читает счета без облака
Практическое руководство 2026 года по OCR на устройстве под Android с ML Kit Text Recognition — настройка, код и приёмы извлечения полей, которые держат данные пользователей вне ваших серверов.
В тот момент, когда пользователь наводит телефон на счёт, у вашего приложения появляется выбор. Вы можете отправить это изображение в облачный сервис OCR и получить обратно аккуратный текст за 400 мс — ценой того, что чужой личный финансовый документ пройдёт через ваши серверы и сторонний сервис. Или вы можете выполнить работу прямо на устройстве, написать более честную политику конфиденциальности и никогда не думать о том, что очередная утечка данных окажется вашей виной.
Я выбрал второй путь для Subly — трекера подписок под Android, который позволяет отсканировать счёт от Netflix или Spotify и получить автоматически заполненные название сервиса, сумму, периодичность и дату следующего списания. Ничто не покидает телефон. В этой статье я разбираю, как это работает в 2026 году — настройку, код и приёмы извлечения полей, которые я усвоил на своих ошибках.
Зачем на устройстве, когда облако настолько просто
Облачные OCR API — это одна строчка. Google Cloud Vision, AWS Textract, Azure Read — все они дают чёткие результаты и все хотят изображения ваших пользователей. Для трекера подписок эти изображения могут содержать:
- Имя, последние 4 цифры или полный адрес электронной почты владельца счёта
- Блоки с адресом внизу распечатанного счёта
- Реквизиты способа оплаты
- Полную стоимость, позиции и даты
Ничего из этого списка я не хочу видеть в логах сторонней компании. И с точки зрения продукта фраза «мы никогда никуда не отправляем ваши счета» — гораздо более сильный маркетинговый аргумент, чем «мы отправляем ваши счета в Google Cloud и удаляем их в течение 30 дней». Поэтому архитектура следует за ценностью: держать изображение на устройстве, сохранять только извлечённые поля и вообще не открывать интернет-соединение ради сканирования.
Самый приватный байт — тот, что никогда не покидает телефон.
ML Kit Text Recognition v2: рабочая лошадка
ML Kit Text Recognition v2 от Google — это в 2026 году скучный, но правильный ответ для OCR на устройстве под Android. Он поставляется с латинской моделью, встроенной в ваше приложение (около 4 МБ), работает на телефоне уровня Pixel за десятки миллисекунд и достаточно хорошо справляется с печатным текстом для чеков, счетов и скриншотов писем.
Что мне в нём нравится:
- Не требуется разрешение на сеть. Вы можете выпустить приложение с удалённым
INTERNET, и OCR всё равно будет работать. Одно это стоит времени на интеграцию. - Работает офлайн. Сканирование в режиме полёта — это функция, а не баг.
- Модель на приложение. Встраивание модели означает отсутствие внезапной загрузки при первом запуске и отсутствие диалога
Try Again, когда у пользователя нестабильный Wi-Fi. - Бесплатно. Никаких затрат на API по мере роста. Для инди-приложения это разница между запуском и бесконечным обдумыванием.
Компромисс — точность в пограничных случаях: рукописный текст распознаётся через раз, плотные чеки с термопечати могут запутать разметку, а нелатинские письменности требуют отдельной модели. Для сценария с чеками, счетами и письмами этого более чем достаточно.
Настройка в 2026 году
Зависимость Gradle:
// app/build.gradle.kts
dependencies {
implementation("com.google.mlkit:text-recognition:16.0.1")
implementation("androidx.camera:camera-camera2:1.4.0")
implementation("androidx.camera:camera-lifecycle:1.4.0")
implementation("androidx.camera:camera-view:1.4.0")
}
И одна строка в манифесте, чтобы Play понимал, что модель встроена и загружена заранее:
<application>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="ocr" />
</application>
Вот и вся настройка. Ни ключей API, ни JSON сервисного аккаунта, ни подписанных запросов.
Распознавание текста из кадра CameraX
Вот основной цикл. CameraX передаёт вам кадры ImageProxy, вы оборачиваете один в InputImage, а распознаватель возвращает объект Text с абзацами, строками и прямоугольниками.
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
private val recognizer = TextRecognition.getClient(
TextRecognizerOptions.DEFAULT_OPTIONS,
)
@androidx.camera.core.ExperimentalGetImage
fun analyzeFrame(image: ImageProxy, onScanned: (String) -> Unit) {
val media = image.image ?: return image.close()
val input = InputImage.fromMediaImage(media, image.imageInfo.rotationDegrees)
recognizer.process(input)
.addOnSuccessListener { result ->
// Whole page as a single string, line breaks preserved.
onScanned(result.text)
}
.addOnCompleteListener { image.close() }
}
Пара вещей, которые стоит знать:
- Всегда вызывайте
image.close()ровно один раз, даже при ошибке. Паттерн со слушателями в ML Kit может подвести, если забыть об этом. result.text— это лёгкий режим. Для лучшего извлечения вам понадобитсяresult.textBlocks, чтобы отслеживать, какие строки на чеке расположены рядом друг с другом.- ML Kit также возвращает прямоугольники. Они полезны для отрисовки наложений («мы это обнаружили») и для проверки того, что пользователь действительно навёл камеру на счёт.
Извлечение полей: от сырого текста к структурированным данным
OCR даёт вам текст. Интересная работа — превратить этот текст в подписку. Для Subly это означает четыре поля:
- Название сервиса — «Netflix», «Spotify», «ChatGPT Plus»
- Сумма — «₺199.00» или «$9.99»
- Периодичность оплаты — ежемесячно, ежегодно
- Дата следующего списания — дата в будущем
Прагматичный конвейер на устройстве выглядит так:
data class ExtractedSubscription(
val service: String?,
val amount: Money?,
val cycle: BillingCycle?,
val nextDate: LocalDate?,
)
fun extract(text: String): ExtractedSubscription {
val lines = text.lines().map { it.trim() }.filter { it.isNotEmpty() }
return ExtractedSubscription(
service = findService(lines),
amount = findAmount(lines),
cycle = findCycle(lines),
nextDate = findNextDate(lines),
)
}
Каждая функция find* — маленькая, тестируемая вещь. Несколько уроков из реальной реализации:
- Название сервиса — самое сложное. Словарь распространённых сервисов (Netflix, Spotify, ChatGPT, YouTube Premium, Apple Music…) покрывает 80% случаев. Для остального хорошо работают первая заметная строка чека или эвристика вокруг заголовка
From:/Sent by:в письмах со счетами. - Сумма — самое простое. Регулярное выражение с учётом локали вроде
(?:[₺$€£])\s?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{2})?покрывает большинство валют. Всегда убирайте разделители тысяч перед парсингом. - Периодичность извлекается из формулировок вокруг суммы: «per month», «/mo», «monthly», «aylık», «yearly», «/yr». Небольшого словаря на турецком и английском достаточно.
- Следующая дата — две стратегии: подхватить явные фразы («next billing on», «renews on»), если они есть, иначе вывести из последней даты на чеке плюс один цикл.
Ничего из этого не эффектно. Но всё это происходит на устройстве, поэтому сбой не означает утечку счёта.
Покажите пользователю проделанную работу
Сканер — это чёрный ящик, если вы не показываете, что он сделал. Паттерн Subly: как только кадр совпадает, поверх превью камеры рисуется карточка с извлечёнными полями и кнопкой Edit. Уверенность в OCR хрупка; кнопка Edit — это честно и бесплатно.
@Composable
fun ExtractedCard(s: ExtractedSubscription, onEdit: () -> Unit, onAccept: () -> Unit) {
Card(...) {
Text("AI detected", style = labelMono)
Text("${s.service ?: "—"} ${s.amount?.format() ?: ""}", style = title)
Text("${s.cycle?.label} · next ${s.nextDate?.format()}", style = body)
Row {
TextButton(onClick = onEdit) { Text("Edit") }
FilledButton(onClick = onAccept) { Text("Add") }
}
}
}
Если два из четырёх полей отсутствуют, не добавляйте автоматически — переведите пользователя в форму ручного ввода, предзаполненную тем, что удалось получить.
Чего вы не получите от OCR на устройстве
Несколько честных компромиссов:
- Языки. Каждой письменности нужна своя модель. Латиница встроена по умолчанию; китайский, японский, корейский и деванагари — отдельно.
- Табличные разметки. Если вам нужна точная реконструкция строк и столбцов из многоколоночного счёта, вы напишете больше кода для разметки, чем ожидаете. Для большинства чеков подписок это не проблема.
- Рукописный текст. Не обещайте его. Даже облачные OCR здесь ненадёжны.
- Размер модели. Добавление нескольких языковых моделей может заметно увеличить ваш APK. Используйте App Bundle от Play и feature delivery, если вам важен размер установки.
Маркетинговая фраза пишется сама
Фраза, которую я хочу иметь возможность сказать в нашей политике конфиденциальности: «изображение счёта, которое вы захватываете, не загружается на наши серверы и не сохраняется после обработки». С ML Kit на устройстве эта фраза буквально описывает то, как работает код. В ней нет ничего от маркетинга — это утверждение об архитектуре, и политика конфиденциальности следует из него, а не наоборот.
Вот настоящий аргумент в пользу OCR на устройстве в 2026 году. Дело не просто в том, что это чуть быстрее или чуть дешевле. Дело в том, что «мы никогда не видим ваши данные» — это то, что вы можете сказать, потому что это правда, а не потому что юристы решили, что так, вероятно, нормально.
Если вы создаёте что-либо в сфере подписок / чеков / личных финансов и не уверены, каким путём идти, выбирайте по умолчанию обработку на устройстве. Библиотека бесплатна, история с приватностью пуленепробиваема, а худшее, что может случиться — вы выпустите функцию, которую ваши конкуренты буквально не смогут повторить, не перестроив свой стек.
Subly теперь доступен в Google Play — с приоритетом приватности по архитектуре, а не по обещанию. Подробнее на странице приложения.
// По теме
Ещё из журнала
Разработка Granyn: трекер бюджета без входа в банк, всего на трёх таблицах
Как Granyn отслеживает расходы в разных валютах и распознаёт повторяющиеся счета, не подключая ни одного банковского аккаунта — схема Room под капотом и компромиссы, которые она навязывает.
Как я делал Stocky: предсказать, что заканчивается на вашей кухне — прямо на устройстве
Как Stocky превращает скан штрихкода и немного истории использования в список покупок, который появляется прежде, чем у вас что-то закончится — и вся математика, и все данные остаются на вашем телефоне.
Local-first на Android в 2026 году: SQLite, Room и хранение пользовательских данных на устройстве
Руководство 2026 года по созданию local-first приложений для Android на Room и SQLite — проектирование схемы, миграции, WAL, экспорт и когда (и когда не стоит) добавлять синхронизацию.