开源一个企业可用的 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"五连绝。。。诶,老哥别走,点个赞也行啊。

相关推荐
编程、小哥哥22 分钟前
python操作mysql
android·python
Couvrir洪荒猛兽1 小时前
Android实训十 数据存储和访问
android
闲暇部落3 小时前
kotlin内联函数——let,run,apply,also,with的区别
kotlin·内联函数
五味香3 小时前
Java学习,List 元素替换
android·java·开发语言·python·学习·golang·kotlin
十二测试录4 小时前
【自动化测试】—— Appium使用保姆教程
android·经验分享·测试工具·程序人生·adb·appium·自动化
Couvrir洪荒猛兽5 小时前
Android实训九 数据存储和访问
android
aloneboyooo6 小时前
Android Studio安装配置
android·ide·android studio
Jacob程序员6 小时前
leaflet绘制室内平面图
android·开发语言·javascript
2401_897907867 小时前
10天学会flutter DAY2 玩转dart 类
android·flutter
m0_748233647 小时前
【PHP】部署和发布PHP网站到IIS服务器
android·服务器·php