company_banner

Побеждаем 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))
    

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

        .first(captureResultParams);
    

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

    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. Пулреквесты приветствуются!
    • +46
    • 6,2k
    • 5
    Badoo 323,06
    Big Dating
    Поделиться публикацией
    Похожие публикации
    Комментарии 5
    • +1
      Интересная статья, спасибо. Но для себя я решил, что в обычном приложении, если нужно сделать фотку, то проще вызвать стандартное приложение через Intent. Бывали случаи, когда попадались телефоны со своим драйвером под камеру и код из API просто не работал. Другое дело, если само приложение очень близко по функционалу со стандартным приложением для съемки и тогда ваш подход оправдан.
      И вопрос — а в сторону Kotlin не смотрите? Почему-то на Хабре по-прежнему много кода на Java, хотя у «буржуев» переход идёт достаточно активно.
      • +1
        Проще, согласен, но тогда никакого контроля над UI камеры. Нам надо было иметь кастомный UI.
        На Kotlin мы не просто смотрим, мы его активно используем! Почти весь новый код пишем на Kotlin.
      • 0
        А как насчет видео? Было бы очень интересно посмотреть на реализацию
        • 0
          Я не играл с видео, но на первый взгляд его добавить несложно, достаточно использовать Surface от MediaRecorder вместо ImageReader.
        • 0
          Отличные у Вас публикации, очень многое подчеркнул для себя по rx.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое