服务端相关 / Kotlin 如何开发IDEA图片压缩插件

Kotlin 如何开发 IDEA 图片压缩插件

这篇文章将会带大家使用 Kotlin 开发一个非常实用的工具,一款基于 Intellij IDEA 并且可以适用于所有 jetBrains 全家桶 IDE 的图片压缩插件,可以直接用于平时开发中。

我们经常会遇到一些图片需要压缩的需求,特别是 Android 开发一些打入包内的图片,为了不增加包体积大小需要手动压缩一遍图片,这时候一般会把图片拖到具体压缩网站上,在线压缩然后下载。

如果说只需要在 AndroidStudio 或 IDEA 中直接选中要压缩的图片即可,是不是很方便呢。开发该插件目的有两个:一个是学习 Intellij IDEA 插件的开发流程,另一个是练习 Kotlin 的开发实战技能。

1. 插件开发导学篇

1.1 为什么需要开发一款图片压缩插件

我们在项目开发过程中常会使用图片,一般开发者都不会直接把设计切的图片放入到项目中,而是会去压缩一下,那么一般会去 TinyPng 网页端压缩一遍,你一般会先把要压缩的图片拖进去,然后又一张张把图片点击下载下来,是不是感觉特别的浪费时间,是不是需要把浪费的时间省下来,然后就愉快地早点下班啦。如果你还没有使用过 TinyPng,那么这个插件也许适合你。

图片描述
图片描述
然后这段时间正研究插件,所以决定试试,其实很简单的。因为 TinyPng 提供 develop api,可以方便实现图片压缩。这次插件也就是利用了它的 API 开发的。

1.2 插件基本介绍

本插件是一款基于 TinyPng API 开发的图片压缩的 IDEA 工具插件,采用的是 Kotlin 语言开发以及 Java Swing 框架设计 UI 界面。可运行在 AndroidStudio,Intellij IDEA,WebStorm 等 JetBrains 全家桶系列 IDE 中。主要支持以下功能:

  • 1、支持整个目录中的图片批量压缩,只需要指定图片源目录和压缩的输出目录即可;
  • 2、支持单张或者选定多张图片文件进行压缩;
  • 3、支持 png,jpg 格式图片;
  • 4、支持输入目录和输出目录二次选择功能,减少繁琐指定相同的目录;
  • 5、支持指定输入文件的前缀,也即是批量文件添加前缀名,以及前缀名二次选择功能;
  • 6、图片压缩过程中,仍然继续 coding, 工作并行执行。

1.3 需要使用的技术点

  • Intellij Idea 插件开发基础知识;
  • 插件开发中执行一个后台线程任务 Task.Backgroundable 的使用;
  • Intellij Idea open api 的使用;
  • Kotlin 开发基础知识;
  • Kotlin 中扩展函数的封装;
  • Kotlin 中 Lambda 表达式的使用;
  • Kotlin 中函数式 API 的使用;
  • Kotlin 中 IO 操作 API 的使用;
  • Java 中 Swing UI 框架的基本使用;
  • TinyPng API 基本使用。

1.4 实现后基本效果

图片描述

2. IntelliJ IDEA 插件开发基础篇

2.1 什么是 IntelliJ IDEA 插件

IDE 插件利用 jetBrains 公司开源的 IntelliJ Platform SDK (java 语言) 来开发一个独立功能可以安装在 IDEA 之类的编辑器的功能组件。 IDE 插件是基于 IntelliJ IDEA 开发工具开发,里面集成了插件的项目的构建。采用的是 Java 语言开发和 IntelliJ 的 SDK 相结合开发。

并且在开发出来的插件不仅在 AndroidStudio 上可以使用,可以通用于 jetBrains 的编辑器的全家桶工具。通过源码可以发现 Intellij Idea 内置了大量的插件,可以这么说 Intellij Idea 开发工具大部分功能是由插件组合而成的。

2.2 开始编写第一个插件

构建插件项目的方式主要分为两种:一种是直接创建 IDEA 内置的插件项目。
图片描述

另一种则是先通过构建一个 gradle 项目,然后加入 plugin.xml 配置以及 加入 IDEA ERP 的依赖,然后来构建一个插件项目 (整个开发过程就和开发一个 Android 项目一样),当然这个构建过程可参考官方给出的 gradle-intellij-plugin 项目来实现。

图片描述

(这里我们以第一种为例) 打开已经安装好的 IntelliJ IDEA,然后 create New Project. 选择一个 IntelliJ Platform Plugin 项目。注意需要引入 IntelliJ IDEA 的 SDK

图片描述

选择好 SDK 后,然后只需要一步一步把项目创建完毕即可,创建好的项目结构如下:

图片描述

正如你所看到,生成了一个 plugin.xml,这个文件是插件项目的配置文件,它记录了插件相关的版本扩展等基本信息,还记录了插件事件与具体实现类绑定过程,下面就一一介绍每个标签的含义。

<idea-plugin>
  <id>com.your.company.unique.plugin.id</id>
  <name>Plugin display name here</name>
  <version>1.0</version>
  <vendor email="support@yourcompany.com" url="http://www.yourcompany.com">YourCompany</vendor>

  <description><![CDATA[
      Enter short description for your plugin here.<br>
      <em>most HTML tags may be used</em>
    ]]></description>

  <change-notes><![CDATA[
      Add change notes here.<br>
      <em>most HTML tags may be used</em>
    ]]>
  </change-notes>

  <!-- please see http://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/build_number_ranges.html for description -->
  <idea-version since-build="173.0"/>

  <!-- please see http://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/plugin_compatibility.html
       on how to target different products -->
  <!-- uncomment to enable plugin in all products
  <depends>com.intellij.modules.lang</depends>
  -->

  <extensions defaultExtensionNs="com.intellij">
    <!-- Add your extensions here -->
  </extensions>

  <actions>
    <!-- Add your actions here -->
  </actions>

</idea-plugin>
标签 含义 解释说明
plugin 插件项目的标识 和 Android 项目中的 package 功能类似,唯一标识一个插件项目
插件名字 发布到 jetBrains plugin 仓库中会用这个
插件版本号 这个用于标识插件版本,一般用于更新 jetbrains plugins 仓库中插件版本标识
开发者信息,邮箱和个人主页,公司名字或个人开发者姓名 用于插件仓库中插件信息介绍显示
<description> 插件的描述信息 主要是描述插件有什么功能,支持标签内部内嵌 HTML 标签
<changNote> 插件版本变更信息 一般用于插件版本变更的信息,支持标签内部内嵌 HTML 标签
<idea-version> 插件支持的 idea 版本 这个版本标签需要注意下,它决定了该插件能够运行在最低版本的 IDEA 中,一旦配置不当,会导致插件安装不成功,有点类似 Android 中 AndroidManifest.xml 中配置最低兼容 Android 版本意思
<depends> 当前的插件项目依赖哪些内置或者外部的插件库依赖 例如你需要实现类似 git 功能插件,你就可以通过 depends 标签引入 Git4Idea 即可,Git4Idea, 如果看过 IDEA 源码的话,实际上内置 GitHub 插件就是通过 depends 依赖内部 Git4Idea 插件实现的,还有现在的码云 git 工具插件也是通过依赖 Git4Idea 内置插件来实现的
<extension> 插件与其他插件或与 IDE 本身交互 (默认是 IDEA) 如果您希望插件扩展其他插件或 IntelliJ Platform 的功能,则必须声明一个或多个扩展名
<action> 决定了你的插件在 IDE 上显示的位置和顺序 这个标签非常重要,它决定了你的插件在 IDE 上显示的位置和顺序,以及这个插件的点击事件和插件项目 Action 实现类的绑定。

创建一个 Action 类,在 IDEA 插件项目中,IDEA 点击 Item 或者按钮或者一个图标对应是触发了插件中一个 Action,创建 Action 主要有两种方式:

第 1 种:通过 IDEA 提供的一个入口,直接去创建 Action,然后它自动帮你实现 plugin.xml 中的事件绑定的注册
图片描述
图片描述
第 2 种: 手动创建一个 Action 类,然后继承 AnAction 类或者 DumbAwareAction 类,然后在 plugin.xml 中的 action 标签去注册 action 类与事件绑定

//创建Action类
package com.imooc.plugins.demo

import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.ui.Messages//注意import,是com.intellij.openapi包下

class DemoAction: AnAction() {
    override fun actionPerformed(p0: AnActionEvent?) {
        Messages.showInfoMessage("Just a Test ", "来自DemoAction提示")
    }
}

在 plugin.xml 中注册 action 类的绑定:

 <actions>
    <!-- Add your actions here -->
    <action id="com.imooc.plugins.demo.DemoAction" class="com.imooc.plugins.demo.DemoAction" text="DemoAction"
            description="just a test demo">
      <add-to-group group-id="ToolbarRunGroup" anchor="last"/><!--加入到ToolbarRunGroup内置组-->
    </action>
 </actions>

在 plugin.xml 中配置插件图标,先在插件项目中 resource 目录下创建一个 image 目录或者直接把图标拷贝目录下即可 然后 action 标签中指定 icon 属性:

  <actions>
    <!-- Add your actions here -->
    <action id="com.imooc.plugins.demo.DemoAction" class="com.imooc.plugins.demo.DemoAction" text="DemoAction"
            description="just a test demo" icon="/image/icon_pic_demo.png"><!--指定图标-->
      <add-to-group group-id="ToolbarRunGroup" anchor="last"/><!--加入到ToolbarRunGroup内置组-->
    </action>
  </actions>

在 plugin.xml 中配置自定义组,并把自定义的组加入内置的组中:

    <group id="com.imooc.plugins.group.demo" text="Demo" description="just a demo group"><!--group标签实现自定义组,id:组的唯一标识,text:组显示名称,description:组的描述名-->
        <add-to-group group-id="MainMenu" anchor="last"/><!--把组加入到内置的组中-->
        <action id="com.imooc.plugins.demo.DemoAction" class="com.imooc.plugins.demo.DemoAction" text="DemoAction" description="just a test demo" icon="/image/icon_pic_demo.png"><!--指定图标-->
          <add-to-group group-id="ToolbarRunGroup" anchor="last"/><!--加入到ToolbarRunGroup内置组-->
        </action>
    </group>

配置 OK 后,现在就可以运行插件了,运行成功后会新启动一个 Intellij IDEA,这个 IDE 就是安装了开发的插件,然后就可以在里面去调试你的插件功能:

图片描述

点击运行,进行测试,此外还支持断点调试:
图片描述

最后一步,打包插件,并发布。选择顶部工具栏 Build, 点击 "Prepare Plugin Module ‘Demo’ For Deployment", 就会在当前工作目录下生成一个 jar 或 zip 的包。然后发布插件,只需要在 jetBrains Plugins Repository 上传你的包,等待 jetBrains 官方的审核通过了,就能通过 ide 中的 plugins 仓库中搜索找到。

3. 图片插件开发篇

3.1 开发的前期准备

访问 TinyPng 官网注册 TinyPng 开发者账号,拿到 TinyPng ApiKey, 整个过程只需简单注册验证即可:

图片描述

由于本项目图片压缩框架是基于 TinyPng 的图片压缩 API 来实现的,所以需要在 TinyPng 官网提供了 develop 开发库,可以找到相应 Java 的 jar,为了方便下载这里就直接贴出地址了:TinyPng 依赖包下载

图片描述
由于图片插件使用到 GUI,插件 GUI 采用的是 Java 中的 Swing 框架搭建,具体可以去复习相关 Swing 的知识点,当然只需要大概了解即可。此外你还需要掌握插件开发的基础知识,Kotlin 的基本开发知识,比如 Kotlin 中扩展函数的封装,Lambda 表达式,函数式 API,IO 流 API 的使用。

3.2 插件实现原理分析

实现的整体思路:首先我们需要找到实现关键点,然后从关键点一步步向外扩展延伸,那么实现图片压缩的插件的关键点在哪里,肯定毫无疑问是图片压缩 API,也就是 TinyPng API 函数调用实现。

Tinify.fromFile(inputFile).toFile(inputFile)

通过以上的 TinyPng API 就可以找到关键点,一个是输入文件另一个则是输出文件,那么我们这个图片压缩插件的所有实现都是围绕着如何通过一个简单的方式指定一个输入文件或目录和一个输出文件或目录。
没错就是这么简单,那么我们一起来分析下上面两大功能实现思路其实也很简单:

  • 功能点一:就是通过 Swing 框架中的 JFileChooser 组件,打开并指定一个图片输入文件或目录和一个图片压缩后的输出文件或目录即可。

  • 功能点二:通过 Intellij Idea open api 中的 DataKeys.VIRTUAL_FILE_ARRAY.getData(this) 拿到当前选中的 Virtual Files,也就是当前选中的文件把选中的文件当做输入文件,然后图片压缩后文件直接输出到源文件中即可。

注意:由于 Tiny.fromFile ().toFile () 内部源码实际上通过 OkHttp 发送图片压缩的网络请求,而且内部采用的方式是同步请求的,但是在 IDEA Plugin 开发中主线程是不能执行耗时任务的,所以需要将该 API 方法调用放在异步任务中。

3.3 项目代码结构

图片描述
action 包主要定义插件中的两个 action,我们都知道在插件开发中 Action 是功能执行的入口,ImageSlimmingAction 是前面说到第一个功能点批量压缩指定输入和输出目录的,RightSelectedAction 是前面说过的第二个功能点在项目选中图中文件直接右键压缩的,最后这两个 Action 都需要在 plugin.xml 中注册。

  <actions>
        <action class="com.imooc.plugins.image.slimming.action.ImageSlimmingAction" text="ImageSlimming"
                id="com.imooc.plugins.image.slimming.action.ImageSlimmingAction"
                description="compress picture plugin" icon="/img/icon_image_slimming.png">
            <add-to-group group-id="MainToolBar" anchor="after" relative-to-action="Android.MainToolBarSdkGroup"/>
        </action>

        <action id="com.imooc.plugins.image.action.rightselectedaction"
                class="com.imooc.plugins.image.slimming.action.RightSelectedAction" text="Quick Slim Images"
                description="Quick Slim Images">
            <add-to-group group-id="ProjectViewPopupMenu" anchor="after" relative-to-action="ReplaceInPath"/>
        </action>
    </actions>

extension 包主要是定义了 Kotlin 中的扩展函数,一个是 Boolean 的扩展可以类似链式调用来替代 if-else 判断,另一个则是 Dialog 使用的扩展:

//Boolean 扩展
sealed class BooleanExt<out T>

object Otherwise : BooleanExt<Nothing>()//Nothing是所有类的子类,协变的类继承关系和泛型参数类型继承关系一致

class TransferData<T>(val data: T) : BooleanExt<T>()

inline fun <T> Boolean.yes(block: () -> T): BooleanExt<T> = when {
    this -> TransferData(block.invoke())
    else -> Otherwise
}

inline fun <T> Boolean.no(block: () -> T): BooleanExt<T> = when {
    this -> Otherwise
    else -> TransferData(block.invoke())
}

inline fun <T> BooleanExt<T>.otherwise(block: () -> T): T = when (this) {
    is Otherwise ->
        block()
    is TransferData ->
        this.data
}


//Dialog扩展
fun Dialog.showDialog(width: Int = 550, height: Int = 400, isInCenter: Boolean = true, isResizable: Boolean = false) {
    pack()
    this.isResizable = isResizable
    setSize(width, height)
    if (isInCenter) {
        setLocation(Toolkit.getDefaultToolkit().screenSize.width / 2 - width / 2, Toolkit.getDefaultToolkit().screenSize.height / 2 - height / 2)
    }
    isVisible = true
}

fun Project.showWarnDialog(icon: Icon = UIUtil.getWarningIcon(), title: String, msg: String, positiveText: String = "确定", negativeText: String = "取消", positiveAction: (() -> Unit)? = null, negativeAction: (() -> Unit)? = null) {
    Messages.showDialog(this, msg, title, arrayOf(positiveText, negativeText), 0, icon, object : DialogWrapper.DoNotAskOption.Adapter() {
        override fun rememberChoice(p0: Boolean, p1: Int) {
            if (p1 == 0) {
                positiveAction?.invoke()
            } else if (p1 == 1) {
                negativeAction?.invoke()
            }
        }
    })
}

helper 包主要是用文件 IO 操作,由于两个 Action 都存在图片压缩操作,为了复用就直接把图片压缩 API 调用的实现操作抽出封装在 ImageSlimmingHelper 中,ui 包主要就是 Swing 框架中一些界面 GUI 的实现和交互。

3.4 插件实现核心点分析

插件开发中如何执行一个异步任务:

IDEA Plugin 开发和 Android 开发很类似,一些耗时的任务是不能直接在主线程执行的,需要在特定后台线程执行,否则会阻塞主线程。在 intellij open api 中有个 Task.Backgroundable 抽象类就是处理异步任务的。Backgroundable 继承了 Task 类以及实现了 PerformInBackgroundOption 接口。具体使用很简单传入两个参数一个是 Project 对象和一个执行异步中 hint 提示文本,有四个回调函数分别为 run (progress: ProgressIndicator)、onSuccess、onThrowable、onFinished. 最后通过 queue 方法加入到异步任务队列中。为了方便调用将其封装成一个扩展函数来使用。

//创建后台异步任务的Project的扩展函数asyncTask
private fun Project.asyncTask(
        hintText: String,
        runAction: (ProgressIndicator) -> Unit,
        successAction: (() -> Unit)? = null,
        failAction: ((Throwable) -> Unit)? = null,
        finishAction: (() -> Unit)? = null
) {
    object : Task.Backgroundable(this, hintText) {
        override fun run(p0: ProgressIndicator) {
            runAction.invoke(p0)
        }

        override fun onSuccess() {
            successAction?.invoke()
        }

        override fun onThrowable(error: Throwable) {
            failAction?.invoke(error)
        }

        override fun onFinished() {
            finishAction?.invoke()
        }
    }.queue()
}
//asyncTask的使用
  project?.asyncTask(hintText = "正在压缩", runAction = {
        //执行图片压缩操作
        outputSameFile.yes {
            //针对右键选定图片情况,直接压缩当前目录选中图片,输出目录包括文件也是原来的
            inputFiles.forEach { inputFile -> Tinify.fromFile(inputFile.absolutePath).toFile(inputFile.absolutePath) }
        }.otherwise {
            inputFiles.forEach { inputFile -> Tinify.fromFile(inputFile.absolutePath).toFile(getDestFilePath(model, inputFile.name)) }
        }
    }, successAction = {
        successAction?.invoke()
    }, failAction = {
        failAction?.invoke("TinyPng key存在异常,请重新输入")
    })

插件开发中如何获取当前选中的文件或目录

在插件开发中如何获得当前选中文件,实际上 open api 提供了类似 DataContext 数据上下文环境,我们需要去拿到文件集合对象就需要先找到文件管理的窗口对象,还记得上篇博客中说到的 AnActionEvent 对象是插件与 IDEA 交互通信的一个媒介,通过 AnActionEvent 内部的 dataContext 的 getData 方法,传入对应的 DataKey 对象获得相应的窗口对象。在 CommonDataKey 中有一个 DataKey<VirtualFile []>,通过传入当前 event 中的 dataContext 对象即可获得当前选中的文件对象集合。

    private fun DataContext.getSelectedFiles(): Array<VirtualFile>? {
        return DataKeys.VIRTUAL_FILE_ARRAY.getData(this)//右键获取选中多个文件,扩展函数
    }

api key 的验证和图片压缩的实现

在进行图片压缩前就是需要去验证一下 TingPng ApiKey 的合法性,如果第一次验证合法就需要把该 ApiKey 存储在本地,下次压缩就直接使用本地的 key 进行压缩,一旦本地 key 失效后,需要重新弹出 TinyPng apikey 的验证提示框,进行重新认证。当然需要注意的是验证 api key 的合法性也是进行一次同步的网络请求所以它也要放在异步任务执行。

fun checkApiKeyValid(
        project: Project?,
        apiKey: String,
        validAction: (() -> Unit)? = null,
        invalidAction: ((String) -> Unit)? = null
) {
    if (apiKey.isBlank()) {
        invalidAction?.invoke("TinyPng key为空,请重新输入")
    }
    project?.asyncTask(hintText = "正在检查key是否合法", runAction = {
        try {
            Tinify.setKey(apiKey)
            Tinify.validate()
        } catch (exception: Exception) {
            throw exception
        }
    }, successAction = {
        validAction?.invoke()
    }, failAction = {
        println("验证Key失败!!${it.message}")
        invalidAction?.invoke("TinyPng key验证失败,请重新输入")
    })
}

然后就是利用异步任务进行图片压缩操作。

4. 总结

到这里有关 Kotlin 实现图片插件实战篇就结束了,本篇文章篇幅比较多,主要介绍了如何去开发一个 IDEA 插件以及使用 Kotlin 去开发一个图片压缩插件具体实现方案和技术重点和难点。下篇文章我们将继续 Kotlin 的实战篇系列。