Когда только начинаешь разрабатывать под Android, самые неприятные баги появляются не из-за опечаток, а из-за систематических ошибок. Хардкод строк и цветов, корутины, которые живут сами по себе, попытки писать Compose по старинке, как старые view — всё это превращается в технический долг, который мешает развивать продукт. 

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

Анатолий Спитченко

Главный инженер-программист в банке ПСБ, преподаватель по Android-разработке

Ошибка 1. Хардкод строк и цветов

На первых порах кажется удобным писать строки и цвета прямо в коде. Кажется, что это помогает немного сэкономить время и быстрее увидеть результат на экране. Но это обманчивая простота. Через несколько недель разработки хардкод превращается в мину замедленного действия: локализовать текст невозможно, цвета приходится менять вручную по всему проекту, а тёмная тема ломает половину экранов.

Посмотрим на классический пример:

@Composable
fun Greeting() {
    Text("Привет, мир!") // Хардкод строки
}

На первый взгляд, ничего страшного: экран показывает нужный текст, приложение работает. Но представьте, что завтра продукту понадобится английская версия: "Привет, мир!" должно стать "Hello, world!". Если строки зашиты прямо в коде, вам придётся идти по всем функциям @Composable и менять текст вручную.

А теперь вспомним, что Android поддерживает локализацию через отдельные файлы ресурсов strings.xml. Переводчики могут работать с этими файлами, добавлять новые языки, не влезая в код. Если вы хардкодите текст прямо в @Composable, никакой инструмент автоматически его не вытянет, а значит, локализация превращается в ручной, одноразовый и потенциально опасный процесс: легко пропустить строку, сделать опечатку или забыть поменять что-то при изменении дизайна.

Правильный подход — вынести строку в ресурс:

<!-- values/strings.xml -->
<resources>
    <string name="hello_world">Привет, мир!</string>
</resources>

И использовать её через stringResource:

@Composable
fun Greeting() {
    Text(stringResource(R.string.hello_world)) // Без хардкода
}

Теперь у продукта могут быть десятки языков, но для вас всё сведётся к одной строчке: stringResource(R.string.hello_world).

И ещё один момент: такой подход упрощает не только локализацию, но и поддержку интерфейса. Если дизайнер решит изменить текст или вы захотите добавить переменные в строку ("Привет, $name!"), это делается в одном месте, а не по всему проекту.

Та же история с цветами. Хардкодить их удобно на этапе прототипа:

@Composable
fun ColoredBox() {
    Box(
        modifier = Modifier
            .background(Color(0xFFFF5722)) // Оранжевый напрямую в коде
            .size(100.dp)
    )
}

Но у этого подхода три больших минуса. Во-первых, нет централизованного управления — захочет дизайнер другой оттенок, и придётся менять его в десятках мест. Во-вторых, невозможно сделать тёмную и светлую тему: цвета зашиты, и придётся дублировать код. В-третьих, у цвета теряется смысл: 0xFFFF5722 не говорит ничего, а вот errorColor сразу позволяет понять, зачем он.

Поэтому цвета выносят в тему:

// theme/Colors.kt
val Orange500 = Color(0xFFFF5722) // Лучше вынести в объект темы

// Или расширить MaterialTheme:
fun Colors.myCustomColors() = copy(
    error = Orange500
)

И используют так:

Box(
    modifier = Modifier
        .background(MaterialTheme.colors.error) // Семантический цвет
        .size(100.dp)
)

Теперь у вас не «оранжевый 0xFFFF5722», а «цвет ошибки». И если дизайнер решит, что ошибки должны подсвечиваться не оранжевым, а красным, вы поменяете это в одном месте.

Хардкод всё ещё можно использовать, но только для отладки: временно подкрасить фон, проверить границы элементов. В продакшен такой код попадать не должен.

Правило: строки — в strings.xml, цвета — в тему. Даже если приложение на одном языке и с одной темой. Иначе очень быстро окажется, что вы не работаете над новым функционалом, а бесконечно чините мелкие баги и поддерживаете хаос.

Ошибка 2. Использование fragments и устаревших view

Когда вы открываете свежую Android Studio и создаёте новый проект, он сразу будет построен на Jetpack Compose. Это не случайность — с 2021 года именно Compose считается стандартом для Android. Он проще, современнее и избавляет от десятков проблем, над которыми разработчики бились годами. Но многие новички продолжают тянуть старые привычки: писать интерфейсы через XML-разметку и управлять ими с помощью fragment и findViewById. На первых порах это кажется безобидным: вы нашли туториал на YouTube 2018 года, скопировали код, у вас даже что-то заработало. Но у такого подхода есть последствия.

Фрагменты (fragment) изначально задумывались, чтобы разбивать экран на части, и действительно кажутся удобным решением, пока проект маленький. Но у них сложный жизненный цикл — много состояний (onCreate, onStart, onResume, onPause, onDestroyView), и нужно точно понимать, когда можно обращаться к данным или обновлять UI. Ошибка в этом порядке приводит к крашам. Например, вы загрузили данные в onCreateView(), пользователь свернул приложение, а потом вернулся — и внезапно всё упало, потому что в этот момент view уже уничтожен, а корутина или callback всё ещё пытается обновить интерфейс.

Навигация — отдельная боль. Раньше, чтобы перейти с одного фрагмента на другой, нужно было вручную управлять back stack — стеком экранов, куда добавляются и из которого удаляются фрагменты при переходах. Чем больше экранов, тем выше шанс что-то перепутать: не тот фрагмент открылся, а старый не закрылся, и вот уже пользователь видит два слоя UI поверх друг друга. 

Если проект живёт дольше пары месяцев, добавляются новые проблемы. Нужно сделать адаптивную вёрстку под планшеты, добавить анимации, переработать логику переходов. Внезапно каждая правка превращается в борьбу с костылями. Вместо развития продукта команда чинит архитектуру, которую давно пора переписать.

Compose решает это иначе: экран описывается функцией @Composable, а переходы управляются декларативной навигацией. Вы работаете не с «куском интерфейса в XML-файле», а с функцией, которая получает состояние и рисует UI на лету. Это делает код проще и понятнее.

Если у вас проект на старых view и фрагментах, не надо переписывать всё за один день. Начните с самых независимых частей интерфейса — кнопок, карточек, небольших экранов. Постепенно эти куски переводите на Compose, и со временем проект становится «чистым». Такой подход спасает от резкого перехода и даёт команде привыкнуть к новому стилю.

Фрагменты и XML-вёрстка не запрещены, они всё ещё работают. Но если в 2025 году вы начинаете новый проект с них, то сознательно строите продукт на старом фундаменте. Это как открыть кофейню и вместо эквайринга поставить кассовый аппарат с жетонами: вроде работает, но уже вчерашний день.

Правило: если проект новый, сразу выбирайте Single Activity с Jetpack Compose. Этот подход упрощает навигацию, снимает массу проблем с жизненным циклом и позволяет быстрее доставлять фичи. Если же у вас уже есть приложение на XML и фрагментах, не беритесь переписывать всё в один заход. Переводите интерфейсы по частям: сначала независимые виджеты и маленькие экраны, потом более крупные разделы. Иначе вместо развития продукта вы рискуете всё время бороться с наследием архитектуры и тратить ресурсы на фиксы.

Ошибка 3. Null-safety и Java-подход

Многие новички приходят в Android-разработку из Java и продолжают писать на Kotlin так, как привыкли: длинные проверки на null, вложенные if, громоздкие конструкции. Формально такой код работает, но он выглядит тяжёлым и неудобным в поддержке. А главное, в Kotlin уже встроены инструменты, чтобы избежать всей этой рутины.

Возьмём пример. Есть простой класс:

data class User(val name: String? = null)

И нам нужно взять поле name, привести его к верхнему регистру и вывести в лог. В стиле Java это выглядело бы так:

if (user != null && user.name != null) {
    println(user.name.uppercase())  
}

Код работает, но выглядит избыточно. При большом количестве таких проверок программа превращается в лес из if (x != null). Читать это тяжело, а пропустить ошибку — легко.

Kotlin предлагает другой подход. Тот же пример можно написать куда проще:

user?.name?.let { 
    println(it.uppercase()) 
}

Вместо вложенных проверок мы используем «safe call» (?.) и функцию let. Если user или name окажется null, код просто не выполнится. Коротко, читаемо, и риск пропустить NPE (NullPointerException) минимален.

Ещё один приём — оператор ?:, его называют Elvis-оператором. Он позволяет указать значение по умолчанию:

val upperName = user?.name?.uppercase() ?: "Unknown"
println(upperName)

Если name окажется пустым, в лог уйдёт строка "Unknown", и приложение не упадёт.

Большинство непонятных падений приложений у новичков связаны именно с null. Программа работает, пока данные идеальные. Но стоит серверу прислать пустое поле или пользователю сбросить настройки — и всё рушится. Safe call и Elvis избавляют от этих сюрпризов.

Правило: в Kotlin не нужно защищаться от null длинными проверками, как в Java. Используйте безопасные вызовы (?.), let, оператор ?:. Это делает код короче, яснее и надёжнее.

Ошибки с корутинами

Корутины — мощный инструмент для фоновых задач, но важно понимать, как они связаны с жизненным циклом экрана или компонента. Если запустить корутину просто так, она может продолжать работать даже после того, как экран закрыт. Это не баг Kotlin, а особенность работы с асинхронностью: приложение продолжает выполнять задачу, которой уже никто не управляет.

Пример 1. Утечка памяти из-за Activity

Посмотрим на такой код:

class MyActivity : AppCompatActivity() {
    private val scope = CoroutineScope(Dispatchers.IO)

    fun loadData() {
        scope.launch {
            fetchData() // Корутина переживёт уничтожение Activity!
        }
    }
}

Здесь корутина продолжает жить даже после того, как Activity уничтожена. Получается, что в памяти висит уже ненужный экран, а сборщик мусора не может его убрать. Более того, корутина может попытаться обновить UI через this@MyActivity, которого уже нет. Итог — краш.

Правильный подход — использовать встроенные scope, которые привязаны к жизненному циклу:

class MyActivity : AppCompatActivity() {
    fun loadData() {
        lifecycleScope.launch {
            fetchData() // Автоматически отменится при уничтожении Activity
        }
    }
}

В ViewModel вместо этого используют viewModelScope.

Пример 2. Множественные запросы по клику

Давайте разберёмся, что не так с этим кодом:

fun onClick() {
    viewModelScope.launch { loadData() }  // При каждом клике — новая корутина
}

На первый взгляд, всё нормально, но если пользователь быстро кликнет кнопку пять раз, вы получите пять параллельных сетевых запросов. Результаты придут в случайном порядке, и UI начнёт прыгать.

Чтобы исправить ошибку, нужно хранить ссылку на текущую задачу (job) и отменять старую перед запуском новой. Тогда одновременно будет выполняться только один актуальный запрос:

private var job: Job? = null

fun onClick() {
    job?.cancel()
    job = viewModelScope.launch { loadData() }
}

Теперь всегда будет выполняться только один актуальный запрос.

Пример 3. Злоупотребление GlobalScope

GlobalScope — это глобальная область видимости для корутин. Корутины, запущенные в ней, живут столько же, сколько само приложение, и не привязаны к жизненному циклу конкретного activity или ViewModel.

Пример:

fun loadData() {
    GlobalScope.launch {
        fetchData() // Живёт всё время работы приложения
    }
}

На первый взгляд, удобно: корутина не зависит от экрана, её не нужно отменять. Но именно это создаёт проблемы:

  1. Утечки памяти. Корутина может продолжать ссылаться на объекты, которые уже уничтожены.

  2. Непредсказуемое поведение. Результаты фоновой работы могут прийти на давно закрытый экран, приводя к падениям или гонкам данных.

  3. Сложность отладки. Когда задачи выполняются «где-то в фоне», сложно понять, какая из них вызывает проблему.

Использовать GlobalScope стоит только для задач, которые действительно должны жить «вечно» вместе с приложением, например, глобальной синхронизации состояния или логов. Для всего остального лучше использовать viewModelScope или lifecycleScope.

Правило: корутины должны жить не дольше, чем тот объект, для которого они запущены. Для UI — это lifecycleScope, для ViewModel — viewModelScope. Если нужен ручной контроль, храните job и отменяйте лишние задачи. А GlobalScope оставьте для редких случаев, когда задача действительно должна жить всё время работы приложения.

Резюмируем 

Что важно запомнить об ошибках в Android-разработке:

  • Хардкод строк и цветов. Не пишите "Привет, мир!" или Color(0xFFFF5722) напрямую. Выносите строки в strings.xml, цвета в тему (MaterialTheme). Это упрощает локализацию, поддержку и тёмную тему.

  • Фрагменты и старые view. Если проект новый, используйте только Single Activity + Compose. Старый проект переносите постепенно, начиная с независимых виджетов.

  • Null-safety. Не пишите громоздкие if (x != null). Используйте ?., let и ?: для безопасного и читаемого кода.

  • Корутины не запускайте в GlobalScope. Привязывайте к жизненному циклу (viewModelScope, lifecycleScope) и отменяйте старые job, чтобы избежать утечек и гонок запросов.


Научиться создавать мобильные приложения под Android без ошибок можно на расширенном и обновлённом курсе «Android-разработчик». Сможете добавить до 9 проектов в своё портфолио и попрактиковаться проходить технические собеседования с экспертами. А в качестве дипломной работы разработаете мобильное приложение для соцсети. В новой программе с нейросетями доступно ещё больше навыков. Оценить программу и бонусы →

Создание приложений под Android и сотни других навыков доступны в Базе знаний по подписке. Более 10 000 видео по разным темам: от IT и Digital до саморазвития и хобби. Подписка на 1-3-6 месяцев. Попробовать на 2 недели бесплатно →