Compose For Desktop 实践:使用 Compose-jb 做一个时间水印助手

前言

在我之前的文章 在安卓中实现读取Exif获取照片拍摄日期后以水印文字形式添加到照片上 中,我们已经实现了在安卓端读取 Exif 信息后添加文字水印到图片上。

也正如我在这篇文章中所说的,其实这个需求使用手机来实现是非常不合理的,一般来说,这种工作都应该交由桌面端来实现。

而我在上篇文章中所述之所以没有使用 Compose-jb 实现跨平台的原因是没有找到合适的跨平台图片编辑库。

虽然现在依旧没有合适的跨平台编辑库,但是我现在决定做一个纯粹的桌面端,而不是继续拘泥于跨平台。

如此一来,可选择的库就多了。

先来看看实现效果:

原谅我的 UI 一如既往的丑,希望各位看官别在意,我们主要是实现需求,能用就行能用就行。

s1

s2

得益于 Compose 的特性,这个程序同时支持 Mac、Windows、Linux 系统。

代码地址:TimelapseHelper

UI布局

UI布局总体来说分为左右两个部分:左边的图像预览区(ImageContent)、右边的参数控制区(ControlContent)。

为了确保我们的内容能够完整显示,我们需要首先在 Window 入口处设置窗口最小尺寸限制:

window.minimumSize = Dimension(MinWindowSize.width.value.roundToInt(), MinWindowSize.height.value.roundToInt())

其中 MinWindowSize 是我自定义的一个变量:val MinWindowSize = DpSize(1100.dp, 700.dp)

下面分开讲解两个部分的UI布局。

ImageContent

图像预览区同样分为两个部分:上面的图像预览、下面的文件列表。

因为桌面端需要支持批量处理,一次可以添加不限制数量的多张图片,所以还需要加上一个文件列表,用来展示当前添加了那些文件。

具体代码如下:

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ImageContent(
    onclick: () -> Unit,
    onDel: (index: Int) -> Unit,
    fileList: List<File> = emptyList()
) {
    var showImageIndex by remember { mutableStateOf(0) }

    Card(
        onClick = onclick,
        modifier = Modifier.size(CardSize).padding(16.dp),
        shape = RoundedCornerShape(8.dp),
        elevation = 4.dp,
        backgroundColor = CardColor,
        enabled = fileList.isEmpty()
    ) {
        if (fileList.isEmpty()) {
            Column(
                modifier = Modifier.fillMaxSize(),
                verticalArrangement = Arrangement.Center,
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(
                    text = "请点击选择文件(夹)或拖拽文件(夹)至此\n仅支持 ${legalSuffixList.contentToString()}",
                    textAlign = TextAlign.Center
                )
            }
        }
        else {
            Column(
                modifier = Modifier.fillMaxSize(),
                verticalArrangement = Arrangement.Center,
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Image(
                    bitmap = fileList[showImageIndex.coerceAtMost(fileList.lastIndex)].inputStream().buffered().use(::loadImageBitmap),
                    contentDescription = null,
                    modifier = Modifier.height(CardSize.height / 2).fillMaxWidth(),
                    contentScale = ContentScale.Fit
                )

                LazyColumn(
                    modifier = Modifier.fillMaxWidth()
                ) {
                    item {
                        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
                            Button(onClick = onclick ) {
                                Text("添加")
                            }
                            Button(onClick = { onDel(-1) }) {
                                Text("清空")
                            }
                        }

                    }

                    itemsIndexed(fileList) {index: Int, item: File ->
                        Row(
                            modifier = Modifier.fillMaxWidth(),
                            verticalAlignment = Alignment.CenterVertically,
                            horizontalArrangement = Arrangement.SpaceBetween
                        ) {
                            Text(
                                item.absolutePath,
                                modifier = Modifier.clickable {
                                    showImageIndex = index
                                }.weight(0.9f),
                            )

                            Icon(
                                imageVector = Icons.Rounded.Delete,
                                contentDescription = null,
                                modifier = Modifier.clickable {
                                    onDel(index)
                                }.weight(0.1f)
                            )
                        }
                    }
                }
            }
        }
    }
}

布局很简单,使用 Card 作为父布局,然后判断传入的文件列表是否为空 fileList.isEmpty() ,如果为空则显示提示文本,不为空则显示图像和文件列表。

在这里我们定义了一个名为 showImageIndexmutableState 用于记录当前显示预览的是第几个图像文件。

在我们点击 LazyColumn 中的文件时,会对应的更改这个值。

上面的代码我们还需要注意一点,那就是关于如何加载本地文件并显示。

我们使用的是 File.inputStream().buffered().use(::loadImageBitmap) 从这段代码不难看出,我们读取文件的输入流(inputStream)后,通过 loadImageBitmap 转为了 Image 组件支持的参数类型 ImageBitmap

同时,我们还将 LazyColumn 的第一列写为了两个按钮 “添加” 和 “清空” ,用于方便的继续添加文件和清空所有文件。

并且,每一个文件名称后面,我们都会跟上一个删除图标,用于删除单个文件。

效果如下:

s3

另外,在没有选中任何文件时,这个界面支持直接将文件或文件夹拖拽到应用中,也支持点击后打开文件选择界面。这部分内容的具体实现我们将在后面的实现逻辑中解释。

ControlContent

参数控制界面的效果如下:

s4

可以看到,这个界面无非就是一堆控件的堆叠,没有任何难度,所以我就不贴代码了。

需要注意的地方有两点:

一是布局之间会有关联影响,比如第一个 “输出路径” 这个参数,如果勾选了 “输出至原路径” ,则将输入框和"选择"按钮禁用,并更改输入框内容为 “原路径”。

实现起来也很简单,这里直接上代码:


var isUsingSourcePath by remember { mutableStateOf(true) }

// ……

Row(
    verticalAlignment = Alignment.CenterVertically,
) {
    Text("输出路径:")
    OutlinedTextField(
        value = outputPath,
        onValueChange = { outputPath = it },
        modifier = Modifier.width(CardSize.width / 3),
        enabled = !isUsingSourcePath
    )
    Button(
        onClick = {
                  // ……
        },
        modifier = Modifier.padding(start = 8.dp),
        enabled = !isUsingSourcePath
    ) {
        Text("选择")
    }
    Checkbox(
        checked = isUsingSourcePath,
        onCheckedChange = {
            isUsingSourcePath = it
            outputPath = if (it) "原路径" else ""
        }
    )
    Text("输出至原路径", fontSize = 12.sp)
}

另外一个需要注意的点是我们需要对输入框的内容做过滤。

因为实际上我们输入框中的内容基本都是有固定格式的。

比如第二个输入框 “导出图像质量”,需要限定输入内容为 0-1 的浮点数。

第三个输入框 “文字颜色”,输入格式为首字母为 “#” 剩下的是八位十六进制数。

最后一个输入框 “时区”,格式为首字母固定 “GMT” ,接下来紧跟一个 “+” 或者 “-",最后是固定的 “xx:xx” 格式,其中 xx 可以是任意数字。(其实这里的时区可以使用多种表示方式,但是这里我们人为限制只能使用这种标准表示方式)

因为输入内容过滤我还没玩明白,所以这里就暂时不说了,等我玩明白了会另开一篇文章讲解。(我绝对不会承认其实是我代码在另外一台电脑上忘记 push 到 github 了,而我一时半会拿不到这台电脑)

逻辑代码

读取 Exif

由于我们这次是给桌面端写的程序,所以之前使用的安卓官方的 Exif 库显然是用不了的,好在我们有一大堆 java 库可以使用。

这里我选择的是 metadata-extractor 这个库。

首先在 build.gradle.kts 文件中添加依赖:

dependencies {
    commonMainImplementation("com.drewnoakes:metadata-extractor:2.18.0")
}

接下来是示例化 Metadata 对象:

val metadata = ImageMetadataReader.readMetadata(file)

这里因为我们传入的文件本来就是 File 类型,所以我们直接使用 File 实例化。

除此之外我们还可以使用输入流实例化:

val metadata = ImageMetadataReader.readMetadata(inputStream)

示例化完成后就是读取特定的 Exif 标签内容,这里我们直接读取 DATETIME_ORIGINAL 标签,不知道各个标签是什么意思的可以看我之前的文章,里面有详细解释:

val directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory::class.java)
val date = directory.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL)

这样我们就能拿到一个 Date 对象,接下只要解析这个 Date 即可。

但是,你们觉得这样就完了吗?

非也非也,一开始我也以为这样就完了。

直到我实际使用时却发现,这样获取到的时间总是和实际时间相差八个小时。

不多不少刚刚好八个小时,有经验的读者可能已经意识到了,八个小时,那不就是时区不对嘛,因为中国的官方时区就是 GMT+08:00 。

其实这个问题也很好理解,正如我之前文章中所述,在旧版本的 Exif 标准中,并没有指定时区这一内容,也就是说, Exif 中保存的时间不包含时区信息,所以我们需要自己重新解析时区。

但是这里又出现一个问题,我们不能将时区写死,因为我们不能假定我们的用户就一定是某个时区的人,亦或者说,我们怎么能保证我们拍照就一定是在 GMT+08:00 拍呢?格局大一点。(狗头

所以,我这里将时区的选择权交给了用户自己,也就是我们上面 UI 一节中所示的需要用户自己输入时区信息。

所以,最终完整的获取 Exif 的函数应该是:

fun getDateFromExif(
    file: File,
    timeZoneID: String
): Date? {
    return try {
        val timeZone = TimeZone.getTimeZone(timeZoneID)
        val metadata = ImageMetadataReader.readMetadata(file)
        val directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory::class.java)
        directory.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL, timeZone)
    } catch (tr: Throwable) {
        tr.printStackTrace()
        null
    }
}

给图片添加文字水印

对于给图片添加文字水印这个需求,我们使用 JDK 中自带的 Graphics2D 来实现。

使用 Graphics2D 需要先从文件中读取文件流,然后将文件流转为 BufferedImage ,最后使用 BufferedImage 创建 Graphics2D 对象,文字添加完毕后再将 BufferedImage 写入文件中即可。

简单实现代码如下:

// 读取原文件
val targetImg: BufferedImage = ImageIO.read(file)
// 创建 Graphics2D
val graphics: Graphics2D = targetImg.createGraphics()
// 往 Graphics2D 上绘制文字
graphics.drawString(text, x, y)
// 保存文件
saveImage(targetImg, outPath, outputQuality)
// 关闭
graphics.dispose()

其中保存 BufferedImage 的函数如下:

fun saveImage(image: BufferedImage?, saveFile: File?, quality: Float) {
    val outputStream = ImageIO.createImageOutputStream(saveFile)
    val jpgWriter: ImageWriter = ImageIO.getImageWritersByFormatName("jpg").next()
    val jpgWriteParam: ImageWriteParam = jpgWriter.defaultWriteParam
    jpgWriteParam.compressionMode = ImageWriteParam.MODE_EXPLICIT
    jpgWriteParam.compressionQuality = quality
    jpgWriter.output = outputStream
    val outputImage = IIOImage(image, null, null)
    jpgWriter.write(null, outputImage, jpgWriteParam)
    jpgWriter.dispose()
    outputStream.flush()
    outputStream.close()
}

saveImage 这个函数接收一个名为 quality 用于指定保存文件时的质量。

具体实现是通过设置参数 jpgWriteParam.compressionQuality = quality

保存的时候记得要定义这个参数,否则默认值设置的压缩率比较大,一开始我没有设置这个值,导致我十几Mb的图片添加文字后只剩下了几百Kb,画质也肉眼可见的变差,都给我整不会了,这样显然是不符合我的要求的啊。

下面再看看给图片添加水印的具体实现代码:graphics.drawString(text, x, y)

第一个参数很好理解,就是要添加的文字字符串,第二和第三个参数分别表示放置文字的位置坐标。

这里的坐标表示的是第一个字符的基线坐标。

那么问题来了,坐标怎么拿呢?

还记得我们的UI界面吗?我们的软件是可以定义水印位置的,可以选择图片的四个角。

也就是说,我们需要单独处理一下坐标的计算:

// 水印坐标位置
val width: Int = targetImg.width //图片宽
val height: Int = targetImg.height //图片高
val textWidth = graphics.fontMetrics.stringWidth(text)
val textHeight = graphics.fontMetrics.height
val point = textPos.getPoint(width, height, textWidth, textHeight)
val x = point.x
val y = point.y

// ……

private fun TextPos.getPoint(
    width: Int,
    height: Int,
    textWidth: Int,
    textHeight: Int,
    padding: Int = 10
): Point {
    return when (this) {
        TextPos.LEFT_TOP -> {
            Point(padding, textHeight)
        }
        TextPos.LEFT_BOTTOM -> {
            Point(
                padding,
                (height - padding).coerceAtLeast(0)
            )
        }
        TextPos.RIGHT_TOP -> {
            Point(
                (width - textWidth - padding).coerceAtLeast(0),
                textHeight
            )
        }
        TextPos.RIGHT_BOTTOM -> {
            Point(
                (width - textWidth - padding).coerceAtLeast(0),
                (height - padding).coerceAtLeast(0)
            )
        }
    }
}

上面的 x、y 即计算出来的坐标。

其中,TextPos 是我定义的一个枚举类:

enum class TextPos {
    LEFT_TOP,
    LEFT_BOTTOM,
    RIGHT_TOP,
    RIGHT_BOTTOM
}

在上面的获取坐标的函数 getPoint 中,我们通过文字的高度 textHeight = graphics.fontMetrics.height ;所有文字的宽度 textWidth = graphics.fontMetrics.stringWidth(text) ,按照用户选择的文字位置计算出文字应该位于的坐标点。

例如,如果选择水印在左上角,则 x 坐标为 0(实际还添加了 padding),y 坐标为 文字高度

如果为右下角,则 x 坐标为 图片宽度 - 文字总宽度,y 坐标为 图片高度

现在,添加文字的代码已经全部完成,但是我们还需要加亿点小细节,例如设置文字大小,设置文字颜色等:

graphics.color = textColor //水印颜色
graphics.font = Font(null, Font.PLAIN, fontSize) // 文字样式,第一个参数是字体,这里直接使用 Null(因为支持多种桌面端,指定字体的话可能反而会找不到)

选择文件

直接调用文件选择

这里我们使用的是 java swing 中的文件选择器: JFileChooser 来实现文件选择功能:

fun showFileSelector(
    suffixList: Array<String> = arrayOf("jpg", "jpeg"), // 过滤的文件扩展名
    isMultiSelection: Boolean = true,  // 是否允许多选
    selectionMode: Int = JFileChooser.FILES_AND_DIRECTORIES, // 可以选择目录和文件
    selectionFileFilter: FileNameExtensionFilter? = FileNameExtensionFilter("图片(.jpg .jpeg)", *suffixList), // 文件过滤
    onFileSelected: (Array<File>) -> Unit, // 选择回调
    ) {
    JFileChooser().apply {
        // 这里是设置选择器的 UI
        try {
            val lookAndFeel = UIManager.getSystemLookAndFeelClassName()
            UIManager.setLookAndFeel(lookAndFeel)
            SwingUtilities.updateComponentTreeUI(this)
        } catch (e: Throwable) {
            e.printStackTrace()
        }

        fileSelectionMode = selectionMode
        isMultiSelectionEnabled = isMultiSelection
        fileFilter = selectionFileFilter

        // 显示选择器
        val result = showOpenDialog(ComposeWindow())
        
        // 选择后返回
        if (result == JFileChooser.APPROVE_OPTION) {
            if (isMultiSelection) {
                // this.selectedFiles 表示选中的多个文件 array,只有 isMultiSelectionEnabled 为 true 这个变量才有值,否则为 NUll
                onFileSelected(this.selectedFiles)
            }
            else {
                // 如果不开启多选,则返回的是单个文件 this.selectedFile ,但是我们回调接收的是 Array,所以需要手动创建
                val resultArray = arrayOf(this.selectedFile)
                onFileSelected(resultArray)
            }
        }
    }
}

代码很简单,这里就不再过多解释了,需要注意的点已经在注释中说明。

拖拽选择

拖拽选择需要调用到 awt 的原生代码。

我们需要给主入口的 window 添加一个 dropTarget 用于接收拖拽事件:

window.contentPane.dropTarget = dropFileTarget { fileList ->
    println(fileList)
}

其中,dropFileTarget 函数如下:

fun dropFileTarget(
    onFileDrop: (List<String>) -> Unit
): DropTarget {
    return object : DropTarget() {
        override fun drop(event: DropTargetDropEvent) {

            event.acceptDrop(DnDConstants.ACTION_REFERENCE)
            val dataFlavors = event.transferable.transferDataFlavors
            dataFlavors.forEach {
                if (it == DataFlavor.javaFileListFlavor) {
                    val list = event.transferable.getTransferData(it) as List<*>

                    val pathList = mutableListOf<String>()
                    list.forEach { filePath ->
                        pathList.add(filePath.toString())
                    }
                    onFileDrop(pathList)
                }
            }
            event.dropComplete(true)
        }
    }
}

需要注意的是,因为我们这个拖拽事件是添加到主入口的 window 的,而不是单独的图像预览 Card 这意味着接收拖拽事件的是整个程序窗口而不是单独的这个图像预览界面。

过滤文件

完成上面两种的选择文件代码后,我们的处理逻辑还没有完哦,别忘了,我们说过,这个文件选择支持多选文件,甚至是文件夹。

这意味着我们需要对传入的选择文件(夹)做遍历以及过滤处理:

fun filterFileList(fileList: List<String>): List<File> {
    val newFile = mutableListOf<File>()
    fileList.map {path ->
        newFile.add(File(path))
    }

    return filterFileList(newFile.toTypedArray())
}

fun filterFileList(fileList: Array<File>): List<File> {
    val newFileList = mutableListOf<File>()

    for (file in fileList) {
        if (file.isDirectory) {
            newFileList.addAll(getAllFile(file))
        }
        else {
            if (file.extension.lowercase() in legalSuffixList) {
                newFileList.add(file)
            }
        }
    }

    return newFileList
}

private fun getAllFile(file: File): List<File> {
    val newFileList = mutableListOf<File>()
    val fileTree = file.walk()
    fileTree.maxDepth(Int.MAX_VALUE)
        .filter { it.isFile }
        .filter { it.extension.lowercase() in legalSuffixList }
        .forEach {
            newFileList.add(it)
        }

    return newFileList
}

然后在选择文件的回调处调用即可。

上面的代码做的工作就是遍历接收到的文件列表,如果是文件则判断扩展名是否符合需求,符合则添加至文件列表。

如果是文件夹则使用 FileTreeWalk 遍历这个文件夹,然后找出符合条件的文件添加至文件列表,这里我们的遍历深度是最大(Int.MAX_VALUE)也就是说会遍历该文件的所有子文件,以及子文件夹,包括所有深度的子文件夹的所有子文件。

总结

Compose-jb 让原本的移动端开发者也能很方便的进行桌面端开发,但是毕竟 Compose 只是一个 UI 工具包,对于实际的业务逻辑代码,还是需要调用原生 API 来实现。

好在 Kotlin 是 jvm 语言,并且 Compose-jb 的实现也是基于 java 的 Swing ,也就是说对于安卓开发者来说,即使很多逻辑需要调用的也只是 Swing API ,对于安卓开发来说,基本没有什么门槛,看一下文档基本就能上手写了。

参考资料

  1. 使用ComposeDesktop开发一款桌面端多功能APK工具
  2. From Swing to Jetpack Compose Desktop #2
  3. Java中图片添加水印(文字+图片水印)
  4. Image and in-app icons manipulations