Что такое Context и какой он бывает

text
android.content.Context          ← абстрактный базовый класс
├── ContextImpl                  ← внутренняя реализация
├── ContextWrapper               ← декоратор
│   ├── Application              ← живёт всё время жизни процесса
│   ├── ContextThemeWrapper      ← добавляет тему
│   │   └── Activity             ← живёт до destroy экрана
│   └── Service                  ← живёт пока работает сервис
ТипКогда использоватьТипичные ошибки
ApplicationСинглтоны, репозитории, DI-графПопытка показать Dialog / надуть View с темой Activity
ActivityUI: Dialog, inflate, startActivityУтечка памяти, если хранить дольше onDestroy
ServiceФоновые операцииТо же, что и Activity

Уже на этом этапе видно: под видом одного типа скрываются сущности с принципиально разным временем жизни и возможностями.


S — Single Responsibility Principle

Проблема

Context содержит ~100+ публичных методов, сгруппированных в совершенно несвязанные области:

kotlin
// Ресурсы
getResources()
getString(R.string.app_name)
getColor(R.color.primary)
getDrawable(R.drawable.icon)

// Файловая система
openFileInput("data.txt")
getFilesDir()
getCacheDir()
deleteFile("data.txt")

// Запуск компонентов
startActivity(intent)
startService(intent)
sendBroadcast(intent)

// Разрешения
checkSelfPermission(Manifest.permission.CAMERA)
requestPermissions(...)

// Системные сервисы (Service Locator!)
getSystemService(Context.LOCATION_SERVICE)
getSystemService(Context.CLIPBOARD_SERVICE)

// Темы и UI
getTheme()
setTheme(R.style.AppTheme)

// Базы данных
openOrCreateDatabase("app.db", MODE_PRIVATE, null)
databaseList()
deleteDatabase("app.db")

// Уведомления, обои, вибрация, буфер обмена, пакетный менеджер...

У этого класса нет одной причины для изменения. Любая новая системная capability в Android добавляется сюда же. Это антипаттерн God Object в чистом виде.

Что делать на уровне приложения

Мы не можем изменить SDK, но можем не плодить зависимость от Context внутри своей кодовой базы:

kotlin
// ❌ Плохо: класс зависит от Context целиком
class UserRepository(private val context: Context) {
    fun getDb() = context.openOrCreateDatabase("users.db", MODE_PRIVATE, null)
    fun getCacheDir() = context.cacheDir
}

// ✅ Хорошо: зависимость только от того, что нужно
class UserRepository(
    private val dbFactory: DatabaseFactory,
    private val cacheDir: File
)

O — Open/Closed Principle

Проблема

Context закрыт для расширения. Вы не можете добавить в него новую системную возможность, не дожидаясь нового API в Android SDK. Каждый новый сервис — новый метод getSystemService или новый метод в самом Context.

Исторический пример: чтобы получить WindowManager, вы пишете:

kotlin
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager

А начиная с API 23 появился context.getSystemService(WindowManager::class.java). Старый код работает, но новый способ не расширяет старый, а добавляет параллельный путь.

Что делать

Оборачивать Context-зависимости в свои абстракции, открытые для расширения:

kotlin
// Абстракция в вашем модуле
interface ResourceManager {
    fun string(@StringRes id: Int): String
    fun color(@ColorRes id: Int): Int
    fun drawable(@DrawableRes id: Int): Drawable
}

// Реализация на границе с фреймворком
class AndroidResourceManager(private val context: Context) : ResourceManager {
    override fun string(id: Int) = context.getString(id)
    override fun color(id: Int) = ContextCompat.getColor(context, id)
    override fun drawable(id: Int) = ContextCompat.getDrawable(context, id)!!
}

Новая возможность = новый метод в ResourceManager, а не правка по всему проекту.


L — Liskov Substitution Principle

Проблема

Это самое больное место Android Context. Формально Activity — это Context. Но замена одного типа Context на другой ломает поведение:

kotlin
fun showDialog(context: Context) {
    // Скомпилируется с ЛЮБЫМ Context
    AlertDialog.Builder(context)   // ← но с Application Context будет краш
        .setTitle("Удалить?")
        .setMessage("Файл будет удалён навсегда")
        .setPositiveButton("Да") { _, _ -> deleteFile() }
        .show()
}
Что делаемApplicationActivityService
getString()
startActivity()⚠️ нужен FLAG_ACTIVITY_NEW_TASK⚠️ нужен флаг
showDialog()❌ краш❌ краш
inflate(R.layout.x, root)⚠️ без темы✅ с темой⚠️ без темы
getTheme()базоваятема из манифестабазовая

Подстановка Application вместо Activity в код, ожидающий UI-контекст, приводит к BadTokenException. Это прямое нарушение LSP: подтип не сохраняет контракт базового типа.

Что делать

kotlin
// ❌ Плохо: принимаем абстрактный Context, не зная, что внутри
fun inflateCard(context: Context): View {
    return LayoutInflater.from(context).inflate(R.layout.card, null)
}

// ✅ Хорошо: явно требуем контекст с темой
fun inflateCard(context: ContextThemeWrapper): View {
    return LayoutInflater.from(context).inflate(R.layout.card, null)
}

// ✅ Ещё лучше: принимаем готовый LayoutInflater
fun inflateCard(inflater: LayoutInflater): View {
    return inflater.inflate(R.layout.card, null)
}

В современном Android (Jetpack Compose) эта проблема частично уходит: LocalContext передаётся через Composition, и тип контекста контролируется средой.


I — Interface Segregation Principle

Проблема

Когда вы передаёте Context в утилиту или хелпер, потребитель получает весь набор из 100+ методов, даже если ему нужен один:

kotlin
// Нужен только getString()
fun formatPrice(context: Context, amount: Long): String {
    return context.getString(R.string.price_format, amount / 100.0)
}

// Но вызывающий код вынужден передать ВЕСЬ контекст
formatPrice(activity, 19990L)

Потребитель не должен знать о startActivity, openDatabase, sendBroadcast, если он просто форматирует строку. Но сигнатура метода говорит обратное.

Что делать

kotlin
// ✅ Передаём минимально необходимое
fun formatPrice(resources: Resources, amount: Long): String {
    return resources.getString(R.string.price_format, amount / 100.0)
}

// ✅ Или выделяем узкий интерфейс
interface StringProvider {
    fun string(@StringRes id: Int, vararg args: Any): String
}

fun formatPrice(strings: StringProvider, amount: Long): String {
    return strings.string(R.string.price_format, amount / 100.0)
}

В модульной архитектуре это критично: feature-модуль не должен зависеть от Context, если ему нужны только ресурсы или строки.


D — Dependency Inversion Principle

Проблема

Context — это конкретный класс фреймворка, а не абстракция. Любой код, принимающий Context, привязан к Android SDK:

kotlin
// ❌ Доменная логика зависит от Android
class TaxCalculator(private val context: Context) {
    fun calculate(order: Order): BigDecimal {
        val rate = context.resources
            .openRawResource(R.raw.tax_rates)  // ← Android в домене
            .bufferedReader().use { it.readText() }
        return parseAndCalculate(rate, order)
    }
}

Такой класс невозможно протестировать без Robolectric или инструментальных тестов, потому что Context нельзя просто замочить.

Отдельная боль — getSystemService, который является встроенным Service Locator:

kotlin
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

Вы получаете конкретную реализацию по строковой константе. Ни инверсии, ни типобезопасности.

Что делать

kotlin
// ✅ Домен не знает про Android
interface TaxRateProvider {
    fun loadRates(): List<TaxRate>
}

class TaxCalculator(private val rateProvider: TaxRateProvider) {
    fun calculate(order: Order): BigDecimal {
        val rates = rateProvider.loadRates()
        return parseAndCalculate(rates, order)
    }
}

// ✅ Реализация на границе
class AndroidTaxRateProvider(private val context: Context) : TaxRateProvider {
    override fun loadRates(): List<TaxRate> {
        return context.resources.openRawResource(R.raw.tax_rates)
            .bufferedReader().use { it.readText() }
            .let(::parseRates)
    }
}

Тестирование становится тривиальным:

kotlin
@Test
fun `calculate tax for order`() {
    val calculator = TaxCalculator(FakeTaxRateProvider(listOf(TaxRate("RU", 0.20))))
    val result = calculator.calculate(order)
    assertEquals(BigDecimal("20.00"), result)
}

Сводная таблица

ПринципНарушение в Android ContextПрактическое решение
SRP~100 методов: ресурсы, файлы, intents, сервисы, БД, разрешенияОбёртки-адаптеры с одной зоной ответственности
OCPНовые возможности = новые методы в SDK, не расширяемоСвои интерфейсы поверх Context
LSPApplication ≠ Activity для UI-операций, подстановка ломает поведениеЯвные типы (ContextThemeWrapper, Activity), не абстрактный Context
ISPПередавая Context, отдаём 100+ методов ради одного getStringПередача Resources, StringProvider, конкретных данных
DIPДомен зависит от конкретного класса фреймворкаАбстракции на границе, Context только в инфраструктурном слое

Практические правила для Android-проекта

1. Context не должен покидать инфраструктурный слой

text
┌─────────────────────────────┐
│        Presentation         │  ← Activity/Fragment знают про Context
├─────────────────────────────┤
│          Domain             │  ← НЕ знает про Context. Вообще.
├─────────────────────────────┤
│         Data / Infra        │  ← Context живёт здесь, за интерфейсами
└─────────────────────────────┘

2. Не храните Context в синглтонах без необходимости

kotlin
// ❌ Утечка Activity
object Analytics {
    lateinit var context: Context   // если передали Activity — утечка
}

// ✅ Application Context, если действительно нужен
object Analytics {
    fun init(app: Application) {
        this.context = app.applicationContext  // именно applicationContext
    }
}

3. Используйте applicationContext, когда не нужен UI

kotlin
class ApiClient(context: Context) {
    private val appContext = context.applicationContext  // отвязываемся от Activity
    private val cacheDir = appContext.cacheDir
}

4. Минимизируйте передачу через extension-функции

kotlin
// Вместо передачи Context повсюду
fun View.showSnackbar(message: String) {
    Snackbar.make(this, message, Snackbar.LENGTH_SHORT).show()
}

// Вместо context.getString(...)
fun Fragment.toast(@StringRes resId: Int) {
    Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show()
}

5. В Compose-мире Context уже не течёт как раньше

kotlin
@Composable
fun PriceCard(amount: Long) {
    // Context доступен через LocalContext, но не передаётся в бизнес-логику
    val context = LocalContext.current

    // Лучше: вынести форматирование в ViewModel или use-case
    val formatted = viewModel.formatPrice(amount)
    Text(formatted)
}

Историческая справка: почему так вышло

Context появился в Android 1.0 (2008), когда Java не имела DI-фреймворков, Kotlin не существовал, а архитектура приложения сводилась к одному Activity. Context был единственной точкой входа к системе, и это было оправдано для того уровня сложности.

Сегодня, когда приложение — это десятки модулей, Clean Architecture, Hilt/Koin, Compose и многопроцессность, Context остаётся архитектурным долгом уровня SDK, который невозможно удалить без обратной несовместимости.


Заключение

Context в Android — это вынужденный God Object на уровне фреймворка, который мы не можем исправить. Но мы можем и должны контролировать его влияние в нашем коде:

  • Не передавать Context глубже, чем необходимо.
  • Оборачивать в узкие интерфейсы на границе с фреймворком.
  • Не хранить Activity Context дольше жизненного цикла экрана.
  • Не пускать Context в доменный слой.
  • Понимать, какой именно тип Context нужен: Application, Activity или Service.

Context — это мост между вашим приложением и Android. Мост должен быть на границе, а не проходить через каждую комнату в доме.