Побеждаем Android Camera2 API с помощью RxJava2 (часть 2)
Это вторая часть статьи, в которой я показываю, как использование 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 mResultStateKey;
private final List 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 mResultStateKey;
private final List 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 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 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 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 triggerObservable = CameraRxWrapper.fromCapture(captureResultParams.session, triggerRequest);
Observable previewObservable = CameraRxWrapper.fromSetRepeatingRequest(captureResultParams.session, previewRequest);
Формирование цепочки операторов
Теперь мы можем сформировать реактивный поток, в котором будут события от обоих Observable c помощью оператора merge
.
Observable 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 convergeSingle = Observable
.merge(previewObservable, triggerObservable)
.filter(resultParams -> isStateReady(resultParams.result))
.first(captureResultParams);
На случай если процесс схождения затягивается слишком долго или что-то пошло не так, введём таймаут:
private static final int TIMEOUT_SECONDS = 3;
Single 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 waitForConverge(@NonNull CaptureSessionData captureResultParams, @NonNull CaptureRequest.Builder builder) {
CaptureRequest previewRequest = builder.build();
builder.set(mRequestTriggerKey, mRequestTriggerStartValue);
CaptureRequest triggerRequest = builder.build();
Observable triggerObservable = CameraRxWrapper.fromCapture(captureResultParams.session, triggerRequest);
Observable previewObservable = CameraRxWrapper.fromSetRepeatingRequest(captureResultParams.session, previewRequest);
Single convergeSingle = Observable
.merge(previewObservable, triggerObservable)
.filter(resultParams -> isStateReady(resultParams.result))
.first(captureResultParams);
Single 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 waitForAf(@NonNull CaptureSessionData captureResultParams) {
return Observable
.fromCallable(() -> createPreviewBuilder(captureResultParams.session, mSurface))
.flatMap(
previewBuilder -> mAutoFocusConvergeWaiter
.waitForConverge(captureResultParams, previewBuilder)
.toObservable()
);
}
@NonNull
private Observable 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. Пулреквесты приветствуются!