Jetpack Compose - Tab切换UI缓存

612 阅读2分钟

前言

实际开发中,切换Tab的功能很常见,根据选中的Tab类型,切换对应的界面:

录屏2024-01-23 22.53.13.gif

在Compose中做这个功能的时候,容易陷入固定思维,写出以下的代码:

@Composable
private fun Content() {
    /** 当前选中的Tab */
    var selectedTab by remember { mutableStateOf(TabType.Home) }
    Box {
        when (selectedTab) {
            TabType.Home -> TabHome()
            TabType.Me -> TabMe()
        }
    }
}

这样子用起来没什么问题,但是每次切换的时候,Tab对应的可组合项都会从组合中做移除和添加的操作,如果Tab的UI比较复杂那么切换动作是一个比较重的操作。

问题重现

我们先重现一下问题,通过日志打印一下Tab可组合项的生命周期:

private enum class TabType {
    Home,
    Me,
}

@Composable
private fun Content() {
    /** 当前选中的Tab */
    var selectedTab by remember { mutableStateOf(TabType.Home) }
    Column(modifier = Modifier.fillMaxSize()) {
        // 显示Tab
        Tabs(selectedTab, modifier = Modifier.weight(1f))
        // 底部导航栏,点击切换Tab
        BottomNavigation(selectedTab) {
            Log.i("compose-demo", "click $it")
            selectedTab = it
        }
    }
}

@Composable
private fun Tabs(
    selectedTab: TabType,
    modifier: Modifier = Modifier,
) {
    Box(modifier = modifier.fillMaxSize()) {
        when (selectedTab) {
            TabType.Home -> TabContent(TabType.Home)
            TabType.Me -> TabContent(TabType.Me)
        }
    }
}

@Composable
private fun TabContent(tabType: TabType) {
    // 打印生命周期日志
    DisposableEffect(tabType) {
        Log.i("compose-demo", "tab:${tabType.name}")
        onDispose { Log.i("compose-demo", "tab:${tabType.name} onDispose") }
    }
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center,
    ) {
        Text(text = tabType.name)
    }
}

代码比较简单,Tabs函数根据选中的Tab类型selectedTab来展示对应的Tab内容,TabContent是Tab要显示的内容,里面的DisposableEffect用来打印可组合项的生命周期日志。

运行代码,查看日志:

compose-demo             I  tab:Home
compose-demo             I  click Me
compose-demo             I  tab:Home onDispose
compose-demo             I  tab:Me
compose-demo             I  click Home
compose-demo             I  tab:Me onDispose
compose-demo             I  tab:Home

可以看到点击切换Tab的时候会打印onDispose,说明TabContent在切换的时候会被移除。

分析解决

如果Tab的内容是复杂的UI,那么每次切换都移除创建,是不合理的,应该缓存下来。

怎么缓存呢?可以把未选中的Tab隐藏起来而不是移除掉。在Compose中显示隐藏很容易想到AnimatedVisibilityAnimatedContent这两个可组合项,然而它们也会更改组合,有兴趣的读者可以测试一下。

我们可以设置缩放值来达到显示隐藏的效果,选中的话就是缩放1,未选中的话就是缩放0,写一个包装缩放功能的函数:

@Composable
private fun SelectedBox(
    modifier: Modifier = Modifier,
    // 当前是否选中
    selected: Boolean,
    content: @Composable () -> Unit
) {
    Box(
        modifier = modifier.graphicsLayer {
            // 根据selected改变缩放值
            this.scaleX = if (selected) 1f else 0f
        }
    ) {
        content()
    }
}

代码很简单,用Box包装一下要显示的content,根据选中状态selected切换缩放值。

使用一下SelectedBox

@Composable
private fun Tabs(
    selectedTab: TabType,
    modifier: Modifier = Modifier,
) {
    Box(modifier = modifier.fillMaxSize()) {
        SelectedBox(selected = selectedTab == TabType.Home) {
            TabContent(TabType.Home)
        }
        SelectedBox(selected = selectedTab == TabType.Me) {
            TabContent(TabType.Me)
        }
    }
}

直接放置了两个SelectedBox,相当于一开始,组合就是固定的,通过修改缩放值来实现显示和隐藏。

运行代码,查看日志:

录屏2024-01-24 11.41.00.gif

可以看到,现在切换Tab不会导致Tab的内容被移除了,但是又有一个新的问题,一开始就加载了所有Tab,继续解决一下这个问题。

@Composable
private fun Tabs(
    selectedTab: TabType,
    modifier: Modifier = Modifier,
) {
    // 保存所有选中过的Tab
    val selectedTabHolder = remember { mutableStateListOf<TabType>() }

    LaunchedEffect(selectedTab) {
        // 保存选中的Tab
        if (!selectedTabHolder.contains(selectedTab)) {
            selectedTabHolder.add(selectedTab)
        }
    }

    Box(modifier = modifier.fillMaxSize()) {
        // 判断选中过才添加组合
        if (selectedTabHolder.contains(TabType.Home)) {
            SelectedBox(selected = selectedTab == TabType.Home) {
                TabContent(TabType.Home)
            }
        }
        // 判断选中过才添加组合
        if (selectedTabHolder.contains(TabType.Me)) {
            SelectedBox(selected = selectedTab == TabType.Me) {
                TabContent(TabType.Me)
            }
        }
    }
}

selectedTabHolder保存所有选中过的Tab,当selectedTab变化时,把它添加到selectedTabHolder里面,最后再判断一下selectedTabHolder里面存在的Tab才做显示。

这里提一下,selectedTabHolder是一个SnapshotStateList,它的内容变化可以被Compose监测到。

运行代码,查看日志:

录屏2024-01-24 13.19.41.gif

可以看到一开始只加载了Home的内容,点击Me之后才加载并显示了Me

封装

如果每个地方都这么写的话,很麻烦,我们可以把这个功能封装起来。

先看一下期望的Api:

@Composable
private fun Tabs(
    selectedTab: TabType,
    modifier: Modifier = Modifier,
) {
    TabContainer(selectedKey = selectedTab, modifier = modifier) {
        tab(key = TabType.Home) {
            TabContent(TabType.Home)
        }
        tab(key = TabType.Me) {
            TabContent(TabType.Me)
        }
    }
}

封装一个TabContainer函数,在它的Lambda参数中,可以通过tab方法为每个Tab类型配置要显示的内容,最后把当前选中的selectedTab传给TabContainer就可以了。

这样子的Api还是挺方便的,我们来实现一下。

interface TabContainerScope {
    fun tab(
        key: Any,
        content: @Composable () -> Unit,
    )
}

Lambda参数的接收者TabContainerScope,用于调用tab方法配置要显示的内容。

再写一下TabContainerScope的实现类:

private class TabContainerImpl : TabContainerScope {
    // 所有Tab的配置信息
    private val _store: MutableMap<Any, @Composable () -> Unit> = hashMapOf()
    // 选中过的Tab的配置信息
    private val _activeTabs: MutableMap<Any, MutableState<@Composable () -> Unit>> = mutableStateMapOf()
    // 当前是否处于配置模式
    private var _config = false

    // 开始配置
    fun startConfig() {
        _config = true
    }

    override fun tab(
        key: Any,
        content: @Composable () -> Unit,
    ) {
        // 检查是否处于配置模式
        check(_config) { "Config not started." }
        // 保存配置
        _store[key] = content
    }

    // 显示当前选中的Tab
    @Composable
    fun Content(selectedKey: Any) {
        SideEffect {
            if (_config) {
                _config = false
                // 把新配置更新到已经选中过的Tab中
                _activeTabs.forEach { active ->
                    active.value.value = checkNotNull(_store[active.key])
                }
            }
        }

        LaunchedEffect(selectedKey) {
            if (!_activeTabs.containsKey(selectedKey)) {
                // 把选中的Tab信息加载到_activeTabs中
                val content = checkNotNull(_store[selectedKey]) { "Key $selectedKey was not found." }
                _activeTabs[selectedKey] = mutableStateOf(content)
            }
        }

        // 遍历_activeTabs,根据选中状态设置缩放值
        for ((key, state) in _activeTabs) {
            key(key) {
                SelectedBox(
                    selected = key == selectedKey,
                    content = state.value
                )
            }
        }
    }
}

TabContainerImpl中重要的地方都用注释标记了,整体逻辑不复杂,应该都看得懂,大概流程如下:

  1. 调用tab方法为每个Tab配置要显示的内容
  2. 把所有Tab的配置信息保存在_store
  3. Content函数显示selectedKey对应的内容
  4. 根据selectedKey把配置从_store加载到_activeTabs
  5. 遍历_activeTabs,把Tab对应的可组合项添加到组合中

最后在写一下TabContainer函数:

@Composable
fun TabContainer(
    modifier: Modifier = Modifier,
    // 当前选中的Key
    selectedKey: Any,
    apply: TabContainerScope.() -> Unit,
) {
    val container = remember { TabContainerImpl() }.apply {
        startConfig()
        apply()
    }
    Box(modifier = modifier) {
        container.Content(selectedKey)
    }
}

使用TabContainer的代码就不展示了,和上面期望的Api一模一样。

运行代码,测试一下:

录屏2024-01-24 19.44.52.gif

可以看到,已经可以正常工作了。

结束

到此为止,大概的功能已经实现了,当然,还有可以优化的地方,比如:

  • 把显示隐藏逻辑,即SelectedBox的逻辑,抽取出来,可以单独配置
  • 当key减少的时候,_store_activeTabs中对应的配置要移除

由于篇幅有限,就不继续展开了,以上提到的优化都已经完整实现了,有兴趣的读者可以看一下:compose-tab-container

以上就是全部内容,如果有错误的地方,还请读者评论指出,一起学习,如果有任何问题,也可以加作者的微信探讨,感谢你的阅读。

作者微信:zj565061763