Compose Mutiplatform 实战联机小游戏

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

1. 认识 Compose Multiplatform

Jetpack Compose 作为 Android 端的新一代UI开发工具,得益于 Kotlin 优秀的语法特性,代码写起来十分简洁,广受开发者好评。作为 Kotlin 的开发方,JetBrains 在 Compose 的研发过程中也给与了大量帮助,可以说 Compose 是 Google 和 JetBrains 合作的产物。

在参与合作的过程中,JetBrains 也看到了 Compose 在跨平台方面的潜力,Compose 良好的分层设计使得其除了渲染层以外的的大部分代码都是平台无关的,依托 Kotlin Multiplatform (KMP), Compose 可以低成本地化身为一个跨平台框架。

JetBrains 一年多前开始基于 Jetpack 源码开发更多的 Compose 应用场景:

DateMilestone
2020/11发布 Compose for Desktop,Compose 可以支持 MacOS、Windows、Linux 等桌面端 UI 的开发,并在后续的几个 Milestone 中持续扩展新能力
2021/05发布 Compose for Web,Compose 基于 Kotlin/JS 实现前端 UI 的开发
2021/08JetBranins 将 Android/Desktop/Web 等各端的 Compose 版本统一,发布 Compose Multiplatform (CMP),使用同一套 ArtifactId 就可以开发跨端的 UI 。

虽然 CMP 尚处于 alpha 阶段,由于它 fork 了 Jetpack 稳定版的分支进行开发,API 已经稳定,乐观预计年内就会发布 1.0 版 。

jetbrains compose:github.com/JetBrains/a…

接下来通过一个例子感受一下如何使用 CMP 开发一个跨端应用:

Sample:跨端联机五子棋

screenshot.gif

地址:github.com/vitaviva/cm…

设计目标:

  • 通过 CMP 实现 APP 同时运行在移动端和桌面端,代码尽量复用
  • 通过 Kotlin Multiplatform 实现逻辑层和数据层的代码
  • 基于单向数据流实现 UI层/逻辑层/数据层的关注点分离

2. 新建工程

IDE:IntelliJ IDEA or Android Studio

CMP 可以像普通 KMP 项目一样使用 IntelliJ IDEA 开发( >= 2020.3),当然 Anroid Studio 作为 IDEA 的另一个发行版也可以使用的

Anroid Studio 和 IDEA 的对应关系: juejin.cn/post/701837…

AS 编辑器对 Compose 的支持更友好,比如在非 @Composable 函数中调用 @Composable 函数时 IDE 自动标红提示错误, IDEA 则只能在编译时才能发现错误。所以个人更推荐使用 AS 开发。

IDE Plugin 实现预览

AS 自带对 @Preview 注解进行预览, IDEA 也可以通过安装插件实现预览

插件安装后,IDE中遇到 @Preview 注解时左侧会出现 Compose logo 的小图标,点击后右侧窗口可以像 AS 一样进行预览。

需要注意的是,此插件只能针对 desktop 工程进行预览 ,对 android 工程无效。反之使用 AS 自带的预览也无法预览 desktop 。所以在 AS 中开发,想要预览 desktop 效果仍然要安装此插件。

接下来让我们看一下 CMP 工程的文件结构是怎样的

4. 工程结构

如上,整个工程第一级目录由三个 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 发生变化:

jetpackjetbrains
androidx.compose.runtime:runtimeorg.jetbrains.compose.runtime:runtime
androidx.compose.ui:uiorg.jetbrains.compose.ui:ui
androidx.compose.material:materialorg.jetbrains.compose.material:material
androidx.compose.fundation:fundationorg.jetbrains.compose.fundation:fundation

/android

/android 目录就是一个标准 Android 工程,这里就不赘述了

/desktop

最后看一下 /desktop/.build.gradle.kts

plugins {
    kotlin("multiplatform") //KMP插件
    id("org.jetbrains.compose") // CMP插件
}


kotlin {
    jvm { //Kotlin/jVM
        compilations.all {
            kotlinOptions.jvmTarget = "11"
        }
    }
    sourceSets {
        val jvmMain by getting {
            dependencies {
                implementation(project(":common")) //依赖common下的desktopMain
                implementation(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 项目提供了跨平台渲染能力。

5. 工程代码

接下来具体看一下工程的业务代码,从上到下逐层介绍

UI:Compose Graphic

五子棋小游戏的 UI 部分比较简单,大部分依靠 Compose 的 Canvas API 完成

Box(modifier) {
    with(LocalDensity.current) {
        val (linePaint, framePaint) = remember {
            Paint().apply {
                color = Color.Black
                isAntiAlias = true
                strokeWidth = BOARD_LINE_WIDTH_DP.dp.toPx()
            } to Paint().apply {
                color = Color.Black
                isAntiAlias = true
                strokeWidth = 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 中实现:参考 github.com/JetBrains/c…

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 获取绘制网格线所需的 CanvasPaint 对象,这些都是平台无关的抽象接口,所以基于 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 : ImageBitmap

android 侧的图片资源存放在 /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 的 ViewModelLiveData 等组件,只能手动实现,或者使用 KMP 的一些三方库,例如 DecomposeMVIKotlin 等。 下游戏的逻辑比较简单,我们自己实现一个 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.value
        if (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-kotlinKtor 的基础上提供了 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.GameQuit
            TypeGameReset -> Message.GameReset
            TypeGameLog -> 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/responseFlow,用来收发消息。 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 用于收发对端的消息。

6. 总结与思考

通过上面例子,大家初步了解了 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 (www.google.com/trends)

近年来 Kotlin 的热度不断增高,与 “因为要用 Flutter 所以学习 Dart" 不同," 因为掌握 Kotlin,所以用 CMP " 的的选型逻辑更加合理。Google 对 CMP 的态度也是乐见其成的,借助 Compose 能够拓宽 Android 开发者的能力边际,将有助于吸引更多的开发者加入 Android 阵营。

凭借先发优势 Flutter 仍然是当前移动端跨平台方案的首选,但是 CMP 更具想象空间,随着功能的进一步完善(Skiko 也已支持了 iOS 侧渲染)未来大有可期。 如果你是一个 Kotlin First 的程序员,那么感谢 CMP 让你已经具备了开发跨平台应用的能力。

欢迎在评论区讨论,掘金官方将在掘力星计划活动结束后,在评论区抽送100份掘金周边,抽奖详情见活动文章