Compose Multiplatform+kotlin Multiplatfrom
前言
现在Android原生需求日渐减少,多平台或车载,端侧大模型等我看是未来的主流,上一年做了新能源DBC协议的数据可视化显示,将数据实时保存到本地的csv或asc文件中,处理的难点高频数据的缓存和释放。端侧目前各系统不成熟,后面纯血鸿蒙处理应该底层就搭建大模型,应用层可直接调用模型的接口,下放更多能力,可是除了图片,音频我也不知道大模型能怎么改变我们普通的开发者,我们的应用没那么多AI,高手可留言讨论😁。今年把原生的转Compose Multiplatform+kotlin Multiplatfrom,开始搭建时kotlin版本1.9.22,compose1.6,过程解决各类问题,进入无数的无人区调试。
功能需求分析
源码涉及公司后续抽离公布
1.尽量一套代码开发Android、iOS的功能,ui或逻辑都尽量在commonMain实现,问题是ui预览时相当麻烦,都要androidApp内的MainActivity设置@Preview后查看,那么多页面不知道大家平时怎么用预览的,太麻烦了。
2.通过网络链接下载文件到设备本地,目前下载是ktor库可回调进度,下载目录是参考coil它内部用FileSystem来处理文件,在commonMain内部不包含java的File类、FileOutputStrem,这里确实坑很不熟悉。后续我尝试放到相册和外部存储。
3.webview用kevinnzou的实现,这里有个大坑是和导航库voyager-navigator一起用会导致每次点击输入框都重新刷新网页,后面没办法用precompose导航了,但这玩意不足是传递参数时放在path后面会有截取,字符长度我看就300多,如果直接把实体对象转字符放到path后面就截取了,最后我在viewmodel里建个单例对象管理😂,那么页面的路由路径就可不带参数,要取参数直接从viewModel里拿。
4.json解析坑Json { isLenient = true ignoreUnknownKeys = true}构造器要设置参数,不然基本接口回调数据基本报错,还有涉及手动解析属性,封装jsonArray,都跟java不同,addJsonObject{putJsonArray(){}}。我那个要处理成😉{"pageNum":1,"pageSize":20,"query":"空调","mainRecommend":false,"first":false,"productTagParam":{"productName":"家用空调","tags":{"hp":["小1匹","3匹"],"color":["白色"],"series":["冷静享","星宇"]},"othersTags":[]}}
5.权限问题,dev.icerock.moko:mvvm-compose,Git只找到这个,没有人做权限库,在安卓那ComponentActivity下用它库的viewmodel绑定写法不会蹦,用FragmentActivity不会报bindeffect,但是我们要compose肯定是ComponentActivity,然后要写SampleViewModel,但是案例只每次申请一个权限,后续有需要自己改。iOS的注意info.list文件加入节点,因为iOS不算太了解,xcode内部的调试感觉太简陋了。
6.视频播放没看到有功能库,所以做桥接接口,Android用media3-exoplayer,iOS用AvPlayer各自原生实现,注意原生构建view只有一次,那么更新用AndroidView{ update={}},还有个释放资源问题,BackHandler{}内只能处理Android的,iOS没有这个响应,所以用 DisposableEffect(Unit) { onDispose { player.pause() }}
,不然退出页面还在播放😅
7.声明式ui,目前还不太熟悉,在用paging3做自动加载列表时,每次从详情页返回都重新刷新列表,后面是在Pager{}.flow.cacheIn(scope)就可以,但是页面的刷新次数貌似无法控制,要触发一些功能要写很多state去修改它的值进而remember状态发生变化自动更新ui,这里跟命令式代码差别很大,还有LaunchedEffect{}也常用。
8.用coil3加载超长图会oom,注意不是coil2,它官网根本都没发compose multiplatform对应的版本库,我是自己拼地址猜出来了,但是在开发中也可以依赖,目前应该还有很多不足。Android的com.github.piasy:BigImageViewer性能非常好,推荐单独集成Android内。
9.Android端集成第三方腾讯文档浏览服务,这个是要付费,跟包名绑定,这里我是要把文件链接下载到本地然后调用sdk功能打开pdf/doc等格式文件,涉及TbsFileSdk_dwg_universal_release这个aar依赖,我在手机连线打debug包时没问题,但是在generate signed apk时生成真正的release模式下apk时会报错,就是aar依赖导致的。我的改好了,自提代码大伙😉。
10.编译问题,Compose Multiplatform+Kotlin Multiplatfrom这套开发架构是我是基于Android来开发的,会jetpack compose就容易入手,对iOS很多不熟悉,网上很多都是单纯跑下一两个页面,实际项目一大堆业务合并拆分,编译会遇到各种问题,在AndroidStudio写完Android也能跑了,那么跑iOS模拟器网上很多有改写run configurations跑iosApp,那么真机呢!网上都没人搞,我尝试了当成iOS项目原生跑,用xcode 打开iOSApp文件夹内的iosApp.xcodeproj,编译过程没有配置开发者签名的话就会报错,根据编译过程的日志提示解决报错,很多情况是改info.list,不同版本xcode有很多配置问题,我这iOS17.4模拟器,真机17.4.1 ipad。
好用的库依赖
这里贴了项目中用到的库,筛选了很多类似的最后选用的库,版本基本最新。
如下shared的build.gradle
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.jetbrainsCompose) //加了后重新编译,可以快捷引入compose的resource
alias(libs.plugins.kotlinxSerialization)
alias(libs.plugins.kotlinCocoapods) // 如果 IOS 使用 cocoapods,引入此插件
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "11"
}
}
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "shared"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
//所有公共逻辑、ui都写在common模块,那么就必须要引用这里以kotlin写的库
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
//多平台公共依赖库 https://www.5axxw.com/wiki/content/znqhcn
implementation(libs.kotlin.datetime)
//ios库无法获取以下
implementation(compose.components.uiToolingPreview)
//支持多平台的网络库
implementation(libs.ktor.client.core) //网络请求
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.logging)
//依赖注入
implementation(libs.koin.core)
//页面导航 https://github.com/adrielcafe/voyager/tree/main
implementation(libs.voyager.navigator)
implementation(libs.voyager.koin)
implementation(libs.voyager.tab.navigator)
implementation(libs.voyager.bottom.navigator)
implementation(libs.voyager.transitions)
//另一个导航
implementation(libs.precompose.navigator)
implementation(libs.precompose.koin)
implementation(libs.precompose.viewmodel)
//异步图片加载 //这个版本还缺其他?
implementation(libs.coil3.core)
implementation(libs.coil3.ktor)
implementation(libs.coil3.compose) //这个地址太坑了,官网没更新出来
//异步协程库
implementation(libs.kotlinx.coroutines.core)
//https://github.com/KevinnZou/compose-webview-multiplatform/tree/main https://blog.csdn.net/weixin_51235693/article/details/133277648
api(libs.compose.webviews)
//权限 compose multiplatform https://github.com/icerockdev/moko-permissions
implementation(libs.mokopermission)
implementation(libs.mokopermission.compose)
implementation(libs.stately.common)
implementation(libs.mokoMvvmCore)
implementation(libs.mokoMvvmCompose)
//多平台uuid https://github.com/benasher44/uuid/tree/master 不知道怎么用
implementation(libs.uuid)
//日志库
implementation(libs.logger.kermit)
//key-value存储
implementation(libs.multiplatform.settings)
//https://github.com/KoalaPlot/koalaplot-core 图表库
//页面自适配判断库
implementation(libs.windowSize)
//https://github.com/skydoves/FlexibleBottomSheet 从底部弹窗
implementation(libs.bottomSheet)
//分页库
implementation(libs.paging.compose)
//网络流的数据可存FileSystem
implementation(libs.okio.core)
//文件选择器
implementation(libs.file.picker)
//第三方libs composeView https://github.com/ltttttttttttt/ComposeViews/blob/main/README_CN.md
implementation(libs.view.libs1)
}
androidMain.dependencies {
//引入本地Android aar库
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation(files("../androidApp/libs/TbsFileSdk_dwg_universal_release_1.0.5.6000030.20231109143411.aar"))
//android平台引擎
implementation(libs.ktor.client.android)
//预览
implementation(libs.compose.ui.tooling.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.perference)
//mmvm
implementation(libs.androidx.lifecycle)
implementation(libs.lifecycle.extension)
implementation(libs.composeMaterial)
implementation(libs.appCompat)
//图片库加载,超长图moo
implementation(libs.coil3.video)
implementation(libs.coil3.gif)
//远程日志上报
implementation(libs.android.bugly)
//视频播放器
implementation(libs.medie3.core)
implementation(libs.medie3.dash)
implementation(libs.medie3.ui)
}
iosMain.dependencies {
//网络库,提供iOS平台引擎
implementation(libs.ktor.client.ios)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
cocoapods { //cocoapods类似gradle管理包构建依赖,这里集成日志库
version = "1.0"
summary = "Sample for Kmm"
homepage = "https://www.touchlab.co"
framework {
baseName = "LiteLibs"
isStatic = true
// Only if you want to talk to Kermit from Swift
export("co.touchlab:kermit-simple:2.0.3")
}
}
}
android {
namespace = "com.hwj.cn"
compileSdk = 34
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
下面是/gradle/libs.versions.toml
[versions]
agp = "8.2.0"
kotlin = "1.9.22"
compose = "1.6.2"
compose-compiler = "1.5.10"
compose-material3 = "1.1.2"
composeMaterialVersion = "1.4.1"
androidx-activityCompose = "1.8.2"
kotlinxDatetime = "0.4.0"
compose-plugin = "1.6.0"
appcompat = "1.6.1"
material = "1.11.0"
activity = "1.8.0"
constraintlayout = "2.1.4"
lifecycleRuntimeKtx = "2.7.0"
composeBom = "2023.08.00"
voyager = "1.1.0-alpha04"
androidMediaFilePicker = "1.9.1"
koin = "3.5.3"
mokoMvvmVersion = "0.16.1"
androidLifecycleVersion = "2.2.0"
stately = "2.0.6"
ktor = "2.3.9"
coroutines-core = "1.8.0-RC"
uuid = "0.8.4"
kermit = "2.0.3"
settings = "1.1.1"
mokopermission = "0.18.0"
webviews = "1.9.0"
perference = "1.2.0"
androidAppCompatVersion = "1.6.1"
coil3 = "3.0.0-alpha06"
windowSize = "0.5.0"
sheet = "0.1.2"
pagingCommonVersion = "3.3.0-alpha02-0.5.1"
precompose = "1.6.0"
okio = "3.9.0"
bugly = "4.1.9"
file = "0.4.0"
view1 = "1.6.0.3"
media3="1.1.0"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
composeMaterial = { module = "androidx.compose.material:material", version.ref = "composeMaterialVersion" }
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" }
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
kotlin-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-lifecycle = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-perference = { module = "androidx.preference:preference-ktx", version.ref = "perference" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
voyager-bottom-navigator = { module = "cafe.adriel.voyager:voyager-bottom-sheet-navigator", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" }
precompose-navigator = { module = "moe.tlaster:precompose", version.ref = "precompose" }
precompose-koin = { module = "moe.tlaster:precompose-koin", version.ref = "precompose" }
precompose-viewmodel = { module = "moe.tlaster:precompose-viewmodel", version.ref = "precompose" }
mokoMvvmCore = { module = "dev.icerock.moko:mvvm-core", version.ref = "mokoMvvmVersion" }
mokoMvvmCompose = { module = "dev.icerock.moko:mvvm-compose", version.ref = "mokoMvvmVersion" }
lifecycle-extension = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "androidLifecycleVersion" }
mediaFilePicker = { module = "com.github.icerockdev:MaterialFilePicker", version.ref = "androidMediaFilePicker" }
#mediaFilePicker = { module = "com.nbsp:materialfilepicker", version.ref = "androidMediaFilePicker" }
detektGradleLib = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version = "1.22.0" }
stately-common = { module = "co.touchlab:stately-common", version.ref = "stately" }
mokopermission = { module = "dev.icerock.moko:permissions", version.ref = "mokopermission" }
mokopermission-compose = { module = "dev.icerock.moko:permissions-compose", version.ref = "mokopermission" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-ios = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines-core" }
uuid = { module = "com.benasher44:uuid", version.ref = "uuid" }
logger-kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "settings" }
compose-webviews = { module = "io.github.kevinnzou:compose-webview-multiplatform", version.ref = "webviews" }
coil3-core = { module = "io.coil-kt.coil3:coil", version.ref = "coil3" }
coil3-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3" }
coil3-ktor = { module = "io.coil-kt.coil3:coil-network-ktor", version.ref = "coil3" }
coil3-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coil3" }
coil3-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil3" }
appCompat = { module = "androidx.appcompat:appcompat", version.ref = "androidAppCompatVersion" }
windowSize = { module = "dev.chrisbanes.material3:material3-window-size-class-multiplatform", version.ref = "windowSize" }
bottomSheet = { module = "com.github.skydoves:flexible-bottomsheet-material3", version.ref = "sheet" }
paging-compose = { module = "app.cash.paging:paging-compose-common", version.ref = "pagingCommonVersion" }
okio-core = { module = "com.squareup.okio:okio", version.ref = "okio" }
android-bugly = { module = "com.tencent.bugly:crashreport", version.ref = "bugly" }
file-picker = { module = "io.github.vinceglb:filekit-compose", version.ref = "file" }
view-libs1 = { module = "io.github.ltttttttttttt:ComposeViews", version.ref = "view1" }
medie3-core = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
medie3-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "media3" }
medie3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" }
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
下面是androidApp下的build.gradle
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
}
android {
namespace = "com.hwj.cn"
compileSdk = 34
defaultConfig {
applicationId = "com.hwj.cn"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
ndk { //设置支持的SO库架构(开发者可以根据需要,选择一个或多个平台的so)
abiFilters += listOf("armeabi", "armeabi-v7a", "arm64-v8a")
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("debug") {
//签名
signingConfig = signingConfigs.getByName("debug")
}
register("alpha") {
//继承debug配置
initWith(getByName("debug"))
//混淆
// isMinifyEnabled = true //有混淆无法编译?
// proguardFiles(
// getDefaultProguardFile("proguard-android-optimize.txt"),
// "proguard-rules.pro"
// )
//移除无用的resource文件
// isShrinkResources = true
// proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
getByName("release") {
//继承alpha配置
initWith(getByName("alpha"))
//关闭debug
// isDebuggable = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
signingConfigs {
getByName("debug") {
enableV1Signing = true
enableV2Signing = true
enableV3Signing = true
// enableV4Signing = true //增量安装,不需要
storeFile = file("../hwj_pad_app.jks")
storePassword = "111116"
keyAlias = "gggg"
keyPassword = "111116"
}
register("release") {
enableV1Signing = true
enableV2Signing = true
enableV3Signing = true
storeFile = file("../hwj_pad_app.jks")
storePassword = "111116"
keyAlias = "gggg"
keyPassword = "111116"
}
}
sourceSets {
getByName("main") {
jniLibs.srcDirs("libs")
}
}
//自定义打包文件名
afterEvaluate {
tasks.named("assembleRelease") {
finalizedBy("copyAndRenameApkTask")
}
}
}
val copyAndRenameApkTask by tasks.registering(Copy::class) {
val config = project.android.defaultConfig
val versionName = config.versionName
val versionCode = config.versionCode
val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmm")
val createTime = LocalDateTime.now().format(formatter)
val gitHash = providers.exec {
commandLine("git", "rev-parse","--short","HEAD")
}.standardOutput.asText.get()
val destDir = File(rootDir, "apkBackup/compose_${versionName}")
from("release/androidApp-release.apk")
into(destDir)
rename { _ -> "compose_ark_${versionName}_${versionCode}_${createTime}.apk" }
doLast {
File(destDir, "App上传配置.txt").outputStream().bufferedWriter().use {
it.appendLine("版本号:${versionCode}")
.appendLine("版本名称:${versionName}")
.appendLine("软件名称:composeApp")
.appendLine("软件包名:com.hwj.cn")
.appendLine("版本说明:kotlin multiplatform、compose-multiplatform")
.appendLine("发布时间:${createTime}")
.appendLine("git记录:${gitHash}")
}
}
}
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation(projects.shared)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.androidx.activity.compose)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.appCompat)
}
结尾
整套架构下来,页面数据传递还是有点迷糊,例如列表点击跳转详情,一个实体对象内部如果有几个属性是网络链接,那么转字符长度就很大,如果是Screen(arg1:string)构建页时把参数独立传,如果有多个中间页很麻烦,我这里是利用viewModel的单例暂存传递的对象解决;图片的功能太少了;预览ui时非常麻烦,都在Activity内声明;目前性能问题也是没有怎么考虑,ipad2018下感觉卡顿。
Jason
2024/6/3