тестирование жизненного цикла androidx.fragment путем остановки и возобновления с помощью FragmentScenario, onCreateView() вызывается дважды, но эта ошибка уже исправлена ​​в версии 1.3.1.

Я пишу инструментальные тесты для своего приложения, используя androidx.fragment:fragment-testing. Один из тестовых случаев — проверить, правильно ли ведет себя вся базовая логика, когда Fragment останавливается и возобновляется, чтобы имитировать сворачивание приложения (кнопка «Домой») и его возврат. Эти тесты используют FragmentScenario.moveToState(). Сначала я написал свои тесты, используя androidx.fragment:fragment-testing:1.2.5, и все они прошли. Но когда я обновил androidx.fragment:fragment-testing до 1.3.1, вышеупомянутые тесты начали давать сбои.

Я проверил, что не так, и оказалось, что Fragment.onCreateView() снова вызывается во время изменения жизненного цикла, даже если это не должно (в случае возврата к CREATED и обратно к RESUMED), что приводит к «сбросу» представлений к исходному состоянию, объявленному в макет. Я просмотрел это и нашел ошибку с описанием, в котором упоминается, что метод жизненного цикла onCreateView() вызывается дважды https://issuetracker.google.com/issues/143915710 (это также упоминается в https://medium.com/androiddevelopers/fragments-rebuilding-the-internals-61913f8bf48e). Проблема в том, что это уже исправлено во Фрагменте 1.3.0-alpha08, поэтому в 1.3.1 этого быть не должно. Это означает, что что-то не так с конфигурацией моего проекта.

Вот пример кода, который воспроизводит проблему. Это показывает, что представления не сохраняют свой текст и видимость при изменении жизненного цикла RESUMED -> CREATED -> RESUMED. Ручное тестирование не воспроизводит эту проблему, оно влияет только на инструментальные тесты.

class LifecycleBugFragment : Fragment() {

    lateinit var textView: TextView
    lateinit var editText: EditText
    lateinit var button: Button

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        val view =  inflater.inflate(R.layout.fragment_lifecycle_bug, container, false)
        textView = view.findViewById<TextView>(R.id.textView)
        textView.setOnClickListener { textView.text = "I was clicked" }
        editText = view.findViewById<EditText>(R.id.editText)
        button = view.findViewById<Button>(R.id.button)
        button.setOnClickListener { button.visibility = View.GONE }
        return view
    }
}

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".fragmenttesting.LifecycleBugFragment">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="default text" />

    <EditText
        android:id="@+id/editText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:inputType="text"
        />

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="click to hide me"
        />
</LinearLayout>

const val TYPED_TEXT = "some example text"
const val DEFAULT_TEXT = "default text"
const val CLICKED_TEXT = "I was clicked"

class LifecycleBugFragmentTest {

    lateinit var fragmentScenario: FragmentScenario<LifecycleBugFragment>

    @Before
    fun setUp() {
        fragmentScenario = FragmentScenario.launchInContainer(LifecycleBugFragment::class.java)
    }

    @Test
    fun whenTextViewclickedAndFragmentLifecycleStoppedAndResumed_ThenTextViewTextIsStillChanged() {
        onView(withId(R.id.textView)).check(matches(withText(DEFAULT_TEXT)))
        onView(withId(R.id.textView)).perform(click())
        onView(withId(R.id.textView)).check(matches(withText(CLICKED_TEXT)))
        stopAndResumeFragment()
        onView(withId(R.id.textView)).check(matches(withText(CLICKED_TEXT)))
    }

    // this test passes, others fail
    @Test
    fun whenEditTextIsEditedAndFragmentLifecycleStoppedAndResumed_ThenEditTextTextIsStillChanged() {
        onView(withId(R.id.editText)).perform(typeText(TYPED_TEXT))
        stopAndResumeFragment()
        onView(withId(R.id.editText)).check(matches(withText(TYPED_TEXT)))
    }

    @Test
    fun whenButtonIsClickedAndFragmentLifecycleStoppedAndResumed_ThenButtonISStillNotVisible() {
        onView(withId(R.id.button)).perform(click())
        onView(withId(R.id.button)).check(matches(not(isDisplayed())))
        stopAndResumeFragment()
        onView(withId(R.id.button)).check(matches(not(isDisplayed())))
    }

    private fun stopAndResumeFragment() {
        fragmentScenario.moveToState(Lifecycle.State.CREATED)
        fragmentScenario.moveToState(Lifecycle.State.RESUMED)
    }
}

dependencies {
    implementation fileTree(dir: "libs", include: ["*.jar"])
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    implementation "org.jetbrains.kotlin:kotlin-stdlib:1.4.31"
    implementation 'androidx.legacy:legacy-support-v4:1.0.0'
    testImplementation 'junit:junit:4.13'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
    androidTestImplementation "androidx.test:runner:1.3.0"
    androidTestImplementation "androidx.test:core:1.3.0"
    androidTestImplementation "androidx.test.ext:junit:1.1.2"
    androidTestImplementation "androidx.test:rules:1.3.0"

    implementation "androidx.navigation:navigation-fragment-ktx:2.3.4"
    implementation "androidx.navigation:navigation-ui-ktx:2.3.4"
    androidTestImplementation "androidx.navigation:navigation-testing:2.3.4"
    debugImplementation "androidx.fragment:fragment-testing:1.3.1"

    implementation "androidx.navigation:navigation-compose:1.0.0-alpha09"

    // other dependencies unrelated to issue skipped for clarity
}

Поскольку я не объявляю androidx.fragment:fragment напрямую, это является транзитивной зависимостью, поэтому я задавался вопросом, может быть, он разрешается до 1.3.0-альфа меньше 8, поэтому не содержит исправления. Я добавил ограничения зависимостей, чтобы убедиться, что 1.3.1 разрешен.

constraints {
    implementation('androidx.fragment:fragment:1.3.1') {
        because 'avoid bug'
    }
    implementation('androidx.fragment:fragment-ktx:1.3.1') {
        because 'avoid bug'
    }
}

но это не помогло, так что дело не в этом

Что еще может быть не так с моим кодом (скорее всего, зависимости от градиента)?


person Piotr Śmietana    schedule 14.03.2021    source источник
comment
text и visibility TextView в целом - это то, что вам нужно вручную сохранить и восстановить в представлениях; это не то, что представления делают автоматически за вас.   -  person ianhanniballake    schedule 15.03.2021
comment
И обратите внимание, что перемещение вашего фрагмента на CREATED абсолютно должно вызвать onDestroyView() в вашем фрагменте, а перемещение его обратно на RESUMED должно вызвать onCreateView(). Если это еще не произошло для вас, то это указывало бы на проблему с более старыми версиями Fragments, а не с более новыми версиями.   -  person ianhanniballake    schedule 15.03.2021
comment
Я не ожидал, что представления сохранят свой текст и видимость в общем случае, я просто использовал текст и видимость, чтобы легко проверить условия тестирования, чтобы указать, что фрагмент был воссоздан, когда я ожидал, что это не так.   -  person Piotr Śmietana    schedule 15.03.2021
comment
что касается состояния СОЗДАНИЕ, developer.android.com/reference/androidx/lifecycle/ говорит, что CREATED достигается не только после вызова onCreate, но и прямо перед вызовом onStop. Поэтому я понял, что состояние — это не точка жизненного цикла, а скорее диапазон, и FragmentScenario.moveToState() перемещается к ближайшей границе этого диапазона, поэтому в случае RESUMED->CREATED он вызовет onStop и ничего больше. Я понимаю, что это неправильно, и ожидается, что moveToState() всегда будет перемещаться в самую раннюю точку данного состояния?   -  person Piotr Śmietana    schedule 15.03.2021
comment
Кстати, как правильно протестировать остановку и возобновление фрагмента без воссоздания представления с помощью FragmentScenario? Я попытался перейти RESUMED->STARTED->RESUMED, но, очевидно, он даже не вызывает onStop   -  person Piotr Śmietana    schedule 15.03.2021


Ответы (1)


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

При возврате к RESUMED (фрагмент снова присоединен) вид воссоздается и его состояние восстанавливается. Примечание: представления НЕ восстанавливаются с помощью savedInstanceState, фрагмент фактически содержит сохраненное состояние представления внутри.

EditText сохраняет свое состояние, поэтому он не дает сбоев, но TextViews и Buttons ничего не сохраняют.

Вы можете заставить TextView сохранить свой текст, добавив android:saveEnabled="true" в свой XML, но для видимости вам нужно будет сохранить состояние в поле фрагментов (или даже сохранить/восстановить его через savedInstanceState) и использовать его в onViewCreated.

person Pawel    schedule 14.03.2021