Рефакторинг функций расширения в Kotlin: использование объекта-компаньона
В 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(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(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(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(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 {
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
}
Можем убрать все применения старого метода и обновить использовавшие его тесты.
Заключение
Мы смогли аккуратно отрефакторить метод расширения, поэтапно внося изменения. При этом мы не создали помехи коллегам и минимизировали количество конфликтов слияния.
Объект-компаньон интерфейса — это мощная функция, позволяющая использовать синглтоны, которые легко внедрить в конструктор и заменить заглушками.