Мобильная разработка Датчики и медиа: камера, геолокация, звук и разрешения на них
0%

Датчики и медиа: камера, геолокация, звук и разрешения на них

Датчики и медиа: камера, геолокация, звук и разрешения на них

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

Телефон — это не маленький компьютер. Это набор датчиков, к которому приложили экран: две-три камеры, микрофонный массив, 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().

Правило нулевого разрешения: системные пикеры и интенты

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

// 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 Мп, минута видео, часовая запись трека. Держать это в памяти нельзя, отправлять на сервер синхронно — тоже: пользователь снимает документ в подвале, где сети нет.

Правильный конвейер выглядит так:

  1. Захват сразу пишет на диск, во временную директорию песочницы. В памяти живёт путь, а не байты.
  2. Уменьшение до отправки. Загружать оригинал 12 Мп ради аватара 200×200 — это чужой трафик и чужие деньги. Обе платформы умеют строить уменьшенную копию, не разворачивая исходник целиком: на Android это BitmapFactory.Options с прореживанием либо ImageDecoder с целевым размером, на iOS — построение миниатюры средствами ImageIO.
  3. Запись в исходящую очередь — ту самую, из главы про оффлайн: операция «загрузить файл X к сущности Y» ложится в локальную базу и переживает перезапуск.
  4. Отправка фоновым механизмом с ограничениями: только по Wi-Fi, только при нормальном заряде, с докачкой по частям. На iOS это фоновая сессия загрузки, продолжающаяся после смерти процесса, на Android — задача планировщика с ограничениями по сети.
  5. Очистка временных файлов после подтверждения от сервера. Забытый шаг — приложение, которое за полгода съедает три гигабайта и попадает в топ «занимает много места».

Идемпотентность здесь обязательна, как и везде, где есть повторные отправки: у загрузки должен быть ключ, по которому сервер поймёт, что это повтор, а не второй файл. Механика разобрана в распределённых системах.

Приватность: что видит пользователь и что спрашивает магазин

Каждое обращение к датчику сегодня оставляет след, видимый трём сторонам.

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

Ревьюеру — строки-обоснования в 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. Классическое «фото загрузилось боком», которое всплывает уже у пользователей.
  • Возобновлять звук после любого прерывания. Пользователь принял звонок и открыл другое приложение, а ваш плеер снова заговорил.
  • Не обрабатывать исчезновение наушников. Самый узнаваемый способ опозорить пользователя в общественном транспорте.
  • Заказывать максимальную точность геолокации по умолчанию. Списку «рядом» хватает ста метров, а разница в потреблении — кратная.
  • Опрашивать координаты по таймеру вместо геозон. Своя реализация того, что система уже делает бесплатно, но в десятки раз дороже по энергии.
  • Просить фоновую геолокацию без видимой функции. Отклонение на ревью и требование видеодемонстрации в лучшем случае.
  • Оставлять подписку на датчики после ухода с экрана. Утечка, которую не покажет профайлер памяти, но покажет системный отчёт о расходе заряда.
  • Не чистить временные медиафайлы. Три гигабайта кэша за полгода и просьбы удалить приложение ради места.

Мини-итог

Работа с датчиками — это работа на границе с физическим миром, и правила там другие. У любой возможности три цены: разрешение, энергия и декларация перед магазином, — и самое дешёвое разрешение то, которое вы не просите: системный пикер медиа и интент съёмки решают большинство задач вообще без диалогов. Разрешение — автомат состояний, а не флаг: ограниченный доступ, приблизительная точность, одноразовая выдача, автосброс и блокировка навсегда — рабочие ветки, каждая со своим экраном, а системный диалог показывается один раз за жизнь установки и потому тратится только после собственного праймера. Камера — это аренда буфера из конечного пула: удержали дольше кадра — встало превью; разрешение анализа выбирается под задачу, а не под матрицу; поворот приходит отдельно от пикселей. Звук — переговоры за единственный динамик, где приглушение, прерывание и пропажа маршрута вывода обрабатываются по-разному, а фоновое воспроизведение покупается видимым управлением. Геолокация — лестница из механизмов с разбросом потребления в два порядка, и правильный вопрос звучит как «на какое событие подписаться», а не «как часто спрашивать координаты»; фоновый доступ обосновывается функцией, которую видно на экране. Датчики движения почти бесплатны сами по себе и дороги пробуждениями процессора, поэтому сначала проверьте, не посчитала ли система за вас, а если нужны сырые данные — включайте аппаратный батчинг и отписывайтесь вместе с экраном. Всё захваченное живёт файлом на диске и уезжает через исходящую очередь, а не из памяти. И наконец: домен не должен знать про датчики — тогда девять десятых сценариев проверяются юнит-тестами, а живому устройству остаётся то, что действительно требует рук.

Источники

Что дальше

Покупки и подписки в приложении: StoreKit, Play Billing, права и восстановление — заключительная статья трека и единственная, где приложение имеет дело с чужой кассой. Разберём, почему покупка и право на функциональность живут в разных системах; StoreKit 2 и Play Billing по шагам, включая слушатель транзакций, который обязан подниматься при старте приложения; серверную валидацию и уведомления площадок; жизненный цикл подписки с грейс-периодом, удержанием и возвратом; восстановление покупок и конфликт «один чек — два аккаунта»; поведение платной функции в оффлайне; требования ревью к пейволлу — и способы всё это протестировать, не тратя настоящих денег. Там же — куда идти после трека.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов