Compose For Desktop 實踐:使用 Compose-jb 做一個時間水印助手

語言: CN / TW / HK

前言

在我之前的文章 在安卓中實現讀取Exif獲取照片拍攝日期後以水印文字形式添加到照片上 中,我們已經實現了在安卓端讀取 Exif 信息後添加文字水印到圖片上。

也正如我在這篇文章中所説的,其實這個需求使用手機來實現是非常不合理的,一般來説,這種工作都應該交由桌面端來實現。

而我在上篇文章中所述之所以沒有使用 Compose-jb 實現跨平台的原因是沒有找到合適的跨平台圖片編輯庫。

雖然現在依舊沒有合適的跨平台編輯庫,但是我現在決定做一個純粹的桌面端,而不是繼續拘泥於跨平台。

如此一來,可選擇的庫就多了。

先來看看實現效果:

原諒我的 UI 一如既往的醜,希望各位看官別在意,我們主要是實現需求,能用就行能用就行。

s1.png

s2.png

得益於 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

圖像預覽區同樣分為兩個部分:上面的圖像預覽、下面的文件列表。

因為桌面端需要支持批量處理,一次可以添加不限制數量的多張圖片,所以還需要加上一個文件列表,用來展示當前添加了那些文件。

具體代碼如下:

```kotlin @OptIn(ExperimentalMaterialApi::class) @Composable fun ImageContent( onclick: () -> Unit, onDel: (index: Int) -> Unit, fileList: List = 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.png

另外,在沒有選中任何文件時,這個界面支持直接將文件或文件夾拖拽到應用中,也支持點擊後打開文件選擇界面。這部分內容的具體實現我們將在後面的實現邏輯中解釋。

ControlContent

參數控制界面的效果如下:

s4.png

可以看到,這個界面無非就是一堆控件的堆疊,沒有任何難度,所以我就不貼代碼了。

需要注意的地方有兩點:

一是佈局之間會有關聯影響,比如第一個 "輸出路徑" 這個參數,如果勾選了 "輸出至原路徑" ,則將輸入框和"選擇"按鈕禁用,並更改輸入框內容為 "原路徑"。

實現起來也很簡單,這裏直接上代碼:

```kotlin

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 文件中添加依賴:

kotlin 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 標籤,不知道各個標籤是什麼意思的可以看我之前的文章,裏面有詳細解釋:

kotlin 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 的函數應該是:

kotlin 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 寫入文件中即可。

簡單實現代碼如下:

kotlin // 讀取原文件 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 的函數如下:

kotlin 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界面嗎?我們的軟件是可以定義水印位置的,可以選擇圖片的四個角。

也就是説,我們需要單獨處理一下座標的計算:

```kotlin // 水印座標位置 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 是我定義的一個枚舉類:

kotlin 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 座標為 圖片高度

現在,添加文字的代碼已經全部完成,但是我們還需要加億點小細節,例如設置文字大小,設置文字顏色等:

kotlin graphics.color = textColor //水印顏色 graphics.font = Font(null, Font.PLAIN, fontSize) // 文字樣式,第一個參數是字體,這裏直接使用 Null(因為支持多種桌面端,指定字體的話可能反而會找不到)

選擇文件

直接調用文件選擇

這裏我們使用的是 java swing 中的文件選擇器: JFileChooser 來實現文件選擇功能:

```kotlin fun showFileSelector( suffixList: Array = arrayOf("jpg", "jpeg"), // 過濾的文件擴展名 isMultiSelection: Boolean = true, // 是否允許多選 selectionMode: Int = JFileChooser.FILES_AND_DIRECTORIES, // 可以選擇目錄和文件 selectionFileFilter: FileNameExtensionFilter? = FileNameExtensionFilter("圖片(.jpg .jpeg)", *suffixList), // 文件過濾 onFileSelected: (Array) -> 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 用於接收拖拽事件:

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

其中,dropFileTarget 函數如下:

```kotlin fun dropFileTarget( onFileDrop: (List) -> 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 這意味着接收拖拽事件的是整個程序窗口而不是單獨的這個圖像預覽界面。

過濾文件

完成上面兩種的選擇文件代碼後,我們的處理邏輯還沒有完哦,別忘了,我們説過,這個文件選擇支持多選文件,甚至是文件夾。

這意味着我們需要對傳入的選擇文件(夾)做遍歷以及過濾處理:

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

return filterFileList(newFile.toTypedArray())

}

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

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 { val newFileList = mutableListOf() 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

本文正在參加「金石計劃 . 瓜分6萬現金大獎」