Всем привет! Меня зовут Анатолий Варивончик, я Android-разработчик Badoo. Сегодня я поделюсь с вами переводом второй части статьи моего коллеги Zsolt Kocsi о реализации MVI, которую мы ежедневно используем в процессе разработки. Первая часть здесь.
Чего мы хотим и как мы это сделаем
В первой части статьи мы познакомились с Features, центральными элементами MVICore, которые можно переиспользовать. Они могут обладать максимально простой структурой и включать всего один Reducer, а могут стать полнофункциональным средством для управления асинхронными задачами, событиями и многим другим.
Каждая Feature отслеживаема — есть возможность подписаться на изменения её состояния и получать уведомления об этом. При этом Feature можно подписать на источник ввода. И в этом есть смысл, ведь с включением Rx в кодовую базу у нас и так появилось множество наблюдаемых объектов и подписок на самых разных уровнях.
Именно в связи с увеличением количества реактивных компонентов пришло время поразмышлять о том, что мы имеем и можно ли сделать систему ещё лучше.
Нам предстоит ответить на три вопроса:
- Какие элементы следует использовать при добавлении новых реактивных компонентов?
- Какой способ управления подписками самый простой?
- Можно ли абстрагироваться от управления жизненным циклом / необходимости очищать подписки, чтобы избежать утечек памяти? Иными словами, можем ли мы отделить связывание компонентов от управления подписками?
В этой части статьи мы рассмотрим основы и преимущества построения системы при помощи реактивных компонентов и увидим, как 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 содержит класс под названием 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 — и тогда любая подписка будет удаляться вместе с ней. Полная свобода действий.
- Хотите, чтобы подписки сохранялись до того, как Observable отправит элемент? Преобразуйте этот объект в Lifecycle и передайте его Binder. Реализуйте следующий код в extension-функции и используйте его в дальнейшем:
fun Observable<T>.toBinderLifecycle() = Lifecycle.wrap(this .first() .map { END } .startWith(BEGIN) )
- Хотите, чтобы привязки сохранялись до окончания работы Completable? Никаких проблем — это делается по аналогии с предыдущим пунктом:
fun Completable.toBinderLifecycle() = Lifecycle.wrap( Observable.concat( Observable.just(BEGIN), this.andThen(Observable.just(END)) ) )
- Хотите, чтобы какой-нибудь другой не Rx-код решал, когда удалять подписки? Используйте ManualLifecycle как описано выше.
В любом случае вы можете либо проложить реактивный поток к потоку элементов Lifecycle.Event, либо использовать ManualLifecycle, если вы работаете с не Rx-кодом.
Общий обзор системы
Binder прячет подробности создания и управления Rx-подписками. Остаётся только сжатый, обобщённый обзор: «Компонент A взаимодействует с компонентом B в области видимости C».
Предположим, что для текущего экрана у нас есть следующие реактивные компоненты:
Мы хотели бы, чтобы компоненты были связаны в пределах текущего экрана, и знаем, что:
- 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.