ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

IDEA插件开发实战:从悬浮提示到点击方法信息卡

IDEA插件开发实战:从悬浮提示到点击方法信息卡 昨天有个同事问我鼠标悬浮到自己项目里的一个方法名上IDEA 有时会弹一个浮层显示签名有时却什么都不弹是不是设置问题我给他讲了默认悬浮提示的配置结果聊着聊着发现他真正的需求其实更具体——他想要“鼠标悬浮或者点击就能看到当前所在方法的信息”而且这个信息最好不止签名还要带上注释、注解、甚至团队自定义的业务元数据。这其实就是 IDE 行为定制里非常典型的一个场景。今天把我自己折腾这条路的完整过程写出来先从 IDEA 内置能力讲起讲清楚它默认能干什么、不能干什么然后进入插件开发从工程搭建到监听器实现再到几个必须处理的坑。普通用户看完前两节基本够用想深度定制的可以直接跟着后面动手做。1. IDEA 自带的能力到哪一步悬浮提示与快捷键盘点先说个结论IDEA 默认不是不能悬浮提示而是它提示的信息量、触发方式和位置都“够用但不完全好用”。把默认能力摸清楚你才知道后面插件到底补的是什么。1.1 悬浮弹窗默认给你什么当鼠标悬停在一个方法名上时IDEA 默认会弹出一个灰色小浮层里面一般包含方法签名返回类型、方法名、参数列表如果这个方法带了 Javadoc还会显示 doc 注释里的摘要文字。如果方法是某个接口的实现浮层里有时还会出现“ Implementation of ... ”之类的提示告诉你它override了哪个接口方法。这个浮层的问题是信息基本停留在“签名”层面而且样式很朴素不支持自定义。想看完整的 Javadoc、参数说明、注解等细节得再按按键。另外默认悬停提示的开关和延迟是有设置的很多人不知道。1.2 点击/固定查看的另一条路Quick Documentation 与 Parameter Info“点击”这个动作默认其实也有对应能力只是大家不一定把它们和“查看方法信息”联系起来Quick DocumentationWindows/Linux 下 CtrlQmacOS 下 CtrlJ光标停在一个方法名上时按下会弹出一个固定的文档窗口里面包含 Javadoc、参数说明、返回类型、异常信息甚至方法所在类的链接。这个比悬浮弹窗的信息丰富很多。Quick DefinitionWindows/Linux 下 CtrlShiftImacOS 下 CmdY不跳转页面直接在当前文件里预览方法定义适合确认一个调用点的真实实现。Parameter InfoWindows/Linux 下 CtrlPmacOS 下 CmdP当光标在方法调用的括号内时会显示该方法的参数列表重载方法还能翻页切换。这三组快捷键组合起来其实已经能回答“这个方法签名是什么、参数是什么、定义在哪”这几个高频问题。但它们的共同点是都得按键不会因为你“点了方法名”就自动弹出来。我同事想要的那种“单击一下方法名就固定展示信息”的体验默认还真没有。1.3 把默认提示调到顺手的状态在讲插件之前先花两分钟把内置配置调好。因为很多场景下默认功能调一调就够用了。打开 SettingsmacOS 是 Preferences在搜索框里直接搜 “hover” 或者 “documentation”重点看两个地方一是 Editor General Code Completion。这里面有一个和参数提示相关的延迟设置通常默认是 500ms 左右控制的是光标在括号内停顿多久弹出参数提示。我个人的习惯是调到 300ms响应更快如果机器性能一般反而建议调到 700ms 以上避免弹窗抢焦点。二是 Editor General Hovering。这里面有 Show quick documentation on hover 和 Show error description on hover 两个开关前者就是你鼠标悬停时弹签名浮层的总开关。下面的延迟delay默认值不同版本不太一样有人习惯调成 0让提示秒出但实际写代码时你会发现太灵敏鼠标滑过代码就闪一屏所以我建议保留一个 200-300ms 的延迟刚好能避免误触。提示不同 IDEA 版本的设置项名称有差异搜不到准确名字时直接搜 “MouseMove” 或 “quick doc”基本都能定位到对应选项。别死记路径不同年份版本的位置真不一样。把内置配置摸清楚之后你会发现它解决的是“悬浮看签名”的问题但解决不了“点击固定查看”“自定义内容”“展示业务注解”这些问题。这就是接下来要展开的边界。2. 哪些场景下内置方案真的不够用这一节的目的不是劝退内置功能而是帮你判断你到底要不要进入插件开发这条“不归路”。我的判断标准很简单——你需要的到底是“信息”还是“交互”。2.1 默认弹窗看不见的三类信息第一类方法的归属与状态信息。默认悬浮窗不会告诉你这个方法是静态的还是实例方法定义在哪个类的第几行有没有被 Deprecated 标记方法上写了哪些自定义注解。写自己的代码还好接手别人一个两三千行的老类时这些信息能省掉大量上下跳转。第二类业务元数据。在 Spring 项目里一个 Controller 方法上可能挂着 GetMapping(/order/list)、PreAuthorize(hasRole(ADMIN)) 之类。默认弹窗不会解析注解的属性值你只能点进去跳转再看。这类“接口路由 权限注解”的信息反而往往是排查接口问题时最想一眼看到的。第三类来自 Javadoc 的结构化内容。比如 param 参数说明、return 说明、author 和 since。默认悬浮窗只会显示 doc 里最前面的一两行摘要不会把整个结构化注释给你。2.2 “悬浮”和“点击”本质是两种交互需求这一点很多人没意识到。悬浮适合你在阅读代码时“临时确认一下这是谁”目光扫过去信息浮一下就消失不打断思路。点击适合你想“暂存”一个方法的信息比如开会讲代码、做代码评审或者在一个超长方法里来回比对逻辑时希望能把签名固定在某个角落。默认悬浮是给“阅读”设计的它没有给“点击固定”设计。你要么用快捷键调文档窗口要么自己写插件把一个信息卡“钉”在编辑器旁边。很多人觉得 IDEA 缺这个功能其实不是缺是它认为你不需要。但对常年翻接口、写 Controller 的人来说“点击方法名看注解”这个需求是真实存在的。2.3 为什么不直接用现成插件而是自己写市面上确实有一些代码信息展示插件比如 JavaDoc 类的辅助插件还有一些 AI 辅助插件能对代码块做解释。我试用过几个问题集中在三处要么太“重”为了看一个方法信息额外拉了一个大而全的代码分析引擎要么信息展示位置不理想弹出的面板在南边或右下方目光要离开代码好远要么不能自定义信息维度团队自己的注解、内部框架的标记根本没地方配。自己写插件的核心好处就一个信息内容和交互方式完全由你控制。这个需求本身不复杂不需要搞一堆依赖也不需要框架就用 IntelliJ Platform SDK 自带的编辑器监听扩展点就够了。整个工程几十个类文件核心逻辑可能不到 200 行。如果你只是“偶尔想看看方法签名”说实话不用折腾把 1.3 小节的设置一调就很好用。但如果你像我一样经常要和接口注释、权限注解、方法归属信息打交道那就值得花一个下午写这个小插件。3. 动手前先搭好 IDE Plugin 工程插件开发现在比前几年省心多了——IDEA 自带工程模板不用手工去拉 SDK。前提是你用的是官方 GitHub 仓库下载的 IntelliJ IDEA社区版就能做插件开发不需要商业版。激活破解之类的内容这里不展开反正正规渠道拿到的 IDE 都支持这套开发方式。3.1 新建工程与 Gradle 关键配置在 IDEA 里 File New Project左侧选 IDE Plugin旧版本叫 IntelliJ Platform Plugin语言选 Kotlin 还是 Java 看你习惯。我用 Kotlin因为事件回调写起来短但本文的核心代码稍作修改 Java 也一样能跑。模板工程会自带一个 build.gradle.kts核心依赖是 gradle-intellij-plugin它负责下载 IntelliJ SDK、帮你运行沙箱实例。关键配置如下plugins { java kotlin(jvm) version 1.9.0 id(org.jetbrains.intellij) version 1.17.3 } group com.example version 1.0.0 java { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } intellij { pluginName.set(method-info-hover) version.set(2023.2) type.set(IC) updateSinceUntilBuild.set(false) }几个经验点说一下。version.set(2023.2)表示你要基于哪个 IDEA 版本做 SDK 编译。注意 SDK 和运行沙箱是两个概念SDK 只是编译期用的依赖沙箱是你点 run 时自动启动的一个“干净的 IDEA 实例”用于测试插件。第一次运行runIde任务时会去下载对应版本的 IDE 包网络差的时候会等比较久之后增量更新会快很多。updateSinceUntilBuild.set(false)是开发期常用的一个偷懒操作意思是不生成 since/until 校验避免每次换小版本都要改。如果你要把插件发给团队其他人建议还是设置一下后面专门说。3.2 plugin.xml 里注册两个编辑器监听器IDEA 插件的基本结构是 plugin.xml 声明扩展点代码实现接口。我们要用的扩展点有两个editorMouseMotionListener监听鼠标在编辑器内移动对应“悬浮”场景editorMouseListener监听鼠标按钮事件对应“点击”场景。在 src/main/resources/META-INF/plugin.xml 里声明idea-plugin idcom.example.method-info-hover/id nameMethod Info Hover/name vendorYour Name/vendor descriptionHover or click to show method info/description dependscom.intellij.modules.platform/depends extensions defaultExtensionNscom.intellij editorMouseMotionListener implementationcom.example.MethodInfoHoverListener/ editorMouseListener implementationcom.example.MethodInfoClickListener/ /extensions /idea-plugin注册之后IDEA 会在编辑器里维护这两个监听器实例你不用自己管理生命周期也尽量不要在监听器里持有长期引用避免沙箱 reload 时出现内存残留。3.3 拿到 Project、Editor 和 PsiFile进入代码实现之前先把三个基础概念理清后面代码才不会看晕。Editor是你在 IDEA 里看到的那个编辑区对象它持有Document文档的底层文本模型Project是当前打开的工程而PsiFile是 IDEA 对代码文件的语法树表示通过它能定位到方法、类、注解这些结构元素。从编辑器事件里我们天然能拿到 Editor但拿不到 PsiFile 吗需要转换val project editor.project ?: return val psiFile PsiDocumentManager.getInstance(project).getPsiFile(editor.document) ?: return这个PsiDocumentManager很关键。Document 是文本层PsiFile 是语法层两者之间需要这个管理器做同步。如果你拿到 Document 后直接解析文件可能会和当前编辑状态不一致。4. 核心实现从鼠标事件到方法信息卡这节是正文的重头戏。我按“悬浮监听、点击监听、信息拼装、弹窗展示”四步拆开讲每一步都说清楚为什么这么做。4.1 悬浮监听用鼠标坐标而不是光标位置很多人第一次写悬浮提示时会犯一个错误——直接用editor.caretModel.offset去定位方法。这个 offset 是键盘光标的位置不是鼠标悬停的位置。鼠标可以悬停在屏幕第 20 行键盘光标在第 40 行你用 caret 取到的就是第 40 行的方法完全不对。正确做法是把鼠标事件的坐标点转换成编辑器里的文本 offsetclass MethodInfoHoverListener : EditorMouseMotionListener { private var lastCheckTime 0L override fun mouseMoved(e: EditorMouseEvent) { if (e.isConsumed) return val editor e.editor ?: return val project editor.project ?: return val now System.currentTimeMillis() if (now - lastCheckTime 200) return lastCheckTime now val point e.mouseEvent.point val offset editor.logicalPositionToOffset(editor.xyToLogicalPosition(point)) val method findMethodAt(editor, offset) ?: return showMethodHint(editor, method) } }xyToLogicalPosition把屏幕坐标转成逻辑行列logicalPositionToOffset再把行列转成文档偏移量。这两个 API 是编辑器坐标系的标配凡是要处理“鼠标在哪一行哪一列”的场景都用它们。4.2 点击监听左键单击时“钉住”弹窗点击监听和悬浮监听最大的不同是点击要判断鼠标按钮和点击次数你不能让右键也触发也不能在用户双击选词时弹窗干扰。class MethodInfoClickListener : EditorMouseListener { override fun mouseClicked(e: EditorMouseEvent) { val raw e.mouseEvent if (raw.button ! MouseEvent.BUTTON1 || raw.clickCount ! 1) return if (e.isConsumed) return val editor e.editor ?: return val offset editor.logicalPositionToOffset(editor.xyToLogicalPosition(raw.point)) val method findMethodAt(editor, offset) ?: return showMethodPopup(editor, method, raw.point) } }这里的过滤条件有三个左键、单击、事件未被其他组件消费。e.isConsumed这个判断很重要因为 IDEA 自身也有很多编辑器鼠标处理逻辑如果你不检查这个标记你的弹窗很可能跟 IDEA 内置的点击跳转、代码折叠等功能打架。点击弹窗的定位我选择直接在鼠标点击的位置附近展示而不是在编辑器底部开一个面板。原因很实际目光正在看方法名信息出现在鼠标旁边最顺眼。这个问题在 4.4 节还会展开。4.3 信息卡内容怎么拼才实用——通用解析方法下面这段是悬浮和点击两个监听器共用的核心函数作用是给定一个 offset找到这个位置所在的方法并且确保鼠标位置落在方法名上而不是方法体里任意位置。fun findMethodAt(editor: Editor, offset: Int): PsiMethod? { val project editor.project ?: return null val psiFile PsiDocumentManager.getInstance(project).getPsiFile(editor.document) ?: return null val element psiFile.findElementAt(offset) ?: return null val method PsiTreeUtil.getParentOfType(element, PsiMethod::class.java) ?: return null val nameIdentifier method.nameIdentifier ?: return null if (!nameIdentifier.textRange.contains(offset)) return null return method }解释一下几个关键点psiFile.findElementAt(offset)拿到的是鼠标位置最具体的语法元素可能是一个标识符、一个括号、一个分号PsiTreeUtil.getParentOfType(element, PsiMethod::class.java)沿着语法树往上找“包含这个元素的方法”这是定位“所在方法”的核心 API最后判断nameIdentifier.textRange.contains(offset)意思是“鼠标必须真正落在方法名上”。这一步是体验的关键不加的话鼠标悬停在方法体内部的任何代码上都会弹方法信息写代码的时候会烦到怀疑人生。然后组装展示文本fun buildMethodCard(method: PsiMethod): String { val sb StringBuilder() method.docComment?.text?.let { sb.appendLine(it) } sb.appendLine(类: ${method.containingClass?.qualifiedName ?: 未知}) sb.appendLine(方法: ${method.name}) val returnType method.returnType?.presentableText ?: void val params method.parameterList.parameters.joinToString { ${it.type.presentableText} ${it.name} } sb.appendLine(签名: $returnType ${method.name}($params)) method.annotations.forEach { annotation - sb.appendLine(注解: ${annotation.qualifiedName}) annotation.parameterList.attributes.forEach { attr - sb.appendLine( ${attr.name} ${attr.value?.text}) } } return sb.toString() }这个函数的信息维度你可以自由增删。比如要显示“是否是静态方法”可以判断method.hasModifierProperty(PsiModifier.STATIC)要显示 Spring 路由可以单独取GetMapping的 value 属性要显示作者可以从 docComment 里解析author。把团队自定义注解的解析逻辑加在这里就是别人复制不走的定制价值。4.4 弹窗方式选择HintManager 还是 JBPopupIDEA 给开发者提供了两种常见的“临时信息展示”方式HintManager适合轻量提示调用简单但样式基本固定只能显示短文本而且位置由 IDEA 控制不适合承载多行结构化信息。JBPopup则灵活得多可以放任意 Swing 组件支持设置关闭方式、移动、聚焦等适合做信息卡。我的选择是悬浮场景用轻量提示点击场景用 JBPopup。因为悬浮需要频繁触发太重会卡点击是主动动作用户可以接受一个更“重”但内容更丰富的弹窗。点击弹窗的代码fun showMethodPopup(editor: Editor, method: PsiMethod, screenPoint: Point) { val text buildMethodCard(method) val label JLabel(html${text.replace(\n, br)}/html) label.border JBEmptyBorder(10) val popup JBPopupFactory.getInstance().createComponentPopupBuilder(label, label) .setRequestFocus(false) .setCancelOnClickOutside(true) .setCancelOnOtherWindowOpen(true) .setMovable(true) .createPopup() popup.show(RelativePoint(editor.contentComponent, screenPoint)) }这里有两个细节值得说。第一setRequestFocus(false)。如果不设置弹窗会抢走编辑器焦点你点击完想继续打字发现光标不响应还得先点回编辑器。这个坑我在初版插件里踩过。第二setCancelOnClickOutside(true)。点击弹窗外部任意区域时自动关闭这是“钉住但不恶心人”的关键。同理setCancelOnOtherWindowOpen(true)能保证打开其他窗口时这个信息卡自己收起来不会残留。5. 上线前必须处理的四个死角代码写完、能弹出信息卡这只是跑通了主线。真正让我在项目里稳定用上的是下面这四个平时文档不会告诉你的细节。不处理的话轻则体验差重则 IDE 卡死。5.1 不要在鼠标移动回调里做重活编辑器鼠标事件全部跑在 EDTEvent Dispatch ThreadSwing 的事件分发线程上。你在mouseMoved里直接做 PSI 解析文件小还好遇到大文件时鼠标会明显变得粘滞因为每次移动都在等你的代码解析完成。我的处理方案是把方法解析放到后台线程回到 EDT 再展示弹窗。ApplicationManager.getApplication().executeOnPooledThread { val method findMethodAt(editor, offset) ApplicationManager.getApplication().invokeLater { method?.let { showMethodHint(editor, it) } } }注意后台线程访问 PSI 必须在 read action 内或通过ReadAction.compute不能直接拿 PsiFile 就想当然地解析。简单起见可以用ReadAction.compute包裹findMethodAt。这个线程模型是 IntelliJ Platform 开发的必修课凡是涉及 PSI 的插件都逃不掉。5.2 方法名范围过滤否则写代码时你会被烦死我在 4.3 节已经把一个判断写进了findMethodAtnameIdentifier.textRange.contains(offset)。这个判断在实际使用中救了大命。第一次实现时我没有加这个判断后果是鼠标悬停在一个很长的方法体内任意一个变量名、一个 if 关键字、甚至一个大括号上弹窗都会弹出“这个方法的信息”。当时我一度想删掉整个功能因为这已经不是辅助是干扰。加上这个过滤之后弹窗只在鼠标停留到方法名那一小段时才出现频率大幅下降也不影响我在方法体里移动光标时的注意力。如果你想让行为更激进一点比如允许方法体任意位置触发把这行判断去掉即可但我的建议是别去。5.3 同位置反复触发的防抖处理鼠标悬浮到一个方法名上哪怕手微微抖一下mouseMoved都会触发多次。每次触发都做一次 PSI 解析、重建一次弹窗不仅浪费还会让弹窗闪烁。我在悬浮监听器里用了一个简单的时间戳防抖两次触发间隔小于 200ms 的直接忽略。private var lastCheckTime 0L ... val now System.currentTimeMillis() if (now - lastCheckTime 200) return lastCheckTime now如果你连“同一个方法名上抖动”都嫌多余还可以再存一个上次弹出的 offset发现 offset 没变就完全不处理。两个策略叠加悬浮逻辑基本就不怎么耗资源了。5.4 打包给团队用之前检查的版本兼容runIde沙箱里跑通以后要分享给同事需要 Build Plugin 生成一个 zip 包。打包前有件事容易忽略plugin.xml 里的 sinceBuild / untilBuild。如果你在build.gradle.kts里设置了updateSinceUntilBuild.set(false)打包出来的插件不会带版本范围校验这时 IDEA 可能会拒绝安装或者安装后提示不兼容。要正式分发就在 build.gradle.kts 里配置tasks { patchPluginXml { sinceBuild.set(231) untilBuild.set(241.*) } }这两个值对应具体的 IDEA 主版本号比如 231 对应 2023.1241 对应 2024.1。宁可保守一点把范围写窄等测试稳定了再放宽也不要为了省事随便写一个巨大范围否则用户装完出现 API 不兼容弹一堆错插件口碑就直接没了。打包后的 zip 在 build/distributions 目录下同事通过 Settings Plugins Install Plugin from Disk 安装即可。整个过程不需要申请插件市场账号也不用签名内部工具完全够用。最后说一个我个人的使用心得我用了这个插件之后其实把 IDEA 自带的 quick documentation on hover 关掉了因为两套弹窗同时出现会打架。信息密度上这个自定义弹窗远高于默认浮层而且点击固定这个交互是内置功能给不了的。如果你也是第一次写 IDEA 插件我建议别一上来就做悬浮先只做“点击方法名弹出信息卡”这一版改动小、调试容易、体感也最明显悬浮版在这个基础上做防抖和线程优化会顺畅很多。
返回列表