开源一个企业可用的 Kotlin Multiplatform 项目模板

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 官方仓库,参考 发布文档 发布即可。实例可以参考我之前写的 配置插件,源码和教程都在里面,核心代码在 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 架构由以下三大部分组成:

  1. Model:在 MVI 中,Model 不只是简单的数据对象;它是包含了应用程序状态的一种表现形式。任何对于应用程序的改变都会反映在 Model 中。
  2. View:View 是用户看到并与之交互的界面,它负责将 Model 显示给用户,并根据用户交互产生 Intent。
  3. 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 插件内置了 getpostput 等扩展方法,参数传入定义好的请求类即可。

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"五连绝。。。诶,老哥别走,点个赞也行啊。

相关推荐
guoruijun_2012_42 小时前
fastadmin多个表crud连表操作步骤
android·java·开发语言
Winston Wood2 小时前
一文了解Android中的AudioFlinger
android·音频
B.-4 小时前
Flutter 应用在真机上调试的流程
android·flutter·ios·xcode·android-studio
有趣的杰克4 小时前
Flutter【04】高性能表单架构设计
android·flutter·dart
大耳猫9 小时前
主动测量View的宽高
android·ui
帅次12 小时前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat
枯骨成佛13 小时前
Android中Crash Debug技巧
android
kim565918 小时前
android studio 更改gradle版本方法(备忘)
android·ide·gradle·android studio
咸芝麻鱼18 小时前
Android Studio | 最新版本配置要求高,JDK运行环境不适配,导致无法启动App
android·ide·android studio
无所谓จุ๊บ18 小时前
Android Studio使用c++编写
android·c++