DRY требует, чтобы у каждого знания было одно место, KISS предлагает выбирать самое простое работающее решение, а YAGNI запрещает строить под требования, которых ещё нет. DRY вредит, когда объединяют код, который лишь выглядит похоже, но меняется по разным причинам.
DRY (Don't Repeat Yourself) касается знаний, а не символов: правило валидации, формула цены или базовый URL API должны жить в одном месте, чтобы изменение не пропустило копию.
KISS (Keep It Simple): when на три ветки лучше реестра плагинов; каждый лишний слой добавляет ещё одно место для бага.
YAGNI (You Aren't Gonna Need It): никакого интерфейса CacheStrategy «на будущее» и никакого обобщённого BaseRepository<T, ID> ради одной сущности. Абстракцию добавляют, когда появляется второй реальный случай и становится видно, что именно меняется.
Когда DRY вредит: регистрация и редактирование профиля проверяют имя похожим кодом, кто-то сливает их в одну функцию, и в ней появляется флаг isSignUp, потом второй. Это было случайное дублирование: сегодня код одинаковый, а причины меняться разные. Две маленькие функции обошлись бы дешевле.
AndroidMiddle
ООП, SOLID и паттерны проектирования
ООП и SOLID в Kotlin, DRY, KISS и YAGNI, связность и зацепление, внедрение зависимостей, паттерны GoF и где их используют Android и его библиотеки, антипаттерны
Закрыто вопросов
0 / 19
- 0Повторить
Вопросы
Связанность (coupling) показывает, насколько один класс зависит от того, как устроен другой; связность (cohesion) показывает, насколько части одного класса относятся к одному делу. Низкая связанность позволяет менять класс, не трогая соседей, а высокая связность даёт каждому классу одну понятную задачу.
Сильная связанность на Android: ViewModel, которая сама собирает Retrofit и читает SharedPreferences по ключу, знает, как устроены две чужие вещи. Замена HTTP-клиента или хранилища правит ViewModel, а тест не может подменить ни то, ни другое.
Как ослабить: зависеть от узкого интерфейса вроде UserRepository, получать его через конструктор и передавать через границу простые модели, а не DTO Retrofit или сущности Room.
Низкая связность: объект AppUtils с formatDate, hideKeyboard и md5 или UserManager, который логинит, кеширует аватары и шлёт аналитику. Его членов объединяет только файл, поэтому от него зависит половина приложения и он меняется по любой причине.
Высокая связность: DateFormatter, KeyboardExt.kt, AvatarCache: маленькие единицы, члены которых работают с одними данными.
Observer позволяет объекту (субъекту) оповещать любое число зависимых об изменениях, не зная, кто они. На Android им служат каждый листенер и колбэк, LiveData и Flow, где сбор (collect) означает подписку, а отмена корутины означает отписку.
Классическая форма: субъект хранит список и предлагает addListener/removeListener: View.OnClickListener, SensorEventListener, SharedPreferences.OnSharedPreferenceChangeListener. В Kotlin листенер записывают функциональным типом или fun interface.
**LiveData**: наблюдаемый контейнер, который знает жизненный цикл наблюдателя: он доставляет значения, только пока владелец в STARTED, и удаляет наблюдателя на DESTROYED.
**Flow и StateFlow**: подписка представляет собой работающий collect. Она заканчивается вместе с отменой скоупа, поэтому repeatOnLifecycle или collectAsStateWithLifecycle() привязывают её к экрану.
Поток субъекта: наблюдатели вызываются синхронно в том потоке, где изменился субъект, поэтому листенер, которого кормят из фонового потока, должен переключиться на main, прежде чем трогать view.
Правило, которое нарушают чаще всего: каждой подписке нужна отписка в парном колбэке жизненного цикла (onStart с onStop). Листенер, оставленный в долгоживущем субъекте, удерживает Activity и срабатывает в уже мёртвом экране.
Strategy прячет каждый вариант алгоритма за одним интерфейсом, чтобы вызывающий код выбирал или подменял его во время работы, а не ветвился. В Kotlin стратегия без состояния с одним методом часто оказывается просто функциональным типом, а Comparator и TimeInterpolator вы и так передаёте как стратегии.
На платформе: sortedWith(comparator), TimeInterpolator у аниматора, LayoutManager и ItemAnimator у RecyclerView, Arrangement, который передают в Row в Compose. Контейнер остаётся прежним, поведение меняет подключённый объект.
Функциональный тип: (Long) -> Long для правила скидки. Стратегией служит лямбда или ссылка на функцию, класс не нужен.
**fun interface: когда стратегия заслуживает имени в API или её реализуют вызывающие на Java.
Полноценный интерфейс**: когда методов несколько или стратегия несёт конфигурацию, например RetryPolicy с shouldRetry и delayMs.
Кто выбирает: стратегию внедряют или выбирают снаружи (настройка, фича-флаг, тариф пользователя). Стратегии ничего не знают друг о друге, поэтому новая не трогает ни вызывающий код, ни существующие.
Код выведет 20, а затем 16. Square нарушает принцип подстановки Лисков: код, написанный под Rectangle, рассчитывает, что ширина и высота меняются независимо, а переданный Square молча ломает это ожидание.
Чего требует принцип Лисков: подтип сохраняет контракт родителя, а не только сигнатуры. Он не может принимать меньше (более строгие предусловия), обещать меньше (более слабые постусловия), ломать инварианты родителя или бросать новые исключения.
Здесь: Rectangle неявно обещает, что изменение height не трогает width. Square сохраняет все сигнатуры и нарушает обещание, поэтому resize получает неверную площадь без всякой ошибки. Компилятор проверяет типы, а не контракты.
Тревожные признаки: переопределение, которое бросает UnsupportedOperationException или ничего не делает, либо вызывающий код, которому нужен if (shape is Square).
Исправление: не делать один тип подтипом другого. Обе фигуры моделируют неизменяемыми значениями за абстракцией только для чтения (sealed interface Shape { val area: Int }); «изменить ширину» превращается в copy(width = 5), а квадрат такого обещания давать не обязан.
Ни один клиент не должен зависеть от методов, которыми не пользуется: толстый интерфейс разбивают на маленькие ролевые, и один класс по-прежнему может реализовать их все. Методы по умолчанию избавляют от пустых переопределений лишь реализующих; вызывающие всё так же зависят от всего интерфейса.
Симптом в листенерах: у Animator.AnimatorListener четыре колбэка, у ViewPager.OnPageChangeListener три, и коду, которому нужен один, приходится писать пустые тела. Ответом Java был базовый класс с пустыми методами (AnimatorListenerAdapter, ViewPager2.OnPageChangeCallback), который съедает ваш единственный суперкласс.
Kotlin на стороне вызова: doOnEnd { } и doAfterTextChanged { } из core-ktx регистрируют полный листенер и показывают только нужный колбэк.
Методы по умолчанию: DefaultLifecycleObserver даёт каждому колбэку жизненного цикла пустое тело, и вы переопределяете только onStart. Это нормально, когда все методы описывают события одной сущности. Интерфейс, смешивающий несвязанные роли, так не исправить: клиент по-прежнему видит их все и перекомпилируется при изменении любой.
В своём коде: UserRepository на двадцать методов внедрён в экран, который только читает профиль. Разбейте его по ролям (ProfileReader, ProfileEditor, AvatarUploader); одна реализация может реализовать все три, а DI привязывает к ней каждый интерфейс.
Что это даёт: фейк в тесте реализует один метод вместо двадцати. Делите по ролям, которые есть у реальных клиентов; интерфейс на каждый метод сам по себе запах.
Нет. Интерфейс оправдан там, где реализацию нужно подменять: фейк в тестах, другая платформа, граница модуля, которая не должна видеть настоящий класс. IUserRepository в паре с единственным UserRepositoryImpl, который никто не заменяет, превращается в церемонию, удваивающую файлы и переходы.
Когда одной реализации всё же нужен интерфейс
Медленные зависимости или зависимости с побочными эффектами в тестах: сеть, база, часы, платежи. Фейковый PaymentGateway проще и стабильнее мока конкретного класса.
Граница модуля или слоя: :feature:checkout компилируется против CartRepository из :core:domain, а реализация живёт в :core:data, поэтому фича не может дотянуться до настоящего класса, а домен сам объявляет, что ему нужно (инверсия зависимостей).
Когда это церемония
Мапперы, форматтеры, валидаторы и use case с чистой логикой: тестируйте настоящий класс, это быстро и детерминированно.
Церемонию выдают префикс I и суффикс Impl: если у реализации нет имени лучше, чем у интерфейса, значит, способ сделать работу один.
«Чтобы можно было замокать» больше не довод: MockK мокает final-классы Kotlin, а Mockito 5 по умолчанию использует inline mock maker.
Здесь работает YAGNI: при внедрении через конструктор выделить интерфейс позже можно рефакторингом IDE за минуту, так что дождитесь второй реализации или теста, которому она нужна.
Фабричная функция прячет за именем то, как строится один объект; Factory Method позволяет подклассу решить, что создавать, переопределив метод, который вызывает фреймворк; Abstract Factory представляет собой один объект, создающий целое семейство связанных объектов, которые должны подходить друг к другу.
Фабричная функция: Toast.makeText, Intent.createChooser, fun from(dto) в companion. В Kotlin есть и фабричные функции с именем типа: MutableStateFlow(0), Job(), Channel() и CoroutineScope(...) на самом деле функции, поэтому они могут проверять аргументы и возвращать скрытый класс реализации, а конструктор так не умеет.
Factory Method: фреймворк владеет жизненным циклом и вызывает ваше переопределение, чтобы получить объект. RecyclerView.Adapter.onCreateViewHolder выбирает класс холдера по типу view; через FragmentFactory.instantiate и ViewModelProvider.Factory.create аргументы конструктора попадают во фрагменты и ViewModel, которые пересоздаёт система.
Abstract Factory: Converter.Factory в Retrofit создаёт конвертеры тела запроса, тела ответа и строк для одного формата; замените Gson на Moshi или kotlinx.serialization, и весь набор поменяется разом.
FragmentFactory нужно установить до super.onCreate(), потому что этот вызов уже пересоздаёт восстанавливаемые фрагменты.
Builder по-прежнему окупается, когда объект настраивают по шагам в разных местах, когда build() должен проверить опции вместе, когда из готового объекта выводят варианты и когда API библиотеки должен оставаться бинарно совместимым; OkHttp и Retrofit попадают во все четыре случая.
Пошаговая настройка: OkHttpClient.Builder собирает интерсепторы в цикле, получает HttpLoggingInterceptor только в debug-сборках и передаётся в функцию, которая добавляет certificate pinning. Вызову конструктора все значения нужны сразу, в одной строке.
Проверка в конце: Retrofit.Builder().build() бросает IllegalStateException("Base URL required."). Builder служит естественным местом для правил вида «эта опция требует ту».
Варианты: client.newBuilder() начинает с настроенного клиента и разделяет с ним пул соединений и dispatcher, поэтому вариант с более длинным таймаутом обходится дёшево. Для простых значений в Kotlin то же самое делает copy() у data class.
Бинарная совместимость: новый параметр со значением по умолчанию в опубликованном конструкторе меняет его JVM-сигнатуру, и уже скомпилированный код падает с NoSuchMethodError. Новый метод билдера только добавляется. По той же причине руководство Kotlin для авторов библиотек не советует data class в публичном API.
OkHttpClient.Builder изменяемый: addInterceptor добавляет интерсептор в сам base и возвращает тот же билдер, поэтому последующий base.build() создаёт второй клиент, в котором тоже есть AuthInterceptor, и каждый запрос картинки отправляет Authorization на CDN.
Почему так происходит: fluent-методы билдера написаны как apply { interceptors += interceptor } и возвращают this, а не копию. val apiClient = base.addInterceptor(...) читается как ответвление, но оба имени указывают на один билдер, и каждый build() снимает снимок того, что в нём лежит в этот момент.
Почему ничего не падает: оба клиента работают, а CDN игнорирует заголовок. Токен на чужом хосте покажет только лог прокси или security-ревью.
Исправление: собрать общий клиент один раз и выводить варианты через baseClient.newBuilder(), который копирует настройки клиента (вместе с интерсепторами) в новый билдер и не трогает исходный. Производный клиент использует тот же пул соединений и dispatcher, поэтому обходится почти даром.
Как поймать в следующий раз: тест с MockWebServer на CDN-клиенте, проверяющий, что takeRequest().getHeader("Authorization") равен null. На ревью должен настораживать билдер, сохранённый в переменную и собранный дважды.
По намерению: Adapter меняет интерфейс, чтобы несовместимый код подошёл друг к другу, Decorator сохраняет интерфейс и добавляет поведение, Proxy сохраняет интерфейс и управляет доступом к настоящему объекту, а Facade ставит один более простой интерфейс перед целой подсистемой.
Adapter: callback-API, превращённый в suspend-функцию через suspendCancellableCoroutine, листенер, превращённый во Flow через callbackFlow, CallAdapter в Retrofit, который превращает Call<T> в объявленный вами тип возврата. Клиент видит интерфейс, которого у обёрнутого объекта никогда не было.
**RecyclerView.Adapter**: лишь отчасти. Он приспосабливает ваши данные к тому, что спрашивает виджет (число элементов, тип view, создать и привязать холдер), но по сути служит источником данных, который RecyclerView вызывает обратно, и фабрикой view holder'ов.
Decorator: тот же интерфейс, дополнительное поведение, обёртки можно наслаивать: ContextThemeWrapper вокруг Context, LoggingRepo : Repo by inner, BufferedInputStream вокруг потока.
Proxy: тот же интерфейс, но он замещает настоящий объект и управляет доступом к нему: java.lang.reflect.Proxy в Retrofit за вашим интерфейсом API (локальный заместитель удалённого сервера), ленивая обёртка, кеш или проверка прав перед сервисом. Вызывающий не может отличить его от настоящего объекта.
Facade: новый, меньший интерфейс поверх многих объектов: Glide.with(view).load(url).into(imageView) прячет кеши, декодеры и потоки; репозиторий прячет Retrofit, Room и политику слияния.
Flyweight разделяет одно тяжёлое состояние между многими лёгкими объектами; Object Pool переиспользует изменяемые объекты вместо создания новых. Drawable из одного ресурса разделяют состояние, поэтому перед изменением одного нужен mutate(), а Message.obtain() берёт сообщение из пула.
Flyweight: общее состояние drawable
Все drawable, загруженные из одного ресурса, разделяют один Drawable.ConstantState (у BitmapDrawable в нём лежат битмап и его paint). Поменяйте alpha или тинт у одного, и изменится каждая иконка, нарисованная из этого ресурса. Состояние в классическом Flyweight неизменяемо, а здесь нет, и поэтому существует mutate().
mutate() даёт экземпляру собственную копию состояния; отменить это нельзя, и после вызова изменение остаётся локальным.
Object Pool (не паттерн GoF): Message.obtain()
Message.obtain() и handler.obtainMessage() берут сообщение из глобального пула размером до 50 сообщений, и post тоже работает через пул; Looper возвращает сообщение в пул после диспатча.
Цена пула в правиле времени жизни: после отправки сообщение принадлежит очереди и потом будет переиспользовано. Хранить на него ссылку или отправлять один экземпляр дважды означает баг, и MessageQueue бросает IllegalStateException с текстом, который заканчивается на «This message is already in use.»
Свой пул
Сборщик мусора ART делает короткоживущие аллокации дешёвыми. Пул окупается только для объектов, которые дорого создавать (битмапы, переиспользуемые через BitmapFactory.Options.inBitmap, большие буферы), или которые аллоцируются каждый кадр в onDraw или обработчике касаний; в остальных случаях он добавляет баги со сбросом состояния и удерживает память.
Запрос проходит по упорядоченному списку обработчиков, и каждый либо обрабатывает его, либо передаёт дальше, так что отправитель не знает, кто ответит. Интерсепторы OkHttp и диспатч касаний служат платформенными версиями; список DeepLinkHandler, перебираемых по порядку, вы пишете сами.
Две разновидности: «побеждает первый подошедший», когда ответивший обработчик останавливает цепочку (события касания, где onTouchEvent, вернувший true, забирает жест; маршрутизация диплинков). И конвейер, где каждый обработчик оборачивает следующий и может менять запрос на входе и ответ на выходе (chain.proceed в OkHttp, плагины Ktor).
Зачем: новый маршрут или новый сквозной шаг становится новым обработчиком, а не ещё одной веткой в when, которую правят все команды, и каждый обработчик тестируется отдельно.
Порядок задаёт поведение: обработчик «на всё», поставленный первым, проглатывает всё, что за ним. Собирайте список в одном месте и держите порядок явным.
Решите, что значит конец цепочки: необработанный запрос должен уходить в видимый фолбэк (главный экран, ошибку), а не молча пропадать.
С DI: мультибиндинги Hilt (@IntoSet) позволяют каждому фича-модулю добавить свой обработчик. На порядок обхода мультибиндингового Set не полагайтесь: дайте обработчикам приоритет и отсортируйте их.
Template Method фиксирует шаги алгоритма в базовом классе и даёт подклассам заполнить хуки. Activity устроена именно так: фреймворк выполняет свою работу вокруг вашего onCreate, поэтому без super приложение падает. Jetpack теперь передаёт поведение колбэками и наблюдателями вместо переопределения.
Где Android это использует: колбэки Activity и Fragment, View.measure() и draw(), которые вызывают ваши onMeasure и onDraw.
Контракт, который вы наследуете: базовый класс делает реальную работу внутри super.onCreate() (восстанавливает фрагменты и сохранённое состояние, рассылает ON_CREATE). Пропустите вызов, и фреймворк бросит SuperNotCalledException: ... did not call through to super.onCreate(); @CallSuper ещё раньше превращает это в ошибку Lint. onMeasure, который так и не вызвал setMeasuredDimension, бросает IllegalStateException.
Почему он плохо стареет: он занимает ваш единственный суперкласс, подкласс зависит от того, когда и в каком порядке база вызывает хуки, а несвязанные задачи скапливаются в одном переопределении, как один onActivityResult, разбирающий пять request code.
Что его заменило: Activity Result API регистрирует по колбэку на каждый лаунчер вместо onActivityResult; OnBackPressedCallback заменяет переопределение onBackPressed() (deprecated в API 33); DefaultLifecycleObserver позволяет компоненту реагировать на onStart без вызова из Activity.
Command превращает запрос в объект, который можно сохранить, поставить в очередь, залогировать или откатить. Runnable, отправленный в Handler, WorkRequest и action в MVI служат командами, а undo представляет собой стек команд, каждая из которых умеет откатить себя.
Что даёт объект: код, который решает, что должно произойти, отделён от кода, который это делает, и от момента выполнения. Запрос может ждать в очереди (WorkManager даже сохраняет WorkRequest в своей базе), пересечь границу, попасть в лог или быть воспроизведённым заново.
Action в MVI: sealed interface CartAction, передаваемый в onAction, описывает набор команд без привязанного исполнителя; ViewModel их интерпретирует, и у каждого намерения пользователя появляется одно место для логирования и тестов.
Undo и redo: каждая команда реализует execute и undo. Выполнение кладёт команду в стек undo и очищает redo; undo снимает её, откатывает и кладёт в redo. Храните только то, что нужно для отката (удалённый текст и его позицию), а не копию всего документа.
Подводные камни: команда в очереди, захватившая Activity или View, удерживает их до своего выполнения, а команде, которая должна пережить смерть процесса, нужны сериализуемые данные, а не ссылки на объекты.
Сделайте каждое состояние sealed-подтипом, который несёт только свои данные, и соберите все переходы в одну функцию (State, Event) -> State, которую компилятор проверяет на полноту. State отличается от Strategy тем, кто переключает: автомат переходит сам по событиям, а стратегию выбирают снаружи.
Состояния несут свои данные: у Sending(file, attempt) есть счётчик попыток, у Failed есть причина, у Idle нет ничего, поэтому невозможные комбинации вроде URL у упавшей загрузки просто не построить.
Одна функция переходов: вложенные when делают каждый допустимый переход явным, а событие, бессмысленное в текущем состоянии, возвращает состояние без изменений, как повторное нажатие Send во время загрузки.
**GoF State или when**: в классическом паттерне у каждого класса состояния свой onEvent, и поведение живёт рядом с состоянием. В Kotlin одна центральная чистая функция обычно понятнее и тестируется без моков; переносите методы в состояния, только когда у каждого много поведения.
State против Strategy: диаграмма классов одинаковая, намерение разное. Состояние загрузки само решает, каким будет следующее; стратегию расчёта доставки выбирает вызывающий, и она никогда не заменяет себя сама.
На Android: держите текущее состояние в MutableStateFlow и меняйте через update { reduce(it, event) }, чтобы два одновременных события не перемешались. Держите reduce чистой: update может вызвать её повторно, если другой поток опередил.
Интерфейс, методы которого реализует каждый тип, открыт для новых типов и закрыт для новых операций; sealed-иерархия с when устроена наоборот: новая операция становится просто новой функцией, а новый подтип ломает каждый when на этапе компиляции. Выбирайте по тому, какая ось меняется чаще.
Компромисс
Интерфейс: PaymentProvider из другого модуля добавляется ещё одним классом, и ничего существующего не меняется: хрестоматийный случай Open/Closed. Но новая операция, например политика возвратов, правит каждую реализацию, часто в модулях других команд.
**Sealed-тип с when**: операции живут вне типов (message(), analyticsEvent()), и новая операция не трогает ни одного подтипа. Новый подтип даёт ошибку компиляции в каждом исчерпывающем when, и это ровно та страховка, которая нужна, когда набор случаев фиксирован.
Правило: закрытый набор случаев при открытом наборе операций требует sealed (состояния экрана, результаты, события, дерево парсера). Открытый набор случаев, который пополняют плагины или другие модули, требует интерфейса (платёжные провайдеры, бэкенды аналитики, обработчики диплинков). Sealed-подтипы должны жить в том же модуле и пакете, так что фича-модуль вообще не может добавить свой.
Не отключайте проверку: ветка else у sealed-типа молча поглощает подтип, добавленный позже.
Почему Visitor в Kotlin редкость
Visitor нужен, чтобы добавлять операции к фиксированной иерархии в языке без исчерпывающего сопоставления: каждый тип реализует accept(visitor), а каждая операция становится классом-посетителем с visit на каждый тип (двойная диспетчеризация).
when со smart cast по sealed-типу даёт ту же диспетчеризацию и ту же проверку полноты без методов accept и классов-посетителей.
Visitor ещё встречается там, где иерархия огромная, глубокая или чужая: KSVisitor в KSP, UAST-визиторы в Lint.
Каждый уровень добавляет поведение всем экранам, даже тем, кому оно не нужно, порядок вызовов super становится скрытой связанностью, а одно изменение может сломать любой экран. Вынесите каждую задачу в то, что экран подключает сам: наблюдатель жизненного цикла, функцию-расширение, ViewModel или общий composable.
Что идёт не так
Всё для всех: база показывает диалог загрузки, проверяет сессию, шлёт аналитику, управляет клавиатурой и регистрирует ресивер сети, и экран настроек платит за всё это.
Скрытый порядок: BaseAnalyticsActivity.onResume() читает поле, которое ставит BaseAuthActivity.onCreate(). Подкласс, который вызывает super в другой момент, ломает экран где-то далеко: проблема хрупкого базового класса.
Суперкласс только один: экран, который должен наследовать Activity из библиотеки, или Fragment, которому нужно то же поведение, в цепочку не встроить, и код копируют.
Не тестируется: поведение работает только внутри настоящей Activity.
Рефакторинг, по одной задаче за раз
Перечислите, что делает каждый уровень и какие экраны этим действительно пользуются.
Работа, привязанная к жизненному циклу (проверка сессии, трекинг экранов, ресиверы), становится DefaultLifecycleObserver, который экран добавляет одной строкой. То, что действительно относится к каждой Activity, уходит в Application.ActivityLifecycleCallbacks вообще без наследования.
Хелперы без состояния вроде hideKeyboard() становятся функциями-расширениями Activity или Fragment.
Общее состояние (загрузка, ошибки) переезжает во ViewModel и переиспользуемый UI-компонент.
Удаляйте уровень, как только им никто не пользуется. В Compose-приложении с одной Activity базовый класс исчезает: поведение живёт в composable и ViewModel.
Обычные подозреваемые: god Activity, свалки Utils и Manager, глобальные изменяемые синглтоны и скопированная логика. Рефакторить стоит там, где концентрируются изменения и падения, под защитой характеризационных тестов, по одному маленькому шагу на коммит и никогда не переписывая с нуля.
Что искать
God Activity или Fragment: две тысячи строк сети, парсинга, кеширования, навигации и форматирования. Каждая фича правит этот файл, поэтому конфликты слияния и регрессии скапливаются в нём.
**Utils, Helper, Manager: названия, которые ничего не говорят о задаче, собирают несвязанные функции. Низкая связность, и от них зависит всё.
Глобальное изменяемое состояние**: object Session { var user: User? } или companion object, хранящий Context. Скрытые зависимости, утечки и тесты, которые проходят поодиночке и падают вместе.
Копипаста с расхождением: одна и та же валидация на трёх экранах, каждая чуть другая, и исправление попадает только в одну.
Спекулятивная обобщённость: event bus, обобщённый BaseRepository<T> или система плагинов ради одного случая.
Как рефакторить
Сначала измерьте: частота изменений по git log и топ падений в Crashlytics показывают, где болит. Некрасивый, но стабильный код подождёт.
Зафиксируйте поведение: характеризационные тесты вокруг логики, которую собираетесь переносить. Вынесенные чистые функции получают JVM-тесты, UI получает скриншот-тесты.
Выносите, а не переписывайте: логику из Activity переносите во ViewModel и репозиторий; Utils делите по вызывающим на функции-расширения рядом с местом использования; глобальный object заменяйте внедряемой зависимостью, а старый object пусть делегирует ей, пока не уйдёт последний вызывающий (подход strangler).
Закрепите результат: правила Detekt вроде LargeClass и TooManyFunctions, границы модулей, которые делают старую зависимость невозможной, и ревью.