Всем привет! Меня зовут Максим Бредихин, я Android-разработчик в Тинькофф. В этой серии статей я расскажу об интересных моментах из Fragment API, о которых вы могли не знать. Материал будет полезен как начинающим разработчикам, так и закаленным в боях с багами девелоперам.

Усаживайтесь поудобнее, мы начинаем! Рассмотрим несколько функций-расширений, которые помогут нам комфортно работать с транзакциями.

Fragment-ktx

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

FragmentManager. Теперь можно описывать транзакции в DSL-стиле, а функции beginTransaction() и commit() или commitAllowStateLoss() вызываются под капотом:

fun FragmentManager.commit(
  allowStateLoss: Boolean = false, 
  block:  FragmentTransaction.() -> Unit
)

// Example
fragmentTransaction.commit {
  // some transaction
}

FragmentTransaction. Добавлена замена перегрузкам метода FragmentTransaction.add(Int, Class<out Fragment>, Bundle?). Аналогичное расширение добавлено и для FragmentTransaction.replace():

fun <reified T: Fragment> FragmentTransaction.add(
  containerId: Int, 
  tag: String? = null, 
  args: Bundle? = null
): FragmentTransaction

fun <reified T: Fragment> FragmentTransaction.replace(
  containerId: Int, 
  tag: String? = null, 
  args: Bundle? = null
): FragmentTransaction

// Example
fragmentManager.commit {
  val args = bundleOf("key" to "value")
  add<ExampleFragment>(R.id.container, "tag", args)
  replace<AnotherFragment(R.id.container)
}

Оптимизация транзакций

Оптимизация — довольно важная штука. Чтобы разобраться, как FragmentManager может провернуть все за нас, попробуем оптимизировать транзакции руками. Посмотрим на этот код: 

fragmentManager.commit {
  add<FragmentA>(R.id.container)
  replace<FragmentB>(R.id.container)
  replace<FragmentC>(R.id.container)
}

Как ускорить транзакцию? В ходе «сложнейшего технического анализа» видим, что по ее итогам пользователь увидит FragmentC. Мы люди простые и просто выбросим лишние два действия, сразу показав FragmentC. Done!

Другой пример — уже с двумя транзакциями, выполняющимися одна за другой:

// 1
fragmentManager.commit {
  add<FragmentA>(R.id.container)
}

// 2
fragmentManager.commit {
  replace<FragmentB>(R.id.container)
}

В этом случае мы могли бы прервать операцию по добавлению FragmentA и сразу добавить FragmentB. Сделать мы этого не можем, но задачка скорее теоретическая.

Все вышеописанное FragmentManager может делать самостоятельно. Нужно лишь разрешить ему, добавив setReorderingAllowed(true) к транзакции, которую хотим оптимизировать:

fragmentManager.commit {
  setReorderingAllowed(true)
  add<FragmentA>(R.id.container)
  replace<FragmentB>(R.id.container)
  replace<FragmentC>(R.id.container)
}

Во втором примере нужно выставить флаг в первой транзакции, потому что именно ее мы разрешаем прервать, а вторая, в свою очередь, должна выполниться полностью: 

// 1
fragmentManager.commit {
  setReorderingAllowed(true)
  add<FragmentA>(R.id.container)
}

// 2
fragmentManager.commit {
  replace<FragmentB>(R.id.container)
}

Фактически мы разрешаем FragmentManager вести себя как лентяй и не выполнять ненужные команды, что дает некоторый прирост к производительности. Более того, это помогает корректно обрабатывать анимации, переходы и backstack.

Но если он такой хороший, почему по умолчанию выключен? Добавляя фрагменты в транзакцию, мы ожидаем, что каждый из них пройдет через свой жизненный цикл от рождения до смерти, сделает все, что от него требуется, и уйдет в небытие, дав дорогу другим фрагментам.

Стоит помнить, что оптимизированный лентяй-FragmentManager может:

  • не создавать фрагмент, если он заменяется в той же транзакции;

  • прервать жизненный цикл в любой момент до RESUMED, если началась транзакция по замене добавленного фрагмента;

  • привести к тому, что onCreate() нового фрагмента будет вызван до onDestroy() старого.

В большинстве кейсов это не страшно, но с ночными кошмарами от дебага оставить может.

Важно! Мы обязаны использовать FragmentTransaction.setReorderingAllowed(true) с любой транзакцией, добавляемой в backstack, который будет сохранен с помощью FragmentManager.saveBackStack(String). Подробнее — в третьей части статьи.

Не add/replace единым

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

Изменение видимости. Можем спрятать и показать фрагмент без изменения состояния его жизненного цикла. Такое поведение аналогично View.visibility = View.GONE и View.visibility = View.VISIBLE.

Мы же можем просто спрятать контейнер! Это правда, но сокрытие контейнера в backstack сохранить не получится, а транзакцию с аналогичной командой — легко. Чтобы спрятать фрагмент от лишних глаз, достаточно вызвать метод FragmentTransaction.hide(Fragment):

fragmentManager.commit {
  setReorderingAllowed(true)
  fragmentManager.findFragmentById(R.id.container)?.let { hide(it) }
}

Чтобы его снова показать, нужно вызвать метод FragmentTransaction.show(Fragment):

fragmentManager.commit {
  setReorderingAllowed(true)
  fragmentManager.findFragmentById(R.id.container)?.let { show(it) }
}

Уничтожение View. Мы можем уничтожить View фрагмента, но не уничтожать сам фрагмент вызовом метода FragmentTransaction.detach(Fragment). В результате такой транзакции фрагмент перейдет в состояние STOPPED:

fragmentManager.commit {
  setReorderingAllowed(true)
  fragmentManager.findFragmentById(R.id.container)?.let { detach(it) }
}

Чтобы пересоздать View фрагмента, достаточно вызвать метод FragmentTransaction.attach(Fragment):

fragmentManager.commit {
  setReorderingAllowed(true)
  fragmentManager.findFragmentById(R.id.container)?.let { attach(it) }
}
Жизненный цикл фрагментов при использовании FragmentTransaction.detach(Fragment) и FragmentTransaction.attach(Fragment)
Жизненный цикл фрагментов при использовании FragmentTransaction.detach(Fragment) и FragmentTransaction.attach(Fragment)

Важно! View будет не просто скрыто, как в первом случае, оно будет уничтожено.

Ограничение ЖЦ. Мы можем ограничить максимальное состояние жизненного цикла. К примеру, запретим фрагменту подниматься выше состояния STARTED:

fragmentManager.commit {
  setReorderingAllowed(true)
  fragmentManager.findFragmentById(R.id.container)?.let {
    setMaxLifecycle(it, Lifecycle.State.STARTED)
  }
}

В итоге у фрагмента будут вызываться все колбэки жизненного цикла до onResume(), кроме его самого.

Жизненный цикл фрагмента, ограниченный до STARTED
Жизненный цикл фрагмента, ограниченный до STARTED

Так работает ViewPager2 — он ограничивает ЖЦ видимых фрагментов до RESUMED, а невидимых — до STARTED.

Мы можем вешать любые ограничения (кроме DESTROYED, его вообще нельзя ограничивать, иначе IllegalArgumentException) в любой момент времени с одним исключением. Выставить максимальное состояние в INITIALIZED возможно только в рамках транзакции с добавлением этого фрагмента, иначе увидим страшный красный текст про IllegalArgumentException в логах:

fragmentManager.commit {
  setReorderingAllowed(true)
  val fragment = ExampleFragment()
  add(R.id.container, fragment)
  setMaxLifecycle(fragment, Lifecycle.State.INITIALIZED)
}

Исключения

Разберем самые интересные и неочевидные исключения, которые можно поймать в ходе работы с транзакциями и которые я не упоминал выше. Все исключения, выбрасываемые в ходе транзакций, являются IllegalStateException либо IllegalArgumentException с разными описаниями, поэтому буду использовать часть описания этих исключений.

Can't change tag of fragment. Первый наш гость — IllegalStateException, который может быть выброшен во время операций FragmentTransaction.add() и FragmentTransaction.replace(). Помним, что во время транзакции мы можем указать фрагменту тег. Одному фрагменту может быть дан один и только один тег, но если мы решим, что один фрагмент достоин двух разных тегов, то увидим это прекрасное исключение в логах:

val fragment = ExampleFragment()
fragmentManager.commit {
  replace(R.id.container, fragment, "tag")
}

fragmentManager.commit {
  replace(R.id.container, fragment, "another_tag") // throws IllegalStateException
}

Чтобы избежать исключения, нужно указывать один и тот же тэг одному и тому же фрагменту, либо указывать его только один раз.

Неочевидный факт: null не присваивается тегу никогда. Изначально тег является null, но если мы в транзакции укажем tag = null, то он всегда будет игнорироваться, следовательно, никогда не вызовет исключения.

Can’t change container ID of fragment. Этот IllegalStateException аналогичен предыдущему, но говорит о том, что нельзя использовать один и тот же инстанс фрагмента в разных контейнерах.

Cannot * Fragment attached to a different FragmentManager. Фрагменты сильно привязываются к FragmentManager, который совершил коммит по их добавлению, и только он имеет право изменять их состояние. 

К примеру, если мы добавили фрагмент через parentFragmentManager, а удалить пытаемся через childFragmentManager, то словим этот IllegalStateException:

val fragment = ExampleFragment()
parentFragmentManager.commit {
  add(R.id.container, fragment)
}

childFragmentManager.commit {
  remove(fragment)	// throws IllegalStateException
  hide(fragment) 	// throws IllegalStateException
  show(fragment) 	// throws IllegalStateException
  detach(fragment) 	// throws IllegalStateException
  setPrimaryNavigationFragment(fragment) 	// throws IllegalStateException
  setMaxLifecycle(fragment, STARTED)        // throws IllegalStateException
}

Чтобы этого избежать, используем один и тот же FragmentManager для всех транзакций с одним и тем же фрагментом. 

Commit already called. Возможно, это не совсем очевидный момент, но коммит одной транзакции можно сделать только один раз. На каждую новую транзакцию нужно создавать новый объект, иначе поймаем IllegalStateException:

val transaction = fragmentManager.beginTransaction()

transaction.add<FirstFragment>(R.id.container1)
transaction.commit()

transaction.add<SecondFragment>(R.id.container2)
transaction.commit() // throws IllegalStateException

// commit() вызывается под капотом
// throws IllegalStateException
fragmentManager.commit {
   add<ExampleFragment>(R.id.container)
   commit()
}

Во втором случае исключение выбрасывается расширением FragmentManager.commit(), так как мы сначала вызвали FragmentTransaction.commit() внутри лямбды, а затем он вызвался второй раз под капотом.

Чтобы этого избежать, не нужно использовать тот же объект FragmentTransaction после вызова FragmentTransaction.commit(), лучше начать новую транзакцию.

Can not perform this action after onSaveInstanceState. Состояние наших фрагментов сохраняется в SavedState родителя, из чего следует, что мы не можем безопасно изменить состояние фрагмента после того, как оно было сохранено. Данный IllegalStateException выбрасывается, если мы пытаемся совершить любую транзакцию после onSaveInstanceState() или после onStop():

override fun onStop() {
  super.onStop()
  // throws IllegalStateException
  fragmentManager.commit {
    add<ExampleFragment>(R.id.container)
  }
}

Если нам нужна транзакция на этих этапах жизненного цикла родителя, мы можем разрешить потенциальную потерю состояния:

fragmentManager.beginTransaction()
  .add(R.id.container, ExampleFragment())
  .commitAllowingStateLoss()

// fragment-ktx
fragmentManager.commit(allowStateLoss = true) {
  add<ExampleFragment>(R.id.container)
}

Вместо заключения

Вот и закончилась первая часть саги о фрагментах. Мы разобрали несколько интересных функций-расширений из Fragment-ktx, научились оптимизировать транзакции, узнали еще три способа управления видимостью и жизненным циклом фрагментов помимо всем известных add() и replace().

Это базовые знания для построения прекрасного Андроида будущего без багов и костылей. В следующей части разберем, как создавать новые инстансы фрагментов.

До скорых встреч!

Комментарии (0)