1. 认识 Compose Multiplatform


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

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

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

Date Milestone
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 发生变化:

jetpack jetbrains
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 获取绘制网格线所需的 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 的一些三方库,例如 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/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 用于收发对端的消息。

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 实战:联机五子棋相关推荐

  1. Compose Multiplatform 正式官宣,与 Flutter 必有一战?

    作者 | fundroid 来源 | AndroidPub 7月底 Compose for Android 1.0 刚刚发布,紧接着 8月4日 JetBrains 就宣布了 Compose Multi ...

  2. Python百日百行代码挑战-day8,day9,day10,游戏实战系列-五子棋

    Python百日百行代码挑战-day8,day9,day10,游戏实战系列-五子棋 写在前面 需要用到的工具包和参考 游戏设定 初始化 切换下棋方 判断五子连珠(核心) 鼠标点击流程事件 成品展示 打 ...

  3. 基于socket的联机五子棋

    基于socket的联机五子棋 一.团队介绍 团队名称: 团队成员 职务 负责部分 个人链接 林仕峰 组长 网络编程和多线程 (114条消息) 五子棋个人报告_林仕峰的博客-CSDN博客 吴双 组员 五 ...

  4. Compose Multiplatform 正式官宣,与 Flutter 迟早必有一战?

    7月底 Compose for Android 1.0 刚刚发布,紧接着 8月4日 JetBrains 就宣布了 Compose Multiplatform 的最新进展,目前已进入 alpha 阶段. ...

  5. 纯java编写的联机五子棋项目(附带开源链接)

    文章目录 说明 功能展示 客户端说明 服务端说明 总结 说明 这是用java写的一个联机五子棋项目,该项目是我大二上期的时候写的,那时候学完了java基础,想要把学的技术都综合使用一下,于是就在国庆节 ...

  6. Compose也能跨平台?Compose Multiplatform是啥?KMM又是什么?

    现在的跨平台框架真是跟打了鸡血似的,跟生产队的驴一样玩命的更新啊,一会儿功夫就遍地开花,开发者尤其是Androiders们还能学得过来吗? Compose Multiplatform Compose ...

  7. [教你做小游戏] 用86行代码写一个联机五子棋WebSocket后端

    我是HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者HullQin授权.我独立开发了<联机桌游合集>,是个网页,可以很方便的跟朋友联机 ...

  8. 10个问题带你看懂 Compose Multiplatform 1.0

    近日 JetBrains 正式发布了 Compose Multiplatform 1.0 版,这标志其在生产环境中使用的时机已经成熟.相信有不少人对它还不太熟悉,本文通过下面 10 个热门问题带大家认 ...

  9. inventor2五子棋游戏apk_联机五子棋手机版下载|联机五子棋游戏下载v1.3.2 安卓版_ 单机手游网...

    单机100手游网下载吧! 联机五子棋手游简介 是可以和好友实时对战的棋类游戏,界面简单清新,操作方便快捷,系统为你匹配棋力相当的对手. 联机五子棋手机版特色 1.五子棋大师增加滑动落子方式,小屏手机操 ...

最新文章

  1. 中文地址转英文地址网站_SSL证书可能让你网站源IP地址暴露
  2. 计算机二进制加减符号,(带符号的二进制数的表示方法及加减法运算).ppt
  3. 虚拟机网卡引起的一个问题
  4. Tomcat 内存与优化篇
  5. 网络工程师成长日记421-某银行技术支持
  6. Jmeter使用笔记之意料之外的
  7. android服务自动重启,安卓service关闭后怎么自动重启
  8. python简单笔记
  9. 高性能开发,别点,发际线要紧!
  10. JFinal自动扫描表绑定model(包含jar包扫描)
  11. python asyncio tcp server_关于 asyncio 创建多个 tcp 连接,线程数不准确的问题
  12. 424.替换后的最长重复字符
  13. 对抗训练+FGSM, FGM理解与详解
  14. 瑞星云安全截获新感染病毒 6月感染网民263万
  15. 《CLR via C#》读书笔记-.NET多线程(一)
  16. 彻底退出,刘强东转让所持京东股份;华为前三季研发费用超 1100 亿;腾讯会议部分功能开始收费 | EA周报...
  17. 解决chrome浏览器应用商店排版混乱问题
  18. Android判断当前系统时间是否在指定时间的范围内(免消息打扰)
  19. 电脑被格式化后数据还能恢复吗【图文】
  20. 【英语-同义词汇词组】advantage | ascendancy | predominance | preponderance | prepotency | superh的用法及区别

热门文章

  1. android pak文件_游戏中的Pak文件解析
  2. Python应用之植物大战僵尸2-功夫世界BOSS关卡无限刷金币
  3. Docker 核心技术(2)- helloworld 镜像
  4. 软考——程序设计语言概述
  5. 浅谈Android Architecture Components
  6. 谷歌SEO优化八步走
  7. MVC 音乐商店 第 2 部分: 控制器
  8. appium自动化获取app的appPackage与appActivity方法总结
  9. 只能输入零和非零开头的数字的正则表达式
  10. 【19调剂】中国科学院上海天文台接收报考硕士研究生调剂生的通知