Android Studio国际化插件开发实战:从硬编码扫描到自动化资源替换
1. 为什么我要自己动手写一个国际化插件
做 Android 开发的朋友应该都有过这样的经历:项目做到一半,产品突然说“我们要出海,支持英文”,或者“下个版本要加日语、韩语”。这个时候你打开项目一看,几百个字符串散落在十几个 layout 和代码文件里,全部硬编码,没有 values-en、values-ja 这些资源目录,更别说做多语言适配了。
我最早处理国际化需求的方式非常原始:全局搜索双引号字符串,手动复制到 strings.xml,逐条翻译,再手动替换代码里的硬编码。一个中型项目搞下来,两三天时间就没了,而且很容易漏掉某个 Activity 里的字符串。后来我试过一些现成的国际化方案,要么是命令行工具需要额外配置,要么是收费的,要么不够贴合 AS 的操作习惯。折腾了一圈后,我决定自己写一个 Android Studio 插件,把“提取硬编码字符串、生成多语言资源、自动替换代码引用”这条链路完全自动化。这就是 SmartI18n 这个插件项目的由来。
这篇文章会完整复盘这个插件的设计思路、核心实现、踩坑记录和实际使用效果。不管你是想给自己的团队做一个内部提效工具,还是单纯对 IDE 插件开发感兴趣,这篇内容都能给你一个完整的参考路径。
先说清楚这个插件能做什么。它以 Android Studio 和 IntelliJ IDEA 为运行环境,解决的核心痛点是:字符串资源国际化时的机械重复劳动。具体来说有三个能力:
- 扫描项目代码和布局文件中的硬编码中文字符串
- 一键生成多语言 strings.xml 资源文件
- 自动将代码里的字符串替换为 @string/ 引用
听起来不复杂,但真正实现起来,牵扯到 IntelliJ Platform SDK 的很多底层机制,包括 PSI(程序结构接口)解析、文件监听、资源文件合并、代码修改回滚等等。接下来我会把整个开发过程掰开揉碎了讲,包括每一步的思考和取舍。
2. 插件的整体设计与技术选型
2.1 为什么选择 IntelliJ Platform SDK 而不是其他方案
在动手之前,我其实评估过三种实现路径。
第一种是写一个独立的命令行工具,用 Python 或 JavaScript 扫描代码文件,然后批量替换。这个方案缺点很明显:和 IDE 的工作流是割裂的,开发者需要在终端和编辑器之间来回切换,而且对代码文件的修改容易破坏语法结构——你没办法保证正则替换之后,Java 文件里的字符串拼接和资源引用是合法的。
第二种是基于 Gradle 插件实现。Gradle 插件可以在构建阶段自动处理资源,但问题是它不是实时的,开发者每次都要跑一次构建才能看到效果,反馈链路太长。而且 Gradle 插件没法直接操作 IntelliJ 的编辑器模型,做不了“在代码里直接预览替换结果”这种交互体验。
第三种就是 IntelliJ Platform SDK,也就是 IDE 插件方案。这是和 Android Studio 结合最紧密的方式,可以使用 PSI 精确地解析和处理代码,避免字符串误替换,也可以在编辑器中实时展示修改建议,用户确认后再执行替换,安全性最高。
我最终选择了第三种方案。虽然它的学习曲线最陡峭,但从产品的角度来说,这恰恰是体验最好的方案——一个 Android 开发者不需要离开 Android Studio,只需要右键点一下,或者用快捷键呼出插件面板,就能完成整个国际化流程。
2.2 SmartI18n 的模块划分
为了让代码结构清晰,我把插件分成了四个模块:
- 扫描模块:负责遍历项目源文件,识别出所有硬编码字符串
- 资源生成模块:根据扫描结果生成或更新 strings.xml 文件
- 代码替换模块:把源文件中的字符串字面量替换为资源引用
- UI 交互模块:提供设置界面、结果预览面板和操作入口
这四个模块之间的依赖关系是单向的:扫描模块产出字符串列表,资源生成模块消费这个列表生成资源文件,代码替换模块再读取资源文件做替换,UI 模块负责把整个流程串起来。这种分层设计的好处是每个模块都可以独立测试,后面要加新功能(比如支持更多语言、支持更多文件类型)也只需要在对应模块里扩展。
2.3 技术栈和开发环境
开发这个插件用到的核心依赖是 IntelliJ Platform SDK。我选择的版本是 202.7660.26,对应 Android Studio 4.0 之后的版本。这里有个经验:版本选择一定要和大部分团队成员使用的 IDE 版本匹配,否则插件装不上或者部分功能失效。
开发语言我用的是 Kotlin。IntelliJ 平台本身大量使用 Kotlin,用 Kotlin 写插件可以天然享受许多平台 API 的语法糖,而且很多官方文档和开源插件的示例代码都是 Kotlin 写的,参考资料更好找。构建工具用 Gradle,配合 gradle-intellij-plugin 插件来自动下载 SDK 和打包插件。
提示:如果你是第一次接触 IDE 插件开发,建议先不要急着写业务逻辑。花半天时间跑通一个空的插件项目(Action、Plugin.xml、Gradle 配置),理解插件是怎么被 IDE 加载的,再开始加功能。这个基础步骤省不得。
3. 核心技术点解析:PSI、扫描与替换
3.1 什么是 PSI,为什么它比正则表达式可靠
如果你写过代码处理工具,一定知道用正则表达式处理代码有多容易出问题。比如你想匹配 Java 字符串常量,正则可能会匹配到注释里的内容,或者把字符串的一部分误判为完整字符串。多行字符串、转义字符、字符串拼接,任何一个情况都能让正则崩溃。
IntelliJ Platform 提供了一套专门处理源码结构的 API,叫做 PSI(Program Structure Interface)。简单来说,PSI 把源码文件解析成了一个树状结构,每一个语法元素(类、方法、变量、字符串字面量)都是一个 PSI 节点。你不需要关心文本内容是什么样,只需要遍历这个树,找到需要的节点类型,就能精确定位到代码里的每一个字符串字面量。
用生活类比来解释这件事:正则表达式像是戴着手套在工具箱里摸螺丝钉,你摸到的可能是螺丝钉,也可能是铁钉、图钉甚至弹簧。而 PSI 像是把工具箱的所有抽屉都贴上标签,你打开对应抽屉拿出来的东西,一定是正确的。
在 SmartI18n 中,我要做的第一件事就是通过 PSI 遍历 Java/Kotlin 文件中的字符串字面量节点。Java 中的字符串字面量节点类型是 PsiLiteralExpression,Kotlin 中则是 KtStringTemplateExpression。遍历的方式很简单:获取当前文件的 PSI 根节点,然后递归遍历所有子节点,判断节点类型即可。
这里有个细节:Java 中的字符串可能在很多位置出现,比如变量声明、方法参数、注解参数、switch case 等。如果只处理 PsiLiteralExpression,switch case 里的字符串常量(如 case "hello")是匹配不到的,因为它的节点类型是 PsiCaseLabelElement,需要单独处理。这个知识点是我在测试阶段发现漏检时补上的。
3.2 扫描策略:如何避免误伤和漏网
扫描策略是这个插件最核心、也最容易出问题的部分。我的目标不是把代码里所有字符串都提取出来,而是要提取“需要国际化的业务字符串”。这就要排除掉很多不需要处理的场景。
先说需要排除的情况。日志字符串(Log.d("TAG", "something"))一般是开发调试用的,不需要翻译。Toast 和 Dialog 的标题、按钮文案需要处理——但 Snackbar 的 action 文案也得处理。布局文件里的 android:text、android:hint、android:contentDescription 这些属性需要处理。另一个容易漏掉的是 android:label、android:summary 这些在 XML 中比较少见的属性,也需要加入扫描规则。还有资源文件里已有的字符串不需要重复生成,如果代码里已经使用了 getString(R.string.xxx),那也不需要处理。
判断一个字符串是否需要国际化,我采用的是“黑名单 + 白名单”的组合策略。黑名单包括:纯数字、纯符号、URL、文件路径、True/False/null 等常量词、日志 TAG、正则表达式模式串。符合这些特征的直接跳过。白名单包括:包含中文字符的字符串、包含日文假名的字符串、包含韩文文字的字符串——这类字符串基本可以肯定需要国际化。
原理上,之所以黑白名单组合比单一规则实用,是因为业务字符串的特征太复杂,单靠“是否包含中文”来决定会漏掉纯英文的硬编码(比如有的团队习惯直接写英文 UI 文案),单靠“是否是引号字符串”来提取又会把大量非 UI 字符串混进来。黑白名单机制允许用户自定义规则,适合不同团队的不同编码习惯。
3.3 XML 文件的资源生成
扫描完之后,插件需要生成字符串资源文件。这里涉及到 Android 资源系统的几个知识点了。
Android 的字符串资源按语言存放在 res/values-语言码/strings.xml 中。默认语言放在 res/values/strings.xml,英文放在 res/values-en/strings.xml,日文放在 res/values-ja/strings.xml。资源 key 的命名要保持唯一且有意义,比如 login_button_text、home_title,而不是 str_1、str_2。
SmartI18n 的生成逻辑是这样的:默认语言使用原始字符串(比如中文),目标语言需要用户提供翻译。翻译从哪来?我做了三种方案:手动填写、调用在线翻译 API、导入已有的翻译文件。手动填写是最朴素也是兜底方案,在线翻译 API 需要用户自己申请 key,批量翻译速度快但准确性需要人工校对。
生成 XML 文件时需要注意两个点。第一,字符串里的特殊字符必须转义,比如单引号、双引号、&、<、> 这些符号在 Android 资源文件中有特殊含义。第二,有些字符串包含格式化占位符,比如 "欢迎回来,%s",在翻译成其他语言时,占位符的顺序可能会变化(比如日语中会把对象说在前、动作说在后),所以生成资源时要保留 %1$s 这种带位置的占位符格式,方便翻译时调整顺序。
3.4 代码替换的底层机制
生成资源之后,插件要做的最后一步是替换代码里的硬编码字符串。
这里的关键机制是 IntelliJ 的 WriteAction。IDE 对代码文件的修改必须放在 WriteAction 中执行,否则会抛出异常。但 WriteAction 不只是加锁那么简单——它保证了代码修改的事务性,也就是说,如果修改过程中出现问题,可以回滚到修改之前的状态。
我的实现思路是:遍历字符串节点,记录它在代码中的位置和原始内容,为每个节点生成一个 Replace 操作,收集所有操作后,一次性在一个 WriteAction 里执行。这比每改一个节点开一个 WriteAction 要高效得多,而且保证了修改的原子性。
替换操作本身也有讲究。Java 代码中,字符串出现在 TextView.setText("hello") 这种场景时,可以直接替换为 TextView.setText(R.string.hello);但字符串出现在 getString("key") 这种需要字符串参数的方法中时,不能直接替换为资源 ID,因为方法签名不匹配。这种情况我会在结果预览面板中标记为“需要手动处理”,提示开发者改用 context.getString(R.string.hello)。
4. 实操过程与核心环节实现
4.1 开发环境的完整配置
如果你跟着这篇文章从头搭建开发环境,我的路径是:安装 JDK 11、Android Studio 4.2、IntelliJ IDEA Community Edition(用于 SDK 下载和调试),然后创建插件工程。
插件工程的 Gradle 配置是关键环节。build.gradle 文件里配置了平台类型和版本:
注意到我这里引入了 OkHttp,是为了调用在线翻译 API。如果你不想依赖网络,也可以去掉这个依赖,只保留本地手动翻译功能。
plugin.xml 是插件的描述文件,声明了插件的 id、name、依赖的模块和注册的 Action:
depends 这里特别说明一下:因为插件需要解析 Android 资源文件,必须依赖 org.jetbrains.android 模块。如果不加这个依赖,插件可以正常加载,但是无法识别 Android 特有的 PSI 元素和资源文件结构。
4.2 Action 的创建与用户交互入口
IntelliJ 插件的功能入口通常是一个 Action。Action 是用户在 IDE 中触发某个操作时执行的类,它继承了 AnAction 并重写 actionPerformed 方法。右键菜单、快捷键、工具栏按钮都可以绑定 Action。
我在 SmartI18n 中注册了两个 Action:一个是 ScanAction,用于在右键菜单中触发扫描;另一个是 GenerateAction,用于在资源预览面板中点击“生成”按钮时触发。这里有个开发经验的细节:不要在 actionPerformed 中执行耗时操作,否则 IDE 会卡住。扫描整个模块的字符串可能耗时几秒到几十秒,必须放到后台任务中执行。
后台任务在 IntelliJ 平台中叫做 ProgressManager,用法如下:
如果不在后台执行,用户会看到一个假死的 IDE,体验非常差。这是我在早期版本里踩过的一个大坑——扫描 200 个文件时,IDE 直接 ANR(应用无响应)。后来用后台任务解决,体验好了很多。
4.3 扫描结果预览面板
为了让用户知道插件准备做什么,我设计了一个结果预览面板,用 JTable 展示扫描到的字符串。每一行显示:
- 来源文件(比如
MainActivity.java或activity_main.xml) - 原始字符串内容
- 建议的资源名称
- 处理状态(待处理、已跳过、需要手动处理)
这个面板是插件和用户之间最重要的交互界面。用户可以在面板中勾选或取消勾选某个字符串,决定是否要提取它。这个功能很重要,因为自动扫描不可能每次都对——有的字符串确实不需要国际化(比如日志 TAG),有的可能是第三方库里的代码,不应改动。
JTable 配合 TableModel 实现,每行的勾选状态用一个 Boolean 列保存。用户点击“开始处理”按钮后,插件会根据勾选的行生成资源和替换代码。
4.4 在线翻译 API 的集成
翻译功能是 SmartI18n 的加分项。我集成了几个翻译 API 的通用接口,用户可以在设置页面填入自己的 API key,也可以选择手动翻译模式。
这里选择 API 时考虑了几个因素。百度翻译的市场份额高、文档齐全,但对于中文的翻译质量在个别场景下会显得“生硬”。有道翻译在某些垂直领域的术语翻译更准,但个人开发者申请 key 的额度限制较多。Google 翻译的准确性和语种覆盖是最全面的,但由于网络环境限制,在某些运行环境里无法正常调用,反而成了不确定性因素。DeepL 在翻译长文本时的自然度确实更强,但免费额度有限。由于插件本身是开源项目,我把这些翻译源的适配都做成独立接口,用户根据自己的网络环境和预算选择即可——毕竟翻译不是插件的主流程,没有完全自动化的场景也不需要依赖它。
翻译接口的网络请求我统一封装在一个 Translator 类中,支持异步调用。翻译失败不会阻塞主流程,插件会提示“翻译失败,请手动填写翻译”,然后继续处理其他字符串。这种降级策略很重要——工具应该辅助人,而不是惹恼人。
4.5 编译、打包与本地安装验证
开发和测试基本完成后,就到了编译打包这个环节。通过 Gradle 的 buildPlugin 任务可以生成 zip 包:
生成的 zip 包在 build/distributions/ 目录下,可以直接通过 Android Studio 的 Settings -> Plugins -> Install Plugin from Disk 安装。
在正式发布到 JetBrains Plugin Repository 之前,我建议先做一个内部测试。把 zip 包发给团队里的几个人,让他们在实际项目中用一段时间,收集反馈。这个阶段特别要注意的是:插件是否兼容你们项目里使用的 Gradle 版本、AGP 版本、Kotlin 版本——这些环境差异往往比代码 bug 更容易导致插件崩溃。
5. 常见问题与排查技巧实录
5.1 Action 点不了、插件没反应怎么办
这是 IDE 插件开发里最常见的问题:插件装上了,菜单也出现了,但点击 Action 没反应,也不报错。
排查思路是:先看 plugin.xml 里 Action 的 class 路径是否写对了。路径错误会导致 ClassNotFoundException,但 IDE 不一定会在界面上显示错误。打开 IDE 的日志文件(Help -> Show Log in Finder/Explorer),搜索 ClassNotFound 或 SmartI18n,就能看到具体的报错信息。
另一个原因是 Action 没有在 plugin.xml 中注册,只是写了一个类。我早期经常犯这个错——写了一堆 Action 类,忘了在 xml 里注册,结果毫无反应。
还有可能是 IntelliJ Platform SDK 版本和 IDE 版本不兼容。如果你用新版本 SDK 编译的插件装到旧版本 IDE,可能连插件列表里都看不到。这时候建议用 sinceBuild/untilBuild 声明版本范围,同时把 IDE 升级到兼容版本测试。
5.2 扫描慢、卡顿、内存溢出的优化
扫描整个模块的字符串是 IO 密集和 CPU 密集操作。早期版本我用的是递归遍历 PSI 树并同步逐个文件扫描,遇到大型项目时(比如 500 个以上 Java 文件)会明显卡顿。优化方案主要有三个。
- 使用
FileBasedIndex代替手动遍历文件。IntelliJ 平台有一套文件索引机制,我们可以用它对项目中包含中文字符的字符串快速建立索引,避免全量解析。 - 多线程分片扫描。把文件列表按线程池大小分片处理,每个线程处理一个文件列表,最后合并结果。
- 在扫描过程中使用
ProgressManager.checkCanceled()检查用户是否点击了取消按钮,支持随时中断操作。
FileBasedIndex 的使用方式可以这样理解:它是 IDE 内部的文件搜索引擎,本质上是在做内容索引。你用 FileBasedIndex.getInstance().processValues() 时,底层就涉及到了 IntelliJ 平台自己的守卫机制——索引在建立过程中会对文件加状态锁,所以我的经验是,如果只是想一次性扫描,直接遍历文件比走一遍索引更快;但如果需要经常扫描同一批文件,通过索引能省下很多时间。
还有一种情况:第三方库的源码也被扫描进来了。解决办法是在扫描时获取 module.getModuleScope(),只扫描模块的源码集,不扫描外部库的编译产物和依赖缓存。
5.3 替换后代码编译不过、字符串被误替换怎么办
如果插件把代码里的字符串替换成资源引用后,项目编译失败,最常见的原因是:替换后的方法签名不匹配,比如把 textView.setText("hello") 替换成了 textView.setText(R.string.hello),这个方法本身没问题,但如果有其他代码期望的是 String 类型,就会编译报错。
我的插件之所以在预览面板里特意增加了“需要手动处理”的标记,就是为了应对这种情况。经验原则是:插件只处理 100% 安全的情况,不确定的情况一律交给人类判断。
另一个编译失败的可能原因是资源文件中有重复的 key。如果 values/strings.xml 中已经存在一个 login_button_text,插件扫描到同一个字符串再次生成时,就会重复声明,导致编译失败。所以在生成资源之前,插件会检查现有资源文件,已存在的 key 直接跳过,只追加新增的 key。
如果你遇到被误替换的情况,最好的调试方法是使用 IDE 的 Local History(本地历史)功能。这一步在偏好设置里默认是开启的。右键点击被修改的文件,选择 Local History -> Show History,就能看到修改前后的差异,一键回滚到修改前状态。我自己的习惯是,对核心模块做批量替换之前,先用 VCS 提交一下,保证出问题可以随时回退。
5.4 中文注释里的字符串也被扫描出来了
这个问题在最初的版本里非常常见:代码注释里有中文,比如 // 上面的代码是获取用户ID,插件误以为这是需要国际化的中文内容。
后来我在扫描逻辑中增加了一个判断:检查字符串节点的父节点是否是 PsiComment。如果是注释节点,直接跳过。这个判断在 PSI 树中很容易实现,但对于文本内容里的中文,还有个更隐蔽的情况——代码中有一个中文字符串但它是通过 BuildConfig 或常量定义的,不应该被替换。
所以最终的策略是:先通过 PSI 准确理解代码结构,再通过“字符串的使用场景”判断是否要处理。比如,检查字符串节点的父级调用者是 Log.d、System.out.println 之类的,就跳过;如果是 setText、setTitle、setHint 之类的 UI 方法,就处理。这比单纯判断是否包含中文要可靠得多。
5.5 不同 Android Studio 版本兼容性问题
这个是插件维护阶段最让人头疼的问题。IntelliJ 平台更新频繁,每隔一两个版本,一些 API 就会被废弃或变更,导致插件在新版本 IDE 上完全无法工作。
我自己的应对方法是:
- 使用
@Deprecated注解标记的方法时,先查一下官方文档确认是否有替代方案。 - 保持插件版本发布频率,尽量跟随 Android Studio 的稳定版本进行适配。
- 在
plugin.xml中设置合理的sinceBuild范围,避免用户在不支持的版本上安装。
举个例子,早期版本我用的是 JavaPsiFacade.getInstance(project).findClass() 来查找类,后面发现这个 API 在新版本中被标记为 deprecated,需要改为 JavaPsiFacade.getInstance(project).findClass(qualifiedName, GlobalSearchScope.allScope(project)) 的写法。这类小改动非常频繁,需要有耐心。
5.6 常见问题速查表
我把开发过程中常遇到的问题整理成一张速查表,方便工作遇到同类问题时直接查阅:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 插件安装后菜单中无入口 | plugin.xml 中未注册 Action | 检查 plugin.xml 的 actions 节点是否正确 |
| 点击 Action 无响应 | Action class 路径错误 | 查看 IDE 日志文件中的 ClassNotFoundException |
| 扫描结果为空 | 扫描范围配置不对 | 检查是否扫描了正确的 module 和 sourceSet |
| 中文注释被误提取 | 未过滤 PsiComment 节点 | 在扫描逻辑中增加注释节点跳过逻辑 |
| 资源 key 重复导致编译错误 | 已有资源未检测 | 生成前先读取现有 strings.xml 并合并去重 |
| 替换后编译失败 | 方法签名不匹配 | 使用“手动处理”标记,让用户确认 |
| 代码被误替换 | 字符串使用场景判断不完整 | 回滚本地历史,完善场景白名单/黑名单 |
| 新版本 AS 插件失效 | API 废弃或包名变更 | 追踪 IDE 升级日志,及时适配新版 SDK |
6. 多文件同步替换与大型项目落地实践
6.1 处理跨文件的字符串资源移动
在实际项目中,同一个字符串可能出现在多个文件里。比如登录按钮的文案 "登录",在 LoginActivity.java、activity_login.xml、LoginViewModel.kt 中各出现一次。手动处理时需要改三处,而 SmartI18n 可以把这三处合并成一个资源 key,三个引用点全部替换成 R.string.login_button_text。
实现这个逻辑时,字符串的去重和合并是关键。扫描阶段收集到所有字符串后,会按字符串内容做分组,相同内容的字符串归为一组。但这里有一个坑——如果两个字符串的内容相同,但语义不同(比如 "确定" 在删除对话框和保存对话框里的含义有细微差别),就不应该合并成一个 key。所以我给每组字符串增加了“上下文预览”功能,在预览面板中展示每个字符串出现的类名和方法名,让用户决定是否合并。不合并的情况下,插件会自动在资源 key 后面追加数字后缀,比如 confirm_dialog_1。
6.2 Kotlin 文件和 Java 文件的处理差异
同一项目往往同时包含 Java 和 Kotlin 代码,两种语言的字符串处理有细节差异。
Kotlin 的字符串模板(如 "Hello, $name")是由多个 PSI 片段组成的:一个普通字符串文本片段和若干个表达式片段。如果直接提取整个字符串为资源 ID,会丢失模板变量。正确做法是:把模板部分提取为带占位符的资源,比如 "Hello, %1$s",再用 getString(R.string.hello, name) 的方式传参。
另一个 Kotlin 特有的问题是字符串前面可以加前缀 r 表示原始字符串(raw string),这种字符串可以包含换行和多行文本,直接提到资源文件里并不合适,因为 XML 资源文件中的字符串默认不支持跨行。遇到 raw string,插件会跳过并提示用户手动处理。
6.3 增量扫描与缓存机制
大型项目往往有几千个文件,每次全量扫描耗时太长。我实现了增量扫描:通过 IntelliJ 的文件修改事件,记录哪些文件在上次扫描之后被修改过,只扫描增量部分。
IntelliJ 平台提供了 FileContentWatcher 和 VirtualFileListener 两种方式监听文件变化。VirtualFileListener 需要手动注册和注销,而 FileContentWatcher 会一直运行。为了插件性能,VirtualFileListener 的方式更轻量,我采用的是这种方案。
缓存机制方面,我把扫描结果序列化到本地,存储为 JSON 文件,保存每个文件的内容哈希和提取出的字符串列表。下次扫描时,先检查文件哈希是否变化,没变化就直接用缓存结果,变化才重新扫描。这个优化让大型项目的二次扫描时间从十几秒降到了 1 到 2 秒。
6.4 在团队中落地推广的经验
写插件不只是技术活,还牵扯到团队协作。我发现,工具只有真正被团队用起来,才有价值。促进团队落地,我的经验是:给每个人开放自定义规则的权限、把插件的使用流程文档化,并且第一时间处理反馈意见。
举个具体的例子:有团队成员希望跳过项目内 test/ 目录下的测试代码扫描,有人希望在某些模块(比如网络层)不做注入式替换,这些需求如果实现成本不高,我都会及时更新,形成新版本。插件不是写完就结束的静态项目,而是一个需要持续迭代和用户反馈驱动的工具。
7. 插件性能优化与安全机制设计
7.1 内存占用与扫描阶段的 GC 优化
处理大型项目时,扫描阶段需要把所有文件的 PSI 树加载到内存中。如果一次性加载 500 个文件的全部 PSI,内存占用非常惊人。我的优化方案是:分阶段处理,先用 FileBasedIndex 做快速筛选(只找出可能包含中文字符的文件),然后逐个文件延迟加载 PSI,扫描完一个文件就丢一个引用。
这样做的原理是:IntelliJ 平台对未引用的 PSI 对象有内存回收机制,只要你不主动持有它的强引用,IDE 就能在内存不足时自动回收。所以在扫描模块中,我避开了把所有文件都放集合里统一扫描的写法,而是逐个文件处理并立刻释放引用。
7.2 防止插件在后台修改代码时与用户编辑冲突
用户可能在插件替换代码的同时手动编辑同一个文件,这会导致修改冲突。我在实现时处理的策略是:在执行 WriteAction 之前,检查文件的修改时间戳是否和扫描时一致。如果不一致,说明文件已被用户修改,跳过该文件的自动替换,并提示用户重新扫描。
时间戳检查的底层原理是 VirtualFile 的 getModificationStamp() 方法。这个方法返回文件的修改标识,每次文件保存或修改时都会变化。扫描时记录每个文件的修改时间戳,替换前再验证一次,双保险。实测下来,这个机制在多人同时操作同一个项目时非常有效——至少不会出现插件把用户刚改好的代码覆盖掉的情况。
7.3 插件的异常处理与用户反馈机制
插件代码运行在 IDE 的主进程中,如果插件抛出未捕获的异常,可能影响 IDE 的稳定性。所以我在所有入口方法中增加了 try-catch,捕获所有异常并记录到日志文件。
异常反馈的机制是:弹出一个非模态通知,提示用户“操作失败,已在日志中记录详细信息”,同时把异常堆栈输出到 IDE 的日志文件。这样既不会打断用户工作流,也能定位问题。
注意:千万不要在插件里使用
System.exit()或强行停止线程。IDE 的进程是共享的,你杀掉线程的同时可能把 IDE 的其他功能也干掉。
7.4 避免插件性能影响 IDE 启动速度
插件的加载时间是衡量体验的重要指标。如果插件在启动时做太多初始化,用户就会明显感觉 IDE 变慢。
我在 SmartI18n 中坚持的原则是“按需加载”。插件启动时不创建任何重量级对象,只在 Action 被触发时才初始化核心服务。这个设计在 IntelliJ 平台中很容易实现——比如在 Action 的 actionPerformed 方法内部创建 Service 实例,而不是在 plugin.xml 中配置 <applicationService>。
8. 插件开发过程中的核心经验总结
8.1 用好 IntelliJ 的 PSI 查看器
写插件的过程中,最强大的调试工具是 IntelliJ 自带的 PsiViewer 插件。它可以在编辑器中展示当前文件的 PSI 树结构,让你看清楚每个节点的类型、层级和属性。遇到“为什么这段代码没被扫描到”的问题时,打开 PsiViewer 看一下节点的实际类型,比翻文档高效得多。
如果你开发的是 Android 插件,建议再装一个 Android PSI Viewer 插件,专门查看 XML 资源文件的 PSI 结构。
8.2 从“能用”到“好用”的关键差异
功能实现的插件只能做到“能用”,距离“好用”还有很长的路。我的体会是,好用的插件必须做到三点:
第一,反馈及时。任何耗时的操作都必须有进度条,不能让人等得心慌。扫描过程中实时显示“已扫描 56/200 个文件”,用户就能感知到工作正在进行而不是卡死。
第二,操作可逆。只要涉及批量修改用户代码的操作,都要考虑回滚方案。让用户使用前先提交代码是底线,另外可以借助 IDE 的 Local History 机制提供细粒度的回滚。
第三,减少打断。能不弹窗就不弹窗,能批量处理就批量处理。尽量把需要用户确认的操作整合到一次面板中,而不是反复弹出一个一个对话框。
8.3 自动生成的资源命名规范
资源命名直接决定代码的可读性。我最初版本生成的资源名是 button_text_1 这种无意义名字,后来被团队吐槽得不行。后来改进成了一种基于上下文的命名方案:优先从字符串所在的方法名、控件 id、Layout 文件路径推断语义,比如 login_button_text 就是结合了 LoginActivity 和 button 的语义推断出来的。
命名算法的核心思路是:把字符串的上下文(方法名、控件ID、所在布局)提取出来,用驼峰转下划线规则拼接成前缀 + 描述词的组合。如果冲突,再追加功能后缀。这套命名虽然不是 100% 完美,但大部分时候比人手工命名更具一致性,也让团队评审代码时更容易理解。
8.4 开源与持续维护
SmartI18n 从最初的个人工具发展成了团队日常开发中的标配。它的代码我开源到了个人仓库,持续维护。每次发布新版本前,我都会在三个版本的 Android Studio 上做兼容性测试(稳定版、预览版、上一代稳定版)。这个测试流程虽然繁琐,但能提前发现兼容性问题,避免用户升级 IDE 之后插件直接失效。
另外,我从这个项目中学到了很重要的一点:写工具插件的人,就是工具的最终用户。当我自己也觉得某个交互顺手、某个功能好用的时候,这个插件离“好产品”就不远了。每次更新版本,我会要求自己用新版本处理一个真实模块的国际化需求,从第一视角感受有没有卡顿、有没有误导性的提示、有没有让流程变复杂的地方。这个过程虽然耗时,但能筛选出很多藏在细节里的问题。
9. 写在最后:给想做 IDE 插件开发的人一些建议
如果你看完这篇文章,也想尝试写一个属于自己的 Android Studio 插件,我建议你把起点放低。不用一开始就做一个完整的大型工具,可以从一个微小的痛点入手——比如自动为选中的文本添加日志输出、一键生成 RecyclerView 的 Adapter 骨架代码。小而具体的工具更容易开发完成、更容易获得用户反馈,也能让你更好地掌握 IntelliJ 插件开发的核心流程。
开发过程中一定要保持和用户的沟通。给团队用的工具,必须在真实项目里跑过一轮才能发现各种 edge case;给社区用的开源工具,更要重视 issue 中的每一个 bug 报告。技术难题都有解决路径,但产品体验的提升来自持续不断的反馈闭环。
最后分享一个小技巧。写 IDE 插件和写应用代码最大的区别是:你运行代码的环境本身就是你的 IDE,而且它不支持热更新插件源码。这意味着开发调试时,每改一次代码都要重启 IDE 才能生效,非常耗时。我后来发现,可以开启 IntelliJ 的 Hot Swap 功能,使用 Gradle 的 runIde 任务启动开发实例,配合 IDE 的“Build > Recompile”可以大幅减少重启次数。这个小技巧能节省大量开发时间,强烈推荐给所有 IDE 插件开发者。
做 SmartI18n 这个项目,前前后后花了大概一个月的时间,从方案的初步设计,到第一个可用版本跑通,再经过团队试用反馈做了三轮迭代,现在成了日常开发离不开的小工具。希望这篇文章能帮到同样在做提效工具的开发者们,也期待看到更多优秀的 IDE 插件出现。