当组件依赖于 ViewModel 提供的某些数据时,如何在 Jetpack Compose 中制作 @Preview

How to make a @Preview in JetpackCompose, when the component depends of some data provide by ViewModel

我正在开发一个应用程序,我试图在其中实现一些新技术,例如 Jetpack Compose。总的来说,它是一个很棒的工具,除了它比常规 xml 设计文件具有硬预可视化系统 (@Preview) 的事实。

当我尝试创建代表不同行的组件的 @Preview 时,我的问题就出现了,我在其中加载了从网络恢复的数据。

就我而言,我是这样做的:

@Preview(
    name ="ListScreenPreview ",
    showSystemUi = true,
    showBackground = true,
    device = Devices.NEXUS_9)
@Composable
fun myPokemonRowPreview(
    @PreviewParameter(PokemonListScreenProvider::class) pokemonMokData: PokedexListModel
) {
        PokedexEntry(
            model = pokemonMokData,
            navController = rememberNavController(),
            viewModel = hiltViewModel())

}

class PokemonListScreenProvider: PreviewParameterProvider<PokedexListModel> {
    override val values: Sequence<PokedexListModel> = sequenceOf(
        PokedexListModel(
            pokemonName = "Cacamon",
            number = 0,
            imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/2.png"
        ),
        PokedexListModel(
            pokemonName = "Tontaro",
            number = 73,
            imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png"

        )
    )
}

代表这个@Composable:


@Composable
fun PokemonListScreen(
    navController: NavController,
    viewModel: PokemonListViewModel
) {
    
    Surface(
        color = MaterialTheme.colors.background,
        modifier = Modifier.fillMaxSize()
    )
    {
        Column {
            Spacer(modifier = Modifier.height(20.dp))
            Image(
                painter = painterResource(id = R.drawable.ic_international_pok_mon_logo),
                contentDescription = "Pokemon",
                modifier = Modifier
                    .fillMaxWidth()
                    .align(CenterHorizontally)
            )
            SearchBar(
                hint = "Search...",
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp)

            ) {

                viewModel.searchPokemonList(it)

            }

            Spacer(modifier = Modifier.height(16.dp))
            PokemonList(navController = navController,
                        viewModel = viewModel)


        }
    }
}


@Composable
fun SearchBar(
    modifier: Modifier = Modifier,
    hint: String = " ",
    onSearch: (String) -> Unit = { }
) {

    var text by remember {
        mutableStateOf("")
    }

    var isHintDisplayed by remember {
        mutableStateOf(hint != "")
    }

    Box(modifier = modifier) {
        BasicTextField(value = text,
            onValueChange = {
                text = it
                onSearch(it)
            },
            maxLines = 1,
            singleLine = true,
            textStyle = TextStyle(color = Color.Black),
            modifier = Modifier
                .fillMaxWidth()
                .shadow(5.dp, CircleShape)
                .background(Color.White, CircleShape)
                .padding(horizontal = 20.dp, vertical = 12.dp)
                .onFocusChanged {
                    isHintDisplayed = !it.isFocused
                }
        )
        if (isHintDisplayed) {
            Text(
                text = hint,
                color = Color.LightGray,
                modifier = Modifier
                    .padding(horizontal = 20.dp, vertical = 12.dp)

            )
        }

    }
}

@Composable
fun PokemonList(
    navController: NavController,
    viewModel: PokemonListViewModel
) {

    val pokemonList by remember { viewModel.pokemonList }
    val endReached by remember { viewModel.endReached }
    val loadError by remember { viewModel.loadError }
    val isLoading by remember { viewModel.isLoading }
    val isSearching by remember { viewModel.isSearching }


    LazyColumn(contentPadding = PaddingValues(16.dp)) {

        val itemCount = if (pokemonList.size % 2 == 0) {
            pokemonList.size / 2
        } else {
            pokemonList.size / 2 + 1
        }

        items(itemCount) {
            if (it >= itemCount - 1 && !endReached && !isLoading && !isSearching) {
                viewModel.loadPokemonPaginated()
            }
            PokedexRow(rowIndex = it, models = pokemonList, navController = navController, viewModel = viewModel)
        }
    }


    Box(
        contentAlignment = Center,
        modifier = Modifier.fillMaxSize()
    ) {
        if (isLoading) {
            CircularProgressIndicator(color = MaterialTheme.colors.primary)
        }
        if (loadError.isNotEmpty()) {
            RetrySection(error = loadError) {
                viewModel.loadPokemonPaginated()
            }
        }
    }

}


@SuppressLint("LogNotTimber")
@Composable
fun PokedexEntry(
    model: PokedexListModel,
    navController: NavController,
    modifier: Modifier = Modifier,
    viewModel: PokemonListViewModel
) {
    val defaultDominantColor = MaterialTheme.colors.surface
    var dominantColor by remember {
        mutableStateOf(defaultDominantColor)
    }

    Box(
        contentAlignment = Center,
        modifier = modifier
            .shadow(5.dp, RoundedCornerShape(10.dp))
            .clip(RoundedCornerShape(10.dp))
            .aspectRatio(1f)
            .background(
                Brush.verticalGradient(
                    listOf(dominantColor, defaultDominantColor)
                )
            )
            .clickable {

                navController.navigate(
                    "pokemon_detail_screen/${dominantColor.toArgb()}/${model.pokemonName}/${model.number}"
                )
            }

    ) {

        Column {
            CoilImage(
                imageRequest = ImageRequest.Builder(LocalContext.current)
                    .data(model.imageUrl)
                    .target {
                        viewModel.calcDominantColor(it) { color ->
                            dominantColor = color
                        }
                    }.build(),
                imageLoader = ImageLoader.Builder(LocalContext.current)
                    .availableMemoryPercentage(0.25)
                    .crossfade(true)
                    .build(),
                contentDescription = model.pokemonName,
                modifier = Modifier
                    .size(120.dp)
                    .align(CenterHorizontally),
                loading = {
                    ConstraintLayout(
                        modifier = Modifier.fillMaxSize()
                    ) {
                        val indicator = createRef()
                        CircularProgressIndicator(
                            //Set constrains dynamically
                            modifier = Modifier.constrainAs(indicator) {
                                top.linkTo(parent.top)
                                bottom.linkTo(parent.bottom)
                                start.linkTo(parent.start)
                                end.linkTo(parent.end)
                            }
                        )
                    }
                },
                // shows an error text message when request failed.
                failure = {
                    Text(text = "image request failed.")
                }
            )

            Log.d("pokemonlist", model.imageUrl)
            Text(
                text = model.pokemonName,
                fontFamily = RobotoCondensed,
                fontSize = 20.sp,
                textAlign = TextAlign.Center,
                modifier = Modifier.fillMaxWidth(),

            )
        }
    }
}

@Composable
fun PokedexRow(
    rowIndex: Int,
    models: List<PokedexListModel>,
    navController: NavController,
    viewModel: PokemonListViewModel
) {
    Column {
        Row {
            PokedexEntry(
                model = models[rowIndex * 2],
                navController = navController,
                modifier = Modifier.weight(1f),
                viewModel = viewModel
            )

            Spacer(modifier = Modifier.width(16.dp))

            if (models.size >= rowIndex * 2 + 2) {
                PokedexEntry(
                    model = models[rowIndex * 2 + 1],
                    navController = navController,
                    modifier = Modifier.weight(1f),
                    viewModel = viewModel
                )
            } else {
                Spacer(modifier = Modifier.weight(1f))
            }
        }

        Spacer(modifier = Modifier.height(16.dp))
    }

}

@Composable
fun RetrySection(
    error: String,
    onRetry: () -> Unit,
) {
    Column() {
        Text(error, color = Color.Red, fontSize = 18.sp)
        Spacer(modifier = Modifier.height(8.dp))
        Button(
            onClick = { onRetry() },
            modifier = Modifier.align(CenterHorizontally)
        ) {
            Text(text = "Retry")
        }
    }
}

我尝试使用 PokemonListScreen @Composable 的 @Nullable navController 和 viewmodel 进行注释,但也不起作用。我仍然看到空白屏幕:

所以我尝试搜索 Jetpack 文档,但它只是定义了非常简单的可组合项。

因此,如果您对此有更多了解并且可以提供帮助,请提前致谢!

主要问题是如果我想预览@Composable,尽管我将@Nullable 设置为viewmodel 参数,我猜这是这里的问题,因为仍然需要初始化。因为我想将参数传递给预览的正确方法是通过 @PreviewArgument 注释。

[编辑]

经过一番挖掘,我发现 AS 在预览屏幕下返回以下错误:

所以,有什么方法可以避免视图模型错误??

[解决方案]

最后应用以下解决方案,该解决方案有效,因为问题的原因是 Hilt 与 Jetpack Compose 预览版存在一些不兼容问题:

  1. 创建 ViewModel 的接口,恢复所有变量和方法。
  2. 使您当前的 viemodel class 扩展界面。
  3. 创建一个在界面上扩展的 2º class 并将其传递给您的@Preview

@SuppressLint("UnrememberedMutableState")
@Preview(
    name ="ListScreenPreview",
    showSystemUi = true,
    showBackground = true,
    device = Devices.PIXEL)
@Composable
fun MyPokemonRowPreview(
    @PreviewParameter(PokemonListScreenProvider::class) pokemonMokData: PokedexListModel
) {
    JetpackComposePokedexTheme {
        PokedexRow(
            rowIndex = 0,
            models = PokemonListScreenProvider().values.toList(),
            navController = rememberNavController(),
            viewModel = PokemonListViewModelMock(
                0, mutableStateOf(""), mutableStateOf(value = false),
                mutableStateOf(false), mutableStateOf(listOf(pokemonMokData))
            )
        )
    }
}

class PokemonListScreenProvider: PreviewParameterProvider<PokedexListModel> {
    override val values: Sequence<PokedexListModel> = sequenceOf(
        PokedexListModel(
            pokemonName = "Machasaurio",
            number = 0,
            imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/2.png"
        ),
        PokedexListModel(
            pokemonName = "Tontaro",
            number = 73,
            imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png"

        )
    )
}

PokemonListViewModelInterface

interface PokemonListViewModelInterface {

    var curPage : Int

    var loadError: MutableState<String>
    var isLoading: MutableState<Boolean>
    var endReached: MutableState<Boolean>
    var pokemonList: MutableState<List<PokedexListModel>>

    fun searchPokemonList(query: String)

    fun loadPokemonPaginated()

    fun calcDominantColor(drawable: Drawable, onFinish: (Color) -> Unit)
}

PokemonListViewModelMock

class PokemonListViewModelMock (
    override var curPage: Int,
    override var loadError: MutableState<String>,
    override var isLoading: MutableState<Boolean>,
    override var endReached: MutableState<Boolean>,
    override var pokemonList: MutableState<List<PokedexListModel>>
): PokemonListViewModelInterface{
    override fun searchPokemonList(query: String) {
        TODO("Not yet implemented")
    }

    override fun loadPokemonPaginated() {
        TODO("Not yet implemented")
    }

    override fun calcDominantColor(drawable: Drawable, onFinish: (Color) -> Unit) {
        TODO("Not yet implemented")
    }
}

实际预览如下,虽然图片没有显示,但显示正确:

我不确定这个应用程序的深度,但一个潜在的想法是针对接口而不是实现进行编码。

也就是说,创建一个包含您需要的所有功能(可能已经存在于您的 ViewModel 中)的接口,让您的 PokemonListViewModel 实现它,然后创建另一个实现它的模拟 class以及。将模拟传递到您的预览中,并使用 PokemonListViewModel

留下真正的实现
interface PokeListViewModel {
  ...
  // your other val's
  val isLoading: Boolean
  fun searchPokemonList(pokemon: String)
  fun loadPokemonPaginated()
  // your other functions
  ...
}
  

创建界面后,您只需将可组合项更新为期待一个“是”PokeListViewModel 的对象。

希望这对您有所帮助

您可以创建另一个可组合项,它通过 lambda 函数调用视图模型逻辑,而不是使用视图模型本身。将您的 uiState 提取到一个单独的 class,因此它可以在您的视图模型中用作 StateFlow,而这又可以从可组合项中观察到。

@Composable
fun PokemonListScreen(
        navController: NavController,
        viewModel: PokemonListViewModel
) {
    /*
     rememberStateWithLifecyle is an extension function based on
     https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda
    */
    val uiState by rememberStateWithLifecycle(viewModel.uiState)

    PokemonListScreen(
        uiState = uiState,
        onLoadPokemons = viewModel::loadPokemons,
        onSearchPokemon = {viewModel.searchPokemon(it)},
        onCalculateDominantColor = {viewModel.calcDominantColor(it)},
        onNavigate = {route -> navController.navigate(route, null, null)},
    )
}

@Composable
private fun PokemonListScreen(
        uiState: PokemonUiState,
        onLoadPokemons:()->Unit,
        onSearchPokemon: (String) -> Unit,
        onCalculateDominantColor: (Drawable) -> Color,
        onNavigate:(String)->Unit,
) {


}


@HiltViewModel
class PokemonListViewModel @Inject constructor(/*your datasources*/) {

    private val loading = MutableStateFlow(false)
    private val loadError = MutableStateFlow(false)
    private val endReached = MutableStateFlow(false)
    private val searching = MutableStateFlow(false)
    private val pokemons = MutableStateFlow<Pokemon?>(null)

    val uiState: StateFlow<PokemonUiState> = combine(
        loading,
        loadError,
        endReached,
        searching,
        pokemons
    ) { loading, error, endReached, searching, pokemons ->
        PokemonUiState(
            isLoading = loading,
            loadError = error,
            endReached = endReached,
            isSearching = searching,
            pokemonList = pokemons,
        )
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = PokemonUiState.Empty,
    )
}


data class PokemonUiState(
        val pokemonList: List<Pokemon> = emptyList(),
        val endReached: Boolean = false,
        val loadError: Boolean = false,
        val isLoading: Boolean = false,
        val isSearching: Boolean = false,
) {
    companion object {
        val Empty = PokemonUiState()
    }
}