Рефакторинг функций расширения в Kotlin: использование объекта-компаньона

Kate

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

Точка отсчёта​

Допустим, у нас есть такая функция:
fun Context.canUseBiometrics(): Boolean =
when (BiometricManager.from(this)) {
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
}
Описанный далее подход к рефакторингу можно применять и к функциям верхнего уровня, а также к методам синглтона:
fun canUseBiometrics(context: Context): Boolean = { /* implementation */ }

object BiometricUtils {
fun canUseBiometrics(context: Context): Boolean = { /* implementation */ }
}
Как видите, функции проверяют, доступна ли биометрия на текущем устройстве. Довольно простая и понятная логика для реализации в виде метода расширения.

Тестируемость​

Функции расширения по сути являются @JVMStatic методами конкретного вспомогательного класса. Вот Java-эквивалент этого метода:
class BiometricsUtils {
public static boolean canUseBiometrics(Context $this$) { /* implementation */ }
}
Вообще мы используем старомодный синглтон (определённый в области видимости класса с помощью статического модификатора), к которому можем обращаться из любого места кода, чтобы воспользоваться его логикой. А в чём главная проблема синглтонов? В тестируемости.
Рассмотрим такой случай:
class ScreenViewModel(applicationContext: Context): ViewModel() {
val displayBiometricsOption = MutableLiveData<Boolean>(false)

init {
displayBiometricsOption.value = applicationContext.canUseBiometrics()
}
}
Удобен ли этот код для тестирования? Не очень. Ведь даже если вы подставите в тесте заглушку Context, эффективно её использовать всё равно не получится, поскольку она опосредованно используется в BiometricManager. Вам нужно знать подробности реализации BiometricManager и то, как именно он использует Context, чтобы правильно настроить эту заглушку.
Решить эту проблему можно с помощью Robolectric или запуска теста на устройстве. Но нужно ли нам это? Эти варианты тестирования займут намного больше времени.

Усложнение логики​

Что ещё может произойти со вспомогательными функциями? Они могут сильно усложниться, а мы поздно это заметим. В контексте предыдущего примера представим, что у нас появилось новое требование: при каждой проверке доступности аппаратной биометрии нужно также проверять результат А/В-теста, чтобы активировать функциональность.
Во-первых, не следует отправлять в продакшен код, который я сейчас покажу (это просто пример). Во-вторых, рано или поздно вы всё равно столкнётесь с проблемой усложнения функций расширения в продакшен-коде. Никто не идеален.
Пример:
fun Context.canUseBiometrics(abTestStore: AbTestStore): Boolean =
if (abTestStore.isEnabled(AbTests.BIOMETRICS)) {
when (BiometricManager.from(context)) {
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
}
} else {
false
}
class ScreenViewModel(
applicationContext: Context,
++ abTestStore: AbTestStore
): ViewModel() {
val displayBiometricsOption = MutableLiveData<Boolean>(false)

init {
-- displayBiometricsOption.value = applicationContext.canUseBiometrics()
++ displayBiometricsOption.value = applicationContext.canUseBiometrics(abTestStore)
}
}
Получилась весьма неприглядная функция расширения, в которой смешаны бизнес-логика и логика данных. Её может быть трудно отрефакторить, если она применяется во многих местах.

Проблема большой кодовой базы​

А что мешает нам просто реализовать новый класс для обработки логики и внедрить в конструктор каждого класса, который его использует? Мешает размер пул-реквеста.
В больших кодовых базах функция расширения может использоваться в десятках и даже сотнях разных мест. И для каждого места в коде потребуется вносить изменения в соответствующий конструктор класса. Если у вас многомодульное приложение с прямыми и явными зависимостями, то может потребоваться явно объявить новый класс в виде зависимости в каждом модуле, который его использует.
Из-за всех этих изменений ваш пул-реквест может раздуться до гигантских размеров — и его будет сложно проверить. К тому же возрастёт риск пропустить ошибку.
С помощью описанного ниже подхода мы сможем реализовать каждый этап в виде отдельного пул-реквеста.

Замена функции расширения на синглтон​

Сначала признаем проблему использования синглтонов. Нам нужно заменить неявный синглтон на явный:
-- fun Context.canUseBiometrics(abTestStore: AbTestStore): Boolean { /* implementation */ }

++ object BiometricsUtils {
++ fun canUseBiometrics(context: Context, abTestStore: AbTestStore): Boolean { /* implementation */ }
++ }
class ScreenViewModel(
applicationContext: Context,
abTestStore: AbTestStore
): ViewModel() {
val displayBiometricsOption = MutableLiveData<Boolean>(false)

init {
-- displayBiometricsOption.value = applicationContext.canUseBiometrics(abTestStore)
++ displayBiometricsOption.value = BiometricsUtils.canUseBiometrics(applicationContext, abTestStore)
}
}

Волшебство объекта-компаньона интерфейса​

Теперь у нас есть класс, с которым можно работать. Поскольку мы стремимся к тестируемости, в будущем мы заменим прямые использования класса BiometricsUtils на интерфейс. Сейчас интерфейс выглядит так:
interface BiometricsUtils {
fun canUseBiometrics(context: Context, abTestStore: AbTestStore)
}
Но потом нам понадобится убрать из списка параметров метода Context и AbTestStore, потому что они одинаковы во всех местах вызова метода: Context — это контекст приложения, а AbTestStore — локальный синглтон в графе зависимостей.
interface BiometricsUtils {
fun canUseBiometrics()
}
Вернёмся к варианту с параметрами в методе и в конце дополнительным этапом мигрируем на вариант без них.
Теперь у нас есть интерфейс и синглтон-класс. Как нам их соединить друг с другом, чтобы потом не пришлось вносить изменения во всех случаях использования метода?
Нам поможет объект-компаньон.
Объекты-компаньоны появились на заре развития Kotlin, ещё до выхода версии 1.0. В то время их преимущества перед обычными объектами и статическими методами Java не были очевидны. Особенно потому, что при каждом обращении к компаньону приходилось использовать слово Companion.
class Foo {
companion object {
fun bar()
}
}

fun main() {
Foo.Companion.bar()
}
К счастью, требование использовать Companion отменили. И теперь мы можем обращаться к объектам-компаньонам в привычной манере — как к статическим функциям Java.
fun main() {
Foo.bar()
}
Более того, компилятор Kotlin достаточно сообразителен, чтобы различать вызовы методов интерфейса и его компаньона.
interface Foo {
companion object {
fun bar()
}
}

fun main() {
Foo.bar()
}
И поскольку объекты-компаньоны в Kotlin — это обычный синглтон-класс, они могут расширять классы и интерфейсы. Это приводит нас к объекту-компаньону, который реализует интерфейс своего родителя:
interface Foo {
fun bar()

companion object : Foo {
override fun bar()
}
}

fun main() {
Foo.bar()
}
Мы можем вызвать абстрактную функцию bar применительно к интерфейсу Foo, делегируя её объекту-компаньону Foo. Воспользуемся этой методикой для рефакторинга кода:
interface BiometricsUtils {
fun canUseBiometrics(context: Context, abTestStore: AbTestStore): Boolean

companion object : BiometricsUtils {
override fun canUseBiometrics(context: Context, abTestStore: AbTestStore): Boolean { /* implementation */ }
}
}
Мы по-прежнему можем спокойно использовать BiometricsUtils.canUseBiometrics(applicationContext, abTestStore). Теперь мы на шаг ближе к завершению рефакторинга.

Внедрение интерфейса в качестве значения по умолчанию​

Раз у нас теперь есть интерфейс, мы можем передать его в качестве параметра конструктора.
class ScreenViewModel(
applicationContext: Context,
abTestStore: AbTestStore,
++ biometricsUtils: BiometricsUtils = BiometricsUtils
): ViewModel() {
val displayBiometricsOption = MutableLiveData<Boolean>(false)

init {
-- displayBiometricsOption.value = BiometricsUtils.canUseBiometrics(applicationContext, abTestStore)
++ displayBiometricsOption.value = biometricsUtils.canUseBiometrics(applicationContext, abTestStore)
}
}
Как значение по умолчанию параметра biometricsUtils используем BiometricsUtils.Companion. Тогда нам не придётся менять код, создающий этот класс. Но это изменение важно и ещё по одной причине. Мы наконец-то можем протестировать ScreenViewModel с помощью JVM-тестов. BiometricsUtils является интерфейсом, и мы можем применить в тесте заглушку:
class ScreenViewModelTest {

@Test
fun `WHEN biometrics available THEN displayBiometricsOption true`() {
val utils = mock<BiometricsUtils> {
on { canUseBiometrics(any(), any()) } doReturn true
}

val viewModel = ScreenViewModel(
applicationContext = mock(),
abTestStore = mock(),
biometricsUtils = utils
)

assertEquals(true, viewModel.displayBiometricsOption.value)
}

}
Поскольку в качестве параметров canUseBiometrics мы используем только applicationContext и abTestStore, можно подставить пустые заглушки. Больше нет нужды в заглушках для других методов этих классов, как раньше. С помощью модульных тестов можно отдельно проверить логику метода canUseBiometrics, чтобы убедиться, что всё работает как нужно.

Убираем значение по умолчанию​

Теперь можно убрать значение по умолчанию параметра biometricsUtils и через DI-систему подставить реальное значение.
class ScreenViewModel(
applicationContext: Context,
abTestStore: AbTestStore,
-- biometricsUtils: BiometricsUtils = BiometricsUtils
++ biometricsUtils: BiometricsUtils
): ViewModel()
@Module
@InstallIn(SingletonComponent::class)
class BiometricsModule {
@Provide
fun biometricsUtils(): BiometricsUtils = BiometricsUtils
}

Улучшаем интерфейс​

Перенесём параметры biometricsUtils в конструктор класса и обновим все места его использования. Затем текущую функцию отметим как @Deprecated и добавим новую. Кроме того, поскольку мы уже убрали все использования BiometricsUtils.Companion, можно избавиться и от него самого.
interface BiometricsUtils {
++ @Deprecated
fun canUseBiometrics(context: Context, abTestStore: AbTestStore): Boolean

++ fun canUseBiometrics(): Boolean

-- companion object : BiometricsUtils {
-- override fun canUseBiometrics(context: Context, abTestStore: AbTestStore): Boolean { /* implementation */ }
-- }
}
Добавим новую реализацию BiometricsUtils:
class BiometricsUtilsImpl(
applicationContext: Context,
abTestStore: AbTestStore
) : BiometricsUtils {

fun canUseBiometrics(): Boolean { /* implementation */ }

@Deprecated
fun canUseBiometrics(context: Context, abTestStore: AbTestStore): Boolean =
canUseBiometrics()

}
Теперь через DI-систему предоставим новый класс:
@Module
@InstallIn(SingletonComponent::class)
class BiometricsModule {

-- @Provide
-- fun biometricsUtils(): BiometricsUtils = BiometricsUtils

++ @Binds
++ abstract fun biometricsUtils(impl: BiometricsUtilsImpl): BiometricsUtils

}
Можем убрать все применения старого метода и обновить использовавшие его тесты.

Заключение​

Мы смогли аккуратно отрефакторить метод расширения, поэтапно внося изменения. При этом мы не создали помехи коллегам и минимизировали количество конфликтов слияния.
Объект-компаньон интерфейса — это мощная функция, позволяющая использовать синглтоны, которые легко внедрить в конструктор и заменить заглушками.


 
Сверху