Pārlūkot izejas kodu

修复考试一体机,上线离线问题

donggaosheng 1 stundu atpakaļ
vecāks
revīzija
151c930aa6

+ 34 - 3
HttpCoreLibrary/src/main/java/com/rc/httpcore/HttpClient.kt

@@ -43,16 +43,47 @@ object HttpClient {
         }
     }
 
+    /**
+     * 全局复用同一个 OkHttpClient。
+     *
+     * 之前每次 API 调用都会新建一个 OkHttpClient,带着一套新的线程池和连接池,
+     * 连接完全无法复用;心跳 60 秒一次的情况下会造成线程/连接持续堆积。
+     */
+    private val okHttpClient: OkHttpClient by lazy { buildHttpClient() }
+
+    /**
+     * 缓存已创建的 Api 实例。
+     *
+     * Retrofit.create() 需要反射解析接口上每个方法的注解(ApiService 有几十个方法),
+     * 单次开销不小,而且这里是同步调用 —— 发生在主线程上会卡 UI。
+     * 之前每次 API 调用都会重新 create 一遍(ExamRetrofit 是每次 new 的),
+     * 心跳 60 秒一次就会周期性地在主线程上反射一次,低端机上足以触发 ANR。
+     *
+     * 这里按 baseUrl 缓存:baseUrl 不变就复用,改了服务器地址(baseUrl 变化)则自动重建。
+     */
+    private var cachedBaseUrl: String? = null
+
+    @Volatile
+    private var cachedApi: Any? = null
+
+    @Suppress("UNCHECKED_CAST")
     fun <T> createRetrofitApi(
         apiClass: Class<T>,
         baseUrl: String = HttpConfig.API_BASE_URL,
     ): T {
-        return buildRetrofit(baseUrl).create(apiClass)
+        val cached = cachedApi
+        if (cached != null && cachedBaseUrl == baseUrl && apiClass.isInstance(cached)) {
+            return cached as T
+        }
+        val api = buildRetrofit(baseUrl, okHttpClient).create(apiClass)
+        cachedApi = api
+        cachedBaseUrl = baseUrl
+        return api
     }
 
     private fun buildRetrofit(
         baseUrl: String = HttpConfig.API_BASE_URL,
-        okHttpClient: OkHttpClient = buildHttpClient()
+        okHttpClient: OkHttpClient = this.okHttpClient
     ): Retrofit {
         return Retrofit.Builder()
             .client(okHttpClient)
@@ -69,7 +100,7 @@ object HttpClient {
 //            LOGGER.log(Level.INFO, message)
 //        })
 //        interceptor.level = HttpLoggingInterceptor.Level.BODY
-        return return OkHttpClient.Builder()
+        return OkHttpClient.Builder()
             .readTimeout(TIMEOUT_DEFAULT, TimeUnit.SECONDS) // 设置读取超时时间
             .connectTimeout(TIMEOUT_DEFAULT, TimeUnit.SECONDS) // 设置请求超时时间
             .writeTimeout(TIMEOUT_DEFAULT, TimeUnit.SECONDS) // 设置写入超时时间

+ 2 - 2
HttpCoreLibrary/src/main/java/com/rc/httpcore/HttpConfig.kt

@@ -8,8 +8,8 @@ class HttpConfig {
         //var API_BASE_URL = "http://pc44sory.xiaomy.net:31738/"
 //        var API_BASE_URL = "http://192.168.251.2/labSystem/"
 //        var API_BASE_URL = "http://192.168.1.8:8080/"
-        var API_BASE_URL = "http://172.16.0.65/api/"
-        //var API_BASE_IMG_URL = "http://172.16.0.65/api/"
+        var API_BASE_URL = "http://192.168.166.11/api/"
+        //var API_BASE_IMG_URL = "http://192.168.166.11/api/"
 
         //var API_BASE_URL = "http://192.168.166.11/api/"
         var API_BASE_IMG_URL = "http://192.168.166.11/api/"

+ 2 - 2
HttpCoreLibrary/src/main/java/com/rc/httpcore/config/ConfigFactory.kt

@@ -17,9 +17,9 @@ class ConfigFactory {
         val buildReleaseConfig = ConfigParam.Builder()
 //            .baseUrl("https://lab.sxitdlc.com/labSystem/")
             .baseUrl("http://192.168.166.11/api/")
-            //.baseUrl("http://172.16.0.65/api/")
+            //.baseUrl("http://192.168.166.11/api/")
 //            .fileBrowserBaseUrl("http://180.76.134.43:31007/")
-            //.fileBrowserBaseUrl("http://172.16.0.65/api/")
+            //.fileBrowserBaseUrl("http://192.168.166.11/api/")
             .fileBrowserBaseUrl("http://192.168.166.11/api/")
             .httpStrategy(HttpConfig.HTTP_STRATEGY_Retrofit)
             .mqttServerUri("tcp://192.168.166.11:1883")

+ 25 - 1
HttpCoreLibrary/src/main/java/com/rc/httpcore/interceptor/TokenHeaderInterceptor.kt

@@ -35,7 +35,10 @@ class TokenHeaderInterceptor : Interceptor {
                 val data = source.buffer.clone().readString(StandardCharsets.UTF_8)
 
                 val commonResponse = Gson().fromJson<CommonResponse>(data, CommonResponse::class.java)
-                if (commonResponse.isTokenExpired()) {
+                // 服务端白名单接口(设备级接口,不需要 token)返回 401 时不能广播 "token_expired",
+                // 否则 App 会被 CLEAR_TASK 踢回启动页,而启动页需要人工点击才能进入首页,
+                // 设备心跳等接口就再也发不出去了。
+                if (commonResponse.isTokenExpired() && !isNoAuthRequired(originalRequest)) {
                     HttpClient.getAppContext()?.apply {
                         val intent = Intent("com.rc.core.token_expired")
                         LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
@@ -48,4 +51,25 @@ class TokenHeaderInterceptor : Interceptor {
 
         return response
     }
+
+    /**
+     * 判断是否为服务端白名单接口(设备级接口,不需要 token)
+     */
+    private fun isNoAuthRequired(request: Request): Boolean {
+        val target = request.toString()
+        return NO_AUTH_REQUIRED_PATHS.any { target.contains(it) }
+    }
+
+    companion object {
+        /**
+         * 服务端白名单:设备级接口,不需要 authorization 头。
+         * 与后端白名单保持一致,避免这些接口异常返回 401 时把 App 踢回启动页。
+         */
+        private val NO_AUTH_REQUIRED_PATHS = listOf(
+            "iot/aio/monitor",  // 设备在线/离线心跳上报
+            "iot/aio/report",   // 设备基础信息上报
+            "laboratory/apkfile", // APK 版本查询
+            "auth/time/current"  // 服务器时间
+        )
+    }
 }

+ 15 - 0
RcCore/src/main/java/com/rc/core/ui/ActivityCollector.kt

@@ -68,4 +68,19 @@ object ActivityCollector {
         mActivities.clear()
     }
 
+    /**
+     * 获取当前栈顶(top)的Activity,没有则返回 null。
+     *
+     * 栈顶 = 最后创建且尚未销毁/正在结束的 Activity。
+     */
+    fun getTopActivity(): Activity? {
+        for (i in mActivities.indices.reversed()) {
+            val activity = mActivities[i]
+            if (!activity.isFinishing && !activity.isDestroyed) {
+                return activity
+            }
+        }
+        return null
+    }
+
 }

+ 82 - 0
RcCore/src/main/java/com/rc/core/util/AnrWatchDog.kt

@@ -0,0 +1,82 @@
+package com.rc.core.util
+
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+
+/**
+ * 主线程 ANR 看门狗
+ *
+ * 系统弹"应用没有响应"时我们拿不到主线程堆栈,只能靠猜。
+ * 这里自己检测:每隔 [timeoutMs] 往主线程 post 一个探针,探针执行时刷新 tick。
+ * 如果超过 timeoutMs 主线程还没执行到探针,说明主线程被卡住了,
+ * 直接把主线程堆栈打到 logcat(TAG = LAB_ANR),方便定位。
+ */
+class AnrWatchDog(private val timeoutMs: Long = DEFAULT_TIMEOUT_MS) {
+
+    companion object {
+        private const val TAG = "LAB_ANR"
+        const val DEFAULT_TIMEOUT_MS = 5000L
+    }
+
+    private val mainHandler = Handler(Looper.getMainLooper())
+
+    @Volatile
+    private var tick = 0L
+
+    @Volatile
+    private var running = false
+
+    /**
+     * 是否已经打印过一次堆栈。
+     * 主线程没恢复之前不重复打印,避免卡顿期间 logcat 刷屏、日志文件暴涨。
+     */
+    @Volatile
+    private var reported = false
+
+    fun start() {
+        if (running) return
+        running = true
+        Thread({
+            while (running) {
+                val expected = tick + 1
+                mainHandler.post {
+                    tick = expected
+                    reported = false
+                }
+                try {
+                    Thread.sleep(timeoutMs)
+                } catch (e: InterruptedException) {
+                    break
+                }
+                if (tick != expected && !reported) {
+                    reported = true
+                    dumpMainThreadStack(expected)
+                }
+            }
+        }, "anr-watchdog").apply {
+            isDaemon = true
+            start()
+        }
+        Log.i(TAG, "ANR 看门狗已启动,超时阈值=${timeoutMs}ms")
+    }
+
+    fun stop() {
+        running = false
+    }
+
+    private fun dumpMainThreadStack(expected: Long) {
+        try {
+            val mainThread = Looper.getMainLooper().thread
+            Log.e(TAG, "==================== 主线程无响应 (超过 ${timeoutMs}ms) ====================")
+            Log.e(TAG, "thread=${mainThread.name} state=${mainThread.state} priority=${mainThread.priority}")
+            Log.e(TAG, "---------- 主线程堆栈 ----------")
+            mainThread.stackTrace.forEach {
+                Log.e(TAG, "\tat $it")
+            }
+            Log.e(TAG, "==================== 主线程无响应 END ====================")
+        } catch (e: Throwable) {
+            Log.e(TAG, "dump main thread stack failed", e)
+        }
+    }
+}

+ 46 - 0
RcCore/src/main/java/com/rc/core/util/ProcessUtils.kt

@@ -0,0 +1,46 @@
+package com.rc.core.util
+
+import android.app.Application
+import android.content.Context
+import android.os.Build
+import java.io.File
+
+/**
+ * 进程工具
+ */
+object ProcessUtils {
+
+    /**
+     * 当前是否为主进程。
+     *
+     * App 可能有多个进程(例如 X5 内核的 :dexopt 进程),
+     * Android 会为每个进程各创建一份 Application 实例并调用 onCreate(),
+     * 所以"只该主进程做的事"必须在这里做判断,
+     * 否则会出现多个进程重复上报心跳、用同一个 MQTT clientId 互相把对方踢下线等问题。
+     *
+     * 取不到进程名时默认按主进程处理,避免误伤导致功能整个不生效。
+     */
+    fun isMainProcess(context: Context): Boolean {
+        return try {
+            val processName = currentProcessName()
+            if (processName.isNullOrEmpty()) true else processName == context.packageName
+        } catch (e: Exception) {
+            true
+        }
+    }
+
+    /**
+     * 取当前进程名。API 28+ 有官方静态方法,低版本读 /proc/self/cmdline。
+     */
+    fun currentProcessName(): String? {
+        return try {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+                Application.getProcessName()
+            } else {
+                File("/proc/self/cmdline").readText().trim { it <= ' ' }
+            }
+        } catch (e: Exception) {
+            null
+        }
+    }
+}

+ 56 - 4
app/src/main/java/com/dlc/exam/ExamApp.kt

@@ -3,10 +3,13 @@ package com.dlc.exam
 import android.app.ActivityManager
 import android.app.Application
 import android.content.BroadcastReceiver
+import android.content.ComponentName
 import android.content.Context
 import android.content.Intent
 import android.content.IntentFilter
+import android.content.ServiceConnection
 import android.os.Handler
+import android.os.IBinder
 import android.os.Looper
 import android.os.Message
 import android.util.Log
@@ -16,12 +19,16 @@ import androidx.multidex.MultiDex
 import com.blankj.utilcode.util.AppUtils
 import com.blankj.utilcode.util.LogUtils
 import com.dlc.exam.common.Constants
+import com.dlc.exam.monitor.MonitorManager
+import com.dlc.exam.mqtt.MqttService
 import com.dlc.exam.sp.EnvSp
 import com.dlc.exam.ui.StartActivity
 import com.iflytek.cloud.SpeechConstant
 import com.iflytek.cloud.SpeechUtility
 import com.rc.core.log.RcLog
+import com.rc.core.util.AnrWatchDog
 import com.rc.core.util.CrashHandler
+import com.rc.core.util.ProcessUtils
 import com.rc.core.util.ScreenAdapter
 import com.rc.httpcore.HttpClient
 import com.rc.httpcore.config.ConfigCore
@@ -71,18 +78,63 @@ class ExamApp : Application() {
 
         CrashHandler.initCrash(this)
 
+        // 启动主线程 ANR 看门狗,卡住时把主线程堆栈打到 logcat(LAB_ANR)
+        AnrWatchDog().start()
+
         initBugly()
 
         // 初始化网络配置参数
         initHttpConfig()
 
-        // 初始化讯飞语音引擎
-        initIflytek()
+        // 以下几件事只在主进程做。
+        // App 还有第二个进程 :dexopt(X5 内核的 dex 优化进程),
+        // Android 会为每个进程各创建一份 Application 实例并执行 onCreate();
+        // 如果那里也做这些事,会出现:抢讯飞音频焦点、多发 HTTP 请求、
+        // 用同一个 MQTT clientId 把主进程的连接互踢下线(设备状态会在线/离线交替)。
+        if (ProcessUtils.isMainProcess(this)) {
+            // 设备在线心跳上报(与 Activity 生命周期无关,进程存活就一直上报)
+            MonitorManager.start(this)
+
+            // MQTT 常驻:由 Application 持有绑定,
+            // 页面之间来回切换 / Activity 销毁都不会断开 MQTT 连接
+            startMqttService()
+
+            // 初始化讯飞语音引擎
+            initIflytek()
+
+            // 同步服务器时间(异步执行,不阻塞启动)
+            syncServerTimeOnStartup()
+        }
 
         initX5()
+    }
 
-        // 同步服务器时间(异步执行,不阻塞启动)
-        syncServerTimeOnStartup()
+    /**
+     * 绑定 MqttService,由 Application 持有。
+     *
+     * 之前是 MainActivity 绑定、销毁时解绑,导致每切换一次页面(或 Activity 被重建)
+     * MQTT 就会断开重连一次 —— 服务端如果依据 MQTT 连接状态判在线,设备就会
+     * 在"在线/离线"之间来回跳。改成 Application 级常驻后,连接只随进程生命周期存在。
+     */
+    private fun startMqttService() {
+        try {
+            bindService(Intent(this, MqttService::class.java), mMqttConnection, BIND_AUTO_CREATE)
+        } catch (e: Exception) {
+            e.printStackTrace()
+        }
+    }
+
+    private val mMqttConnection: ServiceConnection = object : ServiceConnection {
+        override fun onServiceConnected(name: ComponentName, service: IBinder) {
+            try {
+                (service as? MqttService.MqttBinder)?.initMqtt()
+            } catch (e: Exception) {
+                e.printStackTrace()
+            }
+        }
+
+        override fun onServiceDisconnected(name: ComponentName) {
+        }
     }
 
 

+ 51 - 6
app/src/main/java/com/dlc/exam/common/CommonUtils.kt

@@ -4,14 +4,17 @@ import android.content.Context
 import android.content.Intent
 import android.graphics.Bitmap
 import android.provider.Settings
-import com.dlc.exam.ui.StartActivity
+import com.dlc.exam.sp.EnvSp
+import com.dlc.exam.ui.MainActivity
+import com.rc.core.ui.ActivityCollector
+import com.rc.httpcore.config.ConfigCore
+import com.rc.httpcore.config.ConfigFactory
 import java.io.BufferedOutputStream
 import java.io.File
 import java.io.FileOutputStream
 import java.math.BigDecimal
 import java.math.BigInteger
 import java.security.MessageDigest
-import kotlin.system.exitProcess
 
 /**
  * info
@@ -40,11 +43,53 @@ object CommonUtils {
         }
     }
 
+    /**
+     * 登出后回到首页待命。
+     *
+     * 这里刻意"不杀进程",原因是设备在线状态会因此在"在线/离线"之间交替:
+     * 1. 杀进程会断开 MQTT 连接(客户端自己的 30 秒心跳 bg/heart 靠 MqttService 维持),
+     *    服务端会立刻把设备判为离线;
+     * 2. 而 180 秒无交互就会触发一次 restartApp,交替会非常明显。
+     * 3. 也不能用 FLAG_ACTIVITY_CLEAR_TASK:它会在主线程"同步"销毁整个 Activity 栈,
+     *    连带触发讯飞 TTS destroy()、EventBus 反注册、Service 解绑等一串清理,
+     *    在一体机这类低端设备上容易卡住主线程导致 ANR。
+     *
+     * 处理方式:
+     * - 当前已经在首页(MainActivity):什么都不做,只登出,保住进程与 MQTT 连接;
+     * - 当前在子页面:用 ActivityCollector.finishAll()(异步销毁)回到首页。
+     */
     fun restartApp(appContext: Context) {
-        val intent = Intent(appContext, StartActivity::class.java)
-        val restartIntent = Intent.makeRestartActivityTask(intent.component)
-        appContext.startActivity(restartIntent)
-        exitProcess(0)
+        // 不杀进程了,Application 的 initHttpConfig() 不会重新执行,
+        // 这里手动重新加载一次环境配置,保证改完服务器地址/MQTT 后立即生效
+        applyEnvConfig(appContext)
+
+        // 已经在首页就不重建 Activity 栈,避免断开 MQTT 导致设备被判离线
+        if (ActivityCollector.getTopActivity() is MainActivity) {
+            return
+        }
+
+        ActivityCollector.finishAll()
+        val intent = Intent(appContext, MainActivity::class.java).apply {
+            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        }
+        appContext.startActivity(intent)
+    }
+
+    /**
+     * 重新应用环境配置(服务器地址 / 文件浏览地址 / MQTT)。
+     *
+     * 配置保存在 EnvSp 里,原本依赖杀进程重启、由 Application 的 initHttpConfig() 重新加载。
+     * 现在不杀进程了,所以在需要让新配置生效的地方手动调用一次。
+     */
+    fun applyEnvConfig(appContext: Context) {
+        try {
+            val envConfig = EnvSp.loadEnv(appContext)
+            val configParam = ConfigFactory.buildReleaseConfig
+            ConfigCore.initConfig(configParam, envConfig?.baseUrl, envConfig?.fileBrowserBaseUrl)
+            Constants.MqttConfig.initMqttConfig(configParam, envConfig)
+        } catch (e: Exception) {
+            e.printStackTrace()
+        }
     }
 
     fun stringParseInt(value: String?): Int {

+ 2 - 2
app/src/main/java/com/dlc/exam/common/Constants.kt

@@ -15,9 +15,9 @@ object Constants {
      */
     object ArcFace {
 //        const val APP_ID = "HG2DA2a7JcVshnq7gp6sSgx4evKGB6KoQn7kXweWWds4"
-        const val APP_ID = "A3XKg9AUQhgd7RjBk8JKDrVkkoQRd6W9kxm5R2MtAeu1"
+        const val APP_ID = "HG2DA2a7JcVshnq7gp6sSgxBpKaRVgFw9HSLsDwDnWfP"
 //        const val SDK_KEY = "aCzJcPw5XEWVMsVyxtffkFs1ayZ1uYwU72TE9rtSt4w"
-        const val SDK_KEY = "HXf4r2UBofUkGPUPQPQKag58YvLvYupjdX51beH497Gx"
+        const val SDK_KEY = "9zDCUrVZTSUuhQUKYiynt9wgXtRqqmKgLjKkmjfztVFn"
 
         const val FACE_SIMILAR_SCORE = 0.9f
     }

+ 140 - 0
app/src/main/java/com/dlc/exam/monitor/MonitorManager.kt

@@ -0,0 +1,140 @@
+package com.dlc.exam.monitor
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import com.dlc.exam.common.CommonUtils
+import com.dlc.exam.mqtt.MqttManager
+import com.rc.core.log.RcLog
+import com.rc.core.util.ProcessUtils
+import com.rc.httpcore.client.ApiRepository
+import com.rc.httpcore.exception.NetException
+
+/**
+ * 设备在线状态心跳上报
+ *
+ * 设计说明:
+ * 1. 挂在 Application 层(ExamApp 启动时开启),不挂在任何 Activity 上。
+ *    这样登录页 / 考试页 / 学习页来回切换都不会中断上报。
+ * 2. iot/aio/monitor 在服务端白名单中,不需要 token,
+ *    所以进程刚启动、用户还没登录时上报同样有效。
+ * 3. 只在主进程上报。App 还有第二个进程(:dexopt,X5 内核的 dex 优化进程),
+ *    Android 会为每个进程各创建一份 Application 实例并调用 onCreate;
+ *    如果每个进程都上报,同一个 deviceNo 会去抢服务端同一个 30 秒限流 key,
+ *    请求互相被拒,设备状态就会在"在线/离线"之间来回跳。
+ * 4. 上报失败只写日志,不弹 toast、不打断用户操作,下一个周期自动重试。
+ */
+object MonitorManager {
+
+    /**
+     * 心跳上报周期:5 秒。
+     *
+     * 与其他一体机保持完全一致。
+     * 服务端 /monitor 有 30 秒的每设备限流窗口(Redis SETNX,窗口内重复请求直接拒绝、
+     * 且被拒的那次不会转发到 MQTT),所以正常情况下真正打到 MQTT 的"有效刷新间隔"
+     * 恒为 30 秒;这里取 5 秒是为了:
+     * - 与其他正常的一体机完全对齐,不留下"我们跟别人不一样"的变量;
+     * - 万一服务端限流没按预期生效(例如 key 没过期),5 秒也能兜住刷新频率。
+     */
+    private const val INTERVAL_MS = 5_000L
+
+    /** 限流日志降噪间隔 */
+    private const val LIMIT_LOG_INTERVAL_MS = 60_000L
+
+    private var lastLimitLogAt = 0L
+
+    /** 上一次成功上报的时刻(elapsedRealtime),用于统计真实刷新间隔 */
+    @Volatile
+    private var lastSuccessAt = 0L
+
+    /** 上一次发起请求的时刻(elapsedRealtime) */
+    @Volatile
+    private var lastRequestAt = 0L
+
+    private val handler = Handler(Looper.getMainLooper())
+
+    private var appContext: Context? = null
+
+    @Volatile
+    private var started = false
+
+    private val heartbeatTask = object : Runnable {
+        override fun run() {
+            report()
+            handler.postDelayed(this, INTERVAL_MS)
+        }
+    }
+
+    /**
+     * 启动心跳上报(幂等,重复调用只会生效一次)
+     */
+    @JvmStatic
+    fun start(context: Context) {
+        if (started) return
+
+        // 非主进程不启动心跳,详见类注释第 3 点
+        if (!isMainProcess(context)) {
+            RcLog.info("[Monitor] 当前不是主进程,跳过心跳启动")
+            return
+        }
+
+        started = true
+        appContext = context.applicationContext
+        // 立即上报一次,之后按 INTERVAL_MS 周期上报
+        handler.post(heartbeatTask)
+        RcLog.info("[Monitor] 心跳已启动,间隔=$INTERVAL_MS ms")
+    }
+
+    /**
+     * 立即上报一次(例如登录成功后,立刻把设备置为在线)
+     */
+    @JvmStatic
+    fun reportNow() {
+        report()
+    }
+
+    /**
+     * 判断当前是否主进程。
+     *
+     * 取当前进程名与包名比较;取不到进程名时默认按主进程处理,
+     * 避免误伤导致心跳完全没启动。
+     */
+    private fun isMainProcess(context: Context): Boolean {
+        return ProcessUtils.isMainProcess(context)
+    }
+
+    private fun report() {
+        val context = appContext ?: return
+        try {
+            val deviceNo = CommonUtils.getAndroidId(context)
+            val now = SystemClock.elapsedRealtime()
+            lastRequestAt = now
+            ApiRepository.monitor(deviceNo).subscribe({
+                val t = SystemClock.elapsedRealtime()
+                val gap = if (lastSuccessAt > 0) t - lastSuccessAt else -1L
+                lastSuccessAt = t
+                RcLog.info("[Monitor] 上报成功 deviceNo=$deviceNo 距上次成功=${gap}ms")
+            }, { throwable ->
+                val t = SystemClock.elapsedRealtime()
+                val gap = if (lastSuccessAt > 0) t - lastSuccessAt else -1L
+                if (throwable is NetException) {
+                    // 服务端业务拒绝:命中 30 秒限流窗口,属于预期内的正常反馈,不是故障。
+                    // 10 秒一次意味着大部分请求都会命中,这里做降噪,每分钟只记一条。
+                    if (t - lastLimitLogAt > LIMIT_LOG_INTERVAL_MS) {
+                        lastLimitLogAt = t
+                        RcLog.info("[Monitor] 命中服务端 30 秒限流,本次未上报(属正常,下个窗口会成功)")
+                    }
+                } else {
+                    // 网络异常等真实故障
+                    RcLog.warn("[Monitor] 心跳上报失败 deviceNo=$deviceNo, err=$throwable")
+                }
+                RcLog.info("[Monitor] 诊断 请求时刻=${now}ms 本次结果=${throwable.javaClass.simpleName}" +
+                        " code=${(throwable as? NetException)?.code} 距上次成功=${gap}ms" +
+                        " 距上次请求=${t - lastRequestAt}ms MQTT连接=${MqttManager.instance.isConnected()}")
+            })
+        } catch (e: Throwable) {
+            RcLog.warn("[Monitor] 心跳上报异常", e)
+        }
+    }
+}

+ 15 - 1
app/src/main/java/com/dlc/exam/mqtt/MqttManager.kt

@@ -85,6 +85,17 @@ class MqttManager private constructor() {
     }
 
     /**
+     * 当前 MQTT 是否已连接,用于诊断。
+     */
+    fun isConnected(): Boolean {
+        return try {
+            mMqttWorker.isConnected()
+        } catch (e: Exception) {
+            false
+        }
+    }
+
+    /**
      * 释放单例, 及其所引用的资源
      */
     @Synchronized
@@ -101,6 +112,9 @@ class MqttManager private constructor() {
 
     /**
      * 发送心跳
+     *
+     * 间隔 10 秒:服务端如果依据 bg/heart 判断设备在线,
+     * 30 秒一次容易卡在 TTL 边界导致被判离线。加密到 10 秒留足余量。
      */
     private val mRunSendHeartBeat = object : Runnable {
         override fun run() {
@@ -113,7 +127,7 @@ class MqttManager private constructor() {
 
             val message = mGson.toJson(heartBeatEntry)
             mMqttWorker.publish(Constants.MqttConfig.Topic.HEART, message, 0)
-            mHandler.postDelayed(this, 30000)
+            mHandler.postDelayed(this, 10000)
         }
     }
 

+ 14 - 1
app/src/main/java/com/dlc/exam/mqtt/MqttWorker.kt

@@ -44,7 +44,9 @@ class MqttWorker(
             // 设置超时时间,单位:秒
             connectionTimeout = 10
             // 心跳包发送间隔,单位:秒
-            keepAliveInterval = 20
+            // 60 秒:broker 侧按 1.5 倍(90 秒)没收到 PINGRESP 才判超时,
+            // 留足余量,一体机网络抖动时不会轻易被 broker 踢掉连接。
+            keepAliveInterval = 60
             // 用户名
             userName = uName
             // 密码
@@ -300,6 +302,17 @@ class MqttWorker(
         mHandlerThread.quitSafely()
     }
 
+    /**
+     * 当前 MQTT 是否处于连接状态,用于诊断(服务端也可能依据 MQTT 连接状态判在线)。
+     */
+    fun isConnected(): Boolean {
+        return try {
+            mClient?.isConnected == true
+        } catch (e: Exception) {
+            false
+        }
+    }
+
     interface Listener {
 
         /**

+ 7 - 46
app/src/main/java/com/dlc/exam/ui/MainActivity.kt

@@ -1,8 +1,6 @@
 package com.dlc.exam.ui
 
-import android.content.ComponentName
 import android.content.Intent
-import android.content.ServiceConnection
 import android.os.*
 import android.view.LayoutInflater
 import android.view.View
@@ -13,7 +11,7 @@ import com.dlc.exam.common.CommonUtils
 import com.dlc.exam.common.ExamType
 import com.dlc.exam.databinding.ActivityMainBinding
 import com.dlc.exam.iflytek.VoiceItsManager
-import com.dlc.exam.mqtt.MqttService
+import com.dlc.exam.monitor.MonitorManager
 import com.dlc.exam.mqtt.event.UpdateEvent
 import com.dlc.exam.ui.certificate.CertificateManagementActivity
 import com.dlc.exam.ui.common.BaseCountDownActivity
@@ -45,9 +43,6 @@ import org.greenrobot.eventbus.ThreadMode
  */
 class MainActivity : BaseCountDownActivity<ActivityMainBinding>(), TitleBar.TitleListener {
 
-    private lateinit var runnable: Runnable
-    private var handler: Handler = Handler(Looper.getMainLooper())
-
     override fun createViewBinding() = ActivityMainBinding.inflate(LayoutInflater.from(this))
 
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -55,54 +50,17 @@ class MainActivity : BaseCountDownActivity<ActivityMainBinding>(), TitleBar.Titl
         VoiceItsManager.initIts(this)
         VoiceItsManager.speakVoice("欢迎使用实验室安全学习与考试系统")
         EventBus.getDefault().register(this)
-        bindService(Intent(this, MqttService::class.java), mConnection, BIND_AUTO_CREATE)
-        // 创建定时任务
-        runnable = object : Runnable {
-            override fun run() {
-                // 每隔一个小时执行一次操作
-                // 这里可以添加你需要执行的代码
-                subAddData()
-                // 重新启动定时任务
-                handler.postDelayed(this, 3600000) // 3600000 毫秒为一小时
-            }
-        }
-        // 第一次启动定时任务
-        handler.post(runnable)
+        // MQTT 连接与设备在线心跳都由 ExamApp(Application)常驻持有,
+        // 不再绑在 Activity 上 —— 否则每次页面切换/Activity 销毁都会断开 MQTT,
+        // 服务端据此判在线时设备就会在"在线/离线"之间交替。
     }
 
-    private fun subAddData() {
-        val androidId = CommonUtils.getAndroidId(this)
-        val disposable = ApiRepository.monitor(androidId)
-            .subscribe({ data ->
-
-            }, { throwable ->
-                showNetError(throwable)
-            })
-        addDisposable(disposable)
-    }
     override fun onDestroy() {
         VoiceItsManager.release()
         EventBus.getDefault().unregister(this)
-        unbindService(mConnection)
-        try {
-            handler.removeCallbacks(runnable)
-            handler.removeCallbacksAndMessages(null)
-        } catch (e: Exception) {
-        }
         super.onDestroy()
     }
 
-    private val mConnection: ServiceConnection = object : ServiceConnection {
-
-        override fun onServiceConnected(name: ComponentName, service: IBinder) {
-            service as MqttService.MqttBinder
-            service.initMqtt()
-        }
-
-        override fun onServiceDisconnected(name: ComponentName) {
-        }
-    }
-
     override fun onResume() {
         if (null == ExamApp.sLearnLoginVo) {
             viewBinding.titleBar.showLogoutView(false)
@@ -214,6 +172,9 @@ class MainActivity : BaseCountDownActivity<ActivityMainBinding>(), TitleBar.Titl
                 // 登录成功
                 queryTodoViolation()
 
+                // 登录后才拿到 token,立即补报一次心跳,保证服务端尽快把设备置为在线
+                MonitorManager.reportNow()
+
                 viewBinding.titleBar.showLogoutView(true)
 //                viewBinding.titleBar.setUsername(ExamApp.sLearnLoginVo!!.nickName)
                 viewBinding.titleBar.setUsername(ExamApp.sLearnLoginVo!!.userName)

+ 4 - 1
app/src/main/java/com/dlc/exam/ui/settings/PasswordDialog.kt

@@ -33,8 +33,11 @@ class PasswordDialog(context: Context, private val dialogListener: (() -> Unit))
             val password = viewBinding.etInput.text.toString().trim()
             val settingPwd = EnvSp.loadEnv(context)?.settingPwd ?: Constants.Mock.SETTING_PWD
             if (password == settingPwd) {
-                dialogListener.invoke()
+                // 先关闭弹窗再跳转目标页。
+                // 反过来先 startActivity 的话,Activity 切换过程中弹窗 window 还挂在上面,
+                // 在低端机上容易出现跳转卡顿/无响应。
                 dismiss()
+                dialogListener.invoke()
             } else {
                 Toast.makeText(context, "密码不正确!", Toast.LENGTH_SHORT).show()
             }

BIN
app/src/main/jniLibs/armeabi-v7a/libarcsoft_face_engine.so