Датчики и медиа: камера, геолокация, звук и разрешения на них
Предыдущие статьи трека были про то, как устроено приложение внутри: слои, состояние, база на диске, окна фонового исполнения, бюджет кадра, ключи в защищённом хранилище, конвейер релиза. Всё это верно и для десктопного клиента, просто в мобильном варианте жёстче. Но есть класс задач, ради которого приложение вообще ставят на телефон, а не открывают в браузере, и он в трек до сих пор не попадал: работа с физическим миром.
Телефон — это не маленький компьютер. Это набор датчиков, к которому приложили экран: две-три камеры, микрофонный массив, GNSS-приёмник, акселерометр, гироскоп, магнитометр, барометр, датчик освещённости и приближения, NFC, Bluetooth, иногда UWB и лидар. Продукт, который сканирует штрихкод, показывает курьера на карте, считает шаги, распознаёт документ, играет подкаст в машине или снимает сторис, — это продукт, у которого основная логика живёт на границе с железом, а не в бизнес-слое.
И граница эта устроена не так, как остальной код. У обычного вызова репозитория два исхода: данные или ошибка. У вызова камеры их шесть: разрешения нет и его ещё не просили; попросили и отказали; отказали навсегда; выдали урезанный доступ; выдали, но пользователь выключил камеру глобальным тумблером системы; выдали, всё работает, но устройство перегрелось и отдаёт 12 кадров в секунду вместо 30. Каждый из этих исходов — рабочий продуктовый сценарий, а не исключительная ситуация. Приложение, которое обрабатывает только шестой, будет отклонено на ревью и разругано в отзывах, причём в таком порядке.
Эта статья — про то, как устроен доступ к возможностям устройства и во что он обходится. Мы разберём разрешения как автомат состояний, семейство API, которые не требуют разрешений вообще, конвейер кадра камеры и его буферы, аудиосессию и прерывания, лестницу точности геолокации с честной ценой в процентах заряда, батчинг датчиков через аппаратный FIFO — и способы это тестировать, не бегая по улице с телефоном.
У любой возможности устройства три цены
Прежде чем брать API, полезно понимать, за что вы платите. Счёт приходит в трёх разных валютах, и забывают обычно про третью.
Первая цена — разрешение. Диалог, который пользователь увидит, и вероятность отказа. Она не одинакова: на согласие давать доступ к фото соглашаются заметно охотнее, чем на фоновую геолокацию, а разница между «пока приложение открыто» и «всегда» — это разница между обычным диалогом и разговором с ревьюером стора.
Вторая цена — энергия. Непрерывный GNSS — единицы процентов заряда в час; камера с активным превью — сопоставимо, плюс нагрев и тепловой троттлинг; акселерометр на 100 Гц с пробуждением процессора на каждый отсчёт — незаметно дорого, потому что мешает системе спать. Подробный разбор энергетики — в производительности; здесь важно, что энергия — это не абстракция, а строка в системном экране «Аккумулятор» с названием вашего приложения.
Третья цена — приватность и обязательства перед сторами. Каждое обращение к датчику превращается в строку декларации: NSCameraUsageDescription в Info.plist, запись в форме Data safety, иногда — в PrivacyInfo.xcprivacy с указанием причины использования API. Эти декларации читает ревьюер и видит пользователь на карточке приложения. Плагин, добавленный ради выбора фотографии и притащивший в манифест точную геолокацию, меняет карточку вашего продукта в магазине — про это уже говорилось в кроссплатформе.
| Возможность | Разрешение | Энергия | Что появляется в декларациях |
|---|---|---|---|
| Выбор фото системным пикером | нет | нулевая | ничего |
| Съёмка системной камерой через интент | нет | разовая | ничего |
| Кадр камеры в своём UI | да, жёсткое | высокая, плюс нагрев | цель использования камеры |
| Микрофон | да, жёсткое | средняя | цель использования микрофона |
| Геолокация при открытом приложении | да | от низкой до высокой | цель + точность |
| Геолокация всегда | да, отдельный диалог | высокая | цель + обоснование для Play, часто видео |
| Акселерометр и гироскоп | нет на Android, нет на iOS | низкая, но мешает сну | иногда как required-reason API |
| Шагомер и распознавание активности | да | очень низкая | цель использования движения |
| Bluetooth и сканирование окружения | да | средняя | цель, на Android — связка с геолокацией |
Из таблицы следует правило, которое стоит всех остальных: самое дешёвое разрешение — то, которое вы не просите. Прежде чем открывать документацию камеры, проверьте, нельзя ли решить задачу системным компонентом, который работает в чужом процессе и не требует ничего.
Как закручивали доступ: краткая история
Ни одно из сегодняшних ограничений не придумано из вредности — каждое отвечает на конкретный класс злоупотреблений, найденный на телеметрии сотен миллионов устройств.
Тренд не колеблется: свободы становится меньше, а гранулярности — больше. Архитектурный вывод не «успеть воспользоваться, пока разрешают», а «спроектировать так, чтобы следующее ужесточение не потребовало переписывать экран». Практически это значит: любая функция с разрешением обязана иметь работающий сценарий без него, а доступ к датчику должен быть спрятан за интерфейсом вашего домена, а не размазан по вью-моделям.
Разрешение — это состояние, а не булев флаг
Самая частая ошибка в коде выглядит так: if (hasPermission) { start() } else { requestPermission() }. Она порождает экран, который при отказе показывает бесконечный спиннер, и приложение, которое при повторном заходе бесконечно дёргает системный диалог, а тот молча не появляется.
Правильная модель — автомат, и его надо нарисовать до того, как писать код.
Разберём узлы, которые ломают продуктовые планы чаще всего.
Limited — не «почти Full». На iOS ограниченный доступ к фотобиблиотеке означает, что вы видите ровно те снимки, которые пользователь выбрал, а остальных для вас не существует; на Android 14 то же самое даёт READ_MEDIA_VISUAL_USER_SELECTED. Приблизительная геолокация (accuracyAuthorization == .reducedAccuracy на iOS, ACCESS_COARSE_LOCATION без FINE на Android) даёт точность порядка нескольких километров: этого хватит для выбора города и не хватит для вызова курьера к подъезду. Оба состояния — не ошибка, а нормальный режим работы, в котором продукт обязан быть полезным.
OneTime — доступ на одну сессию. Пользователь ушёл из приложения — разрешение вернулось в NotDetermined. Кэшировать результат проверки в поле объекта нельзя: проверять надо перед каждым использованием.
Автосброс. Android с 11-й версии сам отзывает разрешения у приложений, которыми давно не пользовались. Значит, «мы спросили при онбординге год назад» не гарантирует ничего, и приложение должно уметь переспросить в момент нужды, а не падать.
Blocked — точка невозврата. На Android второй отказ выключает системный диалог: shouldShowRequestPermissionRationale() вернёт false, а launch() завершится мгновенным отказом без единого пикселя на экране. На iOS системный диалог показывается ровно один раз за всю жизнь установки. Из этого следует, что системный диалог — дефицитный ресурс, и тратить его наугад нельзя.
Отсюда обязательный паттерн: собственный экран-праймер до системного диалога. Он объясняет, зачем нужен доступ, на языке пользы («чтобы находить рядом», а не «чтобы получить ACCESS_FINE_LOCATION»), и даёт кнопку «не сейчас», которая ничего не тратит. Праймер поднимает согласие в разы — просто потому, что человек, который понял зачем, нажимает «Разрешить».
Код же должен возвращать состояние, а не булево значение. Это тот же принцип, что и в архитектуре: состояние экрана — тип, а не набор флагов.
import AVFoundation
// Состояние доступа — часть состояния экрана, а не деталь реализации.
enum CaptureAccess {
case notDetermined // диалог ещё не потрачен, можно показать праймер
case granted
case denied // диалог потрачен, дорога только через настройки
case restricted // родительский контроль или политика MDM: кнопки быть не должно
case blockedBySystem // глобальный тумблер камеры выключен в Пункте управления
}
enum CameraGate {
static func current() -> CaptureAccess {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .notDetermined: return .notDetermined
case .authorized: return .granted
case .restricted: return .restricted
case .denied: return .denied
@unknown default: return .denied
}
}
/// Вызывать ТОЛЬКО после того, как пользователь нажал «Продолжить» на праймере.
static func request() async -> CaptureAccess {
guard current() == .notDetermined else { return current() }
return await AVCaptureDevice.requestAccess(for: .video) ? .granted : .denied
}
}
sealed interface PermissionState {
data object NotDetermined : PermissionState // диалог ещё доступен
data object Granted : PermissionState
data class Partial(val visibleItems: Int) : PermissionState // Android 14: выбранные фото
data object Denied : PermissionState // отказ, диалог ещё можно показать
data object Blocked : PermissionState // диалог выключен навсегда
}
class PermissionGate(private val activity: ComponentActivity) {
fun state(permission: String): PermissionState = when {
ContextCompat.checkSelfPermission(activity, permission) == PERMISSION_GRANTED ->
PermissionState.Granted
// Ключевой нюанс: rationale == true означает «отказал один раз, диалог ещё жив».
ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) ->
PermissionState.Denied
// Ложь наоборот: false бывает и до первого спрашивания, и после блокировки.
// Различить их системным API нельзя — поэтому факт первого показа
// приходится хранить самим, в обычных настройках приложения.
prefs.wasAsked(permission) -> PermissionState.Blocked
else -> PermissionState.NotDetermined
}
fun openSystemSettings() = activity.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", activity.packageName, null)),
)
}
Обратите внимание на комментарий про prefs.wasAsked. Это не хак, а необходимость: платформа не различает «ещё не спрашивали» и «заблокировано навсегда», и без собственного флага вы не сможете показать правильный экран. Пишите его в тот же момент, когда вызываете launch().
Правило нулевого разрешения: системные пикеры и интенты
Половина запросов на камеру и галерею в реальных приложениях не нужна. Обе платформы предоставляют компоненты, которые работают в отдельном процессе, показывают свой интерфейс поверх вашего и возвращают готовый файл. Ваше приложение при этом не получает доступа ни к чему, кроме выбранного, — и потому не требует ни разрешения, ни строки в декларации.
разрешение НЕ нужно] C -- Снять --> E[Системная камера через интент
разрешение НЕ нужно] B -- Да: рамка, подсказки, разбор в реальном времени --> F{Нужен ли каждый кадр вашему коду} F -- Нет, только предпросмотр --> G[Сессия захвата: превью и снимок] F -- Да: сканер, распознавание, дополненная реальность --> H[Сессия захвата: превью, анализ, снимок] G --> I[Разрешение на камеру обязательно
плюс строка-обоснование] H --> I D --> J[Скопировать файл к себе в песочницу
чужой URI живёт не вечно] E --> J I --> K[Свой UI поверх превью
вся ответственность за качество на вас]
// Android: системный пикер фото. Ни строки в манифесте, ни диалога.
private val pickMedia = registerForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri ->
// URI чужой и временный: право на чтение живёт до конца процесса.
// Первое, что делаем, — копируем содержимое к себе.
uri?.let { viewModel.onPicked(copyIntoSandbox(it)) }
}
fun choosePhoto() = pickMedia.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
private fun copyIntoSandbox(uri: Uri): File {
val target = File(cacheDir, "upload-${System.currentTimeMillis()}.jpg")
contentResolver.openInputStream(uri)!!.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
return target
}
На iOS аналог — PHPickerViewController: он тоже исполняется вне вашего процесса, возвращает NSItemProvider и не требует NSPhotoLibraryUsageDescription. Прямой доступ к PHPhotoLibrary нужен только тогда, когда вы строите собственную галерею с альбомами и сортировкой, — а таких продуктов на порядок меньше, чем приложений, которым нужен один аватар.
Две ловушки, на которых спотыкаются даже опытные команды.
Чужой URI — не путь к файлу. Он не переживёт перезапуск, его нельзя передать в фоновую задачу «на потом» и почти никогда нельзя открыть спустя сутки. Для долгоживущего доступа к документу существует ACTION_OPEN_DOCUMENT + takePersistableUriPermission, но для картинки, которую вы всё равно собираетесь ужать и загрузить, проще и надёжнее скопировать байты в песочницу немедленно.
Объявили CAMERA в манифесте — обязаны его спрашивать. Android документированно требует: если приложение декларирует разрешение CAMERA, то даже при использовании системного интента съёмки его нужно запросить. Поэтому не объявляйте разрешений, которые не используете, и следите за тем, что тянут в манифест зависимости.
Камера: конвейер кадров и его буферы
Если живой кадр внутри вашего интерфейса действительно нужен — сканер, распознавание документа, наложение подсказок, — начинается работа с сессией захвата. Здесь важно понимать физику: вы не получаете изображение, вы арендуете буфер из конечного пула.
Читается схема слева направо. Матрица отдаёт сырые данные, аппаратный ISP превращает их в кадры, кадры складываются в пул буферов ограниченного размера — обычно четыре-восемь штук. Дальше у пула есть до трёх потребителей: превью (уходит прямо в композитор через GPU, ваш код его не видит и не должен видеть), анализ (кадры отдаются вашему коду в YUV) и снимок (разовый кадр полного разрешения). Пул общий и конечный, отсюда главное следствие: если ваш код удерживает буфер дольше, чем длится кадр, пул опустеет и встанет всё, включая превью. Пользователь увидит замерзшую картинку и решит, что приложение сломалось.
// CameraX: три сценария использования, привязанные к жизненному циклу экрана.
private fun bindCamera(previewView: PreviewView) {
val provider = ProcessCameraProvider.getInstance(this).get()
val preview = Preview.Builder().build()
.also { it.setSurfaceProvider(previewView.surfaceProvider) }
val analysis = ImageAnalysis.Builder()
.setTargetResolution(Size(640, 480)) // для сканера больше не нужно
// Свежий кадр важнее полного ряда: старые просто выбрасываются.
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
// Выделенный однопоточный исполнитель: НЕ главный поток и не общий пул.
analysis.setAnalyzer(analysisExecutor) { proxy ->
try {
val result = barcodeScanner.scan(proxy.image, proxy.imageInfo.rotationDegrees)
result?.let { mainHandler.post { viewModel.onCodeFound(it) } }
} finally {
proxy.close() // ОБЯЗАТЕЛЬНО и в любом исходе: буфер возвращается в пул
}
}
val capture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
provider.unbindAll()
// bindToLifecycle сам остановит и освободит камеру при уходе с экрана —
// ровно то, что мы обсуждали в жизненном цикле: камера не должна пережить экран.
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis, capture)
}
// AVFoundation: та же идея, но собирается руками.
final class ScannerSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
private let session = AVCaptureSession()
// Своя последовательная очередь: конфигурация и кадры НИКОГДА не на главной.
private let sessionQueue = DispatchQueue(label: "camera.session")
private let frameQueue = DispatchQueue(label: "camera.frames")
func configure() {
sessionQueue.async {
self.session.beginConfiguration()
self.session.sessionPreset = .hd1280x720 // не .photo: превью столько не нужно
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device),
self.session.canAddInput(input) else { return }
self.session.addInput(input)
let output = AVCaptureVideoDataOutput()
// Ключевой флаг: опоздавшие кадры выбрасываются, а не копятся в очереди.
output.alwaysDiscardsLateVideoFrames = true
output.setSampleBufferDelegate(self, queue: self.frameQueue)
if self.session.canAddOutput(output) { self.session.addOutput(output) }
self.session.commitConfiguration()
self.session.startRunning() // блокирующий вызов, отсюда и очередь
}
}
func captureOutput(_ out: AVCaptureOutput, didOutput sample: CMSampleBuffer,
from connection: AVCaptureConnection) {
// Бюджет — один кадр. Не уложились: либо теряем кадры, либо греем устройство.
// Держать CMSampleBuffer дольше обработчика нельзя: он из того же пула.
guard let pixels = CMSampleBufferGetImageBuffer(sample) else { return }
let code = scanner.scan(pixels)
if let code { DispatchQueue.main.async { self.onCodeFound(code) } }
}
}
Практические правила, каждое из которых стоит инцидента в проде:
- Разрешение анализа — не разрешение снимка. Сканеру штрихкода хватает 640×480; ставить туда 12 Мп значит отдавать 18 МБ на кадр в поток анализа и получить нагрев за минуту.
- Считайте память честно. Кадр 1080p в YUV — примерно 3,1 МБ; снимок 12 Мп, декодированный в ARGB, — около 48 МБ, и на бюджетном устройстве это прямой путь к тому самому убийству процесса из обзора. Никогда не декодируйте снимок целиком, если нужен предпросмотр: обе платформы умеют строить уменьшенную копию, не разворачивая исходник в памяти.
- Поворот приходит отдельно от пикселей. Матрица физически ориентирована фиксированно, а телефон в руке — как угодно. Кадр приходит с числом градусов (
imageInfo.rotationDegrees) или с флагом ориентации в EXIF, и игнорирование этого — причина классического бага «на iPhone фото загружается боком». - Тепловой троттлинг реален. Через две-три минуты активного захвата с распознаванием частота кадров падает, и это не баг, а защита устройства. Продукт должен деградировать осмысленно: снизить частоту анализа, а не показать пользователю зависший экран.
- Камера умирает вместе с экраном. Забытая активная сессия — это горящий индикатор записи в статус-баре и жалоба в поддержку.
bindToLifecycleна Android делает это сам, на iOS остановку пишут руками.
Микрофон устроен так же по структуре разрешений и так же виден пользователю: с iOS 14 работающий микрофон подсвечивается оранжевой точкой, камера — зелёной, а Android 12 добавил панель приватности с историей обращений. С 12-й версии Android у пользователя есть ещё и глобальные тумблеры: камера и микрофон могут быть выключены системно, и тогда ваше разрешение действует, но данных нет — приложение получает чёрный кадр и тишину. Это отдельная ветка в состоянии экрана, а не повод падать.
Звук: аудиосессия, фокус и прерывания
Со звуком есть особенность, которой нет ни у камеры, ни у геолокации: динамик один, а желающих много. Поэтому воспроизведение — не «запустить плеер», а переговоры с системой о праве занять аудиовыход, и переговоры эти можно проиграть в любой момент.
Три сценария на диаграмме — это три разные вещи, которые новички склеивают в один обработчик, и получают приложение, которое орёт на весь вагон после того, как из телефона выдернули наушники.
Приглушение (AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK на Android, AVAudioSession.InterruptionOptions на iOS) — не пауза. Навигатор произнёс фразу поверх вашей музыки, вы снизили громкость и вернули. Пауза здесь испортит впечатление.
Прерывание — пауза с запоминанием позиции. Ключевой нюанс: возобновлять можно только если система разрешила. На iOS при окончании прерывания приходит опция shouldResume; её отсутствие означает, что пользователь сам запустил другое приложение, и оживать нельзя.
Исчезновение маршрута вывода — всегда безусловная пауза. Наушники вынули, Bluetooth-колонка отключилась: звук хлынет в динамик телефона. На Android это широковещательное сообщение ACTION_AUDIO_BECOMING_NOISY, на iOS — уведомление о смене маршрута с причиной «прежнее устройство недоступно». Пропущенная обработка этого события — самая узнаваемая ошибка мобильного плеера.
// Media3 умеет вести переговоры за вас — это лучший вариант по умолчанию.
val attributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
val player = ExoPlayer.Builder(context)
// Второй аргумент — «управляй фокусом сам»: приглушение, паузы, возобновление.
.setAudioAttributes(attributes, /* handleAudioFocus = */ true)
.build()
// Фоновое воспроизведение обязано быть видимым: сервис переднего плана
// с типом mediaPlayback и сессия, которая рисует управление на экране блокировки.
class PlaybackService : MediaSessionService() {
private lateinit var session: MediaSession
override fun onCreate() {
super.onCreate()
session = MediaSession.Builder(this, player).build()
}
override fun onGetSession(info: MediaSession.ControllerInfo) = session
}
Фоновое воспроизведение подчиняется правилам из фоновой работы и пушей: на iOS нужен режим audio в Info.plist и активная сессия, на Android — сервис переднего плана с типом mediaPlayback. И там, и там действует один принцип: исполнение в фоне покупается видимостью. Управление на экране блокировки, в шторке, в автомобиле — это не украшение, а условие сделки с операционной системой. Про доступность медиаконтента — субтитры, транскрипты, управление скоростью — есть отдельный разбор в треке доступности.
Геолокация: точность, которую вы покупаете энергией
Геолокация — самая дорогая и самая злоупотребляемая возможность устройства. Ключ к правильным решениям в том, что это не один API, а лестница из пяти-шести механизмов с разной физикой, и разница в потреблении между верхней и нижней ступенью — два порядка.
Правый нижний квадрант — то, ради чего стоит переписать функцию. Геозона не «включает GPS», она регистрируется в системе, и система будит ваше приложение, когда устройство пересекло границу, используя те же сигналы, которые уже собирает для себя. Одно приложение с геозонами и десять таких же приложений стоят почти одинаково — платит система, и платит один раз.
Отсюда главное правило работы с геолокацией: не спрашивайте координаты, подписывайтесь на события. «Курьер приехал к клиенту» — это геозона, а не сравнение расстояний в цикле по таймеру. «Пользователь вернулся домой» — мониторинг посещений. «Сколько прошёл пешком» — шагомер, который считает сопроцессор движения за доли процента заряда, а не трек GNSS.
final class Nearby: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
func startForScreen() {
// Точность заказывается под задачу: для списка «рядом» сто метров — с запасом.
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
manager.distanceFilter = 50 // не будить на каждый метр
manager.delegate = self
manager.requestWhenInUseAuthorization() // ТОЛЬКО «пока открыто»
manager.startUpdatingLocation()
}
func stopForScreen() {
manager.stopUpdatingLocation() // экран закрыт — приёмник выключен
}
func locationManager(_ m: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let fix = locations.last else { return }
// Гигиена: первая координата почти всегда из кэша и может быть вчерашней.
guard fix.horizontalAccuracy > 0, fix.horizontalAccuracy < 200 else { return }
guard abs(fix.timestamp.timeIntervalSinceNow) < 30 else { return }
onFix(fix)
}
func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {
// Приблизительная точность — отдельное состояние экрана, а не отказ.
if m.accuracyAuthorization == .reducedAccuracy {
// Просить полную точность разово можно, но только под конкретную задачу
// и с ключом-обоснованием в Info.plist.
m.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "DeliveryPinpoint")
}
}
}
// Android: приоритет вместо «точности», интервалы вместо частоты опроса.
private val request = LocationRequest.Builder(
Priority.PRIORITY_BALANCED_POWER_ACCURACY, // Wi-Fi и вышки, около ста метров
/* intervalMillis = */ 30_000L,
).setMinUpdateIntervalMillis(10_000L) // чаще не присылать, даже если есть
.setMaxUpdateDelayMillis(120_000L) // разрешить системе копить и отдавать пачкой
.build()
// Геозона: система будит приложение сама, приёмник постоянно не работает.
private fun watchDeliveryPoint(point: LatLng) {
val fence = Geofence.Builder()
.setRequestId("delivery-42")
.setCircularRegion(point.lat, point.lng, /* radius = */ 150f)
.setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER)
.setExpirationDuration(6 * 60 * 60 * 1000L) // не вечно: истекающая геозона дешевле
.build()
geofencingClient.addGeofences(
GeofencingRequest.Builder().addGeofence(fence).build(),
pendingIntentForBroadcast(),
)
}
Отдельный разговор — фоновая геолокация. Это единственное разрешение в мобильной разработке, где технический вопрос переходит в юридический.
- На Android с 10-й версии
ACCESS_BACKGROUND_LOCATION— отдельное разрешение, и с 11-й его нельзя запросить диалогом: пользователя надо отправить в настройки, где он выберет «Разрешить всё время». Двухшаговый сценарий обязателен: сначала «пока открыто», потом — только если функция реально нужна — объяснение и переход в настройки. - Google Play требует отдельную декларацию с обоснованием, а на практике — видеодемонстрацию функции, ради которой фоновый доступ нужен. Отказ на этом этапе останавливает релиз целиком.
- На iOS при выданном «Всегда» система периодически показывает пользователю карту с точками, где вы его читали, и спрашивает, оставить ли доступ. Приложение, которое собирает координаты «на всякий случай», теряет разрешение само.
- Лимиты платформ: на iOS одновременно отслеживается не более 20 регионов, на Android — 100 геозон на приложение. Продукт с тысячей точек интереса обязан подгружать ближайшие динамически, а не пытаться зарегистрировать все.
Практический вывод: фоновая геолокация должна быть обоснована функцией, которую видно на экране. Запись трека пробежки, навигация, доставка — да, и с постоянным уведомлением. «Улучшение рекомендаций» — нет, и попытка это протащить стоит недель на ревью.
Датчики движения: частота, батчинг и готовые ответы
Акселерометр, гироскоп, магнитометр и барометр разрешений не требуют — и именно поэтому с ними легко навредить. Датчик сам по себе почти ничего не потребляет; дорого стоит пробуждение процессора на каждый отсчёт. Приложение, подписавшееся на акселерометр с максимальной частотой и оставившее подписку на всё время жизни экрана, не даёт устройству уйти в глубокий сон и съедает заряд незаметно для профайлера.
| Что нужно | Наивное решение | Правильное решение | Разница в цене |
|---|---|---|---|
| Считать шаги | акселерометр на 50 Гц и свой алгоритм | системный шагомер | сотни раз |
| Понять, что человек поехал | сравнение координат по таймеру | распознавание активности от ОС | десятки раз |
| Ориентация устройства | сырые данные трёх датчиков и своя фильтрация | вектор поворота от системного фьюжна | заметно, плюс качество выше |
| Реакция на «телефон подняли» | опрос на 100 Гц | триггерный датчик значимого движения | сотни раз |
| Компас в интерфейсе | максимальная частота | частота под 60 кадров, отписка при уходе с экрана | кратно |
Правило звучит просто: прежде чем считать самому, проверьте, не посчитала ли уже система. Платформенные шагомеры, детекторы активности и фьюжн ориентации работают на отдельном низкопотребляющем сопроцессоре и обходятся в доли процента заряда, потому что процессор при этом спит.
Если сырые данные всё-таки нужны, есть второй рычаг — аппаратный буфер. Датчик умеет копить отсчёты в собственном FIFO и отдавать их пачкой, разбудив процессор один раз вместо тысячи.
val sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
val accel = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
sensorManager.registerListener(
listener,
accel,
SensorManager.SENSOR_DELAY_GAME, // около 20 мс между отсчётами
/* maxReportLatencyUs = */ 5_000_000, // копить в аппаратном FIFO до 5 секунд
)
// Отписка — в onPause, а не в onDestroy: экран не виден, данные не нужны.
override fun onPause() {
super.onPause()
sensorManager.unregisterListener(listener)
}
Два дополнения по платформам. На Android с 12-й версии частота выше 200 Гц требует отдельного разрешения HIGH_SAMPLING_RATE_SENSORS — это ответ на атаки, восстанавливавшие ввод с клавиатуры по вибрации корпуса. А системный счётчик шагов с Android 10 закрыт разрешением ACTIVITY_RECOGNITION; на iOS то же самое требует строки NSMotionUsageDescription. То есть «датчики без разрешений» верно ровно до тех пор, пока речь о сырых числах, а не об извлечённом из них поведении человека.
Захваченное — это файл, а не объект в памяти
Отдельный сюжет на стыке этой статьи с данными и оффлайном. Медиа, полученное от датчика, — самый крупный объект, который проходит через мобильное приложение: фотография 12 Мп, минута видео, часовая запись трека. Держать это в памяти нельзя, отправлять на сервер синхронно — тоже: пользователь снимает документ в подвале, где сети нет.
Правильный конвейер выглядит так:
- Захват сразу пишет на диск, во временную директорию песочницы. В памяти живёт путь, а не байты.
- Уменьшение до отправки. Загружать оригинал 12 Мп ради аватара 200×200 — это чужой трафик и чужие деньги. Обе платформы умеют строить уменьшенную копию, не разворачивая исходник целиком: на Android это
BitmapFactory.Optionsс прореживанием либоImageDecoderс целевым размером, на iOS — построение миниатюры средствамиImageIO. - Запись в исходящую очередь — ту самую, из главы про оффлайн: операция «загрузить файл X к сущности Y» ложится в локальную базу и переживает перезапуск.
- Отправка фоновым механизмом с ограничениями: только по Wi-Fi, только при нормальном заряде, с докачкой по частям. На iOS это фоновая сессия загрузки, продолжающаяся после смерти процесса, на Android — задача планировщика с ограничениями по сети.
- Очистка временных файлов после подтверждения от сервера. Забытый шаг — приложение, которое за полгода съедает три гигабайта и попадает в топ «занимает много места».
Идемпотентность здесь обязательна, как и везде, где есть повторные отправки: у загрузки должен быть ключ, по которому сервер поймёт, что это повтор, а не второй файл. Механика разобрана в распределённых системах.
Приватность: что видит пользователь и что спрашивает магазин
Каждое обращение к датчику сегодня оставляет след, видимый трём сторонам.
Пользователю — индикаторы записи, панель приватности с историей обращений, карта с точками чтения геолокации, глобальные тумблеры камеры и микрофона.
Ревьюеру — строки-обоснования в Info.plist и объявления в манифесте. Их читают буквально: формулировка «для улучшения работы приложения» — стандартная причина отказа, потому что не объясняет, что именно и зачем. Пишите конкретно: «чтобы сканировать штрихкод товара камерой», «чтобы показать магазины рядом с вами».
Платформе — манифест приватности на iOS, где для ряда системных API нужно указать причину обращения, и форма Data safety в Play. Обе декларации распространяются и на сторонние SDK: аналитика, притащившая доступ к списку установленных приложений, станет вашей строкой в карточке магазина. Полный разбор процесса — в релизе и сторах и в статье про магазины приложений; принципы минимизации данных как таковые — в безопасности мобильного.
Инженерный вывод из всего этого короткий: собирайте минимум, который нужен функции прямо сейчас, и отключайте датчик, как только экран закрылся. Это одновременно лучшая практика приватности, лучшая практика энергопотребления и самый простой ответ ревьюеру.
Как это тестировать без беготни с телефоном
Возможности устройства — та часть приложения, где симулятор врёт сильнее всего, а живое тестирование дороже всего. Разложим по слоям, продолжая пирамиду из тестирования.
Домен не должен знать про датчики. Логика «показать список ближайших» получает координату как значение, а не менеджер геолокации. Тогда девять десятых сценариев — включая «координата пришла с точностью 3 км», «координата протухла», «пользователь дал приблизительный доступ» — проверяются обычными юнит-тестами без единого устройства.
Состояния разрешений — снапшот-тесты. Экран сканера имеет минимум пять визуальных состояний: праймер, системный диалог поверх, отказ с кнопкой в настройки, блокировка навсегда, глобальный тумблер выключен. Это пять снимков, а не пять прогонов на ферме.
Симулятор и эмулятор умеют больше, чем думают. Ими стоит пользоваться:
# iOS: подставить координату работающему симулятору
xcrun simctl location booted set 55.751244,37.618423
# iOS: проиграть маршрут из GPX-файла — так тестируют навигацию и геозоны
xcrun simctl location booted start --speed 12 route.gpx
# iOS: отозвать и выдать доступ, не трогая интерфейс настроек
xcrun simctl privacy booted revoke camera com.example.scanner
xcrun simctl privacy booted grant location com.example.scanner
# Android: подставить координату эмулятору — порядок аргументов долгота, широта
adb emu geo fix 37.618423 55.751244
# Android: выдать и отозвать разрешение на живом устройстве
adb shell pm grant com.example.scanner android.permission.CAMERA
adb shell pm revoke com.example.scanner android.permission.ACCESS_FINE_LOCATION
Эмулятор Android умеет подавать в камеру виртуальную сцену и произвольное изображение — этого хватает, чтобы автоматически проверить распознавание штрихкода в CI. Симулятор iOS камеры не имеет вовсе: сценарии съёмки закрываются либо подстановкой тестового изображения на уровне абстракции захвата, либо ручным чек-листом на устройстве.
Что остаётся людям. Поведение на морозе, качество автофокуса, реальная точность GNSS в городской застройке, звук в машине через Bluetooth, тепловой троттлинг после трёх минут сканирования. Это не автоматизируется, и попытки автоматизировать обходятся дороже, чем получасовой чек-лист перед релизом.
Кроссплатформа: где плагины протекают
Работа с датчиками — ровно та область, где экономия кроссплатформы минимальна, о чём подробно говорилось в сравнении подходов. Причина структурная: плагин камеры или геолокации — это тонкая обёртка над двумя нативными реализациями, и любая платформенная новинка (частичный доступ к галерее, глобальный тумблер, новая ступень точности) требует правки внутри плагина, а не в вашем коде.
Три практических следствия. Аудит транзитивных разрешений обязателен: собранный манифест и итоговый Info.plist надо смотреть глазами на каждом релизе, потому что плагин, поставленный ради выбора фото, вполне может добавить в карточку магазина строку про точную геолокацию. Живой кадр камеры через мост дорог: передавать кадры в JavaScript или Dart на 30 к/с — это копирование памяти тридцать раз в секунду, поэтому распознавание пишут нативным модулем, а наверх отдают уже результат. Держите свой интерфейс над плагином: смена плагина — обычное событие в жизни кроссплатформенного проекта, и она не должна затрагивать экраны.
Типичные ошибки
- Спрашивать разрешения на онбординге. Пять системных диалогов подряд до того, как человек понял ценность продукта, — рекордный процент отказов и частая причина отклонения на ревью.
- Считать разрешение булевым флагом. Ограниченный доступ к фото, приблизительная геолокация, одноразовое разрешение и глобальный тумблер — четыре состояния, каждое из которых требует своего экрана.
- Не различать «ещё не спрашивали» и «заблокировано». Платформа их не различает, и без собственного флага приложение показывает кнопку, которая не делает ничего.
- Просить камеру там, где хватает системного пикера. Лишнее разрешение, лишняя декларация, лишний процент отказов — ради экрана, который вы всё равно нарисуете хуже системного.
- Держать буфер кадра дольше обработчика. Пул опустеет, превью замрёт, а причину будут искать в рендеринге.
- Декодировать снимок целиком ради миниатюры. Полсотни мегабайт на ровном месте и убитый процесс на бюджетном устройстве.
- Игнорировать ориентацию из EXIF. Классическое «фото загрузилось боком», которое всплывает уже у пользователей.
- Возобновлять звук после любого прерывания. Пользователь принял звонок и открыл другое приложение, а ваш плеер снова заговорил.
- Не обрабатывать исчезновение наушников. Самый узнаваемый способ опозорить пользователя в общественном транспорте.
- Заказывать максимальную точность геолокации по умолчанию. Списку «рядом» хватает ста метров, а разница в потреблении — кратная.
- Опрашивать координаты по таймеру вместо геозон. Своя реализация того, что система уже делает бесплатно, но в десятки раз дороже по энергии.
- Просить фоновую геолокацию без видимой функции. Отклонение на ревью и требование видеодемонстрации в лучшем случае.
- Оставлять подписку на датчики после ухода с экрана. Утечка, которую не покажет профайлер памяти, но покажет системный отчёт о расходе заряда.
- Не чистить временные медиафайлы. Три гигабайта кэша за полгода и просьбы удалить приложение ради места.
Мини-итог
Работа с датчиками — это работа на границе с физическим миром, и правила там другие. У любой возможности три цены: разрешение, энергия и декларация перед магазином, — и самое дешёвое разрешение то, которое вы не просите: системный пикер медиа и интент съёмки решают большинство задач вообще без диалогов. Разрешение — автомат состояний, а не флаг: ограниченный доступ, приблизительная точность, одноразовая выдача, автосброс и блокировка навсегда — рабочие ветки, каждая со своим экраном, а системный диалог показывается один раз за жизнь установки и потому тратится только после собственного праймера. Камера — это аренда буфера из конечного пула: удержали дольше кадра — встало превью; разрешение анализа выбирается под задачу, а не под матрицу; поворот приходит отдельно от пикселей. Звук — переговоры за единственный динамик, где приглушение, прерывание и пропажа маршрута вывода обрабатываются по-разному, а фоновое воспроизведение покупается видимым управлением. Геолокация — лестница из механизмов с разбросом потребления в два порядка, и правильный вопрос звучит как «на какое событие подписаться», а не «как часто спрашивать координаты»; фоновый доступ обосновывается функцией, которую видно на экране. Датчики движения почти бесплатны сами по себе и дороги пробуждениями процессора, поэтому сначала проверьте, не посчитала ли система за вас, а если нужны сырые данные — включайте аппаратный батчинг и отписывайтесь вместе с экраном. Всё захваченное живёт файлом на диске и уезжает через исходящую очередь, а не из памяти. И наконец: домен не должен знать про датчики — тогда девять десятых сценариев проверяются юнит-тестами, а живому устройству остаётся то, что действительно требует рук.
Источники
- Apple. AVFoundation Capture — сессии захвата, выходы, очереди и работа с буферами.
- Apple. PHPickerViewController — выбор медиа без доступа к библиотеке.
- Apple. Core Location — уровни точности, регионы, значимые изменения и посещения.
- Apple. Requesting authorization to use location services — порядок запросов и временная полная точность.
- Apple. Core Motion — шагомер, распознавание активности, фьюжн ориентации.
- Apple. AVAudioSession и Responding to audio interruptions — категории, прерывания, смена маршрута.
- Apple. Describing use of required reason API — манифест приватности и причины обращения к API.
- Apple. Human Interface Guidelines: Privacy — как формулировать обоснования и когда спрашивать.
- Android Developers. CameraX overview — сценарии использования, привязка к жизненному циклу, анализ кадров.
- Android Developers. Photo picker и Media permissions — доступ к медиа без разрешений и частичный доступ.
- Android Developers. App permissions best practices — праймеры, обработка отказов, автосброс.
- Android Developers. Location strategies и Geofencing — приоритеты, интервалы, батчинг, лимиты геозон.
- Android Developers. Background location access — двухшаговый запрос и правила Play.
- Android Developers. Sensors overview — частоты, аппаратный FIFO, триггерные датчики.
- Android Developers. Media3 и MediaSession — воспроизведение, аудиофокус, управление на экране блокировки.
- Google Play. Location permissions policy — декларация и требования к обоснованию фонового доступа.
- Ilya Grigorik. High Performance Browser Networking, глава про мобильные сети — почему передача больших медиафайлов стоит энергии, а не только времени.
- Смежное чтение: датчики и исполнительные механизмы во встраиваемых системах — та же физика на уровне микроконтроллера, без прослойки ОС и разрешений.
Что дальше
Покупки и подписки в приложении: StoreKit, Play Billing, права и восстановление — заключительная статья трека и единственная, где приложение имеет дело с чужой кассой. Разберём, почему покупка и право на функциональность живут в разных системах; StoreKit 2 и Play Billing по шагам, включая слушатель транзакций, который обязан подниматься при старте приложения; серверную валидацию и уведомления площадок; жизненный цикл подписки с грейс-периодом, удержанием и возвратом; восстановление покупок и конфликт «один чек — два аккаунта»; поведение платной функции в оффлайне; требования ревью к пейволлу — и способы всё это протестировать, не тратя настоящих денег. Там же — куда идти после трека.