Поворот экрана и жизненный цикл Fragment

Два вопроса, которые задают подряд: «что происходит при повороте экрана» и «чем цикл фрагмента отличается от цикла Activity». Второй — фильтр на тех, кто реально писал фрагменты.

Configuration change — изменение конфигурации устройства (ориентация, размер окна, язык, тема, размер шрифта). По умолчанию Android уничтожает Activity и создаёт её заново, чтобы она подтянула ресурсы под новую конфигурацию.

Вопрос 1: «Что происходит при повороте экрана?»

Что проверяет интервьюер

Понимаете ли вы, зачем система вообще пересоздаёт экран (а не «почему это баг Android»), и знаете ли три разных механизма сохранения состояния — они переживают разные катастрофы.

Развёрнутый ответ

Поворот — это смена конфигурации. Разметка, строки и размеры могут отличаться для портрета и ландшафта (layout-land/, values-sw600dp/), поэтому проще всего пересоздать экран с нуля, чем пытаться «переприменить» ресурсы. Порядок такой:

onPause → onStop → onSaveInstanceState → onDestroy
    (новый экземпляр Activity)
onCreate(savedInstanceState) → onStart → onRestoreInstanceState → onResume

Что при этом выживает — вопрос на засыпку. Механизмов три, и они покрывают разные сценарии:

МеханизмПереживает поворотПереживает смерть процессаОграничения
Поля Activityнетнет
onSaveInstanceState (Bundle)дадатолько Parcelable, суммарно сотни килобайт
ViewModelданетумирает вместе с процессом
SavedStateHandle внутри ViewModelдадатот же лимит размера, что у Bundle

ViewModel переживает поворот не магией: ViewModelStore сохраняется системой как «non-configuration instance» и передаётся новому экземпляру Activity. При этом ViewModel не переживает убийство процесса — для этого и нужен SavedStateHandle.

class SearchViewModel(
    private val state: SavedStateHandle
) : ViewModel() {

    // переживёт и поворот, и смерть процесса
    val query: StateFlow<String> = state.getStateFlow("query", "")

    // переживёт только поворот
    private val _results = MutableStateFlow<List<Item>>(emptyList())
    val results: StateFlow<List<Item>> = _results

    fun onQueryChanged(value: String) {
        state["query"] = value
    }
}

Про Bundle обязательно скажите про размер: он передаётся через Binder-транзакцию, лимит которой около 1 МБ на процесс. Попытка положить туда список объектов или битмап даёт TransactionTooLargeException — уже на устройстве пользователя, а не в дебаге.

Типичные ошибки кандидатов

  • Предлагают android:configChanges="orientation|screenSize" как «решение». Это не решение, а отключение механизма: Activity перестаёт пересоздаваться, вы получаете onConfigurationChanged и обязаны руками переприменить все ресурсы. И это всё равно не спасёт от смерти процесса — то есть баг вы не убрали, а спрятали.
  • Говорят «ViewModel решает проблему сохранения состояния». Только половину: перезапуск процесса он не переживает.
  • Забывают, что смена темы «светлая/тёмная», размера шрифта в настройках, языка и режима split-screen — это тоже configuration change с тем же пересозданием.

Вопрос 2: «Чем жизненный цикл Fragment отличается от Activity?»

Что проверяет интервьюер

Знание ключевого факта: у фрагмента два жизненных цикла — самого фрагмента и его View. Их рассинхрон — причина половины утечек и «двойных подписок» в реальных проектах.

Развёрнутый ответ

Помимо привычных колбэков у фрагмента есть свои: onAttachonCreateonCreateViewonViewCreatedonStartonResume и обратно onPauseonStoponDestroyViewonDestroyonDetach.

Главное отличие — onDestroyView. Когда фрагмент уходит в back stack, его View уничтожается, а сам объект фрагмента остаётся жив. Потом при возврате вызывается новый onCreateView. То есть один экземпляр фрагмента может пережить несколько View.

Отсюда два практических следствия, которые и хотят услышать:

class ProfileFragment : Fragment(R.layout.fragment_profile) {

    private var _binding: FragmentProfileBinding? = null
    private val binding get() = _binding!!

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        _binding = FragmentProfileBinding.bind(view)

        // ВАЖНО: viewLifecycleOwner, а не this
        viewModel.user.observe(viewLifecycleOwner) { user ->
            binding.name.text = user.name
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null   // иначе фрагмент удержит мёртвую иерархию View
    }
}
  1. Обнуляйте binding в onDestroyView. Иначе живой фрагмент держит ссылку на уничтоженную иерархию View — это классическая утечка памяти, которую ловит LeakCanary.
  2. Подписывайтесь на viewLifecycleOwner, а не на this. Если передать сам фрагмент, подписка не снимется при уничтожении View. Вернувшись из back stack, фрагмент подпишется ещё раз — и колбэк начнёт срабатывать дважды, трижды, четырежды. Симптом «после возврата назад экран мигает и запросы дублируются» почти всегда об этом.

Типичные ошибки кандидатов

  • Считают, что фрагмент — «просто маленькая Activity». Тогда не объяснить, зачем нужен viewLifecycleOwner.
  • Кладут инициализацию View в onCreate фрагмента — там View ещё не существует.
  • Не знают, что фрагмент должен иметь публичный конструктор без аргументов: система пересоздаёт его сама, а аргументы передаются через Bundle в arguments.

Как ответить кратко (20–30 секунд)

«Поворот — это configuration change: Activity уничтожается и создаётся заново, чтобы подтянуть ресурсы под новую конфигурацию. Состояние сохраняю тремя способами: ViewModel переживает поворот, но не смерть процесса; onSaveInstanceState и SavedStateHandle переживают оба случая, но ограничены размером Binder-транзакции. configChanges отключать не стоит — это маскирует проблему. У фрагмента же два жизненных цикла: свой и у View. Поэтому binding обнуляю в onDestroyView, а на данные подписываюсь через viewLifecycleOwner, иначе после возврата из back stack получаю дублирующиеся подписки.»

Проверьте себя
1. Приложение свернули, система убила процесс, пользователь вернулся. Что из перечисленного сохранит данные?
Aобычное поле в ViewModel
Bполе самой Activity, помеченное как lateinit
CSavedStateHandle внутри ViewModel
Dстатическое поле в companion object
2. Во фрагменте подписались на LiveData, передав в observe() сам фрагмент (this). Что произойдёт после ухода в back stack и возврата?
Aподписка снимется автоматически при onDestroyView, поведение корректное
Bприложение упадёт с IllegalStateException при повторном observe
CLiveData перестанет присылать значения, пока фрагмент не пересоздадут
Dнакопятся дублирующиеся подписки, и колбэк начнёт срабатывать по несколько раз
3. Почему в Bundle из onSaveInstanceState нельзя класть большие списки или изображения?
ABundle хранится в оперативной памяти и очищается при первом же GC
BBundle передаётся через Binder-транзакцию с лимитом около 1 МБ, иначе TransactionTooLargeException
CBundle поддерживает только примитивные типы и строки
Dсистема шифрует Bundle, и большие данные слишком долго сериализуются