ARTICLE DETAIL

资讯详情

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

IntelliJ插件开发实战:从零构建写作陪伴工具Typing Novel

IntelliJ插件开发实战:从零构建写作陪伴工具Typing Novel 聊一个我最近折腾完的小项目Typing Novel。这是一款专门给写作者用的 IntelliJ 插件让你不用切出 IDE直接在编辑器里码字同时提供实时字数统计、打字速度测算、章节目标进度这些原本只出现在专业写作软件里的功能。很多人写小说、写技术博客、写课程脚本时其实是常年泡在 IDEA 里的代码和文稿混在同一个工程里反而方便管理。但 IDE 默认只照顾“写代码的人”不会管你写了多少个字、这个段落卡了多久。Typing Novel 就是来补这块空缺的。这篇文章会把整个流程完整走一遍从工程创建、环境配置、核心代码实现到本地调试、打包上传最后发布到 JetBrains Marketplace。不管是单纯想做个自用工具还是打算认真维护一个开源插件这篇东西都能帮你把那些只有踩过坑才知道的门道讲清楚。1. 项目动机与整体设计为什么在 IDE 里做“打字陪伴”先说清楚 Typing Novel 到底解决什么问题。早些年写长篇内容常见组合是“Typora 写作 Git 管理 码字软件看统计”工具链一长状态切换就很伤。后来我发现身边不少人把 Markdown 文档直接放 IDEA 工程里代码和文稿放一起用同一套工程管理、搜索、版本控制确实方便。但 IDEA 的编辑器只负责“显示文本”它不会告诉你今天写了多少字、刚才那半小时是不是在摸鱼。Typing Novel 的定位就一句话让 IntelliJ 系的 IDE 拥有基本的写作陪伴能力。核心功能我控制在三块实时字数统计当前文档字数、光标所在段落字数、文档总字数。打字速度与节奏统计每分钟击键数生成最近 30 分钟的节奏曲线。章节目标进度识别“第X章”之类的标题行给每一章设定字数目标显示完成百分比。一开始也想做云端同步、多人协作、排行动态这些东西后来全部砍掉了。原因很简单插件越重维护成本越高用户安装顾虑越大。JetBrains Marketplace 上大量插件的通病就是什么功能都塞最后跟 IDE 抢资源。轻量工具只需要把“打字”这件事做透用户自然愿意留。技术选型上IntelliJ 插件开发目前的标准方案是 Gradle IntelliJ Platform SDK。语言我选了 Kotlin理由很实在Kotlin 对 Java 库的互操作几乎没有摩擦而且写事件监听、状态管理这类回调密集的代码Kotlin 的语法糖能省不少样板代码。官方现在的插件模板也已经默认 Kotlin直接用不会踩兼容性大坑。后面你会发现 plugin.xml 里注册扩展点、写监听器Kotlin 的写法比 Java 清晰得多。2. 工程骨架搭建从零到能跑起来2.1 本地开发环境怎么准备做 IntelliJ 插件本质上是在跟 IDE 本身共享一套运行时所以环境配置跟普通 Java 项目不太一样。我的建议是这样一套组合JDK 17当前主流 IntelliJ 版本2022.3都用它跑。IntelliJ IDEA Community 或 Ultimate建议直接装你日常写代码的版本本地调试时的体验最接近真实用户。Gradle 8.x配合 gradle-intellij-plugin 使用。Kotlin 1.9.x跟 IntelliJ 平台自带的 Kotlin 版本不要差距太大。这里有一个新手最容易忽略的点你不是在自己的项目里引入 IntelliJ SDK 的 jar 包而是通过 Gradle 插件去“拉取”一个完整的 IDE 运行时作为依赖。所以构建脚本的核心是 gradle-intellij-plugin它负责下载 IDE、构建插件、提供 runIde 调试任务。我第一次接触时以为要手动管理一堆依赖后来发现完全不用。2.2 用 Gradle 直接初始化工程现在创建一个插件工程主要有两条路一是 IDE 内置的 New Project → IntelliJ Platform Plugin 向导二是手写 Gradle 文件。内置向导生成的是比较老的模板默认用 Java DevKit 方式配置分散不太推荐。我更建议手写或者基于官方 GitHub 模板改理由下面会说。这是我最小的 build.gradle.kts 配置足够跑通整个流程plugins { id(java) id(org.jetbrains.kotlin.jvm) version 1.9.24 id(org.jetbrains.intellij) version 1.17.4 } group com.yourname version 1.0.0 repositories { mavenCentral() } dependencies { implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1) } intellij { version.set(2024.1.4) type.set(IC) // IC IntelliJ CommunityIU Ultimate plugins.set(listOf(com.intellij.modules.platform)) } tasks { patchPluginXml { sinceBuild.set(231) untilBuild.set(243.*) } }这个配置里有几个关键细节intellij.version 指定的是“用来编译和调试的 IDE 版本”不是你的插件目标版本。默认用你当前最新的稳定版就好。type 用 IC 可以省下载量而且 Community 版覆盖了 90% 的扩展点如果你要依赖某些只有 Ultimate 才有的功能再改成 IU。sinceBuild / untilBuild 决定插件在哪些 IDE 版本上可见。这个区间不是随便写的我建议根据你实际测试过的版本范围来填太宽会有兼容性风险太窄用户装不上。patchPluginXml 任务会在构建时自动把 version、sinceBuild 这些参数写进 plugin.xml不需要手改。工程目录结构也顺带说一下照着这个建不会出错src/main/kotlin/com/yourname/typingnovel/ ├── TypingNovelPlugin.kt ├── listener/ ├── ui/ ├── service/ └── resources/META-INF/plugin.xml2.3 plugin.xml 里的那些“门道”plugin.xml 是整个插件的地基IDE 靠它来发现你的扩展点。它的基本结构不长但每个字段都有讲究idea-plugin idcom.yourname.typingnovel/id nameTyping Novel/name version1.0.0/version vendor emailyouexample.com urlhttps://example.comYourName/vendor description![CDATA[ Typing Novel is a writing companion plugin for IntelliJ IDEA. Real-time word count, typing speed monitor and chapter goals. ]]/description dependscom.intellij.modules.platform/depends extensions defaultExtensionNscom.intellij notificationGroup idTypingNovel notifications displayTypeBALLOON/ applicationService serviceImplementationcom.yourname.typingnovel.service.WritingStatsService/ /extensions applicationListeners listener classcom.yourname.typingnovel.listener.DocumentListenerImpl topiccom.intellij.openapi.editor.event.DocumentListener/ /applicationListeners /idea-plugin几点容易被文档带偏的地方depends 声明平台模块。如果只依赖系统默认模块写 com.intellij.modules.platform 就行。如果你的功能用到了 Java 相关的扩展点比如代码分析就得声明 com.intellij.modules.java。所有 class 属性必须写全限定名。我见过好几个人包括我自己把包名写漏编译能过但运行时报 ClassNotFoundException。applicationListeners 注册的是全局监听器不需要手动 registerIDE 启动时会按 plugin.xml 自动装配。这个机制非常方便但也意味着你在代码里不需要在 plugin.xml 和初始化函数之间搞双重注册。extension 和 listener 的注册时机不一样前者是 IDE 在构建组件树时拉取后者是应用启动时绑定。如果你的监听器需要访问某些 Service务必确保 Service 已初始化否则可能在启动阶段就 NPE。3. 核心功能实现让 IDE 听懂“打字”3.1 监听文档变化从敲击到字数统计Typing Novel 最核心的工作就是不间断地感知你“写了什么、删了什么”。IntelliJ 平台为此提供了 DocumentListener 接口只要文档内容变化回调就会触发。我的实现思路是通过全局事件通道挂一个 DocumentListener每次回调里只做轻量计算把结果写入一个内存中的状态对象再由 UI 层定期拉取刷新。核心代码大致长这样private fun installDocumentListener() { val connection project.messageBus.connect(this) connection.subscribe(DocumentListener.TOPIC, object : DocumentListener { override fun documentChanged(event: DocumentChangeEvent) { if (isTypingNovelFile(event.document)) { statsService.onDocumentChange(event) } } }) } private fun isTypingNovelFile(document: Document): Boolean { val file FileDocumentManager.getInstance().getFile(document) ?: return false return file.extension in setOf(md, txt, markdown) }注意这里两个细节。第一不要对 IDEA 里所有文档都做统计否则你编辑 Java 代码时也会被算进去。我只统计 md、txt、markdown 这类纯文本文件确保“写作计数”不污染。第二DocumentChangeEvent 触发频率极高每敲一个字都会来一次所以回调里绝对不能做磁盘写入或 UI 更新这类重操作否则 EDT 线程会卡到你怀疑人生。字数统计的核心逻辑不复杂但有一个反直觉的坑一个字一个字地统计正则匹配是可以的但在几万字的长文档里每次击键都全文正则一遍性能直接崩。我的做法是把文档拆成段落维护一个增量索引。具体方案是文档变更时先拿到变更区间startLine 到 endLine。只重算这个区间内的段落字数其他段落直接用缓存值。总字数 缓存总字数 - 旧段落数 新段落数。这套增量统计方案跑下来即使一篇 10 万字的文档编辑时也不会有可以感知的卡顿。之前用全文正则实测输入延迟高达几十毫秒改成增量后基本为零。3.2 打字速度计算滑动窗口里的节奏感打字速度KPM每分钟击键数看着简单但直接统计会有问题你连着打 3 个小时平均下来每个小时都是 500KPM看起来很稳定但这中间有多少次发呆、有多少时间在换思路全被抹平了。所以我决定用滑动窗口只统计最近 5 分钟内有效击键的速率。实现上就是一个循环队列存时间戳class TypingSpeedTracker(private val windowSizeMillis: Long 5 * 60 * 1000L) { private val keystrokes ArrayDequeLong() fun recordKeystroke(now: Long System.currentTimeMillis()) { keystrokes.addLast(now) while (keystrokes.isNotEmpty() now - keystrokes.first() windowSizeMillis) { keystrokes.removeFirst() } } fun currentKpm(now: Long System.currentTimeMillis()): Int { while (!keystrokes.isEmpty() now - keystrokes.first() windowSizeMillis) { keystrokes.removeFirst() } val minutes windowSizeMillis / 60_000.0 return (keystrokes.size / minutes).toInt() } }这里有个容易误判的点KPM 不能简单用“字数”来算因为中文输入法会把拼音串临时留在候选框里组合拼音时的击键并不等于上屏字数。如果按字数算打字速度用拼音输入的人速度会被高估。所以我改成直接数“文档内容变化的次数”也就是击键事件而不是上屏字符数。这样到了用户可见的数据上KPM 反而更接近“打字动作的节奏”而不是“打字正确的结果”。3.3 章节目标与持久化写完第三章进度条到 70% 了章节识别这块最好玩。我的方案是正则匹配行首的章节标题private val chapterRegex Regex(^第[0-9一二三四五六七八九十百千万][章节回].*) fun findChapterAtLine(lines: ListString, lineIndex: Int): String? { for (i in lineIndex downTo 0) { if (chapterRegex.containsMatchIn(lines[i])) { return lines[i].trim() } } return null }为什么要倒着找因为你要判断“当前光标所在段落属于哪一章”就往上扫描最近的一个标题。这里注意文档前几行不一定有章节标题所以找不到时要返回一个默认章节名比如“序章”。目标进度的计算逻辑分成两层章节内部进度和全局进度。局部进度用“当前章节已写字数 / 章节目标字数”全局进度用“所有章节已写字数 / 全书目标字数”。目标存哪里我用了 IntelliJ 提供的 PersistentStateComponent这是官方推荐的插件数据持久化方式State(name TypingNovelSettings, storages [Storage(typingNovelSettings.xml)]) class WritingStatsService : PersistentStateComponentWritingStatsService.WritingStats { data class WritingStats( var totalWordsWritten: Long 0, var chapterTargets: MutableMapString, Long mutableMapOf(), var dailyHistory: MutableMapString, Long mutableMapOf() ) private var state WritingStats() override fun getState(): WritingStats state override fun loadState(state: WritingStats) { this.state state } }这个方案有几个好处配置会安全地存到 IDE 的 config 目录用户换电脑同步配置时写作目标还能保留。但注意PersistentStateComponent 的存储文件格式不能随意改一旦发布后字段结构有变化要考虑旧数据兼容不然老用户的进度会“神秘消失”。4. UI 交互与做减法的边界4.1 状态栏不影响编辑器但时时刻刻看得见插件的 UI 分为两个层级轻量的状态栏和完整的 Tool Window。状态栏适合放“看一眼就知道”的信息比如“当前 3250 字 / 今日 12000 字”。我用 StatusBarWidget 实现了一个自定义组件挂在编辑器右下角。实现步骤不复杂实现 StatusBarWidget 接口返回唯一 id。在 widgetPresentation 里返回一个 LabelPresentation。在 StatusBar 上注册这个 widget通常放在项目文件的右侧。这里要吐槽一下 IntelliJ 平台的 API 变更。老版本用 StatusBar.addWidget() 手动注册新版本推荐使用 ExtensionPointName 注册 statusBarWidgetFactory。两个版本写法完全不同这也就是为什么打包前一定要测试多个 IDE 版本的原因。我的做法是在代码里判断 platform 版本分别走两条注册路径而不是硬撑某一个 API。状态栏的刷新频率也要克制没必要每敲一个键就调用 setText那会频繁触发 UI 重绘。我做了个 coalesce 合并每 500ms 才刷新一次。实测用户几乎察觉不到延迟但 CPU 占用直接从 8% 降到了 1% 以内。4.2 Tool Window把今日数据和节奏曲线放一起状态栏放不下的东西就交给 Tool Window。Typing Novel 的写作面板我分了三块顶部今日字数、今日目标、本月累计三个大数字卡片。中部章节目标进度条每个章节标题配一个百分比。底部30 分钟打字曲线用简单的 Swing 组件绘制不引第三方图表库。Tool Window 注册沿用的还是老一套在 plugin.xml 里写extension defaultExtensionNscom.intellij toolWindow idTypingNovel anchorright icon/icons/typing-novel.svg factoryClasscom.yourname.typingnovel.ui.TypingNovelToolWindowFactory/ /extensionFactory 里创建 JPanel往里面塞你想要的组件。这个工具窗口适合固定放在右侧配合编辑器和 Markdown 预览左手写右手看进度。这里有个 UI 线程的纪律问题Tool Window 的所有操作都要在 EDTEvent Dispatch Thread上做而统计数据的写入是后台线程。不要把后台线程的数据模型直接塞给 Swing 组件中间加一层“快照”对象不可变数据类EDT 每 2 秒取一次快照渲染。这个设计听起来简单但能省掉一大堆并发 bug。4.3 性能设计一个插件的自我修养作为一个在编辑器里长期驻留的插件Typing Novel 的性能优先级高于一切。我踩过的性能坑主要有三个第一个是文档事件的“高频风暴”。DocumentChangeEvent 在输入法组合、自动补全时都会密集触发没有合并处理的话CPU 直接打满。我的策略是所有统计逻辑只记时间戳和变更区间真正的重计算交给一个 300ms 的 debounce 定时器。第二个是对象泄漏。每次打开新的 editor 都会触发 EditorFactory 的 listener如果你忘了 dispose等于每次都泄漏一个监听器。时间长了 IDE 会越来越慢。务必在 Disposer 里注册解绑方法。第三个是图标资源。插件图标文件不能太大SVG 控制在 5KB 以内PNG 图标注意 DPI。Marketplace 对插件包体积也有要求能小则小。5. 打包、调试与发布从本地到 Marketplace5.1 用 runIde 做本地验证第一次跑插件最好的调试方式不是打包安装而是直接用 Gradle 启动一个嵌入了插件的 IDE 实例。执行./gradlew runIdeGradle 会下载指定版本的 IDE然后自动加上你插件模块的 classpath。你可以在代码里打断点调试器直接连上“作为插件运行的那个 IDE”跟开发普通后端应用一样。这里有个很实用的技巧runIde 默认启动的是一个全新的 IDE 实例用户配置是空的很多环境问题测不出来。你可以在 build.gradle.kts 里指定intellij { // 使用本机已有的 IDE 配置 ideDir.set(file(/Applications/IntelliJ IDEA.app/Contents)) }这样 runIde 会继承你本机的配置、插件和主题测试起来更接近真实环境。但注意这样容易把“本机有的插件”误认为“用户也会有”发布前还是要用干净配置跑一遍。5.2 打包构建和 Marketplace 上传本地验证没问题后打发布包./gradlew buildPlugin生成的 zip 位于 build/distributions/ 目录。这个 zip 就是最终上传到 Marketplace 的文件。上传流程不复杂注册 JetBrains Marketplace 账号创建自己的 Vendor。在账号后台找到 Upload Plugin 入口。填写插件名称、简介、分类、许可证上传 zip。等待审核。第一次审核通常 1~3 个工作日后续更新审核会快一些。审核阶段有个隐藏坑Marketplace 后台会下载你声明的 sinceBuild/untilBuild 对应的 IDE 版本在干净环境下做一次兼容性检查。如果你的 sinceBuild 填低了但代码用了比较新的 API后台可能直接判定不兼容。我的经验是宁可把 sinceBuild 抬高一点也不要为了覆盖更多版本而冒险使用旧 API。5.3 发布前检查清单这部分是长时间维护插件得出的血泪清单每一条都对应过至少一次真实事故检查项说明出问题时的表现插件 id 唯一性Marketplace 上不能重名上传后显示 id 冲突sinceBuild/untilBuild 区间真实必须在这些版本上手动测试过老版本装不上或闪退plugin.xml 描述不带 HTML 漏洞MarketPlace 会转义但description里不要塞 script审核被拒插件包大小不要引入大型依赖库上传慢用户下载意愿低IDE 版本兼容同一份代码至少跑一个 LTS 版本和一个新版本某些 API 在新版本被移除图标设计不能使用 IDE 自带图标或商标审核被拒涉及版权问题版本号语义化不要用 1.0.1 之后直接跳 2.0.0除非有破坏性变更用户升级后配置失效6. 实战经验我踩过的坑和解决套路6.1 插件开发中最常见的五个问题第一个问题是 Kotlin 版本冲突。默认情况下Gradle 插件的 Kotlin stdlib 会被打包进插件这会导致运行时跟 IDE 自带的 Kotlin 版本撞车报各种 NoClassDefFoundError。解法是显式关闭 stdlib 依赖tasks.buildSearchableOptions { enabled false } configureorg.jetbrains.intellij.tasks.PatchPluginXmlTask { // 保证插件运行时使用 IDE 的 Kotlin }其实更直接的做法是在 gradle.properties 里设kotlin.stdlib.default.dependencyfalse这行配置写过两次以上的人都知道它是稳定打包的命根子。第二个问题是 plugin.xml 里的扩展声明和代码类不一致。这个通常发生在重构之后你改了类名或包路径但忘了同步 plugin.xml。IDE 在启动时加载扩展点ClassNotFoundException 会直接导致插件失败且没有明确错误。排查方法是看 idea.log里面有详细的类加载信息。每次重构后记得全局搜索 plugin.xml 里的类名。第三个问题是 JDK 版本和 targetCompatibility 不匹配。用 JDK 17 编译的 class 放到要求 JDK 11 的 IDE 上直接 UnsupportedClassVersionError。建议统一设置编译目标并在 CI 里用目标版本跑一遍。第四个问题是 EDT 线程卡顿。比较隐蔽很多时候正常打字没问题一旦鼠标选中大面积文本或者打开一个超大文件时文档事件回调里做了太多计算EDT 就冻结了。这个我前面已经说过这里再强调所有纯数据处理切到后台线程UI 刷新用 timer 合并。第五个问题是 sinceBuild 和 untilBuild 覆盖范围写错。特别是用通配符比如243.*时你得确保新版本 API 没有破坏性变更。JetBrains 每年都有一版 API 大清理很多扩展点被标记 Deprecated下一年直接删除。稳妥做法是只声明你实际测过的版本每季度更新一次版本区间。6.2 兼容性问题的摸排套路如果插件在某台机器上表现异常先分类型是“装上就报错”还是“用的时候报错”这两类问题的排查路径完全不同。碰上“装上就报错”80% 跟 plugin.xml 的声明有关。打开 Help | Show Log in Explorer找 idea.log 里跟插件 id 相关的 Exception通常几分钟就能定位。另外用 IntelliJ 自带的 Plugin DevKit 工具可以检查扩展点的声明是否符合 ID。碰上“用的时候报错”多半是某个 API 在你的目标版本里不存在或签名变了。我的做法是在代码里写一个小型兼容层把平台相关的 API 调用集中到三四个工具类里换版本时只改这几处。比如状态栏注册老 API 和使用新 API 的代码可以同时存在运行时通过反射判断当前平台版本走哪一条路。还有一个比较好用的排查方法在本地跑多个 IDE 版本每个版本开一个干净的虚拟环境。这个测试虽然费时但能提前发现大量发布后才会炸的问题。我现在的习惯是每逢新的 IDE 大版本发布先跑一遍 runIde 验证核心功能再把 sinceBuild/untilBuild 区间更新到最新。6.3 做这个项目让我学到的东西Typing Novel 从雏形到发布中间折腾了差不多三周。技术上最有价值的收获是我彻底搞懂了 IntelliJ 插件的生命周期。它不像普通 Java 程序那样 main 函数启动、执行、退出而是深度绑定在 IDE 的扩展点系统里应用启动时加载 application 级组件项目打开时加载 project 级组件编辑器打开时加载 editor 级组件每一层的生命周期都有对应的创建和销毁时机没处理好就是内存泄漏。另一个收获是“给用户做减法”这件事。插件越做越大很容易但让一个功能聚焦到极致反而难。Typing Novel 的每个统计细节都经过反复取舍不加社交、不加云端只保留“帮你把字写完”这个核心场景。结果倒是意外地受欢迎不少人反馈说它就是想要这样一个小而直的写作工具。如果这个项目你打算继续深入我建议下一步可以从两个方向发力一是针对 Markdown 文档做更细的写作数据可视化比如每天写作时段分布图二是尝试接入本地模型做灵感辅助——注意是自己配置模型服务地址和密钥不要依赖任何第三方云服务这既安全又保护用户隐私。总之先把打字统计这个地基打牢后面加什么功能都不会跑偏。
返回列表