Побеждаем Android Camera2 API с помощью RxJava2 (часть 2)

в 15:01, , рубрики: android, api, camera, camera2 api, java, rxjava2, Блог компании Badoo, Программирование, разработка мобильных приложений, Разработка под android

image

Это вторая часть статьи, в которой я показываю, как использование RxJava2 помогает строить логику поверх асинхронного API. В качестве такого интерфейса я выбрал Android Camera2 API (и не пожалел!). Этот API не только асинхронен, но и таит в себе неочевидные особенности реализации, которые нигде толком не описаны. Так что статья нанесет читателю двойную пользу.

Для кого этот пост? Я рассчитываю, что читатель — умудрённый опытом, но всё ещё любознательный Android-разработчик. Очень желательны базовые знания о реактивном программировании (хорошее введение — здесь) и понимание Marble Diagrams. Пост будет полезен тем, кто хочет проникнуться реактивным подходом, а также тем, кто планирует использовать Camera2 API в своих проектах.  

Исходники проекта можно найти на GitHub.

Чтение первой части обязательно!

Постановка задачи

В конце первой части я пообещал, что раскрою вопрос ожидания срабатывания автофокуса/ автоэкспозиции.

Напомню, цепочка операторов выглядела так:

Observable.combineLatest(previewObservable, mOnShutterClick, (captureSessionData, o) -> captureSessionData)
    .firstElement().toObservable()
    .flatMap(this::waitForAf)
    .flatMap(this::waitForAe)
    .flatMap(captureSessionData -> captureStillPicture(captureSessionData.session))
    .subscribe(__ -> {}, this::onError)

Итак, что же мы хотим от методов waitForAe и waitForAf? Чтобы были запущены процессы автофокусировки/ автоэкспозиции, а по их завершении мы бы получили уведомление о готовности к снимку.

Для этого нужно, чтобы оба метода возвращали Observable, который испускает событие, когда камера сообщает о том, что процесс схождения сработал (чтобы не повторять слова «автофокусировка» и «автоэкспозиция», далее я буду использовать слово «схождение»). Но как запустить и проконтролировать этот процесс?

Те самые неочевидные особенности конвейера Camera2 API

Сначала я думал, что достаточно вызвать capture c нужными флажками и дождаться в переданном CaptureCallback вызова onCaptureCompleted.

Вроде логично: запустили запрос, дождались выполнения — значит, запрос выполнен. И такой код даже ушел в продакшен.

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

Для проверки своего тезиса я добавил задержку в секунду — и снимки стали получаться! Понятно, что таким решением я не мог быть доволен, и начал искать, как на самом деле можно понять, что автофокус сработал и можно продолжать. Документации на эту тему найти не удалось, и мне пришлось обратиться к сорсам системной камеры, благо они доступны как часть Android Open Source Project. Код оказался на редкость нечитаемым и запутанным, пришлось добавлять логирование и анализировать логи камеры при съёмке в темноте. И я обнаружил, что после capture с нужными флажками системная камера вызывает setRepeatingRequest для продолжения превью и ждёт, пока в колбек не придёт onCaptureCompleted с определённым набором флагов в TotalCaptureResult. Нужный ответ мог прийти через несколько onCaptureCompleted!

Когда я осознал эту особенность, поведение Camera2 API стало казаться логичным. Но сколько потребовалось приложить усилий, чтобы найти эти сведения! Что ж, теперь можно перейти к описанию решения.

Итак, наш план действий:

  • вызов capture с флагами, запускающими процесс схождения;
  • вызов setRepeatingRequest для продолжения превью;
  • получение уведомлений от обоих методов;
  • ожидание в результатах уведомлений onCaptureCompleted свидетельств того, что процесс схождения завершён.

Поехали!

Флажки

Создадим класс ConvergeWaiter со следующими полями:

private final CaptureResult.Key<Integer> mResultStateKey;
private final List<Integer> mResultReadyStates;

Это ключ и значение флажка, который запустит необходимый процесс схождения при вызове capture.

Для автофокуса это будут CaptureRequest.CONTROL_AF_TRIGGER и CameraMetadata.CONTROL_AF_TRIGGER_START соответственно. Для автоэкспозиции — CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER и CameraMetadata.CONTROL_AE_PRECAPTURE_TRIGGER_START соответственно.

private final CaptureResult.Key<Integer> mResultStateKey;
private final List<Integer> mResultReadyStates;

А это ключ и набор ожидаемых значений флага из результата onCaptureCompleted. Когда мы увидим одно из ожидаемых значений ключа, можно считать, что процесс схождения выполнен.

Для автофокуса значение ключа CaptureResult.CONTROL_AF_STATE, список значений:
CaptureResult.CONTROL_AF_STATE_INACTIVE,
CaptureResult.CONTROL_AF_STATE_PASSIVE_FOCUSED,
CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED,
CaptureResult.CONTROL_AF_STATE_NOT_FOCUSED_LOCKED;

для автоэкспозиции значение ключа CaptureResult.CONTROL_AE_STATE, список значений:
CaptureResult.CONTROL_AE_STATE_INACTIVE,
CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED,
CaptureResult.CONTROL_AE_STATE_CONVERGED,
CaptureResult.CONTROL_AE_STATE_LOCKED.

Не спрашивайте меня, как я это выяснил! Теперь мы можем создавать инстансы ConvergeWaiter для автофокуса и экспозиции, для этого сделаем фабрику:

static class Factory {
    private static final List<Integer> afReadyStates = Collections.unmodifiableList(
        Arrays.asList(
            CaptureResult.CONTROL_AF_STATE_INACTIVE,
            CaptureResult.CONTROL_AF_STATE_PASSIVE_FOCUSED,
            CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED,
            CaptureResult.CONTROL_AF_STATE_NOT_FOCUSED_LOCKED
        )
    );

    private static final List<Integer> aeReadyStates = Collections.unmodifiableList(
        Arrays.asList(
            CaptureResult.CONTROL_AE_STATE_INACTIVE,
            CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED,
            CaptureResult.CONTROL_AE_STATE_CONVERGED,
            CaptureResult.CONTROL_AE_STATE_LOCKED
        )
    );

    static ConvergeWaiter createAutoFocusConvergeWaiter() {
        return new ConvergeWaiter(
            CaptureRequest.CONTROL_AF_TRIGGER,
            CameraMetadata.CONTROL_AF_TRIGGER_START,
            CaptureResult.CONTROL_AF_STATE,
            afReadyStates
        );
    }

    static ConvergeWaiter createAutoExposureConvergeWaiter() {
        return new ConvergeWaiter(
            CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER,
            CameraMetadata.CONTROL_AE_PRECAPTURE_TRIGGER_START,
            CaptureResult.CONTROL_AE_STATE,
            aeReadyStates
        );
    }
}

capture/setRepeatingRequest

Для вызова capture/setRepeatingRequest нам потребуются:

  • открытая ранее CameraCaptureSession, которая доступна в CaptureSessionData;
  • CaptureRequest, который мы создадим, используя CaptureRequest.Builder.

Создадим метод

Single<CaptureSessionData> waitForConverge(@NonNull CaptureSessionData captureResultParams, @NonNull CaptureRequest.Builder builder)

Во второй параметр мы будем передавать builder, настроенный для превью. Поэтому CaptureRequest для превью можно создать сразу вызовом CaptureRequest previewRequest = builder.build();

Для создания CaptureRequest для запуска процедуры схождения добавим в builder флаг, который запустит необходимый процесс схождения:

builder.set(mRequestTriggerKey, mRequestTriggerStartValue);
CaptureRequest triggerRequest = builder.build();

И воспользуемся нашими методами для получения Observable из методов capture/setRepeatingRequest:

Observable<CaptureSessionData> triggerObservable = CameraRxWrapper.fromCapture(captureResultParams.session, triggerRequest);
Observable<CaptureSessionData> previewObservable = CameraRxWrapper.fromSetRepeatingRequest(captureResultParams.session, previewRequest);

Формирование цепочки операторов

Теперь мы можем сформировать реактивный поток, в котором будут события от обоих Observable c помощью оператора merge.
Побеждаем Android Camera2 API с помощью RxJava2 (часть 2) - 2

Observable<CaptureSessionData> convergeObservable = Observable
    .merge(previewObservable, triggerObservable)

Полученный convergeObservable будет испускать события с результатами вызовов onCaptureCompleted.

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

private boolean isStateReady(@NonNull CaptureResult result) {
    Integer aeState = result.get(mResultStateKey);
    return aeState == null || mResultReadyStates.contains(aeState);
}

Проверка на null нужна для кривых реализаций Camera2 API, чтобы не зависнуть в ожидании навеки.

Теперь мы можем воспользоваться оператором filter, чтобы дождаться события, для которого выполнено isStateReady:
Побеждаем Android Camera2 API с помощью RxJava2 (часть 2) - 3

    .filter(resultParams -> isStateReady(resultParams.result))

Нам интересно только первое такое событие, поэтому добавляем

    .firstElement()

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

Single<CaptureSessionData> convergeSingle = Observable
    .merge(previewObservable, triggerObservable)
    .filter(resultParams -> isStateReady(resultParams.result))
    .first(captureResultParams);

На случай если процесс схождения затягивается слишком долго или что-то пошло не так, введём таймаут:

private static final int <i>TIMEOUT_SECONDS</i> = 3;

Single<CaptureSessionData> timeOutSingle = Single
    .just(captureResultParams)
    .delay(TIMEOUT_SECONDS, TimeUnit.SECONDS, AndroidSchedulers.mainThread());

Оператор delay переиспускает события с заданной задержкой. По умолчанию он это делает в потоке, принадлежащем computation scheduler, поэтому мы перекидываем его в Main Thread с помощью последнего параметра.

Теперь скомбинируем convergeSingle и timeOutSingle, и кто первый испустит событие — тот и победил:

return Single
    .merge(convergeSingle, timeOutSingle)
    .firstElement()
    .toSingle();

Полный код функции:

@NonNull
Single<CaptureSessionData> waitForConverge(@NonNull CaptureSessionData captureResultParams, @NonNull CaptureRequest.Builder builder) {
    CaptureRequest previewRequest = builder.build();

    builder.set(mRequestTriggerKey, mRequestTriggerStartValue);
    CaptureRequest triggerRequest = builder.build();

    Observable<CaptureSessionData> triggerObservable = CameraRxWrapper.fromCapture(captureResultParams.session, triggerRequest);
    Observable<CaptureSessionData> previewObservable = CameraRxWrapper.fromSetRepeatingRequest(captureResultParams.session, previewRequest);
    Single<CaptureSessionData> convergeSingle = Observable
        .merge(previewObservable, triggerObservable)
        .filter(resultParams -> isStateReady(resultParams.result))
        .first(captureResultParams);

    Single<CaptureSessionData> timeOutSingle = Single
        .just(captureResultParams)
        .delay(TIMEOUT_SECONDS, TimeUnit.SECONDS, AndroidSchedulers.mainThread());

    return Single
        .merge(convergeSingle, timeOutSingle)
        .firstElement()
        .toSingle();
}

waitForAf/waitForAe

Основная часть работы сделана, осталось лишь создать инстансы:

private final ConvergeWaiter mAutoFocusConvergeWaiter = ConvergeWaiter.Factory.createAutoFocusConvergeWaiter();
private final ConvergeWaiter mAutoExposureConvergeWaiter = ConvergeWaiter.Factory.createAutoExposureConvergeWaiter();

и использовать их:

private Observable<CaptureSessionData> waitForAf(@NonNull CaptureSessionData captureResultParams) {
    return Observable
        .fromCallable(() -> createPreviewBuilder(captureResultParams.session, mSurface))
        .flatMap(
            previewBuilder -> mAutoFocusConvergeWaiter
                .waitForConverge(captureResultParams, previewBuilder)
                .toObservable()
        );
}

@NonNull
private Observable<CaptureSessionData> waitForAe(@NonNull CaptureSessionData captureResultParams) {
    return Observable
        .fromCallable(() -> createPreviewBuilder(captureResultParams.session, mSurface))
        .flatMap(
            previewBuilder -> mAutoExposureConvergeWaiter
                .waitForConverge(captureResultParams, previewBuilder)
                .toObservable()
        );
}

Основной момент тут — использование оператора fromCallable. Может возникнуть соблазн использовать оператор just. Например, так:

just(createPreviewBuilder(captureResultParams.session, mSurface)).

Но в данном случае функция createPreviewBuilder будет вызвана прямо в момент вызова waitForAf, а мы хотим, чтобы она была вызвана, только когда появится подписка на наш Observable.

Заключение

Как известно, самая ценная часть любой статьи на Хабре — комментарии! Поэтому я призываю вас активно делиться своими соображениями, замечаниями, ценными знаниями и ссылками на более удачные имплементации в комментариях.

Исходники проекта можно найти на GitHub. Пулреквесты приветствуются!

Автор: ArkadyGamza

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js