Рефакторинг функций расширения в 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<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 }

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

Заключение

Мы смогли аккуратно отрефакторить метод расширения, поэтапно внося изменения. При этом мы не создали помехи коллегам и минимизировали количество конфликтов слияния.

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

Читайте так же:

  • Как настраивать эффективную поисковую рекламу в Яндекс.Директе в 2021 годуКак настраивать эффективную поисковую рекламу в Яндекс.Директе в 2021 году Описание 15 апреля с 13:00 до 15:00 по московскому времени пройдет бесплатный вебинар «Как настраивать эффективную поисковую рекламу в Яндекс.Директе в 2021 году». Вебинар проведет Никита Кравченко. Ведущий специалист по работе с платным трафиком, eLama. Программа […]
  • Минэкономики: предустановка супераппов еще сильнее исказит конкуренцию на рынкеМинэкономики: предустановка супераппов еще сильнее исказит конкуренцию на рынке Минэкономики раскритиковало порядок предустановки отечественного ПО на смартфоны. Компьютеры и Smart TV. В министерстве считают. Что предустановка приложений-супераппов с разными сервисами, например. Таких как Яндекс и ВКонтакте. Приведет к монополизации рынка приложений несколькими […]
  • Опасная тенденция: большинство россиян откладывают обновление смартфоновОпасная тенденция: большинство россиян откладывают обновление смартфонов «Лаборатория Касперского» поделилась интересной статистикой. Как показали результаты опроса, проведённого в апреле 2021 года. Большинство россиян откладывают обновление устройств. Когда приходят уведомления.  Больше половины россиян (55%), увидев уведомление о необходимости […]
  • Ромеро объявил, что Sigil 2 будет модом для Doom IIРомеро объявил, что Sigil 2 будет модом для Doom II По информации PC Gamer, создатель и разработчик многих культовых компьютерных игр Джон Ромеро работает над SIGIL 2. Это будет мод для Doom II.Ромеро пояснил, что в SIGIL 2 будет несколько классных крутых уровней, которые будут последовательно дополнять друг друга. Они будут, как обычно […]