本文译自「ZeroMQ on Android: Bridging a Linux Service with PUB SUB」,原文链接levelup.gitconnected.com/zeromq-on-a...,由James Cullimore发布于2026年3月9日。

在最近的一个项目中,我们遇到了一个看似简单却又充满挑战的需求。我们需要在一个基于 Linux 的小型服务和一个 Android 应用之间建立可靠、低延迟的通信。没有云组件,没有 HTTP API,也没有长期运行的后端基础设施。只有同一台机器上的两个进程需要高效且持续地进行通信。
传统的 REST 或 gRPC 等方法对于我们面临的问题来说显得过于繁琐。我们不需要请求/响应语义,也不想承担序列化格式、连接管理或重试逻辑带来的额外开销。我们真正需要的是一个轻量级的消息传递层,能够在数据可用时推送消息,并允许 Android 应用在需要时发布消息。
这时,ZeroMQ 就派上了用场。
ZeroMQ 不是消息代理,也不是框架。它是一个底层消息库,提供 PUB/SUB、PUSH/PULL 和 REQ/REP 等构建模块,并允许你根据系统需求将它们组合起来。它完全在进程内运行,使用纯 TCP 协议,并且可以在包括 Linux 和 Android 在内的不同平台上良好运行。
在本文中,我将详细介绍我们如何使用 ZeroMQ 通过简单的 PUB/SUB 设置将 Linux 服务连接到 Android 应用。我们将探讨我们实际交付的客户端实现、其结构设计的原因以及我们在此过程中遇到的权衡取舍。
架构概述和消息模型
在编写任何代码之前,我们花时间确定了 Linux 服务和 Android 应用应该如何通信。这两个进程将在同一设备上运行,并通过 localhost 进行通信。不需要加密、身份验证或消息持久化。最重要的是简单性、可预测性和低延迟。
ZeroMQ 的消息传递模式使这一决策变得非常简单。
我们为主要数据流选择了发布/订阅(PUB/SUB)模型。Linux 服务充当发布者,在新数据可用时广播消息。Android 应用充当订阅者,持续监听并响应更新。这种模式非常适合传输状态变化、传感器数据或状态更新,而无需轮询。
同时,Android 应用需要一种向 Linux 服务发送消息的方式。我们没有引入第二个协议或不同的传输方式,而是使用了反向的第二个 ZeroMQ 套接字。在 Android 端,该套接字发布消息,Linux 服务订阅这些消息。虽然这不是严格的请求/响应机制,但它保持了通信模型的对称性,并且易于理解。
从 Android 应用的角度来看,这会产生两个独立的连接:
-
一个连接到 Linux 服务发布端口的订阅套接字,用于接收消息
-
一个连接到 Linux 服务订阅端口的发布套接字,用于发送消息
两个套接字都运行在本地主机上的 TCP 协议下,每个套接字都绑定到一个专用端口。这种分离方式保持了消息流的清晰性,避免了将不相关的功能耦合到同一个通道中。
其余的实现工作都围绕着在 Android 上安全地管理这些套接字展开。这意味着要以一种不会泄漏资源或阻塞主线程的方式来处理线程、生命周期、连接状态和清理工作。然而,核心思想依然很简单:连接、订阅、接收消息,并在需要时发布响应。
Android 设置和 ZeroMQ 注意事项
在 Android 上使用 ZeroMQ 有一些值得提前注意的实际问题。虽然 ZeroMQ 本身是平台无关的,但 Android 的运行时、线程模型和生命周期限制会影响你将其集成到实际应用程序中的方式。
在我们的案例中,我们依赖于 Java ZeroMQ 绑定,它公开了熟悉的 ZContext 和 ZMQ.Socket API。这些绑定在 Android 上运行良好,但它们不具备生命周期感知能力。一旦创建了上下文或套接字,你就需要显式地管理其生命周期。否则,很容易导致线程泄漏或本地资源的生命周期超过创建它们的组件。
线程是第二个主要问题。ZeroMQ 套接字默认是阻塞的。在主线程上调用 recv 或 send 是不可行之选,即使是短暂的操作,如果处理不当,也可能导致卡顿或 ANR(应用程序无响应)。因此,所有套接字创建和消息处理都被推到后台线程进行。
Kotlin 协程非常适合这里。它们允许我们将工作卸载到 Dispatchers.IO,保持代码的可读性,并在需要时将状态更改协调回主线程。稍后你将看到的客户端实现使用协程来管理连接建立、消息接收和清理,而无需将线程问题暴露给应用程序的其他部分。
另一个重要的细节是,如果出现问题,ZeroMQ 不会自动重新连接套接字。如果连接断开或套接字发生故障,你需要决定如何应对。在我们的设置中,我们有意保持简单。客户端通过回调报告连接状态更改,并将重新连接策略留给调用者。这使得 ZeroMQ 客户端专注于通信,而不是策略。
考虑到这些限制,我们将所有与 ZeroMQ 相关的逻辑封装在一个类中。该类拥有上下文、套接字和协程,并向应用程序的其他部分公开了一个简洁明了的 API。
将 ZeroMQ 添加到 Android 项目
要在 Android 上使用 ZeroMQ,我们依赖于JeroMQ,它是 ZeroMQ 的纯 Java 实现。这是一个重要的区别。JeroMQ 不需要原生二进制文件或 NDK 集成,这使得设置过程非常简单,并避免了 ABI 相关问题。
在这个项目中,依赖项使用版本目录进行管理。ZeroMQ 依赖项只需定义一次,即可在所有模块中保持一致的引用。
首先,我们声明版本:
toml
jeromq = "0.6.0"
接下来,我们定义实际的库入口:
toml
jeromq = { group = "org.zeromq", name = "jeromq", version.ref = "jeromq" }
完成这些步骤后,就可以像添加其他库一样,将依赖项添加到 Android 模块中:
kotlin
implementation(libs.jeromq)
使用 JeroMQ 意味着 ZeroMQ 上下文和套接字完全运行在 JVM 中。这与 Android 的进程模型非常契合,并使资源管理更加可预测。这样做的缺点是无法使用 ZeroMQ 的原生实现,但对于本地低延迟通信而言,这种方法已经绰绰有余。
添加依赖项后,无需其他配置。你可以创建 ZContext,打开套接字,并立即开始发送或接收消息。
ZeroMqClient 类及其职责
Android 端实现的核心是一个名为 ZeroMqClient 的类。它的作用是将所有 ZeroMQ 特有的细节隐藏在一个简洁、可预测的 API 之后,以便应用程序的其他部分可以安全地与之交互。
该类负责:
-
创建并拥有 ZeroMQ 上下文
-
管理一个用于接收消息的 SUB 套接字
-
管理一个用于发送消息的 PUB 套接字
-
在主线程之外运行阻塞式套接字操作
-
公开消息和连接状态的回调函数
-
确定性地清理所有资源
该类没有直接公开套接字,而是采用了一种配置驱动的方法。订阅和发布端口是可选的,这使得客户端可以根据使用场景以仅接收、仅发送或双向模式运行。
以下是项目中完整的类定义:
kotlin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.zeromq.SocketType
import org.zeromq.ZContext
import org.zeromq.ZMQ
import org.zeromq.ZMQException
import java.io.Closeable
class ZeroMqClient(
private val subPort: Int? = null, // eg. 54054,
private val pubPort: Int? = null, // eg. 54055,
private val messageCallback: ((String) -> Unit)? = null,
private val onConnectionChanged: ((Boolean) -> Unit)? = null
) : Closeable {
private var context: ZContext? = null
private var subSocket: ZMQ.Socket? = null
private var pubSocket: ZMQ.Socket? = null
private var isRunning = false
private var receiveJob: Job? = null
该类实现了 Closeable 接口,使其生命周期清晰可见。调用 close 函数时,预期所有后台工作都会停止,所有原生资源都会被释放。这一点在之后将客户端与 Android 组件生命周期绑定时尤为重要。
subPort 和 pubPort 参数可以为空。这使得客户端可以根据调用者的需求有条件地创建套接字,而无需引入多个实现或标志。
回调函数也以传入的方式传递,而不是作为流或可观察对象公开。这是为了保持客户端轻量级且不依赖框架而特意做出的选择。应用程序中的更高层可以根据需要将这些回调函数适配到 StateFlow、LiveData 或任何其他抽象层。
最后,ZeroMQ 上下文、套接字和协程作业等内部状态都是私有的。应用程序的其他部分无需了解消息是如何接收或发送的,只需知道消息正在接收或发送即可。
连接和初始化套接字
connect() 函数用于将客户端从一个普通对象转换为一个活跃的 ZeroMQ 参与者。目标很简单:创建一个上下文,创建所需的套接字,将它们连接到本地主机,并在配置了 SUB 端口后开始接收消息。
实现过程中受到以下几个限制:
-
我们绝不阻塞主线程。
-
我们希望 SUB 和 PUB 套接字是可选的。
-
我们希望应用程序的其他部分能够知道连接是否成功。
以下是 connect() 函数的实现:
kotlin
fun connect() {
if (isRunning) return
CoroutineScope(Dispatchers.IO).launch {
try {
context = ZContext()
subPort?.let {
subSocket = context?.createSocket(SocketType.SUB)
subSocket?.subscribe("".toByteArray())
subSocket?.connect("tcp://127.0.0.1:$subPort")
Log.d(TAG, "Subbing to $subPort")
startReceiving()
}
pubPort?.let {
pubSocket = context?.createSocket(SocketType.PUB)
pubSocket?.connect("tcp://127.0.0.1:$pubPort")
Log.d(TAG, "Pubbing to $pubPort")
}
isRunning = subSocket != null || pubSocket != null
onConnectionChanged?.let {
CoroutineScope(Dispatchers.Main).launch { it.invoke(true) }
}
} catch (e: Exception) {
Log.e(TAG, "Error connecting to server: ${e.message}", e)
onConnectionChanged?.let {
CoroutineScope(Dispatchers.Main).launch { it.invoke(false) }
}
close()
}
}
}
提前返回的 if (isRunning) return 可以防止重复连接。如果没有这个保护机制,很容易创建多个上下文和套接字,这很快就会导致混乱的行为和资源泄漏。
所有设置都在 Dispatchers.IO 中启动。套接字创建和 connect() 调用都不是应该在主线程上运行的操作。这也让我们可以在以后添加重试或超时机制,而无需更改调用代码。
当客户端配置了 subPort 时,它会创建一个 SocketType.SUB 套接字并立即进行订阅:
kotlin
subSocket?.subscribe("".toByteArray())
使用空前缀订阅意味着:订阅所有内容。这保持了客户端的灵活性。如果你之后引入了基于主题的过滤,你可以将其替换为特定的前缀,并保持其余代码不变。
两个套接字都连接到 tcp://127.0.0.1:<port>。这是最初需求的关键部分:Linux 服务和 Android 应用之间的本地通信。
最后,连接回调函数在主调度器上被调用。即使回调函数只是切换状态,从后台线程更新 UI 状态也常常会导致一些难以察觉的问题,因此需要显式地处理。
如果在设置过程中出现任何错误,代码会记录错误,报告连接失败,并调用 close() 以确保部分创建的资源不会残留。
使用协程接收消息
SUB 套接字连接成功后,下一个问题虽然简单却至关重要:如何在不阻塞应用程序的情况下持续监听,并将接收到的消息传递给代码的其他部分。
这就是 startReceiving() 的作用。它会在后台调度器上运行一个循环,阻塞在 recvStr() 上,并将接收到的每条消息通过回调函数转发出去。
以下是具体的实现:
kotlin
private fun startReceiving() {
receiveJob = CoroutineScope(Dispatchers.IO).launch {
try {
while (isRunning && subSocket != null) {
val message = subSocket?.recvStr()
if (message != null) {
Log.d(TAG, "Server received message: $message")
messageCallback?.invoke(message)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error receiving message: ${e.message}", e)
}
}
}
recvStr() 是一个阻塞调用。在这里没问题,因为这个协程运行在 Dispatchers.IO 上。线程可以等待消息而不会影响 UI 的响应速度。
循环条件会检查 isRunning 和 subSocket != null。这样,当客户端关闭或套接字意外被清理时,就可以快速、清晰地停止接收消息。结合 close() 中的 receiveJob?.cancel(),它提供了两层停止行为:基于状态的退出和协程取消。
消息通过 messageCallback 向上传递。这使得该类专注于传输问题,而不是消息的解析方式。实际上,应用程序可以在回调中解析 JSON、更新 StateFlow 或将事件路由到应用程序的更深层。
一个关于日志的小提示:日志标签显示"服务器已收到消息",但这段代码位于客户端,接收来自服务器的消息。如果保持现状,它仍然可以工作,但在调试时可能会产生误导。是否要调整这一点取决于代码风格,但值得注意。
错误会被捕获并记录,但协程不会自动重启。这符合客户端的轻量级设计。如有需要,可以在上一层实现连接策略(重试、重新连接、退避)。
使用 sendMessage() 发送消息
接收消息只是成功的一半。Android 应用还需要一种简单的方法将消息发布回 Linux 服务。
sendMessage() 函数特意设计得非常简洁。它会验证客户端是否正在运行以及 PUB 套接字是否存在,然后发送消息并记录结果。
以下是实际使用的实现:
kotlion
fun sendMessage(message: String) {
if (!isRunning || pubSocket == null) {
Log.e(TAG, "Cannot send message, client not connected")
return
}
try {
pubSocket?.send(message)
Log.d(TAG, "Sent message to server: $message")
} catch (e: ZMQException) {
Log.e(TAG, "Error sending message or receiving response: ${e.message}", e)
}
}
首先,该方法预先处理了常见的误用情况。如果客户端创建时没有指定 pubPort,或者 connect() 尚未建立 PUB 套接字,我们会快速失败并记录清晰的日志消息。这避免了应用"以为"已发送消息但实际上并未发送任何消息的静默丢失。
其次,该方法直接使用 pubSocket?.send(message)。在 PUB/SUB 架构中,不会立即收到确认或响应。send 调用要么在本地成功,要么抛出异常。实际的数据传输语义取决于对方是否已订阅并已连接。
第三,错误被限定为 ZMQException。这样可以确保捕获的异常只针对套接字发送操作的实际预期结果,而不是吞掉所有异常。
如果之后将其扩展为请求响应模式,API 的结构就会发生改变。在 Android 端,你可能需要将 PUB 替换为 REQ,并引入阻塞式接收或返回值的挂起函数。对于当前的设计而言,即发即弃的发送方式最为合适。
清理、生命周期以及安全地关闭客户端
在 Android 上,资源管理至关重要。ZeroMQ 上下文及其套接字可能在底层持有原生资源,除非你主动关闭,否则接收循环可能会在创建它的 UI 组件消失后继续运行很长时间。
因此,此客户端实现了 Closeable 接口,并将所有清理逻辑放在 close() 函数中。
以下是实际使用的实现:
kotlin
override fun close() {
try {
isRunning = false
receiveJob?.cancel()
subSocket?.close()
pubSocket?.close()
context?.close()
Log.d(TAG, "Disconnected from server")
CoroutineScope(Dispatchers.Main).launch { onConnectionChanged?.invoke(false) }
} catch (e: Exception) {
Log.e(TAG, "Error disconnecting from server: ${e.message}", e)
} finally {
receiveJob = null
subSocket = null
pubSocket = null
context = null
}
}
停止接收循环
第一行代码设置了 isRunning = false。这足以使 startReceiving() 中的 while (isRunning && subSocket != null) 循环自然退出。
然后,receiveJob?.cancel() 添加了第二个关闭机制。取消操作至关重要,因为 recvStr() 是阻塞的。实际上,取消操作和关闭套接字同时进行,有助于协程展开,而不是无限期地等待。
释放套接字和上下文
套接字会先关闭,然后是上下文:
-
subSocket?.close() -
pubSocket?.close() -
context?.close()
这样的顺序保证了清理过程的可预测性。在这样的系统中,需要避免在上下文已经消失后,套接字仍然处于活动状态。
在主线程上报告状态变更
断开连接后,客户端通过以下方式报告连接状态:
kotlin
CoroutineScope(Dispatchers.Main).launch { onConnectionChanged?.invoke(false) }
这与 connect() 中使用的方法一致:状态更新会路由回主调度器,以便调用者可以安全地更新 UI 状态。
最后,finally 代码块会将引用设置为 null。这与其说是为了确保正确性,不如说是为了明确地"关闭"对象,使其可以被垃圾回收,而不会留下残留引用。
结论
这种设置之所以有效,是因为它缩小了问题的范围,并将消息传递视为基础设施,而不是架构声明。我们有一个需要广播更新的 Linux 服务,以及一个需要快速响应并偶尔发布消息的 Android 应用。ZeroMQ 以极低的开销满足了我们的需求,无需引入代理、HTTP 层或更复杂的 RPC 栈。
ZeroMqClient 的设计非常精简。它拥有 ZeroMQ 上下文和套接字,所有阻塞操作都在 Dispatchers.IO 上完成,并且只暴露应用其他部分所需的功能:connect()、sendMessage()、消息回调和连接状态回调。生命周期通过 Closeable 显式控制,清理工作也集中进行,因此套接字不会在创建它们的组件之后仍然存在。
如果你正在考虑类似的方案,需要记住的主要权衡是,发布/订阅模式并非请求/响应。你优化的是流式更新和松耦合,而不是确认和保证交付。这非常适合本地低延迟通信,但这仍然是一个需要你有意做出的设计选择。
在我们的案例中,这个选择是值得的。我们在 Linux 服务和 Android 应用之间建立了一个简洁、轻量级的通道,并且我们保持了实现的简洁性,使其易于调试和维护。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!