Summer MVP. Насколько гибок Kotlin?


/ Оригинал /

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

Под катом история про то, как мы испытывали Kotlin на гибкость (и пару раз сломали), реализуя MVP-библиотеку Summer без кодогенерации и с поддержкой Kotlin Multiplatform.

Зачем нужна MVP библиотека?

Если вы программируете под Android, то ваши Fragment и Activity могут иногда пересоздаваться. Если не использовать никакой специальной библиотеки, то разработчику придётся думать о том, что делать, если view сейчас нет и как при повторном создании view доставить в него актуальные данные. Есть разные подходы, как упростить этот процесс. MVP-библиотеки создают иллюзию, что view всегда существует даже если это не так. Достигаться это может за счёт того, что создаётся специальный объект, с которым можно общаться как с view, но он всегда существует. В рамках статьи я буду называть такой объект прокси. К примеру, Moxy генерирует прокси с помощью APT (Annotation Processing Tool). Moxy является отличной MVP-библиотекой. Легко настраивается, имеет довольно приятный синтаксис и большую часть времени отлично работает.

Что не так с APT?

  1. Подключаем Moxy
  2. Жёстко тупим
  3. Ставим аннотацию @InjectViewState на Fragment
  4. Получаем кучу ошибок, похожих на те, что ниже. Про Moxy ни слова.

e: <...>/kapt3/stubs/debug/adev/TestFragment.java:16: error: cannot find symbol
    public TestPresenterFactory presenterFactory;
           ^
  symbol:   class TestPresenterFactory
  location: class TestFragment

e: <...>/kapt3/stubs/debug/adev/ui/TestFragment.java:22:
error: [ComponentProcessor:MiscError] dagger.internal.codegen.ComponentProcessor was unable to process this class because not all of its dependencies could be resolved. Check for compilation errors or a circular dependency with generated code.
public final class TestFragment extends adev.ui.BaseFragment implements adev.presentation.TestView {
             ^

Я, в полной уверенности, что Dagger опять сломался, иду чинить свой граф. Через 4 часа, подключив троих коллег, мы абсолютно случайно находим проблему. Спасибо, что проект только начинался и состоял всего из десятка экранов.

Ставим аннотацию на Fragment в проекте одного из коллег. Не повторяется. Делаем Clean Project в моём, удаляем все кэши, снова ставим и опять ошибка.

Я так и не раскопал эту проблему до конца. Единственное различие в стеке наших проектов — это AutoFactory, но даже если отключить её в моём проекте, всё равно возникает та же ошибка.

Такие ошибки возникают не очень часто и чаще всего с Dagger. Проблема в том, что возникают они обычно в самый неподходящий момент и абсолютно не очевидно, как их исправлять. Даже если у вас всё построено с помощью лучших практик проектирования и сущности изолированы друг от друга, то это вас не спасёт. Ошибки библиотек с APT нелокальны и выстреливают при любой архитектуре в самых неочевидных местах проекта.

APT для Kotlin/Multiplatform

Для Kotlin/Multiplatform сейчас нет стабильного API компилятора. Поэтому Annotation Processing там очень сложен. Придётся завязаться на нестабильный API с сильным недостатком документации.

Что делать?

Для Android-only проектов моим решением была минимизация использования APT. Koin/Kodein вместо Dagger, своя реализация для передачи аргументов фрагментам вместо FragmentArgs. Moxy всё ещё оставалась безальтернативной. Было время, когда я пробовал Google MVVM, но как-то слишком много кода получается для решения, казалось бы, простых задач. К тому же, никаких эвентов из коробки, приходится городить костыли.

Решение для меня пришло из моих экспериментов с Kotlin/JS в 2017 году. Тогда я с помощью property-делегатов проксировал установку состояния компонента VueJS для того, чтобы убрать суффиксы. Мне в голову пришла мысль: "А почему бы по тому же принципу не проксировать установку состояния Fragment/Activity?" Так я написал первый прототип библиотеки для проксирования состояния. Выглядело это примерно вот так:

interface View {
    var counter: Int
}

class Presenter : BasePresenter<View>() {

    override fun createViewProxy(view: View) = object : View {
        override var counter by state(view::counter, initial = 0)
    }

    override fun onEnter() {
        viewProxy.counter = 1
    }
}

Вместо обращения к настоящей view мы обращаемся к viewProxy, которая тоже реализует интерфейс view. К каждому свойству view привязан property delegate, созданный с помощью функции state. Этот делегат сохраняет ссылку на переданное ему свойство из view и удаляет эту ссылку когда view уничтожается. Сам делегат при этом продолжает существовать и, если его вызвать, когда view уже уничтожена, то приложение не падает. При каждой установке значения свойства делегат сохраняет его в Map. При каждом создании view функция createViewProxy вызывается заново и при каждом вызове state в переданное свойство устанавливается ранее сохранённое в Map значение.

Проблемы тут две:

  1. До первого вызова createViewProxy нельзя обращаться к viewProxy. Будет краш. Это особенно сильно мешает, когда у диалога или BottomSheet есть свой собственный презентер и нужно обратиться обратиться к нему из основного презентера.
  2. Непонятно, что делать с методами view.

Каждую из этих проблем я решил далеко не с первого раза. Порой я ожидал от языка невозможного и немного "перегибал".

Перелом №1. Состояние

Как я уже написал выше, viewProxy будет доступен только при первом присоединении view. Решение проблемы, казалось бы, очевидно. Пусть viewProxy создаётся без view вообще, а потом делегаты, созданные с помощью функции state, получают свойства view c помощью переданной им лямбды.

Первая реализация была такой:

interface View {
    var counter: Int
}

class Presenter : BasePresenter<View>() {

    override val viewProxy = object : View {
        override var counter by state({ ::counter }, initial = 0)
    }

    override fun onCreate() {
        viewProxy.counter = 1
    }
}

abstract class BasePresenter<TView> {
    fun <T> state(
        rule: TView.() -> KMutableProperty0<T>
    ): StateDelegate<T> {}
}

Kotlin поддерживает получение ссылки на свойство с помощью вот такого синтаксиса: obj::prop. Так же можно получить ссылку на свойство объекта изнутри него вот так:

class A {
    var b = 1
    val prop = ::b
}

Я подумал: "Ага, наверное, можно получить свойство текущего this даже если он является receiver-ом функции!".

class A {
    var b = 1
}

fun A.foo() {
    val prop = ::b
}

И да. Можно! Отлично!

Реализую полный вариант:

interface View {
    var counter: Int?
}

class Presenter : BasePresenter<View>() {
    override val viewProxy = object : View {
        override var counter by state({ ::counter }, initial = null)
    }
}

И…

e: <...>/adev/presentation/Presenter.kt: (7, 22): Type of 'counter' doesn't match the type of the overridden var-property 'public abstract var counter: Int? defined in adev.presentation.View'

При этом, Android Studio ошибки не показывает. Навигация работает правильно. В чём же дело? Похоже, старый frontend компилятора Kotlin, который используется для сборки, берёт свойство у viewProxy, а новый, который используется в IDE, берёт его, как и надо, у this лямбды, переданной в state. Забавно, что если сделать counter не опциональным или получить свойство не с помощью ::counter, а с помощью this::counter, то всё работает.

На следующий день я сделал view явным аргументом и решил для себя: поменьше неявных this внутри пользовательских классов.

abstract class BasePresenter<TView> {
    fun <T> state(
        rule: (TView) -> KMutableProperty0<T>
    ): StateDelegate<T> {}
}

Теперь пользовательский код выглядит так:

interface View {
    var counter: Int?
}

class Presenter : BasePresenter<View>() {
    override val viewProxy = object : View {
        override var counter by state({ it::counter }, initial = null)
    }
}

Меня это устроило. Читается неплохо. К тому же, теперь презентер не зависит от начального состояния view потому что сам приводит его в нужное начальное состояние при присоединении. Единственное, мне было лень самому писать viewProxy поэтому я написал плагин для Intellij IDEA/Android Studio.

Прокси для событий

Оставался второй вопрос: как проксировать события?

Первое решение было плохим:

Страх. Ужас. Печалька.

object View {
    interface State {
        var counter: Int
    }
    interface Methods {
        fun showMessage(message: String)
    }
}

class Presenter : BasePresenter<View.State, View.Methods>() {

    override val viewStateProxy = object : View.State {
        override var counter by state({ it::counter }, initial = 0)
    }

    override fun onEnter() {
        viewStateProxy.counter = 1
        viewMethods?.showMessage("Hello!")
    }
}

А потом выяснилось, что переходы между экранами нельзя делать через viewMethods потому что они могут происходить в фоне. Например, после сетевого запроса. Поэтому стало вот так:

Смерть. Чума. Апокалипсис.

object View {
    interface State {
        var counter: Int
    }
    interface Methods {
        fun showMessage(message: String)
    }
}

interface Router {
    fun toNext()
}

class Presenter : BasePresenter<View.State, View.Methods, Router>() {

    override val viewStateProxy = object : View.State {
        override var counter by state({ it::counter }, initial = 0)
    }

    override fun onEnter() {
        viewStateProxy.counter = 1
        viewMethods?.showMessage("Hello!")
        router.toNext()
    }
}

В целом, с шаблонами для презентера и фрагмента жить можно, но кода слишком много. Помогло то, что на этом этапе нам с командой нужно было разработать проект, код которого потом будет поддерживать другая компания. Тащить туда какие-то внутренние наработки было бы неправильно. Поэтому мы выбрали более традиционный стек: Dagger, Retrofit и Moxy.

В Moxy очень удобно реализованы команды. От выбранной стратегии зависит то, что произойдёт с командой при присоединении view.
При AddToEndSingleStrategy команда повторяется при каждом присоединении view.
При OneExecutionStrategy команда повторяется один раз только в том случае, если она была добавлена, когда view не существовало.
При SkipStrategy команда никогда не повторяется.

Функцию AddToEndSingleStrategy в Summer выполняет state. Остальные были реализованы через механизм событий.

К сожалению, в Kotlin нет синтаксиса для прокидывания аргументов функции в другую функцию или лямбду и код, вроде этого, не скомпилируется.

interface View {
    var counter: Int?
    fun showMessage()
}

class Presenter : BasePresenter<View>() {
    override val viewProxy = object : View {
        override var counter by state({ it::counter }, initial = null)
        override fun showMessage = event { it::showMessage }
    }
}

abstract class BasePresenter<TView> {
    fun event(
        getAction: (TView) -> (() -> Unit)
    ) = Event(getAction)
}

class Event<TView>(
    getAction: (TView) -> (() -> Unit)
) : () -> Unit

Я попробовал несколько других синтаксисов. Например:

interface View {
    var counter: Int?
}

interface Events {
    fun showMessage(message: String)
}

class Presenter : BasePresenter<View>() {

    override val viewProxy = object : View {
        override var counter by state({ it::counter }, initial = null)
    }

    val showMessage = event { it::showMessage }
}

В итоге, отказался от функций в интерфейсе View в пользу лямбд. Это решение далось мне не легко, но сейчас я считаю, что оно было правильным.

В чём разница между функциями и лямбдами?

  1. Лямбду можно передать куда-то, а функцию — нет.
  2. У лямбды больше ограничений. Она не может иметь аргументов по-умолчанию, в неё нельзя передавать аргументы по именам.
  3. Разный синтаксис.

Первый пункт – ключевой. Если функцию нельзя куда-то передать, то её нельзя и проксировать. В любом случае, нужно будет создать лямбду, вызывающую функцию, и передать её в прокси. К тому же, прокси не сможет реализовать на себе тип функции. В Kotlin у функции нет такого типа, который можно бы было на чём-то реализовать. Прокси реализует на себе функциональный тип. Такой же, как у лямбд, и ограничения у него будут такие же, как у лямбды. Соответственно, второй пункт тоже отпадает.

Именно из-за третьего пункта у меня было больше всего сомнений. Насколько синтаксис лямбд будет мешать программированию?

Функция на Kotlin:

override fun showMessage(message: String) {
    Toast.makeText(requireContext(), "message", Toast.LENGTH_LONG).show()
}

Лямбда на Kotlin:

override val showMessage = { message: String ->
    Toast.makeText(requireContext(), "message", Toast.LENGTH_LONG).show()
}

Функция на Swift:

funс showMessage(message: String) {
    printMessage(message)
}

Лямбда на Swift:

lazy var showMessage: (String) -> Void = { message in
    self.printMessage(message)
}

Лямбда на идеальном языке программирования (つ✧ω✧)つ

override val showMessage = { message: String -> Unit in
    printMessage(message)
}

Что-то я замечтался. В целом, разница между записями функций и лямбд невелика. После реализации 5-6 лямбд, я уже почти не чувствовал разницы, а потом просто добавил генерацию реализаций в плагин.

Перелом №2. Фабрика событий

Когда я определился с синтаксисом действий view, настало время реализации прокси для них. Первый прототип для действий без аргументов был таким:

interface View {
    val showMessage: () -> Unit
}

class Presenter : BasePresenter<View>() {
    override val viewProxy = object : View {
        override val showMessage = doOnlyWhenAttached { it.showMessage }
    }
}

abstract class BasePresenter<TView> {
    fun doOnlyWhenAttached(
        getAction: (TView) -> (() -> Unit)
    ) = Event(getAction)
}

class Event<TView>(
    action: (TView) -> (() -> Unit)
) : () -> Unit

Всё работало отлично. К сожалению, в Kotlin нет возможности сделать у функции вариативное количество аргументов разных типов. Есть vararg, но все его аргументы должны иметь строго один тип. Поэтому я реализовал по функции для каждой возможной арности действия view.

interface View {
    val showMessage: (message: String) -> Unit
}

class Presenter : BasePresenter<View>() {
    override val viewProxy = object : View {
        override val showMessage = doOnlyWhenAttached { it.showMessage }
    }
}

abstract class BasePresenter<TView> {

    fun doOnlyWhenAttached(
        getAction: (TView) -> (() -> Unit)
    ) = Event0(getAction)

    fun <T1> doOnlyWhenAttached(
        getAction: (TView) -> ((T1) -> Unit)
    ) = Event1(getAction)
}

class Event0<TView>(
    action: (TView) -> (() -> Unit)
) : () -> Unit

class Event1<TView, T1>(
    action: (TView) -> ((T1) -> Unit)
) : (T1) -> Unit

e: <...>/adev/presentation/View.kt: (10, 22): Type of 'showMessage' is not a subtype of the overridden property 'public abstract val showMessage: (message: String) -> Unit defined in adev.presentation.View'

Компилятор Kotlin не может определить, какую из перегрузок взять и всегда берёт первую. Судя по всему, так происходит потому что Kotlin может определить либо все типовые параметры сразу, либо не определить ни одного. Поэтому во всех doOnlyWhenAttached тип TView не определяется, а без него нельзя определить и T1. Видимо, особенность реализации компилятора.

"Хорошо" – подумал я и разделил создание Event на 2 этапа:

  1. Определение типа TView и типа лямбды
  2. Создание нужного Event в зависимости от типа лямбды

interface View {
    val showMessage: (message: String) -> Unit
}

class Presenter : BasePresenter<View>() {
    override val viewProxy = object : View {
        override val showMessage = event { it.showMessage }.doOnlyWhenAttached()
    }
}

abstract class BasePresenter<TView> {
    fun <TAction> event(getAction: (TView) -> TAction) = EventBuilder(getAction)

    fun EventBuilder<TView, () -> Unit>.doOnlyWhenAttached() {
        return Event0(this.getAction)
    }

    fun <T1> EventBuilder<TView, (T1) -> Unit>.doOnlyWhenAttached() {
        return Event1(this.getAction)
    }
}

inline class EventBuilder<TView, TAction>(
    val getAction: (TView) -> TAction
)

class Event0<TView>(
    action: (TView) -> (() -> Unit)
) : () -> Unit

class Event1<TView, T1>(
    action: (TView) -> ((T1) -> Unit)
) : (T1) -> Unit

Теперь всё работает как надо. Оставалось только реализовать поддержку разных стратегий для событий и shorthands для основных из них. Синтаксис получился таким:

interface View {
    val showMessage: (message: String) -> Unit
    val showMessage1: (message: String) -> Unit
    val showMessage2: (message: String) -> Unit
}

class Presenter : BasePresenter<View>() {
    override val viewProxy = object : View {
        override val showMessage = event { it.showMessage }.build(RepeatLastStrategy())
        override val showMessage1 = event { it.showMessage1 }.doOnlyWhenAttached()
        override val showMessage2 = event { it.showMessage2 }.doExactlyOnce()
    }
}

Теперь синтаксис создания прокси и для состояния, и для событий готов!

Благодарности

В этой статье я описал только удачные попытки проектирования синтаксиса. На самом деле, всего их было порядка 30. Многие из них мы тщательно тестировали, в том числе, в production. Разработка суммарно шла больше 2-х лет. И вот, наконец, релиз!

Сейчас на Summer написано 5 production проектов. 2 из них – мультиплатформенные. Спасибо огромное MaksimNovikov, metnyov и deks1309 за то, что решились писать на новом фреймворке и за большое количество полезного фидбэка. Отдельное спасибо XanderZhu и umpteenthdev за вклад в разработку самого фреймворка и mowenik за консультирование по реализации iOS части.

Паттерны

За то время, что мы пишем на Summer, мы выработали несколько паттернов для решения типовых задач. Вот несколько из них:

Подпрезентер

Внутри презентера экрана можно объявить несколько подпрезентеров и передать их в конструкторы android.widget.View, UIView или попапов. Даже когда сам попап ещё не был открыт, можно общаться с его презентером и заранее установить у него какие-то значения. При открытии, попап приведётся в нужное состояние. Также, в подпредентер можно прокинуть каллбэк.

interface View {}

class Presenter : SummerPresenter<View> {
    override val viewProxy = object : View {}
    val subPresenter = SubPresenter(
        onAction = { doSomething() }
    )
}

class Fragment : SummerFragment(R.layout.fragment), View {
    private val presenter by bindPresenter { Presenter() }

    private lateinit var popup: Popup
    override fun onViewCreated(...) {
        super.onViewCreated(...)
        popup = Popup(presenter.subPresenter)
    }
}

interface SubView {
    var counter: Int
}

class SubPresenter(
    private val onAction: () -> Unit
): SummerPresenter<SubView>() {
    override val viewProxy = object : SubView {
        override var counter by state({ it::counter }, initial = 0)
    }
}

class Popup(
    private val presenter: SubPresenter
) : <...>, SubView {
    init {
        bindPresenter { presenter }
    }
}

Получается что-то похожее на компоненты во VueJS или ReactJS.

sealed State

Вместо AddToEndSingleTagStrategy в Moxy, в Summer можно использовать state с sealed классом.

sealed class ViewState {
    object Loading : ViewState()
    class Content(val counter) : ViewState()
}

interface View {
    var state: ViewState
}

class Presenter : SummerPresenter<View>() {
    override val viewProxy = object : View {
        override var state by state({ it::state }, initial = ViewState.Loading)
    }
}

Спасибо MaksimNovikov за этот трюк.

didSet in Kotlin

В Kotlin довольно громоздкий синтаксис для переопределения set свойства. Поэтому я написал делегаты didSet и didSetNotNull, вдохновляясь didSet из Swift. Пример использования

Обновление списка

Обновление элемента в списке:

data class Item(val id: Int, val count: Int)

interface View {
    var items: List<Item>
}

class Presenter : SummerPresenter<View>() {

    override val viewProxy = object : View {
        override var items by state({ it::items }, initial = emptyList())
    }

    fun increment(id: Int) {
        viewProxy.items = viewProxy.items.map { item ->
            if (item.id == id)
                item.copy(count = item.count + 1)
            else
                item
        }
    }
}

На самом деле, здесь вы не берёте состояние view, как может показаться. Делегат, созданный функцией state, хранит состояние в специальном store и view не может модифицировать это состояние напрямую.

Выводы

Резюмируя наш опыт разработки с использованием Summer и Kotlin/Multiplatform в целом:

Разработка с использованием Kotlin/Multiplatform почти ничем не отличается от обычной нативной разработки. Те iOS-программисты, которым я показывал, как это устроено, практически сразу начинали писать код в проект. Трудности возникали, в основном, с корутинами. Это новая концепция для iOS разработчика и с ними обычно нужно знакомить с 0. Впрочем, вы можете взять любую другую технологию для реализации асинхронности.

Мы постарались сделать Summer максимально интуитивной и простой в использовании для iOS-разработчиков. Можно писать в XCode. Вся платформенная часть может быть написана на Swift. Работает подсветка и дебаг общего кода на Kotlin в XCode при использовании плагина от TouchLab. Можно построить архитектуру, очень похожую на то, что популярно среди iOS-разработчиков. VIPER, Clean Swift и т.д. В своём проекте мы выбрали просто MVP.

У нас получилось реализовать библиотеку, которая не модифицирует процесс компиляции и использует исключительно стандартные конструкции языка Kotlin. Так как в Summer всё ещё и строго типизировано, unsafe casts почти не используются, ошибки обычно выводятся компилятором Kotlin в понятном виде. Основная проблема, которую мы пока не придумали, как решить элегантно — это как под iOS на Swift разрешить присоединять презентер только к правильному view. Мы работаем над этим.

Абсолютно нерелевантных ошибок вроде тех, что выдают библиотеки, использующие APT, при использовании Summer, у нас ни разу не было.

Есть проблема с тем, что внутри viewProxy есть boilerplate.

interface View {
    var counter: Int
}

class Presenter : SummerPresenter<View>() {
    override val viewProxy = object : View {
        override var state by state({ it::counter }, initial = 0)
    }
}

Код { it::counter } ничего не значит для клиентского кода. Теоретически, можно случайно передать ссылку на другое свойство такого же типа. У нас так не получалось потому что мы используем плагин, но у кого-нибудь может выстрелить. Я подумываю о том, чтобы написать lint на это.

Один узкий случай при реализации view

Также есть проблема с тем чтобы установить в state сложный объект при инициализации если приложение разрабатывается, в том числе, на Swift. На Kotlin делегаты didSet и didSetNotNull позволяют не передавать начальное значение при реализации view. На Swift внутри view для каждого свойства нужно задать начальное значение. Чаще всего, если объект сложный, его сначала нужно получить из сети. В этом случае свойство будет опциональным и в качестве начального значения можно задать nil. Если же свойство известно изначально — оно, скорее всего, примитивного типа.

Если же у вас, всё же, появился кейс когда нужно сделать state со сложным initial значением, то советую использовать не state, а event с RepeatLastStrategy и выполнять его в init блоке презентера.

По моему опыту, перевод проекта с Moxy на Summer и с Summer на Moxy занимает очень мало времени. У меня без спешки получалось перевести 15-20 средних экранов за вечер. Перевод iOS приложения при наличии Android-приложения на Summer у меня шёл по 3-4 экрана за вечер. Проблема была в том, как в этом приложении был реализован VIPER. Презентер часто дёргал методы ViewController-а, использовал много iOS-специфичных возможностей. Мне приходилось сначала рефакторить экраны, а уже потом присоединять их к презентерам на Kotlin. Опыта перевода приложения под iOS, которое не надо сначала рефакторить, у меня пока нет.

Также можно перевести 1-2 экрана чтобы попробовать. Kotlin имеет довольно хороший interop со Swift и Swift-у не так важно, на каком языке написан код. Классы, написанные на Kotlin, выглядят для Swift так, как будто они написаны на Objective-C с проставленными в правильных местах аннотациями совместимости.

Заключение

Спасибо всем, кто дочитал до конца! Надеюсь вы узнали что-то новое о Kotlin для себя, а возможно, даже попробуете Summer. Буду очень благодарен за конструктивный фидбэк.

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр