feat(face): 优化人脸库更新同步及识别引擎刷新机制
- 引入进程级互斥锁 FaceSyncLock,串行化 MQTT 实时更新与 HTTP 增量轮询人脸库写操作,避免重复入库 - 优化 MQTT 实时消息处理逻辑,批内去重并加入乱序守卫,确保数据正确性与一致性 - 将识别引擎内存刷新调度防抖至 scheduleFaceRefresh,合并短时间内多次变更减少资源消耗 - 在 BaseActivity 中实现防抖刷新机制,统一 MQTT、HTTP增量和定时轮询三路触发入口 - 移除应用启动时人脸 MQTT 订阅初始化,改为首次全量同步完成后启动,防止并发竞态 - LoginByFaceActivity 和 DeviceInitActivity 适配新增机制,优化人脸数据同步及识别引擎刷新流程
This commit is contained in:
@@ -3,7 +3,6 @@ package com.sw.platecabinet
|
||||
import android.util.Log
|
||||
import com.sw.plate.App
|
||||
import com.sw.plate.utils.AppUtil
|
||||
import com.sw.platecabinet.mqtt.FaceMqttSubscriber
|
||||
import com.sw.platecabinet.utils.CrashHandler
|
||||
import com.sw.platecabinet.utils.SpTool
|
||||
import timber.log.Timber
|
||||
@@ -21,9 +20,6 @@ class MyApp : App() {
|
||||
|
||||
// 初始化崩溃处理器
|
||||
CrashHandler.init(this)
|
||||
|
||||
// 启动人脸 MQTT 实时订阅(进程级单例,应用生命周期内保持连接)
|
||||
FaceMqttSubscriber.start()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.TextUtils
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
@@ -399,6 +401,19 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
|
||||
|
||||
val recognizeViewModel by viewModels<RecognizeViewModel>()
|
||||
|
||||
/**
|
||||
* 引擎内存刷新防抖:合并短时间内连续的人脸变更(MQTT 实时 + HTTP 增量补拉 + 定时轮询),
|
||||
* 避免多次全量重载 ArcSoft 引擎内存(removeFaceFeature(-1) + registerFaceFeature 非原子)。
|
||||
*/
|
||||
private val faceRefreshHandler = Handler(Looper.getMainLooper())
|
||||
private val faceRefreshRunnable = Runnable { recognizeViewModel.refreshFaceList() }
|
||||
|
||||
/** 防抖调度引擎内存刷新(2s 内连续变更合并为一次) */
|
||||
protected fun scheduleFaceRefresh() {
|
||||
faceRefreshHandler.removeCallbacks(faceRefreshRunnable)
|
||||
faceRefreshHandler.postDelayed(faceRefreshRunnable, 2000)
|
||||
}
|
||||
|
||||
fun startFaceTask() {
|
||||
faceTaskJob =
|
||||
intervalExecutor.startIntervalTaskWithInitialDelay(initialDelay, dealyMillis) {
|
||||
@@ -418,7 +433,7 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
|
||||
pageNo = pageNo,
|
||||
timestamp = timestamp
|
||||
) {
|
||||
recognizeViewModel.refreshFaceList()
|
||||
scheduleFaceRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.sw.plate.utils.ToastUtils
|
||||
import com.sw.platecabinet.GlobalData
|
||||
import com.sw.platecabinet.member.databinding.ActivityDeviceInitBinding
|
||||
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
|
||||
import com.sw.platecabinet.mqtt.FaceMqttSubscriber
|
||||
import com.sw.platecabinet.utils.SpTool
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -76,6 +77,9 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
|
||||
}
|
||||
|
||||
private fun goLoginActivity() {
|
||||
// 首次全量同步(如需)已完成,此时启动人脸 MQTT 实时订阅,
|
||||
// 避免与首次全量同步的 clearFaceData 产生并发写竞态
|
||||
FaceMqttSubscriber.start()
|
||||
val intent = Intent(this, LoginByFaceActivity::class.java)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
|
||||
@@ -146,16 +146,13 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
|
||||
}
|
||||
|
||||
/**
|
||||
* MQTT 实时同步后的人脸库已落库,防抖重载识别引擎内存(合并短时间内连续变更)
|
||||
* MQTT 实时同步后的人脸库已落库,防抖重载识别引擎内存(合并短时间内连续变更)。
|
||||
* scheduleFaceRefresh 定义于 BaseActivity,统一 MQTT/HTTP 增量/定时轮询三路刷新入口。
|
||||
*/
|
||||
private val faceRefreshHandler = Handler(Looper.getMainLooper())
|
||||
private val faceRefreshRunnable = Runnable { recognizeViewModel.refreshFaceList() }
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onFaceChanged(event: FaceChangedEvent) {
|
||||
Timber.d("onFaceChanged 收到人脸实时变更,2s 后刷新引擎内存")
|
||||
faceRefreshHandler.removeCallbacks(faceRefreshRunnable)
|
||||
faceRefreshHandler.postDelayed(faceRefreshRunnable, 2000)
|
||||
scheduleFaceRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,7 +167,7 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
|
||||
}
|
||||
Timber.d("onFaceSyncTrigger MQTT 已连接,执行 HTTP 增量补拉 timestamp=$timestamp")
|
||||
netViewModelV2.getFaceIncrementList(pageNo = 1, timestamp = timestamp) {
|
||||
recognizeViewModel.refreshFaceList()
|
||||
scheduleFaceRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sw.platecabinet.mqtt
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import com.sw.plate.utils.Base64
|
||||
import com.sw.plate.utils.arcface.FaceApi
|
||||
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
|
||||
import com.sw.platecabinet.GlobalData
|
||||
@@ -10,6 +11,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -150,7 +152,7 @@ object FaceMqttSubscriber {
|
||||
}
|
||||
|
||||
/** 解析广播 JSON 数组并落库(faceUpdateTimestamp 兼容 long/String 两种格式) */
|
||||
private fun handleFaceUpdate(payload: ByteArray) {
|
||||
private suspend fun handleFaceUpdate(payload: ByteArray) {
|
||||
// 联调排查:记录原始报文预览(特征码很长,只打前 120 字符)
|
||||
val raw = String(payload, Charsets.UTF_8)
|
||||
Timber.i("$TAG 收到人脸广播: size=${payload.size} 预览=${raw.take(120)}")
|
||||
@@ -206,55 +208,67 @@ object FaceMqttSubscriber {
|
||||
* 按 userFaceId 查本地记录,消息时间戳不大于本地时间戳的条目直接丢弃,
|
||||
* 防止 QoS1 重复投递或乱序到达时旧数据覆盖新数据。
|
||||
*
|
||||
* 与 HTTP 增量轮询通过 [FaceSyncLock] 串行化写库,避免同一 userFaceId 重复入库。
|
||||
*
|
||||
* 注意:本方法不推进增量水位。水位只由 HTTP 增量接口的响应推进,
|
||||
* 后台推送失败只记日志不重发,若 MQ 消息把水位推到漏发变更之后,
|
||||
* 轮询/补拉将永远拉不到那条变更。
|
||||
*/
|
||||
private fun applyRealtimeUpdates(items: List<UserFaceModelV2>) {
|
||||
private suspend fun applyRealtimeUpdates(items: List<UserFaceModelV2>) {
|
||||
if (items.isEmpty()) return
|
||||
|
||||
// 批内去重:同一 userFaceId 仅保留时间戳最大的一条(含其删除标志)
|
||||
val batch = items.groupBy { it.userFaceId }
|
||||
.flatMap { (key, list) ->
|
||||
if (key.isNullOrEmpty()) list
|
||||
else listOfNotNull(list.maxByOrNull { it.faceUpdateTimestamp ?: 0L })
|
||||
}
|
||||
var changed = false
|
||||
FaceSyncLock.mutex.withLock {
|
||||
// 批内去重:同一 userFaceId 仅保留时间戳最大的一条(含其删除标志)
|
||||
val batch = items.groupBy { it.userFaceId }
|
||||
.flatMap { (key, list) ->
|
||||
if (key.isNullOrEmpty()) list
|
||||
else listOfNotNull(list.maxByOrNull { it.faceUpdateTimestamp ?: 0L })
|
||||
}
|
||||
|
||||
// 乱序守卫:本地已有同 userFaceId 且时间戳不旧的记录则跳过该条
|
||||
val fresh = batch.filter { msg ->
|
||||
val key = msg.userFaceId
|
||||
if (key.isNullOrEmpty()) return@filter true
|
||||
val local = faceApi.queryByUserFaceId(key)
|
||||
val msgTs = msg.faceUpdateTimestamp ?: 0L
|
||||
when {
|
||||
local == null -> true
|
||||
msgTs <= 0L -> true
|
||||
msgTs > local.faceUpdateTimestamp -> true
|
||||
else -> {
|
||||
Timber.d("$TAG 实时消息乱序/重复,丢弃 userFaceId=$key ts=$msgTs")
|
||||
false
|
||||
// 乱序守卫:本地已有同 userFaceId 且时间戳不旧的记录则跳过该条
|
||||
val fresh = batch.filter { msg ->
|
||||
val key = msg.userFaceId
|
||||
if (key.isNullOrEmpty()) return@filter true
|
||||
val local = faceApi.queryByUserFaceId(key)
|
||||
val msgTs = msg.faceUpdateTimestamp ?: 0L
|
||||
when {
|
||||
local == null -> true
|
||||
msgTs <= 0L -> true
|
||||
msgTs > local.faceUpdateTimestamp -> true
|
||||
else -> {
|
||||
Timber.d("$TAG 实时消息乱序/重复,丢弃 userFaceId=$key ts=$msgTs")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fresh.isEmpty()) return
|
||||
if (fresh.isEmpty()) return@withLock
|
||||
|
||||
var changed = false
|
||||
for (msg in fresh) {
|
||||
if (msg.faceDeleted == true) {
|
||||
val userFaceId = msg.userFaceId
|
||||
if (!userFaceId.isNullOrEmpty() && faceApi.queryByUserFaceId(userFaceId) != null) {
|
||||
faceApi.deleteByUserFaceId(userFaceId)
|
||||
for (msg in fresh) {
|
||||
if (msg.faceDeleted == true) {
|
||||
val userFaceId = msg.userFaceId
|
||||
if (!userFaceId.isNullOrEmpty() && faceApi.queryByUserFaceId(userFaceId) != null) {
|
||||
faceApi.deleteByUserFaceId(userFaceId)
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
val entity = buildEntity(msg) ?: continue
|
||||
val userFaceId = msg.userFaceId
|
||||
if (!userFaceId.isNullOrEmpty()) {
|
||||
// 优先按 userFaceId 精确判重,避免特征字段不一致导致重复入库
|
||||
if (faceApi.queryByUserFaceId(userFaceId) != null) continue
|
||||
faceApi.insert(entity)
|
||||
} else {
|
||||
// userFaceId 为空时回退到特征判重(与 HTTP 增量逻辑对齐)
|
||||
val featureStr = msg.resolveFeatureStr()
|
||||
val existList = faceApi.queryAllByUserName(msg.userId)
|
||||
val alreadyExists = existList.any { e ->
|
||||
featureStr != null && Base64.encode(e.featureData) == featureStr
|
||||
}
|
||||
if (!alreadyExists) faceApi.insert(entity)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
// 优先按 userFaceId 精确判重,避免特征字段不一致导致重复入库
|
||||
val userFaceId = msg.userFaceId
|
||||
if (!userFaceId.isNullOrEmpty() && faceApi.queryByUserFaceId(userFaceId) != null) {
|
||||
continue
|
||||
}
|
||||
val entity = buildEntity(msg) ?: continue
|
||||
faceApi.insert(entity)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.sw.platecabinet.mqtt
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
||||
/**
|
||||
* 本地人脸库写操作的进程级互斥锁。
|
||||
*
|
||||
* MQTT 实时更新([FaceMqttSubscriber])与 HTTP 增量轮询(NetViewModelV2.getFaceIncrementList)
|
||||
* 会并发写本地人脸库。二者对同一 userFaceId 的「判重 → 插入」不是原子操作,
|
||||
* 并发时会产生重复记录;共享此锁将两路写入串行化。
|
||||
*/
|
||||
object FaceSyncLock {
|
||||
val mutex = Mutex()
|
||||
}
|
||||
@@ -15,11 +15,13 @@ import com.sw.platecabinet.GlobalKey
|
||||
import com.sw.platecabinet.model.DeviceConfigV2
|
||||
import com.sw.platecabinet.model.response.UserFaceModel
|
||||
import com.sw.platecabinet.model.response.UserFaceModelV2
|
||||
import com.sw.platecabinet.mqtt.FaceSyncLock
|
||||
import com.sw.platecabinet.network.ApiClient
|
||||
import com.sw.platecabinet.repository.RemoteRepositoryV2
|
||||
import com.sw.platecabinet.utils.SpTool
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
@@ -190,7 +192,8 @@ class NetViewModelV2 : ViewModel() {
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
val list = response.data ?: emptyList()
|
||||
updateFaceData(list)
|
||||
// 与 MQTT 实时更新串行化写库,避免同一 userFaceId 重复入库
|
||||
FaceSyncLock.mutex.withLock { updateFaceData(list) }
|
||||
// 取当前页最大时间戳,与已累积的比较取最大值
|
||||
val pageMaxTimestamp = maxFaceTimestamp(list)
|
||||
if (pageMaxTimestamp > faceTimestamp) {
|
||||
|
||||
Reference in New Issue
Block a user