feat(dialog): FoodSearchDialog 新增空页面效果及 Window.Callback 手势冲突修复
- FoodSearchDialog:adapter 启用 isStateViewEnable,新增 loadEmptyView() 通过 adapter.stateView 展示空视图,替换原 toast 提示;覆写 show() 通过 Window.Callback.dispatchTouchEvent 动态控制 behavior.isDraggable,解决列表滑动与 BottomSheet 拖拽的手势冲突;注释旧 addOnScrollListener 方案保留备查 - SeasoningSelectDialog:同步补齐空页面效果,修复网络错误时也触发 loadEmptyView,优化 Handler 为 View.postDelayed - FoodRecognizeActivity:修复识别结果回填后列表未显示的问题 - 新增 scale包架构分析.md 文档
This commit is contained in:
@@ -1,21 +1,29 @@
|
|||||||
package com.shuwei.dish.match.dialog
|
package com.shuwei.dish.match.dialog
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.graphics.Rect
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
|
import android.view.KeyboardShortcutGroup
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.Window
|
||||||
|
import android.widget.FrameLayout
|
||||||
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.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.shuwei.dish.match.R
|
import com.shuwei.dish.match.R
|
||||||
import com.shuwei.dish.match.adapter.FoodAdapter
|
import com.shuwei.dish.match.adapter.FoodAdapter
|
||||||
import com.shuwei.dish.match.base.BaseActivity
|
import com.shuwei.dish.match.base.BaseActivity
|
||||||
import com.shuwei.dish.match.databinding.DialogFoodSearchBinding
|
import com.shuwei.dish.match.databinding.DialogFoodSearchBinding
|
||||||
|
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
|
||||||
import com.shuwei.dish.match.dialog.SeasoningSelectDialog.Companion.PAGE_SIZE
|
import com.shuwei.dish.match.dialog.SeasoningSelectDialog.Companion.PAGE_SIZE
|
||||||
import com.shuwei.dish.match.entity.GoodsItem
|
import com.shuwei.dish.match.entity.GoodsItem
|
||||||
import com.shuwei.dish.match.net.UiState
|
import com.shuwei.dish.match.net.UiState
|
||||||
@@ -42,20 +50,29 @@ class FoodSearchDialog(
|
|||||||
|
|
||||||
private val binding = DialogFoodSearchBinding.inflate(LayoutInflater.from(activity))
|
private val binding = DialogFoodSearchBinding.inflate(LayoutInflater.from(activity))
|
||||||
private val list = mutableListOf<GoodsItem>()
|
private val list = mutableListOf<GoodsItem>()
|
||||||
// .apply {
|
.apply {
|
||||||
// add(GoodsItem(goodsId = "200001", goodsName = "土豆丝"))
|
add(GoodsItem(goodsId = "200001", goodsName = "土豆丝"))
|
||||||
// add(GoodsItem(goodsId = "200002", goodsName = "土豆片"))
|
add(GoodsItem(goodsId = "200002", goodsName = "土豆片"))
|
||||||
// add(GoodsItem(goodsId = "200003", goodsName = "土豆丁"))
|
add(GoodsItem(goodsId = "200003", goodsName = "土豆丁"))
|
||||||
// add(GoodsItem(goodsId = "200004", goodsName = "胡萝卜丝"))
|
add(GoodsItem(goodsId = "200004", goodsName = "胡萝卜丝"))
|
||||||
// add(GoodsItem(goodsId = "200005", goodsName = "胡萝卜片"))
|
add(GoodsItem(goodsId = "200005", goodsName = "胡萝卜片"))
|
||||||
// add(GoodsItem(goodsId = "200006", goodsName = "胡萝卜丁"))
|
add(GoodsItem(goodsId = "200006", goodsName = "胡萝卜丁"))
|
||||||
// add(GoodsItem(goodsId = "200007", goodsName = "黄瓜丝"))
|
add(GoodsItem(goodsId = "200007", goodsName = "黄瓜丝"))
|
||||||
// add(GoodsItem(goodsId = "200008", goodsName = "黄瓜片"))
|
add(GoodsItem(goodsId = "200008", goodsName = "黄瓜片"))
|
||||||
// add(GoodsItem(goodsId = "200009", goodsName = "黄瓜丁"))
|
add(GoodsItem(goodsId = "200009", goodsName = "黄瓜丁"))
|
||||||
// add(GoodsItem(goodsId = "200010", goodsName = "洋葱丝"))
|
add(GoodsItem(goodsId = "200010", goodsName = "洋葱丝"))
|
||||||
// add(GoodsItem(goodsId = "200011", goodsName = "洋葱丁"))
|
add(GoodsItem(goodsId = "200011", goodsName = "洋葱丁"))
|
||||||
// }
|
add(GoodsItem(goodsId = "200012", goodsName = "A"))
|
||||||
|
add(GoodsItem(goodsId = "200013", goodsName = "B"))
|
||||||
|
add(GoodsItem(goodsId = "200014", goodsName = "C"))
|
||||||
|
add(GoodsItem(goodsId = "200015", goodsName = "D"))
|
||||||
|
add(GoodsItem(goodsId = "200016", goodsName = "E"))
|
||||||
|
add(GoodsItem(goodsId = "200017", goodsName = "F"))
|
||||||
|
add(GoodsItem(goodsId = "200018", goodsName = "G"))
|
||||||
|
add(GoodsItem(goodsId = "200019", goodsName = "H"))
|
||||||
|
}
|
||||||
private val adapter = FoodAdapter(list).apply {
|
private val adapter = FoodAdapter(list).apply {
|
||||||
|
isStateViewEnable = true
|
||||||
setOnItemClickListener { _, _, position ->
|
setOnItemClickListener { _, _, position ->
|
||||||
list[position].isClicked = true
|
list[position].isClicked = true
|
||||||
notifyItemChanged(position)
|
notifyItemChanged(position)
|
||||||
@@ -92,18 +109,18 @@ class FoodSearchDialog(
|
|||||||
addOnActionSearchListener { searchGoods(this) }
|
addOnActionSearchListener { searchGoods(this) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecyclerView 初始化及滑动冲突处理
|
// RecyclerView 初始化
|
||||||
binding.recyclerView.run {
|
binding.recyclerView.run {
|
||||||
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
|
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
|
||||||
adapter = this@FoodSearchDialog.adapter
|
adapter = this@FoodSearchDialog.adapter
|
||||||
addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
// 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 滑动冲突
|
||||||
val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
|
// val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
|
||||||
binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
|
// binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.refreshLayout.run {
|
binding.refreshLayout.run {
|
||||||
@@ -118,13 +135,51 @@ class FoodSearchDialog(
|
|||||||
|
|
||||||
initObserver()
|
initObserver()
|
||||||
|
|
||||||
// 若有默认食材名称,自动填充并触发搜索
|
// // 若有默认食材名称,自动填充并触发搜索
|
||||||
//if (!defGoodsName.isNullOrBlank()) {
|
// //if (!defGoodsName.isNullOrBlank()) {
|
||||||
binding.etSheetInput.setText(defGoodsName)
|
// binding.etSheetInput.setText(defGoodsName)
|
||||||
pageNo = 1
|
// pageNo = 1
|
||||||
getGoodsList()
|
// getGoodsList()
|
||||||
KeyboardUtil.hideKeyboard(binding.root)
|
// KeyboardUtil.hideKeyboard(binding.root)
|
||||||
//}
|
// //}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弹窗显示时在 Window.Callback 层提前拦截触摸,解决 BottomSheet 拖拽与列表滑动的手势冲突 */
|
||||||
|
override fun show() {
|
||||||
|
super.show()
|
||||||
|
// Window.Callback.dispatchTouchEvent 在整个 View 树的 onInterceptTouchEvent 之前执行,
|
||||||
|
// 是最早能感知触摸的时机。在此处判断触摸点位置来控制 behavior.isDraggable,
|
||||||
|
// 从而确保 BottomSheetBehavior 在 CoordinatorLayout.onInterceptTouchEvent 时
|
||||||
|
// 已经是禁用状态,不会拦截列表区域的滑动手势。
|
||||||
|
val originalCallback = window?.callback ?: return
|
||||||
|
window?.callback = object : Window.Callback by originalCallback {
|
||||||
|
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
val rect = Rect()
|
||||||
|
binding.refreshLayout.getGlobalVisibleRect(rect)
|
||||||
|
// 触摸点落在列表区域内则禁止 BottomSheet 拖拽
|
||||||
|
behavior.isDraggable = !rect.contains(event.rawX.toInt(), event.rawY.toInt())
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
behavior.isDraggable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalCallback.dispatchTouchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPointerCaptureChanged(hasCapture: Boolean) {
|
||||||
|
originalCallback.onPointerCaptureChanged(hasCapture)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProvideKeyboardShortcuts(
|
||||||
|
data: List<KeyboardShortcutGroup?>?,
|
||||||
|
menu: Menu?,
|
||||||
|
deviceId: Int
|
||||||
|
) {
|
||||||
|
originalCallback.onProvideKeyboardShortcuts(data, menu, deviceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,9 +234,12 @@ class FoodSearchDialog(
|
|||||||
private fun loadGoodsList(records: MutableList<GoodsItem>?) {
|
private fun loadGoodsList(records: MutableList<GoodsItem>?) {
|
||||||
finishRefresh()
|
finishRefresh()
|
||||||
if (records.isNullOrEmpty()) {
|
if (records.isNullOrEmpty()) {
|
||||||
activity.toast("暂未搜索到食材信息")
|
if (pageNo == 1) {
|
||||||
|
loadEmptyView()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
binding.recyclerView.layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
|
||||||
if (pageNo == 1) list.clear()
|
if (pageNo == 1) list.clear()
|
||||||
list.addAll(records)
|
list.addAll(records)
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
@@ -194,4 +252,36 @@ class FoodSearchDialog(
|
|||||||
if (pageNo == 1) binding.refreshLayout.finishRefresh()
|
if (pageNo == 1) binding.refreshLayout.finishRefresh()
|
||||||
else binding.refreshLayout.finishLoadMore()
|
else binding.refreshLayout.finishLoadMore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var emptyViewBinding: LayoutEmptyViewBinding? = null
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
|
private fun loadEmptyView() {
|
||||||
|
try {
|
||||||
|
binding.refreshLayout.setEnableRefresh(true)
|
||||||
|
binding.refreshLayout.setEnableLoadMore(false)
|
||||||
|
list.clear()
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
if (emptyViewBinding == null) {
|
||||||
|
emptyViewBinding = LayoutEmptyViewBinding.inflate(
|
||||||
|
LayoutInflater.from(activity),
|
||||||
|
binding.recyclerView,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
emptyViewBinding!!.tvContent.text = "暂无数据"
|
||||||
|
emptyViewBinding!!.tvSubContent.text = "未查询到食材信息,请联系管理员添加"
|
||||||
|
emptyViewBinding!!.root.layoutParams = FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
binding.recyclerView.layoutManager = LinearLayoutManager(activity)
|
||||||
|
binding.recyclerView.post {
|
||||||
|
emptyViewBinding!!.root.minimumHeight = binding.recyclerView.height
|
||||||
|
adapter.stateView = emptyViewBinding!!.root
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.Window
|
import android.view.Window
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.widget.FrameLayout
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.shuwei.dish.match.R
|
import com.shuwei.dish.match.R
|
||||||
import com.shuwei.dish.match.adapter.SeasoningSearchAdapter
|
import com.shuwei.dish.match.adapter.SeasoningSearchAdapter
|
||||||
import com.shuwei.dish.match.base.BaseActivity
|
import com.shuwei.dish.match.base.BaseActivity
|
||||||
import com.shuwei.dish.match.databinding.DialogSeasoningSelectBinding
|
import com.shuwei.dish.match.databinding.DialogSeasoningSelectBinding
|
||||||
|
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
|
||||||
import com.shuwei.dish.match.entity.GoodsItem
|
import com.shuwei.dish.match.entity.GoodsItem
|
||||||
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.net.UiState
|
||||||
@@ -48,10 +51,11 @@ class SeasoningSelectDialog(
|
|||||||
private val binding = DialogSeasoningSelectBinding.inflate(LayoutInflater.from(activity))
|
private val binding = DialogSeasoningSelectBinding.inflate(LayoutInflater.from(activity))
|
||||||
private val list = mutableListOf<SeasoningEntity>()
|
private val list = mutableListOf<SeasoningEntity>()
|
||||||
private val adapter = SeasoningSearchAdapter(list).apply {
|
private val adapter = SeasoningSearchAdapter(list).apply {
|
||||||
setOnItemClickListener { _, _, position ->
|
isStateViewEnable = true
|
||||||
|
setOnItemClickListener { _, v, position ->
|
||||||
list[position].isClicked = true
|
list[position].isClicked = true
|
||||||
notifyItemChanged(position)
|
notifyItemChanged(position)
|
||||||
Handler(Looper.getMainLooper()).postDelayed({
|
v.postDelayed({
|
||||||
onItemSelected(list[position])
|
onItemSelected(list[position])
|
||||||
dismiss()
|
dismiss()
|
||||||
}, 300)
|
}, 300)
|
||||||
@@ -121,6 +125,9 @@ class SeasoningSelectDialog(
|
|||||||
is UiState.Error -> {
|
is UiState.Error -> {
|
||||||
activity.toast(state.msg)
|
activity.toast(state.msg)
|
||||||
finishRefresh()
|
finishRefresh()
|
||||||
|
if (pageNo == 1) {
|
||||||
|
loadEmptyView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is UiState.Idle -> {}
|
is UiState.Idle -> {}
|
||||||
}
|
}
|
||||||
@@ -195,9 +202,13 @@ class SeasoningSelectDialog(
|
|||||||
private fun loadGoodsList(records: MutableList<GoodsItem>?) {
|
private fun loadGoodsList(records: MutableList<GoodsItem>?) {
|
||||||
finishRefresh()
|
finishRefresh()
|
||||||
if (records.isNullOrEmpty()) {
|
if (records.isNullOrEmpty()) {
|
||||||
activity.toast("暂未搜索到调料信息")
|
//activity.toast("暂未搜索到调料信息")
|
||||||
|
if (pageNo == 1) {
|
||||||
|
loadEmptyView()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
binding.recyclerView.layoutManager = GridLayoutManager(activity, 3, GridLayoutManager.VERTICAL, false)
|
||||||
if (pageNo == 1) list.clear()
|
if (pageNo == 1) list.clear()
|
||||||
list.addAll(records.map { it.toSeasoningEntity() })
|
list.addAll(records.map { it.toSeasoningEntity() })
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
@@ -210,4 +221,37 @@ class SeasoningSelectDialog(
|
|||||||
if (pageNo == 1) binding.refreshLayout.finishRefresh()
|
if (pageNo == 1) binding.refreshLayout.finishRefresh()
|
||||||
else binding.refreshLayout.finishLoadMore()
|
else binding.refreshLayout.finishLoadMore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private var emptyViewBinding: LayoutEmptyViewBinding? = null
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
|
private fun loadEmptyView() {
|
||||||
|
try {
|
||||||
|
binding.refreshLayout.setEnableRefresh(true)
|
||||||
|
binding.refreshLayout.setEnableLoadMore(false)
|
||||||
|
list.clear()
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
if (emptyViewBinding == null) {
|
||||||
|
emptyViewBinding = LayoutEmptyViewBinding.inflate(
|
||||||
|
LayoutInflater.from(activity),
|
||||||
|
binding.recyclerView,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
emptyViewBinding!!.tvContent.text = "暂无数据"
|
||||||
|
emptyViewBinding!!.tvSubContent.text = "未查询到调料信息,请联系管理员添加"
|
||||||
|
emptyViewBinding!!.root.layoutParams = FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
binding.recyclerView.layoutManager = LinearLayoutManager(activity)
|
||||||
|
binding.recyclerView.post {
|
||||||
|
emptyViewBinding!!.root.minimumHeight = binding.recyclerView.height
|
||||||
|
adapter.stateView = emptyViewBinding!!.root
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,6 +352,10 @@ class FoodRecognizeActivity : BaseActivity() {
|
|||||||
selectedPosition = 0
|
selectedPosition = 0
|
||||||
}
|
}
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
|
if (list.isNotEmpty()) {
|
||||||
|
binding.rvRecognizeList.visible()
|
||||||
|
binding.tvNoData.gone()
|
||||||
|
}
|
||||||
updateRvParentLayoutHeight()
|
updateRvParentLayoutHeight()
|
||||||
binding.tvSelectedFood.text = item.goodsName ?: "-"
|
binding.tvSelectedFood.text = item.goodsName ?: "-"
|
||||||
updateListVisibility()
|
updateListVisibility()
|
||||||
|
|||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
# scale 包架构分析
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
scale 包负责多设备秤数据的采集、发现、传输与聚合,采用 **mDNS + UDP 双路冗余发现 + WebSocket 长连接**的架构。
|
||||||
|
|
||||||
|
根据设备角色(MASTER/SLAVE)运行不同的服务组合:
|
||||||
|
|
||||||
|
| 类 | 子设备 | 主设备 |
|
||||||
|
|---|:---:|:---:|
|
||||||
|
| MdnsRegisterManager | ✅ | ✅ |
|
||||||
|
| ScaleWebSocketServer | ✅ | ✅ |
|
||||||
|
| UdpBroadcastSender | ✅ | ❌ |
|
||||||
|
| MdnsDiscoveryManager | ❌ | ✅ |
|
||||||
|
| UdpBroadcastReceiver | ❌ | ✅ |
|
||||||
|
| ScaleWebSocketClient | ❌ | ✅ |
|
||||||
|
| ScaleDataAggregator | ❌ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 整体数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
子设备 主设备
|
||||||
|
│ │
|
||||||
|
├─ UdpBroadcastSender ──UDP广播(8766)──▶ UdpBroadcastReceiver ─┐
|
||||||
|
├─ MdnsRegisterManager ──mDNS注册──▶ MdnsDiscoveryManager ─┤
|
||||||
|
│ │ │
|
||||||
|
│ onDeviceFound │
|
||||||
|
│ │ │
|
||||||
|
├─ ScaleWebSocketServer ◀──WS连接(8765)── ScaleWebSocketClient ◀┘
|
||||||
|
│ │ 秤数据推送 │
|
||||||
|
│ └──────────────────────────────▶ ScaleDataAggregator
|
||||||
|
│ │
|
||||||
|
│ StateFlow → UI
|
||||||
|
│
|
||||||
|
│ ◀── ScaleCommand(清零指令)─────────────────┤
|
||||||
|
│ ◀── ScaleEvent(配置同步)──────────────────┤
|
||||||
|
│ ──▶ ScaleEvent(调料添加通知)──────────────▶│
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据模型(3个)
|
||||||
|
|
||||||
|
### ScaleData
|
||||||
|
单个秤的数据快照,是整个包内流转的核心数据结构。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| deviceId | String | 所属设备 ID |
|
||||||
|
| address | Int | 秤硬件地址编号 |
|
||||||
|
| weight | Double | 重量(克) |
|
||||||
|
| state | Int | 1=稳定,0=不稳定,2=量程溢出 |
|
||||||
|
| ts | Long | 数据时间戳(毫秒) |
|
||||||
|
| ip | String | 所属设备 IP,用于 UI 展示;网络传输数据中可能为空 |
|
||||||
|
| name | String? | 秤槽位名称(可选),默认为 null |
|
||||||
|
|
||||||
|
### ScaleEvent
|
||||||
|
主子设备之间的**非重量类**通知,通过 WebSocket 传输。
|
||||||
|
|
||||||
|
| 事件类型 | 方向 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `seasoning_added` | 子设备 → 主设备 | 某秤检测到调料添加,携带 `delta`(重量变化量) |
|
||||||
|
| `seasoning_config` | 主设备 → 子设备 | 调料槽位配置同步,携带 `List<SlotConfig>` |
|
||||||
|
|
||||||
|
### ScaleCommand
|
||||||
|
主设备向子设备发送的**控制指令**,子设备收到后校验 `deviceId` 是否匹配自身再执行。
|
||||||
|
|
||||||
|
| 指令 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `tare` | 清零指定地址的秤 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设备发现(4个)
|
||||||
|
|
||||||
|
采用 mDNS + UDP 双路冗余,任意一路发现子设备均可触发连接。
|
||||||
|
|
||||||
|
### MdnsRegisterManager(主设备和子设备均运行)
|
||||||
|
将本机 WebSocket 服务以 mDNS 形式注册到局域网。
|
||||||
|
|
||||||
|
- 服务名格式:`DishMatch-{deviceId}`
|
||||||
|
- 服务类型:`_dishmatch._tcp.`
|
||||||
|
- 端口:`8765`
|
||||||
|
|
||||||
|
### MdnsDiscoveryManager(仅主设备运行)
|
||||||
|
持续扫描局域网中所有 `DishMatch-*` 的 mDNS 服务,解析出 IP:PORT 后触发 `onDeviceFound`。
|
||||||
|
|
||||||
|
> **关键细节**:Android `NsdManager.resolveService` 不支持并发调用,多台子设备同时被发现时会报 `FAILURE_ALREADY_ACTIVE(3)`。内部使用串行队列(`resolveQueue`)逐一解析,避免解析失败。
|
||||||
|
|
||||||
|
### UdpBroadcastSender(仅子设备运行)
|
||||||
|
每 5 秒向 `255.255.255.255:8766` 广播一个 JSON 包,作为 mDNS 的兜底发现机制。
|
||||||
|
|
||||||
|
广播包结构:
|
||||||
|
```json
|
||||||
|
{ "deviceId": "xxx", "ip": "192.168.1.x", "port": 8765 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### UdpBroadcastReceiver(仅主设备运行)
|
||||||
|
监听 `8766` 端口,接收子设备的 UDP 广播包,解析后触发 `onDeviceFound`。
|
||||||
|
|
||||||
|
- 内部用 `knownDevices`(`ConcurrentHashMap`)缓存已发现的设备,避免每 5 秒重复触发连接
|
||||||
|
- 设备断线时需调用 `removeDevice()` 清除缓存,才能在重连时重新触发 `onDeviceFound`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据传输(2个)
|
||||||
|
|
||||||
|
### ScaleWebSocketServer(主设备和子设备均运行)
|
||||||
|
基于 `java-websocket` 的服务端,监听 `8765` 端口。
|
||||||
|
|
||||||
|
**职责:**
|
||||||
|
- 监听本机 `WeightUtil` 回调,将秤数据实时推送给所有已连接客户端(节流 100ms)
|
||||||
|
- 新客户端连接时,立即推送所有秤的最新快照(`latestData` 缓存)
|
||||||
|
- 接收主设备下发的 `ScaleCommand`(清零)和 `ScaleEvent`(配置同步)
|
||||||
|
- 通过 `connectionCount` 计数器跟踪连接数,避免 `connections` 集合竞态问题
|
||||||
|
|
||||||
|
### ScaleWebSocketClient(仅主设备运行)
|
||||||
|
管理主设备与多台子设备的 WebSocket 长连接。
|
||||||
|
|
||||||
|
**职责:**
|
||||||
|
- 多设备并发连接(`ConcurrentHashMap` 管理)
|
||||||
|
- 断线自动重连(指数退避:2s → 4s → 8s → ... → 30s)
|
||||||
|
- 向指定设备或全部设备发送指令/事件
|
||||||
|
- 子设备首次连接成功时触发 `onDeviceConnected`,供主设备推送全量配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据聚合(1个)
|
||||||
|
|
||||||
|
### ScaleDataAggregator(仅主设备运行)
|
||||||
|
将本机秤和所有子设备秤的数据统一汇总,以 `StateFlow` 暴露给 UI 层。
|
||||||
|
|
||||||
|
- **本机秤**:直接监听 `WeightUtil` 回调,无需经过网络
|
||||||
|
- **子设备秤**:由 `ScaleWebSocketClient.onScaleData` 回调写入
|
||||||
|
- **Map key 格式**:`{deviceId}#{address}`,便于 UI 按设备分组展示
|
||||||
|
- **IP 回填**:若数据包先于 IP 信息到达,`setDeviceIp()` 会回填已缓存数据中的空 IP 字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置(1个)
|
||||||
|
|
||||||
|
### ScaleDeviceConfig
|
||||||
|
硬编码各子设备的固定 UUID 和秤地址显示顺序。
|
||||||
|
|
||||||
|
| 常量 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `DEVICE_ID_2/22/18/1` | 各子设备固定 UUID |
|
||||||
|
| `SCALE_ORDER_22` | 22个秤的物理位置排列顺序 |
|
||||||
|
| `SCALE_ORDER_18` | 18个秤的物理位置排列顺序 |
|
||||||
|
| `DEVICE_ORDER` | 设备在列表中的显示顺序 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 门面(1个)
|
||||||
|
|
||||||
|
### ScaleServiceManager
|
||||||
|
整个包的统一入口(单例),根据设备角色决定启动哪些服务,并将各组件串联起来。
|
||||||
|
|
||||||
|
**外部使用方式:**
|
||||||
|
```kotlin
|
||||||
|
// Application.onCreate()
|
||||||
|
ScaleServiceManager.start(context)
|
||||||
|
|
||||||
|
// 主设备 UI 订阅全量秤数据
|
||||||
|
ScaleServiceManager.allScales?.collect { scales -> ... }
|
||||||
|
|
||||||
|
// 主设备发送清零指令
|
||||||
|
ScaleServiceManager.sendTare(deviceId, address)
|
||||||
|
|
||||||
|
// 主设备广播调料配置
|
||||||
|
ScaleServiceManager.sendSeasoningConfig(slots)
|
||||||
|
|
||||||
|
// 主设备监听子设备秤事件(如调料添加)
|
||||||
|
ScaleServiceManager.onScaleEvent = { event -> ... }
|
||||||
|
|
||||||
|
// 子设备监听主设备下发的调料配置同步
|
||||||
|
ScaleServiceManager.onSeasoningConfig = { event -> ... }
|
||||||
|
|
||||||
|
// 子设备向主设备广播秤事件
|
||||||
|
ScaleServiceManager.broadcastEvent(event)
|
||||||
|
|
||||||
|
// 子设备监听主设备连接状态变化
|
||||||
|
ScaleServiceManager.onMasterConnectionChanged = { connected -> ... }
|
||||||
|
|
||||||
|
// 子设备查询当前是否有主设备连接
|
||||||
|
ScaleServiceManager.isMasterConnected
|
||||||
|
|
||||||
|
// Application.onTerminate()
|
||||||
|
ScaleServiceManager.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
外部代码只需与 `ScaleServiceManager` 交互,无需感知内部任何组件。
|
||||||
Reference in New Issue
Block a user