在 Android 上跑 Kafka Consumer:一个实时定位数据接入的踩坑记录

在 Android 上跑 Kafka Consumer:一个实时定位数据接入的踩坑记录

场景:调度平台通过 Kafka 实时广播车辆定位,我们的 Android 记录仪终端需要直接消费 Kafka 消息,作为 GPS 之外的第二定位源。听起来离谱,但它真的在生产环境跑着。本文记录完整的实现与踩坑。

一、为什么不走 HTTP 轮询?

先说清楚技术选型的理由,避免"为用而用":

  • 定位数据由调度平台广播式下发,HTTP 轮询要么延迟高、要么把服务器打爆;
  • 设备与调度平台在同一内网,直连 Kafka broker 网络可达;
  • Kafka 的消费组语义天然适配"每台设备都要收到全量消息"。

最终架构:

markdown 复制代码
调度平台 ──produce──→ Kafka broker ──consume──→ Android 终端(KafkaConsumer)
                                                │
GPS(海康 SDK)────────────────────────────────┤
                                                ▼
                                    双源择优 → 状态上报

二、Android 端 Kafka 客户端的适配坑

Kafka 官方 Java 客户端能在 Android 上跑,但有两个必踩的坑:

坑 1:JMX 报告器在 Android 上不存在

Kafka 客户端默认注册 JMX metrics,Android 没有 JMX,会刷一堆 ClassNotFoundException 警告甚至初始化失败。必须显式置空:

kotlin 复制代码
val props = Properties().apply {
    put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "10.x.x.x:9092")
    // 每台设备独立消费组:保证所有设备都收到全量消息
    put(ConsumerConfig.GROUP_ID_CONFIG, "recorder-$deviceSerial")
    put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name)
    put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name)
    put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true")
    put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest")
    // ★ 关键:Android 无 JMX,必须关闭默认的 JmxReporter
    put(ConsumerConfig.METRIC_REPORTER_CLASSES_CONFIG, "")
    // 网络参数调优:工业内网环境,超时给短一点快速失败
    put(ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG, "15000")
    put(ConsumerConfig.DEFAULT_API_TIMEOUT_MS_CONFIG, "15000")
    put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "10000")
    put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, "3000")
    put(ConsumerConfig.CLIENT_DNS_LOOKUP_CONFIG, "use_all_dns_ips")
}

坑 2:消费组 ID 的设计

常规后端服务里,同组消费者瓜分消息做负载均衡。但我们的需求相反------每台终端都要收到全量广播。所以 groupId 用设备序列号区分:

kotlin 复制代码
val groupId = "recorder-" + if (serial.isNotEmpty()) {
    serial
} else {
    "unknown-${UUID.randomUUID()}"  // 拿不到序列号也要保证唯一
}

效果等价于 Kafka 版的"发布订阅",每台设备一个独立消费者组。

三、无限重连的消费循环

工业现场网络环境恶劣(4G/5G 切换、隧道断连),消费循环必须永远不死:

kotlin 复制代码
private fun consumeLoop() {
    while (started) {
        var kafkaConsumer: KafkaConsumer<String, String>? = null
        try {
            // 先做 TCP 预检,区分"网络不通"与"Kafka 协议错误"
            val tcpError = checkTcpConnection()
            if (tcpError != null) {
                recordError(tcpError)
            } else {
                kafkaConsumer = createConsumer()
                kafkaConsumer.subscribe(listOf(TOPIC))
                while (started) {
                    val records = try {
                        kafkaConsumer.poll(Duration.ofMillis(1000))
                    } catch (e: WakeupException) {
                        break  // stop() 调用了 wakeup(),优雅退出
                    }
                    records.forEach { handleMessage(it.value()) }
                }
            }
        } catch (e: Exception) {
            if (started) recordError("${e.javaClass.simpleName}: ${e.message}", e)
        } finally {
            try { kafkaConsumer?.close() } catch (_: Exception) {}
        }
        if (started) Thread.sleep(5_000)  // 失败 5 秒后重建整个 Consumer
    }
}

要点:

  • 线程模型 :独立 daemon Thread,KafkaConsumer 不是线程安全的,所有 poll 都在这一个线程里;
  • 优雅停止 :stop() 调 consumer.wakeup(),正在阻塞的 poll() 会抛 WakeupException,break 出循环;
  • 整体重建:出错不是重 subscribe,而是 close 后整个 Consumer 重建------状态最干净,避免半死不活。

四、最有意思的部分:多网卡 TCP 预检

工业终端同时有有线、WiFi、5G 三张网卡,Kafka 客户端只会走系统默认网络。现场"连不上 broker"时,到底是哪张网卡没路由?我们在 Consumer 建连前先遍历所有网卡做 TCP 探测:

kotlin 复制代码
private fun checkTcpConnection(): String? {
    val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE)
        as ConnectivityManager
    val networks = cm.allNetworks
    if (networks.isEmpty()) return "设备没有任何可用网络"

    val defaultNetwork = cm.activeNetwork
    val results = StringBuilder()
    var defaultOk = false

    networks.forEach { network ->
        val caps = cm.getNetworkCapabilities(network)
        val type = when {
            caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "蜂窝"
            caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "WiFi"
            caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true -> "有线"
            else -> "其他"
        }
        val ips = cm.getLinkProperties(network)?.linkAddresses
            ?.mapNotNull { it.address?.hostAddress }?.joinToString(",") ?: ""
        // 用该网卡的 SocketFactory 建连,强制走这张网卡
        val error = checkSingleNetwork(network.socketFactory, host, port)
        if (error == null) {
            results.append("$type($ips): 通;")
            if (network == defaultNetwork) defaultOk = true
        } else {
            results.append("$type($ips): 不通($error);")
        }
    }
    // 默认网卡通即可,Kafka 客户端走系统默认网络
    return if (defaultOk) null
           else "TCP 无法连接 $BOOTSTRAP,各网卡检测结果:$results"
}

private fun checkSingleNetwork(factory: SocketFactory?, host: String, port: Int): String? {
    return try {
        (factory?.createSocket() ?: Socket()).use {
            it.connect(InetSocketAddress(host, port), 3_000)
        }
        null
    } catch (e: Exception) {
        "${e.javaClass.simpleName}: ${e.message}"
    }
}

这一招在现场排查中救过命:一次"消费不到消息"的问题,预检日志直接显示 蜂窝(10.x.x.x): 通;有线(192.168.x.x)[默认]: 不通------有线网卡是默认路由但没插网线,一目了然。

五、消息处理:限流 + 时效性

定位消息推送频率可能很高,而设备每 10 秒才上报一次,照单全收纯属浪费 CPU:

kotlin 复制代码
private const val ACCEPT_INTERVAL_MS = 3_000L   // 最短接受间隔
private const val FRESH_TIMEOUT_MS = 60_000L    // 超过60秒的定位视为过期

private fun handleMessage(value: String?) {
    if (value.isNullOrBlank()) return
    val now = System.currentTimeMillis()
    if (now - lastAcceptedTime < ACCEPT_INTERVAL_MS) return  // 限流丢弃

    val location = parseLocation(value, now) ?: return
    lastAcceptedTime = now
    latestLocation = location  // @Volatile,只保留最新一条
}

// 上报时取数:超过时效返回 null,调用方回退 GPS
fun getEffectiveLocation(): KafkaLocation? {
    val location = latestLocation ?: return null
    return if (System.currentTimeMillis() - location.receiveTimeMillis <= FRESH_TIMEOUT_MS)
        location else null
}

上层取数的双源择优策略:

kotlin 复制代码
val kafkaLocation = KafkaLocationManager.getEffectiveLocation()
val (longitude, latitude) = when {
    kafkaLocation != null -> kafkaLocation  // Kafka 定位新鲜,优先用
    else -> HikLocationManager.getLatestLocation()  // 回退 GPS
}
viewModel?.sendMsg(context, longitude, latitude)

六、诊断设计:没有 adb 也要能定位问题

沿用了上一篇的文件日志体系,Kafka 模块所有关键事件(启动、订阅成功、首条消息、错误)都写入 api_log.txt,并做了防刷屏:

  • 相同错误 60 秒内只写一次文件日志;
  • 正常消息只在首条和之后每 50 条记一次;
  • 订阅成功时记录一次分区位点(position vs endOffset)------position == end 说明 broker 上真没新消息,position < end 却收不到才是消费端异常,这是排查"消费不到数据"最关键的判据;
kotlin 复制代码
val desc = assignment.joinToString("; ") { tp ->
    val pos = runCatching { c.position(tp) }.getOrDefault(-1L)
    val end = runCatching { c.endOffsets(setOf(tp))[tp] }.getOrNull() ?: -1L
    "${tp.topic()}-${tp.partition()}: position=$pos, end=$end"
}

异常记录还有一个细节:拼接完整 cause 链,因为 Android 上日志经常被截断,根因往往藏在第三层 cause 里:

kotlin 复制代码
private fun buildCauseChain(throwable: Throwable): String {
    val sb = StringBuilder()
    var current: Throwable? = throwable
    var depth = 0
    while (current != null && depth < 5) {
        if (sb.isNotEmpty()) sb.append(" <- ")
        sb.append(current.javaClass.name)
        current.message?.take(150)?.let { sb.append(": ").append(it) }
        current = current.cause
        depth++
    }
    return sb.toString()
}

七、总结

在 Android 上跑 Kafka Consumer 的关键结论:

  1. 能跑,且稳定,但必须关掉 JMX reporter;
  2. 一设备一消费组实现广播语义;
  3. 消费线程独立 daemon,出错整体重建 Consumer 而不是局部恢复;
  4. 多网卡 TCP 预检 是工业设备网络诊断的利器,Network.socketFactory 可以强制指定出口网卡;
  5. 消息限流 + 时效判断再入业务,配合本地文件日志实现无 adb 排查。

如果你的场景只是普通的业务数据同步,HTTP/轮询/WebSocket 依然是更稳妥的选择;但当数据源天然在 Kafka 上、且设备在内网直连时,客户端直连方案值得考虑。

相关推荐
mmsx1 小时前
Android 自定义 View 三关:触摸分发、绘制与坐标错位
android·kotlin
JMchen1 小时前
自定义View在复杂业务场景中的实战
android·kotlin·canvas
hai_android2 小时前
RecyclerView 缓存机制详解
android·kotlin·android jetpack
_祝你今天愉快8 小时前
UI核心 - UI绘制流程及原理
android
mmsx8 小时前
Android 一个设备多个 App 抢:跨进程共享服务的架构设计
android·kotlin·app
mmsx9 小时前
Android GIS系列 栅格怎么存、格网怎么归算:两个容易混淆的 GIS 链路
android·性能优化·app
陈柒吖9 小时前
安卓代码加固(1):加密DEX
android·前端
mmsx9 小时前
Android GIS系列 串口、蓝牙、USB、网络:一套统一抽象怎么扛住四种通道
android·app·产品
二流小码农9 小时前
鸿蒙开发:ArrayList,可不会让UI更新哦
android·ios·harmonyos