fix: MQTT连接时序竞争导致扫码绑定失败

- MqttManager: 防止重复创建client实例,加connecting标记和client存在检查
- SplashFragment: MQTT连接和API检查并行,等MQTT就绪后再导航
- BindFragment: 等待MqttConnected事件再显示状态,不立即检查

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-05-11 17:05:50 +09:30
parent c84af9dfb4
commit 9e3616d1a0
3 changed files with 123 additions and 56 deletions

View File

@@ -36,12 +36,18 @@ class MqttManager @Inject constructor(
/** MQTT 异步客户端 */
private var client: MqttAsyncClient? = null
/** 是否正在连接中(防止重复创建 client */
private var connecting = false
/** 是否已连接 */
val isConnected: Boolean get() = client?.isConnected == true
/**
* 连接 MQTT Broker
* 使用 DevicePrefs.imei 作为 clientIdTCP 协议连接
*
* 关键:不重复创建 client。如果 client 已存在(连接中/已连接/自动重连中),
* 直接复用,避免覆盖正在连接的实例导致连接丢失。
*/
fun connect() {
val imei = devicePrefs.imei
@@ -56,7 +62,21 @@ class MqttManager @Inject constructor(
return
}
// 正在连接中,不重复发起
if (connecting) {
Timber.d("MQTT: 正在连接中,跳过")
return
}
// client 已存在(可能在自动重连中),不重新创建
if (client != null) {
Timber.d("MQTT: client 已存在(可能在自动重连中),跳过")
return
}
try {
connecting = true
// 构建 TCP 连接地址
val serverUri = "tcp://${EnvConfig.mqttHost}:${MqttConfig.PORT}"
Timber.d("MQTT: 连接 $serverUri, clientId=$imei")
@@ -82,6 +102,7 @@ class MqttManager @Inject constructor(
} catch (e: Exception) {
Timber.e(e, "MQTT: 连接异常")
connecting = false
}
}
@@ -89,6 +110,8 @@ class MqttManager @Inject constructor(
fun disconnect() {
try {
client?.disconnect()
client = null
connecting = false
Timber.d("MQTT: 已断开")
} catch (e: Exception) {
Timber.w(e, "MQTT: 断开异常")
@@ -98,12 +121,16 @@ class MqttManager @Inject constructor(
/** 连接回调:成功后订阅 3 个 Topic */
private val connectCallback = object : IMqttActionListener {
override fun onSuccess(asyncActionToken: IMqttToken?) {
connecting = false
Timber.d("MQTT: 连接成功")
emitEvent(AppEvent.MqttConnected)
subscribeTopics()
}
override fun onFailure(asyncActionToken: IMqttToken?, exception: Throwable?) {
connecting = false
// 连接失败,清除 client 以允许下次重新创建
client = null
Timber.e(exception, "MQTT: 连接失败")
}
}

View File

@@ -16,6 +16,7 @@ import com.xiaoqu.watch.event.AppEvent
import com.xiaoqu.watch.event.EventBus
import com.xiaoqu.watch.ui.common.BaseFragment
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -163,17 +164,39 @@ class BindFragment : BaseFragment<FragmentBindBinding>() {
binding.loadingWrap.visibility = View.VISIBLE
}
/** 显示 MQTT 连接状态(调试用,确认扫码时连接是否正常) */
/**
* 显示 MQTT 连接状态(调试用)
* 如果已连接直接显示;否则等待 MqttConnected 事件(最多 8 秒超时)
*/
private fun showMqttStatus() {
val connected = mqttManager.isConnected
val imei = devicePrefs.imei
val msg = if (connected) "MQTT已连接 (IMEI=$imei)" else "MQTT未连接!"
android.widget.Toast.makeText(requireContext(), msg, android.widget.Toast.LENGTH_LONG).show()
Timber.d("绑定页: $msg")
if (mqttManager.isConnected) {
// 已连接,直接显示
showMqttToast(true)
return
}
// 未连接则尝试重连
if (!connected) {
// MQTT 还在连接中Splash 刚发起异步连接),等待连接成功事件
viewLifecycleOwner.lifecycleScope.launch {
// 先尝试重连(防止 Splash 连接失败的情况)
mqttManager.connect()
// 等待 MqttConnected 事件,最多 8 秒
val connected = kotlinx.coroutines.withTimeoutOrNull(8000L) {
eventBus.events.first { it is AppEvent.MqttConnected }
true
} ?: false
if (isAdded) {
showMqttToast(connected)
}
}
}
/** 显示 MQTT 连接状态 Toast */
private fun showMqttToast(connected: Boolean) {
val imei = devicePrefs.imei
val msg = if (connected) "MQTT已连接 (IMEI=$imei)" else "MQTT未连接! 请检查网络"
android.widget.Toast.makeText(requireContext(), msg, android.widget.Toast.LENGTH_LONG).show()
Timber.d("绑定页: $msg")
}
}

View File

@@ -10,13 +10,18 @@ import androidx.navigation.fragment.findNavController
import com.xiaoqu.watch.R
import com.xiaoqu.watch.data.prefs.DevicePrefs
import com.xiaoqu.watch.data.prefs.UserPrefs
import com.xiaoqu.watch.event.AppEvent
import com.xiaoqu.watch.event.EventBus
import com.xiaoqu.watch.network.ApiResult
import com.xiaoqu.watch.network.api.CommonApi
import com.xiaoqu.watch.network.safeApiCall
import com.xiaoqu.watch.service.manager.MqttManager
import com.xiaoqu.watch.util.DeviceUtil
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import javax.inject.Inject
@@ -32,6 +37,7 @@ class SplashFragment : Fragment() {
@Inject lateinit var userPrefs: UserPrefs
@Inject lateinit var mqttManager: MqttManager
@Inject lateinit var commonApi: CommonApi
@Inject lateinit var eventBus: EventBus
override fun onCreateView(
inflater: LayoutInflater,
@@ -47,11 +53,29 @@ class SplashFragment : Fragment() {
// 1. 初始化设备信息(首次启动时写入 SP
initDevicePrefs()
// 2. 连接 MQTT(定制 ROM + Launcher 应用,系统不会杀进程)
mqttManager.connect()
// 2. 连接 MQTT + 检查绑定状态 并行执行,等 MQTT 就绪后再导航
viewLifecycleOwner.lifecycleScope.launch {
// 并行MQTT 连接 + API 绑定检查
mqttManager.connect()
// 3. 检查绑定状态并导航
checkBindStatus()
// 等待 MQTT 连接成功(最多 10 秒),同时检查绑定状态
val mqttReady = async {
if (mqttManager.isConnected) return@async true
withTimeoutOrNull(10_000L) {
eventBus.events.first { it is AppEvent.MqttConnected }
true
} ?: false
}
val bindResult = async { checkBindStatusAsync() }
// 等待两者都完成
val mqttOk = mqttReady.await()
Timber.d("Splash: MQTT 就绪=$mqttOk")
// 根据绑定结果导航
navigateByResult(bindResult.await())
}
}
/** 首次启动时初始化设备信息到 device_prefs */
@@ -69,61 +93,54 @@ class SplashFragment : Fragment() {
}
/**
* 检查绑定状态
* 检查绑定状态(挂起函数,返回是否已绑定)
* 1. 调用 API 获取最新绑定状态
* 2. 有数据 → 已绑定 → 更新 UserPrefs → 导航到首页
* 3. 无数据 → 未绑定 → 导航到绑定页
* 2. 有数据 → 已绑定 → 更新 UserPrefs → 返回 true
* 3. 无数据 → 未绑定 → 返回 false
* 4. 网络异常 → 用本地 isBound 兜底
*/
private fun checkBindStatus() {
viewLifecycleOwner.lifecycleScope.launch {
val imei = devicePrefs.imei
private suspend fun checkBindStatusAsync(): Boolean {
val imei = devicePrefs.imei
// 调用 API 检查绑定状态
val result = safeApiCall { commonApi.getWatchByImei(imei) }
// 调用 API 检查绑定状态
val result = safeApiCall { commonApi.getWatchByImei(imei) }
when (result) {
is ApiResult.Success -> {
// API 返回数据 → 已绑定,存入 UserPrefs
val data = result.data
if (data != null && data.userId > 0) {
Timber.d("Splash: API 返回已绑定 userId=${data.userId}")
userPrefs.saveUser(
userId = data.userId,
mobile = data.mobile,
userName = data.userName,
headUrl = data.headUrl
)
navigateToHome()
} else {
// 返回数据无效,视为未绑定
Timber.d("Splash: API 返回数据无效,视为未绑定")
navigateToBind()
}
return when (result) {
is ApiResult.Success -> {
val data = result.data
if (data != null && data.userId > 0) {
Timber.d("Splash: API 返回已绑定 userId=${data.userId}")
userPrefs.saveUser(
userId = data.userId,
mobile = data.mobile,
userName = data.userName,
headUrl = data.headUrl
)
true
} else {
Timber.d("Splash: API 返回数据无效,视为未绑定")
false
}
is ApiResult.Error -> {
if (result.code == 1) {
// code=1 未绑定
Timber.d("Splash: API 返回未绑定")
navigateToBind()
} else {
// 其他错误,用本地状态兜底
Timber.w("Splash: API 错误 code=${result.code}, 用本地状态兜底")
navigateByLocalState()
}
}
is ApiResult.NetworkError -> {
// 网络异常,用本地状态兜底
Timber.w("Splash: 网络异常, 用本地状态兜底")
navigateByLocalState()
}
is ApiResult.Error -> {
if (result.code == 1) {
Timber.d("Splash: API 返回未绑定")
false
} else {
Timber.w("Splash: API 错误 code=${result.code}, 用本地状态兜底")
userPrefs.isBound
}
}
is ApiResult.NetworkError -> {
Timber.w("Splash: 网络异常, 用本地状态兜底")
userPrefs.isBound
}
}
}
/** 根据本地 UserPrefs 绑定状态导航(离线兜底) */
private fun navigateByLocalState() {
if (userPrefs.isBound) {
/** 根据绑定结果导航 */
private fun navigateByResult(isBound: Boolean) {
if (isBound) {
navigateToHome()
} else {
navigateToBind()