简介基于Qt开发的任务管理器完整项目主要面向Qt初学者、需要课程设计或毕业设计参考的高校开发者。资源共32个文件压缩包仅1.5MB包含设计报告Word文档、2个核心cpp源文件、头文件、ui界面文件、工程pro文件及20张运行截图目录结构清晰便于按文档对照代码学习。源码基于Qt Creator在Ubuntu14.04环境开发界面采用Tab Widget整合系统信息、进程信息、性能三个模块系统信息展示操作系统与硬件数据进程信息列出当前进程性能模块实时显示CPU和内存使用率并通过槽函数响应页面切换自动刷新对应内容。状态栏、菜单栏中的运行新进程和关机模块也均已完成功能完整。已有594人学习下载适合以此掌握Qt布局管理、信号槽机制、进程列表获取与系统性能读取等常用API是一份可直接运行和二次开发的实战参考资料。1. 重新审视“任务管理器”这个需求一个 Qt 项目真正要解决的是什么把一个任务管理器工程装进 zip 压缩包和写一个能跑的 Demo 之间隔着两条很深的坎第一坎是数据从哪来第二坎是界面在多快的频率里刷新。基于 Qt 的任务管理器并不是 Qt 官方的一个一键组件它本质是在 QWidget 或 QML 之上重新组装进程枚举、指标采集、权限处理和表格渲染的一套小系统。如果你只是临时看内存占用系统自带工具确实够用但当你需要定制进程列、批量结束、按公司内网策略隐藏敏感进程或者把工具部署到不能安装额外软件的机器上时自己用 Qt 写一个反而更可控。这篇文章不围绕某个特定源码包讲而是把这类项目拆开讲清楚一个能交付的任务管理器应该怎么设计、怎么写、哪里会翻车。2. 先定数据底座进程枚举与指标采集怎么做界面才不会抖2.1 进程信息最少需要哪些字段先建模再写界面很多人的第一版任务管理器是从 QTableWidget 直接往表格里塞数据写了两天发现刷新一次界面卡半秒原因是数据模型和界面模型混在一起。我一般会先把进程快照定义成一个独立的结构体界面只管显示这个结构体采集模块只负责填这个结构体。struct ProcessSnapshot { qint64 pid; // 进程号列表里不直接显示但作为唯一标识 QString name; // 进程名取可执行文件或命令行首段 QString userName; // 启动进程的用户过滤和展示用 qint64 memoryBytes; // 常驻内存单位字节 double cpuPercent; // 两次采样间隔内的平均占用率 QString commandLine; // 完整命令行调试定位用 };这不是一个严谨的操作系统进程结构体而是任务管理器 UI 需要的最小字段集。pid 一定要单独保留因为进程名会重复pid 才是行的主键。memoryBytes 存字节而不是存“MB”显示层再做一次单位换算否则不同平台的统计口径会在界面层混杂。cpuPercent 不能存瞬时值任务管理器里那个 CPU 列通常是按两次采样间隔计算的均值后面会专门说采样逻辑。结构体定了之后采集模块的接口就非常简单。一个采集器只需要两个能力拿一次快照、结束一个进程。把接口抽出来是为了后续切换平台实现时不改 UI。class IProcessDataSource { public: virtual ~IProcessDataSource() default; // 采集一次快照耗时可能从几十毫秒到几百毫秒 virtual QVectorProcessSnapshot collectOnce() 0; // 结束指定进程返回是否调用成功 virtual bool terminateByPid(qint64 pid) 0; };注意collectOnce()的耗时注释不是写给人看的是写给调用者看的。如果这个函数在大循环里同步执行UI 线程会被拖死。所以接口设计的下一步就是回答一个问题采集动作到底放在哪个线程里。2.2 采集放在工作线程UI 只接收快照常见做法是开一个采集线程或者用QtConcurrent::run把采集动作丢到线程池采集完成后通过信号把快照数组发回主线程。这里最忌讳的是用 QProcess 在 UI 线程里同步执行外部命令比如ps -ef一旦系统进程数多命令执行超过几百毫秒用户就会觉得界面“粘住”了。我在项目里通常这样组织刷新链路// 定时器只负责触发采集不负责刷新 UI m_timer new QTimer(this); m_timer-setInterval(2000); // 默认每 2 秒一次 connect(m_timer, QTimer::timeout, this, TaskManagerWindow::requestSnapshot); // 采集线程完成后发信号 connect(collector, ProcessCollector::snapshotReady, this, TaskManagerWindow::applySnapshot);requestSnapshot里做的只是把采集任务提交出去立即返回。采集完成信号回到主线程后applySnapshot再更新表格。这样做的好处是采集耗时波动不会直接导致界面卡顿2 秒定时器即使上一轮还没结束也能保证 UI 线程不被阻塞。这个方案唯一的坑是快照乱序。如果采集耗时超过定时器间隔可能会有两次采集结果同时排队。我的做法是在采集器内部加一个序号字段界面只应用比当前新且序号连续的快照否则丢弃重来。大部分任务管理器场景不需要这么严格但如果你准备把刷新间隔调到 500 毫秒必须考虑这个边界。2.3 平台差异Qt 没有原生进程枚举常见做法是两套后端很多初学者以为 Qt 自带跨平台进程列表 API实际上标准的 Qt Widgets 模块并没有直接提供“列出所有进程”的接口。常见的落地路线有两条我建议按部署平台二选一。第一条路线是调用系统命令。Windows 侧用系统自带命令行工具输出进程清单再解析Linux/macOS 则用ps命令。优点是代码量小不容易碰到底层数据结构缺点是输出格式随系统语言和版本变化解析时容易踩坑。第二条路线是直接读系统伪文件系统。Linux 下遍历/proc目录里的数字文件夹读stat、status、cmdline文件macOS 则调用系统的进程列表接口。优点是准确、可控、不依赖外部命令缺点是要处理大量边界情况比如进程在读取过程中退出。我实际做的时候采用了折中方案类 Unix 系统优先直接解析/proc这也是 Linux 任务管理器类工具最常见的做法。核心代码如下QVectorProcessSnapshot LinuxProcDataSource::collectOnce() { QVectorProcessSnapshot result; QDir procDir(QStringLiteral(/proc)); // 只取纯数字目录名这些才是进程目录 const QStringList dirs procDir.entryList( QDir::Dirs | QDir::NoDotAndDotDot); for (const QString dir : dirs) { bool ok false; qint64 pid dir.toLongLong(ok); if (!ok) { continue; } ProcessSnapshot snap; snap.pid pid; // 读取进程状态文件拿进程名和内存占用 QFile statFile(QStringLiteral(/proc/%1/stat).arg(dir)); // ... 解析 stat 文件中的 comm 字段和 rss 字段 QFile cmdFile(QStringLiteral(/proc/%1/cmdline).arg(dir)); // ... 读取完整命令行但注意可能为空僵尸进程尤其常见 result.append(snap); } return result; }这段代码有两个必须处理的细节。第一/proc目录下还存在cpuinfo、meminfo等非数字目录toLongLong会失败直接跳过。第二进程在遍历过程中可能退出导致stat或cmdline打开失败这是正常现象日志记一条跳过即可不要中断整个采集循环。Windows 平台的常见做法是调用系统命令或者系统 API这里不展开写代码关键是理解一个原则采集层做到平台隔离界面层完全不感知后端差异。你的IProcessDataSource有两个实现类运行时根据#ifdef Q_OS_WIN或者构建配置选一个后续加新平台只增加一个实现不动 UI。3. 把进程清单变成表格QTableView 与刷新状态设计3.1 表格选型QTableWidget 起步QTableView 保底任务管理器列表通常有几十到几百行数据刷新频率是秒级。用 QTableWidget 最直接单元格一个个 setText完全满足小工具需求。但如果你在列表滚动时发现卡顿或者想加列排序、动态列宽、状态着色QTableView 加 QAbstractTableModel 是更稳的路线。我习惯在两种场景做不同选择。纯粹给内网同事用的轻量工具直接 QTableWidget代码简单维护成本低。要长期迭代、或者进程数可能超过 500 的项目从一开始就用 QTableView。原因是模型视图分离后数据更新只触发刷新区不会像 QTableWidget 那样把每个单元格都重绘一遍。下面是一个最小模型的骨架class ProcessTableModel : public QAbstractTableModel { Q_OBJECT public: explicit ProcessTableModel(QObject *parent nullptr); // 纯虚函数行数、列数、格子内容 int rowCount(const QModelIndex parent QModelIndex()) const override; int columnCount(const QModelIndex parent QModelIndex()) const override; QVariant data(const QModelIndex index, int role Qt::DisplayRole) const override; QVariant headerData(int section, Qt::Orientation orientation, int role Qt::DisplayRole) const override; // 自定义接口把采集到的快照应用到模型 void applySnapshot(const QVectorProcessSnapshot snapshots); private: QVectorProcessSnapshot m_items; };rowCount返回m_items.size()columnCount返回你定义的列数。data函数里根据index.column()返回对应字段的文本或者数值。这里有个非常容易被忽略的点CPU 列和内存列应当返回原始数值通过Qt::DisplayRole时做格式化而真正排序时用的Qt::EditRole再返回原始数值这样点击表头排序时才不会把“1.2 MB”这种字符串按字典序排。3.2 把刷新逻辑写成“差异更新”而不是“整表重建”很多初版代码会在每次快照到达时清空表格重新填充。这样做有个明显问题列表会闪烁而且用户正在点击的行会突然消失。正确做法是按 pid 做差异更新。void ProcessTableModel::applySnapshot( const QVectorProcessSnapshot snapshots) { QHashqint64, int pidToRow; // 重建一个 pid - 行号的映射用于快速定位老行 for (int row 0; row m_items.size(); row) { pidToRow.insert(m_items[row].pid, row); } // 第一步收集本次快照中存在的 pid QSetqint64 currentPids; for (const ProcessSnapshot snap : snapshots) { currentPids.insert(snap.pid); } // 第二步删除已经消失的进程对应行 for (int row m_items.size() - 1; row 0; --row) { if (!currentPids.contains(m_items[row].pid)) { beginRemoveRows(QModelIndex(), row, row); m_items.removeAt(row); endRemoveRows(); } } // 第三步新增进程追加老进程逐字段更新 for (const ProcessSnapshot snap : snapshots) { if (pidToRow.contains(snap.pid)) { m_items[pidToRow[snap.pid]] snap; } else { int newRow m_items.size(); beginInsertRows(QModelIndex(), newRow, newRow); m_items.append(snap); endInsertRows(); } } }这段逻辑写得比较细但真实项目里可以简化为“先删后插”。删旧行、插新行会带来行号跳动但对于数据量几十行的小工具影响不大。差异更新的核心价值是保留表格滚动位置和选中状态如果你希望用户能选中几个进程后批量操作就必须用差异更新否则用户每两秒就会被重置一次选择。3.3 主窗口装配定时器、状态栏、菜单框架一起搭表格模型做好之后主窗口的装配就非常机械了。我这里把整个骨架列出来让读者能看到任务管理器的主窗口大概长什么样。class TaskManagerWindow : public QMainWindow { Q_OBJECT public: explicit TaskManagerWindow(QWidget *parent nullptr); private slots: void requestSnapshot(); void applySnapshot(const QVectorProcessSnapshot snap); private: ProcessTableModel *m_model; QTableView *m_view; ProcessController *m_controller; QTimer *m_timer; }; TaskManagerWindow::TaskManagerWindow(QWidget *parent) : QMainWindow(parent) { setWindowTitle(QStringLiteral(TaskManager)); resize(960, 640); m_model new ProcessTableModel(this); m_view new QTableView(this); m_view-setModel(m_model); m_view-setSortingEnabled(true); // 开启表头排序 m_view-setSelectionBehavior( QAbstractItemView::SelectRows); // 整行选中 setCentralWidget(m_view); statusBar()-showMessage(QStringLiteral(准备就绪)); }setSortingEnabled(true)以后Qt 会调用模型的data函数拿Qt::DisplayRole的文本排序。为了让 CPU、内存列按数值排序前面我在data里保留 EditRole 返回原始数值这一步就体现了它的作用。表格的排序只会影响视图层的显示顺序不会改变模型里的行号所以选中行之后拿到 pid 依然准确。右键菜单的框架用setContextMenuPolicy(Qt::CustomContextMenu)再连接customContextMenuRequested信号在槽函数里弹QMenu。这个做法比继承重写contextMenuEvent更直观菜单项后续要变也更好维护。4. 从看到到操作结束进程、置顶窗口与性能曲线4.1 结束进程要有二次确认这是业务安全不是技术问题任务管理器里的结束进程按钮真实代码实现可能只有三五行但产品逻辑却不算少。常见的落地做法是先弹出确认框把进程名和 pid 明确展示给用户再调用数据源的terminateByPid接口。直接双击就结束进程、没有任何提示的做法在内网工具里很容易误杀关键服务。void TaskManagerWindow::onTerminateAction() { // 拿到当前选中行对应的进程信息 const QModelIndexList selected m_view-selectionModel() -selectedRows(); if (selected.isEmpty()) { statusBar()-showMessage(QStringLiteral(没有选中任何进程)); return; } const int row selected.first().row(); const qint64 pid m_model-pidAt(row); const QString name m_model-nameAt(row); // 确认框里的信息必须包含 pid避免同名进程混淆 const QString msg QStringLiteral(确认结束进程 %1 (PID: %2)?) .arg(name).arg(pid); const auto answer QMessageBox::question(this, QStringLiteral(结束进程), msg); if (answer ! QMessageBox::Yes) { return; } if (m_controller-terminateByPid(pid)) { statusBar()-showMessage( QStringLiteral(已请求结束进程 %1).arg(pid)); } else { statusBar()-showMessage( QStringLiteral(结束进程失败请检查权限)); } }pidAt和nameAt是模型暴露的两个按行取值的接口这比让 UI 直接访问模型内部结构体字段要干净。确认框弹出期间定时器仍然在跑表格可能已经刷新过了所以确认框中展示的进程名和 pid 应当从模型里取而不是在菜单点击时缓存。否则用户盯着确认框犹豫几秒列表刷新后 pid 可能已经被复用。4.2 后端实现platform-native 的终止逻辑terminateByPid的实现通常分成两条路径。Windows 平台常见做法是用系统命令或系统调用Linux 平台直接发kill信号。下面是类 Unix 平台的实现框架bool LinuxProcessController::terminateByPid(qint64 pid) { // 先用普通信号尝试失败再用强制结束 QProcess killer; QStringList args; args QStringLiteral(-15) // SIGTERM QString::number(pid); killer.start(QStringLiteral(kill), args); if (!killer.waitForFinished(2000)) { return false; } if (killer.exitCode() 0) { return true; } // SIGTERM 失败后再尝试 SIGKILL args.clear(); args QStringLiteral(-9) QString::number(pid); killer.start(QStringLiteral(kill), args); return killer.waitForFinished(2000) killer.exitCode() 0; }先SIGTERM再SIGKILL的顺序很重要。直接-9强制结束会让目标进程来不及清理临时文件和释放锁在业务系统里可能留下脏数据。先给一次优雅退出机会几秒后还活着才强杀这是比较稳妥的习惯。这段代码有个隐患waitForFinished是阻塞调用如果在主线程执行界面会卡住最多 4 秒。项目里常见做法是把这个调用放到工作线程或者用QProcess::startDetached以异步方式发送信号后立即返回。内部小工具为了省事可以接受卡 1 秒但交付给其他人用的时候还是建议做成异步。4.3 窗口置顶用 Qt 窗口标志而不是频繁重设 geometry有些任务管理器会提供“窗口置顶”功能让管理器浮在桌面最上层。这个功能在 Qt 里实现很简单但有个容易翻车的细节。void TaskManagerWindow::toggleAlwaysOnTop() { const bool onTop !m_alwaysOnTop; // 置顶标志属于窗口标志设置后需要重新显示才能生效 if (onTop) { setWindowFlag(Qt::WindowStaysOnTopHint, true); } else { setWindowFlag(Qt::WindowStaysOnTopHint, false); } m_alwaysOnTop onTop; show(); }setWindowFlag修改窗口标志后已经显示的窗口不一定立刻刷新置顶状态所以代码里补了一次show()。这在大多数桌面环境下有效但个别 Linux 窗口管理器对置顶窗口有自己的策略可能出现程序设置了置顶、窗口管理器不配合的情况。遇到这种场景不要强行追界面提示用户“置顶可能受窗口管理器限制”就够了。4.4 CPU 性能曲线一个简单的采样缓冲区任务管理器里第二个容易被炫耀的功能是 CPU 曲线。Qt 自带的 Qt Charts 模块可以直接画折线图我这里不展开绘图细节只讲采样数据结构。class CpuHistoryBuffer { public: static const int kCapacity 60; // 保留最近 60 个采样点 void push(double percent) { m_values.append(percent); if (m_values.size() kCapacity) { m_values.removeFirst(); } } private: QVectordouble m_values; };这个缓冲区只保存数值不保存时间戳因为绘图时横轴就是数组下标。采样间隔 2 秒时60 个点正好是一分钟曲线刷新间隔 1 秒时60 个点是 1 分钟图。真实项目里一般把这个值做成可配置参数避免用户觉得曲线太密或太疏。CPU 曲线只是展示层的东西真正难的是 CPU 百分比计算。这里必须有两次采样求差值第一次记录每个进程的 CPU 时间第二次再记录一次用两次的差值除以时间差作为负载。只采样一次拿到的是自进程启动以来的平均值不是当前负载这就是新手经常做出来的 CPU 列永远不准的根源。5. 任务管理器开发中五个高频卡点现象、原因与可靠修复5.1 刷新一次界面卡半秒滚动列表像掉帧现象定时器触发后列表要停顿几百毫秒才能更新滚动时明显卡顿。原因最常见的根源是采集逻辑和 UI 刷新挤在主线程里。很多人用 QProcess 同步调用系统命令并把命令输出的解析放在同一个函数里没有任何异步逻辑。另一个次要原因是每次整表刷新把所有格子全部重新创建即使数据没变。解决把采集放到QtConcurrent::run或者独立QThread中采集完成通过信号槽回到主线程。UI 更新用差异更新只对变化的行做数据替换。如果数据量实在大可以把滚动区域的updatesEnabled()在批量更新前后临时关闭减少重绘次数。5.2 按“结束进程”按钮没反应日志里也没有报错现象点击确认后状态栏显示“已请求结束”但目标进程还在列表里。原因多数情况是权限不足。高权限进程不是普通用户能杀的Linux 下可能触发权限错误Windows 下需要管理员权限执行。还有一类情况是进程已经变成了僵尸进程信号发过去了但父进程还没回收它所以列表里依然能看到。解决先判断源码里terminateByPid返回值和exitCode权限不足时给出明确提示。如果是僵尸进程提示用户“该进程已被结束等待父进程回收”不要反复发送结束信号。如果希望普通用户能结束更多进程可以在工具启动时请求提权但这是产品决策不是技术修复。5.3 内存占用量和系统自带任务管理器对不上现象同一个进程系统工具显示 300 MB自己的工具显示 480 MB两边差了将近一倍。原因统计口径不同。进程结构体中memoryBytes可能使用的是常驻内存但常驻内存包含与其他进程共享的内存页系统自带工具通常展示专用内存或者工作集的一个特定子集。两边口径不一致就会出现数字对不上的情况。解决在代码结构体上加一个字段注释明确标注“本字段来自哪个统计口径”。列表表头也直接标成“常驻内存 (RSS)”而不是笼统的“内存”避免用户误解。如果你需要对齐系统指标就去查对应平台 API 的字段说明把采集逻辑按该字段调整。5.4 长时间挂机之后程序占用内存越来越大现象任务管理器工具自己开着放一晚上内存占用从几十 MB 涨到几百 MB。原因典型的句柄泄漏。在/proc遍历中每打开一个stat文件或者cmdline文件如果没有正常关闭文件句柄积少成多就会非常可观。另一个常见来源是 QProcess 类的重复创建而没有清理以及模型和视图之间循环引用。解决遍历/proc时尽量用局部 QFile 对象让析构自动关闭文件并确认没有在循环外引用已经结束的 QFile。可以加一个简单的自检每次采集时打印当前进程的句柄数观察是否单调增长。如果发现cmdline读取存在长时间阻塞考虑给读取操作加上超时机制。5.5 置顶窗口在副屏上位置错乱或者置顶后显示在最角落现象多显示器环境下置顶窗口被设置到主屏以外的位置时坐标跑到桌面之外。原因多显示器环境下窗口坐标可能为负值而某些代码会把窗口move到一个基于主屏计算的位置。还有一种情况是最小化和恢复时窗口管理器不保留置顶状态。解决所有与位置有关的计算基于QGuiApplication::screens()而不是假设单屏。窗口恢复时检查当前位置是否在任意屏幕的可用范围内不在则移动到当前鼠标所在屏幕的中央。置顶标志放在窗口恢复后重新设置而不是只在菜单点击时设置一次。6. 把任务管理器从能跑变成好用最后三个打磨点到这一步基础功能已经齐了进程列表、数据采集、结束进程、置顶窗口、性能曲线。接下来如果还有精力我建议把改动收窄到三个高性价比的打磨点上。第一是支持自定义列和刷新间隔。做一个简单的设置对话框让用户勾选需要显示的列刷新间隔给几个档位比如 0.5 秒、1 秒、2 秒、5 秒。这个功能实现成本不高但能显著提升工具在不同配置机器上的可用性。刷新间隔改短后要回到 5.1 节那个异步采集队列确保 UI 不会被快照风暴淹没。第二是增加异常退出后的日志记录。很多内网工具出问题后使用者只能截图说“它不动了”。我们可以在程序里预埋一个日志模块把每次结束进程的请求、成功失败状态、系统错误码写到本地日志文件。这不需要完整日志系统一个QFile追加写就够。关键是把日志路径固定且写在状态栏里让使用者能直接找到。第三是验证差量更新正确性。这个点听起来不起眼但它直接决定你会不会收到“列表状态总是被重置”的反馈。把进程列表当作长期运行的窗口观察刷新 10 分钟以上确认选中高亮、滚动位置、排序状态都不被重置。如果排序状态掉了排查是否在模型调用beginResetModel改用增删行就没事。我曾经在一个内部工具里偷懒整表重建结果使用者反馈“选中进程后想点结束表格先闪了一下点击事件就落空了”。后来改成按 pid 差量更新问题消失。这类问题并不难修难的是意识任务管理器是一种长期驻留窗口不是一次性对话框所有刷新逻辑都要考虑状态保持。希望这个小工具的改造经验能帮到正在做同样方向的人。本文还有配套的精品资源点击获取