github 地址:github.com/Jadyli/kotl...
1 起源
我是 14 年开始接触 Android 开发,那时候安卓的社区非常活跃,Jake Wharton、扔物线、邓凡平、鸿洋、郭霖、谷歌的小弟、郭树煜等大神辈出,我是看着他们的博客学习的。我也在 CSDN 注册了博客开始写作,当时用的是 MWeb markdown 编辑器,用惯了 markdown 真的没法再接受 word 之类的编辑器,现在推荐 语雀,用了很多编辑器,语雀确实是用起来最舒服的。发布文章后看到自己写的文章有人点赞有人收藏很有成就感,写文章是个很正向反馈的事,虽然可能没法带来收入,但是本身完成一篇文章就是件很快乐的事,而且会养成总结知识点的习惯,边学习边写博客我觉得是技术人最好的学习方式。
我从 17 年使用 kotlin 到现在,看着 kotlin 一步步支持协程、跨平台,越来越成熟,kotlin 用户也越来越多。但是从我最近几年在公司收到的简历和面试的情况来看,大多数人都停留于简单的使用,能用好作用域函数、委托、泛型等功能的人不多,了解协程机制的更是少之又少。目前国内对 Kotlin Multiplatform 的使用还在起步阶段,掘金上关于 Kotlin Multiplatform 的博客也基本停留于简单介绍、跑个官方 demo、官方文档和资讯的搬运,真正深入踩坑的很难找到,生态太弱了。所以我之前建立了一个分享小群,试图组织大家一起完善相关的使用文档、踩坑记录,这是我们已经沉淀下来的一些资料。
现在开源这个模板也是希望更多的人能加入 Kotlin Multiplatform 的开发中,具体内容会在下面介绍。
对于刚入门的同学,推荐一些资料和博客,遇到不懂的也可以直接问 GPT:
Kotlin: 看官方文档,中文版 + 英文版,英文版是最新的,建议先看中文版学个基础,然后再去看英文版的一些新东西,然后有兴趣再去看 kotlin 官方设计思路文档。公众号建议关注下 "JetBrains"、"霍丙乾 bennyhuo"。 Compose: 推荐王鹏大佬的 Jetpack Compose 从入门到实战,尤其是看官方文档看不懂了就要想到这本书哦,此书之外的 compose 书都不推荐 。博客可以看看 Coffeeee 大佬的文章,很多有意思的 UI。 Android: 看 中文官网,再多看看博客和开源库就可以了,github 很多 Android 项目可以跑起来看。博客推荐: 唐子玄:学思维,学架构,尤其推荐 Android 架构之 MVI 雏形 | 响应式编程 + 单向数据流 + 唯一可信数据源。 究极逮虾户、yechaoa:学习大厂思维,编译优化等技巧。 彭旭锐:计算机基础、数据结构、算法、Gradle、Jvm,太高产了😭,你想学的都有。 kotlin 上海用户组:乔禹昂大佬的博客,推荐看看 携程机票 App KMM 跨端生产实践。 程序员江同学:一些新技术都有比较完整的介绍,并附有 github demo。 扔物线:基础讲解最清晰易懂了。当年 给 Android 开发者的 RxJava 详解 是封神之作。 还有很多很厉害的博客就不一一列举了,可以自己探索。
2 项目结构
2.1 项目整体的结构
这里通用代码层只放了 framework,可以自己根据业务需要添加 common、third (第三方库源码、aar等)、plugins(内部开发的各种 gradle 插件) 层, 这里的子工程采用 composite build(复合构建)的形式组织,复合构建的好处有几个:
- 插件不再只是 buildSrc,可以独立出来,在 plugins 工程下可以写多个独立插件;
- 各个子工程相对于根目录的总工程是独立的,既可以降低工程耦合度,也可以直接把工程外的其他路径下的工程引入编译,比如有两个工程:StudioProjects/App1/compositeProjectA 和 StudioProjects/App2/compositeProjectB,App1 是能直接通过复合构建把 compositeProjectB 引入编译的;
- 子工程的任意模块可以轻松切换源码编译和采用该模块的 maven 版本。
注意如果一个模块依赖另一个模块,不要直接写 implementation(projects.moduleA)
,而要采用 implementation("com.xxgroup:moduleA")
的写法,在 includeBuild
模式下,只要该模块参与了编译,并且 group 和模块名跟声明依赖的 group 和 module 名对应,那么即使没发布到 maven 仓库,也会自动采用源码依赖。
2.2 模块结构
Kotlin Multiplatform 只是一个插件,可以让你的某个模块具有跨平台的能力,所以原有工程不需要大改也能用。建议先把一些基础库支持上跨平台的能力,然后针对业务库进行跨平台改造。
模块引入 Kotlin Multiplatform 插件后,我们开发的时候基本只需要用到 commonMain、desktopMain、iosMain、androidMain,我们需要手动创建这几个文件夹,建好后模块内的结构如下:
css
src
├─androidMain
│ ├─kotlin
│ └─res
├─commonMain
│ ├─kotlin
│ └─resources
├─desktopMain
│ └─kotlin
└─iosMain
└─kotlin
可以在 commonMain 里写通用逻辑,对于平台差异化代码,通过定义接口和使用 expect / actual
关键字来在各平台实现。
需要注意的是,在 Kotlin Multiplatform 模块里不允许包含 java 文件,否则如果其他模块依赖了这个模块,java 代码将无法引用。
3 Gradle
Android 和通用属性的配置采用了插件,具体可以参考
3.1 Gradle Kotlin build script
参考 Android gradle 插件升级和 kts 迁移踩坑指南。
3.2 version catlog
参考 Android 依赖管理及通用项目配置插件,Android 和 compose 插件的配置也是在这个插件里的,插件地址:config-plugin。
目前模板里添加的重点依赖我都带上了链接,方便大家查看最新版本的信息:
3.3 Compose Multiplatform 插件
Compose Multiplatform 插件是 JetBrains 基于 Jetpack Compose 开发的跨平台共享 UI 库。支持 Android、iOS、Desktop、Web(基于 Kotlin/Wasm)。
引入 Compose 插件后,依赖采用 compose.xxx
的形式添加,最好不要和 Jetpack Compose 混用(androidx.compose
),编译容易出问题。Kotlin 1.9.20 以前的版本,如果没有使用 2.2 小节的插件,出问题的话可能需要手动在 build.gradle.kts
里加上:
kotlin
compose {
kotlinCompilerPlugin.set(kotlinVersion)
kotlinCompilerPluginArgs.add("suppressKotlinVersionCompatibilityCheck=${kotlinVersion}")
}
3.4 Kotlin Multiplatform 插件
这个插件是跨平台主力插件,compose 插件、ksp 插件版本需要与这个插件配套,下面是 Kotlin 1.9.10 的配套设置:
kotlin
# https://kotlinlang.org/docs/gradle-configure-project.html#apply-the-plugin
kotlin = "1.9.10"
# https://search.maven.org/artifact/org.jetbrains.compose.compiler/compiler
compose-plugin-compiler = "1.5.2"
# https://central.sonatype.com/artifact/org.jetbrains.compose/org.jetbrains.compose.gradle.plugin/versions
compose-plugin = "1.5.3"
# ksp 版本列表 https://github.com/google/ksp/releases?page=1
ksp = "1.9.10-1.0.13"
下面是 Kotlin 1.9.20 的配套设置。
kotlin
# https://kotlinlang.org/docs/gradle-configure-project.html#apply-the-plugin
kotlin = "1.9.20"
# https://search.maven.org/artifact/org.jetbrains.compose.compiler/compiler
compose-plugin-compiler = "1.5.3"
# https://central.sonatype.com/artifact/org.jetbrains.compose/org.jetbrains.compose.gradle.plugin/versions
compose-plugin = "1.5.10"
# ksp 版本列表 https://github.com/google/ksp/releases?page=1
ksp = "1.9.20-1.0.14"
3.5 Cocoapods 插件
这个插件用于管理 ios 依赖,看文档和模板配置即可。目前在复合构建下,iosApp 编译还有问题,已经提了 issue 给 kotlin,需要等他们修复,根据我之前反馈的 After using Kotlin 1.9.20 on Windows 11, the gradle sync failed 问题来看,修复速度还是很快的,他们基本会在一天内给回复,一个版本内处理完。
3.6 Maven 发布插件
这里的 Maven Publish 插件包含两个:
- Gradle 官方插件:Maven Publish Plugin
- 支持发布 KMP 模块的第三方插件:Gradle Maven Publish Plugin
开源插件建议直接发布到 gradle 官方仓库,参考 发布文档 发布即可。实例可以参考我之前写的 配置插件,源码和教程都在里面,核心代码在 build.gradle.kts 里。
KMP 模块发布参考插件文档即可。注意 KMP 模块不能在 settings.gradle.kts
里配置模块依赖仓库,如果配置了需要删掉,原因比较复杂。
KMP 内部一些依赖的下载不是通过 gradle settings.gradle.kts
文件里 dependencyResolutionManagement
配置的仓库来下载的,而是通过插件内部配置的仓库(具体逻辑可以看 NativeCompilerDownloader)。不知道 dependencyResolutionManagement
的可以看:Android gradle 插件升级和 kts 迁移踩坑指南,我们先来看下正常的 dependencyResolutionManagement
配置:
kotlin
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
...
mavenCentral()
google()
}
}
repositoriesMode
用于设置冲突解决模式,当你在 settings.gradle.kts
文件里配置了所有模块的依赖下载仓库,又在各模块的 build.gradle.kts
文件里设置了依赖下载仓库,或者在插件里设置了依赖下载仓库(KMP 插件就是这种情况),这个时候 gradle 就需要知道模块到底要用哪个设置来下载仓库。你可能会问:不能同时用吗?很遗憾,不支持。看看源码:
kotlin
public enum RepositoriesMode {
/**
* 如果设置了此模式,则项目中声明的任何库都会导致项目使用项目声明的库,而忽略设置中声明的库。
* 这是默认行为。
*/
PREFER_PROJECT,
/**
* 如果设置了此模式,则项目中直接声明的任何库(直接或通过插件)都会被忽略。
*/
PREFER_SETTINGS,
/**
* 如果设置了此模式,则项目中直接声明的任何库(直接或通过插件)都会引发构建错误。
*/
FAIL_ON_PROJECT_REPOS;
}
这三种模式都是只能二选一,那么对于 KMP 插件这种情况,为了保持代码清真(不在 settings.gradle.kts 和 build.gradle.kts 里重复声明仓库),只有一种选择了,放弃在 settings.gradle.kts
声明模块依赖仓库,在根工程 build.gradle.kts
里配置仓库:
kotlin
allprojects {
repositories {
...
mavenCentral()
google()
}
}
4 模块开发
4.1 代码架构
以 ktor 为例,来看一个不考虑跨平台的初始化代码:
kotlin
val httpClient = HttpClient(OkHttp) {
// this: HttpClientConfig<OkHttpConfig>
engine {
// 平台差异化部分,比如 jvm 用 OkHttpConfig,ios 用 DarwinClientEngineConfig
// this: OkHttpConfig
config {
// this: OkHttpClient.Builder
// 假设这是业务统一的设置,即框架内的平台实现部分
connectTimeout(30_000L, TimeUnit.MILLISECONDS)
// 框架和业务差异化部分,比如请求头拦截有业务的东西,就应该放业务模块实现
addInterceptor(HeaderInterceptor())
}
}
// 平台通用部分
defaultRequest {
header(HttpHeaders.ContentType, ContentType.Application.Json)
}
install(Resources)
install(Logging)
}
如果需要改成跨平台的写法,OkHttp 只在 JVM 平台能用,iOS 要换成 Darwin,所以 HttpClient 的构造方法参数要分平台实现,平台通用配置可以直接在配置块内实现,平台的通用配置和业务配置需要通过 expect/actual 或者依赖注入来实现。
一开始可能会这样设计:
kotlin
// commonMain
val httpClient = HttpClient(getEngine()) {
configClient()
// 省略通用配置代码
}
expect fun getEngine(): HttpClientEngine
expect fun <T : HttpClientEngineConfig> HttpClientConfig<T>.configClient()
// androidMain
actual fun <T : HttpClientEngineConfig> HttpClientConfig<T>.configClient() {
engine {
// this: T,编辑器报错,没有 config 方法
config {
}
}
}
当你想在 androidMain
下编写通用 engine 配置时,发现写不了。其实我们预期的是,在 commonMain
里能拿到 config 基类 HttpClientConfig
就行,在各平台下拿到具体的 config 类而不是泛型,比如 OkHttpConfig
,同时业务模块还能嵌入业务逻辑。
最终我们的代码长这样:
kotlin
// framework/http/commonMain
fun createHttpClient(): HttpClient = createClient { httpConfig ->
// httpConfig: HttpClientConfig<out HttpClientEngineConfig>
// 所有平台通用逻辑
defaultRequest {
header(HttpHeaders.ContentType, ContentType.Application.Json)
}
install(Resources)
...
}
// 需要各平台实现
expect fun createClient(commonConfig: HttpClientConfig<out HttpClientEngineConfig>.(BaseHttpConfig<*>) -> Unit): HttpClient
// 定义一个配置类,用于业务代码注入
abstract class BaseHttpConfig<T : HttpClientEngineConfig> {
open var connectTimeOut: Long = 30_000L
open var readTimeOut: Long = 30_000L
abstract fun HttpClientConfig<T>.config()
}
// framework/http/jvmCommonMain
fun createClient(commonConfig: HttpClientConfig<out HttpClientEngineConfig>.(BaseHttpConfig<*>) -> Unit): HttpClient {
// 通过依赖注入框架拿到配置类 BaseOkHttpHttpConfig 的业务实现类
val httpClientConfig = KoinPlatform.getKoin().get<BaseOkHttpHttpConfig>()
return HttpClient(OkHttp) {
// 通过回调配置所有平台通用配置
commonConfig(httpClientConfig)
// 注入业务实现
// HttpClientConfig<T>.config() 需要两个上下文,HttpClientConfig 和 BaseHttpConfig,所以用了 with。
with(httpClientConfig) {
config()
}
// jvm 平台通用逻辑代码
...
}
}
abstract class BaseOkHttpHttpConfig : BaseHttpConfig<OkHttpConfig>()
// feature/startup/androidMain
@Factory(binds = [BaseOkHttpHttpConfig::class])
class HttpConfig : BaseAndroidHttpConfig() {
override fun HttpClientConfig<OkHttpConfig>.config() {
engine {
// this: OkHttpConfig
config {
// 配置业务逻辑
addInterceptor(HeaderInterceptor())
...
}
}
}
}
这样既保证了没有重复代码,又保证了各模块最大的灵活性。
4.2 MVI
MVI (Model-View-Intent) 是用于构建用户界面的一种架构模式。这种模式常被用于响应式编程和状态管理。
MVI 架构由以下三大部分组成:
- Model:在 MVI 中,Model 不只是简单的数据对象;它是包含了应用程序状态的一种表现形式。任何对于应用程序的改变都会反映在 Model 中。
- View:View 是用户看到并与之交互的界面,它负责将 Model 显示给用户,并根据用户交互产生 Intent。
- Intent:Intent 是表示用户行为或者更准确地说,是用户与 View 交互的事件。例如,点击按钮、输入文本等都可以被视为 Intent。这些 Intent 被处理后更新 Model,并反馈到 View 上,形成一个循环。
MVI 的主要优点是它创建了一个单向、可预测的数据流,使得应用程序的状态管理变得更容易。此外,因为所有的改变都会反映在 Model 上,所以测试也变得更简单。
这是我们模板项目里的 MVI 流向图:
5 基础库
5.1 koin
Koin 是一个专门为 Kotlin 设计的轻量级依赖注入框架,可以简化定义和查找依赖的工作。koin 支持 ksp 注解,推荐使用注解。
kotlin
// @Singleton 表示这个 Engine 只会被创建一次
@Singleton(binds = [Engine::class])
class EngineA
@Factory
class Car(private val engine: Engine)
// 定义一个模块
@Module
@ComponentScan
class AppModule
// 启动 Koin
startKoin {
// 加载模块
modules(AppModule().module)
}
// 然后在需要的地方获取依赖
class SomeClass {
// 通过 inject() 函数获取 Car 实例
val car: Car by inject()
}
在 KMP 模块使用注解需要注意添加 ksp 依赖并引入 ksp 生成的源码,目前还不支持自动查找。
kotlin
kotlin {
sourceSets {
val desktopMain by getting {
kotlin.srcDir("build/generated/ksp/desktop/desktopMain")
}
// kotlin 1.9.20
androidMain {
kotlin.srcDir("build/generated/ksp/android/androidDebug")
kotlin.srcDir("build/generated/ksp/android/androidRelease")
}
// kotlin 1.9.20 以前
val androidMain by getting {
kotlin.srcDir("build/generated/ksp/android/androidDebug")
kotlin.srcDir("build/generated/ksp/android/androidRelease")
}
}
}
dependencies {
with(sharedCommonLibs.koin.ksp.compiler.get().toString()) {
// commonMain 不要和平台层同时用,目前阶段推荐使用各平台 ksp
// add("kspCommonMainMetadata", this)
add("kspDesktop", this)
add("kspAndroid", this)
add("kspIosX64", this)
add("kspIosArm64", this)
add("kspIosSimulatorArm64", this)
}
}
ksp {
// koin 配置
arg("KOIN_CONFIG_CHECK", "true")
}
5.2 ktor
基础部分看文档即可。这里重点介绍下 Resource
插件。
Resource
插件用于实现类型安全的请求,有点类似 retrofit,但是跟 retrofit 不太一样,Resources
主要是用类来描述一个请求的 路径,在这个类中并不指定请求方法,而是在发起的时候指定请求方法。 定义请求的时候需要用到 @Serializable
注解,所以需要参考前文引入 kotlinx.serialization。
为了方便理解,我写了个比较全的 demo,基本涵盖了各种用法:
kotlin
baseulr/.../school/teacher/xx
baseulr/.../school/class/xx
baseulr/.../school/class/studuent
@Resource("school")
class School {
@Resource("teacher/{id}")
class Teacher(val parent: School = School(), val id: Int)
@Resource("class/{id}")
class Class(val parent: School = School(), val id: Int) {
@Resource("student")
class Student(val parent: Class) {
@Resource("create")
class Create(val parent: Student) {
@Serializable
data class StudentRequest(val name: String, val gender: Int)
}
@Resource("{id}}")
class Id(val parent: Student, val id: Int)
}
}
}
@Serializable
data class StudentResponse(val id: Int, val name: String, val gender: Int)
suspend fun getStudent(classId: Int, studentId: Int) {
val (id, name, gender) =
apiService.get(School.Class.Student.Id(School.Class.Student(School.Class(id = classId)), id = studentId))
.body<StudentResponse>()
println("student name: $name, gender: $gender")
}
suspend fun createStudent(classId: Int, studentRequest: StudentRequest) {
httpClient.post(
School.Class.Student.Create(School.Class.Student(School.Class(id = classId)))
) {
setBody(studentRequest)
}
}
- Resource 参数里只能是 path,不能含有 query 参数,不能出现
?
,{}
是占位符,运行时会替换为对应的成员变量的值,get 请求的 query 参数需要添加到成员变量中,会自动解析成 query 参数。
kotlin
// demo1, 不能这样用,输出 url 为:https://www.random.org/integers/%3Fnum=1&col=1&base=10&format=plain?min=-20&max=20
@Resource("integers/?num=1&col=1&base=10&format=plain")
class RandomInteger(val parent: RandomNumber = RandomNumber(), val min: Int, val max: Int)
从 demo1 可以看出来,?
会被编码,class RandomInteger
的成员变量如果没在 Resource 参数里占位,就会解析成 query 参数添加到请求 url 最后,并且会自动拼接 ?
。
parent
成员变量表示 url 当前节点的上一级节点,比如 path 是 school/class/12/student/create,create 节点的父节点就是是 student,在定义类的时候,你也可以把 Create
放其他类下,只要 parent
变量的类型是 Student
即可,只是放 Student
下更直观一点。
Resource
插件内置了 get
、post
、put
等扩展方法,参数传入定义好的请求类即可。
createStudent
中的 setBody
方法使用了 ContentNegotiation
插件的功能。
通过这种形式,可以集中管理同一个 path 下的所有请求 url,对于 post 请求,请求参数也可以在对应 url 的 class 下定义 data class,参考内容协商小节。这样统一管理同一个 path 下的所有请求没有问题,但有个问题是,如果我有多个 base url 怎么办,同一个 path 下的 base url 虽然是同一个,但是不同 path 下可能是不同的,Resource 注解只支持 path,不支持配置 base url,总不能我每个请求下都声明下 url 吧。临时方案是每个 base url 分别定义一个 ApiService:
kotlin
abstract class IApiService(baseUrl: String) {
protected val urlBuilder = Url(baseUrl)
inline fun <reified T : Any> get(
resource: T,
builder: HttpRequestBuilder.() -> Unit = {}
): HttpResponse {
return httpClient.getBuilder {
build<T>(resource, builder)
}
}
@PublishedApi
internal inline fun <reified T : Any> HttpRequestBuilder.build(
resource: T,
builder: HttpRequestBuilder.() -> Unit
) {
url {
protocol = urlBuilder.protocol
host = urlBuilder.host
port = urlBuilder.port
encodedPath = urlBuilder.encodedPath
}
href(resources().resourcesFormat, resource, url)
builder()
}
@PublishedApi
internal fun resources(): io.ktor.resources.Resources {
return httpClient.pluginOrNull(Resources) ?: throw IllegalStateException("Resources plugin is not installed")
}
}
class Service : IApiService("https://www.random.org/")
class BaiduService : IApiService("https://api.baidu.com/") {
}
这个问题我已经提了个 issue 给 ktor:youtrack.jetbrains.com/issue/KTOR-...,希望后续 Resource 插件能支持下,如果他们后续不支持,那我们就自己改下 Resource 插件好了。
5.3 molecule
molecule 是一个使用 Compose 来生产 StateFlow 的框架。至于为什么要用 molecule,官网已经说的很清楚了,由于内容太长,我直接另外发了篇文章翻译了一下官网:Molecule 中文翻译。
再贴下 github 地址:github.com/Jadyli/kotl...
是时候,来个"点赞、收藏、关注、Star、Follow"五连绝。。。诶,老哥别走,点个赞也行啊。