refactor(viewmodel): 将回调方式统一改为 StateFlow + UiState,消除竞态问题

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 14:01:24 +08:00
co-authored by Claude Sonnet 4.6
parent df0580d68f
commit 0243fe0dc8
8 changed files with 197 additions and 115 deletions
@@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlin.collections.forEach
class DbViewModel : ViewModel() { class DbViewModel : ViewModel() {
@@ -24,10 +23,12 @@ class DbViewModel : ViewModel() {
} }
} }
fun getCookFoodById(foodId: String, cookMode: Int, action: (CookFoodEntity?) -> Unit) { private val _cookFoodDetailState = MutableStateFlow<CookFoodEntity?>(null)
val cookFoodDetailState: StateFlow<CookFoodEntity?> = _cookFoodDetailState.asStateFlow()
fun getCookFoodById(foodId: String, cookMode: Int) {
viewModelScope.launch { viewModelScope.launch {
val data = rep.getCookFoodById(foodId, cookMode) _cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode)
action(data)
} }
} }
@@ -43,11 +44,8 @@ class DbViewModel : ViewModel() {
} }
} }
fun countCookFood(cookMode: Int, action: (Int) -> Unit) { suspend fun countCookFood(cookMode: Int): Int {
viewModelScope.launch { return rep.countCookFood(cookMode)
val count = rep.countCookFood(cookMode)
action(count)
}
} }
// fun saveCookFood(cookMode: Int, entity: CookFoodEntity) { // fun saveCookFood(cookMode: Int, entity: CookFoodEntity) {
@@ -141,11 +139,18 @@ class DbViewModel : ViewModel() {
// } // }
private val loadSeasoningMutex = Mutex() private val loadSeasoningMutex = Mutex()
fun loadSeasoning(action: (MutableList<SeasoningEntity>) -> Unit) {
/**
* 本地调料列表状态流
*/
private val _seasoningState = MutableStateFlow<MutableList<SeasoningEntity>>(mutableListOf())
val seasoningState: StateFlow<MutableList<SeasoningEntity>> = _seasoningState.asStateFlow()
fun loadSeasoning() {
if (loadSeasoningMutex.isLocked) return if (loadSeasoningMutex.isLocked) return
viewModelScope.launch { viewModelScope.launch {
loadSeasoningMutex.withLock { loadSeasoningMutex.withLock {
action(rep.getAllStream()) _seasoningState.value = rep.getAllStream()
} }
} }
} }
@@ -172,21 +177,26 @@ class DbViewModel : ViewModel() {
} }
} }
fun clearAllSeasoning(action:()->Unit) { private val _clearAllSeasoningState = MutableStateFlow(false)
val clearAllSeasoningState: StateFlow<Boolean> = _clearAllSeasoningState.asStateFlow()
fun clearAllSeasoning() {
viewModelScope.launch { viewModelScope.launch {
rep.clearAllSeasoning() rep.clearAllSeasoning()
action() _clearAllSeasoningState.value = true
} }
} }
private val _hasSeasoningSlotConfigState = MutableStateFlow(false)
val hasSeasoningSlotConfigState: StateFlow<Boolean> = _hasSeasoningSlotConfigState.asStateFlow()
/** /**
* 查询指定设备是否已配置调料槽位 * 查询指定设备是否已配置调料槽位
* @param deviceId 目标设备 ID * @param deviceId 目标设备 ID
* @param action 回调,true 表示已配置,false 表示未配置
*/ */
fun hasSeasoningSlotConfig(deviceId: String, action: (Boolean) -> Unit) { fun hasSeasoningSlotConfig(deviceId: String) {
viewModelScope.launch { viewModelScope.launch {
action(rep.hasSeasoningSlotConfig(deviceId)) _hasSeasoningSlotConfigState.value = rep.hasSeasoningSlotConfig(deviceId)
} }
} }
@@ -8,6 +8,9 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import androidx.core.view.isEmpty import androidx.core.view.isEmpty
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
@@ -17,9 +20,12 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.DialogFoodSearchBinding import com.shuwei.dish.match.databinding.DialogFoodSearchBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/** /**
* 食材搜索弹窗,继承 BottomSheetDialog 确保只初始化一次 * 食材搜索弹窗,继承 BottomSheetDialog 确保只初始化一次
@@ -97,6 +103,8 @@ class FoodSearchDialog(
binding.ivSearch.setOnClickListener { searchGoods(it) } binding.ivSearch.setOnClickListener { searchGoods(it) }
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it.context, it) } binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it.context, it) }
initObserver()
// 若有默认食材名称,自动填充并触发搜索 // 若有默认食材名称,自动填充并触发搜索
if (!defGoodsName.isNullOrBlank()) { if (!defGoodsName.isNullOrBlank()) {
binding.etSheetInput.setText(defGoodsName) binding.etSheetInput.setText(defGoodsName)
@@ -104,6 +112,26 @@ class FoodSearchDialog(
} }
} }
/**
* 收集 goodsListState,统一处理 Loading / Success / Error 状态
*/
private fun initObserver() {
activity.lifecycleScope.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
activity.netViewModel.goodsListState.collect { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
}
else -> {}
}
}
}
}
}
/** /**
* 触发搜索:校验输入、隐藏键盘、发起请求 * 触发搜索:校验输入、隐藏键盘、发起请求
*/ */
@@ -130,12 +158,7 @@ class FoodSearchDialog(
if (!goodsName.isNullOrBlank()) { if (!goodsName.isNullOrBlank()) {
param["goodsName"] = goodsName!! param["goodsName"] = goodsName!!
} }
activity.netViewModel.queryGoodsList(param = param, onSuccess = { activity.netViewModel.queryGoodsList(param)
loadGoodsList(it)
}, onFailure = { _, msg ->
activity.toast(msg)
finishRefresh()
})
} }
/** /**
@@ -18,6 +18,9 @@ import android.view.WindowManager
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.core.view.isEmpty import androidx.core.view.isEmpty
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
@@ -27,6 +30,7 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.DialogSeasoningSearchBinding import com.shuwei.dish.match.databinding.DialogSeasoningSearchBinding
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.WeightUtil import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
@@ -35,6 +39,8 @@ import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.dp import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/** /**
* 调料搜索弹窗,继承 BottomSheetDialog 确保只初始化一次 * 调料搜索弹窗,继承 BottomSheetDialog 确保只初始化一次
@@ -106,10 +112,10 @@ class SeasoningSearchDialog(
} }
// RecyclerView 初始化及滑动冲突处理 // RecyclerView 初始化及滑动冲突处理
binding.recyclerView.run { binding.recyclerView.let {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false) it.layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = this@SeasoningSearchDialog.adapter it.adapter = adapter
addOnScrollListener(object : RecyclerView.OnScrollListener() { it.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(rv, dx, dy) super.onScrolled(rv, dx, dy)
// 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突 // 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
@@ -147,6 +153,8 @@ class SeasoningSearchDialog(
) )
binding.tvWeight.text = getTextSpan(0.0) binding.tvWeight.text = getTextSpan(0.0)
initObserver()
// 若有默认调料名称,自动填充并触发搜索 // 若有默认调料名称,自动填充并触发搜索
if (!clickName.isNullOrBlank()) { if (!clickName.isNullOrBlank()) {
binding.etSheetInput.setText(clickName.trim()) binding.etSheetInput.setText(clickName.trim())
@@ -155,6 +163,26 @@ class SeasoningSearchDialog(
} }
} }
/**
* 收集 seasoningListState,统一处理 Loading / Success / Error 状态
*/
private fun initObserver() {
activity.lifecycleScope.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
activity.netViewModel.seasoningListState.collect { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
}
else -> {}
}
}
}
}
}
/** /**
* 触发搜索:校验输入、隐藏键盘、发起请求 * 触发搜索:校验输入、隐藏键盘、发起请求
*/ */
@@ -181,12 +209,7 @@ class SeasoningSearchDialog(
if (!goodsName.isNullOrBlank()) { if (!goodsName.isNullOrBlank()) {
param["goodsName"] = goodsName!! param["goodsName"] = goodsName!!
} }
activity.netViewModel.querySeasoningList(param = param, onSuccess = { activity.netViewModel.querySeasoningList(param)
loadGoodsList(it)
}, onFailure = { _, msg ->
activity.toast(msg)
finishRefresh()
})
} }
/** /**
@@ -35,16 +35,19 @@ class NetViewModel(
} }
} }
/**
* 查询菜品详情的 UI 状态流,UI 层通过 collect 监听
*/
private val _foodDetailState = MutableStateFlow<UiState<CookFoodEntity?>>(UiState.Idle)
val foodDetailState: StateFlow<UiState<CookFoodEntity?>> = _foodDetailState.asStateFlow()
/** /**
* 查询菜品详情 * 查询菜品详情
*/ */
fun getFoodDetail( fun getFoodDetail(foodId: String) {
foodId: String,
onSuccess: (CookFoodEntity?) -> Unit,
onFailure: (String, String) -> Unit
) {
viewModelScope.launch { viewModelScope.launch {
repository.getFoodDetail(foodId, onSuccess, onFailure) _foodDetailState.value = UiState.Loading
_foodDetailState.value = repository.getFoodDetail(foodId)
} }
} }
@@ -80,29 +83,35 @@ class NetViewModel(
} }
} }
/**
* 查询物品信息列表的 UI 状态流,UI 层通过 collect 监听
*/
private val _goodsListState = MutableStateFlow<UiState<MutableList<CookFoodGoodsEntity>?>>(UiState.Idle)
val goodsListState: StateFlow<UiState<MutableList<CookFoodGoodsEntity>?>> = _goodsListState.asStateFlow()
/** /**
* 查询物品信息列表 * 查询物品信息列表
*/ */
fun queryGoodsList( fun queryGoodsList(param: MutableMap<String, Any>) {
param: MutableMap<String, Any>,
onSuccess: (MutableList<CookFoodGoodsEntity>?) -> Unit,
onFailure: (String, String) -> Unit
) {
viewModelScope.launch { viewModelScope.launch {
repository.queryGoodsList(param, onSuccess, onFailure) _goodsListState.value = UiState.Loading
_goodsListState.value = repository.queryGoodsList(param)
} }
} }
/**
* 查询调料信息列表的 UI 状态流,UI 层通过 collect 监听
*/
private val _seasoningListState = MutableStateFlow<UiState<MutableList<SeasoningEntity>?>>(UiState.Idle)
val seasoningListState: StateFlow<UiState<MutableList<SeasoningEntity>?>> = _seasoningListState.asStateFlow()
/** /**
* 查询调料信息列表 * 查询调料信息列表
*/ */
fun querySeasoningList( fun querySeasoningList(param: MutableMap<String, Any>) {
param: MutableMap<String, Any>,
onSuccess: (MutableList<SeasoningEntity>?) -> Unit,
onFailure: (String, String) -> Unit
) {
viewModelScope.launch { viewModelScope.launch {
repository.querySeasoningList(param, onSuccess, onFailure) _seasoningListState.value = UiState.Loading
_seasoningListState.value = repository.querySeasoningList(param)
} }
} }
} }
@@ -30,18 +30,18 @@ class RemoteRepository {
/** /**
* 查询菜品详情 * 查询菜品详情
* @param foodId 菜品 ID * @param foodId 菜品 ID
* @param onSuccess 成功回调 * @return UiState 包装的结果,Success 携带详情数据,Error 携带错误信息
* @param onFailure 失败回调,参数为 (errorCode, errorMsg)
*/ */
suspend fun getFoodDetail( suspend fun getFoodDetail(foodId: String): UiState<CookFoodEntity?> {
foodId: String, return try {
onSuccess: (CookFoodEntity?) -> Unit, val resp = apiService.getFoodDetail(foodId = foodId)
onFailure: (String, String) -> Unit if (resp.isSuccess()) UiState.Success(resp.data)
) = request( else UiState.Error(resp.code, resp.msg ?: "")
onRequest = { apiService.getFoodDetail(foodId = foodId) }, } catch (e: Exception) {
onSuccess = onSuccess, val ex = getApiException(e)
onFailure = onFailure UiState.Error("-1", ex.errorMsg)
) }
}
/** /**
* 搜索菜品列表 * 搜索菜品列表
@@ -78,32 +78,34 @@ class RemoteRepository {
/** /**
* 查询物品信息列表 * 查询物品信息列表
* @param param 查询参数 * @param param 查询参数
* @param onSuccess 成功回调 * @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
* @param onFailure 失败回调,参数为 (errorCode, errorMsg)
*/ */
suspend fun queryGoodsList( suspend fun queryGoodsList(param: MutableMap<String, Any>): UiState<MutableList<CookFoodGoodsEntity>?> {
param: MutableMap<String, Any>, return try {
onSuccess: (MutableList<CookFoodGoodsEntity>?) -> Unit, val resp = apiService.queryGoodsList(param = param)
onFailure: (String, String) -> Unit if (resp.isSuccess()) UiState.Success(resp.data)
) = request( else UiState.Error(resp.code, resp.msg ?: "")
onRequest = { apiService.queryGoodsList(param = param) }, } catch (e: Exception) {
onSuccess = onSuccess, val ex = getApiException(e)
onFailure = onFailure UiState.Error("-1", ex.errorMsg)
) }
}
/** /**
* 查询调料信息列表 * 查询调料信息列表
* @param param 查询参数 * @param param 查询参数
* @param onSuccess 成功回调 * @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
* @param onFailure 失败回调,参数为 (errorCode, errorMsg)
*/ */
suspend fun querySeasoningList( suspend fun querySeasoningList(param: MutableMap<String, Any>): UiState<MutableList<SeasoningEntity>?> {
param: MutableMap<String, Any>, return try {
onSuccess: (MutableList<SeasoningEntity>?) -> Unit, val resp = apiService.querySeasoningList(param = param)
onFailure: (String, String) -> Unit if (resp.isSuccess()) UiState.Success(resp.data)
) = request( else UiState.Error(resp.code, resp.msg ?: "")
onRequest = { apiService.querySeasoningList(param = param) }, } catch (e: Exception) {
onSuccess = onSuccess, val ex = getApiException(e)
onFailure = onFailure UiState.Error("-1", ex.errorMsg)
) }
}
} }
@@ -142,19 +142,18 @@ class HomeActivity : BaseActivity() {
finish() finish()
return@launch return@launch
} }
appViewModel.countCookFood(cookMode = 1) { count -> val count = appViewModel.countCookFood(cookMode = 1)
if (count > 0) { if (count > 0) {
// 有烹饪中数据,打开采样历史列表页面 // 有烹饪中数据,打开采样历史列表页面
startActivity<SamplingListActivity>() startActivity<SamplingListActivity>()
finish()
return@countCookFood
}
// 无烹饪数据,打开新增采样页面
startActivity<PrepareCookActivity> {
putExtra(PrepareCookActivity.PAGE_FROM, PrepareCookActivity.HOME)
}
finish() finish()
return@launch
} }
// 无烹饪数据,打开新增采样页面
startActivity<PrepareCookActivity> {
putExtra(PrepareCookActivity.PAGE_FROM, PrepareCookActivity.HOME)
}
finish()
} }
} }
@@ -95,16 +95,15 @@ class InitActivity : BaseActivity() {
startActivity<SamplingListActivity>() startActivity<SamplingListActivity>()
return@launch return@launch
} }
appViewModel.countCookFood(cookMode = 1) { count -> val count = appViewModel.countCookFood(cookMode = 1)
if (count > 0) { if (count > 0) {
// 有烹饪中数据,打开采样历史列表页面 // 有烹饪中数据,打开采样历史列表页面
startActivity<SamplingListActivity>() startActivity<SamplingListActivity>()
return@countCookFood return@launch
} }
// 无烹饪数据,打开新增采样页面 // 无烹饪数据,打开新增采样页面
startActivity<PrepareCookActivity> { startActivity<PrepareCookActivity> {
putExtra(PrepareCookActivity.PAGE_FROM, PrepareCookActivity.HOME) putExtra(PrepareCookActivity.PAGE_FROM, PrepareCookActivity.HOME)
}
} }
} }
} }
@@ -8,7 +8,9 @@ import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.addCallback import androidx.activity.addCallback
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
@@ -20,6 +22,7 @@ import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.objbox.FoodModule import com.shuwei.dish.match.objbox.FoodModule
import com.shuwei.dish.match.utils.AddressUtil import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.CameraUtils import com.shuwei.dish.match.utils.CameraUtils
@@ -36,6 +39,7 @@ import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.Serializable import java.io.Serializable
@@ -110,6 +114,7 @@ class PrepareCookActivity : BaseActivity() {
addBackKeyListener() addBackKeyListener()
initCamera(binding.flCameraContainer) initCamera(binding.flCameraContainer)
initRecyclerView() initRecyclerView()
initObserver()
if (!isSamplingMode) { if (!isSamplingMode) {
getDishDetail() getDishDetail()
@@ -267,6 +272,28 @@ class PrepareCookActivity : BaseActivity() {
} }
private fun initObserver() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
netViewModel.foodDetailState.collect { state ->
when (state) {
is UiState.Success -> {
val detail = state.data
if (detail == null) {
toast("查询菜品信息为空")
return@collect
}
loadDishDetail(detail)
}
is UiState.Error -> toast(state.msg)
else -> {}
}
}
}
}
}
private fun getDishDetail() { private fun getDishDetail() {
// //649 // //649
// val url = "${UrlConfig.DISH_DETAIL}?foodId=${food?.foodId}" // val url = "${UrlConfig.DISH_DETAIL}?foodId=${food?.foodId}"
@@ -285,17 +312,7 @@ class PrepareCookActivity : BaseActivity() {
// toast(msg) // toast(msg)
// } // }
netViewModel.getFoodDetail( netViewModel.getFoodDetail(food?.foodId ?: "")
food?.foodId ?: "",
onSuccess = { detail ->
if (detail == null) {
toast("查询菜品信息为空")
return@getFoodDetail
}
loadDishDetail(detail)
}, onFailure = { code, msg ->
toast(msg)
})
} }