无用技术之图片压缩CLI

860 阅读7分钟

一、写在前面

图片压缩是一个老生常谈的问题,工作中也很常见。客户端同学会遇到这种情况,各种资源图,UI也不压缩,直接丢过来,我们也懒,直接放工程里,久而久之,越来越臃肿,突然某一天,老大说,我们的安装包怎么这么大,需要压缩一下吧。。。那就压缩吧。对于安装包瘦身,方式有很多,其实大部分都是从资源入手,压缩资源,通过proGuard剔除未使用的资源,麻烦点可以根据R文件内联,通过字节码插桩,修改字节码(Android中app模块是不使用R文件的,而是根据编译过程生成的R.txt,将资源名称直接改成对应的id,而moudle中还使用R文件)将对应名称替换成id,这样R文件为被使用,也可以通过proGuard剔除了。这是题外话,下面回归正题。

二、目标

如图,可以直接使用命令,输入对应的参数(没有参数,可以提示),直接根据输入的目录压缩里面的图片资源,不需要手动替换。一般的会生成新的目录,然后还需要手动去替换原目录。

三、开始吧

1、在Intellij IDEA中新建工程

如果要白嫖Intellij IDEA的,喏。。。 也许还没有过期

KQ8KMJ77TY-eyJsaWNlbnNlSWQiOiJLUThLTUo3N1RZIiwibGljZW5zZWVOYW1lIjoiVW5pdmVyc2l0YXMgTmVnZXJpIE1hbGFuZyIsImxpY2Vuc2VlVHlwZSI6IkNMQVNTUk9PTSIsImFzc2lnbmVlTmFtZSI6IkpldOWFqOWutuahtiDorqTlh4blupflkI0iLCJhc3NpZ25lZUVtYWlsIjoibmtucWFyY214a0AxNjMuY29tIiwibGljZW5zZVJlc3RyaWN0aW9uIjoiRm9yIGVkdWNhdGlvbmFsIHVzZSBvbmx5IiwiY2hlY2tDb25jdXJyZW50VXNlIjpmYWxzZSwicHJvZHVjdHMiOlt7ImNvZGUiOiJHTyIsInBhaWRVcFRvIjoiMjAyNC0xMi0wNyIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiUlMwIiwicGFpZFVwVG8iOiIyMDI0LTEyLTA3IiwiZXh0ZW5kZWQiOmZhbHNlfSx7ImNvZGUiOiJETSIsInBhaWRVcFRvIjoiMjAyNC0xMi0wNyIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiQ0wiLCJwYWlkVXBUbyI6IjIwMjQtMTItMDciLCJleHRlbmRlZCI6ZmFsc2V9LHsiY29kZSI6IlJTVSIsInBhaWRVcFRvIjoiMjAyNC0xMi0wNyIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiUlNDIiwicGFpZFVwVG8iOiIyMDI0LTEyLTA3IiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IlBDIiwicGFpZFVwVG8iOiIyMDI0LTEyLTA3IiwiZXh0ZW5kZWQiOmZhbHNlfSx7ImNvZGUiOiJEUyIsInBhaWRVcFRvIjoiMjAyNC0xMi0wNyIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiUkQiLCJwYWlkVXBUbyI6IjIwMjQtMTItMDciLCJleHRlbmRlZCI6ZmFsc2V9LHsiY29kZSI6IlJDIiwicGFpZFVwVG8iOiIyMDI0LTEyLTA3IiwiZXh0ZW5kZWQiOmZhbHNlfSx7ImNvZGUiOiJSU0YiLCJwYWlkVXBUbyI6IjIwMjQtMTItMDciLCJleHRlbmRlZCI6dHJ1ZX0seyJjb2RlIjoiUk0iLCJwYWlkVXBUbyI6IjIwMjQtMTItMDciLCJleHRlbmRlZCI6ZmFsc2V9LHsiY29kZSI6IklJIiwicGFpZFVwVG8iOiIyMDI0LTEyLTA3IiwiZXh0ZW5kZWQiOmZhbHNlfSx7ImNvZGUiOiJEUE4iLCJwYWlkVXBUbyI6IjIwMjQtMTItMDciLCJleHRlbmRlZCI6ZmFsc2V9LHsiY29kZSI6IkRCIiwicGFpZFVwVG8iOiIyMDI0LTEyLTA3IiwiZXh0ZW5kZWQiOmZhbHNlfSx7ImNvZGUiOiJEQyIsInBhaWRVcFRvIjoiMjAyNC0xMi0wNyIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiUFMiLCJwYWlkVXBUbyI6IjIwMjQtMTItMDciLCJleHRlbmRlZCI6ZmFsc2V9LHsiY29kZSI6IlJTViIsInBhaWRVcFRvIjoiMjAyNC0xMi0wNyIsImV4dGVuZGVkIjp0cnVlfSx7ImNvZGUiOiJXUyIsInBhaWRVcFRvIjoiMjAyNC0xMi0wNyIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiUFNJIiwicGFpZFVwVG8iOiIyMDI0LTEyLTA3IiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IlBDV01QIiwicGFpZFVwVG8iOiIyMDI0LTEyLTA3IiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IlJTIiwicGFpZFVwVG8iOiIyMDI0LTEyLTA3IiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IkRQIiwicGFpZFVwVG8iOiIyMDI0LTEyLTA3IiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IlBEQiIsInBhaWRVcFRvIjoiMjAyNC0xMi0wNyIsImV4dGVuZGVkIjp0cnVlfV0sIm1ldGFkYXRhIjoiMDEyMDIzMTIwOUxQQUEwMDEwMDkiLCJoYXNoIjoiNTI1MDgyODgvMjUxMjMyNjE6LTE1MDQzMDI5NDAiLCJncmFjZVBlcmlvZERheXMiOjcsImF1dG9Qcm9sb25nYXRlZCI6ZmFsc2UsImlzQXV0b1Byb2xvbmdhdGVkIjpmYWxzZSwidHJpYWwiOmZhbHNlLCJhaUFsbG93ZWQiOnRydWV9-QKJUeHFkc+NaPWlEFZFGpoBJBYjehR5cGPezKK9BKHVnVaydzLV4YSAnILt8mz9twXw9lIh0k/HivsPKrffP8F9gZkWA/rfjieSI0jziDr9WBARPzYKRlQHSw/iZn5VUn6zIR9U7uJC6Kd/jiaeLumn+dzL/ia9B/1dBUIg5WQlIOtld4xx2xR0gb4JCNBd4kQMV4SAC3Og13/APGkDiP7KzDz7T3DxmpSKvjAfG1Hg1jn2pt5B/3gmhOK5lmJKbGBDRW40f4sqyDpzXsA5DaPAAaFT07GSL5FlfdKfngGjcQdwQ18k1iFET6wSwWkk+p+OySDqegpFw3wx2Kzj+Ow==-MIIETDCCAjSgAwIBAgIBDzANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA1KZXRQcm9maWxlIENBMB4XDTIyMTAxMDE2MDU0NFoXDTI0MTAxMTE2MDU0NFowHzEdMBsGA1UEAwwUcHJvZDJ5LWZyb20tMjAyMjEwMTAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC/W3uCpU5M2y48rUR/3fFR6y4xj1nOm3rIuGp2brELVGzdgK2BezjnDXpAxVDw5657hBkAUMoyByiDs2MgmVi9IcqdAwpk988/Daaajq9xuU1of59jH9eQ9c3BmsEtdA4boN3VpenYKATwmpKYkJKVc07ZKoXL6kSyZuF7Jq7HoQZcclChbF75QJPGbri3cw9vDk/e46kuzfwpGftvl6+vKibpInO6Dv0ocwImDbOutyZC7E+BwpEm1TJZW4XovMBegHhWC04cJvpH1u98xoR94ichw0jKhdppywARe43rGU96163RckIuFmFDQKZV9SMUrwpQFu4Z2D5yTNqnlLRfAgMBAAGjgZkwgZYwCQYDVR0TBAIwADAdBgNVHQ4EFgQU5FZqQ4gnVc+inIeZF+o3ID+VhcEwSAYDVR0jBEEwP4AUo562SGdCEjZBvW3gubSgUouX8bOhHKQaMBgxFjAUBgNVBAMMDUpldFByb2ZpbGUgQ0GCCQDSbLGDsoN54TATBgNVHSUEDDAKBggrBgEFBQcDATALBgNVHQ8EBAMCBaAwDQYJKoZIhvcNAQELBQADggIBANLG1anEKid4W87vQkqWaQTkRtFKJ2GFtBeMhvLhIyM6Cg3FdQnMZr0qr9mlV0w289pf/+M14J7S7SgsfwxMJvFbw9gZlwHvhBl24N349GuthshGO9P9eKmNPgyTJzTtw6FedXrrHV99nC7spaY84e+DqfHGYOzMJDrg8xHDYLLHk5Q2z5TlrztXMbtLhjPKrc2+ZajFFshgE5eowfkutSYxeX8uA5czFNT1ZxmDwX1KIelbqhh6XkMQFJui8v8Eo396/sN3RAQSfvBd7Syhch2vlaMP4FAB11AlMKO2x/1hoKiHBU3oU3OKRTfoUTfy1uH3T+t03k1Qkr0dqgHLxiv6QU5WrarR9tx/dapqbsSmrYapmJ7S5+ghc4FTWxXJB1cjJRh3X+gwJIHjOVW+5ZVqXTG2s2Jwi2daDt6XYeigxgL2SlQpeL5kvXNCcuSJurJVcRZFYUkzVv85XfDauqGxYqaehPcK2TzmcXOUWPfxQxLJd2TrqSiO+mseqqkNTb3ZDiYS/ZqdQoGYIUwJqXo+EDgqlmuWUhkWwCkyo4rtTZeAj+nP00v3n8JmXtO30Fip+lxpfsVR3tO1hk4Vi2kmVjXyRkW2G7D7WAVt+91ahFoSeRWlKyb4KcvGvwUaa43fWLem2hyI4di2pZdr3fcYJ3xvL5ejL3m14bKsfoOv

2、Clikt

关于命令行工具的开源库,一键集成,具体的可以去全球最大XX交流社区看看。我们使用Gradle集成很简单

implementation 'com.github.ajalt.clikt:clikt:3.5.2'

3、代码编写

1、命令行实现

class Main(): CliktCommand(help = "这是图片压缩工具") {

    private val currentPath: String by option("--current_path", "-c", help = "Current  Path").prompt("请输入需要压缩的目录")


    private val scale: Float by option("--scale", "-s", help = "scale是可以指定图片的大小,值在0到1之间,1f就是原图大小,0.5就是原图的一半大小,这里的大小是指图片的长宽 ").float()
        .prompt("scaleSize")

    private val outputQuality: Float by option("--quality", "-q", help = "outputQuality是图片的质量,值也是在0到1,越接近于1质量越好,越接近于0质量越差 ").float()
        .prompt("outputQuality")


    override fun run() {
        println("压缩开始!")
        try {
            ThumbUtils.init(Paths.get(currentPath), scale, outputQuality).thumb()
        }catch (e:Throwable){
            println("压缩失败! ${e.printStackTrace()}")
        }finally {
            println("压缩结束!")
        }

    }

}


fun main(args: Array<String>) = Main().main(args)

我们创建一个Main类,继承CliktCommand()这个类,(来自clikt),这里面已经封装好命令行的实现了,我们只需要依葫芦画瓢,创建我们需要的入参即可:

 private val currentPath: String by option("--current_path", "-c", help = "Current  Path").prompt("请输入需要压缩的目录")

这个大家都看得懂,就不赘述了,具体的实现是在run函数中。Java入口是main函数,我们就建一个main函数,然后调用Main类。

2、图片压缩代码

关于图片压缩,不得不提到一个类,即BufferedImage:

BufferedImage 子类描述具有可访问图像数据缓冲区的 Image。BufferedImage 由图像数据的 ColorModel 和 Raster 组成。Raster 的 SampleModel 中 band 的数量和类型必须与 ColorModel 所要求的数量和类型相匹配,以表示其颜色和 alpha 分量。所有 BufferedImage 对象的左上角坐标都为 (0, 0)。因此,用来构造 BufferedImage 的任何 Raster 都必须满足:minX=0 且 minY=0。

此类依靠 Raster 的数据获取方法、数据设置方法,以及 ColorModel 的颜色特征化方法。

同时,我们知道图片的类型有很多,不同类型占用的大小是有区别的,比如TYPE_INT_ARGB, 表示一个图像,它具有合成整数像素的 8 位 RGBA 颜色分量,RGB表三原色,A代表透明度,TYPE_INT_RGB没有透明度的图像,占用肯定比TYPE_INT_ARGB要小,关于更多的类型查看这里

实现压缩,我们可以通过compressionQuality,修改大小,达到压缩的目的,不过这种比较直接,如果是带有透明的效果的png格式,可能不太友好,可能图片会黑背景。

 private fun compressImage(input: File, output: File, format: String, quality: Float) {
        val bufferedImage = ImageIO.read(input)
        val compressedImage = BufferedImage(bufferedImage.width, bufferedImage.height, BufferedImage.TYPE_INT_RGB)

        // 将原始图像绘制到压缩图像,并填充背景色
        val graphics = compressedImage.createGraphics()
        graphics.color = Color.WHITE // 设置背景色
        graphics.fillRect(0, 0, compressedImage.width, compressedImage.height)
        graphics.drawImage(bufferedImage, 0, 0, null)
        graphics.dispose()

        // 设置压缩参数
        val writer = ImageIO.getImageWritersByFormatName(format).next()
        val writeParam = writer.defaultWriteParam

        if (writeParam.canWriteCompressed()) {
            writeParam.compressionMode = javax.imageio.ImageWriteParam.MODE_EXPLICIT
            writeParam.compressionQuality = quality
        }

        // 写入压缩后的图像
        ImageIO.createImageOutputStream(output).use { outputStream ->
            writer.output = outputStream
            writer.write(null, javax.imageio.IIOImage(compressedImage, null, null), writeParam)
        }
    }

关于压缩,谷歌也有开源库thumbnailator, 使用也简单

 Thumbnails.of(inputFile)
        .scale(scale.toDouble())
        .outputQuality(outputQuality)
        .toFile(outputFile)

不过同理,对于png格式的不友好,这里推荐另外一个库OpenViewerFX 当然这个不只是用于压缩图片的:

// 导库
implementation 'org.jpedal:OpenViewerFX:6.6.14'

//使用
 PngCompressor.compress(inputFile, inputFile)

这里使用的是6.6.14版本,高版本可能没有PngCompressor这个类了,PngCompressor很方面的是inputFile和outputFile可以是一个,这样可以直接覆盖。

完成上述流程之后,基本功能也就是实现了,只是需要做一下串联,如下代码:

import com.idrsolutions.image.png.PngCompressor
import net.coobird.thumbnailator.Thumbnails
import java.awt.Color
import java.awt.image.BufferedImage
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import javax.imageio.ImageIO
import kotlin.io.path.pathString
import kotlin.streams.toList


class ThumbUtils {

    private val pathTypes = arrayOf(".jpg", ".jpeg", ".png")

    lateinit var currentPath: Path
    var scale: Float = 1.0f
    var outputQuality: Float = 0.5f


    fun thumb() {
        val paths = listAllFiles(currentPath)

        if (paths.isEmpty()) {
            println("没有需要压缩的文件")
            return
        }
        val cacheFile = File(currentPath.parent.pathString + "/cache")
        if (!cacheFile.exists()) {
            cacheFile.mkdirs()
        }

        paths.forEach { path ->
            println("准备压缩 ${path}")

            pathTypes.forEach { type ->
                if (path.pathString.endsWith(type)) {
                    println("压缩中 ${path}")
                    val inputFile = path.toFile()

                    if (type === ".png") {
                        PngCompressor.compress(inputFile, inputFile)
                    } else {
                        val outputFile = File(cacheFile.path, "compressed_${inputFile.name}")
                        if (!outputFile.exists()) {
                            outputFile.createNewFile()
                        }

                        Thumbnails.of(inputFile)
                            .scale(scale.toDouble())
                            .outputQuality(outputQuality)
                            .toFile(outputFile)

//                        compressImage(inputFile, outputFile, type, outputQuality)
                        // 覆盖原有图片
                        Files.move(outputFile.toPath(), inputFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
                    }


                }

            }

        }

        if (cacheFile.exists()) {
            cacheFile.delete()
        }
    }


    private fun compressImage(input: File, output: File, format: String, quality: Float) {
        val bufferedImage = ImageIO.read(input)
        val compressedImage = BufferedImage(bufferedImage.width, bufferedImage.height, BufferedImage.TYPE_INT_RGB)

        // 将原始图像绘制到压缩图像,并填充背景色
        val graphics = compressedImage.createGraphics()
        graphics.color = Color.WHITE // 设置背景色
        graphics.fillRect(0, 0, compressedImage.width, compressedImage.height)
        graphics.drawImage(bufferedImage, 0, 0, null)
        graphics.dispose()

        // 设置压缩参数
        val writer = ImageIO.getImageWritersByFormatName(format).next()
        val writeParam = writer.defaultWriteParam

        if (writeParam.canWriteCompressed()) {
            writeParam.compressionMode = javax.imageio.ImageWriteParam.MODE_EXPLICIT
            writeParam.compressionQuality = quality
        }

        // 写入压缩后的图像
        ImageIO.createImageOutputStream(output).use { outputStream ->
            writer.output = outputStream
            writer.write(null, javax.imageio.IIOImage(compressedImage, null, null), writeParam)
        }
    }


    private fun listAllFiles(directory: Path): List<Path> {
        return if (Files.isDirectory(directory)) {
            Files.walk(directory)
                .filter { Files.isRegularFile(it) } // 只过滤出文件
                .toList() // 将 Stream 转换为 List
        } else {
            emptyList() // 如果传入的不是一个目录,则返回空列表
        }
    }

    companion object {
        fun init(
            currentPath: Path,
            scale: Float,
            outputQuality: Float
        ): ThumbUtils {
            val thumbUtils = ThumbUtils()
            thumbUtils.currentPath = currentPath
            thumbUtils.scale = scale
            thumbUtils.outputQuality = outputQuality
            return thumbUtils
        }
    }
}

流程就是根据输入的路径,读取路径里面的图片,生成列表,然后逐个去压缩。同时生成临时目录,存放临时文件,覆盖之后,再把临时目录删除。

运行man函数:

成功了

3、生成可执行文件

上面是在工程中运行的,不方便,我们需要将其打成可执行文件。

jar

java最终是可以达成jar的,我们只需要在build.gradle中加入配置即可:

jar {
    exclude("**/module-info.class")
    manifest {
        attributes 'Main-Class': 'MainKt'
    }
    from {
        configurations.runtimeClasspath.collect {
            it.isDirectory() ? it : zipTree(it)
        }
    }

}

如果报错,is a duplicate but no duplicate handling strategy has been set. 可以加上:

// 生成的分发包中存在重复的文件
duplicatesStrategy = DuplicatesStrategy.EXCLUDE    

如果报错: Invalid signature file digest for Manifest main attributes 以排除签名文件

 exclude 'META-INF/*.SF'
 exclude 'META-INF/*.DSA'
 exclude 'META-INF/*.RSA'

执行这个jar,就可以生成对应的jar文件了,在命令行输入 java -jar xxx.jar -xx 即可执行了

exec/bat

此步骤依赖上面的jar,jar包执行前依赖java -jar 要解码一次,还不符合我们的要求,所以我们这里要使用distZip/distTar来生成我们需要的可执行文件。执行gradle packageDistribution, 即可生成对应的可执行文件:

解压后,我们再讲环境变量配置在.bash_profile中,

打开命令行工具,输入命令:

成功!

四、写在最后

本文以最简单的方式实现了一个CLI的实现,功能很简单。通过这个入门,学习一下CLI的实现。 示例代码:在这里