如何在初始组合期间从可组合函数中的远程 API 调用/获取数据 [防止无限重组]

Mr.*_*.AF 3 android kotlin android-jetpack-compose

在下面的代码中:

@Composable
fun Device(contentPadding: PaddingValues, modifier: Modifier = Modifier) {
    val vm:DeviceList = viewModel()
    vm.getDevices()
    var devices = vm.uiState.collectAsState();

    LazyColumn(contentPadding = contentPadding) {
        items(devices.value) { device -> DeviceItem(device) }
    }
}
Run Code Online (Sandbox Code Playgroud)

调用vm.getDevices()远程 API 并获取 中所述的设备vm.uiState

问题

正如代码清楚显示的那样,它会导致无限的 UI 重组。更新状态和导致 UI 重组的vm.getDevices()新状态。vm.uiState结果,vm.getDevices()被调用并再次更新状态。

我在寻找什么

我想要一个推荐的解决方案(最佳实践)。此外,我可以放置一些脏代码,例如 if/else 条件,以防止无限的 UI 重组。但是,我认为对于此类问题有更好的干净解决方案。

编辑

class DeviceList : ViewModel() {

    private var deviceListUIState: MutableStateFlow<List<Device>> = MutableStateFlow(
        listOf()
    )

    val uiState
        get() = deviceListUIState.asStateFlow()

    fun getDevices() {
        viewModelScope.launch {
            try {
                val result: List<Device> = myApi.retrofitService.getDevices()
                deviceListUIState.value = result
            } catch (e: Exception) {
                Log.e(this.toString(), e.message ?: "")
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

ian*_*ake 8

正如您所发现的,您不应该请求任何数据作为组合的一部分 - 正如文档中所解释的,组合应该没有副作用。除了这种无限重组问题之外,许多操作(例如动画)也会导致频繁的重组

为了解决这个问题,你需要移动那些getDevices不合时宜的东西。

有三种方法可以做到这一点:

不是最好的: 1. 使用类似的效果LaunchedEffect

val vm:DeviceList = viewModel()
LaunchedEffect(vm) {
    vm.getDevices()
}
var devices = vm.uiState.collectAsState();
Run Code Online (Sandbox Code Playgroud)

这会将调用移出组合,但仍需要在可组合代码中进行手动调用。这也意味着每次您返回到此屏幕(例如,屏幕“进入合成”)时,都会再次调用该方法,而不是使用您已经加载的数据。

更好:2.创建ViewModel时加载一次数据

class DeviceList : ViewModel() {

    private var deviceListUIState: MutableStateFlow<List<Device>> = MutableStateFlow(
        listOf()
    )

    val uiState
        get() = deviceListUIState.asStateFlow()

    init {
      // Call getDevices() only once when the ViewModel is created
      getDevices()
    }

    fun getDevices() {
        viewModelScope.launch {
            try {
                val result: List<Device> = myApi.retrofitService.getDevices()
                deviceListUIState.value = result
            } catch (e: Exception) {
                Log.e(this.toString(), e.message ?: "")
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

通过调用getDevicesViewModelinit的 ,它只会被调用一次。这意味着逻辑根本不必存在于您的可组合项中:

// Just by calling this, the loading has already started
val vm:DeviceList = viewModel()
var devices = vm.uiState.collectAsState();
Run Code Online (Sandbox Code Playgroud)

但是,这使得测试 ViewModel 变得相当困难,因为您无法准确控制加载开始的时间。

最好:3.让你的 ViewModel 从冷流中获取数据

不要单独使用 aMutableStateFlowviewModelScope.launch填充它,而是使用 aFlow来封装数据的加载,然后使用以下方法存储该 Flow 的结果stateIn

class DeviceList : ViewModel() {

    val uiState = flowOf {
        val result: List<Device> = myApi.retrofitService.getDevices()
        // We got a valid result, send it to the UI
        emit(result)
    }.catch { e ->
        // Any exceptions the Flow throws, we can catch them here
        Log.e(this.toString(), e.message ?: "")
    }.stateIn(
        viewModelScope, // Save the result so the Flow only gets called once
        SharingStarted.Lazily,
        initialValue = listOf()
    )
}
Run Code Online (Sandbox Code Playgroud)

我们仍然得到与上面看到的相同的可组合项:

val vm:DeviceList = viewModel()
val devices = vm.uiState.collectAsState();
Run Code Online (Sandbox Code Playgroud)

但现在它是 UI 中的第一个调用,collectAsState从而启动了flowOf. 这使得测试 ViewModel 变得容易(因为您可以调用uiStatecollect来验证它是否返回您的值)。

这也为将来使系统变得更加智能提供了更大的灵活性 - 如果您稍后添加一个数据层和一个控制 Retrofit 数据和本地数据(例如,存储在数据库中的数据)的存储库,您可以轻松地将其替换flowOf {}为调用您的存储库层,交换源代码而不更改任何其余逻辑。

它还SharingStarted允许您使用类似的东西SharingStarted.WhileSubscribed(5000L)- 如果您实际上有一个Flow一直在变化的数据(例如,当用户在该屏幕上时您有更改数据的推送消息),这将确保您的 ViewModel 不会当您的 UI 不可见时(即您的应用程序在后台)执行不必要的工作,但一旦用户重新打开您的应用程序,就会立即重新启动。