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.



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:


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

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

    .firstElement()

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

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

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

private static final int TIMEOUT_SECONDS = 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. Пулреквесты приветствуются!

Комментарии (4)


  1. tehnolog
    09.04.2018 18:16

    Интересная статья, спасибо. Но для себя я решил, что в обычном приложении, если нужно сделать фотку, то проще вызвать стандартное приложение через Intent. Бывали случаи, когда попадались телефоны со своим драйвером под камеру и код из API просто не работал. Другое дело, если само приложение очень близко по функционалу со стандартным приложением для съемки и тогда ваш подход оправдан.
    И вопрос — а в сторону Kotlin не смотрите? Почему-то на Хабре по-прежнему много кода на Java, хотя у «буржуев» переход идёт достаточно активно.


    1. ArkadyGamza Автор
      09.04.2018 18:35

      Проще, согласен, но тогда никакого контроля над UI камеры. Нам надо было иметь кастомный UI.
      На Kotlin мы не просто смотрим, мы его активно используем! Почти весь новый код пишем на Kotlin.


  1. VladislawFox
    10.04.2018 15:05

    А как насчет видео? Было бы очень интересно посмотреть на реализацию


    1. ArkadyGamza Автор
      10.04.2018 15:12

      Я не играл с видео, но на первый взгляд его добавить несложно, достаточно использовать Surface от MediaRecorder вместо ImageReader.