使用 Compose 多平台(Compose Multiplatform)可以在通用代码中实现安卓(Android)中那种通过视图模型(ViewModelhttps://developer.android.com/topic/libraries/architecture/viewmodel)构建用户界面(UI)的方法。
在 Compose 多平台环境中,对通用视图模型(ViewModel)的支持仍处于实验阶段。
Stability of supported platforms | Kotlin Multiplatform Development Documentation
commonMain
源集中:kotlin {
// ...
sourceSets {
// ...
commonMain.dependencies {
// ...
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2")
}
// ...
}
}
在通用代码中使用视图模型(ViewModel)
Compose 多平台实现了通用的 ViewModelStoreOwner
接口,因此,总体而言,在通用代码中使用 ViewModel
类与安卓(Android)最佳实践并没有太大差异。
以导航示例来说明:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
class OrderViewModel : ViewModel() {
private val _uiState = MutableStateFlow(OrderUiState(pickupOptions = pickupOptions()))
val uiState: StateFlow = _uiState.asStateFlow()
// ...
}
声明 ViewModel
类:
@Composable
fun CupcakeApp(
viewModel: OrderViewModel = viewModel { OrderViewModel() },
) {
// ...
}
在视图模型(ViewModel)中运行协程时,要记住
ViewModel.viewModelScope
的值是与Dispatchers.Main.immediate
的值绑定的,而在桌面端默认情况下,Dispatchers.Main.immediate
可能不可用。为了使视图模型中的协程能在 Compose 多平台环境下正确运行,需将
kotlinx-coroutines-swing
依赖项添加到你的项目中。详情请参阅Dispatchers.Main
的相关文档。在使用 Kotlin 协程时,如何确保在不同平台上正确调度?
除了`kotlinx-coroutines-swing`,还有哪些与协程相关的库适用于 Compose Multiplatform?
在 Compose Multiplatform 中,如何处理与平台相关的差异?
,请参阅 Dispatchers.Main 文档。
ViewModel
ViewModel.viewModelScope
Dispatchers.Main.immediate
kotlinx-coroutines-swing
在非 JVM 平台上,无法使用类型反射来实例化对象。因此,在通用代码中,你不能无参数地调用 viewModel()
函数:每次创建视图模型(ViewModel)实例时,都至少需要提供一个初始化器作为参数。
如果仅提供了一个初始化器,该库会在底层创建一个默认的工厂。不过,你可以自行实现工厂,并调用更明确版本的通用 viewModel(...)
函数,这与使用 jetpack-composehttps://developer.android.com/topic/libraries/architecture/viewmodel#jetpack-compose 时的情况类似。
由于官方示例写的太长了,模拟订单相关的业务逻辑,它管理着订单的各项关键信息(如数量、口味、取货日期等),并且能够基于这些信息来计算订单的总价,同时提供了一系列方法用于更新订单状态以及获取相关的可选信息。以下是效果和代码示例
Bean对象
/**
* Data class that represents the current UI state in terms of [quantity], [flavor],
* [dateOptions], selected pickup [date] and [price]
*/
data class OrderUiState(
/** Selected cupcake quantity (1, 6, 12) */
val quantity: Int = 0,
/** Flavor of the cupcakes in the order (such as "Chocolate", "Vanilla", etc..) */
val flavor: String = "",
/** Selected date for pickup (such as "Jan 1") */
val date: String = "",
/** Total price for the order */
val price: String = "",
/** Available pickup dates for the order*/
val pickupOptions: List = listOf()
)
ViewModel
** Price for a single cupcake */
private const val PRICE_PER_CUPCAKE = 2.00
/** Additional cost for same day pickup of an order */
private const val PRICE_FOR_SAME_DAY_PICKUP = 3.00
/**
* [OrderViewModel] holds information about a cupcake order in terms of quantity, flavor, and
* pickup date. It also knows how to calculate the total price based on these order details.
*/
class OrderViewModel : ViewModel() {
/**
* Cupcake state for this order
*/
private val _uiState = MutableStateFlow(OrderUiState(pickupOptions = pickupOptions()))
val uiState: StateFlow = _uiState.asStateFlow()
/**
* Set the quantity [numberCupcakes] of cupcakes for this order's state and update the price
*/
fun setQuantity(numberCupcakes: Int) {
_uiState.update { currentState ->
currentState.copy(
quantity = numberCupcakes,
price = calculatePrice(quantity = numberCupcakes)
)
}
}
/**
* Set the [desiredFlavor] of cupcakes for this order's state.
* Only 1 flavor can be selected for the whole order.
*/
fun setFlavor(desiredFlavor: String) {
_uiState.update { currentState ->
currentState.copy(flavor = desiredFlavor)
}
}
/**
* Set the [pickupDate] for this order's state and update the price
*/
fun setDate(pickupDate: String) {
_uiState.update { currentState ->
currentState.copy(
date = pickupDate,
price = calculatePrice(pickupDate = pickupDate)
)
}
}
/**
* Reset the order state
*/
fun resetOrder() {
_uiState.value = OrderUiState(pickupOptions = pickupOptions())
}
/**
* Returns the calculated price based on the order details.
*/
fun calculatePrice(
quantity: Int = _uiState.value.quantity,
pickupDate: String = _uiState.value.date
): String {
var calculatedPrice = quantity * PRICE_PER_CUPCAKE
// If the user selected the first option (today) for pickup, add the surcharge
if (pickupOptions()[0] == pickupDate) {
calculatedPrice += PRICE_FOR_SAME_DAY_PICKUP
}
return "$calculatedPrice€"
}
/**
* Returns a list of date options starting with the current date and the following 3 dates.
*/
fun pickupOptions(): List {
val dateOptions = mutableListOf()
val now = Clock.System.now()
val timeZone = TimeZone.currentSystemDefault()
// add current date and the following 3 dates.
repeat(4) {
val day= now.plus(0, DateTimeUnit.DAY,timeZone)
dateOptions.add(day.toLocalDateTime(timeZone).date.toString())
}
return dateOptions
}
}
UI交互
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun OrderScreen() {
val viewModel: OrderViewModel = viewModel()
val uiState by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(text = "Cupcake Order", style = MaterialTheme.typography.h5)
// 选择数量下拉框
QuantityDropdown(
quantity = uiState.quantity,
onQuantitySelected = viewModel::setQuantity
)
// 选择口味下拉框
FlavorDropdown(
flavor = uiState.flavor,
onFlavorSelected = viewModel::setFlavor
)
// 选择取货日期下拉框
DateDropdown(
viewModel,
date = uiState.date,
onDateSelected = viewModel::setDate
)
// 显示总价
Text(text = "Total Price: ${uiState.price}", style = MaterialTheme.typography.h6)
// 重置订单按钮
Button(
onClick = viewModel::resetOrder,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(text = "Reset Order")
}
}
}
// 数量下拉框组件
@Composable
fun QuantityDropdown(
quantity: Int,
onQuantitySelected: (Int) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
var selectedQuantity by remember { mutableStateOf(quantity) }
Column(modifier = Modifier.fillMaxWidth()) {
TextField(
value = selectedQuantity.toString(),
onValueChange = {
if (it.isNotEmpty()) {
selectedQuantity = it.toInt()
}
},
label = { Text(text = "Quantity") },
readOnly = true,
trailingIcon = {
IconButton(onClick = { expanded = true }) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = "Expand Quantity Dropdown"
)
}
},
modifier = Modifier.fillMaxWidth()
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
listOf(1, 6, 12).forEach { option ->
DropdownMenuItem(
onClick = {
onQuantitySelected(option)
selectedQuantity = option
expanded = false
}
) {
Text(text = option.toString())
}
}
}
}
}
// 选择口味下拉框组件
@Composable
fun FlavorDropdown(
flavor: String,
onFlavorSelected: (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
var selectedFlavor by remember { mutableStateOf(flavor) }
Column(modifier = Modifier.fillMaxWidth()) {
TextField(
value = selectedFlavor,
onValueChange = { selectedFlavor = it },
label = { Text(text = "Flavor") },
readOnly = true,
trailingIcon = {
IconButton(onClick = { expanded = true }) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = "Expand Flavor Dropdown"
)
}
},
modifier = Modifier.fillMaxWidth()
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
listOf("Chocolate", "Vanilla", "Strawberry").forEach { option ->
DropdownMenuItem(
onClick = {
onFlavorSelected(option)
selectedFlavor = option
expanded = false
}
) {
Text(text = option)
}
}
}
}
}
// 选择取货日期下拉框组件
@Composable
fun DateDropdown(
viewModel: OrderViewModel,
date: String,
onDateSelected: (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
var selectedDate by remember { mutableStateOf(date) }
Column(modifier = Modifier.fillMaxWidth()) {
TextField(
value = selectedDate,
onValueChange = { selectedDate = it },
label = { Text(text = "Pickup Date") },
readOnly = true,
trailingIcon = {
IconButton(onClick = { expanded = true }) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = "Expand Date Dropdown"
)
}
},
modifier = Modifier.fillMaxWidth()
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
viewModel.pickupOptions().forEach { option ->
DropdownMenuItem(
onClick = {
onDateSelected(option)
selectedDate = option
expanded = false
}
) {
Text(text = option)
}
}
}
}
}