前言
好玩系列,好久不见。
致敬王小波。王小波,中国当代学者、作家,代表作品有《黄金时代》《白银时代》《青铜时代》《黑铁时代》等。因90年代软件匮乏,在微机上自行编写了输入法和中文编辑软件,用于小说创作。
笔者最近在尝试新的研发领域,在"学习"过程中,使用到了QT-IDE 和 CLion。
笔者按: 因为学习还不足够深入,掌握的知识还不足够广泛和透彻,因此,文章提到的解决方案未必是最合适的方案。
在这个过程中,我遇到了不少"低效率"的手动工作,使我感到不适。为了改变这些令人烦躁的状况,我使用以往的知识构建了脚本和IDE插件,使我脱离这些低效的工作事项。
聪明人应当有效的分配自己的时间,并不断收获愉悦感,避免在烦人的琐事中不断消耗自身精力。
插件工程仓库 脚本文件托管于插件工程仓库 script_demo 目录下。
琐事
- 维护QT的pro文件,尤其是声明头文件、源文件、form
- 维护CMakeLists文件,尤其是QT模块、头文件、源文件等清单
- 构建过程
- 建立桩代码
很显然,这些都是极其机械化的工作,非常适合使用脚本、IDE插件进行工作简化。
思路及方案
关于:pro和CMakeLists
笔者按: 因为尚未彻底吃透C++编译和构建的所有细节,我暂时不打算全部迁移到使用CMakeLists指导构建,先保留QT的构建
因此,我需要保持正确的pro文件,以支持QT构建,并且让CMakeLists文件的内容,达到支持CLion正确链接文件、跳转、提示的程度。
核心点:
- 递归扫描,找出头文件、源文件、Form文件
- 借助.gitignore,忽略掉不受版本控制的文件
- 参考gitignore机制,额外忽略掉需要被版本控制但不参与编译的文件
- 定位pro和CMakeLists文件,识别内容段,模板生成和内容替换
- 解析pro中使用的QT模块等,自动维护CMakeLists内容段
关于构建过程:
因为过程中涉及到临时指定环境变量、拷贝文件、调用工具链等工作,胶水语言是不二选择
关于桩代码:
本质上,它是需要按照用户指定的意图,选择模板创建文件的过程。虽然胶水语言脚本也能完成,但需要通过命令行进行交互,但不如使用IDE插件。
考虑到我对groovy/kts/python都比较熟悉,而JetBrain系列IDE几乎都支持gradle插件,既往开发Android和Java后端时已经具备完善的gradle环境,因此,胶水语言选择groovy+gradle环境;
在开发Pandora项目时,我已经掌握了JetBrain系列IDE plugin 开发的基本技能,因此选择开发CLion Plugin实现桩代码生成。
实践
实践1-gradle脚本维护清单文件
寻找文件
和CMakeLists不同,pro文件没有固定的文件名,因此需要先定位pro文件。
groovy
File findProFile(File rootDir) {
// 如果 gradle.properties 有配置,就用配置的
if (project.hasProperty("QT_PRO_FILE")) {
return file(project.property("QT_PRO_FILE"))
}
// 否则自动查找
def list = rootDir.listFiles().findAll { it.name.endsWith(".pro") }
if (list.isEmpty()) {
throw new GradleException("未找到 .pro 文件,请配置 QT_PRO_FILE")
}
if (list.size() > 1) {
println "发现多个 .pro 文件,默认使用第一个: ${list[0]}"
}
return list[0]
}
加载忽略规则&寻找有效文件
在递归扫描目录时,搜集整个树分支中的 .gitignore 与 .proignore内容,并进行文件过滤。
groovy
void scanDir(File rootDir, File dir, List<Map> parentPatterns,
List<String> headers, List<String> sources, List<String> forms) {
List<Map> patterns = new ArrayList<>(parentPatterns)
[".gitignore", ".proignore"].each { ignoreFile ->
File file = new File(dir, ignoreFile)
if (file.exists()) {
file.eachLine { line ->
line = line.trim()
if (!line || line.startsWith("#")) return
boolean negate = line.startsWith("!")
if (negate) line = line.substring(1)
patterns << [pattern: line, negate: negate]
}
}
}
dir.eachFile { f ->
if (f.name == ".git") return
String relPath = rootDir.toPath().relativize(f.toPath()).toString().replace("\", "/")
if (isIgnored(relPath, f.isDirectory(), patterns)) {
if (f.isDirectory()) return
else return
}
if (f.isDirectory()) {
scanDir(rootDir, f, patterns, headers, sources, forms)
} else {
def ext = relPath.tokenize('.').last().toLowerCase()
switch (ext) {
case "h": headers << relPath; break
case "cpp": sources << relPath; break
case "ui": forms << relPath; break
}
}
}
}
按模板生成内容并按照插桩位置替换
至此,已收集到足够的信息,按照模板凭借后即可按照插桩TAG识别文件进行替换;
groovy
// 生成 .pro 内容
String buildProContent(List<String> headers, List<String> sources, List<String> forms) { /* ...逐项拼接,行尾续行... */ }
// 生成 CMakeLists 内容(文件清单)
String buildCMakeContent(List<String> headers, List<String> sources, List<String> forms) { /* set(SOURCES/HEADERS/FORMS) */ }
// 通用替换(支持自动新增标识区)
void replaceSection(File file, String newContent, String startMark, String endMark) { /* 基于正则替换 */ }
// 汇总任务:扫描 -> 解析模块 -> 生成文本 -> 替换三个区块
task generateQtProFiles {
doLast {
File proFile = findProFile(project.projectDir)
File cmakeFile = file("CMakeLists.txt")
List<String> headers = [], sources = [], forms = []
scanDir(project.projectDir, project.projectDir, [], headers, sources, forms)
List<String> qtModules = parseQtModules(proFile)
replaceSection(proFile, buildProContent(headers, sources, forms),
"# auto generate start", "# auto generate end")
replaceSection(cmakeFile, buildCMakeContent(headers, sources, forms),
"# auto generate start", "# auto generate end")
replaceSection(cmakeFile, buildCMakeFindPackage(qtModules),
"# auto find package start", "# auto find package end")
}
}
注意:CMakeLists中仍然需要加入QT模块、QT生成文件(如ui文件转换的头文件)等,项目仓库中提供了参考,但仍有不严谨之处。
实践2-gradle脚本组装编译过程
读者诸君,如果你已经为自动化构建平台创建了构建脚本,尤其是构建过程中需要链接文件、移动资源时,这一实践会具有更高价值。
以下是一份参考脚本,可以构建/构建并运行Qt项目,如果你需要在构建过程中链接文件、移动资源,可以用gradle任务串接,比单bat脚本要方便不少。
batch
#!/usr/bin/env bash
set -e
# ==============================
# 配置 使用环境变量更佳
# ==============================
QT_SPEC=win32-msvc
QT_BIN="D:/ide/Qt/6.4.0/msvc2019_64/bin"
QT_EXE="XXX.exe"
VCVARS="D:/ide/VS/VC/Auxiliary/Build/vcvars64.bat"
ROOTDIR="$(cd "$(dirname "$0")"; pwd)"
PROJECT="$ROOTDIR/XXX.pro"
# 参数检查
if [ $# -eq 0 ]; then
echo "用法: $0 [Debug|Release|RunDebug|RunRelease]"
exit 1
fi
MODE="$1"
BUILDDIR="$ROOTDIR/build/Desktop_Qt_6_4_0_MSVC2019_64bit-$MODE"
# 调 MSVC 环境变量(抓 PATH)
VCENV=$(cmd.exe /c "call "${VCVARS}" x64 && set")
export PATH="$QT_BIN:$(echo "$VCENV" | grep '^PATH=' | cut -d= -f2- | tr -d '\r')"
case "$MODE" in
Debug|Release)
mkdir -p "$BUILDDIR/release"
cd "$BUILDDIR"
echo "====== qmake + nmake ======"
qmake "$PROJECT" -r -spec $QT_SPEC CONFIG+=$MODE
nmake -nologo -f Makefile.$MODE
;;
RunDebug)
MODE=Debug
EXE_PATH="$BUILDDIR/$MODE/$QT_EXE"
if [ ! -f "$EXE_PATH" ]; then
echo "错误: 未找到 $EXE_PATH"
exit 1
fi
echo "====== 运行 $EXE_PATH ======"
"$EXE_PATH"
;;
RunRelease)
MODE=Release
EXE_PATH="$BUILDDIR/$MODE/$QT_EXE"
if [ ! -f "$EXE_PATH" ]; then
echo "错误: 未找到 $EXE_PATH"
exit 1
fi
echo "====== 运行 $EXE_PATH ======"
"$EXE_PATH"
;;
*)
echo "参数错误: $MODE"
exit 1
;;
esac
Gradle 任务编排
笔者在实践过程中,面临以下需求:
-
拷贝SQL文件
-
拷贝三方库dll等
-
在
build.gradle
文件中,我定义了一系列 Gradle 任务来调用上述的批处理脚本,并增加了依赖拷贝等额外步骤。
读者诸君可关注以下gradle task:
createQtTasks(String mode)
:动态创建Debug
和Release
两种模式下的所有相关任务。qtBuild<Mode>
:执行编译任务。copyDependencies<Mode>
:一个Copy
类型的任务,负责将运行时需要的动态链接库(如 VLC、LSL 的.dll
)和其他资源文件(如.sql
)拷贝到输出目录。qtBuildRun<Mode>
:一键完成"编译 + 运行"的操作。qtRun<Mode>
:运行已经编译好的程序。
groovy
def createQtTasks(String mode) {
// ...
// 定义编译任务,类型为 Exec,用于执行外部命令
task("qtBuild${mode}", type: Exec) {
group = 'qt'
description = "构建 Qt ${mode} 项目"
dependsOn "qtSetupBuildDir${mode}"
// 调用批处理脚本
commandLine 'cmd', '/c', "build-qt.bat", mode
// 编译完成后自动执行依赖拷贝
doLast {
tasks.named("copyDependencies${mode}").get().execute()
}
}
// 定义依赖拷贝任务
task("copyDependencies${mode}", type: Copy) {
// ...
from(vlcSourceDir) { include "*.dll" }
into destDir
}
// 定义"构建并运行"任务
task("qtBuildRun${mode}", type: Exec) {
dependsOn "qtBuild${mode}"
commandLine 'cmd', '/c', "build-qt.bat", "Run${mode}"
}
}
// 创建 Debug 和 Release 两套任务
createQtTasks("Debug")
createQtTasks('Release')
实践3-CLion插件
从功能上看,这是一个极其简单的插件,因为它只需要:
- 获知用户行为所对应的目录
- 获知用户输入的"关键词"
- 按照模板,替换关键词,在对应目录生成文件、并输入文件内容,及桩代码
并不需要分析当前代码文件的AST、理解上下文、插入对应代码段,更不涉及复杂的代码AST索引、Hint、Navigation等。
创建V2版本插件工程
笔者编写Pandora项目插件已较为久远,当时插件是V1代,如今,已进入V2时代,在工程配置方面需要注意:
- 依赖V2版本plugin
- 测试环境配置。
kotlin
plugins {
id("org.jetbrains.intellij.platform") version "2.7.2"
kotlin("jvm") version "1.9.24"
}
//...
dependencies {
intellijPlatform {
intellijIdeaCommunity("2024.1")
// clion("2025.1")
}
}
笔者按:记忆中最早期需要下载IDEA源码和可运行的测试版软件,后来jetbrains对Idea的license控制更加严格,放宽开发限制,可直接将安装好的 Idea CE 软件作为测试环境。目前虽然配置方便,但又回到了下载单独测试软件的方案上,这一步需要耐心!
本插件项目本身即为一个基于 Gradle 的 IntelliJ Platform Plugin V2 模板工程。其核心配置文件包括 build.gradle.kts
和 settings.gradle.kts
,用于管理依赖和构建设置。项目结构遵循了官方推荐的标准布局。
完整工程配置请参见 仓库
定义Action并注册
插件的功能入口点是一个 Action
。定义 GenerateMvpAction
类以实现功能逻辑。
Action需要注册,src/main/resources/META-INF/plugin.xml
,将其添加到 NewGroup
,及 "File" 的 "New" 菜单中,符合习惯。
xml
<actions>
<action id="osp.leobert.plugins.qt.mvpgenerator.GenerateMvpAction"
class="osp.leobert.plugins.qt.mvpgenerator.GenerateMvpAction"
text="Generate MVP Stubs"
description="Generate MVP stub code">
<!-- 移动到 Project → New 菜单下 -->
<add-to-group group-id="NewGroup" anchor="last"/>
</action>
</actions>
定义默认模板
模板文件放置于 src/main/resources/templates/
目录下,使用 Name
作为占位符,后续将被用户输入的关键词替换。
我使用的模板文件如下,读者诸君可按照自身需求处理:
INamePresenter.h
INameView.h
NamePresenter.cpp
NamePresenter.h
NameWindow.cpp
NameWindow.h
NameWindow.ui
例如, INamePresenter.h
的内容如下,其中 ${NAME}
将被动态替换:
cpp
// I${NAME}Presenter.h
#ifndef I_PRESENTER_H
#define I_PRESENTER_H
class I${NAME}Presenter {
public:
virtual ~I${NAME}Presenter() {}
// ... other methods
};
#endif // I_PRESENTER_H
实现Action
GenerateMvpAction
的核心逻辑在 actionPerformed
方法中实现。
主要步骤如下:
- 获取上下文 :通过
AnActionEvent
获取当前项目project
和用户操作的目录baseDir
。 - 获取用户输入 :弹出一个
InputDialog
对话框,让用户输入模块名(例如StimulationLibrary
)作为关键词keyword
。 - 定义文件映射 :创建一个从目标文件名到模板文件路径的映射。目标文件名使用
${keyword}
占位符。 - 执行写操作 :在
ApplicationManager.getApplication().runWriteAction
中执行文件创建操作,这是与IDE文件系统交互的标准做法。- 遍历模板映射。
- 加载模板文件内容。
- 使用用户输入的
keyword
替换模板中的${NAME}
占位符。 - 在目标目录创建新文件,并写入处理后的内容。
- 反馈结果:操作完成后,显示一个信息对话框,告知用户代码已成功生成。
核心代码片段如下:
kotlin
// GenerateMvpAction.kt
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
val baseDir = e.getData(CommonDataKeys.VIRTUAL_FILE) ?: return
// 2. 获取用户输入
val dialog = InputDialog(project, "Enter keyword (e.g. StimulationLibrary):", "MVP Generator")
if (!dialog.showAndGet()) return
val keyword = dialog.inputText.trim()
if (keyword.isEmpty()) return
// 3. 定义文件映射
val templates = mapOf(
"I${keyword}Presenter.h" to "/templates/INamePresenter.h",
// ... 其他文件
)
// 4. 执行写操作
ApplicationManager.getApplication().runWriteAction {
templates.forEach { (fileName, templatePath) ->
val content = loadTemplate(templatePath).replace("${NAME}", keyword)
val fName = fileName.replace("${keyword}", keyword)
val newFile = baseDir.findOrCreateChildData(this, fName)
newFile.setBinaryContent(content.toByteArray())
}
}
// 5. 反馈结果
Messages.showInfoMessage(project, "MVP stubs generated for $keyword", "Success")
}
总结
受限于知识,这一轮实践不是最佳实践;
我依然坚持以下观点:我们不能仅用编程能力糊口,更应当让自己感到快乐