使用ComposeDesktop開發一款桌面端多功能APK工具

語言: CN / TW / HK

theme: smartblue highlight: androidstudio


前言

終於算是忙完了一個階段!!!從4月份開始,工作內容以及職務上都進行了較大的變動,最直接的就是從海外專案組調到了國內專案組。國內專案組目前有兩個應用在同時跑著,而且還有幾個馬甲包也要維護,不知道大家發版的時候複雜不復雜,反正我們每次發版的時候都需要經歷--打包、加固、對齊、重簽名、打渠道包、上傳雲端儲存、生成渠道推廣連結、生成內更SQL、上傳Mapping檔案等等步驟(xN),簡直是折磨人啊。

所以首要任務就是做出一套自動化的基礎設施來,最初直接考慮到的方案是【Jenkins+Docker+360命令列加固+VasDolly+Bugly等】的方案(下一篇文章會給大家分享該方案),整個過程下來基本能達到自動化的目的。就這麼穩定的跑了一個多月,然而,在5月下旬的時候360加固釋出了一個通知,大致內容就是免費版使用者無法使用命令列的加固方式了,只能手動用工具加固。這就導致最初的方案直接垮掉,我花費了個把月學習Linux,Pipeline,Docker,還製作了各種映象,結果突然不能用了,心塞。然而路還是要繼續走下去的,在儘量不花錢的前提下,想到了開發桌面端工具的方案。

功能一覽

接下來先給大家一覽下桌面端工具的基本功能,我的電腦是Windows的,所以都是基於Windows平臺下的build-tools相關工具進行開發的。首先大部分的功能都是基於jar或exe檔案,那麼在Java(Kotlin)中我們可以通過如下方式來呼叫這些外部程式,exec其實最終也是呼叫了ProcessBuilder,整體的原理就是如此: ```java //方式1 Runtime.getRuntime().exec(cmd)

//方式2 ProcessBuilder(cmd) ```

多渠道打包

這是該工具最基本的功能,使用VasDolly方案對APK檔案進行多渠道打包(當然該APK檔案需要是簽名好的)。 Snipaste_2022-06-29_20-41-40.png

多渠道包命令列工具即 VasDolly.jar,該檔案可以在上述GitHub倉庫中找到,常用的命令如下: ```java // 獲取指定APK的簽名方式 java -jar VasDolly.jar get -s [源apk地址]

// 獲取指定APK的渠道資訊 java -jar VasDolly.jar get -c [源apk地址]

// 刪除指定APK的渠道資訊 java -jar VasDolly.jar remove -c [源apk地址]

// 通過指定渠道字串新增渠道資訊 java -jar VasDolly.jar put -c "channel1,channel2" [源apk地址] [apk輸出目錄]

// 通過指定某個渠道字串新增渠道資訊到目標APK java -jar VasDolly.jar put -c "channel1" [源apk地址] [輸出apk地址]

// 通過指定渠道檔案新增渠道資訊 java -jar VasDolly.jar put -c channel.txt [源apk地址] [apk輸出目錄]

// 提供了FastMode,生成渠道包時不進行強校驗,速度可提升10倍以上 java -jar VasDolly.jar put -c channel.txt -f [源apk地址] [apk輸出目錄]

```

對齊和簽名

上傳應用市場前,APK檔案大部分會被市場要求進行加固,無論是使用騰訊樂固還是360加固等方式,加固後APK的簽名信息總會被破壞,所以我們需要對加固後的APK檔案重新進行簽名。

配置簽名

首先我們需要準備好應用的簽名信息,該工具支援匯入簽名檔案,並儲存相應的StorePass、KeyAlias、KeyPass資訊,如下: Snipaste_2022-06-29_20-52-49.png

當選擇APK後,程式會判斷選擇的APK是否進行了簽名,如果沒有簽名,那麼就會彈窗提醒使用者選擇配置好的簽名檔案進行簽名,簽名之後才可進行多渠道打包的過程。 Snipaste_2022-06-29_20-44-36.png 注:該功能現已升級,添加簽名檔案的時候繫結包名,選擇apk後會自動獲取到包名然後查詢到對應的簽名檔案自動對齊簽名處理,無需手動進行選擇了。

對齊

簽名的過程則需要用到Android SDK中的兩個檔案,以Windows系統為例,一個是處理對齊的【build-tools\版本號\zipalign.exe】檔案,另一個則是用來簽名的【build-tools\版本號\lib\apksigner.jar】檔案。

我們先看下zipalign工具的官方說明:

zipalign is a zip archive alignment tool. It ensures that all uncompressed files in the archive are aligned relative to the start of the file. This allows those files to be accessed directly via mmap(2), removing the need to copy this data in RAM and reducing your app's memory usage. zipalign是一種zip歸檔對齊工具。它確保存檔中所有未壓縮的檔案都與檔案的開頭對齊。這允許通過mmap直接訪問這些檔案,無需將這些資料複製到RAM中,並減少應用程式的記憶體使用。

zipalign should be used to optimize your APK file before distributing it to end-users. This is done automatically if you build with Android Studio. This documentation is for maintainers of custom build systems. 在將APK檔案分發給使用者之前,應使用zipalign優化APK檔案。如果您使用Android Studio進行構建,這將自動完成。本文件面向定製構建系統的維護人員。

Google官方現在要求在使用apksigner對APK檔案進行簽名前需要先使用zipalign來優化APK檔案,具體命令如下,以Windows下的zipalign.exe檔案為例: ```java //對齊APK zipalign.exe -p -f -v 4 [源apk路徑] [輸出apk路徑]

//驗證APK是否對齊 zipalign.exe -c -v 4 [源apk路徑] ``` 其他相關的內容可以參閱官網 zipalign

簽名

當APK檔案對齊後,就可以給對齊後的APK進行簽名操作了,簽名的方法有兩種,我們這裡單說使用--ks選項指定金鑰庫的方式,具體命令如下: java java -jar apksigner.jar sign --verbose --ks [KeyStore檔案路徑] --ks-pass pass:[KeyStorePass] --ks-key-alias [KeyAlias] --key-pass pass:[KeyPass] --out [輸出apk路徑] [源apk路徑] 命令本身很簡單,別搞錯引數就好,尤其是兩個密碼的引數,後面需要使用【pass:密碼】。輸入密碼這裡還支援其他格式,如果有需要請參閱官網 apksigner

加固、對齊、重簽名後,這個apk就可以進行多渠道打包的處理了,然後即可釋出到相關市場和渠道。

其他內容

在專案中還有很多其他的相關配置,比如發版的時候需要對APP進行應用內的更新通知。那麼就需要我們填寫發版的相關資訊,版本名、版本號、更新日誌等等內容都需要完善(可根據APK檔案的命名來獲取部分資訊),然後通過這些資訊生成應用內部更新的SQL語句,傳送釘釘通知給相關後臺人員處理。通知這一步又用到了釘釘的SDK,該工具支援配置釘釘機器人Webhook地址以及需要艾特的人員資訊。

打出來的這些包都需要統一上傳到雲端儲存上面,這一步使用了AWS的雲端儲存SDK,可以配置雲端儲存桶地址等資訊,免去人工手動上傳apk的煩惱。上傳完畢後會根據檔名生成相應的下載連結並通知到釘釘群,以便市場人員獲取到渠道最新的推廣連結等。 Snipaste_2022-06-29_20-45-40.png

桌面端開發

接下來就說下桌面端的開發過程,至於Compose MultiPlatform的介紹,請參閱官網地址。本文主要就描述下一些針對桌面端的相關需求。

彈窗

關於彈窗,ComposeDesktop同樣提供了Dialog可組合函式: ```java @Composable public fun Dialog( onCloseRequest: () -> kotlin.Unit, state: androidx.compose.ui.window.DialogState, visible: kotlin.Boolean, title: kotlin.String, icon: androidx.compose.ui.graphics.painter.Painter?, undecorated: kotlin.Boolean, transparent: kotlin.Boolean, resizable: kotlin.Boolean, enabled: kotlin.Boolean, focusable: kotlin.Boolean, onPreviewKeyEvent: (androidx.compose.ui.input.key.KeyEvent) -> kotlin.Boolean, onKeyEvent: (androidx.compose.ui.input.key.KeyEvent) -> kotlin.Boolean, content: @Composable() (DialogWindowScope.() -> kotlin.Unit) ): kotlin.Unit { / compiled code / }

```

大部分的引數都可以直接看出他的作用,主要看一下state引數,該引數可以控制彈窗的位置及大小,例如我們配置一個在螢幕中央,寬高為500*300dp的彈窗,那麼示例程式碼如下:

java state = DialogState( position = WindowPosition(Alignment.Center), size = DpSize(500.dp, 300.dp), )

不過這個彈窗沒有陰影,如果想新增的話可以內部套一層Surface來做出陰影效果:

java Surface( modifier = Modifier.fillMaxSize().padding(20.dp), elevation = 10.dp, shape = RoundedCornerShape(16.dp) )

檔案選擇器

關於檔案選擇器這一塊目前Compose還沒有專門的函式,但是我們還是可以使用原有的方案:

  • javax.swing.JFileChooser
  • java.awt.FileDialog

個人還是更偏向於使用JFileChooser,因為使用第二種方案的話,在頁面重組的情況下總是會莫名的彈出選擇框來。一個簡單的檔案選擇器如下所示: ```java private fun showFileSelector( suffixList: Array, onFileSelected: (String) -> Unit ) { JFileChooser().apply { //設定頁面風格 try { val lookAndFeel = UIManager.getSystemLookAndFeelClassName() UIManager.setLookAndFeel(lookAndFeel) SwingUtilities.updateComponentTreeUI(this) } catch (e: Throwable) { e.printStackTrace() }

    fileSelectionMode = JFileChooser.FILES_ONLY
    isMultiSelectionEnabled = false
    fileFilter = FileNameExtensionFilter("檔案過濾", *suffixList)

    val result = showOpenDialog(ComposeWindow())
    if (result == JFileChooser.APPROVE_OPTION) {
        val dir = this.currentDirectory
        val file = this.selectedFile
        println("Current apk dir: ${dir.absolutePath} ${dir.name}")
        println("Current apk name: ${file.absolutePath} ${file.name}")
        onFileSelected(file.absolutePath)
    }
}

} ``` 該方式在使用的過程中也有一定的缺陷,就是每次開啟檔案彈窗總是會卡頓一下,所以後續也是有了尋找其他高效選擇檔案方式的想法。

檔案拖拽

選擇檔案除了上面的彈窗選擇方式,還有另一種神奇的方式 - 拖拽選擇,本來也是沒有頭緒,然而在Slack閒逛的時候發現了Jim Sproch推薦了一篇相關的文章:https://dev.to/tkuenneth/from-swing-to-jetpack-compose-desktop-2-4a4h 。看完後也是恍然大悟,但是在Compose Desktop中,window是整個視窗,如何讓某一個指定的區域響應我們的檔案拖拽事件呢?

還記得在Android上有ComposeView吧,用來巢狀原來的那一套View體系。那麼在這裡我也是採用了類似的這麼一種方式,例項一個空的JPanel控制元件然後給它安排到window中去。具體位置及大小的設定呢,在Compose中可以通過 onPlaced(onPlaced: (LayoutCoordinates) -> Unit) 修飾符來獲取到,示例程式碼如下所示: ```java @OptIn(ExperimentalComposeUiApi::class) @Composable fun DropBoxPanel( modifier: Modifier, window: ComposeWindow, component: JPanel = JPanel(), onFileDrop: (List) -> Unit ) {

val dropBoundsBean = remember {
    mutableStateOf(DropBoundsBean())
}

Box(
    modifier = modifier.onPlaced {
        dropBoundsBean.value = DropBoundsBean(
            x = it.positionInWindow().x,
            y = it.positionInWindow().y,
            width = it.size.width,
            height = it.size.height
        )
    }) {
    LaunchedEffect(true) {
        component.setBounds(
            dropBoundsBean.value.x.roundToInt(),
            dropBoundsBean.value.y.roundToInt(),
            dropBoundsBean.value.width,
            dropBoundsBean.value.height
        )
        window.contentPane.add(component)

        val target = object : DropTarget(component, object : DropTargetAdapter() {
            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)

            }
        }) {

        }
    }

    SideEffect {
        component.setBounds(
            dropBoundsBean.value.x.roundToInt(),
            dropBoundsBean.value.y.roundToInt(),
            dropBoundsBean.value.width,
            dropBoundsBean.value.height
        )
    }

    DisposableEffect(true) {
        onDispose {
            window.contentPane.remove(component)
        }
    }
}

} ``` 實際執行效果如下,個人感覺基本還是能達到目的的: GIF 2022-7-19 19-27-50.gif

資料的儲存

最開始的時候,功能很少,每個配置的資料都是使用了txt檔案來一行行儲存,但是到了後來功能越來越複雜,單純的按行來處理貌似有點捉襟見肘了,所以考慮使用json來儲存複雜的型別資料。

json資料的處理從原生JSON到FastJson,Gson,Moshi等都已經體驗過了,於是乎便採用了之前未使用過的Jackson。然而不得不說,就目前為止,jackson是我用過最簡潔、優雅的一款解析庫。

假如我有一個List型別的列表資料,那麼當我要把這個資料儲存到檔案的時候只需: java jacksonObjectMapper().writeValue(File, List<String>) 而從檔案中讀取資料也是簡單的狠啊: ```java //方式1 val list = jacksonObjectMapper().readValue>(jsonFile)

//方式2 val list : List = jacksonObjectMapper().readValue(jsonFile) ```

這種簡潔真的是深入我心。繼續深入瞭解下Jackson,你會發現它的可擴充套件性以及可定製性都很強,簡直相見恨晚啊。之前也是在一個舒適圈待習慣了,這次主動跳出來居然有了意想不到的收穫。

但是呢,每個框架也會有它自己的注意點,比如jackson,屬性命名不可以是is開頭,否則序列化等就會報錯。這點似乎在阿里巴巴JAVA手冊中好像也有提到,具體原因請大家自行百度(Google)。

資源的拷貝

當我們使用[java -jar xxx.jar]命令執行jar檔案的時候,需要明確指定 jar檔案的地址,但是在Compose Desktop中我們要怎麼存放並讀取這個jar檔案呢 ?我們可以從Compose Desktop中讀取並展示圖片的相關程式碼中得到啟發,假如有一個sample.svg圖示檔案存放到了專案的 resources 資料夾下,那麼我們在引用這張圖片的時候就可以使用: java painterResource("sample.svg") 我們點進去這個方法看下: ```java @OptIn(ExperimentalComposeUiApi::class) @Composable fun painterResource( resourcePath: String ): Painter = painterResource( resourcePath, ResourceLoader.Default )

@ExperimentalComposeUiApi @Composable fun painterResource( resourcePath: String, loader: ResourceLoader ): Painter = when (resourcePath.substringAfterLast(".")) { "svg" -> rememberSvgResource(resourcePath, loader) "xml" -> rememberVectorXmlResource(resourcePath, loader) else -> rememberBitmapResource(resourcePath, loader) } 裡面居然有個ResourceLoader類,這名字一聽就有戲啊,大概率就是我們需要的內容,而傳遞的預設引數是ResourceLoader.Default,那麼就看下Default的原始碼吧:java //==========Resources.desktop.kt檔案========== @ExperimentalComposeUiApi interface ResourceLoader { companion object { /* * Resource loader which is capable to load resources from resources folder in an application's * project. Ability to load from dependent modules resources is not guaranteed in the future. * Use explicit ClassLoaderResourceLoader instance if such guarantee is needed. / @ExperimentalComposeUiApi val Default = ClassLoaderResourceLoader() } fun load(resourcePath: String): InputStream }

@ExperimentalComposeUiApi class ClassLoaderResourceLoader : ResourceLoader { override fun load(resourcePath: String): InputStream { // TODO(https://github.com/JetBrains/compose-jb/issues/618): probably we shouldn't use // contextClassLoader here, as it is not defined in threads created by non-JVM val contextClassLoader = Thread.currentThread().contextClassLoader!! val resource = contextClassLoader.getResourceAsStream(resourcePath) ?: (::ClassLoaderResourceLoader.javaClass).getResourceAsStream(resourcePath) return requireNotNull(resource) { "Resource $resourcePath not found" } } }

//==========ClassLoader類========== public InputStream getResourceAsStream(String name) { Objects.requireNonNull(name); URL url = getResource(name); try { return url != null ? url.openStream() : null; } catch (IOException e) { return null; } }

public URL getResource(String name) { Objects.requireNonNull(name); URL url; if (parent != null) { url = parent.getResource(name); } else { url = BootLoader.findResource(name); } if (url == null) { url = findResource(name); } return url; } ```

上述原始碼的整個邏輯基本上就是兩步,根據資原始檔名獲取到資原始檔,然後獲取資原始檔的輸入流。看到這裡其實我們已經有兩種方案了:

  • 方案一:直接拿到檔案的URL然後獲取到檔案的路徑
  • 方案二:根據檔案的輸入流,將檔案重新儲存到本機相關目錄

然而事情並沒有這麼簡單,如果我們使用方案一,那麼在編譯執行的時候完全沒有問題,所有的資原始檔會被儲存到【\build\processedResources\jvm】下,此時我們直接可以通過檔案的URL獲取到檔案路徑,然後呼叫即可。但是,當我們打包成安裝包後,例如在Windows下使用packageMsi命令打包出msi檔案並安裝到電腦上後,執行程式,這時候你就會發現資原始檔所在的路徑就很奇怪,例如我的工程下是【C:\Program Files\工程名\app\工程名-jvm-1.0-SNAPSHOT-xxxxxx.jar!/資原始檔名】,也就是說所有的資原始檔被打包進了這個快照檔案,如果此時直接使用該路徑執行java -jar 等命令,那麼肯定就會報錯了。

所以最穩妥的方式還是使用方案二,使用ResourceLoader獲取到資原始檔流然後重新儲存到本機上的相關目錄就好了,虛擬碼如下: java ResourceLoader.Default.load(resourcesPath) .use { inputStream -> val fos = FileOutputStream(file) val buffer = ByteArray(1024) var len: Int while (((inputStream.read(buffer).also { len = it })) != -1) { fos.write(buffer, 0, len) } fos.flush() inputStream.close() fos.close() }

打包MSI

在Windows環境下打包Msi格式安裝包的時候,有一個downloadWix的Task,該Task涉及到了Wix資源的下載,如下 :

Task :downloadWix Download https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip

在IDEA中下載可能會非常的緩慢,此時我們可以複製上述地址,登上梯子,然後直接去GitHub下載。下載完畢後直接放入【/build/wixToolset】目錄下即可,再次編譯速度就會起飛了。

總結

簡直沒想到啊,作為一個Android開發者,現在藉助Compose Desktop開發起桌面端居然能這麼的輕車熟路,我對Compose真是越來越喜歡了。

另外呢,跳出業務這一段時間來處理這些東西也讓我對干預APK的打包等過程從理論邁出了實踐的一步,同時對市場和運營同學的工作也有了更多瞭解,通過該工具幫助其處理了部分重複機械式的工作,部門間的感情也得到了進一步的增溫(狗頭滑稽)。

就編到這吧,桌面工具還需要持續的維護跟優化,基本是面向市場和運營同事程式設計了。關於開頭說的Jenkins那一套其實早就寫好了,是鄙人少有的萬字長文,但是中間變故太大,一直也沒釋出出來,接下來會重新整理下併發布,還請大家多多指正。