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是一个信号SignalDemoPanel::buttonActivated是一个槽SlotQObject::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 ONCMake 就不会触发 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_ptrrviz_common::ros_integration::RosNodeAbstractionIface node_ptr_; rclcpp::Publisherstd_msgs::msg::String::SharedPtr publisher_; rclcpp::Subscriptionstd_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.cppUI 构建与 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_publisherstd_msgs::msg::String(/output, 10); // 4. 创建订阅者并绑定回调 subscription_ node-create_subscriptionstd_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。这是 C11 的标准做法比旧式的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构建系统“这个包依赖哪些其他包它提供了哪些功能” 对于我们的面板最关键的两行是dependpluginlib/depend dependrviz_common/dependpluginlib是插件机制的基础设施rviz_common是 RViz 的核心库提供了Panel基类和DisplayContext等关键接口。缺少任何一个你的代码都无法编译通过。rviz_common_plugins.xml则是 Pluginlib 的“插件描述文件”它告诉pluginlib“这个.so文件里哪个类是 Panel 插件它的名字叫什么描述是什么” 它的结构非常固定library pathdemo_panel class typerviz_panel_tutorial::DemoPanel base_class_typerviz_common::Panel descriptionA simple demo panel for learning RViz plugin development./description /class /librarylibrary pathdemo_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 version1.0? ?xml-model hrefhttp://download.ros.org/schema/package_format3.xsd schematypenshttp://www.w3.org/2001/XMLSchema? package format3 namerviz_panel_tutorial/name version0.0.1/version descriptionA tutorial package for building custom RViz panels./description maintainer emailyouexample.comYour Name/maintainer licenseApache License 2.0/license buildtool_dependament_cmake/buildtool_depend dependpluginlib/depend dependrviz_common/depend dependrclcpp/depend dependstd_msgs/depend exec_dependpluginlib/exec_depend exec_dependrviz_common/exec_depend exec_dependrclcpp/exec_depend exec_dependstd_msgs/exec_depend export build_typeament_cmake/build_type /export /packageCMakeLists.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 pathdemo_panel class typerviz_panel_tutorial::DemoPanel base_class_typerviz_common::Panel descriptionA simple demo panel for learning RViz plugin development./description /class /libraryinclude/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_ptrrviz_common::ros_integration::RosNodeAbstractionIface node_ptr_; rclcpp::Publisherstd_msgs::msg::String::SharedPtr publisher_; rclcpp::Subscriptionstd_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_publisherstd_msgs::msg::String(/output, 10); subscription_ node-create_subscriptionstd_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 添加图标与美化可选但强烈推荐一个专业的插件应该有自己专属