Разработка мобильных приложений (11)


Настройка CI в GitLab для Android проекта

CI — continuous integration — можно описать как практику автоматизированного внедрения результатов работы программиста по целевому месту назначения и использования (деплой). CI имеет множество преимуществ, и особенностей, среди которых отметим следующие:

  • Единая кодовая база, в которую сливаются ветки разработчиков — достигается с помощью GitFlow
  • Автоматизация сборок — сборка запускается автоматически после вливания в нее нового кода с помощью pipeline в GitLab
  • Автоматизация тестирования — как один из шагов в pipeline-e
  • Удобный доступ к сборкам
  • Автоматизированный деплой — дополнительный скрипт может публиковать сборки в магазинах приложений или по пути дистрибуции.

CI как и любая автоматизация позволяет устранить человеческий фактор и автоматизировать повторяющиеся действия.
Поэтому CI желательно настраивать на любом серьезном проекте. А в случае, когда разработчиков несколько и релизы необходимо собирать часто, CI должен быть настроен в обязательном порядке.
Обычно настройка CI лежит в зоне ответственности DevOps инженера. Однако, хотя в большинстве компаний такой человек есть, он может быть недоступен или не уметь настраивать CI именно для Android проекта. Поэтому Android-разработчику необходимо уметь делать это самостоятельно. Как это делается, расскажу и покажу в этой статьте.

Итак, приступим.
С точки зрения GitLab, CI состоит из

  • Jobs, которые описывают что сделать. например, собрать код или протестировать
  • Stages, которые определяют когда стартовать Job-ы, например, стадия тестирования

Обычно stages бывают
— buiild job под названием compile
— test — запуск тестов
— staging — деплой на stage
— production -деплой на prod

Всю эту информацию о jobs и stages нам нужно передать GitLab-у. Сделаем мы это с помощью файла .gitlab-ci, который положим в корне проекта. В нем будут описаны все шаги, а также выполнятся необходимые скрипты.

Во-первых укажем образ, который будет выполнять Job

image: openjdk:8-jdk

Затем в разделе под названием before_script напишем скрипты, которые сервер должен выполнить, чтобы настроить сборочное окружение.

before_script:
 - apt-get --quiet update --yes
 - apt-get --quiet install --yes wget tar unzip lib32stdc++6 lib32z1
 - wget --quiet --output-document=android-sdk.zip https://dl.google.com/android/repository/sdk-tools-linux-${ANDROID_SDK_TOOLS}.zip
 - unzip -d android-sdk-linux android-sdk.zip
 - echo y | android-sdk-linux/tools/bin/sdkmanager "platforms;android-${ANDROID_COMPILE_SDK}" >/dev/null
 - echo y | android-sdk-linux/tools/bin/sdkmanager "platform-tools" >/dev/null
 - echo y | android-sdk-linux/tools/bin/sdkmanager "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
 - export ANDROID_HOME=$PWD/android-sdk-linux
 - export PATH=$PATH:$PWD/android-sdk-linux/platform-tools/

Никакой магии здесь нет, и, если вы знакомы unix-системами, и их командами, то вам все должно быть понятно
Не забудьте указать номера версий SDK, которые сервер должен установить. Для этого перед блоком before_script добавьте

variables:
 ANDROID_COMPILE_SDK: [номер_версии]
 ANDROID_BUILD_TOOLS: [номер_версии]
 ANDROID_SDK_TOOLS: [номер_версии]

Далее, указываем типы этапов сборки в блоке stages

stages:
 - build
 - test
 - deploy

Теперь мы готовы к выполнению собственно сборок.
Я рекомендую использовать два типа сборок — релизную и дебажную. Вы можете сделать столько вариантов, сколько вам угодня, исходя из ваших задач
Итак, для начала пропишем дебажную сборку

buildDebug:
 stage: build
 tags: [android]
 only:
 - develop
 - master
 - /^release.*$/
 cache:
 paths:
 - .gradle/caches
 variables:
 VAR_NAME: BUILD_NUMBER // автоинкрементируемый номер сборки
 TOKEN: ${CI_PIPELINE_IID_TOKEN}

Тег only позволяет триггерить запуск сборки в случае пуша в конкретнкю ветку или по тегу, как мы увидим далее
Переменная BUILD_NUMBER будет считываться с помощью токена CI_PIPELINE_IID_TOKEN из настроек Variables в разделе CI в GitLab. Она позволяет нам добавлять автоинкрементируемый номер сборки в название версии. Подробнее этот подход описан здесь: https://gitlab.com/jmarcos.cano/CI_PIPELINE_IID/snippets/1675434. А ниже я покажу как настроить build.gradle для генерирования названия версии и номера сборки. А пока продолжим с .gitlab-ci
Следующий блок будет script

script:
 # готовим переменную с номером сборки
 - GITLAB_URL=$(echo ${CI_PROJECT_URL} |awk -F "/" '{print $1 "//" $2$3}')
 - "VAR=$(curl -s -f --header \"PRIVATE-TOKEN: ${TOKEN}\" \"${GITLAB_URL}/api/v4/projects/${CI_PROJECT_ID}/variables/${VAR_NAME}\" | jq -r '.value' ) "
 - let VAR=VAR+1
 - "curl -s -f --request PUT --header \"PRIVATE-TOKEN: ${TOKEN}\" \"${GITLAB_URL}/api/v4/projects/${CI_PROJECT_ID}/variables/${VAR_NAME}\" --form \"value=${VAR}\" "
 # записываем переменную во временный файл, из которого будет считывать этот номер в build.gradle
 - echo $VAR > build.version
 - chmod +wx build.version
 - sed -i 's/android.enableBuildCache=false/android.enableBuildCache=true/g' gradle.properties
 # и собственно собираем сборку
 - ./gradlew clean app:assembleDebug

Теперь остается только указать как и где хранить артефакты, то есть apk:

artifacts:
 when: always
 expire_in: 4 weeks
 paths:
 - app/build/outputs/apk/

Вот и все, а для релизной сборки укажем такой же блок, но stage и него будет deploy, а сборка будет собираться еще и с app:assembleRelease, и собираться такая сборка будет только при создании тэга в GitLab.

Дополнительно нам нужно настроить автоматическое инкрементирование номеров сборок
Для этого в Ваш build.gradle добавьте функции, которые будут генерировать versionCode и versionName на основе минимальной версии target api

private Integer generateVersionCode() {
 def minSDK = rootProject.minSdkVersion * 1000000
 def major = rootProject.versionMajor * 10000
 def minor = rootProject.versionMinor * 100
 def patch = rootProject.versionPatch
 def versionCode = minSDK + major + minor + patch
 project.logger.debug('versonCode ', versionCode)
 return versionCode
 }

private String generateVersionName() {
 String versionName = "${rootProject.versionMajor}.${rootProject.versionMinor}.${rootProject.versionPatch}"
 versionName += '.' + getBuildNumberFromFile()
 return versionName
 }

Естественно, в корневом build.gradle у вас должны быть прописаны номера версии в виде

versionMajor = 1
 versionMinor = 0
 versionPatch = 0
 minSdkVersion = 23

Также добавьте функции для получения номера сборки из CI GitLab, а в случае локальной сборки — из гита

def getBuildNumberFromGit() {

try {
 def stdout = new ByteArrayOutputStream()
 exec {
 commandLine 'git', 'rev-list', '--all', '--count'
 standardOutput = stdout
 }
 return stdout.toString().trim()
 }
 catch (ignored) {
 return '?'
 }
 }

// Этот метод получает номер сборки из специального файла, который мы генерировали выше в скрипте .gitlab-ci
def getBuildNumberFromFile() {

File versionFile = file('../build.version')
 if (versionFile.exists()) {
 return versionFile.readLines().get(0).trim()
 } else {
 return getBuildNumberFromGit()
 }
 }

Остается вызывать этот метод для названия версии, а также для номера сборки

defaultConfig {
 applicationId "ru.andreyaleev"
 minSdkVersion rootProject.minSdkVersion
 targetSdkVersion rootProject.targetSdkVersion
 versionName generateVersionName()
 }

buildTypes {

applicationVariants.all { variant ->
 variant.outputs.each { output ->
 output.versionCodeOverride = generateVersionCode()
 output.outputFileName = "$setup.applicationId-${variant.versionName}.apk"
 }
 }
 ...

Готово с кодом, теперь нужно добавить переменную в GitLab, идея взята отсюда

Заходим в Settings->CI/CD->Variables, далее добавляем две переменные BUILD_NUMBER и CI_PIPELIBE_ID_TOKEB


BUILD_NUMBER присваиваем 1, а в CI_PIPELIBE_ID_TOKEB положим значение токена, который нужно сгенерировать через раздел Access Tokens профиля

Остается запушить изменения в GitLab, и он автоматически будет стартовать Pipeline
Для создания релизной сборки, которую мы настроили выше, нужно создать Тэг через Repository->Tags-> New Tag
Выбираете ветку, из которой хотите создать сборку и запускаете. Все готово!




RxJava. Обработка ошибок от нескольких Observable

Предположим, нам надо выполнить параллельно несколько запросов на сервер с помощью, RxJava и Retrofit.
Скорее всего вы будете использовать такие объединяющие операторы: как combineLatest или zip. В итоге у вас получится
примерно такая цепочка:

    val firstObservable = ServerApi().firstRequest( requestParams1 )
    val secondObservable = ServerApi().secondRequest( requestParams2 )
    val thirdObservable = ServerApi().thirdRequest( requestParams3 )

    // объединяем Observable-ы в один с помощью combineLatest
    Observable.combineLatest(
        firstObservable,
        secondObservable,
        thirdObservable,
        Function3<FirstResponse, SecondResponse, ThirdResponse, CombinedResult> { firstResponse, secondResponse, thirdResponse ->
            // объединяем и создаем результат для Function3
            CombinedResult( firstResponse, secondResponse, thirdResponse )
        }
    )
    .flatMap { combinedResult ->
        // здесь выполняем необходимые операции в случае необходимости
        Observable.just(combinedResult) // return original observable unchanged
    }
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe({ combinedResult ->
        // end of request
        // show result
    }, {
       it.printStackTrace()
    })

В этой цепочке есть проблема. Если один из Observable-ов выбросит HttpException или любой другой Throwable, то вся цепочка прекратит выполнение в onError, и мы потеряем успешные данные от других запросов.
Как избежать этого? Все что нам нужно — это корректно обрабатывать ошибки от каждого из Observable-ов.
Для этого есть удобный оператор onErrorReturn() — он возвращает новый Observable в случае ошибки, без прекращения выполнения цепочки.
добавьте его вызов после каждого параметра (эммитера) в операторе combineLatest следующим образом:

    Observable.combineLatest(
        firstObservable
            .onErrorReturn { responseFromException(it) },
        secondObservable
            .onErrorReturn { responseFromException(it) },
    ....

onErrorReturn — это удобный оператор, который эмитит новый объект вместо того, чтобы ломать цепочку в случае возникновения Exception-а

Для создания новых объектов, которые мы будем эмитить, напишите специальный метод или фабрику на дженериках, подходящую под ваши бизнес-требования:

    ...
    // FirstResponse, SecondResponse and ThirdResponse should inherit from BaseResponse
    fun <T : Any> responseFromException(throwable: Throwable): BaseResponse<T> {
        var errorResponse: BaseResponse<T> = BaseResponse()
        if (throwable is HttpException) {
            errorResponse.code = it.code()
            errorResponse.message = it.message()
        }
      return errorResponse
     }

Вот и все. С помощью одной строчки для каждого из Observable-ов мы сделали нашу цепочку устойчивой к ошибкам.




Flutter: Stateful и Stateless виджеты

Поскольку Flutter декларативный, пользовательский интерфейс строится как некоторая функция от состояния

UI = f(state)

Другими словами, UI наблюдает за State-ом.
Будучи Android Java и Kotlin разработчиком, я привык писать все в императивном стиле. Например,

textView.setText("Lorem")

или, на Kotlin

 textView.text="Lorem"

С другой стороны, во Flutter, если вы хотите поменять UI, сначала Вам нужно поменять State

State может быть определено как “whatever data you need in order to rebuild your UI at any moment in time”

Stateful и stateless виджеты

Вкратце, Stateful — это аналог var, а stateless — это аналог val переменных в Koltin.
Нельзя поменять состояние StatelessWidget. Его следует использовать в случае, когда UI элемент создается единожды и его состояние не будет меняться. Например, статичный текст, иконки, изображения и т.п..

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Stateless Widget',
        home: Scaffold(
          appBar: AppBar(
            title: Text('Appbar title'),
          ),
        body: Center(
          child: Text('static content'),
        ),
      ),
    );
  }
}

Stateful виджеты могут меняться в рантайме. Это может быть поле для ввода текста, слайдер, чекбокс и т.п.
Состояние widget’s храниться в объекте State. Когда состояние виджета меняется, этот объект вызывает setState(), сообщая фреймворку о необходимости перерисовать виджет.

Чтобы объявить StatefulWidget необходимо унаследовать StatefulWidget, а также создать State для него

class MyWidget extends StatefulWidget {
  @override
  _MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  int _count = 42;
  // ···
}

Ephemeral state и App state

Начнем с Application state. Этот тип состояния используется когда есть необходимость шарить данные между компонентами приложения: экранами, виджетами или даже сессиями. В этом типе данным вы вероятно захотите хранить, например, пользовательские настройки, корзину покупателя или список элементов, загруженный с сервера.

Ephemeral state — временное состояние — состояние вью, которое может поменяться. Например, текущая страница в PageView, текущий прогресс в ProgressBar или выбранная radioButton в radioGroup




Наследование зависимостей в Kodein

При внедрении зависимостей с Kodein в Activity, Fragment или т.п., мы переопределяем инстанс kodein. Важно иметь ввиду, что умолчанию наследования объявленных на родительском уровне объектов не происходит.
Если вы хотите получить доступ к этим объектам, вы можете использовать метод extend

Пример

Предположим, у меня есть инстанс настроек, объявленный на уровне приложения

class App : Application(), KodeinAware {

    override val kodein = Kodein {
        import(androidXContextTranslators)
        bind<ISettings>() with singleton { Settings(this@App) }
    }

Далее в активити мы его можем получить следующим образом:

class MainActivity : Activity(), KodeinAware {

    private val appKodein by kodein(App.instance)

    override val kodein = Kodein {
        // или так: 
        // extend(appApplication.appKodein(), allowOverride = true)
        extend(appKodein)
        // после строчки выше мы наследуем все от уровня App
        //...
    }

    private val settings: ISettings by instance() // корректная инициализация зависимости



Dependency Injection с Kodein

Если вы пишите Android-приложение на Kotlin и хотите использовать инъекцию зависимостей, взгляните на платформу Kodein. Она создана специально для Android и Kotlin. Не буду глубоко погружаться в паттерн dependency injection в этом посте. Если вы с ним не знакомы, рекомендую загуглить Dagger 2 для ознакомления.
Сегодня мы вместе с Вами взглянем на Kodein в действии на примере. Предположим, мы хотим занижектить singleton инстанс retrofit-а в каком-либо месте нашего приложения.

Сначала, добавим Kodein зависимости в gradle файл:

 implementation "org.kodein.di:kodein-di-generic-jvm:6.1.0"
 implementation "org.kodein.di:kodein-di-framework-android-core:6.1.0"
 implementation "org.kodein.di:kodein-di-framework-android-x:6.1.0"

Имплементим интерфейс KodeinAware в классе приложения Application. Будет необходимо переопределить kodein val и в нем инициализировать инстанс Retrofit-а:

class App : Application(), KodeinAware {

    override val kodein = Kodein {
        import(androidXContextTranslators)
        bind<Retrofit>() with singleton {
            Retrofit.Builder()
                .client(OkHttpClient().newBuilder().build())
                .baseUrl("htttp://example.com")
                .addCallAdapterFactory(CoroutineCallAdapterFactory())
                .build()
        }
    }
}

Все зависимости внутри блока Kodein формируются по следующему паттерну:

 bind<TYPE>() with

далее идет метод создания и использования объекта: «singleton», «provides» or «factory»
«singleton» говорит сам за себя, «provides» каждый раз генерит новый объект. Если нужен кастомный объект, тогда используйте фабрику: «factory«; как и «provides» он создает каждый раз новый объект, но с конструктором.

Теперь, когда мы объявили зависимости, давайте использовать их!
Заинжектим наш инстанс Retrofit-а в Activity/Fragment или, если вы используете MVP паттерн, то в presenter. Разница в наличии контекста: в презентере его нет по умолчанию. Попробуем сначала внедрить Retrofit в Activity.

И снова заимплементим интерфейс KodeinAware и получим инстанс:

class MainActivity : AppCompatActivity, KodeinAware{

    override val kodein: Kodein by kodein()

    private val retrofit: Retrofit by instance()
    ...
    // use retrofit

А теперь, если мы хотим использовать ретрофит в классе без Android контекста,
то нам нужно всего лишь передать туда Context для того, чтобы создать инстанс Kodein. Для этого я использовал applicationContext.
Добавьте следующие строки в класс Application:

    ...
    override fun onCreate() {
        super.onCreate()
        instance = this
    }
    
    companion object {
        lateinit var instance: App
            private set
    }
    ...

И теперь в презентере:

class MyPresenterImpl : IMyPresenter, KodeinAware {

    override val kodein by kodein(App.instance)

    override val kodeinContext = kcontext(App.instance)

    private val retrofit: Retrofit by instance()

    ...
    // use retrofit

Ссылка на официальную документацию




Получаем данные из Room с помощью RxJava

Привет! Сегодня расскажу как читать данные из sqlite базы с помощью Room и RxJava

Перед началом добавьте необходимые зависимости в build.gradle:

// Room components
implementation "android.arch.persistence.room:runtime:$rootProject.roomVersion"
implementation "android.arch.persistence.room:rxjava2:$rootProject.roomVersion"
annotationProcessor "android.arch.persistence.room:compiler:$rootProject.roomVersion"
androidTestImplementation "android.arch.persistence.room:testing:$rootProject.roomVersion"

// Lifecycle components
implementation "android.arch.lifecycle:extensions:$rootProject.archLifecycleVersion"
implementation "android.arch.lifecycle:reactivestreams:$rootProject.archLifecycleVersion"
annotationProcessor "android.arch.lifecycle:compiler:$rootProject.archLifecycleVersion"

// Rx
implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
implementation 'io.reactivex.rxjava2:rxjava:2.2.1'

Теперь можем взять базу из assets и определить класс Dao для данных:

@Database(entities = {Verse.class}, version = 1, exportSchema = false)
public abstract class MyRoomDatabase extends RoomDatabase {
    public abstract MyDataDao myDataDao();

    private static MyRoomDatabase INSTANCE;

    static MyRoomDatabase getDatabase(final Context context) {
        if (INSTANCE == null) {
            synchronized (MyRoomDatabase.class) {
                if (INSTANCE == null) {
                    INSTANCE = Room.databaseBuilder(context.getApplicationContext(),
                            MyRoomDatabase.class, "data.sqlite3") // get db from assets
                            .openHelperFactory(new AssetSQLiteOpenHelperFactory())
                            .build();

                }
            }
        }
        return INSTANCE;
    }

    public void destroyInstance() {
        synchronized (MyRoomDatabase.class) {
            INSTANCE = null;
        }
    }
}

@Dao
public interface MyDataDao {

    @Query("SELECT * from verses ORDER BY RANDOM() LIMIT 1")
    LiveData<Verse> getRandomVerse();

    @Query("SELECT * from verses ORDER BY RANDOM() LIMIT 1")
        // same request
    Flowable<Verse> getRxRandomVerse();
}

Мне нравится как Room позволяет удобно получать Flowable и LiveData прямо из коробки.
Все что нужно — это предоставить запрос в аннотации.
Далее мы объявляем класс репозитория следующим образом:

class MyRepository(context: Context?) {

    private val mDataDao: MyDataDao
    private val db: MyRoomDatabase? = MyRoomDatabase.getDatabase(context)

    init {
        mDataDao = db!!.myDataDao()
    }

    fun getRxRandomVerse(): Flowable<Verse> {
        return mDataDao.getRxRandomVerse
    }

    fun getDb(context: Context?): MyRoomDatabase? {
        return db ?: MyRoomDatabase.getDatabase(context)
    }

    fun closeDb() {
        db?.close()
    }
}

Наконец, получаем данные в презентере и активити:

class MainActivityPresenter {

    private var mRepository: MyRepository? = null

    fun attach(mainActivityView: MainActivityView, context: Context) {
        this.mainActivityView = mainActivityView
        this.mRepository = MyRepository(context)
    }

    fun getRandomVerse() {
        mainActivityView!!.showProgressBar()
        mRepository.getRxRandomVerse()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe({ verse ->
                    mainActivityView!!.hideProgressBar()
                    updateUI(verse?.toString())
                }, { error ->
                    mainActivityView!!.hideProgressBar()
                    error.printStackTrace()
                })
    }
}

class MainActivity : MainActivityPresenter.MainActivityView {

    ...

    override fun updateUI(verse: String) {
        textViewVerse.text = verse
    }

    ...
}

Вот и все в общем-то. Достаточно просто и удобно. Работу этого кода в действии вы можете в моем новом приложении генератора случайных цитат священного писания.
Если у Вас есть вопросы и комментарии, пишите.




Адаптируем Android приложение под Wear OS

В марте 2018 Google произвела ребрендинг платформы Android Wear. Новое название — Wear OS. По статистике, на данный момент в мире умных часов лидирует watchOS от Apple, занимая 16,2 %, на долю Wear OS приходится меньше 7%, Однако, рынок умных часов еще новый и определенно следует ждать увеличения пользователей. Это значит, мы можем писать приложения, а также watchface для носимых гаджетов.
Рассмотрим пример создания модуля Wear OS для существующего приложения. У меня как раз есть подходящее — все тот же секундомер бодибилдера.

Создание модуля

Итак, создаем новый модуль в нашем проекте и выбираем wearable app

В build.gradle созданного модуля добавляем зависимости:

dependencies {
    implementation 'com.android.support:wear:27.1.1'
    implementation 'com.google.android.support:wearable:2.3.0'
    compileOnly 'com.google.android.wearable:wearable:2.3.0'
}

Wearable app может быть в трех состояниях:

  • Полностью независимо от мобильного преложения
  • Полу-независимо (телефон не требуется, но может предоставлять дополнительные функции)
  • Зависимым от мобильного приложения

Для первых двух, то есть для независимых, в Android Manifest для тега application надо прописать:

 <meta-data
 android:name="com.google.android.wearable.standalone"
 android:value="true" />

иначе приложение будет недоступно для пользователей с iPhone.
То есть, если этот флаг false, то приложение можно будет установить только на устройстве, связнным с телефоном, на котором установлен Play Store.

Помимо этого, для wear-модуля необходимо добавить В AndroidManifest.xml <uses-feature> со значением:

<manifest>
...
 <uses-feature android:name="android.hardware.type.watch" />
...
</manifest>


Packaging

Ранее, apk под Wear 1.0 были встраиваемыми в APKs для телефонов. Wear OS позволяет нам загружать apk скомпилированные под Wear 1.0 сразу в Play Store. Это позволяет уменьшить размер apk основного приложения и дает дополнительную гибкость в версионировании и дистрибуции нашего приложения. Подробнее здесь

Приложение должно быть самостоятельным apk, но это не значит, что мы не можем использовать код нашего приложения повторно. Просто выносим общую логику в библиотечный модуль, и импортируем его в модули app и wear. Таким образом, в модулях app и wear остается в основном только UI-часть приложения.

 

UI

Для создания красивых отзывчивых приложений в wear os есть свои UI-элементы . Например, BoxInsetLayout — layout, автоматически адпатирующийся под круглые и прямоугольные экраны.

BoxInsetLayout      BoxInsetLayout

Для создания списков в wear устройствах имеется аналог RecyclerView — WearableRecyclerView

WearableRecyclerView

 

 

Итог

Помните, что из-за аппаратных ограничений часы не предназначены для больших по функциональности приложений. Они идеально подходят для уведомлений, быстрых сообщений, или простых приложений. Имейте это ввиду, и публикуйте новые приложения или адаптированные под Wear OS версии существующих в Google Play!

Вот и все на сегодня!




A/B Тестирование с Firebase. Просто. Быстро.

Иногда нам нужно протестировать новую функциональность в приложении. Посмотреть, как повлияет, например, новый дизайн на retention и DAU, понравится он пользователям, или нет. Для этого используются AB-тесты.
В упрощенном виде это выглядит так: на бэкенд-стороне администратор выставляет какой части пользователей показывать вариант A (новый дизайн), какой части — вариант B (старый дизайн). Клиент-сторона запрашивает у сервера, какой вариант, А или B, он должен показать. И далее обычной if- или switch- проверкой запускается нужный код.
К счастью для многих мобильных разработчиков, многофункциональный сервис Firebase предоставляет в числе прочего возможность AB-тестов.
Чтобы создать эксперимент, идем в консоль Firebase.
Заходим в нужный проект. Если у Вас еще нет проекта, создайте его.
Для наглядности, я буду добавлять эксперимент по изменению цвета UI-элемента в своем проекте секундомера.
Итак, слева, в разделе Grow выбираем пункт A/B Testing и жмем Create experiment->Remote config. Вводим название эксперимента, описание, выбираем наше приложение и вводим процент целевой аудитории

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

Остается выбрать цели, по которым мы будем отслеживать результативность тестов:

Жмем Review и запускаем эксперимент.

Теперь нам нужно имплементировать A/B логику на клиенте.

В Codelabs есть хорошее описание как это сделать на Android
Все, что нам нужно, лежит в пакете com.google.firebase.remoteconfig, поэтому добавляем зависимость в build.gradle:

 implementation 'com.google.firebase:firebase-config:15.0.0'

Затем инициализируем инстанс Remote Config там, где будет реализована логика A/B теста:

FirebaseRemoteConfig mFirebaseRemoteConfig = FirebaseRemoteConfig.getInstance();FirebaseRemoteConfigSettings firebaseRemoteConfigSettings =
 new FirebaseRemoteConfigSettings.Builder()
 .setDeveloperModeEnabled(true)
 .build();
// Инициализируем данные по умолчанию
Map<String, Object> defaultConfigMap = new HashMap<>();
defaultConfigMap.put("red_circle_enabled", false);
firebaseRemoteConfig.setConfigSettings(firebaseRemoteConfigSettings);
firebaseRemoteConfig.setDefaults(defaultConfigMap);
fetchConfig();

Метод fetchConfig запрашивает конфигурационные данные у Firebase

public void fetchConfig() {
    long cacheExpiration = 3600;
    if (firebaseRemoteConfig.getInfo().getConfigSettings()
            .isDeveloperModeEnabled()) {
        cacheExpiration = 0;
    }
    firebaseRemoteConfig.fetch(cacheExpiration)
            .addOnSuccessListener(aVoid -> {
                firebaseRemoteConfig.activateFetched();
                applyRedCircleTest();
            })
            .addOnFailureListener(e -> {
                Log.w(TAG, "Error fetching config: " +
                        e.getMessage());
                applyRedCircleTest();
            });
}

Метод applyRedCircleTest непосредственно реализует логику в зависимости от полученного значения A/B теста

private void applyRedCircleTest() {
    Boolean redCircleEnabled = firebaseRemoteConfig.getBoolean(Constants.AB_TEST_RED_CIRCLE);
    circleStrokeTextView.setRedCircleEnabled(redCircleEnabled.booleanValue());
}

Вот мы и добавили A/B тест в приложение за 15 минут с помощью Firebase!




RxJava. ConcatMap для зависимых Observable

Расскажу сегодня, как оператор concatMap помогает преобразовать имеющуюся последовательную зависимую синхронную цепочку методов в реактивную и многопоточную.
Предположим, мы запрашиваем булево значение, и выполняем по результату какое-то UI событие:

  ...
  boolean state = mPresenter.getBooleanState(context)
  if(state) {
      showViewA();
    } else {
      showViewB();
  }

Класс, предоставляющий данные, пусть это будет презентер, имеет зависимую структуру методов, выполнение второго зависит от результата выполнения первого:

public class MyPresenter<MyView> {

  ...

  private int getIntValue(Context context) {
      int retValue = someCalculationMethod(context);
      return retValue;
  }
  public boolean getBooleanState(Context context) {
      int intValue = getIntValue(context);
      return performSomeCalculationWith(intValue);
  }
}

Если метод someCalculationMethod выполняется моментально, то мы можем выполнить его в главном потоке. Если же это, например, запрос к серверу, и на нем можно ожидать задержку по времени, то следует вывести его в отдельный фоновый поток.
С помощью Rx это можно легко сделать, предварительно преобразовав методы getIntValue и getBooleanState в Observable:

  private Observable<Integer> getIntValue(Context context) {
      return Observable.fromCallable(() -> {
         int retValue = someCalculationMethod(context);
         return retValue;
      });
  }

  private Observable<Boolean> getBooleanState(Context context, Integer intValue) {
     return Observable.fromCallable(() -> {
        boolean retValue = performSomeCalculationWith(intValue);
        return retValue;
     });
  }

Как же получить Observable метода getBooleanState, не имея результатов getIntValue? Вся магия  по объединению двух Observable реализуется с помощью оператора concatMap

  private Observable<Boolean> rxGetBooleanState(Context context) {
      Observable integerObservable = getIntValue(context)
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread());

      Observable retObservable = integerObservable
        .concatMap(intValue -> getBooleanState(context, intValue));
      return retObservable;
  }

ConcatMap работает аналогично flatMap. Важная отличительная особенность — сохранение последовательности элементов.

Обратимся к документации

Returns a new Observable that emits items resulting from applying a function that you supply to each item emitted by the source Observable, where that function returns an Observable, and then emitting the items that result from concatenating those resulting Observables.

Посмотрим для наглядности на схемы работы оператора concatMap:

concatMap

concatMap scheme

concatMap создает одну цепочку, и выполняет на элементах исходного Observable заданную функцию (тоже Observable), сохраняя порядок элементов. Итоговый Observable содержит преобразованные элементы в соответствующей последовательности.

Теперь нам остается только вызвать наш метод из требуемого контекста:

Disposable disposable = mPresenter.rxGetBooleanState(this)
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe { state ->
            if (required) {
                showViewA();
            } else {
               showViewB();
            }
        }

Update
На самом деле, наиболее простой способ выполнить два зависимых Observable — это оператор .flatMap, как описано здесь: https://github.com/ReactiveX/RxJava/issues/442


getIntValue(context).flatMap( intValue -> {
   return getBooleanState(context, intValue)
})



RxJava. Использование оператора share

Сегодня расскажу про удобный реактивный подход в обработке ввода текста в EditText. В качестве примера возьмем поисковую строку в SearchView.

Задача

Поиск обычно занимает какое-то время. Неважно, ходим мы за данными на сервер, или читаем локальную базу. Пользователь может вводить символы в поисковую строку гораздо быстрее, чем будут отрабатывать такие запросы. Поэтому нам нужно установить какое-то пороговое значение временной задержки, чаще которой не вызывать поиск. Помимо этого, нам может потребоваться отображать в UI изменения в режиме реального времени, без задержек.

Решение

Без использования Rx в Android этого можно добиться с помощью обычного Runnable и Handler, что не очень удобно и громоздко. Благодаря библиотеке RxBinding мы можем применить известный оператор debounce и получить изящное решение в виде:

RxSearchView.queryTextChanges(searchView) 
 .debounce(500, TimeUnit.MILLISECONDS) // задержка в 500 мс 
 .subscribe(query -> mPresenter.searchRequest(query));

Теперь, независимо от скорости ввода текста, запросы на сервер будут уходить не чаще двух раз в секунду.
Однако, что делать, если нам потребуется делать обновления в UI при каждом вводе нового символа? Например, менять подсказку. При текущей реализации эти обновления UI будут происходить с той же задержкой в 500 мс, что совсем не user-friendly.

На наше счастье, в Rx есть возможность широковещательного уведомления нескольких подписчиков. Для этого нужно вызвать оператор .share() на нашем Observable и вуаля!

Observable<String> sharedTextChanges = RxSearchView.queryTextChages(searchViw).share()
 
sharedTextChanges 
 .debounce(500, TimeUnit.MILLISECONDS) // use debounce 
 .subscribe(query -> mPresenter.searchRequest(query)); 
 
sharedTextChanges 
 .subscribe(query -> mPresenter.updateUI(query));

Мы два раза подписались на Observable: с debounce и без него. Теперь UI обновляется в режиме реального времени, при каждом вводе символа, а запросы на сервер уходят не чаще раза в 500 мс.

Что под капотом?

Оператор .share() — это обертка на цепью операторов .publish().refcount(). Они позволяет “расшарить” испускаемые потоком объекты. Рассмотрим эти операторы подробнее.

Оператор .publish( ) — превращает Observable в ConnectableObservable.

rxjava publish operator scheme

“ConnectedObservable” — это такой Observable, который не выпускает данные, пока на нем не вызовут оператор .connect(). Таким образом, мы можем дождаться, пока все Subscriber-ы не подпишутся на Observable, и только затем испускать данные.

Оператор .refcount() берет на себя функции по управлению несколькими подписчиками. Согласно документации,

Returns an Observable that stays connected to this ConnectableObservable as long as there is at least one subscription to this ConnectableObservable.

RxJava operator refcount

refcount() — держит в памяти количество подписчиков на результирующий Observable и не отключается от источника ConnectedObservable пока все не отпишутся.

 

Вот и все. Очень эффективно и элегантно, как всегда с rx. Надеюсь, этот пост оказался полезным для вас!