- PVSM.RU - https://www.pvsm.ru -

Строим систему реактивных компонентов с помощью Kotlin

Строим систему реактивных компонентов с помощью Kotlin - 1

Всем привет! Меня зовут Анатолий Варивончик, я Android-разработчик Badoo. Сегодня я поделюсь с вами переводом второй части статьи моего коллеги Zsolt Kocsi о реализации MVI, которую мы ежедневно используем в процессе разработки. Первая часть здесь [1].

Чего мы хотим и как мы это сделаем

В первой части статьи мы познакомились с Features, центральными элементами MVICore [2], которые можно переиспользовать. Они могут обладать максимально простой структурой и включать всего один Reducer, а могут стать полнофункциональным средством для управления асинхронными задачами, событиями и многим другим.

Каждая Feature отслеживаема — есть возможность подписаться на изменения её состояния и получать уведомления об этом. При этом Feature можно подписать на источник ввода. И в этом есть смысл, ведь с включением Rx в кодовую базу у нас и так появилось множество наблюдаемых объектов и подписок на самых разных уровнях.

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

Нам предстоит ответить на три вопроса:

  1. Какие элементы следует использовать при добавлении новых реактивных компонентов?
  2. Какой способ управления подписками самый простой?
  3. Можно ли абстрагироваться от управления жизненным циклом / необходимости очищать подписки, чтобы избежать утечек памяти? Иными словами, можем ли мы отделить связывание компонентов от управления подписками?

В этой части статьи мы рассмотрим основы и преимущества построения системы при помощи реактивных компонентов и увидим, как Kotlin помогает в этом.

Основные элементы

К тому моменту, когда мы подошли к работе над дизайном и стандартизацией наших Features, мы уже перепробовали множество различных подходов и решили, что Features будут выполнены в форме реактивных компонентов. Сначала мы сосредоточились на главных интерфейсах. Прежде всего нам нужно было определиться с типами входных и выходных данных.

Мы рассуждали следующим образом:

  • Не будем изобретать велосипед — посмотрим, какие интерфейсы уже существуют.
  • Так как мы уже используем библиотеку RxJava, есть смысл обратиться к её базовым интерфейсам.
  • Количество интерфейсов должно быть сведено к минимуму.

В результате мы решили использовать ObservableSource<Т> для вывода и Consumer<Т> для ввода. Почему не Observable/Observer, спросите вы. Observable — абстрактный класс, от которого вам нужно отнаследоваться, а ObservableSource — реализуемый вами интерфейс, полностью удовлетворяющий потребность в реализации реактивного протокола.

package io.reactivex;

import io.reactivex.annotations.*;

/**
 * Represents a basic, non-backpressured {@link Observable} source base interface,
 * consumable via an {@link Observer}.
 *
 * @param <T> the element type
 * @since 2.0
 */
public interface ObservableSource<T> {

    /**
     * Subscribes the given Observer to this ObservableSource instance.
     * @param observer the Observer, not null
     * @throws NullPointerException if {@code observer} is null
     */
    void subscribe(@NonNull Observer<? super T> observer);
}

Observer, первый приходящий на ум интерфейс, реализует четыре метода: onSubscribe, onNext, onError и onComplete. Стремясь максимально упростить протокол, мы предпочли ему Consumer<Т>, который принимает новые элементы с помощью одного-единственного метода. Если бы мы выбрали Observer, то оставшиеся методы чаще всего были бы избыточными либо работали бы иначе (например, нам хотелось представить ошибки как часть состояния (State), а не как исключения, и уж точно не прерывать поток).

/**
 * A functional interface (callback) that accepts a single value.
 * @param <T> the value type
 */
public interface Consumer<T> {
    /**
     * Consume the given value.
     * @param t the value
     * @throws Exception on error
     */
    void accept(T t) throws Exception;
}

Итак, у нас есть два интерфейса, каждый из которых содержит по одному методу. Теперь мы можем связать их, подписав Consumer<Т> на ObservableSource<Т>. Последний принимает только экземпляры Observer<Т>, но мы можем обернуть его в Observable<Т>, который подписан на Consumer<Т>:

val output: ObservableSource<String> = Observable.just("item1", "item2", "item3")
val input: Consumer<String> = Consumer { System.out.println(it) }
val disposable = Observable.wrap(output).subscribe(input)

(К счастью,  функция .wrap(output) не создаёт новый объект если output уже является  Observable<Т> ).

Возможно, вы помните, что компонент Feature из первой части статьи использовал входные данные типа Wish (соответствует Intent из Model-View-Intent) и выходные данные типа State, а потому может находиться с обеих сторон связки:

// Wishes -> Feature
val wishes: ObservableSource<Wish> = Observable.just(Wish.SomeWish)
val feature: Consumer<Wish> = SomeFeature()
val disposable = Observable.wrap(wishes).subscribe(feature)

// Feature -> State consumer
val feature: ObservableSource<State> = SomeFeature()
val logger: Consumer<State> = Consumer { System.out.println(it) }
val disposable = Observable.wrap(feature).subscribe(logger)

Такое связывание Consumer и Producer уже выглядит достаточно просто, но существует ещё более лёгкий способ, при котором не нужно ни создавать подписки вручную, ни отменять их.

Представляем Binder.

Связывание «на стероидах»

MVICore [2] содержит класс под названием Binder, который предоставляет простой API для управления Rx-подписками и обладает целым рядом крутых возможностей.

Зачем он нужен?

  • Создание связывания путём подписки входных данных на выходные.
  • Возможность отписки по завершении жизненного цикла (когда он является абстрактным понятием и не имеет никакого отношения к Android).
  • Бонус: Binder позволяет добавлять промежуточные объекты, например, для ведения лога или time-travel-отладки.

Вместо того чтобы подписываться вручную, можно переписать приведённые выше примеры следующим образом:

val binder = Binder()
binder.bind(wishes to feature)
binder.bind(feature to logger)

Благодаря Kotlin всё выглядит очень просто.

Эти примеры работают, если тип входных и выходных данных совпадает. Но что, если это не так? Реализовав функцию расширения, мы можем сделать трансформацию автоматической:

val output: ObservableSource<A> = TODO()
val input: Consumer<B> = TODO()
val transformer: (A) -> B = TODO()

binder.bind(output to input using transformer)

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

Создание Binder

Создание экземпляра выглядит проще некуда:

val binder = Binder()

Но в этом случае нужно отписываться вручную, и вам придётся вызывать binder.dispose() всякий раз, когда будет необходимо удалить подписки. Есть и другой способ: ввести экземпляр жизненного цикла в конструктор. Вот так:

val binder = Binder(lifecycle)

Теперь вам не нужно волноваться о подписках — они будут удаляться в конце жизненного цикла. При этом жизненный цикл может повторяться многократно (как, например, цикл запуска и остановки в Android UI) — и Binder будет каждый раз создавать и удалять подписки за вас.

А что вообще такое жизненный цикл?

Большинство Android-разработчиков, видя словосочетание «жизненный цикл», представляют цикл Activity и Fragment. Да, Binder может работать и с ними, отписываясь по завершении цикла.

Но это только начало, ведь вы никак не задействуете андроидовский интерфейс LifecycleOwner — у Binder есть свой, более универсальный. Он, по сути, представляет собой поток сигналов BEGIN/END:

interface Lifecycle : ObservableSource<Lifecycle.Event> {

   enum class Event {
       BEGIN,
       END
   }

   // Remainder omitted
}

Вы можете либо реализовать этот поток при помощи Observable (путём маппинга), либо просто использовать класс ManualLifecycle из библиотеки для не Rx-сред (как именно, увидите чуть ниже).

Как при этом действует Binder? Получая сигнал BEGIN, он создаёт подписки для ранее сконфигурированных вами компонентов (input/output), а получая сигнал END, удаляет их. Самое интересное — что можно всё начинать заново:

val output: PublishSubject<String> = PublishSubject.create()
val input: Consumer<String> = Consumer { System.out.println(it) }

val lifecycle = ManualLifecycle()
val binder = Binder(lifecycle)

binder.bind(output to input)

output.onNext("1")
lifecycle.begin()
output.onNext("2")
output.onNext("3")
lifecycle.end()
output.onNext("4")
lifecycle.begin()
output.onNext("5")
output.onNext("6")
lifecycle.end()
output.onNext("7")

// will print:
// 2
// 3
// 5
// 6

Эта гибкость в переназначении подписок особенно полезна при работе с Android, когда может быть сразу несколько циклов Start-Stop и Resume-Pause, помимо обычного Create-Destroy.

Жизненные циклы Android Binder

В библиотеке представлены три класса:

  • CreateDestroyBinderLifecycle(androidLifecycle)
  • StartStopBinderLifecycle(androidLifecycle)
  • ResumePauseBinderLifecycle(androidLifecycle)

androidLifecycle — это возвращаемое методом getLifecycle() значение, то есть AppCompatActivity, AppCompatDialogFragment и т. д. Всё очень просто:

fun createBinderForActivity(activity: AppCompatActivity) = Binder(
    CreateDestroyBinderLifecycle(activity.lifecycle)
)

Индивидуальные жизненные циклы

Давайте не будем на этом останавливаться, ведь мы никак не привязаны к Android. Что такое жизненный цикл Binder? Буквально что угодно: например, время воспроизведения диалога или время выполнения какой-нибудь асинхронной задачи. Можно, скажем, привязать его к области видимости DI — и тогда любая подписка будет удаляться вместе с ней. Полная свобода действий.

  1. Хотите, чтобы подписки сохранялись до того, как Observable отправит элемент? Преобразуйте этот объект в Lifecycle и передайте его Binder. Реализуйте следующий код в extension-функции и используйте его в дальнейшем:
    fun Observable<T>.toBinderLifecycle() = Lifecycle.wrap(this
        .first()
        .map { END }
        .startWith(BEGIN)
    )
    
  2. Хотите, чтобы привязки сохранялись до окончания работы Completable? Никаких проблем — это делается по аналогии с предыдущим пунктом:
    fun Completable.toBinderLifecycle() = Lifecycle.wrap(
        Observable.concat(
            Observable.just(BEGIN),
            this.andThen(Observable.just(END))
        )
    )
  3. Хотите, чтобы какой-нибудь другой не Rx-код решал, когда удалять подписки? Используйте ManualLifecycle как описано выше.

В любом случае вы можете либо проложить реактивный поток к потоку элементов Lifecycle.Event, либо использовать ManualLifecycle, если вы работаете с не Rx-кодом.

Общий обзор системы

Binder прячет подробности создания и управления Rx-подписками. Остаётся только сжатый, обобщённый обзор: «Компонент A взаимодействует с компонентом B в области видимости C».

Предположим, что для текущего экрана у нас есть следующие реактивные компоненты:

Строим систему реактивных компонентов с помощью Kotlin - 2

Мы хотели бы, чтобы компоненты были связаны в пределах текущего экрана, и знаем, что:

  • UIEvent можно «скормить» напрямую AnalyticsTracker;
  • UIEvent можно трансформировать в Wish для Feature;
  • State можно трансформировать во ViewModel для View.

Это можно выразить в паре строк:

with(binder) {
    bind(feature to view using stateToViewModelTransformer)
    bind(view to feature using uiEventToWishTransformer)
    bind(view to analyticsTracker)
}

Мы делаем такие выжимки для наглядной демонстрации взаимосвязи компонентов. И поскольку мы, разработчики, проводим больше времени за чтением кода, чем за его написанием, подобный краткий обзор крайне полезен, особенно по мере увеличения числа компонентов.

Заключение

Мы увидели, как Binder помогает в управлении Rx-подписками и как он помогает получить обзор системы, построенной из реактивных компонентов.

В следующих статьях мы расскажем, как мы отделяем реактивные UI-компоненты от бизнес-логики и как с помощью Binder добавлять промежуточные объекты (для ведения лога и time travel debugging). Не переключайтесь!

А пока познакомьтесь с библиотекой на GitHub [2].

Автор: ANublo

Источник [3]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/programmirovanie/299746

Ссылки в тексте:

[1] здесь: https://habr.com/company/badoo/blog/429728/

[2] MVICore: https://github.com/badoo/MVICore

[3] Источник: https://habr.com/post/430550/?utm_campaign=430550