使用Viewpager2和Jetpack Navigation:恢复片段而不是重新创建它们

46

我在一个Fragment(我们称之为HomeFragment)中有一个Viewpager2。该Viewpager本身也包含Fragments。当我从HomeFragment导航到其他页面时,它的视图将被销毁,当我再次导航回来时,视图将被重新创建。现在我在HomeFragmentonViewCreated()中设置了Viewpager2的适配器。因此,当我导航回到HomeFragment时,适配器将被重新创建,这也会重新创建Viewpager2中的所有Fragments,并将当前项目重置为0。如果我尝试重用我在第一次创建HomeFragment时实例化的适配器,我会收到一个异常,因为在FragmentStateAdapter内部进行了以下检查:

public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
        checkArgument(mFragmentMaxLifecycleEnforcer == null);

有人知道如何在导航回去时防止重新创建所有内容吗?否则,这将是一个相当大的性能开销,并妨碍我的用户体验。


3
你有任何解决方案吗?我遇到了相同的问题。 - Mahmood Ali
1
在我的情况下,我只是注释掉了 viewPager.offscreenPageLimit = 3 并保存了当前项的状态。 - Mahmood Ali
我已将版本更新为beta05,看起来现在没问题了。 - extmkv
1
你找到解决方案了吗?我也遇到了类似的问题。正在谷歌搜索... - Georgiy Chebotarev
2
有什么解决方案吗?我也是一样的情况。另外@extmkv,当升级到beta05时,哪个依赖项修复了您的问题? - Karthik
显示剩余5条评论
5个回答

1
你可以将ViewPager的初始页面设置为具有自己返回栈的NavHostFragment,这将导致下面gif中的实现。

enter image description here

为每个选项卡创建一个 NavHost 片段,或可以有通用的片段,将其添加。
/**
 * Using [FragmentStateAdapter.registerFragmentTransactionCallback] with [FragmentStateAdapter] solves back navigation instead of using [OnBackPressedCallback.handleOnBackPressed] in every [NavHostFragment]
 * ### Should set app:defaultNavHost="true" for [NavHostFragment] for this to work
 */
class DashboardNavHostFragment : BaseDataBindingFragment<FragmentNavhostDashboardBinding>() {
    override fun getLayoutRes(): Int = R.layout.fragment_navhost_dashboard

    private var navController: NavController? = null

    private val nestedNavHostFragmentId = R.id.nested_nav_host_fragment_dashboard


    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val nestedNavHostFragment =
            childFragmentManager.findFragmentById(nestedNavHostFragmentId) as? NavHostFragment
        navController = nestedNavHostFragment?.navController

    }

}

这个片段的布局。
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/appbar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:layout_constraintEnd_toEndOf="parent"
            android:background="#0D47A1"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:popupTheme="@style/ThemeOverlay.AppCompat.ActionBar" />

        </com.google.android.material.appbar.AppBarLayout>

        <fragment
            android:id="@+id/nested_nav_host_fragment_dashboard"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/appbar"

            app:defaultNavHost="true"
            app:navGraph="@navigation/nav_graph_dashboard"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

为每个 ViewPager2 页面创建一个导航图,对于仪表板,如上所示,我们需要 nav_graph_dashboard
此页面的图形是:
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph_dashboard"
    app:startDestination="@id/dashboardFragment1">


    <fragment
        android:id="@+id/dashboardFragment1"
        android:name="com.smarttoolfactory.tutorial6_4_navigationui_viewpager_fragmenttoolbar_nested_navigation.blankfragment.DashboardFragment1"
        android:label="DashboardFragment1"
        tools:layout="@layout/fragment_dashboard1">
        <action
            android:id="@+id/action_dashboardFragment1_to_dashboardFragment2"
            app:destination="@id/dashboardFragment2" />
    </fragment>

    <fragment
        android:id="@+id/dashboardFragment2"
        android:name="com.smarttoolfactory.tutorial6_4_navigationui_viewpager_fragmenttoolbar_nested_navigation.blankfragment.DashboardFragment2"
        android:label="DashboardFragment2"
        tools:layout="@layout/fragment_dashboard2">
        <action
            android:id="@+id/action_dashboardFragment2_to_dashboardFragment3"
            app:destination="@id/dashboardFragment3" />
    </fragment>
    <fragment
        android:id="@+id/dashboardFragment3"
        android:name="com.smarttoolfactory.tutorial6_4_navigationui_viewpager_fragmenttoolbar_nested_navigation.blankfragment.DashboardFragment3"
        android:label="DashboardFragment3"
        tools:layout="@layout/fragment_dashboard3" >
        <action
            android:id="@+id/action_dashboardFragment3_to_dashboardFragment1"
            app:destination="@id/dashboardFragment1"
            app:popUpTo="@id/dashboardFragment1"
            app:popUpToInclusive="true" />
    </fragment>

</navigation>

让我们将这些NavHostFragments与FragmentStateAdapter合并,并实现默认情况下不起作用的返回导航。

/**
 * FragmentStateAdapter to contain ViewPager2 fragments inside another fragment.
 *
 * *  Create FragmentStateAdapter with viewLifeCycleOwner instead of Fragment to make sure
 * that it lives between [Fragment.onCreateView] and [Fragment.onDestroyView] while [View] is alive
 *
 * * https://dev59.com/nOk5XIcBkEYKwwoY3tYY
 */
class ChildFragmentStateAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) :
    FragmentStateAdapter(fragmentManager, lifecycle) {

    init {
        // Add a FragmentTransactionCallback to handle changing
        // the primary navigation fragment
        registerFragmentTransactionCallback(object : FragmentTransactionCallback() {
            override fun onFragmentMaxLifecyclePreUpdated(
                fragment: Fragment,
                maxLifecycleState: Lifecycle.State
            ) = if (maxLifecycleState == Lifecycle.State.RESUMED) {

                // This fragment is becoming the active Fragment - set it to
                // the primary navigation fragment in the OnPostEventListener
                OnPostEventListener {
                    fragment.parentFragmentManager.commitNow {
                        setPrimaryNavigationFragment(fragment)
                    }
                }

            } else {
                super.onFragmentMaxLifecyclePreUpdated(fragment, maxLifecycleState)
            }
        })
    }


    override fun getItemCount(): Int = 3

    override fun createFragment(position: Int): Fragment {

        return when (position) {
            0 -> HomeNavHostFragment()
            1 -> DashboardNavHostFragment()
            else -> NotificationHostFragment()
        }
    }

}

你还需要注意内存泄漏问题,如果你的 ViewPager2 本身位于一个 Fragment 中,请使用 viewLifecycleOwner 而不是 lifecycleOwner
你可以在这个 教程链接 中查看其他示例和更多内容。

我认为这对我的情况来说太复杂了,而且我还是没明白。我在HomeFragment(导航组件的一部分)中有一个ViewPager2,它是一个图片轮播。我想让用户每次点击这个轮播时都会缩小并跳转到ZoomFragment。实际上它可以工作,但当返回时出现问题。它崩溃并显示错误:“预期恢复状态时适配器应该是'新鲜的'”。 - Nanda Z

1
我花了一些时间研究了这个问题,并为需要听到的人诊断了问题。我尽可能地保持了我的解决方案传统。如果我们看一下你的陈述:
因此,当我导航回HomeFragment时,适配器将被重新创建,这也会重新创建Viewpager2中的所有片段和当前项目将重置为0。
问题在于当前项目被重置为0,因为适配器所基于的列表被重新创建了。为了解决这个问题,我们不需要保存适配器,只需要保存其中的数据即可。有了这个想法,解决问题并不难。
让我们列出一些定义:
  • HomeFragment 是你的 ViewPager2 宿主,正如你所说的。
  • MainActivity 是运行活动,它是 HomeFragment 和其中所有创建的片段的宿主。
  • 我们正在浏览 MyFragment 的实例。您甚至可以浏览多种类型的片段,但这超出了本示例的范围。
  • PagerAdapterHomeFragmentViewPager2 适配器,它是您的 FragmentStateAdapter

在此示例中,MyFragment 具有构造函数 constructor(id : Int)。然后,PagerAdapter 可能如下所示:

class PagerAdapter(fm : Fragment) : FragmentStateAdapter(fm){
    
    var ids : List<Int> = listOf()

    ...
    
    override fun createFragment(position : Int) : Fragment{
        return MyFragment(ids[position])
    }
    

}

我们面临的问题是每次重新创建PagerAdapter时,构造函数都会被调用,并且正如我们上面所看到的那样,该构造函数将ids设置为空列表。
我的第一个想法是也许我可以将fm切换为MainActivity。 我不会从MainActivity中导航,所以我不确定为什么,但这个解决方案行不通。
相反,你需要将数据从PagerAdapter中抽象出来。 创建一个“viewModel”:
    /* We do NOT extend ViewModel. This naming just indicates that this is your data- 
    storage vehicle for PagerAdapter*/
    data class PagerAdapterViewModel(
    var ids : List<Int> 
    )

然后,在 PagerAdapter 中进行以下调整:
class PagerAdapter(
    fm : Fragment,
    private val viewModel : PagerAdapterViewModel 
) : FragmentStateAdapter(fm){
    
    // by creating custom getters and setters, you are migrating your code to this 
    // implementation without needing to adjust any code outside of the adapter 
    var ids : List<Int>
        get() = viewModel.ids 
        set(value) {viewModel.ids = value} 
    
    override fun createFragment(position : Int) : Fragment{
        return MyFragment(ids[position])
    }
    

}

最后,在HomeFragment中,您将拥有类似以下内容的内容:
class HomeFragment : Fragment(){ 

    ... 

    /** Calling "by lazy" ensures that this object is only created once, and hence
    we retain the data stored in it, even when navigating away. */
    private val pagerAdapterViewModel : PagerAdapterViewModel by lazy{
        PagerAdapterViewModel(listOf())
    }

    private lateinit var pagerAdapter : PagerAdapter

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        ...
        pagerAdapter = PagerAdapter(this, pagerAdapterViewModel)
        pager.adapter = pagerAdapter 
        ...
    }
    
    ...

}

1
这对于我的情况无效,在切换回包含ViewPager的上一个Fragment时会导致应用程序崩溃。虽然我已经实现了解决崩溃的方法(Fragment no longer exists for key f#0),但这个问题并没有得到解决。是的,我正在将其用于多种类型的Fragment。 - Lalit Fauzdar

1
我尝试设置。
viewPager2.setOffscreenPageLimit(ViewPager2.OFFSCREEN PAGE LIMIT_DEFAULT);

之后,片段的行为变得正常了。
有关OffscreenPageLimit的更多信息在这里

0

我在适配器的构造函数中使用 FragmentActivity 代替 Fragment 作为参数,这样可以工作。当返回到 HomeFragment 时,适配器会被重新创建,但子片段不会。

之前:

class HomeFragmentAdapter() : FragmentStateAdapter(fragment)

现在:

class HomeFragmentAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity)

或者

// needs FragmentActivity's lifecycle
FragmentStateAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle)

这总是错误的解决方案,会导致片段在配置更改或进程死亡/重新创建后无法正确恢复其状态。您需要使用接受片段的构造函数来嵌套片段。 - ianhanniballake
嗨@ianhanniballake,我在这里基本上问了同样的问题:https://stackoverflow.com/questions/71445630/viewpager2-how-to-restore-fragments-when-navigating-back。这是一个常见的问题,仍然没有得到答案,非常令人困惑。我们是否正确地说FragmentStateAdapters仅为配置更改保存和恢复片段,并且无法在我们“返回”到viewpager2时执行此操作?非常感谢您对此或我的问题的回答。谢谢。 - Daniel Wilson
@DanielWilson - 我不知道你在说什么。只要你使用正确的构造函数,每个 Fragment 都会完美地保存和恢复其 状态,无论是在返回堆栈、配置更改还是进程死亡时。 - ianhanniballake

-1

这是ViewPager2中的一个错误(实际上是在RecyclerView中)。 https://issuetracker.google.com/issues/151212195

当返回时(为了避免片段重复),您必须重用旧适配器,并在HomeFragment的onDestroyView()中调用viewPager.adapter = null。

[更新于2022年04月08日] 很奇怪我的回答被踩了 o_O。再次强调,您不必在onViewCreated中重新创建适配器,这是一个错误(不仅对于ViewPager,还对于RecyclerView和其他使用适配器方法的组件都是如此)。


我们究竟是如何重复使用旧的适配器的? - Jimly Asshiddiqy
@JimlyAsshiddiqy 只需在onCreate中创建它,而不是在onCreateView中创建。 - blinker

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接