
欢迎加入 KMP/CMP 鸿蒙化社区https://atomgit.com/CPF-KMP-CMP适配后仓库地址AtomGithttps://atomgit.com/oh-tpc/ohos_Decompose一、为什么先挑 Decompose 下手前面几篇已经把 Essenty 的地基铺好了——生命周期、状态保留、实例保留、返回键这四件事在鸿蒙上都能跑。但地基不是房子。OpenHarmony 上真正缺的是把页面组织起来的那一层多页面怎么切、返回栈谁来管、前进后退时上一个页面要不要销毁、进程被杀之后回到哪一页。KMP 生态里干这件事的事实标准就是 Decompose。它把界面拆成一棵有生命周期的业务组件树每个组件只认识自己的直接子组件导航状态是一个纯函数的输入输出。Android、iOS、Desktop、Web 都能用它唯独鸿蒙还没有。挑它有三个理由。第一它是 Essenty 的天然续集。Decompose 的ComponentContext在源码里就是Lifecycle StateKeeper InstanceKeeper BackHandler四个 Owner 的组合而这四样恰好是上一篇已经适配过的东西。等于说地基已经打好了这篇是在上面盖楼技术栈 100% 复用不用重新趟一遍平台差异。第二它的平台假设薄得惊人。这一点是我翻完源码之后最意外的收获整个decompose模块里需要平台提供实现的expect声明只有四个而且其中三个在 Linux 上已经有现成实现鸿蒙直接复用即可。真正要新写的只有一个主线程检查。也就是说把 Decompose 搬到鸿蒙难点不在要写多少代码而在要先看清楚哪些代码根本不用写。第三验收标准肉眼可见。前进、后退、返回键拦截、状态保留——每一条都能在真机上用手指头验证不需要构造复杂场景。这比时区那种数值对了但看不出来的问题友好得多。这篇文章记录完整过程从 fork 源码、接入 HarmonyOS Kotlin 定制版、声明ohosArm64target到补齐缺失实现、把返回键接到 ArkUI、导出符号给 ArkTS、打成 HAR最后在真机上逐条验证。二、先摸清源码结构Decompose 到底把什么交给了平台动手之前先做一件事把decompose模块的源集摊开看一遍。不看清楚就改很容易在错误的地方加代码。decompose/src/下的源集是这样的decompose/src/ ├── commonMain/ 全部对外 API 与导航逻辑 │ ChildController / NavState / NavStateSaver / Value / ChildStack ... │ 以及 4 个 expectLock、checkMainThread、printError、KClass*.uniqueName ├── nonWebMain/ KClass*.uniqueName 的 actual内容就一行qualifiedName ├── linuxMain/ Lockpthread 递归锁、printError、checkMainThread注意空实现 ├── androidMain/ mainthread/CheckMainThread.ktLooper 版 DefaultComponentContextBuilder.kt ├── darwinMain/ mainthread/CheckMainThread.ktDarwin 版 ├── jvmMain/ mainthread/CheckMainThread.ktJVM 版 └── jsMain/ wasmJsMain/ web 相关与本次无关这张表里有两个信息点直接决定了后面的工作量。第一没有nativeMain兜底。很多 KMP 库会有一个nativeMain中间源集把所有 native target 共有的实现放进去新加一个 native target 时能白拿一批代码。Decompose 没有。它的 native 侧实现只放在linuxMain里而linuxMain是只服务 linuxX64 / linuxArm64 这一组 target的。ohosArm64不属于这一组所以新建 target 之后编译器会老老实实把缺的声明全报出来——不会偷偷继承任何东西。第二nonWebMain必须挂上。KClass*.uniqueName这个 expect 声明在commonMainactual 却放在nonWebMain里它不属于任何一个具体平台源集。这一条是本次适配最容易踩空的地方如果ohosArm64Main只挂在commonMain下面编译commonMain阶段一切正常一到 native 链接阶段就会因为缺uniqueName的 actual 而失败。看清楚了接下来就有章可循了。三、第一步接入 HarmonyOS Kotlin 定制版这是前置条件也是最容易被忽略的一步。ohosArm64()这个 target 在 Kotlin 官方主线发行版里并不存在它是 OpenHarmony 适配生态中的定制能力。如果你用官方 Kotlin 插件直接写ohosArm64()Gradle 会报Unresolved reference——插件根本不认识这个名字。所以第一步是把工程使用的 Kotlin 切到 HarmonyOS Kotlin 定制版并在settings.gradle.kts里把插件仓库指向 CPF-KMP-CMP 对应的发行仓库。// settings.gradle.ktspluginManagement{repositories{// HarmonyOS Kotlin 定制版插件仓库地址见 CPF-KMP-CMP 发布说明gradlePluginPortal()maven(https://jitpack.io)// 上游 Decompose 的 gradle-setup-plugin 来自这里}resolutionStrategy{eachPlugin{if(requested.id.toString()com.arkivanov.gradle.setup){useModule(com.github.arkivanov:gradle-setup-plugin:4a2bf5cb37)}}}}具体坐标和仓库地址以 CPF-KMP-CMP 组织的发布说明为准那里会同步每一版的版本号与配套 Gradle、JDK 要求。顺手建议把上游settings.gradle.kts里的sample:app-android、app-desktop、app-js这些宿主模块先摘掉。鸿蒙适配阶段用不到它们留着只会在配置阶段去解析 Android SDK白白拖长首次同步时间。DevEco Studio 26.0.0 开发界面工程已同步成功。四、第二步声明 target然后让编译器告诉你缺什么上游用的是 Arkivanov 自己的gradle-setup-plugin用setupMultiplatform()声明一套固定的 target再用setupSourceSets { val linux by bundle() }这种 bundle 语法描述源集继承关系。这套 DSL 里没有ohosArm64。所以我没有走 bundle 语法而是把这个 target 和它的源集显式补上。这样做的额外好处是源集继承关系在文件里一眼可见不依赖插件的内部命名约定。// decompose/build.gradle.ktskotlin{// 新增 1/2targetohosArm64()// 新增 2/2源集sourceSets{valcommonMainbygettingvalcommonTestbygetting// nonWebMain 里放着 KClass.uniqueName 的 actual必须挂上valnonWebMainbygettingvalohosArm64Mainbycreating{dependsOn(nonWebMain)}valohosArm64Testbycreating{dependsOn(commonTest)}}}注意dependsOn(nonWebMain)这一行不是可选项。前面说过uniqueName的 expect 在commonMain、actual 在nonWebMain而nonWebMain不属于任何平台源集——必须显式接上。声明完先别急着写实现代码先编译一次把缺什么交给编译器报出来。这是 KMP 适配里最高效的做法比对着源码猜要准得多./gradlew :decompose:compileKotlinOhosArm64Kotlin/Native 的编译任务命名规则是compileKotlin首字母大写的 target 名所以这里就是compileKotlinOhosArm64。第一次编译会失败报出三条Expected declaration xxx has no actual declaration in module decomposee: Lock.kt: Expected declaration Lock has no actual declaration in module decompose for Native e: mainthread/CheckMainThread.kt: Expected declaration checkMainThread has no actual declaration ... e: errorhandler/PrintError.kt: Expected declaration printError has no actual declaration ...每一条就是一个待补的actual。三条就是本次适配的全部工作面。五、第三步三个 actual 直接复用第四个必须真写5.1Lock逐字复用 Linux 的实现先把Lock的定义看清楚。commonMain里的声明是internalexpectclassLock(){inlinefunTsynchronizedImpl(block:()-T):T}一个同步块没有别的。Linux 上的实现是用 pthread 的可重入互斥量internalactualclassLockactualconstructor(){privatevalmutex...// pthread_mutex_t, PTHREAD_MUTEX_RECURSIVEactualinlinefunTsynchronizedImpl(block:()-T):T{pthread_mutex_lock(mutex.ptr)try{returnblock()}finally{pthread_mutex_unlock(mutex.ptr)}}}为什么鸿蒙能原样复用OpenHarmony 的用户态运行时是 POSIX 兼容的Kotlin/Native 为ohosArm64提供的platform.posixcinterop 里pthread_mutex_init/pthread_mutexattr_settype/PTHREAD_MUTEX_RECURSIVE一应俱全。而 Decompose 需要的恰恰是可重入——DecomposeSettings.update()内部会嵌套加锁普通的互斥量会当场自锁。所以ohosArm64Main/kotlin/com/arkivanov/decompose/Lock.kt就是把linuxMain那份拷过来。这不是偷懒而是上游代码本身就没有平台特有的东西它要的不是某个平台的专有 API只是平台得有个能重入的锁。⚠️ 有个反向注意点不要图省事用kotlin.native.concurrent.SynchronizedObject替掉它。SynchronizedObject不可重入DecomposeSettings.update嵌套加锁时会直接死锁而且死锁现场在 native 侧排查成本很高。5.2printError也复用commonMain的声明是internal expect fun printError(exception: Exception)。Linux 侧实现是exception.printStackTrace()。鸿蒙上同样成立Kotlin/Native 写 stderr 的内容会进到 DevEco Studio 的 hilog日常排查直接看 Build 窗口就行。所以这个文件也是原样复用。如果希望错误进 hilog 时带业务 tag不要改 actual——把DecomposeSettings.settings.onDecomposeError指到自己的实现即可那是 Decompose 留出来的正路。5.3checkMainThread唯一要真写的地方这个就值得展开了。先把三份上游实现摆在一起// androidMainprivatevalmainThreadId:Long?Looper.getMainLooper().thread.idinternalactualfuncheckMainThread(){if(settings.mainThreadCheckEnabledmainThreadId!nullThread.currentThread().id!mainThreadId){onDecomposeError(NotOnMainThreadException(Thread.currentThread().name))}}// darwinMain / jvmMain 各有各的取主线程方式// linuxMain —— 干脆是空的internalactualfuncheckMainThread(){// No-op}linuxMain是空实现因为 Linux 上没有主线程这个概念库自己也知道这一层是空的。但鸿蒙不一样ArkUI 有明确的主线程UI 线程而 Kotlin/Native 的 NAPI 同步调用默认就跑在调用方线程上——ArkTS 的 TaskPool、Worker 各有各的线程。也就是说从工作线程误改组件树在鸿蒙上是完全可能发生的这个检查不能空着。那怎么判断当前是不是主线程我没有去 native 侧反查线程归属那种做法在鸿蒙上不可靠。我选择让 ArkTS 主动登记Ability 的生命周期回调onWindowStageCreate/onForeground一定在主线程上执行在这个时机调一次 native 导出函数把当前线程身份记下来之后任何线程访问组件树checkMainThread()就能正确判定。// ohosArm64MainVolatileprivatevarmainThreadId:ULong0uL/** 由 ArkTS 在主线程Ability 生命周期回调里调用一次 */funmarkMainThread():ULong{mainThreadIdpthread_self()returnmainThreadId}internalactualfuncheckMainThread(){if(!DecomposeSettings.settings.mainThreadCheckEnabled)returnvalexpectedmainThreadId// 未登记则放行——与 Android 上取不到 Looper 时 mainThreadId null 的行为一致// 避免 Embedding 启动早期误报if(expected0uL)returnvalcurrentpthread_self()if(current!expected){onDecomposeError(NotOnMainThreadException(currentThreadNameohos pthread_self$current, expected$expected))}}这里有几个刻意的设计取舍值得单独说明。为什么用登记而不是推断因为 ArkTS 的主线程身份没有稳定的 native 侧查询入口Ability 生命周期回调跑在主线程但这是语义约定而不是可查询的 API。既然调用方ArkTS最清楚自己在哪个线程就让它负责登记。为什么没登记时要放行这与 Android 的行为对齐Android 上如果Looper.getMainLooper()抛异常极端早期启动场景mainThreadId为null检查直接跳过。宁可漏报也不要在启动早期误报一堆假错误。为什么用VolatilemainThreadId会在主线程写、在其他线程读不加Volatile时 Kotlin/Native 不保证跨线程可见性可能读到 0 从而静默跳过检查。六、第四步把返回键接到 ArkUI补齐三个 actual 之后编译就能过了如下所示通过后启动运行效果如下第二个真正的坑才开始返回键。先明确 Decompose 的返回键机制是纯逻辑的不依赖任何平台 APIvalbackDispatcherBackDispatcher()if(!backDispatcher.back()){// 没有组件消费这个返回事件}BackDispatcher.back()的派发规则是把所有回调按 priority 升序排序从末尾往前找第一个isEnabled的回调被调用返回true一个都没命中返回false。Decompose 在childStack(handleBackButton true)时会自己注册一个出栈回调priority 默认为 0。而组件自己通过childContext.backHandler.register(...)注册的回调会被ChildBackHandler包一层、priority 抬高后再注册到父 dispatcher 上。于是天然形成这个顺序组件自己注册的回调priority 1000→ 先被检查 ChildStack 的自动出栈回调priority 0→ 后被检查 都没有 → back() 返回 false → 交还系统鸿蒙侧要做的只是把系统返回事件喂进backDispatcher.back()两条入口// 1. ArkUI 页面级Entry 组件上的 onBackPressonBackPress():boolean{returndecompose.back();// true 组件消费了页面不退出}// 2. UIAbility 级页面没消费时才会走到这里onBackPressed():boolean{returndecompose.back();}两端指向同一个BackDispatcher所以无论从哪条路径进来组件注册的回调都会被按优先级正确询问。最终行为就是场景back()结果表现详情页按返回true出栈回上一页确认页按返回组件注册了拦截回调true不出栈只累加拦截次数首页按返回栈深为 1false交还系统 → 退出应用demo展示可操作页面如下确认页拦截返回时的界面与日志截图七、第五步生命周期、状态、实例三件事对齐 UIAbility返回键解决了还剩三件事要接到鸿蒙的宿主上。7.1 生命周期Android 上defaultComponentContext()会把 Activity 的Lifecycle抠出来用。鸿蒙没有对应物得自己拼vallifecycleLifecycleRegistry()valcontextDefaultComponentContext(lifecyclelifecycle,backHandlerbackDispatcher,)// 时序必须与 Android 一致ON_CREATE - 建树 - ON_START - ON_RESUMElifecycle.create()rootComponentRootComponent(context)lifecycle.start()lifecycle.resume()对应的鸿蒙接线点// EntryAbility.onWindowStageCreate —— 主线程且只执行一次decompose.markMainThread();decompose.init();// EntryAbility.onForeground / onBackground / onDestroydecompose.abilityEvent(resume);// 也可能 pause / stop / destroy这里有个必须讲清楚的工程约束根ComponentContext一定要在UIAbility里创建不能放在页面的aboutToAppear或build里。这一点和 Decompose 官方文档对 Compose 的警告是同一个道理——组件树的根只能创建一次放进页面就会出现页面重建一次就多出一棵组件树而且是静默的功能看着正常只是泄漏和日志翻倍。7.2 状态保留鸿蒙与 Android 最大的差异这是整篇里最需要注意的一条。Android 上StateKeeper背后是SavedStateRegistryBundle直接落盘组件的 save/restore 是自动的——很多 Android 开发者甚至没意识到自己在用 Decompose 的状态保留。而在非 Android 平台上Decompose 要求业务代码显式给出序列化器// commonMain 里写的组件代码privatevardraft:IntstateKeeper.consume(keydraft,strategyInt.serializer())?:0init{stateKeeper.register(keydraft,strategyInt.serializer()){draft}}不写这两行鸿蒙上就永远不会恢复状态——而且不会有任何报错只是进程被杀之后再回来草稿没了。这类问题如果没意识到平台差异能查很久。落盘载体在鸿蒙上选ohos.data.preferences进程被杀之前把stateKeeper.save()的结果写进 preferences冷启动时先restore再建树。顺序是关键必须先恢复 StateKeeper再创建组件树否则组件consume的时候历史状态还没进去等于白存。这和 Android 上onCreate(savedInstanceState)里先有savedInstanceState再建组件树是同一个道理。7.3 实例保留注意触发场景不同InstanceKeeper的语义类似 AndroidX ViewModel是一致的但最有说服力的场景不一样。Android 上主要靠配置变更不重建进程来体现鸿蒙这边更直观的是返回栈里的组件不销毁push 到新页面后前一个组件降到CREATED但它没有被销毁仍然在跑所以它的InstanceKeeper实例还在计数、缓存、连接都保得住只有组件被从栈里移出pop / replaceAll / destroy时才会走InstanceKeeper.Instance.onDestroy()。这一条在 demo 里的表现非常清楚首页计数 1 两次 → 进详情 → 返回 → 首页计数还是 2。如果哪天组件被误销毁了计数会归零一眼就能看出来。八、第六步把能力导出给 ArkTSKotlin 层的逻辑要被鸿蒙页面调用需要走 Kotlin/Native 导出 C 符号、再由 NAPI 桥接注册的链路。8.1 Kotlin 侧CName指定符号名CName(decompose_mark_main_thread)fundecomposeMarkMainThread():LongmarkMainThread().toLong()CName(decompose_init)fundecomposeInit(){OhosDecomposeHost.init()}CName(decompose_back)fundecomposeBack():BooleanOhosDecomposeHost.onBackPressed()CName(decompose_state_json)fundecomposeStateJson():StringOhosDecomposeHost.currentStateJson()三条硬约束CName的函数不能有默认参数、不能是internal参数只能用 NAPI 认得的类型Int/Long/Boolean/Double/String/ 指针符号名一旦发布就别再改。改了名字之后.so里符号还在llvm-nm能看到但 ArkTS 调用直接失效属于最难排查的一类问题返回值不要是 Kotlin 对象。只返回String/Boolean/Int最省事。关于数据边界我只传StringJSON和Boolean不传对象、不传回调。理由和序列化那篇一样传对象要多一层类型映射而且会把 Kotlin 对象的生命周期漏到 ArkTS 侧传回调则要在 NAPI 上处理线程与引用计数收益极低。用 JSON 虽然土但边界干净、可打日志、可断言。8.2 动态库的 exportohosArm64{binaries.sharedLib{baseNamedecompose// 把 decompose 以及它 api 依赖的 essenty 四个模块一起导出export(project(:decompose))}}export这一行不能省。虽然我们只导出自己的CName函数但桥接层里一旦有泛型/内联代码被展开到 essenty 里链接期就会报undefined symbol。8.3 NAPI 与 HARC 侧的注册只做参数拆包没有业务逻辑。最容易出错的不是代码而是三处名字必须一致Kotlin 的 CName(decompose_xxx) ⇄ nm_modname decompose ⇄ ArkTS import libdecompose.so三处只要有一处对不上就会出现llvm-nm能看到符号、ArkTS 就是调不到的经典现象。HAR 的目录结构har-ohos-decompose/ ├── oh-package.json5 ├── Index.ets ├── Index.d.ts ├── libs/arm64-v8a/libdecompose.so └── src/main/cpp/ ├── napi_init.cpp └── types/libdecompose/Index.d.ts接入前先确认符号真的导出了这一步能省掉后面一半的排查时间llvm-nm-Dhar-ohos-decompose/libs/arm64-v8a/libdecompose.so|grepdecompose_期望看到T decompose_back T decompose_init T decompose_mark_main_thread T decompose_state_json ...九、第七步操作验证部署后页面显示当前导航栈、宿主生命周期、各组件状态与操作日志。下面按顺序走一遍验收。① 启动。栈深 1Home 为RESUMED宿主RESUMED。② 前进。点「push 详情 #1」。栈深变 2Detail 为RESUMEDHome 降到CREATED但日志里没有它的 onDestroy——这是返回栈里的组件不销毁的直接证据。③ 后退。按系统返回键。Detail 被消费并出栈日志出现VisitCounter.onDestroy()Home 回到RESUMED。如果事先在首页点过两次计数此时首页的 InstanceKeeper 计数仍然是 2——说明组件从头到尾没被销毁过。④ 返回键拦截。点「push 确认页拦截返回」进入确认页后按系统返回键不会退出只在页面上累加拦截返回次数。再点「直接 pop」也不行——因为组件自己注册的BackCallback优先级更高会先把事件吃掉。点「确认离开」之后才真正出栈。⑤ 栈空交还系统。在首页栈深 1按返回键事件不被消费应用正常退出。日志会记录一次返回键未被消费交还系统。⑥ 状态保留。首页把草稿加到 3然后分别点两个按钮「模拟配置变更」组件对象全部重建、状态走内存里的 StateKeeper 恢复 → 草稿仍是 3「模拟进程重建」先把状态 hex 落进preferences再清掉进程内一切从磁盘恢复 → 草稿仍是 3但 InstanceKeeper 计数归零进程都没了实例自然不存在这是符合预期的行为。⑦ 重复配置。连点两次「push 详情 #7」。两次都能入栈栈深变 3两份的Child#key分别是detail_7#0和detail_7#1组件实例不同、状态槽位互不影响。这是 Decompose 3.4 起转正的 Duplicate Configurations 能力也是Child#key从Any改成String的原因——否则在 Compose 侧会直接抛Key XYZ was used multiple times。⑧ 后台降级。把应用切到后台再切回来日志会显示整棵子树降到CREATED、回前台后又恢复RESUMED过程中没有任何组件被销毁。十、踩坑清单坑一ohosArm64()报未定义。十有八九是还在用 Kotlin 官方主线插件。这个 target 只在 HarmonyOS Kotlin 定制版里存在必须先把插件版本切过去。坑二编译commonMain通过native 链接阶段报缺uniqueName。根因是ohosArm64Main没有dependsOn(nonWebMain)。uniqueName的 expect 在commonMain、actual 在nonWebMain而nonWebMain不属于任何平台源集。症状很有欺骗性报错点看起来在commonMain的Utils.kt上容易让人以为是自己的代码有问题。坑三以为是Dispatchers.Main的问题其实 Decompose 根本不依赖 coroutines。这是我在方案阶段自己走的弯路。翻decompose/build.gradle.kts就能确认commonMain的依赖只有 essenty 四个模块 kotlinx-serialization-core没有 kotlinx-coroutines。Decompose 表达线程约束的方式只有同步回调 checkMainThread()这一处。所以不需要给 ohosArm64 补Dispatchers.Main的 actual。反过来也提醒一句用 Decompose 时别指望它帮你切线程耗时逻辑要自己launch。坑四主线程检查一直不生效。检查一下 ArkTS 有没有在onWindowStageCreate里调decompose_mark_main_thread()。没登记时mainThreadId是 0检查会直接放行——这是刻意的容错设计但也意味着忘了登记就等于没有检查。坑五llvm-nm -D能看到符号ArkTS 侧 import 不到。原因在于 Kotlin/Native 导出的 C 符号和 ArkTS 能 import 的模块接口不是一回事中间还隔着 NAPI 注册这一层。按符号名 →nm_modname→Index.d.ts→oh-package.json5的main这个顺序逐一核对即可。坑六状态在鸿蒙上从来不恢复。先确认组件里有没有写stateKeeper.register/consume并给了序列化器。Android 上是自动的鸿蒙上必须显式写而且不会报错。另外检查恢复时序必须先把SerializableContainer灌进StateKeeper再建组件树。坑七把根ComponentContext建在页面里。页面重建一次就多一棵组件树功能看着正常但日志翻倍、内存持续增长。根只能在UIAbility里建且只建一次。十一、小结Decompose 的适配过程其实很反直觉看起来是把一个大框架搬上鸿蒙实际做下来只有一处逻辑要新写。根本原因是 Decompose 把自己拆得很干净——平台相关的东西全部推给了 Essenty而 Essenty 在上一篇已经落地了。剩下那四个expect里Lock和printError是平台有 POSIX 就能过uniqueName是挂对源集就白拿唯一有增量的是checkMainThread()它把上游在 Linux 上被迫留白的检查在鸿蒙上用ArkTS 主线程登记补成了真实现。所以这次适配真正的收获不是代码量而是三条判断先看源集再决定写什么。nativeMain有没有、nonWebMain放的是什么直接决定了工作量是3 个文件还是30 个文件。平台差异要往机制上看不要往API上看。Dispatchers.Main那个弯路就是典型的想当然——如果一开始就去读build.gradle.kts的依赖能省掉半天。状态保留是 Android 与非 Android 之间最大的行为差异。Android 自动、其他平台手动且不写不报错。这一条不只对 Decompose 成立对整个 Essenty 系以及所有依赖SavedStateRegistry的库都成立。下一步我打算沿着同一条链路继续推进先把extensions-compose的鸿蒙化排上依赖 Compose Multiplatform 那条线把组件树渲染到 OHRender再回头看看网络侧——Ktor 的鸿蒙引擎目前还是空白那块含金量更高但要处理的坑也更多。欢迎加入 KMP/CMP 鸿蒙化社区一起共建 OpenHarmony 跨平台生态https://atomgit.com/CPF-KMP-CMP适配后仓库地址AtomGithttps://atomgit.com/oh-tpc/ohos_Decompose环境信息DevEco Studio 26.0.0 Release / HarmonyOS Kotlin 2.2.21-1.0.0 / Gradle 8.14.1 / JDK 21 / 真机 ROM 6.1 / Decompose 3.5.0Kotlin 2.1.0Essenty 2.5.0kotlinx-serialization 1.6.3