4. 订阅者节点实现:编写Python订阅者节点、回调函数机制、接收并处理消息
好,咱们接着聊。上一节我们把发布者节点搭起来了,地图数据能发出去。但光发没人收,那不成单口相声了?这一节,我们就来写一个订阅者节点,让它把地图数据接住,再处理一下。
说白了,订阅者节点就是“听”的那一方。它不主动说话,但耳朵一直竖着。一旦有话题数据过来,它立刻响应。
4.1 订阅者节点的基本结构
先看一个最简的订阅者节点长什么样。我习惯先把骨架搭好,再往里填逻辑。
import rclpy
from rclpy.node import Node
from nav_msgs.msg import OccupancyGrid
class MapSubscriber(Node):
def __init__(self):
super().__init__('map_subscriber')
self.subscription = self.create_subscription(
OccupancyGrid,
'/map',
self.map_callback,
10
)
self.subscription # 防止被垃圾回收
def map_callback(self, msg):
self.get_logger().info('收到地图数据!')
# 这里处理消息
def main(args=None):
rclpy.init(args=args)
node = MapSubscriber()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
嗯,代码不长,但每个点都很关键。你想想看,create_subscription 里四个参数:消息类型、话题名、回调函数、队列大小。跟发布者那边的 create_publisher 是对称的。
核心要点:订阅者不需要设置“发布频率”,它完全是被动的。数据来了就处理,没来就等着。这叫事件驱动。
4.2 回调函数机制——ROS2的“中断服务”
回调函数是订阅者的灵魂。我刚开始学ROS2时,总觉得回调函数有点“玄学”——它什么时候被调用?谁调用的?
其实很简单:当有消息到达话题时,ROS2的底层会自动调用你注册的那个函数。你不需要手动去轮询检查有没有新数据。
为什么会这样?因为 rclpy.spin(node) 这行代码在背后做了大量工作。它让节点进入一个循环,不断检查有没有新消息、新服务请求、新定时器触发。一旦有消息,它就把消息塞进回调函数里。
我的经验:回调函数里不要做耗时操作。比如写文件、发网络请求、复杂计算。我曾经在一个回调里做图像处理,结果地图数据一多,回调被阻塞,后面的消息全丢了。后来我把处理逻辑扔到另一个线程里,回调只负责“收快递”,不负责“拆快递”。
4.3 接收并处理地图消息
地图消息的类型是 OccupancyGrid。它里面有什么?我列一下常用字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| header | std_msgs/Header | 时间戳、坐标系ID |
| info | MapMetaData | 地图分辨率、宽高、原点坐标 |
| data | int8[] | 栅格数据,-1未知,0空闲,100占用 |
收到地图后,我们一般会做这几件事:
- 检查时间戳——地图是不是太旧了?
- 解析info——知道地图有多大、每个格子对应现实世界多少米
- 遍历data——把栅格数据转成我们能用的格式
看一个实际的处理示例:
def map_callback(self, msg):
# 1. 检查地图是否有效
if msg.info.width == 0 or msg.info.height == 0:
self.get_logger().warn('收到空地图,跳过')
return
# 2. 提取地图参数
resolution = msg.info.resolution # 米/像素
width = msg.info.width
height = msg.info.height
origin_x = msg.info.origin.position.x
origin_y = msg.info.origin.position.y
self.get_logger().info(
f'地图尺寸: {width}x{height}, 分辨率: {resolution} m/px'
)
# 3. 处理栅格数据
# 这里只做统计,实际项目中会做路径规划、障碍物提取等
occupied = 0
free = 0
unknown = 0
for cell in msg.data:
if cell == 100:
occupied += 1
elif cell == 0:
free += 1
else:
unknown += 1
self.get_logger().info(
f'占用: {occupied}, 空闲: {free}, 未知: {unknown}'
)
注意:地图数据可能很大。一张 1000x1000 的地图,data 数组就有 100 万个元素。如果你在回调里做复杂循环,会严重影响节点响应速度。我建议:回调里只做轻量级处理,需要重计算的任务放到定时器或独立线程里。
4.4 订阅者节点的避坑指南
写订阅者节点时,有几个坑我踩过,分享给你:
- 队列大小别设太小——如果处理速度跟不上接收速度,队列满了会丢消息。地图数据一般设 10 就够,但高频数据(比如激光雷达)可能需要 100 以上。
- 别忘了 self.subscription——Python 的垃圾回收机制可能会把 subscription 对象回收掉。加一行
self.subscription就是为了防止这个。 - 回调函数不要抛异常——一旦回调里抛出未捕获的异常,整个节点会挂掉。我习惯在回调里加 try-except,至少保证节点不崩。
4.5 知识体系总览
下面这张图把订阅者节点的核心逻辑串起来了。你可以看到,从话题数据流入,到回调函数触发,再到消息处理,是一条清晰的链路。
嗯,到这里,订阅者节点的核心内容就讲完了。你可能会问:回调函数里能不能修改全局变量?当然可以,但要注意线程安全。ROS2 的回调默认是在同一个线程里执行的,所以一般没问题。但如果你用了多线程执行器,那就得加锁了。
我个人习惯是:订阅者节点只做数据接收和初步解析,真正的业务逻辑(比如导航、建图)放到单独的模块里。这样职责清晰,调试也方便。
一句话总结:订阅者节点 = 话题监听 + 回调函数 + 消息处理。回调是核心,别让它变重。
好,这一节就到这里。代码你可以直接拿去跑,把发布者节点和订阅者节点一起启动,看看地图数据能不能顺利传过来。如果遇到问题,检查一下话题名是否匹配、消息类型是否一致——这两个是新手最容易翻车的地方。