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:
2026-05-06 17:35:29 +08:00
parent 2de5ec39b5
commit 6595c4bde0
4 changed files with 365 additions and 33 deletions
@@ -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
View File
@@ -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` 交互,无需感知内部任何组件。