介于团队的代码格式化插件过于远古,我对其进行了升级重写。新的实现切换了代码格式化的核心库,增加了对 Kotlin 语言的支持,并新增了若干 Inspections 静态代码检查功能。在这里记录此过程中,我对 IDEA Plugin 的理解以及首次进行相关开发的经验。

为什么需要 IDEA Plugin?

聚焦于 Android 开发场景,我理解 IDEA Plugin 开发的必要性主要体现在两点:统一规范开发提效

IDE 作为开发工具,是部署在开发者本地的,团队的每个成员都有自定义其配置的能力。如果不同开发者使用不同的开发规范和代码规范,势必会导致代码管理上的混乱。没有人希望在拉取远程最新代码时,因为代码格式不统一而弹出大量的冲突问题。

除了代码格式上的规范,开发团队对于代码安全等方面也有不同的规范。例如,对 java.util.Random 伪随机数的使用,以及 Color.parseColor() 方法在解析未知内容变量时可能引发的 IllegalArgumentException 异常。如果这些问题在开发阶段没有被拦截,工作量就会堆积到 Code Review 阶段,甚至可能被带到线上。这些琐碎问题的堆积,会为项目代码质量和安全性制造隐患,从而影响日常项目的开发效率。

所有这些问题都可以通过定制 IDEA Plugin 来解决。总而言之,IDEA Plugin 开发的目的就是满足团队在代码规范、架构约束以及独特工作流上的个性化需求,解决 特定团队特定痛点,以此提高团队的工程效率。

PSI

**PSI (Program Structure Interface) **是 IntelliJ 平台中一个核心的概念,它是对代码的一种抽象表示。可以将其类比为 Android 视图系统中的 View 层次结构。在 Android 中,UI 界面的所有元素都被抽象为 View 对象,并以树状结构组织起来,我们可以通过遍历 View 树来查找、修改或操作 UI 元素。同样,PSI 将源代码(如 Java、Kotlin 文件)解析成一个抽象语法树(AST),树中的每个节点都是一个 PsiElement,代表了代码中的一个结构,例如类、方法、变量、表达式等。

具体来说,PsiElement 是 PSI 层次结构的基类,所有表示代码元素的类都继承自它。例如:

  • PsiFile:代表一个源代码文件,是 PSI 树的根节点。
  • PsiClass:代表一个类或接口。
  • PsiMethod:代表一个方法。
  • PsiExpression:代表一个表达式,比如 new Random() 或者 Color.parseColor("#FFFFFF")
  • PsiReferenceExpression:代表一个引用表达式,例如方法调用中的方法名部分。
  • PsiNewExpression:代表一个 new 关键字创建对象的表达式。
  • PsiMethodCallExpression:代表一个方法调用表达式。

使用 PSI,可以在不直接操作文本内容的情况下,以结构化的方式访问、分析和修改代码。这就像在 Android 中,我们通过 findViewById 找到一个 Button 对象,然后调用它的 setText() 方法来改变按钮的文本,而不是直接修改布局文件中的 XML 字符串。

在 IDEA Plugin 开发中,通常会用到 PsiDocumentManager 来获取当前文档对应的 PsiFile。例如,在文件保存前对文档进行操作时,通过 PsiDocumentManager.getInstance(project).getPsiFile(document) 可以获取到与 Document 关联的 PsiFile 对象。有了 PsiFile,我们就可以利用 PSI 提供的各种 API 来遍历和分析代码结构,进而实现代码检查、重构、格式化等功能。

Format

之前是插件内部维护格式化规则,在升级后我将具体的格式化规则交给成熟的开源库,Java 使用 google-java-format、Kotlin 使用 ktfmt,并重写格式化逻辑,尽可能的精简代码,提高维护性。大体流程可以分为:注册、监听、格式化。

插件启动时,通过 FormatInstaller 将自定义的 FormatCodeStyleManager 注册为项目的 CodeStyleManager 。它会包装IntelliJ IDEA原生的 CodeStyleManager ,并根据文件类型决定是使用自定义格式化逻辑还是委托给原生管理器。

通过实现 DocumentManagerListener,插件可以 监听 文件保存前的事件。当用户保存 Java 或 Kotlin 文件时,DocumentManagerListenerbeforeDocumentSaving方法会被回调,并触发格式化。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class DocumentManagerListener implements FileDocumentManagerListener {
    @Override
    public void beforeDocumentSaving(@NotNull Document document) {
        // ... 获取当前 Project 和 PsiFile
        if (psiFile != null && (psiFile.getName().endsWith(".java") || psiFile.getName().endsWith(".kt"))) {
            ApplicationManager.getApplication().invokeLater(() -> {
                new WriteCommandAction.Simple(project) {
                    @Override
                    protected void run() {
                        // 调用 CodeStyleManagerDecorator 进行格式化
                        CodeStyleManagerDecorator.getInstance(project).reformatText(psiFile, Collections.singletonList(psiFile.getTextRange()));
                        // 保存文件
                        ApplicationManager.getApplication().invokeLater(() -> FileDocumentManager.getInstance().saveDocument(document));
                    }
                }.execute();
            });
        }
    }
}

FormatCodeStyleManager 接收到格式化请求后,会根据文件类型(Java 或 Kotlin)调用相应的格式化工具,获取对应的格式化结果。最终,所有替换操作在一个 WriteCommandAction 中执行,确保原子性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
private void formatInternal(PsiFile file, Collection<? extends TextRange> ranges) {
    if (JavaFileType.INSTANCE.equals(file.getFileType())) {
        performReplacements(document, JavaFormatterUtil.getReplacements(new Formatter(), document.getText(), ranges));
    } else if (KotlinFileType.INSTANCE.getName().equals(file.getFileType().getName())) {
        performReplacements(document, KotlinFormatterUtil.getReplacements(KotlinUiFormatterStyle.GOOGLE, document.getText()));
    }
}

private void performReplacements(final Document document, final Map<TextRange, String> replacements) {
    if (replacements.isEmpty()) return;
    TreeMap<TextRange, String> sorted = new TreeMap<>(comparing(TextRange::getStartOffset));
    sorted.putAll(replacements);
    WriteCommandAction.runWriteCommandAction(getProject(), () -> {
        for (Entry<TextRange, String> entry : sorted.descendingMap().entrySet()) {
            document.replaceString(entry.getKey().getStartOffset(), entry.getKey().getEndOffset(), entry.getValue());
        }
        PsiDocumentManager.getInstance(getProject()).commitDocument(document);
    });
}

Inspection

还在插件中添加了一系列静态代码检查功能(Inspections),目的是帮助团队成员在开发阶段发现并修正代码中潜在的问题。这与 Android 开发中 Lint 工具的目标非常相似,都是在编译或开发阶段提供反馈,避免问题流入后期。

一个 Inspection 的实现通常继承自 AbstractBaseJavaLocalInspectionTool(或其对应语言的基类),并重写 buildVisitor 方法,返回一个 PsiElementVisitor。这个 Visitor 会遍历 PSI 树,在访问特定类型的 PsiElement 时执行检查逻辑。如果发现问题,就通过 ProblemsHolder.registerProblem 注册一个 ProblemDescriptor,这会在 IDE 中以高亮的形式提示开发者,并可以附带一个 LocalQuickFix 来提供自动修复功能。

介于大多数 Inspections 都是强业务相关的,这里只挑选两个与业务逻辑无关的纯技术规范侧的检查项。

Random 伪随机数

目的:检测代码中是否使用了 java.util.Random 这个伪随机数生成器。在安全性要求较高的场景,推荐使用 java.security.SecureRandom 以获取更好的随机性。

实现:通过重写 JavaElementVisitorvisitNewExpression 方法,检查 PsiNewExpression 中创建的对象是否为 java.util.Random 的实例。

快速修复 (RandomQuickFix):如果检测到问题,会提供一个 Quick Fix,将 new java.util.Random() 替换为 new java.security.SecureRandom(),并自动导入 SecureRandom 类。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Override
public void visitNewExpression(PsiNewExpression expression) {
  super.visitNewExpression(expression);
  String qualifiedName = null;
  PsiJavaCodeReferenceElement classReference = expression.getClassReference();
  if (classReference != null) {
    qualifiedName = classReference.getQualifiedName();
  }
  if ("java.util.Random".equals(qualifiedName)) {
    holder.registerProblem(
        expression, INSPECTION_DESCRIPTION, INSPECTION_TYPE, new RandomQuickFix());
  }
}

private static class RandomQuickFix implements LocalQuickFix {
  @Override
  public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) {
    PsiElementFactory factory = JavaPsiFacade.getElementFactory(project);
    PsiExpression newExpression =
        factory.createExpressionFromText("new java.security.SecureRandom()", descriptor.getPsiElement());
    PsiElement replacedElement = descriptor.getPsiElement().replace(newExpression);
    JavaCodeStyleManager codeStyleManager = JavaCodeStyleManager.getInstance(project);
    codeStyleManager.shortenClassReferences(replacedElement);
    codeStyleManager.optimizeImports(replacedElement.getContainingFile());
  }
}

Color.parseColor()

目的:当 Color.parseColor() 方法解析的是一个未知内容的变量时,建议使用 try-catch 块捕获 IllegalArgumentException 异常,以增强代码的健壮性。

实现:通过重写 JavaElementVisitorvisitMethodCallExpression 方法,检查方法调用是否为 Color.parseColor。如果方法的参数不是常量或字面量,并且当前调用没有被 try-catch 块包裹,则注册问题。isWrappedInTryCatch 方法会向上遍历 PSI 树,查找是否存在 PsiTryStatement

快速修复 (ColorParseFix):提供 Quick Fix,将 Color.parseColor() 的调用语句用一个 try-catch 块包裹起来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 从 ColorParseInspection 中精简
@Override
public void visitMethodCallExpression(PsiMethodCallExpression expression) {
    super.visitMethodCallExpression(expression);
    // ... 获取 methodExpression
    if ("Color.parseColor".equals(method)) {
        PsiExpression argument = argumentList.getExpressions()[0];
        if (!PsiUtil.isConstantExpression(argument)
                && !(argument instanceof PsiLiteralExpression)
                && !isWrappedInTryCatch(expression)) {
            holder.registerProblem(
                    expression, INSPECTION_DESCRIPTION, INSPECTION_TYPE, new ColorParseFix());
        }
    }
}

private boolean isWrappedInTryCatch(PsiElement element) {
    // ... 向上遍历 PSI 树查找 PsiTryStatement
}

private static class ColorParseFix implements LocalQuickFix {
    @Override
    public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor problemDescriptor) {
        // ... 创建 try-catch 代码块并替换原始语句
    }
}

AS 适配

Android Studio 基于 IntelliJ IDEA 平台构建。为了允许插件访问 JDK 内部的 API(这些 API 可能是 google-java-format 等库所依赖的),需要在JVM启动参数中添加特定的 --add-opens--add-exports 选项。如果这些 JVM 选项不足,可能会导致插件运行时出现 InaccessibleObjectException 等错误,从而使代码格式化功能失效。不同版本的 IDEA 平台设置这些 JVM 参数的方式有所不同。

如果基线版本大于等于 213,直接调用 VMOptions.setOption() 来设置所需的 --add-opens--add-exports 参数。

如果基线版本低于 213,则需要手动修改 VM Options 文件。它会获取 VM Options 文件的路径,读取其内容,如果文件中不包含所需的参数,则将这些参数追加到文件末尾,并确保文件可写。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (version >= 213) {
    VMOptions.setOption("--add-opens=java.base/java.lang", "=ALL-UNNAMED");
    VMOptions.setOption("--add-opens=java.base/java.util", "=ALL-UNNAMED");
    // ...
} else {
    Path path = VMOptions.getWriteFile();
    if (path == null) {
        return;
    }
    File file = path.toFile();
    try {
        String content = FileUtil.loadFile(file);
        if (!content.contains("--add-opens=java.base/java.lang=ALL-UNNAMED")) {
            content = content + "\n" +
                    "--add-opens=java.base/java.lang=ALL-UNNAMED\n" +
                    "--add-opens=java.base/java.util=ALL-UNNAMED\n" +
                    // ...
            FileUtil.writeToFile(file, content);
        }
    } catch (IOException ignored) {
    }
}