Shake Detector для Android на RxJava

в 14:11, , рубрики: android development, badoo, java, rxjava, Блог компании Badoo, разработка мобильных приложений, Разработка под android

Shake Detector для Android на RxJava - 1

Вступление

Началось все с того, что была поставлена задача отменять последнее действие в приложении при встряхивании устройства. Но как понять, что случилось это самое встряхивание? Через пару минут изучения вопроса стало ясно, что надо подписываться на события от акселерометра и дальше пытаться как-то определить, что устройство встряхнули.
Обнаружились и готовые решения. Все они были довольно похожи, но в чистом виде они меня не устраивали, и я написал собственный «велосипед». Это был класс, который подписывался на события от сенсора и менял свое состояние по мере их поступления. Потом пару раз я и мои коллеги подкручивали шестеренки этого велосипеда, и в результате он стал напоминать нечто из «Безумного Макса». Я пообещал, что, как выдастся свободное время, приведу это безобразие в порядок.

И вот, читая недавно статьи по RxJava, я вспомнил про эту задачу. «Хм, — подумал я, — RxJava выглядит очень подходящим инструментом для такого рода проблем». Не откладывая в долгий ящик, взял и написал решение на RxJava. Результат меня поразил: вся логика заняла 8 (восемь!) строк! Я решил поделиться своим опытом с другим разработчикам. Так появилась на свет эта статья.

Надеюсь, этот простой пример поможет принять решение тем, кто размышляет о применении RxJava в своих проектах.

Статья ориентирована на читателей, имеющих базовый опыт разработки под Android. Исходный код готового приложения можно посмотреть на GitHub.

Приступим!

Настройка проекта

Подключаем RxJava к проекту

Чтобы подключить RxJava, достаточно добавить в build.gradle

dependencies {
    ...
    compile 'io.reactivex:rxjava:1.1.3'
    compile 'io.reactivex:rxandroid:1.1.0'
}

Примечание: RxAndroid дает нам Scheduler, который привязан к UI-потоку.

Включаем поддержку лямбд

RxJava лучше всего использовать с лямбдами, без них код становится трудночитаемым. На данный момент есть два варианта включить поддержку лямбд в Android проекте: использовать компилятор Jack из Android N Developer Preview или использовать библиотеку Retrolambda.
В обоих случаях надо прежде всего убедиться, что установлен JDK 8. Лично я использовал Retrolambda.

Android N Developer Preview

Для того чтобы использовать Jack из Android N Developer Preview, следуем инструкциям отсюда

Добавляем в build.gradle строки:

android {
  ...
  defaultConfig {
    ...
    jackOptions {
      enabled true
    }
  }
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }
}

Retrolambda

Для подключения retrolambda следуем инструкциям от Эвана Татарки (англ. Evan Tatarka):

buildscript {
  ...
  dependencies {
     classpath 'me.tatarka:gradle-retrolambda:3.2.5'
  }
}

apply plugin: 'com.android.application' 
apply plugin: 'me.tatarka.retrolambda'

android {
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }
}

Обратите внимание, в оригинальных инструкциях рекомендуется подключить репозиторий Maven Central. В вашем проекте, скорее всего, уже используется jcenter, поскольку именно этот репозиторий указывается по умолчанию при создании проекта в Android Studio. Он уже содержит в себе необходимые нам зависимости, дополнительно подключать Maven Central не требуется.

Observable

Итак, у нас в проекте подключены все необходимые инструменты, можно начинать разработку.

При использовании RxJava все начинается с получения Observable.
Напишем фабрику, которая создает Observable, подписанный на события переданного сенсора с помощью метода Observable.create:

public class SensorEventObservableFactory {
   public static Observable<SensorEvent> createSensorEventObservable(@NonNull Sensor sensor, @NonNull SensorManager sensorManager) {
       return Observable.create(subscriber -> {
           MainThreadSubscription.verifyMainThread();

           SensorEventListener listener = new SensorEventListener() {
               @Override
               public void onSensorChanged(SensorEvent event) {
                   if (subscriber.isUnsubscribed()) {
                       return;
                   }

                   subscriber.onNext(event);
               }

               @Override
               public void onAccuracyChanged(Sensor sensor, int accuracy) {
                   // NO-OP
               }
           };

           sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME);

           // unregister listener in main thread when being unsubscribed
           subscriber.add(new MainThreadSubscription() {
               @Override
               protected void onUnsubscribe() {
                   sensorManager.unregisterListener(listener);
               }
           });
       });
   }
}

Теперь у нас есть инструмент для преобразования событий от любого сенсора в Observable. Но какой сенсор подходит лучше всего для наших целей? На скриншоте ниже первый график отображает показания сенсора TYPE_GRAVITY, второй график — TYPE_ACCELEROMETER, третий — TYPE_LINEAR_ACCELERATION. Видно, что сначала устройство плавно повернули, а затем резко встряхнули.

Shake Detector для Android на RxJava - 2

Нас интересуют события сенсора с типом Sensor.TYPE_LINEAR_ACCELERATION. Он содержит значения ускорения, из которых уже была вычтена составляющая земной гравитации.

Любопытно, что многие решения используют Sensor.TYPE_ACCELEROMETER и применяют low pass фильтрацию для того, чтобы убрать гравитационную составляющую. Если вы догадываетесь почему — прошу поделиться знанием в комментариях.

@NonNull
private static Observable<SensorEvent> createAccelerationObservable(@NonNull Context context) {
   SensorManager mSensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
   List<Sensor> sensorList = mSensorManager.getSensorList(Sensor.TYPE_LINEAR_ACCELERATION);
   if (sensorList == null || sensorList.isEmpty()) {
       throw new IllegalStateException("Device has no linear acceleration sensor");
   }

   return SensorEventObservableFactory.createSensorEventObservable(sensorList.get(0), mSensorManager);
}

Реактивная магия

Теперь, когда у нас есть Observable с событиями от акселерометра, мы можем использовать всю мощь RxJava операторов.

Давайте посмотрим, как выглядит «сырая» последовательность событий:

createAccelerationObservable(context)
   .subscribe(event -> Log.d(TAG, formatTime(event) + " " + Arrays.toString(event.values)));


29.398 [0.0016835928, 0.014868498, 0.0038280487]
29.418 [-0.026405454, -0.017675579, 0.024353027]
29.438 [-0.032944083, -0.0029007196, 0.011956215]
29.458 [0.03226435, 0.022876084, 0.032211304]
29.478 [-0.0011371374, 0.022291958, -0.054023743]

Видим, что каждые 20 миллисекунд прилетает событие от датчика. Эта частота соответствует значению SensorManager.SENSOR_DELAY_GAME, которое было передано в качестве параметра samplingPeriodUs при регистрации SensorEventListener.

В качестве полезной нагрузки приходит значение ускорения по всем трем осям.
Нас интересуют только значения по оси X. Они соответствуют тому движению, которое мы хотим отслеживать. Некоторые решения используют значения ускорения по всем трем осям, поэтому срабатывают, например, когда устройство кладут на стол (значительное ускорение по оси Z при контакте со столом).
Создадим класс данных с интересующими нас значениями:

private static class XEvent {
   public final long timestamp;
   public final float x;

   private XEvent(long timestamp, float x) {
       this.timestamp = timestamp;
       this.x = x;
   }
}

Конвертируем SensorEvent в XEvent и фильтруем события, у которых величина ускорения по модулю превышает определенный порог:

createAccelerationObservable(context)
   .map(sensorEvent -> new XEvent(sensorEvent.timestamp, sensorEvent.values[0]))
   .filter(xEvent -> Math.abs(xEvent.x) > THRESHOLD)
   .subscribe(xEvent -> Log.d(TAG, formatMsg(xEvent)));

Чтобы увидеть события в логе, придется впервые потрясти устройство.

Вообще довольно забавно выглядит процесс отладки Shake Detection со стороны: сидит разработчик и все время трясет телефон. Не знаю, что при этом думают окружающие :)

55.347 19.030302
55.367 13.084376
55.388 -15.775546
55.408 -14.443999

В логе остались только события со значительным ускорением по оси X.

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

createAccelerationObservable(context)
           .map(sensorEvent -> new XEvent(sensorEvent.timestamp, sensorEvent.values[0]))
           .filter(xEvent -> Math.abs(xEvent.x) > THRESHOLD)
           .buffer(2, 1)
           .subscribe(buf -> Log.d(TAG, getLogMsg(buf)));

Смотрим лог:

[43.977 -15.497713; 44.017 21.000145]
[44.017 21.000145; 44.037 19.947767]
[44.037 19.947767; 44.057 19.836182]
[44.057 19.836182; 44.077 20.659754]
[44.077 20.659754; 44.098 -16.811298]
[44.098 -16.811298; 44.118 -15.6345

Отлично! Мы видим, что каждое событие сгруппировано с предыдущим, теперь легко можно отфильтровать пары событий с разными знаками ускорения:

 createAccelerationObservable(context)
           .map(sensorEvent -> new XEvent(sensorEvent.timestamp, sensorEvent.values[0]))
           .filter(xEvent -> Math.abs(xEvent.x) > THRESHOLD)
           .buffer(2, 1)
           .filter(buf -> buf.get(0).x * buf.get(1).x < 0)
           .subscribe(buf -> Log.d(TAG, getLogMsg(buf)));
[53.888 -16.762777; 53.928 20.83315]
[53.988 19.87952; 54.028 -16.735554]
[54.089 -16.46596; 54.109 21.682497]
[54.169 20.355597; 54.209 -16.634022]
[54.269 -16.122211; 54.309 21.806463]

Каждое событие теперь соответствует одному взмаху. Всего 4 оператора, и мы уже способны отслеживать резкие движения! Но не будем останавливаться, ведь если детектор будет срабатывать по одному взмаху, то возможны ложные срабатывания. Например, пользователь не собирался трясти устройство, а просто переложил его в другую руку. Решение простое — надо заставить пользователя встряхнуть устройство несколько раз в течение короткого отрезка времени. Вводим параметры SHAKES_COUNT = количество взмахов и SHAKES_PERIOD = интервал времени, за который надо успеть сделать необходимое количество взмахов. Экспериментальным путем выяснилось, что комфортные параметры составляют 3 взмаха за 1 секунду. Иначе возможны случайные срабатывания, либо приходится совсем уж яростно сотрясать телефон.

Итак, мы хотим отследить момент, когда за одну секунду произошло 3 взмаха.
Заметим, что нам больше не нужны значения ускорения, оставим только время возникновения события, заодно переведем время из наносекунд в секунды:

.map(buf -> buf.get(1).timestamp / 1000000000f)

Затем применим уже знакомый прием со скользящим окном. Для каждого события мы будем возвращать массив, содержащий это событие и два предыдущих:

.buffer(SHAKES_COUNT, 1)

И, наконец, оставим только те тройки событий, которые уложились в 1 секунду:

.filter(buf -> buf.get(SHAKES_COUNT - 1) - buf.get(0) < SHAKES_PERIOD)

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

.throttleFirst(SHAKES_PERIOD, TimeUnit.SECONDS)

Готово! Теперь полученный Observable можно использовать там, где мы хотим ждать события встряхивания.

Вот итоговый код для создания Observable:

public class ShakeDetector {

   public static final int THRESHOLD = 13;
   public static final int SHAKES_COUNT = 3;
   public static final int SHAKES_PERIOD = 1;

   @NonNull
   public static Observable<?> create(@NonNull Context context) {
       return createAccelerationObservable(context)
           .map(sensorEvent -> new XEvent(sensorEvent.timestamp, sensorEvent.values[0]))
           .filter(xEvent -> Math.abs(xEvent.x) > THRESHOLD)
           .buffer(2, 1)
           .filter(buf -> buf.get(0).x * buf.get(1).x < 0)
           .map(buf -> buf.get(1).timestamp / 1000000000f)
           .buffer(SHAKES_COUNT, 1)
           .filter(buf -> buf.get(SHAKES_COUNT - 1) - buf.get(0) < SHAKES_PERIOD)
           .throttleFirst(SHAKES_PERIOD, TimeUnit.SECONDS);
   }

 @NonNull
   private static Observable<SensorEvent> createAccelerationObservable(@NonNull Context context) {
       SensorManager mSensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
       List<Sensor> sensorList = mSensorManager.getSensorList(Sensor.TYPE_LINEAR_ACCELERATION);
       if (sensorList == null || sensorList.isEmpty()) {
           throw new IllegalStateException("Device has no linear acceleration sensor");
       }

       return SensorEventObservableFactory.createSensorEventObservable(sensorList.get(0), mSensorManager);
   }

   private static class XEvent {
       public final long timestamp;
       public final float x;

       private XEvent(long timestamp, float x) {
           this.timestamp = timestamp;
           this.x = x;
       }
   }
}

Использование

В примере я воспроизвожу звук при наступлении события.
В Activity, где мы хотим слушать встряхивания, добавим поле:

private Observable<?> mShakeObservable;

Инициализируем его в onCreate:

@Override
protected void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   setContentView(R.layout.activity_main);
   mShakeObservable = ShakeDetector.create(this);
}

Подпишемся на события в onResume:

@Override
protected void onResume() {
   super.onResume();
   mShakeSubscription = mShakeObservable.subscribe((object) -> Utils.beep());
}

И не забудем отписаться в onPause:

@Override
protected void onPause() {
   super.onPause();
   mShakeSubscription.unsubscribe();
}

Вывод

Как видно, мы смогли в нескольких строках написать решение, которое надежно определяет заданный нами жест. Код получился компактный, его легко читать и поддерживать. Сравните с решением без применения RxJava, скажем, Seismic от Джейка Уортона (англ. Jake Wharton). RxJava — прекрасный инструмент, и если его применять умело и по делу, то можно получить отличные результаты. Надеюсь, что эта статья подтолкнет вас к изучению RxJava и применению в своих проектах подходов реактивного программирования.

Да пребудет с вами stackoverflow.com!

Аркадий Гамза, Android Developer.

Автор: Badoo

Источник

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


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