Compose Multiplatform 实战:联机五子棋
1. 認識 Compose Multiplatform
Jetpack Compose 作為 Android 端的新一代UI開發工具,得益于 Kotlin 優秀的語法特性,代碼寫起來十分簡潔,廣受開發者好評。作為 Kotlin 的開發方,JetBrains 在 Compose 的研發過程中也給與了大量幫助,可以說 Compose 是 Google 和 JetBrains 合作的產物。
在參與合作的過程中,JetBrains 也看到了 Compose 在跨平臺方面的潛力,Compose 良好的分層設計使得其除了渲染層以外的的大部分代碼都是平臺無關的,依托 Kotlin Multiplatform (KMP), Compose 可以低成本地化身為一個跨平臺框架。
JetBrains 一年多前開始基于 Jetpack 源碼開發更多的 Compose 應用場景:
| 2020/11 | 發布 Compose for Desktop,Compose 可以支持 MacOS、Windows、Linux 等桌面端 UI 的開發,并在后續的幾個 Milestone 中持續擴展新能力 |
| 2021/05 | 發布 Compose for Web,Compose 基于 Kotlin/JS 實現前端 UI 的開發 |
| 2021/08 | JetBranins 將 Android/Desktop/Web 等各端的 Compose 版本統一,發布 Compose Multiplatform (CMP),使用同一套 ArtifactId 就可以開發跨端的 UI 。 |
參考 《Compose Multiplatform 正式官宣》
雖然 CMP 尚處于 alpha 階段,由于它 fork 了 Jetpack 穩定版的分支進行開發,API 已經穩定,樂觀預計年內就會發布 1.0 版 。
接下來通過一個例子感受一下如何使用 CMP 開發一個跨端應用:
Sample:跨端聯機五子棋
地址:https://github.com/vitaviva/cmp-gobang
設計目標:
- 通過 CMP 實現 APP 同時運行在移動端和桌面端,代碼盡量復用
- 通過 Kotlin Multiplatform 實現邏輯層和數據層的代碼
- 基于單向數據流實現 UI層/邏輯層/數據層的關注點分離
2. 新建工程
IDE:IntelliJ IDEA or Android Studio
CMP 可以像普通 KMP 項目一樣使用 IntelliJ IDEA 開發( >= 2020.3),當然 Anroid Studio 作為 IDEA 的另一個發行版也可以使用的
Anroid Studio 和 IDEA 的對應關系 : https://blog.csdn.net/vitaviva/article/details/120598691
AS 編輯器對 Compose 的支持更友好,比如在非 @Composable 函數中調用 @Composable 函數時 IDE 自動標紅提示錯誤, IDEA 則只能在編譯時才能發現錯誤。所以個人更推薦使用 AS 開發。
IDE Plugin 實現預覽
AS 自帶對 @Preview 注解進行預覽, IDEA 也可以通過安裝插件實現預覽
插件安裝后,IDE中遇到 @Preview 注解時左側會出現 Compose logo 的小圖標,點擊后右側窗口可以像 AS 一樣進行預覽。
需要注意的是,此插件只能針對 desktop 工程進行預覽 ,對 android 工程無效。反之使用 AS 自帶的預覽也無法預覽 desktop 。所以在 AS 中開發,想要預覽 desktop 效果仍然要安裝此插件。
接下來讓我們看一下 CMP 工程的文件結構是怎樣的
3. 工程結構
如上,整個工程第一級目錄由三個 module 構成,/android, /common, /desktop:
| /android | 一個 android 工程,用來打包成 android 應用 |
| /desktop | 一個 jvm 工程,用來打包成 desktop 應用 |
| /common | 這是一個 KMP 工程,內部通過 sourceSet 劃分 /androidMain,/desktopMain,/commonMain 等多個目錄, /commonMain 中存放可共享的 Kotlin 代碼 |
當未來添加 web 端工程時,目錄也照例添加。
雖然第一級的 /android 目錄中可以存放差異性代碼,但這畢竟不是一個 KMP 工程,無法識別 actual 等關鍵字,因此需要在 /common 中在開辟 /androidMain 這樣差異性目錄,既可以依賴 Android 類庫,又可以被 commonMain 通過 expect 關鍵字調用
/common
重點看一下 common/build.gradle.kts ,通過 sourceSet 為 xxxMain 目錄分別指定不同依賴,保證平臺差異性:
plugins {kotlin("multiplatform") // KMP插件id("org.jetbrains.compose") // Compose 插件id("com.android.library") // android 插件 }kotlin {android() jvm("desktop") {compilations.all {kotlinOptions.jvmTarget = "11"}}sourceSets { //配置 commonMain 和各平臺的 xxMain 的依賴val commonMain by getting {dependencies { api(compose.runtime)api(compose.foundation)api(compose.material)api(compose.ui)}}val androidMain by getting {dependencies {api("androidx.appcompat:appcompat:1.3.0")api("androidx.core:core-ktx:1.3.1")api("androidx.compose.ui:ui-tooling:1.0.4")}}val desktopMain by getting} }Compose 的 gradle插件版本依賴 classpath 指定
buildscript {dependencies {...classpath("org.jetbrains.compose:compose-gradle-plugin:1.0.0-alpha2")} }如果 gradle 工程使用 .kts,也可省略 classpath ,直接在聲明插件時指定
id("org.jetbrains.compose") version "1.0.0-alpha2"
得益于 CMP 對 ArtifactId 的統一, commonMain 可以通過 api() 完成所有 compose 公共庫的依賴, androidMain 和 destopMain 通過 commonMain 傳遞依賴 compose。
CMP 的 Gradle 依賴相對于 Jetpack, GroupID 發生變化:
| androidx.compose.runtime:runtime | org.jetbrains.compose.runtime:runtime |
| androidx.compose.ui:ui | org.jetbrains.compose.ui:ui |
| androidx.compose.material:material | org.jetbrains.compose.material:material |
| androidx.compose.fundation:fundation | org.jetbrains.compose.fundation:fundation |
/android
/android 目錄就是一個標準 Android 工程,這里就不贅述了
/desktop
最后看一下 /desktop/.build.gradle.kts
plugins {kotlin("multiplatform") //KMP插件id("org.jetbrains.compose") // CMP插件 }kotlin {jvm { //Kotlin/jVMcompilations.all {kotlinOptions.jvmTarget = "11"}}sourceSets {val jvmMain by getting {dependencies {implementation(project(":common")) //依賴common下的desktopMainimplementation(compose.desktop.currentOs)// compose.desktop 依賴}}} }compose.desktop {application {mainClass = "MainKt" // 應用入口nativeDistributions {targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)packageName = "jvm"packageVersion = "1.0.0"}} }- jvmMain{...} :作為一個 jvm 工程,依賴 :common 以及 compose.desktop.{$currentOs)
- compose.desktop {...} :配置入口等桌面端應用信息
/desktop 針對不同桌面系統提供了差異性依賴,可復用代碼在公共庫 desktop:desktop 中。
object DesktopDependencies {val common = composeDependency("org.jetbrains.compose.desktop:desktop")val linux_x64 = composeDependency("org.jetbrains.compose.desktop:desktop-jvm-linux-x64")val linux_arm64 = composeDependency("org.jetbrains.compose.desktop:desktop-jvm-linux-arm64")val windows_x64 = composeDependency("org.jetbrains.compose.desktop:desktop-jvm-windows-x64")val macos_x64 = composeDependency("org.jetbrains.compose.desktop:desktop-jvm-macos-x64")val macos_arm64 = composeDependency("org.jetbrains.compose.desktop:desktop-jvm-macos-arm64")val currentOs by lazy {composeDependency("org.jetbrains.compose.desktop:desktop-jvm-${currentTarget.id}")} }值得注意的是,/desktop 作為一個 kotlin/jvm 項目,卻可以支持 MacOS、Windows、Linux 等多個桌面端的應用開發。
為了降低多個桌面平臺的適配成本,CMP 借助 KMP 的 Skiko 庫實現了渲染的統一,Skiko 顧名思義是一個經過 Kotlin 封裝的 Skia 庫,其內部通過不同的動態鏈接庫調用各平臺的渲染能力,向上提供統一的 Kotlin API,Skiko 為 kotlin/jvm 項目提供了跨平臺渲染能力。
4. 工程代碼
接下來具體看一下工程的業務代碼,從上到下逐層介紹
UI:Compose Graphic
五子棋小游戲的 UI 部分比較簡單,大部分依靠 Compose 的 Canvas API 完成
Box(modifier) {with(LocalDensity.current) {val (linePaint, framePaint) = remember {Paint().apply {color = Color.BlackisAntiAlias = truestrokeWidth = BOARD_LINE_WIDTH_DP.dp.toPx()} to Paint().apply {color = Color.BlackisAntiAlias = truestrokeWidth = BOARD_FRAME_WIDTH_DP.dp.toPx()}}Canvas(modifier = Modifier.fillMaxSize().pointerInput(Unit) {scope.launch {detectTapGestures {viewModel.placeStone(convertPoint(it.x, it.y))}}}) {drawLines(linePaint, framePaint)drawBlackPoints(BOARD_POINT_RADIUS_DP.dp.toPx())drawStones(boardData)}} }drawLines, drawBlackPoints, drawStones 分別用來繪制圍棋棋盤的網格線,交叉點,以及棋子,繪制棋子的 borderData 作為全局 State 存儲在 ViewModel 中,后文介紹。
游戲的交互非常簡單:點擊、落子。 通過pointerInput 的 Modifer 實現 Compose 手勢點擊即可,這個事件同樣可以響應 desktop 側的鼠標單擊事件。
compose.desktop 針對鼠標和鍵盤等輸入設備提供了更多專用的API, 比如接收鼠標右擊事件等,如果有這方面需求,可以在 desktopMain 中實現:參考 https://github.com/JetBrains/compose-jb/tree/master/tutorials/Mouse_Events
private fun DrawScope.drawLines(linePaint: Paint, framePaint: Paint) {drawIntoCanvas { canvas ->fun drawLines(linePoints: FloatArray, paint: Paint) {for (i in linePoints.indices step 4) {canvas.drawLine(Offset(linePoints[i],linePoints[i + 1]),Offset(linePoints[i + 2],linePoints[i + 3]),paint)}}canvas.withSave {with(BoardDrawInfo) {drawLines(HorizontalLinePoints, linePaint)drawLines(VerticalLinePoints, linePaint)drawLines(BoardFramePoints, framePaint)}}}}以 drawLines 為例,通過 drawIntoCanvas 獲取繪制網格線所需的 Canvas 和 Paint 對象,這些都是平臺無關的抽象接口,所以基于 Canvas 的繪制代碼可以跨端復用。
需要注意 CMP 無法通過 native.canvas 獲取 Android 的 Canvas 對象,而 Compose Canvas 沒有提供 drawText 的方法,所以暫時沒有找到繪制文字的方法
差異性處理
涉及到平臺相關的代碼,需要利用 KMP 的 actual/expect 進行差異化處理。
以繪制圍棋棋子為例,涉及到資源文件的讀取和 Bitmap 的創建,各平臺處理方式不同,需要各自實現。
Compose 提供了統一的的 ImageBitmap 類型,我們在 /commonMain 中定義 ImageBimmap 類型的棋子圖片
commonMain/platform/Bitmap.kt
expect val BlackStoneBmp : ImageBitmap expect val WhiteStoneBmp : ImageBitmapandroid 側的圖片資源存放在 /res 目錄,通過 resource id 獲取:
androidMain/platform/Bitmap.kt
actual val BlackStoneBmp: ImageBitmap by lazy {ImageBitmap.imageResource(resources, blackStoneResId) } actual val WhiteStoneBmp: ImageBitmap by lazy {ImageBitmap.imageResource(resources, whiteStoneResId) }resources 和 resId 由 android 的 application 在 onCreate 時注入
desktopMain/platform/Bitmap.kt
desktop 側將圖片資源放在 /resources 目錄中,通過 compose.desktop 的 useResouce 獲取
actual val BlackStoneBmp: ImageBitmap by lazy {useResource("stone_black.png", ::loadImageBitmap) } actual val WhiteStoneBmp: ImageBitmap by lazy {useResource("stone_white.png", ::loadImageBitmap) }注意 actual 和 expect 的代碼文件路徑需要保持一致
之后,我們就可以在 commonMain 的代碼中通過 ImageBitmap 進行繪制了。 此外,像 dialog 的處理在各端也有所不同(andorid 和 desktop 各有各的 window 系統),也需要進行差異化處理。
Logic:自定義ViewModel
CMP 無法使用 Jetpack 的 ViewModel、LiveData 等組件,只能手動實現,或者使用 KMP 的一些三方庫,例如 Decompose 、MVIKotlin 等。 下游戲的邏輯比較簡單,我們自己實現一個 ViewModel,管理 UI 所需的狀態
以 boardData 的處理為例, boardData 記錄了整個棋牌棋子的狀態
class AppViewModel {private val _board = MutableStateFlow(Array(BOARD_SIZE) { IntArray(BOARD_SIZE) })val boardData: Flow<BoardData>get() = _board/*** place stone*/fun placeStone(offset: IntOffset) {val cur = _board.valueif (cur[offset.x][offset.y] != STONE_NONE) {return}_board.value = cur.copyOf().also {it[offset.x][offset.y] = if (isWhite) STONE_WHITE else STONE_BLACK}}/*** clear stones*/fun clearStones() {_board.value = _board.value.copyOf().also {for (row in 0 until LINE_COUNT) {for (col in 0 until LINE_COUNT) {it[col][row] = STONE_NONE}}}}}typealias BoardData = Array<IntArray>通過 Array<IntArray> 二維數組定義棋盤坐標信息。Int型 表示某坐標的三種狀態:黑子,白子,無子。 UI 接收到用戶輸入后,通過 placeStone 等方法更新 boardData 從而驅動 Compose 刷新。
如果想像 Jetpack ViewModel 那樣對 State 進行持久化,可以使用 rememberSaveable {} ,Savable 在 CMP 也是可以使用的。
數據通信層:Rsocket
可聯機對弈是這個游戲的特色。網絡通信的方案有多種選擇,比如藍牙、Wifi直連等,但是越依靠低層設備就越容易出現差異化代碼,所以這里選擇了應用層協議 WebSocket 進行通信。
RSocket 是一種響應式的通訊協議,其 KMP 的實現 rocket-kotlin 在 Ktor 的基礎上提供了 Rxjava, Flow 等響應式接口,與我們的單向數據流架構非常契合。
在游戲整體設計上,桌面端和移動端采取點對點通信。 RSocket 支持多種通信方式,其中 request/channel 可以提供全雙工通信,非常適合 IM、 網絡游戲之類的場景,可以用來完成我們點對點通信的需求。
我們在 commonMain 定義 API 層實現 P2P 的通信, P2P 的雙端沒有主次之分
object Api {suspend fun connect() = initWsConnect()//接收消息fun receiveMessage(): Flow<Message> = receiveFromRemote().map {when (it.metadata!!.readText()) {TypePlaceStone -> {val (x, y) = it.data.readText().split(",")Message.PlaceStone(IntOffset(x.toInt(), y.toInt()))}TypeChooseColor -> Message.ChooseColor(it.data.readText().toBoolean())TypeGameQuit -> Message.GameQuitTypeGameReset -> Message.GameResetTypeGameLog -> Message.GameLog(it.data.readText())else -> error("Unknown message !")}}//發送消息suspend fun sendMessage(message: Message) =sendToRemote(buildPayload {metadata(message.type)data("$message")}) }P2P的雙端互發消息,角色平等,因此 API 層代碼也實現了復用。 回到前面 ViewModel 中,在擺放棋子后,通過 API 順便給對端發送一個同步消息,完成通信。
fun placeStone(offset: IntOffset) {//...coroutineScope.launch {Api.sendMessage(Message.PlaceStone(offset)) //發送消息給對端} }差異化處理
雖然 API 基于點對點抽象了接口,但是 WebSocket 的實現仍然需要有 Server 和 Client 之分,即便他們是全雙工通信。 這又涉及到差異化處理,我們以 desktop 為 server , android 為 client 建立通信 (反之亦可)
commonMain/Socket.kt
expect suspend fun initWsConnect() // 建立 WebSocket 連接 expect fun receiveFromRemote(): Flow<Payload> //通過 Flow 獲取對方消息 expect suspend fun sendToRemote(payload: Payload)// 相對端發送消息destkopMain/Socket.kt :
private lateinit var _requestFlow: Flow<Payload> private lateinit var _responseFlow: MutableSharedFlow<Payload>actual suspend fun initWsConnect() {startServer().let {_requestFlow = it.first_responseFlow = it.second} }actual fun receiveFromRemote(): Flow<Payload> = _requestFlow.onStart {emit(buildPayload {metadata(Message.TypeGameLog)data("waiting pair ...")}) }actual suspend fun sendToRemote(payload: Payload) = _responseFlow.emit(payload)desktopMain 側在 initWsConnect 中啟動 WebSocket Server,等待來自客戶端的連接后,返回 request/response 的 Flow,用來收發消息。 startServer() 內部使用 RSocket 建立 Server,不是本文重點,介紹略過。
androiMain/platform/Socket.kt
//connect to some url private lateinit var rSocket: RSocket private lateinit var _requestFlow: MutableSharedFlow<Payload> private lateinit var _responseFlow: Flow<Payload>actual suspend fun initWsConnect() {rSocket = client.rSocket(host = serverHost, port = 9000, path = "/rsocket")if (!::_requestFlow.isInitialized) {_requestFlow = MutableSharedFlow()_responseFlow = rSocket.requestChannel(buildPayload { data("Init") }, _requestFlow)} else {throw RuntimeException("duplicated init")} }actual fun receiveFromRemote(): Flow<Payload> = _responseFlow actual suspend fun sendToRemote(payload: Payload) = _requestFlow.emit(payload)client 是 RSocket 創建的 WebSocket 客戶端,通過 requetChannel 與服務端建立全雙工通信。同樣返回 request/response 的兩個 Flow 用于收發對端的消息。
5. 總結與思考
通過上面例子,大家初步了解了 CMP 的工程結構以及如何在 CMP 中完成差異化開發,KMP 提供了很多諸如 rsocket-kotlin 這樣的三方庫來滿足我們的常見的開發需求。除了 desktop, CMP 也支持 Web 端開發,在 DSL 上稍有差別,后續有機會單獨介紹。
最后討論幾個大家關心的問題:
桌面端應用還有市場嗎?
ToC 的市場已近飽和、 ToB 成為新風口的今天,PC 的使用場景會觸底反彈,未來的產品會更加重視移動端和桌面端的打通,越來越多像 JetBrains 這樣的小而美的公司愿意聚焦到桌面端的新技術上。
雖然桌面端已經有了 Electron 這樣優秀的解決方案,但是 JS 的性能距離 JVM 仍有不小差距,像飛書這樣日漸成熟的產品,其開發原則也已經由早期的效率第一轉為體驗第一、為了性能開始向 native 切換。如果 Kotlin 能像 JS 一樣低成本開發跨端應用,那為什么不選擇呢?
與 Flutter 如何取舍?
Compose Multiplatform 是 JetBrains Compose 而非 Jetpack Compose,Flutter 仍然是目前 Google 唯一的跨平臺解決方案,更側重移動端生態;CMP 則是以擴大 Kotlin 的使用場景為出發點,他的“格局"更大,不追求 DSL 的完全一致,更強調開發范式的統一,結合平臺特性、打造包括桌面端在內的 UI 通用解決方案。
Data source: Google Trends (https://www.google.com/trends)
近年來 Kotlin 的熱度不斷增高,與 “因為要用 Flutter 所以學習 Dart" 不同," 因為掌握 Kotlin,所以用 CMP " 的的選型邏輯更加合理。Google 對 CMP 的態度也是樂見其成的,借助 Compose 能夠拓寬 Android 開發者的能力邊際,將有助于吸引更多的開發者加入 Android 陣營。
憑借先發優勢 Flutter 仍然是當前移動端跨平臺方案的首選,但是 CMP 更具想象空間,隨著功能的進一步完善(Skiko 也已支持了 iOS 側渲染)未來大有可期。 如果你是一個 Kotlin First 的程序員,那么感謝 CMP 讓你已經具備了開發跨平臺應用的能力。
- sample : https://github.com/vitaviva/cmp-gobang
- cmp:https://github.com/JetBrains/compose-jb
總結
以上是生活随笔為你收集整理的Compose Multiplatform 实战:联机五子棋的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 177本名著浓缩成了177句话!经典收藏
- 下一篇: Legacy Code Tool