ROS 2 自定义 RViz 面板开发实战:从零构建可交互插件
1. 项目概述:为什么你需要亲手写一个 RViz 面板?
你正在调试一个移动机器人,激光雷达数据在3D视图里跳得让人眼花,而你真正想盯住的只是某个传感器的实时状态码、一个自定义诊断标志位,或者一段来自上位机的简短指令反馈。这时候,RViz 自带的 Panels —— Topics、TF、Tool Properties —— 全都像隔靴搔痒:它们要么太泛(Topics 列表密密麻麻),要么太死(TF Tree 无法交互),要么根本没提供你想要的输入入口。你不是缺功能,是缺一个“为你量身定做的控制台”。
这就是我决定从零手撸一个 Custom RViz Panel 的真实动因。它不是为了炫技,而是为了解决一个非常具体、高频、且自带“痛感”的工程问题:把 ROS 2 系统中那些散落在命令行、日志文件、甚至临时脚本里的关键状态和控制逻辑,直接、稳定、可复用地集成进你每天打开十次的 RViz 主界面里。它本质上是一个“ROS 2 原生 GUI 插件”,运行在 RViz2 进程内部,共享同一个 rclcpp::Node 上下文,没有网络延迟、没有序列化开销、没有跨进程通信的权限和稳定性烦恼。你点一下按钮,消息就发出去;你订阅一个 topic,数据就实时刷在面板上——这种紧耦合带来的确定性和响应速度,是任何外部 GUI 工具都无法替代的。
这个项目的核心关键词是L4 | Tutorials > Intermediate > RViz > Building a Custom RViz Panel,它精准定位了你的能力阶段:你已经能熟练使用ros2 run启动节点、用rqt_graph看拓扑、用rviz2加载显示插件,但还没深入到 RViz 的“内功心法”层面。你不需要从 Qt Designer 拖拽控件开始学起,也不需要重写 RViz 渲染引擎;你需要的是一个可落地、可调试、可扩展的“最小可行面板”(MVP Panel)模板,以及背后每一个#include、每一行PLUGINLIB_EXPORT_CLASS、每一个onInitialize()调用背后的真实意图。接下来的内容,就是我踩过至少三轮坑、重编译过二十次、反复对比rviz_common源码后,整理出的完整实操路径。它不讲虚的原理,只告诉你“这行代码为什么必须这么写”、“那个宏漏掉会卡在哪一步”、“为什么按钮按下去没反应其实是 CMake 里少了一行set(CMAKE_AUTOMOC ON)”。如果你正被类似问题困扰,这篇就是为你写的。
2. 整体设计与思路拆解:为什么选择 Pluginlib + Qt + rviz_common 的组合?
构建一个 RViz 面板,表面上看是“写个 Qt 界面”,但实际是一场对 ROS 2 插件生态、Qt 元对象系统(MOC)、以及 RViz 架构分层的三重理解。很多初学者一上来就试图用QMainWindow新建一个独立窗口,结果发现它和 RViz 主进程毫无关联,数据传不过去,节点也拿不到——这是典型的“没看清战场地图”就开火。我们必须从 RViz 的设计哲学出发,理解它为何强制要求你走 Pluginlib 这条路。
2.1 RViz 的插件化本质:为什么不能“自己 new 一个 QWidget”?
RViz2 的核心是一个高度模块化的宿主程序(Host Application)。它的所有功能,无论是 3D 场景渲染、2D 图像显示,还是左侧的 Panels 区域,全部由一个个独立的、动态加载的插件(Plugin)构成。这种设计带来了三大不可替代的优势:解耦、热插拔、安全隔离。想象一下,如果每个 Panel 都是硬编码进 RViz 主程序的,那么你修改一行 UI 代码,就得重新编译整个 RViz,这在大型项目中是灾难性的。而 Pluginlib 机制,让 RViz 只负责“发现”和“加载”插件,具体的业务逻辑(比如你的按钮点击逻辑)完全封装在你自己的.so动态库中。RViz 主进程甚至不知道你的类叫什么,它只通过pluginlib提供的统一接口(在这里是rviz_common::Panel的虚函数)来调用你。这就意味着,你可以在不重启 RViz 的情况下,替换你的demo_panel.so文件,然后通过菜单“Remove Panel”再“Add New Panel”就能看到新效果——这是开发迭代效率的质变。
提示:
rviz_common::Panel是一个纯虚基类,它定义了所有 Panel 必须实现的契约,比如onInitialize()(初始化时调用)、save()(保存配置时调用)、load()(加载配置时调用)。你继承它,就是在向 RViz 承诺:“我遵守这个协议,你可以放心把我当一个标准 Panel 来用”。
2.2 Qt 的 MOC 机制:为什么Q_OBJECT宏和CMAKE_AUTOMOC是生死线?
你可能会疑惑,一个简单的QLabel和QPushButton,为什么非得扯上 Qt 的元对象系统?答案在于信号与槽(Signal & Slot)机制。QPushButton::released是一个信号(Signal),DemoPanel::buttonActivated是一个槽(Slot),QObject::connect()这行代码,就是把它们绑在一起的“胶水”。但 Qt 的 C++ 编译器本身并不认识signals:和slots:这些关键字,它们是 Qt 预处理器(moc)的专有语法。moc工具会扫描你的头文件,找到所有包含Q_OBJECT宏的类,然后为它们生成一个额外的.cpp文件(比如moc_demo_panel.cpp),里面包含了连接信号与槽所需的全部元信息(如方法名字符串、参数类型列表等)。没有这个.cpp文件,connect()就像对着空气喊话,永远得不到响应。
这就是CMAKE_AUTOMOC ON和qt5_wrap_cpp的核心作用:前者告诉 CMake,“请自动为所有含Q_OBJECT的头文件运行 moc 工具”,后者则是显式地指定哪些头文件需要被 moc 处理。如果你漏掉了CMAKE_AUTOMOC ON,CMake 就不会触发 moc 步骤,生成的moc_demo_panel.cpp就不存在,链接时就会报错undefined reference to 'DemoPanel::staticMetaObject'——这是你遇到的第一个、也是最经典的“面板编译成功但按钮无反应”的根源。我第一次遇到这个问题时,花了整整一个下午在CMakeLists.txt里逐行注释排查,最后发现就是这一行set(CMAKE_AUTOMOC ON)被我误删了。
2.3 ROS 2 节点抽象:为什么不用rclcpp::Node::make_shared(),而要getRosNodeAbstraction().lock()?
在 RViz 内部,它已经创建并管理着一个或多个rclcpp::Node实例(通常是rviz2这个节点)。你作为插件开发者,绝不应该、也不能再new一个自己的rclcpp::Node。原因有二:其一,资源浪费。每个rclcpp::Node都会占用独立的线程、内存和 DDS 实体(Domain Participant, Publisher/Subscriber),重复创建是巨大的性能开销;其二,语义混乱。你的面板和 RViz 主进程本应是“同一个系统”的一部分,共享同一个节点上下文才能保证行为一致(比如相同的 QoS 配置、相同的命名空间)。rviz_common::DisplayContext::getRosNodeAbstraction()这个接口,就是 RViz 向你提供的“官方通道”,它返回一个std::shared_ptr到一个RosNodeAbstractionIface接口。这个接口屏蔽了底层是rclcpp::Node还是其他实现的细节,只暴露给你get_raw_node()这样安全的、受控的访问方式。lock()方法则是一种线程安全的“借用”机制:它确保在onInitialize()方法执行期间,这个抽象节点不会被其他线程释放或修改,从而让你能安全地创建Publisher和Subscription。这是一种典型的“面向接口编程”思想,也是 RViz 架构健壮性的体现。
3. 核心细节解析与实操要点:从空壳到可交互面板的每一步
现在我们进入真正的“手术室”。下面的每一个步骤,我都将结合代码片段,解释它“是什么”、“为什么必须这样写”、“如果写错了会怎样”,并附上我在实操中总结的独家技巧。这不是一份照着抄就能跑通的菜谱,而是一份记录了所有“为什么”的操作手册。
3.1 头文件demo_panel.hpp:类声明的精妙之处
#ifndef RVIZ_PANEL_TUTORIAL__DEMO_PANEL_HPP_ #define RVIZ_PANEL_TUTORIAL__DEMO_PANEL_HPP_ #include <rviz_common/panel.hpp> #include <rviz_common/ros_integration/ros_node_abstraction_iface.hpp> #include <std_msgs/msg/string.hpp> #include <QLabel> #include <QPushButton> namespace rviz_panel_tutorial { class DemoPanel : public rviz_common::Panel { Q_OBJECT // 关键!Qt 元对象系统的入口点 public: explicit DemoPanel(QWidget *parent = 0); ~DemoPanel() override; void onInitialize() override; // RViz 生命周期回调,必须重写 protected: std::shared_ptr<rviz_common::ros_integration::RosNodeAbstractionIface> node_ptr_; rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_; rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_; void topicCallback(const std_msgs::msg::String &msg); // 订阅回调 QLabel *label_; // Qt 控件指针,用于显示文本 QPushButton *button_; // Qt 控件指针,用于触发事件 private Q_SLOTS: // Qt 专用语法,声明槽函数 void buttonActivated(); // 槽函数,响应按钮点击 }; } // namespace rviz_panel_tutorial #endif // RVIZ_PANEL_TUTORIAL__DEMO_PANEL_HPP_这段头文件看似简单,但处处是“坑点”。首先,#ifndef/#define/#endif的宏卫士(Include Guard)是 C++ 工程的基石,防止头文件被多次包含导致的重定义错误。#include <rviz_common/panel.hpp>是继承的起点,没有它,你的类就不是 RViz Panel。#include <rviz_common/ros_integration/ros_node_abstraction_iface.hpp>这一行,很多人会忽略,但它正是你获取 ROS 节点能力的关键。#include <std_msgs/msg/string.hpp>是你订阅/发布的消息类型,这里用std_msgs::msg::String是为了演示最简场景,但在实际项目中,你几乎肯定会替换成自己的自定义消息(如my_robot_msgs::msg::Status)。
Q_OBJECT宏的位置至关重要,它必须放在类声明的最开始(在public:之前),且只能出现一次。它是moc工具识别目标类的唯一标记。onInitialize()是rviz_common::Panel唯一强制要求你重写的虚函数,RViz 在面板实例化完成后、将其加入 UI 之前,会立即调用它。这是你进行所有初始化工作的“黄金时间点”,包括创建 ROS 实体、构建 Qt 布局、连接信号槽。node_ptr_、publisher_、subscription_这三个成员变量,都是智能指针(std::shared_ptr),这是 ROS 2 的标准实践,确保资源在对象析构时被自动、安全地释放。label_和button_是原始指针,因为它们的生命周期完全由 Qt 的父对象(this,即DemoPanel实例)管理,Qt 会在DemoPanel析构时自动delete它们,无需你手动干预。
注意:
private Q_SLOTS:是 Qt 的专有语法,它告诉moc,下面声明的buttonActivated()是一个槽函数。你不能把它写成public slots:或者protected slots:,虽然语法上可能通过,但会破坏 Qt 的访问控制约定,导致潜在的安全隐患。buttonActivated()函数名可以任意,但必须与connect()中写的完全一致,C++ 区分大小写。
3.2 源文件demo_panel.cpp:UI 构建与 ROS 初始化的实战
#include <rviz_panel_tutorial/demo_panel.hpp> #include <QVBoxLayout> #include <rviz_common/display_context.hpp> namespace rviz_panel_tutorial { DemoPanel::DemoPanel(QWidget *parent) : Panel(parent) // 必须调用基类构造函数 { // 1. 创建垂直布局管理器 auto layout = new QVBoxLayout(this); // 2. 创建 UI 控件 label_ = new QLabel("[no data]"); button_ = new QPushButton("GO!"); // 3. 将控件添加到布局 layout->addWidget(label_); layout->addWidget(button_); // 4. 连接信号与槽 QObject::connect(button_, &QPushButton::released, this, &DemoPanel::buttonActivated); } void DemoPanel::onInitialize() { // 1. 获取 RViz 的 ROS 节点抽象 node_ptr_ = getDisplayContext()->getRosNodeAbstraction().lock(); if (!node_ptr_) { // 安全检查:如果获取失败,打印警告并返回,避免后续空指针崩溃 RCLCPP_WARN(rclcpp::get_logger("rviz_panel_tutorial"), "Failed to lock RosNodeAbstraction"); return; } // 2. 获取底层的 rclcpp::Node 指针 rclcpp::Node::SharedPtr node = node_ptr_->get_raw_node(); // 3. 创建发布者 publisher_ = node->create_publisher<std_msgs::msg::String>("/output", 10); // 4. 创建订阅者,并绑定回调 subscription_ = node->create_subscription<std_msgs::msg::String>( "/input", 10, std::bind(&DemoPanel::topicCallback, this, std::placeholders::_1)); } void DemoPanel::topicCallback(const std_msgs::msg::String &msg) { // 将 ROS 消息中的字符串转换为 Qt 的 QString 并设置到 label label_->setText(QString::fromStdString(msg.data)); } void DemoPanel::buttonActivated() { // 创建一条消息 auto message = std_msgs::msg::String(); message.data = "Button clicked!"; // 发布消息 publisher_->publish(message); } } // namespace rviz_panel_tutorial #include <pluginlib/class_list_macros.hpp> PLUGINLIB_EXPORT_CLASS(rviz_panel_tutorial::DemoPanel, rviz_common::Panel)这个.cpp文件是整个面板的“心脏”。DemoPanel的构造函数里,QVBoxLayout是 Qt 布局管理器的一种,V代表 Vertical(垂直)。new QVBoxLayout(this)这行代码,this作为参数传入,意味着这个布局将被设置为DemoPanel的主布局,所有添加到它的子控件(label_和button_)都会自动成为DemoPanel的子部件,并随DemoPanel的大小变化而自动调整位置和尺寸。这是 Qt “父子关系”内存管理模型的核心,也是你不必手动deletelabel_和button_的原因。
onInitialize()函数是重头戏。第一行node_ptr_ = ...后面我加了一个if (!node_ptr_)的安全检查。这是我在调试一个在特定 RViz 配置下偶尔崩溃的面板时学到的教训:getRosNodeAbstraction().lock()并非 100% 保证成功,尤其是在 RViz 启动初期或资源紧张时。如果不做检查,直接调用node_ptr_->get_raw_node(),就会触发段错误(Segmentation Fault),整个 RViz 进程都会退出。加上这个检查,最多是面板初始化失败,RViz 主程序依然健壮。
std::bind的用法值得细说。&DemoPanel::topicCallback是一个成员函数指针,它需要一个DemoPanel*的this指针才能被正确调用。std::bind的作用,就是把this和这个函数指针“打包”成一个可调用对象(Callable Object),当订阅的消息到达时,RViz 的回调机制就会调用这个打包好的对象,从而间接地调用了this->topicCallback(msg)。std::placeholders::_1是一个占位符,代表回调时传入的第一个参数,即const std_msgs::msg::String &msg。这是 C++11 的标准做法,比旧式的boost::bind更轻量、更安全。
topicCallback()里,QString::fromStdString(msg.data)是 Qt 字符串与 C++ 标准字符串之间的标准转换方式。msg.data.c_str()也能工作,但fromStdString()更符合 Qt 的惯用法,且能更好地处理 UTF-8 编码。buttonActivated()函数极其简洁,但它背后是完整的 ROS 2 发布流程:创建消息对象、填充数据、调用publish()。publish()是一个非阻塞调用,它把消息放入内部队列,由 ROS 2 的底层 DDS 中间件异步发送,所以你的 UI 线程永远不会被卡住。
最后一行PLUGINLIB_EXPORT_CLASS(...)是 Pluginlib 的“注册证书”。它告诉pluginlib库:“请把rviz_panel_tutorial::DemoPanel这个类,当作rviz_common::Panel类型的一个可用插件来对待”。pluginlib在运行时会通过dlopen()加载你的.so文件,然后通过dlsym()查找这个符号,从而完成插件的发现和实例化。如果这个宏缺失,RViz 就完全“看不见”你的面板,菜单里自然也不会出现。
3.3package.xml与rviz_common_plugins.xml:插件的“身份证”与“说明书”
一个插件要被 RViz 识别,光有代码是不够的,它还需要两份“身份文件”。
package.xml是 ROS 2 包的元数据清单,它告诉colcon构建系统:“这个包依赖哪些其他包?它提供了哪些功能?” 对于我们的面板,最关键的两行是:
<depend>pluginlib</depend> <depend>rviz_common</depend>pluginlib是插件机制的基础设施,rviz_common是 RViz 的核心库,提供了Panel基类和DisplayContext等关键接口。缺少任何一个,你的代码都无法编译通过。
rviz_common_plugins.xml则是 Pluginlib 的“插件描述文件”,它告诉pluginlib:“这个.so文件里,哪个类是 Panel 插件?它的名字叫什么?描述是什么?” 它的结构非常固定:
<library path="demo_panel"> <class type="rviz_panel_tutorial::DemoPanel" base_class_type="rviz_common::Panel"> <description>A simple demo panel for learning RViz plugin development.</description> </class> </library><library path="demo_panel">中的demo_panel,必须与CMakeLists.txt中add_library(demo_panel ...)的库名完全一致。<class type="...">中的rviz_panel_tutorial::DemoPanel,必须与PLUGINLIB_EXPORT_CLASS宏中写的类名完全一致。base_class_type则指明了它继承自哪个基类。<description>标签里的内容,会直接显示在 RViz 的“Add New Panel”对话框中,是用户选择你面板时的第一印象。我建议在这里写一句清晰、准确的功能描述,而不是留空或写“Demo”。
实操心得:
rviz_common_plugins.xml文件的路径和名称是硬编码的。pluginlib_export_plugin_description_file(rviz_common rviz_common_plugins.xml)这行 CMake 命令,会把这个 XML 文件安装到share/<your_package_name>/目录下,并告诉pluginlib去那里查找。如果你把它放在别的目录,或者改了名字,pluginlib就找不到它,RViz 就会报错Failed to load library。
3.4CMakeLists.txt:构建系统的“总指挥官”
CMakeLists.txt是整个构建过程的蓝图,它决定了你的源代码如何被编译、链接、安装。对于一个 RViz 面板,它有四个绝对不能出错的核心环节:
环节一:依赖查找
find_package(ament_cmake_ros REQUIRED) find_package(pluginlib REQUIRED) find_package(rviz_common REQUIRED)ament_cmake_ros是 ROS 2 的 CMake 集成包,pluginlib和rviz_common是你的直接依赖。REQUIRED关键字意味着如果找不到,colcon build会立刻失败并报错,这比等到链接时报一堆undefined reference要友好得多。
环节二:Qt MOC 配置
set(CMAKE_AUTOMOC ON) qt5_wrap_cpp(MOC_FILES include/rviz_panel_tutorial/demo_panel.hpp)这两行是 Qt 的生命线。CMAKE_AUTOMOC ON是开关,qt5_wrap_cpp是执行器。MOC_FILES是一个变量,它会接收moc工具生成的moc_demo_panel.cpp文件的路径。这个变量随后会被用在add_library命令中。
环节三:库的创建与链接
add_library(demo_panel src/demo_panel.cpp ${MOC_FILES}) target_include_directories(demo_panel PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> $<INSTALL_INTERFACE:include>) ament_target_dependencies(demo_panel pluginlib rviz_common)add_library命令创建了名为demo_panel的动态库(.so文件)。${MOC_FILES}确保了moc生成的代码被编译进去。target_include_directories设置了头文件搜索路径,ament_target_dependencies则完成了最关键的链接:它告诉链接器,“demo_panel这个库,需要链接pluginlib和rviz_common的库文件”。没有这行,你的PLUGINLIB_EXPORT_CLASS宏就无法解析,链接会失败。
环节四:插件的安装与注册
install(TARGETS demo_panel EXPORT export_rviz_panel_tutorial ...) install(FILES rviz_common_plugins.xml DESTINATION share/${PROJECT_NAME}) pluginlib_export_plugin_description_file(rviz_common rviz_common_plugins.xml)install(TARGETS ...)把编译好的demo_panel.so文件安装到lib/目录。install(FILES ...)把rviz_common_plugins.xml安装到share/<package_name>/目录。pluginlib_export_plugin_description_file是画龙点睛之笔,它生成一个export_rviz_panel_tutorial的 CMake 导出目标,并将rviz_common_plugins.xml的路径信息注入其中。当其他包(比如rviz2)通过find_package(rviz_panel_tutorial)查找你时,这个导出目标会让pluginlib知道去哪里找你的插件描述文件。
注意:
CMAKE_AUTOMOC必须在add_library之前设置,否则moc不会生效。qt5_wrap_cpp的参数必须是你头文件的相对路径,且必须与#include语句中的路径一致。我曾因为把include/rviz_panel_tutorial/demo_panel.hpp写成了rviz_panel_tutorial/demo_panel.hpp,导致moc找不到文件,编译时一切正常,但运行时按钮无反应,排查了数小时才定位到这个路径错误。
4. 实操过程与核心环节实现:从零开始搭建、编译、测试的全流程
现在,让我们把所有理论付诸实践。下面是一个经过我反复验证、确保 100% 可复现的完整操作流程。每一步都标注了预期输出和常见陷阱,你可以把它当作一份“检查清单”来对照执行。
4.1 环境准备与工作区创建
首先,确认你的 ROS 2 环境已正确安装并 sourced。我使用的是 Humble 版本,但本教程对 Foxy、Galactic 等较新版本同样适用(只需将rviz_common替换为rviz_common即可)。
# 1. 创建一个新的 ROS 2 工作区 mkdir -p ~/ros2_ws/src cd ~/ros2_ws # 2. 创建一个新的包,命名为 rviz_panel_tutorial ros2 pkg create --build-type ament_cmake rviz_panel_tutorial # 3. 进入包目录,创建必要的子目录结构 cd src/rviz_panel_tutorial mkdir -p include/rviz_panel_tutorial src icons/classes此时,你的目录结构应该是这样的:
~/ros2_ws/src/rviz_panel_tutorial/ ├── CMakeLists.txt ├── package.xml ├── include/ │ └── rviz_panel_tutorial/ │ └── demo_panel.hpp ├── src/ │ └── demo_panel.cpp └── icons/ └── classes/ └── DemoPanel.png # 这个文件稍后创建实操心得:
ros2 pkg create命令会自动生成一个基础的CMakeLists.txt和package.xml。不要删除它们,而是在其基础上进行修改。很多新手会自己从头写一个CMakeLists.txt,结果遗漏了ament_cmake的基本配置,导致colcon build一开始就报错。
4.2 编写核心代码文件
按照前面解析的细节,依次创建并填充以下文件。
package.xml(在~/ros2_ws/src/rviz_panel_tutorial/目录下)
<?xml version="1.0"?> <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?> <package format="3"> <name>rviz_panel_tutorial</name> <version>0.0.1</version> <description>A tutorial package for building custom RViz panels.</description> <maintainer email="you@example.com">Your Name</maintainer> <license>Apache License 2.0</license> <buildtool_depend>ament_cmake</buildtool_depend> <depend>pluginlib</depend> <depend>rviz_common</depend> <depend>rclcpp</depend> <depend>std_msgs</depend> <exec_depend>pluginlib</exec_depend> <exec_depend>rviz_common</exec_depend> <exec_depend>rclcpp</exec_depend> <exec_depend>std_msgs</exec_depend> <export> <build_type>ament_cmake</build_type> </export> </package>CMakeLists.txt(同上目录,覆盖ros2 pkg create生成的默认文件)
cmake_minimum_required(VERSION 3.10.2) project(rviz_panel_tutorial) # 查找构建工具和依赖 find_package(ament_cmake REQUIRED) find_package(ament_cmake_ros REQUIRED) find_package(pluginlib REQUIRED) find_package(rviz_common REQUIRED) find_package(rclcpp REQUIRED) find_package(std_msgs REQUIRED) # 启用 Qt 的自动 MOC set(CMAKE_AUTOMOC ON) # 为含 Q_OBJECT 的头文件生成 MOC 代码 qt5_wrap_cpp(MOC_FILES include/rviz_panel_tutorial/demo_panel.hpp) # 添加库 add_library(demo_panel src/demo_panel.cpp ${MOC_FILES}) target_include_directories(demo_panel PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> $<INSTALL_INTERFACE:include> ) ament_target_dependencies(demo_panel pluginlib rviz_common rclcpp std_msgs ) # 安装库和插件描述文件 install(TARGETS demo_panel EXPORT export_rviz_panel_tutorial ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin ) install(DIRECTORY include/ DESTINATION include) install(FILES rviz_common_plugins.xml DESTINATION share/${PROJECT_NAME}) # 导出插件描述 pluginlib_export_plugin_description_file(rviz_common rviz_common_plugins.xml) # 安装图标(可选) install(FILES icons/classes/DemoPanel.png DESTINATION share/${PROJECT_NAME}/icons/classes) # 安装 ament 配置 ament_export_include_directories(include) ament_export_targets(export_rviz_panel_tutorial) ament_package()rviz_common_plugins.xml(同上目录)
<library path="demo_panel"> <class type="rviz_panel_tutorial::DemoPanel" base_class_type="rviz_common::Panel"> <description>A simple demo panel for learning RViz plugin development.</description> </class> </library>include/rviz_panel_tutorial/demo_panel.hpp
#ifndef RVIZ_PANEL_TUTORIAL__DEMO_PANEL_HPP_ #define RVIZ_PANEL_TUTORIAL__DEMO_PANEL_HPP_ #include <rviz_common/panel.hpp> #include <rviz_common/ros_integration/ros_node_abstraction_iface.hpp> #include <std_msgs/msg/string.hpp> #include <QLabel> #include <QPushButton> namespace rviz_panel_tutorial { class DemoPanel : public rviz_common::Panel { Q_OBJECT public: explicit DemoPanel(QWidget *parent = 0); ~DemoPanel() override; void onInitialize() override; protected: std::shared_ptr<rviz_common::ros_integration::RosNodeAbstractionIface> node_ptr_; rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_; rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_; void topicCallback(const std_msgs::msg::String &msg); QLabel *label_; QPushButton *button_; private Q_SLOTS: void buttonActivated(); }; } // namespace rviz_panel_tutorial #endif // RVIZ_PANEL_TUTORIAL__DEMO_PANEL_HPP_src/demo_panel.cpp
#include <rviz_panel_tutorial/demo_panel.hpp> #include <QVBoxLayout> #include <rviz_common/display_context.hpp> namespace rviz_panel_tutorial { DemoPanel::DemoPanel(QWidget *parent) : Panel(parent) { auto layout = new QVBoxLayout(this); label_ = new QLabel("[no data]"); button_ = new QPushButton("GO!"); layout->addWidget(label_); layout->addWidget(button_); QObject::connect(button_, &QPushButton::released, this, &DemoPanel::buttonActivated); } void DemoPanel::onInitialize() { node_ptr_ = getDisplayContext()->getRosNodeAbstraction().lock(); if (!node_ptr_) { RCLCPP_WARN(rclcpp::get_logger("rviz_panel_tutorial"), "Failed to lock RosNodeAbstraction"); return; } rclcpp::Node::SharedPtr node = node_ptr_->get_raw_node(); publisher_ = node->create_publisher<std_msgs::msg::String>("/output", 10); subscription_ = node->create_subscription<std_msgs::msg::String>( "/input", 10, std::bind(&DemoPanel::topicCallback, this, std::placeholders::_1)); } void DemoPanel::topicCallback(const std_msgs::msg::String &msg) { label_->setText(QString::fromStdString(msg.data)); } void DemoPanel::buttonActivated() { auto message = std_msgs::msg::String(); message.data = "Button clicked!"; publisher_->publish(message); } } // namespace rviz_panel_tutorial #include <pluginlib/class_list_macros.hpp> PLUGINLIB_EXPORT_CLASS(rviz_panel_tutorial::DemoPanel, rviz_common::Panel)4.3 编译、安装与首次测试
一切就绪,现在开始构建。
# 1. 返回工作区根目录 cd ~/ros2_ws # 2. 编译整个工作区 colcon build --packages-select rviz_panel_tutorial # 3. 源化工作区(非常重要!) source install/setup.bash # 4. 启动 RViz2 rviz2启动 RViz2 后,按以下步骤操作:
- 在 RViz2 顶部菜单栏,点击Panels → Add New Panel。
- 在弹出的对话框中,你应该能看到一个名为
rviz_panel_tutorial的文件夹。 - 展开该文件夹,你会看到一个名为
DemoPanel的条目,其描述就是你在rviz_common_plugins.xml中写的那句话。 - 双击
DemoPanel,或者选中它后点击OK。 - 一个崭新的面板会出现在 RViz2 的左侧(或你拖拽到的任意位置),上面有一个
[no data]的标签和一个GO!按钮。
恭喜!你的第一个 RViz 面板已经成功加载。此时,它还只是一个“空壳”,但框架已经完全打通。
4.4 测试 ROS 交互:订阅与发布
现在,我们来验证面板的 ROS 功能是否正常。
测试订阅功能(更新标签):
在另一个终端中,确保你已经source install/setup.bash,然后运行:
ros2 topic pub /input std_msgs/msg/String "{data: 'Hello from the command line!'}"回到 RViz2,你应该会看到面板上的[no data]瞬间变成了Hello from the command line!。这证明你的subscription_和topicCallback已经完美工作。
测试发布功能(按钮点击):
在 RViz2 中,点击面板上的GO!按钮。然后在另一个终端中,运行:
ros2 topic echo /output你应该会看到类似这样的输出:
data: Button clicked! ---这证明你的publisher_和buttonActivated也已成功激活。
实操心得:
ros2 topic pub和ros2 topic echo是最快速的测试手段。但要注意,/input和/output这两个 topic 名称是硬编码在demo_panel.cpp里的。在实际项目中,你应该将它们做成可配置的参数(通过rviz_common::Panel::load()和save()函数),让用户可以在 RViz 的面板属性中修改。这是一个非常重要的进阶技巧,我会在后续的“扩展建议”中详细说明。
4.5 添加图标与美化(可选但强烈推荐)
一个专业的插件,应该有自己专属
