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萬現金大獎」