Хабрахабр

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

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

Теги
Показать больше

Похожие статьи

Кнопка «Наверх»
Закрыть