在 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 条记一次;
- 订阅成功时记录一次分区位点(
positionvsendOffset)------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 的关键结论:
- 能跑,且稳定,但必须关掉 JMX reporter;
- 一设备一消费组实现广播语义;
- 消费线程独立 daemon,出错整体重建 Consumer 而不是局部恢复;
- 多网卡 TCP 预检 是工业设备网络诊断的利器,
Network.socketFactory可以强制指定出口网卡; - 消息限流 + 时效判断再入业务,配合本地文件日志实现无 adb 排查。
如果你的场景只是普通的业务数据同步,HTTP/轮询/WebSocket 依然是更稳妥的选择;但当数据源天然在 Kafka 上、且设备在内网直连时,客户端直连方案值得考虑。