viewpager2中viewModelScope 取消的问题

1,683 阅读3分钟

场景

有这么一个场景,一个菜谱订制的app里,用户是根据每周作为一个周期制定自己的菜谱计划,每天从已知菜谱库存中选一两道菜,规划自己下周做什么吃,下下周做什么吃。

viewpager(或viewpager2)中加载若干个fragment,fragment里被传入一个时间戳的参数,用这个时间戳当做初始时间,计算出本周的起止时间(周日~周六),然后获取这一周的菜谱计划。类似于这样(demo式UI)

image.png

image.png

上图中标注a点击后切到上一周(viewpager2中前一个fragment),b点击切到下一周,下方列表为viewpager2(若干fragments)当然列表显示什么不重要

viewpagerAdapter 类似这样,用list维护一个Fragment

class MealPagerAdapter(fm: FragmentManager?, life : Lifecycle) : FragmentStateAdapter(fm!! , life) {

    val fragmentList = mutableListOf<MealFragment>()

    val startDateTimeList = mutableListOf<Long>()

    fun addItem(startDate : Long) {
        startDateTimeList.add(startDate)
        fragmentList.add(MealFragment.newInstance(startDate))
        notifyItemInserted(fragmentList.size - 1)
    }

    override fun getItemCount(): Int = fragmentList.size

    override fun createFragment(position: Int): MealFragment = fragmentList[position]

}

默认初始调用一次addItem方法,参数传入当前周(周日开始)的周日0点的时间戳,就可以获得这一周的起止日期了,这是一个可以无限添加fragment的viewpager2, 所以

offscreenPageLimit = 5

设置多少不那么重要了,暂定5吧

MealFragment里就是 viewmodel中定义一个initData()方法,伪代码示意:

class MealViewModel : BaseViewModel() {

    fun init() {
        viewModelScope.launch {
            // 请求数据
            ...
        }
    }
}
class MealFragment : Fragment() {

    val viewModel : MealViewModel by viewModels()
    
    onverried fun onViewCreated() {
        viewmodel.init()
    }
}

现在是不是已经看出问题来了。

现象是多次点击【下一周】按钮 添加几个fragment,只要超出了设置的离屏缓存数量,往回滑,之前显示过的fragment会重新加载,因为viewpager移除了fragment,不过重新经过onViewcreated 周期的时候,viewmodel里的 viewmodelScope 不再执行,导致页面空白

viewModelScope.launch {
            // 不再执行了
            ...
        }

显然,这个协程scope被cancel(close)了,不过没有被从ViewModel的map中移除, 所以也就谈不上重建。

扫一眼ViewModel 源码,内部有个

Map<String, Object> mBagOfTags = new HashMap<>();

用来存储scope

出现这种问题大概权当使用不当吧,虽然我这么用viewpager已经很久了

一些方案

方案1

viewmodel里的viewModelScope 被取消但不被移除,那就暂且不用这个了,替换为GlobalScope 总能用吧

stackoverflow上有一个同样和我一知半解的老外提了同样的问题

未测试

不过这种方案应该没人会采用,globalScope 估计只用于demo测试中

方案2

不自己缓存fragmentlist了,只缓存数据,每次去从viewpager缓存中获取

class MealViewPagerAdapter(fm: FragmentManager?, life : Lifecycle) : FragmentStateAdapter(fm!! , life) {

    val startDateTimeList = mutableListOf<Long>()

    fun addItem(startDate : Long) {
        startDateTimeList.add(startDate)
        notifyItemInserted(itemCount - 1)
    }

    override fun getItemCount(): Int = startDateTimeList.size

    override fun createFragment(position: Int): MealItemFragment = MealItemFragment.newInstance(startDateTimeList[position])
}

测试ok

方案3

不使用viewmodelScope, 用fragment的Scope代替

fun launchLifecycle(lifecycleOwner : LifecycleOwner , block: suspend () -> Unit) {
    lifecycleOwner.lifecycleScope.launch {
        block()
    }
}

测试ok

方案4

谷歌官方的示例项目iosched中是这样写的

viewpager2中同样使用list 缓存fragment,但不是缓存实例,只缓存生成方法,createFragment(position: Int) 方法调用的时候调用闭包来获取fragment(invoke)。

/**
 * Adapter that builds a page for each info screen.
 */
inner class InfoAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
    override fun createFragment(position: Int) = INFO_PAGES[position].invoke()

    override fun getItemCount() = INFO_PAGES.size
}

companion object {

    private val INFO_TITLES = arrayOf(
        R.string.event_title,
        R.string.travel_title,
        R.string.faq_title
    )
    private val INFO_PAGES = arrayOf(
        { EventFragment() },
        { TravelFragment() },
        { FaqFragment() }
        // TODO: Track the InfoPage performance b/130335745
    )
}

尽管他只有三个fragment,不过测了下自己的场景,同样能解决问题

测试ok

代码位于项目中的InfoFragment类里

方案5

上边的stackOverflow方法中还提到用共享viewmodel的方式,大致应该是这样

private val viewModel: SearchViewModel by viewModels(
    ownerProducer = { requireParentFragment() }
)

让viewmodel去伴随父fragment的周期,不过感觉设计上不太合适,没有去测