Привет! Меня зовут Виталий Сулимов, я Android-разработчик в компании Wheely, и сегодня я бы хотел поговорить с вами об архитектуре мобильных приложений. А именно о том, как мы в компании применили Redux-архитектуру к двум нашим приложениям и что из этого вышло.
Дисклеймер #1
Я разрабатываю коммерческие Android-приложения с 2016-го года, начинал с классического в то время MVC, потом был MVP, библиотека Moxy от ребят из Arello Mobile, Clean Architecture и вот теперь Redux. Мое мнение — идеальной и единственно правильной архитектуры не существует. Любая из них будет набором компромиссов, начиная от особенностей интеграции с самой платформой и заканчивая простотой, расширяемостью и возможностью написания тестов. То, что отлично подходит под наши приложения, может оказаться абсолютно непригодным для вашего проекта, и наоборот. Цель данной статьи — показать еще один способ написания Android-приложений.
К чему мы стремимся?
Перед тем как начать разбирать Redux, давайте тезисно обозначим, что мы хотим получить в итоге:
1. Пассивные View
Задача View — отображать интерфейс пользователю, в ней не должно быть бизнес-логики, она не должна принимать решения, всё, что она должна делать — сообщать о происходящих с ней событиях и взаимодействиях (создание, уничтожение, изменился размер, нажали кнопку, потянули Pull To Refresh, и т.д.)
2. Отдельно живущая модель, бизнес-логика / механизмы принятия решений
Здесь всё тоже должно быть очень просто, но в то же время четко разграничено.
Модель — чистые данные, которыми оперирует ваше приложение в runtime, она не должна содержать логики сама по себе, при этом должна быть достаточно полной и актуальной, чтобы на основе этих данных другие механизмы могли принимать те или иные решения.
3. Возможность покрывать логику тестами
Тут, я думаю, всё понятно без дополнительных комментариев. При определенных условиях для одного и того же события могут выполняться различные действия, и мы хотим иметь возможность покрывать эту логику тестами во избежание регрессий в процессе рефакторинга / будущей разработки и т.д.
4. Консистентность подхода в масштабе всего приложения
Мало кто сегодня разрабатывает приложения в одиночку, как правило, это команды из нескольких человек, которые отвечают за какое-то отдельное направление. Но иногда каждому из нас приходится залезать в чужой код, и было бы круто, если бы общие подходы были максимально схожими: неважно, статичный ли это экран с двумя кнопками или чат с сокетом в режиме реального времени.
Redux, я выбираю тебя!
Перед тем как говорить о Redux в контексте Android-приложения, давайте для начала разберемся, чем он является сам по себе, отдельно от Android.
В контексте данной статьи Redux рассматривается как библиотека для управления состоянием приложения. Основная идея заключается в том, что глобальное иммутабельное состояние содержится в глобальном компоненте Store, который среди прочего позволяет подписываться на это состояние, получать уведомления каждый раз, когда что-то меняется, а также отправлять ему события, когда что-то происходит. Это базовое описание, давайте теперь копнем чуть глубже и посмотрим, из каких компонентов состоит Redux.
State
Я не просто так вынес состояние на первое место, потому что считаю это основной и ключевой фишкой Redux. Состояние является иммутабельным (важно) объектом и единственным источником истины для всего приложения.
Store
Store содержит глобальное состояние (State) вашего приложения, а также все подключенные к нему Middleware и Reducer.
Типичное API позволяет вам получать текущее состояние, отправлять события (Action), подписываться и отписываться от изменений состояния.
Action
События, единственные данные, которые вы можете отправлять вашему Store. События как правило сообщают о взаимодействии с приложением или являются своего рода намерением явно изменить состояние (рассмотрим на примере позже). Опционально могут содержать внутри себя дополнительные данные.
Reducer
Чистая функция, которая меняет текущее состояние в ответ на пришедшее событие (Action). Для тех, кто не знает, чистая функция всегда возвращает одинаковое значение при одинаковых входных данных (детерминированность) и не имеет побочных эффектов (никаким образом не изменяет локальные переменные, не осуществляет ввод, вывод, и т.д.).
Поскольку состояние у нас является иммутабельным, Reducer использует Copy-on-write подход, копируя состояние целиком с изменением только необходимой части.
Middleware
Middleware является своего рода промежуточным звеном, позволяя перехватывать события (Action) и заменять их в случае необходимости, до того, как они попадут в наш Reducer.
Middleware является тем самым механизмом принятия решений и местом, где содержится бизнес-логика нашего приложения.
А как подружить Redux с Android?
Всё очень просто. Достаточно представить Android как источник событий (Action), не важно, что это, создание Activity, View, нажатие кнопки или BroadcastReceiver - просто отправьте Action об этом и обработайте его, как обычно (рассмотрим детальнее дальше).
Talk is cheap. Show me the code.
Теперь, когда мы имеем общее понимание того, что есть Redux и из чего он состоит, давайте попробуем написать приложение. Пусть это будет Counter, обычный счетчик, который можно увеличивать нажатием кнопки. Приложение должно переживать поворот экрана и обнулять значение счетчика при выходе из него. А выглядеть оно будет примерно так.
Что нам потребуется?
Чистый проект в Android Studio и реализация Redux для языка Kotlin.
Код библиотеки доступен в репозитории на GitLab.
Помимо реализации базовых компонентов Redux, я также добавил полезные утилиты для простой интеграции с Android. Итак, проект есть, библиотека подключена, давайте создавать наше приложение!
Дисклеймер #2
В процессе разбора вам может показаться, что в этой библиотеке очень нужен Rx, или Coroutines, или что-то ещё, о чем я даже не догадываюсь, но здесь этого нет. Библиотека создавалась по принципу KISS, она полностью открыта и вы можете без проблем изменить ее для соответствия вашим требованиям, главное не менять фундаментальную идею.
Описываем состояние
Помните, что я говорил о роли состояния в Redux? Простой иммутабельный объект, который содержит всю необходимую нам информацию и позволяет легко производить над ним операции по принципу Copy-on-write. Лучшее, что вы можете выбрать для этого подхода в Kotlin - data class.
ApplicationState.kt
data class ApplicationState(
val counter: Int = 0
)
Выглядит так же просто, как и звучит, едем дальше.
События
Все события в нашей реализации должны так или иначе реализовывать интерфейс Action из библиотеки Redux. Интерфейс Action является маркерным и не содержит никаких методов для реализации. Я стараюсь логически декомпозировать события для простоты работы с ними и обработки их в Middleware и Reducer, а также использовать sealed class’ы, последние ограничивают всех возможных наследников до узкого круга того, что нас непосредственно интересует. В итоге наши события будут выглядеть вот так.
CounterAction.kt
sealed class CounterAction : Action {
object Increment : CounterAction()
object Reset : CounterAction()
}
Reducer
В нашем случае это объект, который реализует интерфейс Reducer<S>, где S - глобальное состояние нашего приложения, т.е. В нашем случае ApplicationState. Интерфейс описывает одну-единственную функцию - reduce. Не забываем про то, что функция должна быть чистой.
CounterReducer.kt
object CounterReducer : Reducer<ApplicationState> {
override fun reduce(action: Action, state: ApplicationState): ApplicationState =
when (action) {
is CounterAction.Increment ->
state.copy(counter = state.counter.inc())
is CounterAction.Reset ->
state.copy(counter = 0)
else ->
state
}
}
Store
А теперь соберем все эти компоненты в единый механизм, и поможет нам в этом Store.
Библиотека уже содержит в себе абстрактный Store с реализацией всех необходимых методов. Всё что нам нужно сделать - создать наследника класса AbstractStore<S> и явно указать тип нашего глобального состояния. Это же состояние будет передаваться в наши Middleware и Reducer.
ApplicationStore.kt
class ApplicationStore(
initialState: ApplicationState,
middlewares: List<Middleware<ApplicationState>>,
reducers: List<Reducer<ApplicationState>>
) : AbstractStore<ApplicationState>(initialState, middlewares, reducers)
Теперь нам необходимо создать экземпляр класса ApplicationStore, передать ему изначальное состояние и список всех подключенных Middleware и Reducer. Поскольку Store, равно как и ApplicationState должны иметь время жизни равное времени жизни нашего приложения - сделаем AppComponent и положим наш Store туда.
AppComponent.kt
object AppComponent {
val store = ApplicationStore(
initialState = ApplicationState(),
middlewares = emptyList(),
reducers = listOf(CounterReducer)
)
}
Для чистоты кода я также советую создать подобные функции в любом удобном для вас файле, поскольку отправка событий, подписка и отписка будут часто использоваться во всем приложении.
ReduxFunctions.kt
fun dispatch(action: Action) =
AppComponent.store.dispatch(action)
fun subscribe(subscription: Subscription<ApplicationState>) =
AppComponent.store.subscribe(subscription)
fun unsubscribe(subscription: Subscription<ApplicationState>) =
AppComponent.store.unsubscribe(subscription)
Подведем промежуточный итог
Сейчас у нас есть иммутабельное состояние, которое отражает значение нашего счетчика, есть события, которые позволяют взаимодействовать с этим значением, и reducer, который обрабатывает эти события, меняя состояние соответствующим образом. Несмотря на общую простоту конструкции, можно заметить весьма жесткие ограничения. Мы можем быть уверены в том, что состояние может измениться только в результате работы reducer’a, а тот в свою очередь сработает, только если в store будет отправлено соответствующее событие. Дело осталось за малым, UI!
Прикручиваем отображение
В данном случае в ход идет непосредственно часть Android-фреймворка. Тут может быть несколько подходов (Single Activity / Multiple Activities / Fragments?), я покажу один из них, который нравится мне больше всего - это связка одной Activity и чистых View. Activity является своего рода контроллером и может заменять текущую View, а те в свою очередь подписываются на состояние и непосредственно отображают интерфейс пользователю.
CounterView.kt
class CounterView(
context: Context
) : FrameLayout(context) {
private val counterSubscription = SubStateSubscription<ApplicationState, Int>(
transform = { it.counter },
onStateChange = { state: Int, _: Boolean -> handleCounterStateChange(state) }
)
private lateinit var counterTextView: TextView
private lateinit var floatingActionButton: FloatingActionButton
init {
inflate(context, R.layout.view_counter, this)
findViewsById()
setOnClickListeners()
}
private fun findViewsById() {
counterTextView = findViewById(R.id.counterTextView)
floatingActionButton = findViewById(R.id.floatingActionButton)
}
private fun setOnClickListeners() {
floatingActionButton.setOnClickListener { dispatch(CounterAction.Increment) }
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
subscribeToStateChanges()
}
private fun subscribeToStateChanges() {
subscribe(counterSubscription)
}
override fun onDetachedFromWindow() {
unsubscribeFromStateChanges()
super.onDetachedFromWindow()
}
private fun unsubscribeFromStateChanges() {
unsubscribe(counterSubscription)
}
private fun handleCounterStateChange(state: Int) {
counterTextView.text = state.toString()
}
}
Давайте внимательно рассмотрим этот класс и разберем, что происходит. Для начала создадим SubStateSubscription, это вспомогательный класс, который позволяет подписываться не на состояние целиком, а на какую-то из его частей, опять же, если вы захотите Rx, то эту заботу на себя возьмет map(), в нашем случае - руками.
Дальше ничего необычного, объявляем lateinit var для всех виджетов внутри данной View.
Конструктор. Надуваем XML-разметку, находим в ней наши виджеты и вешаем обработчик нажатия на Floating Action Button. Внутри обработчика делаем dispatch события CounterAction.Increment, которое мы создали ранее.
OnViewAttached / Detached from window.
Здесь подписываемся и отписываемся соответственно на наше значение и пишем обработчик, который выполнится, когда произойдет изменение этой части состояния. В нашем случае всё очень тривиально, просто устанавливаем пришедшее значение в TextView.
CounterView.kt
...
counterTextView.text = state.toString()
...
И мы на финишной прямой! Почти.
Если запустить приложение сейчас, то оно будет работать, View будет отображать значение счетчика, которое соответствует переменной counter внутри ApplicationState, поворот экрана никак не ломает наше приложение, это решается by design, ведь состояние нашего счётчика живёт на уровне Application и жизненный цикл View на него никак не влияет, но… Помните, я говорил вам, что счетчик надо сбрасывать, когда мы выходим из приложения (нажимаем клавишу “Назад”). Как это сделать? Давайте разбираться.
Еще раз вспоминаем о том, как подружить Redux и Android
Выше я писал о том, что нам нужно представить Android как источник событий в архитектуре Redux. Давайте сделаем это. В библиотеке также есть уже готовый класс AppCompatActivity, который является самой обычной AppCompatActivity с одним маленьким бонусом: эта Activity отправляет события ActivityLifecycleAction (тоже являются частью библиотеки) на каждое событие жизненного цикла. Всё что вам нужно сделать для интеграции - это создать наследника данной AppCompatActivity и предоставить ему Store, в который она и будет отправлять события. Итоговый код выглядит так.
MainActivity.kt
class MainActivity : AppCompatActivity<ApplicationState>() {
private lateinit var contentViewGroup: ViewGroup
override fun getStore(): Store<ApplicationState> =
AppComponent.store
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
findViewsById()
addCounterView()
}
private fun findViewsById() {
contentViewGroup = findViewById(R.id.contentViewGroup)
}
private fun addCounterView() {
contentViewGroup.addView(CounterView(context = this))
}
}
Наша первая Middleware и принятие решения
У нас есть всё необходимое для написания финального элемента нашего приложения - MIddleware. Для начала давайте сформулируем, что мы хотим: Ловить событие уничтожения Activity (onDestroy) и если флаг isFInishing == true - обнулять наш счетчик.
isFinishing в данном случае определяет причину уничтожения, если он true, значит, пользователь выходит из нашего приложения, если false - активити уничтожается по другой причине, будь то поворот экрана или изменение конфигурации.
Опять же, выглядит так же просто, как и звучит. Создаем объект, который реализует интерфейс Middleware<S>, где S - тип нашего глобального состояния и реализуем метод handleAction().
ActivityLifecycleMiddleware.kt
object ActivityLifecycleMiddleware : Middleware<ApplicationState> {
override fun handleAction(
action: Action,
state: ApplicationState,
next: Next<ApplicationState>
): Action {
val newAction = when (action) {
is ActivityLifecycleAction.OnDestroy ->
handleActivityOnDestroy(action)
else ->
action
}
return next(newAction, state)
}
private fun handleActivityOnDestroy(action: ActivityLifecycleAction.OnDestroy): Action =
if (action.isFinishing) CounterAction.Reset else action
}
Давайте посмотрим, что здесь происходит. До того как событие ActivityLifecycleAction.OnDestroy попадет в Reducer, оно пройдет через все наши Middleware, и именно здесь мы можем заменить это изначальное событие на то, что нас интересует. Это и происходит, если флаг isFinishing == true, то в Reducer попадёт событие CounterAction.Reset, которое обнулит наш счетчик, если же флаг false - событие уйдёт как есть, но поскольку никто его не обрабатывает, оно никак не поменяет состояние нашего приложения, и подписчики на состояние ничего об этом не узнают. Не забудьте добавить middleware в наш AppComponent-класс.
AppComponent.kt
store = ApplicationStore(
initialState = ApplicationState(),
middlewares = listOf(ActivityLifecycleMiddleware),
reducers = listOf(CounterReducer)
)
Вот и всё!
Выводы
Лично мне очень нравится Redux. Простая на вид идея, но в тоже время при этой простоте получается создавать сложные вещи, главное — научиться его правильно готовить. И он действительно предсказуемый, как и говорится в оригинальном описании библиотеки на JavaScript. Он также позволяет вам четко разграничить места, где у вас есть логика, и места, где этой логики нет. Создание унифицированного кода становится очень простым. Что-то происходит - Action. Нужно принять решение - Middleware, нужно отреагировать на событие - Reducer. А View является простым представлением, которое умеет рисовать себя и сообщать о взаимодействиях с ней.
Бонус
Все исходники данной статьи лежат в открытом доступе, там вы можете найти полный код библиотеки, проект Counter, в котором Middleware и Reducer покрыты тестами, но это еще не все. Я также сделал куда более сложное приложение на Redux-архитектуре — это приложение “Погода”, которое поддерживает систему разрешений Android (доступ к локации), определяет местоположение пользователя и выполняет асинхронную загрузку данных с API OpenWeatherMap. Всё это покрыто тестами и также лежит в открытом доступе.
https://gitlab.com/v.sulimov/android-redux-kotlin
https://gitlab.com/v.sulimov/android-redux-demo
https://gitlab.com/v.sulimov/android-openweather-kotlin
Прощаемся
Данная статья подходит к концу, основная моя цель была рассказать вам про Redux в реалиях Android, и я сделал это. Я считаю, что введение должно быть максимально простым и понятным, потому в качестве примера используется обычный счетчик, но это совсем не значит, что на Redux нельзя делать более сложные вещи, именно поэтому я написал второе приложение и вы сможете без проблем разобрать его, используя данные из этой статьи. Но на случай, если у Вас возникнут вопросы - вы всегда можете задать их в комментариях.
Спасибо за уделенное время, надеюсь, вам было интересно.
С уважением, Виталий. Команда разработки Wheely.
kzzzr
Красиво.
Качественная работа!
Отдельное спасибо за код.