<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Kotlin on Trevor's Blog</title><link>http://mings.in/tags/kotlin/</link><description>Recent content in Kotlin on Trevor's Blog</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Thu, 22 Oct 2026 07:33:31 +0800</lastBuildDate><atom:link href="http://mings.in/tags/kotlin/index.xml" rel="self" type="application/rss+xml"/><item><title>Kotlin 2.5 换了发布格式，编译器也跟着变严</title><link>http://mings.in/posts/kotlin25/</link><pubDate>Thu, 22 Oct 2026 07:33:31 +0800</pubDate><guid>http://mings.in/posts/kotlin25/</guid><description>10 月 21 日，Kotlin 2.5.0-Beta2 发布。多数人翻开更新说明，看的是语言特性那几行：四项转正、两项实验。看完觉得这一版平平无奇，就关掉了。</description><content:encoded><![CDATA[<p>10 月 21 日，Kotlin 2.5.0-Beta2 发布。多数人翻开更新说明，看的是语言特性那几行：四项转正、两项实验。看完觉得这一版平平无奇，就关掉了。</p>
<p>真正的变化在后面几节。发布格式换了，编译方案换了，还有三处默认值被悄悄调严。我把两个编译器装在同一台机器上，同一份源码各喂一遍，出来的结果不一样。</p>
<h2 id="这次发布重点不在语法">这次发布，重点不在语法</h2>
<p>四项稳定特性里，有两项其实是「把选择变成默认」：按名解构的 only-syntax 模式、未使用返回值检查器。老代码一行不用改，新代码的表达力会变。</p>
<p>实验性的两项也在同一路数上，companion 扩展与 companion 块、完整 value class。都是让语言少一点莫名其妙的限制。</p>
<p>语言层这次最安静。真正铺开来看，是文档后半段那三节：Kotlin Multiplatform 的发布格式、新的编译方案、Apple 平台的增量编译。这三条不影响你写什么，影响你交付什么、多久能编完。</p>
<h2 id="一个-kar-文件替掉七千多个文件">一个 .kar 文件，替掉七千多个文件</h2>
<p>官方的原话是这样：</p>
<blockquote>
<p>The new format publishes platform artifacts (except for JVM and Android) as a single .kar archive, which is a ZIP archive additionally compressed with XZ.</p>
</blockquote>
<p>一个库支持几个目标，过去就要发几套产物。现在除 JVM 和 Android 之外的平台产物，全打成一份 <code>.kar</code>，本质是 ZIP 再套一层 XZ 压缩。</p>
<p>数字比描述直观。官方拿 cryptography-kotlin 举例：发布产物从 7539 个文件、91 MB，变成 1036 个文件、23 MB。整个多模块工程。</p>
<p><img alt="官方发布说明原文，红框是这组数字" loading="lazy" src="/images/kotlin25/fig1_kar.png"></p>
<p>开启方式一行配置，单个模块写在构建脚本里，全工程写在 <code>gradle.properties</code>：</p>
<pre tabindex="0"><code>kotlin.publicationFormat=KOTLIN_ARCHIVE
</code></pre><p>两个限定必须一起看。除 JVM 和 Android，这两个生态仍旧走标准的 Maven 产物。消费侧已经全面可用，你依赖的库将来换格式，你的构建不用动。发布侧还是实验特性，你自己要发库的话，得单独验一遍流水线。</p>
<h2 id="为什么-maven-central-卡的是文件数">为什么 Maven Central 卡的是文件数</h2>
<p>原文只说了一句「Maven Central 最近引入了发布体积限制」。这句话背后的事，比体积有意思。</p>
<p>卡住库作者的不是总字节数，是文件数。一个支持六个目标的库，每个目标一套产物，再算上源码包和元数据，随便就上几千个文件。每个文件在仓库里是一条独立记录、一次上传、一次索引，对仓库是运维成本，对使用者是依赖解析时第一屏的请求数。</p>
<p>所以文件数从 7539 掉到 1036，降了百分之八十六，比体积从 91 MB 掉到 23 MB 更关键。省下来的是请求和索引，不是带宽。</p>
<p>JVM 和 Android 被排除在外，也说明一件事：这两个生态还锚在标准 Maven 分类器上，动不了。跨平台库的真实处境是「多目标，主流平台守老格式，其余走归档」，这个混合结构会持续一阵子。</p>
<p>这条改动对下游完全透明，正因为透明，它才敢被推荐给所有人。受益的是库作者和维护发布流水线的人，不是写业务代码的你我。</p>
<h2 id="新的编译方案common-代码不该偷偷用上平台-api">新的编译方案：common 代码不该偷偷用上平台 API</h2>
<p>这一节官方写得克制，四条：让编译器处理 common source sets 的方式与 IDE 一致；防止 common 代码意外解析到平台特有声明；改进重载解析与类型推断的一致性；为 common source set 启用增量编译。</p>
<p>前三条在修一个老坑。<code>commonMain</code> 里写错一个 import，IDE 标红但命令行能过，或者反过来，两边都对不上。原因是 IDE 和编译器各有一套解析器，各算各的。</p>
<p>统一之后有个副作用：以前能编过的代码，现在可能编不过了。这不是退步，那些代码本来就摸到了不该看见的符号，只是没人拦。</p>
<p>第四条才是日常收益。common 终于有增量编译了，天天开 IDE 的人感受最直接。CI 上反而看不出来，因为流水线每次都是全量构建。</p>
<p>JetBrains 在九月专门为这个方案写过一篇博客，标题里用了 reliable 这个词。要修的问题正好就是「不可靠」：同一份 common 代码，IDE 和编译器给出两种结论。</p>
<h2 id="编译器变严了三处默认值都改了">编译器变严了，三处默认值都改了</h2>
<p>先看最直观的一处。同一份代码，2.4.21 直接编不过，2.5.0-Beta2 默认就过：</p>
<pre tabindex="0"><code>(val mail = email, val name = username) = user
</code></pre><p>括号里带 <code>val</code>，用等号指定取哪个属性，变量名不用和属性同名。</p>
<p><img alt="三个特性的本机实录" loading="lazy" src="/images/kotlin25/fig3.png"></p>
<p>顺带说个老坑。<code>val (email, username) = user</code> 这种按位写法两个版本都通过，但值是按位置分的。我把 <code>User(username, email)</code> 解成 <code>email=alice</code>、<code>username=alice@example.com</code>，变量名全错，编译器一声不吭。这个特性存在的理由就是它。</p>
<p>第二处是未使用返回值检查器。同一个文件，2.4.21 静默通过，2.5.0-Beta2 直接给 warning：<code>unused return value of 'plus'</code>。表达式算出了非 Unit 的值又没人用，它会指出来。</p>
<p>第三处是跨模块内联。2.4.0 引入时是可选的，2.5.0-Beta2 默认开启，回退开关是 <code>-Xklib-ir-inliner=disabled</code>。</p>
<p>另外还有一处不在默认值上：类型推断的约束生成减少了，处理复杂泛型时不再卡住。官方写得很克制，只说了可能改变推断结果、重载选择、诊断。翻译成人话是，升级后某些代码会推断出不同的类型，而那些代码你之前认为是对的。回退开关 <code>-XXLanguage:-EliminateSecondKindIncorporation</code>。</p>
<h2 id="value-class-这一刀切得最深">value class 这一刀切得最深</h2>
<p>值类以前只允许一个属性，还得配 <code>@JvmInline</code>。现在多属性可以，另外多了抽象 value class 和 value object 两种形态。</p>
<p>本机实测：2.4.21 对多属性 value class 直接报错，提示要开 <code>-XXLanguage:+FullValueClasses</code>；2.5.0-Beta2 加上 <code>-Xfull-value-classes</code> 就通过。</p>
<p>value object 同理，2.4.21 说 <code>modifier 'value' is not applicable to 'standalone object'</code>，2.5 加上开关能编能跑。</p>
<p>有个细节比文档更能说明问题。value object 上写 <code>===</code>，编译器直接报错，identity 比较被语言禁掉了，因为它本来就没有身份。</p>
<p>真正的坑在迁移。<code>value class</code> 不再默认 inline。官方给的时间表是 2.5.20 开始出 warning，2.6 或 2.7 变 error。2.5.0-Beta2 上这个开关还认不出来，编译器回的是 flag 不支持，说明窗口确实要等到 2.5.20 才开。</p>
<p>也就是说现在升级不会炸。但你要在 2.5.20 之前把老代码标清楚，决定哪些继续 inline、哪些放手。一个只包一个 String 的值类到底该不该 inline，本来就是取舍，语言不该替所有人做这个默认。</p>
<h2 id="标准库补了两个小口子">标准库补了两个小口子</h2>
<p><code>if</code> 表达式这块补了三个函数：<code>onTrue()</code>、<code>onFalse()</code>、<code>ifOrNull()</code>。以前写「添加成功了就打印一句」，得先存个局部变量再套 if/else，现在一行接住：</p>
<pre tabindex="0"><code>val added = tags.add(&#34;wasm&#34;).onTrue { println(&#34;Tag added&#34;) }
</code></pre><p>返回值还是原来那个布尔，判断和副作用挤在一条链上，不用再为这种小场景单独造变量。</p>
<p>另一半是 Unicode 码点。<code>Char</code> 只装得下 UTF-16 的一个代码单元，碰上 emoji 这种代理对，按 <code>Char</code> 遍历会把一个字符拆成两半。新的 <code>CodePoint</code> 是个 value class，底层用 Int 存码点，能查它占几个 UTF-16 单元、属于哪个类别、是不是字母。</p>
<p>这两处都要 <code>@OptIn(ExperimentalStdlibApi::class)</code>。留意一下 <code>CodePoint</code> 的形态：它本身就是 value class 放宽之后才写得出来的东西，语言和标准库在这一版里对上了。</p>
<h2 id="我在本机看到的两个意外">我在本机看到的两个意外</h2>
<p><img alt="同一份代码，两个编译器的对照" loading="lazy" src="/images/kotlin25/fig2.png"></p>
<p>第一个意外是编译没有变快。489 行的工程，2.4.21 预热后中位数 2.86 秒，2.5.0-Beta2 是 2.99 秒，产物都是 161 KB。差异落在噪声区间，不能说变慢，但也谈不上推断优化在小工程上有什么收益。这类优化只在大型泛型代码里才显形，小样本看不出来。</p>
<p>第二个是开关的适用范围。<code>-Xklib-ir-inliner=disabled</code> 在 JVM 编译器上直接不被接受，提示 flag 不支持。它只在编 klib 的平台有意义，也就是 Native、JS、Wasm 那几路。</p>
<p>文档里写的「默认开启」，按平台分开读才对。</p>
<h2 id="升级前该做的三件事">升级前该做的三件事</h2>
<p>JDK 门槛抬了。编译器的最低要求从 8 提到 17，现在低于这个版本直接报 error。CI 镜像和 Docker 基础镜像要跟着升，这一步通常比代码改动更耗时间。</p>
<p>发布 Kotlin Multiplatform 库的话，<code>.kar</code> 先别急着开。发布侧还是实验特性，可以在流水线里试跑，别直接推正式版。</p>
<p>测试要跑一遍。未使用返回值检查和类型推断的变化，都属于静默通过但结果不同的那类，读文档看不出问题，跑测试才看得见。</p>
<h2 id="最后">最后</h2>
<p>2.5 这一轮的动作大多落在默认值上：默认打包、默认检查、默认内联，还有默认按名字解构。每改一处都配了回退开关，说明官方清楚会踩到人，也说明这些默认值还没定稿。</p>
<p>你手上有没有那种「IDE 标红、命令行却能编过」的代码？升级到 2.5 之后它大概得老实交代。欢迎在评论区聊聊你踩到的变化。</p>
<p>参考资料：
<a href="https://kotlinlang.org/docs/whatsnew-eap.html">Kotlin 2.5.0-Beta2 更新说明</a>
<a href="https://kotlinlang.org/docs/releases.html">Kotlin 版本发布计划</a>
<a href="https://blog.jetbrains.com/kotlin/2026/09/a-more-reliable-compilation-scheme-for-kotlin-multiplatform-modules/">Kotlin Multiplatform 新编译方案</a></p>
]]></content:encoded></item><item><title>Amper 转正了：它成了 Kotlin 的官方工具链</title><link>http://mings.in/posts/kotlin-toolchain/</link><pubDate>Wed, 07 Oct 2026 11:52:28 +0800</pubDate><guid>http://mings.in/posts/kotlin-toolchain/</guid><description>10 月 6 日，JetBrains 的 Amper 博客发了最后一篇，标题是《Amper Is Now the Kotlin Toolchain》。名字后面还挂着一句 Moving to the Kotlin Blog，是字面意思，这个栏目从那天起不再更新，以后并到 Kotlin 博客里去。</description><content:encoded><![CDATA[<h2 id="先说这周这条不太起眼的公告">先说这周这条不太起眼的公告</h2>
<p>10 月 6 日，JetBrains 的 Amper 博客发了最后一篇，标题是《Amper Is Now the Kotlin Toolchain》。名字后面还挂着一句 Moving to the Kotlin Blog，是字面意思，这个栏目从那天起不再更新，以后并到 Kotlin 博客里去。</p>
<p>改名的不是博客，是 Amper 自己。它现在叫 Kotlin Toolchain，是 Kotlin 的官方工具链。</p>
<p>这类新闻很容易被划过去，觉得又是改个名冲业绩。但我把它的代码和文档翻了一遍，发现这次不太一样：它是在把 Amper 这三个字从代码里一层一层往外清。</p>
<p>清到什么程度？看 0.13 的发布说明就明白了。破坏性变更里有两条，一条是把环境变量 AMPER_BUILD_DIR 改名成 KOTLIN_TOOLCHAIN_BUILD_DIR，另一条是把模块布局里那个叫 amper 的名字改成 default。</p>
<p>一个名字能从配置文件里清到环境变量里，说明这不是营销动作。</p>
<h2 id="三个月里的三个动作">三个月里的三个动作</h2>
<p>我按时间把这个过程排了一下：</p>
<p><img loading="lazy" src="/images/kotlin-toolchain/timeline.png"></p>
<p>仓库搬家在 5 月，宣布转正在 6 月，把名字从代码里清干净是 10 月。前两个动作你在新闻里看得到，第三个动作只有翻开 0.13 的发布说明才看得到。</p>
<p>6 月那篇里有句话我挺喜欢，它解释了为什么要换名字：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">As the project evolved, it became clear that the ecosystem doesn&#39;t just need
</span></span><span class="line"><span class="cl">another build tool – it needs a unified entry point into all of Kotlin.
</span></span></code></pre></div><blockquote>
<p>译文：随着项目往前走，我们看清了一件事，这个生态要的不是又一个构建工具，而是一个通往整个 Kotlin 的统一入口。</p>
</blockquote>
<h2 id="我在这台机器上装了一遍">我在这台机器上装了一遍</h2>
<p>不看文档里的说法，看它实际怎么跑。装法是官方给的脚本，我没直接管道给 shell，先拉下来读了一遍，脚本里带 SHA256 校验，做题还算规矩。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">curl -fsSL -o /tmp/kt-install.sh https://kotl.in/install.sh
</span></span></code></pre></div><p>它默认会往你的 shell 配置里追加一行 PATH，我用它自己的开关跳过了，不碰我的 zshrc：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">KOTLIN_CLI_NO_MODIFY_PATH=1 sh /tmp/kt-install.sh
</span></span></code></pre></div><p>装完是这样：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Kotlin Toolchain version 0.13.0 (abc7a5f, 2026-10-01)
</span></span><span class="line"><span class="cl">Installed Kotlin CLI to /Users/trevorwang/.local/bin/kotlin
</span></span></code></pre></div><p>然后建项目。它有一批非交互的模板，我选了 JVM 命令行那个：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">kotlin init --from-template=jvm-cli --no-git
</span></span></code></pre></div><p>0.34 秒生成完。我以为要看一堆文件，结果整个项目的配置文件就一行：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">product</span><span class="p">:</span><span class="w"> </span><span class="l">jvm/app</span><span class="w">
</span></span></span></code></pre></div><p>源码三个文件：main.kt 里一句 println，World.kt 里一个 object，test 目录里两个测试。</p>
<h2 id="构建运行测试全跑了一遍">构建、运行、测试全跑了一遍</h2>
<p>第一次构建它自己下了一个 Temurin JDK 25，下完抬头就是绿的。装、建、编、跑、测连着走一遍是这样：</p>
<p><img loading="lazy" src="/images/kotlin-toolchain/terminal.png"></p>
<p>整个过程 30.8 秒，扣掉下 JDK 的时间，剩下的很快。测试那段是红的，因为模板里故意放了一个 assertTrue(false)，函数名就叫 shouldFail。我猜它是想让你先看一眼失败的输出长什么样，顺带告诉你退出码是 1。</p>
<p>还有个命令我挺喜欢，kotlin show settings。它把每个模块生效的设置打出来，默认值后面标一行 # default：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">compose:
</span></span><span class="line"><span class="cl">  version: 1.12.1  # default
</span></span><span class="line"><span class="cl">junit: junit-5  # default
</span></span></code></pre></div><p>哪些是你自己写的，哪些是它默认给的，一眼分得清。</p>
<h2 id="三行-yaml-编四个平台">三行 YAML 编四个平台</h2>
<p>单个 JVM 不够看，我又用 kmp/lib 模板建了一个。它给我的 module.yaml 全文，加上各平台编出来的结果是这样：</p>
<p><img loading="lazy" src="/images/kotlin-toolchain/yaml-platforms.png"></p>
<p>平台专属的源码不用 Groovy 那套 source set 命名，是目录后缀：src@jvm、src@android、src@ios，里面各放一份 actual。共享的那份 expect 放在 src。</p>
<p>构建的时候还能只挑平台：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">kotlin build -p jvm -p android
</span></span></code></pre></div><p>11.3 秒，四行绿字，产物是 kt-kmp-jvm.jar 和 kt-kmp-jvm.aar。</p>
<p>这里有个我事先没想到的细节。翻它下载缓存的目录时我发现，为了编 Android，它往我的 Android SDK 里放了一个平台包，时间戳跟这次构建对得上：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">~/Library/Android/sdk/platforms/android-37.0/            11:35
</span></span><span class="line"><span class="cl">~/Library/Android/sdk/platforms/06259a9c8a-platform-37.0_r02.zip.flag
</span></span></code></pre></div><p>加上前面那个 Temurin JDK、测试用的 JUnit 运行器、iOS 要用的 Kotlin/Native，全是它自己拉下来的。对新人友好到有点过分，装完一个命令，什么都不配，JVM 和 Android 就能编。代价是第一次构建得等下载，而且它会往你的 SDK 目录里放东西。</p>
<h2 id="ios-那条腿它停在了-xcode-上">iOS 那条腿，它停在了 Xcode 上</h2>
<p>把 iOS 也加上试试：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">kotlin build -p iosArm64
</span></span><span class="line"><span class="cl">Downloading .../kotlin-native-prebuilt-2.4.20-macos-aarch64.tar.gz
</span></span><span class="line"><span class="cl">ERROR: Xcode first-launch setup could not be completed.
</span></span><span class="line"><span class="cl">       Run `sudo xcodebuild -runFirstLaunch` and try again.
</span></span><span class="line"><span class="cl">ERROR: Task &#39;xcodeEnvironment&#39; failed: Xcode environment is not ready
</span></span></code></pre></div><p>它先把 Kotlin/Native 2.4.20 下下来，然后停在这一步。这个检查是 0.13 新加的，发布说明里写的是 Check and/or provision Xcode components automatically。</p>
<p>有意思的地方在这：Kotlin/Native 本身在我这台机器上是能编的，之前我拿它直编过 iOS arm64 的 framework，9.8 秒出产物。工具链检查的是 Xcode 那套组件有没有完成首次启动安装，比编译器本身的要求更靠前，而这一步要 root，得自己跑一次带 sudo 的命令。</p>
<p>这个行为我觉得是好的。它没有编到一半丢给你一个莫名其妙的链接错误，而是停在前置检查上，把该敲的命令写给你。</p>
<h2 id="名字还没清干净的两个地方">名字还没清干净的两个地方</h2>
<p>第一处，它下载发行包的地址还是老地址，里面带着 amper 两个字，packages.jetbrains.team/maven/p/amper/amper。</p>
<p>第二处更逗，Android 那边的修复清单里有一条，说它以前会把 Android SDK 装成非规范的名字，那个名字叫 Amper-33。</p>
<p>仓库、博客、命令行、环境变量、模块布局，一路清过来，边角还没扫完。这种细节比新闻稿实在，你从里面能看出一次改名到底动了几层。</p>
<h2 id="界面那一侧多了个开关">界面那一侧，多了个开关</h2>
<p>命令行那套说完了，界面这一侧的变化同样明显。</p>
<p>插件在 Marketplace 里就叫 Kotlin Toolchain，装的版本是 262.10968.205。它的自我介绍第一句是：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Provides support for Kotlin Toolchain, JetBrains&#39; build tool for Kotlin and
</span></span><span class="line"><span class="cl">Java projects.
</span></span></code></pre></div><blockquote>
<p>译文：为 Kotlin Toolchain 提供支持，也就是 JetBrains 那套面向 Kotlin 和 Java 项目的构建工具。</p>
</blockquote>
<p><img loading="lazy" src="/images/kotlin-toolchain/plugin-marketplace.png"></p>
<p>功能列表里有三件我关心的：跑和调 JVM、Kotlin Multiplatform、native 的测试，管多模块工程和版本目录，还有 Compose Desktop 与 Compose Multiplatform 的热重载。页面顶部另有一行提示，说部分功能要 Ultimate 订阅才有。</p>
<p>真正有意思的是新建项目那个对话框。Build system 那一栏不再是 Gradle 一个选项，变成了两个并列，右边那个就是 Kotlin Toolchain。</p>
<p>它给这两条各写了一句说明，原话是这样：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Gradle — Flexible and backed by a mature plugin ecosystem
</span></span><span class="line"><span class="cl">Kotlin Toolchain — Easy to use, declarative, and AI-friendly
</span></span></code></pre></div><blockquote>
<p>译文：Gradle 灵活，背后有成熟的插件生态；Kotlin Toolchain 好用、声明式，对 AI 友好。</p>
</blockquote>
<p><img loading="lazy" src="/images/kotlin-toolchain/new-project.png"></p>
<p>AI-friendly 这个词是它自己写上去的。一家做 IDE 的公司在新建项目的默认文案里把「对 AI 友好」当卖点，放在今年不算意外，但这个位置挺说明问题。</p>
<p>勾平台那部分跟 CLI 的模板一个思路，Android、iOS、Desktop、Web、Server 五个复选框，iOS 和 Web 还能选 UI 要不要跟其他端共享。勾完 Android 加 iOS 建出来，结构是这样：</p>
<p><img loading="lazy" src="/images/kotlin-toolchain/project-structure.png"></p>
<p>根目录一份 project.yaml 列模块，androidApp、desktopApp、iosApp、shared、webApp、core、server 七个。每个模块自己一份 module.yaml，跟刚才 CLI 里那份三行的配置是同一个东西。版本号仍然放在 libs.versions.toml 里，Gradle 那套习惯它没丢，wrapper 脚本叫 kotlin 和 kotlin.bat。</p>
<p>运行配置那边也能直接用，设备栏里挑 iPhone 17 | iOS 27.0 就能跑 iosApp。</p>
<h2 id="那你要不要换">那你要不要换</h2>
<p>官方 FAQ 里有一段，直接回答为什么不去改 Gradle，我把原话贴出来：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">With the Kotlin Toolchain, we want to show you our design and get your feedback,
</span></span><span class="line"><span class="cl">as it will help us to decide which direction to take the design. At the same time,
</span></span><span class="line"><span class="cl">we are also working with the Gradle team to improve Gradle support in our IDEs
</span></span><span class="line"><span class="cl">and Gradle itself.
</span></span></code></pre></div><blockquote>
<p>译文：我们想把设计摆出来给你看，靠反馈决定往哪走。同样的时间，我们也在和 Gradle 团队一起改进 IDE 里的 Gradle 体验，以及 Gradle 本身。</p>
</blockquote>
<p>话说得客气，意思很清楚：这不是要把 Gradle 换掉，是一次同台展示。</p>
<p>迁移那件事也有意思。Maven 项目给了转换命令 kotlin tool convert-project，能自动转一部分。Gradle 项目没有工具，官方解释是 Gradle 脚本本身是任意代码，写不出确定性的转换规则。</p>
<p>那怎么办？FAQ 里有一句让我停下来的话，大意是这种事 AI agent 挺擅长，配一套技能就行。我去看了那套技能，在 Kotlin 组织底下，叫 kotlin-agent-skills，1,071 星，十个技能文件，其中两个专门讲怎么从 Gradle 迁到 Kotlin Toolchain。</p>
<p>里面规矩写得很硬：每一个第三方 Gradle 插件只有三条路，砍掉、换成原生等价物、自己重写，不存在留着继续用的选项；而你自己写的 buildSrc 那套约定插件不算插件，算配置，要变成模块模板。</p>
<p>这句话值得抄在本子上。把构建脚本当代码看，AI 能帮你做机械翻译；把构建脚本当配置看，那套隐式约定才是迁移里最贵的东西。</p>
<h2 id="收尾">收尾</h2>
<p>这周的动作概括一下：Amper 这个名字退休，它留下的东西成了 Kotlin 的官方工具链，门面是一个 kotlin 命令加一份 YAML。</p>
<p>新项目值得试，三行配置编四个平台，这套体验确实舒服。老项目先别动，它还是 Alpha，官方自己都写了不保证所有迁移场景。</p>
<p>仓库和文档在这：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">github.com/JetBrains/kotlin-toolchain
</span></span><span class="line"><span class="cl">kotlin-toolchain.org
</span></span></code></pre></div><p>最后留个问题：如果让你现在把手上的 KMP 项目从 Gradle 迁过来，你猜最先卡住你的是哪一步？</p>
]]></content:encoded></item><item><title>JetBrains 撤掉了 KMP 插件里的 Swift 支持：编译链一条没动</title><link>http://mings.in/posts/kmp-ios/</link><pubDate>Tue, 06 Oct 2026 10:44:56 +0800</pubDate><guid>http://mings.in/posts/kmp-ios/</guid><description>10 月 5 日 JetBrains 发了篇不长的公告，标题直白：KMP 插件里停掉 Swift 语言的 IDE 支持。生效版本写得很具体，IntelliJ IDEA 2026.3 和 Android Studio Rabbit 2 | 2026.2.2。</description><content:encoded><![CDATA[<h2 id="先说这条公告的字面意思">先说这条公告的字面意思</h2>
<p>10 月 5 日 JetBrains 发了篇不长的公告，标题直白：KMP 插件里停掉 Swift 语言的 IDE 支持。生效版本写得很具体，IntelliJ IDEA 2026.3 和 Android Studio Rabbit 2 | 2026.2.2。</p>
<p>撤掉的功能清单也具体，我抄一下：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">This includes editing, syntax highlighting, and navigation within Swift
</span></span><span class="line"><span class="cl">files and between Swift and Kotlin.
</span></span></code></pre></div><blockquote>
<p>译文：包括 Swift 文件里的编辑、语法高亮，以及 Swift 文件之间、Swift 与 Kotlin 之间的跳转。</p>
</blockquote>
<p>看到这几行，很多人的第一反应是 KMP 的 iOS 是不是要凉。先别急，同一篇公告的第二段就把边界划出来了。</p>
<h2 id="撤掉的是哪一层">撤掉的是哪一层</h2>
<p>原文第二段是这么写的：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Running iOS applications and debugging Kotlin code on iOS will remain fully
</span></span><span class="line"><span class="cl">supported. Kotlin/Native functionality, such as Swift Export and importing
</span></span><span class="line"><span class="cl">Swift packages into Kotlin projects, is not affected.
</span></span></code></pre></div><blockquote>
<p>译文：运行 iOS 应用、在 iOS 上调试 Kotlin 代码，仍然完整支持。Kotlin/Native 的功能，比如 Swift Export 和把 Swift 包导入 Kotlin 项目，不受影响。</p>
</blockquote>
<p>也就是说，被拿掉的是 IDE 里读改 Swift 的那一层。编译器、Native 工具链、跟 Xcode 对接的那条路都还在。</p>
<p>后半句我在这台机器上验了一遍。本机已经有 Kotlin/Native 2.4.0 的预编译包和 Xcode 26.2，我拿它把一份小代码编成 iOS 的 framework：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kotlinc-native -target ios_arm64 -produce framework -o out/shared src/shared.kt
</span></span></code></pre></div><p>九点八秒，产物是一个 Mach-O 64-bit arm64 的动态库，外加一份给 Xcode 用的头文件。这条链一步没退，撤 Swift 功能没动它。</p>
<p><img loading="lazy" src="/images/kmp-ios/ios-framework.png"></p>
<h2 id="那-ios-到底靠哪几条链跑起来">那 iOS 到底靠哪几条链跑起来</h2>
<p>把链路拆开看，顺序是这样：</p>
<ol>
<li>Kotlin/Native 把共享代码编成 Apple 能用的 framework，就是我上面那九点八秒。</li>
<li>Xcode 把这个 framework 链进 iOS 应用，Gradle 里那条 <code>embedAndSignAppleFrameworkForXcode</code> 干的就是这件事。</li>
<li>Swift 壳调 Kotlin。现在主要走 Objective-C 头文件那套互通，官方另外在推 Swift Export，还在 Alpha，目标是让 Kotlin 直接导出成 Swift 模块，省掉头文件那一层。</li>
<li>IDE 里读改 Swift 只是开发时的顺手，撤掉它，上面三层一步没动。</li>
</ol>
<p>想明白这四条，公告的轻重就出来了。</p>
<h2 id="他们为什么撤">他们为什么撤</h2>
<p>官方给的理由是使用量。他们的埋点和用户调研显示，做 KMP 项目的 iOS 开发者多数用 Xcode 写 Swift，而在 KMP 插件里干活的人越来越多用 Kotlin 和 Compose Multiplatform。</p>
<p>还有一层历史包袱：这套 Swift 工具最初是给 AppCode 开发的，而 AppCode 早就停了；后来挪到 Fleet，再挪进 KMP 插件。要让它的核心架构跟上 Swift、Xcode 和 IntelliJ 平台的持续变化，投入不小。他们权衡之后决定不做，并且写明目前不打算用别的功能替代。</p>
<p>这个取舍其实跟 KMP 这几个月的动作是一条线：他们把力气花在让 Kotlin 侧更好，而不是替 Xcode 干活。</p>
<h2 id="社区那边的进度比二手报道新">社区那边的进度，比二手报道新</h2>
<p>同一天热榜上还有另一条，方向正好相反。开源 OSM 编辑器 StreetComplete 的 iOS 版进了 TestFlight，这是它多年移植工作后的第一个可测版本。</p>
<p>这个移植从 2023 年 12 月开的追踪 issue 算起，到现在还 open 着，累计 51 条评论，维护者说全量移植大概是一人年的工作量，2024 年上半年靠 Prototype Fund 的资金全职推进，到当年 9 月完成约一半。</p>
<p>二手报道里有个说法已经过期了。它写 Compose Multiplatform 的 MapLibre 地图库还不存在，是移植的卡点。我去读了 StreetComplete 的构建文件，现在写的是这行：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;org.maplibre.compose:maplibre-compose:0.19.0&#34;</span><span class="p">)</span>
</span></span></code></pre></div><p>这个库在 Maven Central 上从 0.11.0 一路发到了 0.19.0，那个缺口已经补上。</p>
<p>代价也得说清楚。原型基金到期了，维护者在 issue 里写后续资金不乐观，OpenStreetMap 没钱，NLNet 的项目也在收缩，众筹这几家赞助没涨。已知问题还挂着：iOS 15.x 上地图不工作，表单只能用手势返回，bug 还多。</p>
<h2 id="我数了一遍它的仓库">我数了一遍它的仓库</h2>
<p>顺手把这个仓库按源集数了一遍，比看新闻有说服力：</p>
<ul>
<li>commonMain：1,281 个 Kotlin 文件，3.6MB</li>
<li>commonTest：287 个文件，1.3MB</li>
<li>androidMain：34 个文件，43KB</li>
<li>iosMain：33 个文件，42KB</li>
</ul>
<p>也就是说，两端各补四十多 KB 的壳，就把同一个 app 接到了 iOS 和 Android 上。iOS 侧那 33 个文件里，最大的一块是 IosModule.kt，六千字节，干的是依赖注入装配，剩下的都是网络、同步、上传、周期清理这类平台专属件。</p>
<p><img loading="lazy" src="/images/kmp-ios/source-sets.png"></p>
<p>看清这个比例，公告的影响面也就清楚了：撤掉的功能，作用范围就是这四十二 KB 的壳，和共享的三点六 MB 无关。</p>
<h2 id="这两件事其实是一件事">这两件事其实是一件事</h2>
<p>JetBrains 的选择是「Kotlin 侧我做好，Swift 侧交给 Xcode」。StreetComplete 的做法正好配合这个分工：共享逻辑和 UI 都写在 Kotlin 和 Compose 里，Swift 那边只留一层壳。</p>
<p>所以判断很简单，问自己几句：</p>
<ul>
<li>你项目里的 Swift 平时在哪儿写。本来就是 Xcode 开着，这条公告对你几乎没影响。</li>
<li>你要是在 Android Studio 里统一读改 Swift，那从现在起要改流程，或者用旧版本。</li>
<li>别把 IDE 支持和编译链支持混着看。前者撤了，后者没动。</li>
<li>技术路线通，不等于项目能到终点。StreetComplete 这一条提醒的是钱和人。</li>
</ul>
<h2 id="手上这个版本还能用多久">手上这个版本还能用多久</h2>
<p>门槛是 Android Studio Rabbit 2 | 2026.2.2 和 IntelliJ IDEA 2026.3，本机装的是 2026.2，所以你现在手里那些 Swift 功能还在，只是往后不再更新。急着用就晚点再升，不急就随它去，反正 Swift 真正的家一直是 Xcode。</p>
<p>你的 KMP 项目里，iOS 那侧的 Swift 是在哪儿写的？评论区聊聊。</p>
<p>参考资料：</p>
<ul>
<li><a href="https://blog.jetbrains.com/kotlin/2026/10/discontinuing-swift-language-ide-support-in-the-kotlin-multiplatform-plugin/">https://blog.jetbrains.com/kotlin/2026/10/discontinuing-swift-language-ide-support-in-the-kotlin-multiplatform-plugin/</a></li>
<li><a href="https://github.com/streetcomplete/StreetComplete/issues/5421">https://github.com/streetcomplete/StreetComplete/issues/5421</a></li>
</ul>
]]></content:encoded></item><item><title>Kotlin Multiplatform 编译"看心情"这事，官方认了</title><link>http://mings.in/posts/kmp-sepcomp/</link><pubDate>Sat, 03 Oct 2026 22:24:18 +0800</pubDate><guid>http://mings.in/posts/kmp-sepcomp/</guid><description>你写 Kotlin Multiplatform 的时候大概遇到过：IDE 里那行代码红着，划红线说找不到符号，你犹豫半天不敢提交，结果一构建，绿的，跑起来还对。</description><content:encoded><![CDATA[<h2 id="先说那个瞬间">先说那个瞬间</h2>
<p>你写 Kotlin Multiplatform 的时候大概遇到过：IDE 里那行代码红着，划红线说找不到符号，你犹豫半天不敢提交，结果一构建，绿的，跑起来还对。</p>
<p>以前我们把这事归给 IDE 抽风、索引没建好、清一下缓存。9 月 28 日 JetBrains 发了篇博客，开头第一句就直白得很：</p>
<blockquote>
<p>The current compilation approach to Kotlin Multiplatform projects works, but sometimes can lead to unexpected or hard-to-predict behavior.</p>
</blockquote>
<p>翻译成人话：现在这套编译方式能跑，但有时候会给你难以预测的行为。然后它列了两个例子，都是我们遇到过但说不清原因的：</p>
<blockquote>
<p>The IDE analysis disagrees with the compiler on an overload, type inference, or whether the code should compile at all – with the IDE being stricter than the compiler.</p>
</blockquote>
<blockquote>
<p>Your <code>commonTest</code> code can unexpectedly call something from a platform source set, breaking your assumptions.</p>
</blockquote>
<p>一句是 IDE 比编译器严格，一句是 commonTest 会悄悄调到平台源集的东西。下面我先把机制讲清，再给我实测的结果，因为官方那个例子里，修法的代价只给你看了一半。</p>
<h2 id="为什么会有这种分歧">为什么会有这种分歧</h2>
<p>关键在「common 代码编译时，是拿什么去解析依赖的」。</p>
<p>KMP 模块里，commonMain 不是独立编出来一个产物就完事。编 JVM target 的时候，编译器把 commonMain 和 jvmMain 一起，对着依赖的平台产物（JVM 上是 jar）来编。于是 commonMain 里的调用，有机会隐式落到依赖方 jvmMain 的声明上。</p>
<p>而 IDE 的代码分析不这么干。它假设 common 代码只能看见依赖的 common 声明，也就是 metadata KLIB 里列出来的那部分。jvmMain 的声明不在里面，所以 IDE 报红。</p>
<p>两边对同一行代码有两套理解，谁都不算错，但你会被夹在中间。官方还给了一个更难受的版本：重载选择不一致，IDE 跳转到 commonMain 的重载，运行时打印的却是平台那一个。</p>
<p>以及类型推断分歧，common 侧推出来是 Any，JVM 编译直接报 Unresolved reference。这条反过来也成立：同一个例子开着开关，common 阶段就把 currentEvent() 的返回类型定成 Event，<code>.name</code> 有依据，JVM 编译直接通过。所以这个特性两头都会发生，既会让侥幸能过的代码报错，也会把原来报错的代码修好。</p>
<p>上面这两条路径的差别，画出来是这样：</p>
<p><img loading="lazy" src="/images/kmp-sepcomp/lookup-paths.png"></p>
<h2 id="新方案就是一个开关">新方案就是一个开关</h2>
<p>Kotlin 2.5.0-Beta1 引入了一个可选的「分离编译」（separate compilation），让编译器在编 common 源集时也走 metadata KLIB 那套严格解析，跟 IDE 对齐。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="cl"><span class="c1"># gradle.properties</span>
</span></span><span class="line"><span class="cl"><span class="na">kotlin.kmp.separateCompilation</span><span class="o">=</span><span class="s">true</span>
</span></span></code></pre></div><p>官方标了 Experimental，默认关闭。附带一个好处：它让 common 源集的增量编译成为可能，这是以前做不到的。</p>
<p>官方给的例子读起来很轻松，原来 IDE 报红、编译却能过的代码，现在编译也报错；把 expect/actual 显式连起来就好了。</p>
<h2 id="实测一它真的会拦下来">实测一：它真的会拦下来</h2>
<p>我在本机搭了个最小的 KMP 工程（Kotlin 2.5.0-Beta1，两个 target：jvm 和 js），两个模块：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">lib/  src/commonMain/Marker.kt      # 只有接口
</span></span><span class="line"><span class="cl">      src/jvmMain/Foo.kt            # class Foo，只声明在平台源集
</span></span><span class="line"><span class="cl">app/  src/commonMain/Main.kt        # 直接 new 一个 lib 的 Foo
</span></span></code></pre></div><p>app 依赖 lib，app 的 commonMain 里 new 一个只存在于 lib/jvmMain 的类。</p>
<p>不打开开关：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&gt; Task :app:compileKotlinJvm
</span></span><span class="line"><span class="cl">BUILD SUCCESSFUL in 30s
</span></span></code></pre></div><p>打开开关，同一份代码：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">e: app/src/commonMain/kotlin/Main.kt:3:12 Unresolved reference &#39;Foo&#39;.
</span></span><span class="line"><span class="cl">e: app/src/commonMain/kotlin/Main.kt:6:13 Unresolved reference &#39;Foo&#39;.
</span></span><span class="line"><span class="cl">BUILD FAILED in 2s
</span></span></code></pre></div><p>官方说的没错，而且拦得很干脆，不是警告。</p>
<p>commonTest 那条也一样。我在 app 的 jvmMain 放了个 PlatformOnly 类，让 commonTest 去 new 它：关着开关 BUILD SUCCESSFUL，打开就是 <code>Unresolved reference 'PlatformOnly'</code>。</p>
<h2 id="实测二修法的代价官方例子没给你看">实测二：修法的代价，官方例子没给你看</h2>
<p>官方的修法是显式 expect/actual。lib 的 commonMain 声明 expect，平台源集给 actual。我照着改，JVM 端确实过了：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="c1">// lib/commonMain
</span></span></span><span class="line"><span class="cl"><span class="k">expect</span> <span class="k">class</span> <span class="nc">Foo</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// lib/jvmMain
</span></span></span><span class="line"><span class="cl"><span class="k">actual</span> <span class="k">class</span> <span class="nc">Foo</span> <span class="k">actual</span> <span class="k">constructor</span><span class="p">()</span>
</span></span></code></pre></div><p>开着开关，<code>:app:compileKotlinJvm</code> 是 BUILD SUCCESSFUL in 1s。这一半官方说得对。</p>
<p>然后我去编同一个模块的 JS 端：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">e: lib/commonMain/kotlin/Marker.kt:5:1 The &#39;expect&#39; declaration &#39;lib.Foo&#39;
</span></span><span class="line"><span class="cl">   has no &#39;actual&#39; declaration in module &#39;&lt;commonMain&gt; for JS&#39;.
</span></span><span class="line"><span class="cl">BUILD FAILED in 2s
</span></span></code></pre></div><p>expect/actual 是成对契约，每个 target 都得补一个 actual。博客里的例子只演了 JVM 那一侧，你多平台项目里一开，有几个 target 就是几份 actual。</p>
<h2 id="官方没提的一件事版本号不是门槛">官方没提的一件事：版本号不是门槛</h2>
<p>博客说这个特性是 Kotlin 2.5.0-Beta1 引入的。我拿同一份「坏代码」在 Kotlin 2.4.20（当前稳定版）上又试了一遍：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># 不加这个属性
</span></span><span class="line"><span class="cl">BUILD SUCCESSFUL in 704ms
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># 加上 kotlin.kmp.separateCompilation=true
</span></span><span class="line"><span class="cl">e: app/src/commonMain/kotlin/Main.kt:3:12 Unresolved reference &#39;Foo&#39;.
</span></span><span class="line"><span class="cl">BUILD FAILED in 11s
</span></span></code></pre></div><p>属性在 2.4.20 上照样切换行为，我核过这份工程确实解析到 kotlin-gradle-plugin:2.4.20。</p>
<p>所以这个开关不是 2.5 才存在的东西，只是官方到 2.5.0-Beta1 才把它当特性讲出来。反过来看：别把版本号当门槛，也别把「我 2.4 上试了没事」当结论，两边都不牢靠，得在你自己的工程上跑一遍才算数。</p>
<h2 id="一张速查表">一张速查表</h2>
<p>把上面这些现象和官方那节 Known issues 收成一张表，撞上对应情形时对着看：</p>
<table>
	<thead>
			<tr>
					<th>旧代码的情况</th>
					<th>新方案下的结果</th>
					<th>怎么办</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>commonMain 偷用了依赖库 jvmMain / nativeMain 的声明</td>
					<td>编译失败，报 Unresolved reference</td>
					<td>让库把这项能力声明成 expect/actual，或者把调用下沉到平台源集</td>
			</tr>
			<tr>
					<td>commonTest 偷用了 jvmMain 等平台代码</td>
					<td>测试编译失败</td>
					<td>用 expect/actual 暴露公共接口，或者把测试搬到 jvmTest</td>
			</tr>
			<tr>
					<td>common 与平台侧存在同名重载</td>
					<td>选中的重载可能变（我实测：从 platform 变成 common）</td>
					<td>检查这类调用以前实际落到了哪个重载</td>
			</tr>
			<tr>
					<td>common 的类型推断受平台 actual 额外超类型影响</td>
					<td>推断结果被固定住，可能顺手修掉旧报错，也可能暴露依赖旧行为的代码</td>
					<td>给 common 侧显式声明类型边界</td>
			</tr>
	</tbody>
</table>
<h2 id="你的项目该不该开">你的项目该不该开</h2>
<p>结合官方那节 Known issues，我的判断清单是这样：</p>
<ul>
<li>依赖的库有没有正经发 metadata KLIB。新方案让它从「最好有」变成「必须有」，作者偷懒的库一开就报错，而错不在你。</li>
<li>工程有多依赖「common 调平台」这种写法。我手上那个 KMP 项目平台差异走接口、几乎不写 expect/actual，开之前得先把这类调用扫一遍，否则会一次性冒出一堆 Unresolved reference。</li>
<li>用不用 cinterop。官方点明了这两处已知问题（KT-88178、KT-41509），原生 C 互操作的项目要谨慎。</li>
<li>只有一个 target 的模块不受影响，官方说得很直白。</li>
<li>它还是 Experimental，默认关着。当预览用，别当升级项。</li>
</ul>
<h2 id="顺带一句-ios-那条战线">顺带一句 iOS 那条战线</h2>
<p>同一周 JetBrains 还发了篇 TeamCity 的设计复盘，讲怎么把 Kotlin Multiplatform 的 iOS 打包做成引导式流程：装个插件，push 之后 IDE 弹一个 Configure CI 的提示，跟着走就能从首次 push 到跑测试、签名、发 TestFlight，最后接上 GitHub 每次推送自动跑。</p>
<p>中间生成四个文件（<code>teamcity.yaml</code> 加 Fastfile、Appfile、Gemfile），先留在你本地不提交，第一次构建跑在托管的 macOS agent 上，验过再让你自己提交。</p>
<p>这块我没实测，本机没有 TeamCity 账号，那段内容全部来自官方那篇设计复盘和它指向的官方教程。</p>
<h2 id="最后">最后</h2>
<p>回到开头那个 IDE 报红、编译能过的瞬间。它过去既不是 IDE 的 bug，也不是你的错觉，是两套解析规则长期没对齐。官方现在的做法是把编译器往 IDE 那边拉：更严格、更可预测，代价是以前侥幸能过的代码，要先补成显式的。</p>
<p>我下一步想在手上那个 KMP 项目里开一次，把冒出来的 Unresolved reference 全列出来，看看这个项目到底欠了多少 expect/actual。</p>
<p>你的 Kotlin Multiplatform 项目开过这个开关吗？开完报了多少错？评论区聊聊。</p>
<p>参考资料：</p>
<ul>
<li>A More Reliable Compilation Scheme for Kotlin Multiplatform Modules：https://blog.jetbrains.com/kotlin/2026/09/a-more-reliable-compilation-scheme-for-kotlin-multiplatform-modules/</li>
<li>Designing the Kotlin Multiplatform and TeamCity Integration：https://blog.jetbrains.com/teamcity/2026/09/designing-the-kotlin-multiplatform-and-teamcity-integration/</li>
<li>官方 iOS CI/CD 教程：https://kotlinlang.org/docs/multiplatform/ios-ci-cd-teamcity.html</li>
</ul>
]]></content:encoded></item><item><title>A2UI 实测：agent 画界面，Jetpack Compose 渲染</title><link>http://mings.in/posts/a2ui-compose/</link><pubDate>Wed, 30 Sep 2026 09:15:53 +0800</pubDate><guid>http://mings.in/posts/a2ui-compose/</guid><description>同一份 JSON，同一个 App，同一次运行。</description><content:encoded><![CDATA[<h2 id="先看两张截图">先看两张截图</h2>
<p>同一份 JSON，同一个 App，同一次运行。</p>
<p>第一张，表单是满的：访客 Ada Lovelace、日期 Oct 1, 2026、时间 7:30 PM、人数 2，下面两个按钮 Cancel 和 Confirm booking，全套 Material 3 的原生控件。</p>
<p><img alt="字段填充正常" loading="lazy" src="/images/a2ui-compose/fields-filled.png"></p>
<p>第二张，同一个表单，字段全空。不光空，两个日期时间控件的标签都没了，只剩两个灰色圆角空框。</p>
<p><img alt="同一份 JSON，字段全空" loading="lazy" src="/images/a2ui-compose/fields-empty.png"></p>
<p>差别只在于我什么时候把 <code>updateDataModel</code> 这条消息发进去。前一秒和组件一起发，字段就丢；晚一秒单独发，字段就落上。这条我后面细说，它是我这次踩到的最值钱的一个坑。</p>
<h2 id="a2ui-是什么">A2UI 是什么</h2>
<p>一句话，官方原话：</p>
<blockquote>
<p>A2UI (Agent to UI) is a declarative UI protocol for agent-driven interfaces. AI agents generate rich, interactive UIs that render natively across platforms (web, mobile, desktop) without executing arbitrary code.</p>
</blockquote>
<p>agent 不吐 HTML 或者 JavaScript，它吐一段声明式 JSON 描述界面长什么样，客户端拿自己的原生组件把它渲染出来。官方给的对照场景特别典型：</p>
<blockquote>
<p>User: &ldquo;Book a table for 2 tomorrow at 7pm&rdquo;
Agent: &ldquo;Okay, for what day?&rdquo;
User: &ldquo;Tomorrow&rdquo;
Agent: &ldquo;What time?&rdquo;</p>
</blockquote>
<p>来回三轮只为填一个表单。换成 agent 直接生成一个带日期选择器、时间选择器和提交按钮的表单，用户是在操作界面，不是在跟它说话。</p>
<p>这套协议里我觉得最聪明的设计是 catalog（组件目录）。它不是让 agent 自由发明界面，而是先由客户端声明一份契约：我可以用这些组件、这些属性、这些本地函数。agent 只能在契约里挑选和组合。协议原文写得很直白：</p>
<blockquote>
<p>Rather than having an agent generate arbitrary UI code or invent unregistered components, the catalog acts as a contract that defines the specific UI elements, properties, and functions available to the agent.</p>
</blockquote>
<p>加上「传的是数据不是代码」这条，安全上的账就好算了：没有 iframe，也没有任意代码执行。</p>
<h2 id="compose-这边的官方渲染器">Compose 这边的官方渲染器</h2>
<p>9 月 23 日 AndroidX 发了 Jetpack Compose 的 A2UI 渲染器，1.0.0-alpha01。我在 Google Maven 上逐个确认过这些 artifact 都存在：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;androidx.a2ui.compose:compose-runtime:1.0.0-alpha01&#34;</span><span class="p">)</span>  <span class="c1">// 基于 Compose Snapshot 状态做细粒度重组
</span></span></span><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;androidx.a2ui.compose:compose-ui:1.0.0-alpha01&#34;</span><span class="p">)</span>       <span class="c1">// 组件与 catalog 定义
</span></span></span><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;androidx.a2ui:a2ui-model:1.0.0-alpha01&#34;</span><span class="p">)</span>               <span class="c1">// 协议数据层
</span></span></span><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;androidx.a2ui:a2ui-engine:1.0.0-alpha01&#34;</span><span class="p">)</span>              <span class="c1">// 解析、schema 校验
</span></span></span><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;androidx.compose.material3:material3-a2ui:1.0.0-alpha01&#34;</span><span class="p">)</span>  <span class="c1">// Basic Catalog 的 Material 3 实现
</span></span></span></code></pre></div><p><code>material3-a2ui</code> 就是成品：Basic Catalog 的 Material 3 实现，一行就能起。你要给它补的是图像、视频、音频播放器，以及打开 URL 和处理本地化文案的回调。官方故意不带 Coil 和 Media3：</p>
<blockquote>
<p>The A2UI libraries intentionally don&rsquo;t bundle external media and networking dependencies, such as Coil, Glide, or Media3. Instead, you provide your own renderers.</p>
</blockquote>
<p>还有一个 KMP 开发者会立刻关心的问题。我拆了 <code>compose-runtime</code> 的 Gradle module 元数据：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">- releaseVariantReleaseApiPublication     type=aar
</span></span><span class="line"><span class="cl">- releaseVariantReleaseRuntimePublication type=aar
</span></span></code></pre></div><p>只有 aar，没有 klib 也没有 JVM 变体。这一版是 Android 独占，Compose Multiplatform 现在用不上。</p>
<h2 id="最小接线">最小接线</h2>
<p>数据层就两个对象：一个 parser，一个 processor。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">private</span> <span class="k">val</span> <span class="py">parser</span> <span class="p">=</span> <span class="n">A2uiMessageParser</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">private</span> <span class="k">val</span> <span class="py">catalog</span> <span class="p">=</span> <span class="n">materialA2uiBasicCatalogV1</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">image</span> <span class="p">=</span> <span class="n">MaterialA2uiBasicCatalogV1Defaults</span><span class="p">.</span><span class="n">image</span> <span class="p">{</span> <span class="n">url</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">modifier</span><span class="p">,</span> <span class="n">_</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="n">Placeholder</span><span class="p">(</span><span class="s2">&#34;image&#34;</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">modifier</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">urlOpener</span> <span class="p">=</span> <span class="p">{</span> <span class="n">url</span> <span class="o">-&gt;</span> <span class="nc">Log</span><span class="p">.</span><span class="n">i</span><span class="p">(</span><span class="n">TAG</span><span class="p">,</span> <span class="s2">&#34;open </span><span class="si">$url</span><span class="s2">&#34;</span><span class="p">)</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">messageFormatter</span> <span class="p">=</span> <span class="p">{</span> <span class="n">pattern</span><span class="p">,</span> <span class="n">locale</span><span class="p">,</span> <span class="n">args</span> <span class="o">-&gt;</span> <span class="cm">/* MessageFormat */</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">localeProvider</span> <span class="p">=</span> <span class="n">A2uiLocaleProvider</span><span class="p">.</span><span class="n">Default</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">private</span> <span class="k">val</span> <span class="py">processor</span> <span class="p">=</span> <span class="n">A2uiMessageProcessor</span><span class="p">(</span><span class="n">catalogs</span> <span class="p">=</span> <span class="n">listOf</span><span class="p">(</span><span class="n">catalog</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">surfaces</span> <span class="p">=</span> <span class="n">processor</span><span class="p">.</span><span class="n">activeSurfaces</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">init</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">viewModelScope</span><span class="p">.</span><span class="n">launch</span><span class="p">(</span><span class="nc">Dispatchers</span><span class="p">.</span><span class="n">Default</span><span class="p">)</span> <span class="p">{</span> <span class="n">processor</span><span class="p">.</span><span class="n">collectMessages</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">viewModelScope</span><span class="p">.</span><span class="n">launch</span><span class="p">(</span><span class="nc">Dispatchers</span><span class="p">.</span><span class="n">Default</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">processor</span><span class="p">.</span><span class="n">outboundEvents</span><span class="p">.</span><span class="n">collect</span> <span class="p">{</span> <span class="nc">Log</span><span class="p">.</span><span class="n">e</span><span class="p">(</span><span class="n">TAG</span><span class="p">,</span> <span class="s2">&#34;OUTBOUND: </span><span class="si">$it</span><span class="s2">&#34;</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>界面侧就是一遍遍历，把每个 surface 交给 <code>A2uiSurface</code>：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">surfaces</span><span class="p">.</span><span class="n">forEach</span> <span class="p">{</span> <span class="n">surface</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span><span class="p">(</span><span class="n">surface</span><span class="p">.</span><span class="n">id</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">A2uiSurface</span><span class="p">(</span><span class="n">surfaceModel</span> <span class="p">=</span> <span class="n">surface</span><span class="p">,</span> <span class="n">modifier</span> <span class="p">=</span> <span class="nc">Modifier</span><span class="p">.</span><span class="n">fillMaxWidth</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>喂进去的是三段协议消息，<code>createSurface</code> 开画布、<code>updateComponents</code> 描述组件树、<code>updateDataModel</code> 给绑定值：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span> <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;v0.9&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;createSurface&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;surfaceId&#34;</span><span class="p">:</span> <span class="s2">&#34;booking&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                     <span class="nt">&#34;catalogId&#34;</span><span class="p">:</span> <span class="s2">&#34;https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json&#34;</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">{</span> <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;v0.9&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;updateComponents&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;surfaceId&#34;</span><span class="p">:</span> <span class="s2">&#34;booking&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;components&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;root&#34;</span><span class="p">,</span> <span class="nt">&#34;component&#34;</span><span class="p">:</span> <span class="s2">&#34;Column&#34;</span><span class="p">,</span> <span class="nt">&#34;children&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;title&#34;</span><span class="p">,</span> <span class="s2">&#34;nameField&#34;</span><span class="p">,</span> <span class="s2">&#34;actions&#34;</span><span class="p">]</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;title&#34;</span><span class="p">,</span> <span class="nt">&#34;component&#34;</span><span class="p">:</span> <span class="s2">&#34;Text&#34;</span><span class="p">,</span> <span class="nt">&#34;text&#34;</span><span class="p">:</span> <span class="s2">&#34;Book a Table&#34;</span><span class="p">,</span> <span class="nt">&#34;variant&#34;</span><span class="p">:</span> <span class="s2">&#34;h1&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;nameField&#34;</span><span class="p">,</span> <span class="nt">&#34;component&#34;</span><span class="p">:</span> <span class="s2">&#34;TextField&#34;</span><span class="p">,</span> <span class="nt">&#34;label&#34;</span><span class="p">:</span> <span class="s2">&#34;Guest name&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;value&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;path&#34;</span><span class="p">:</span> <span class="s2">&#34;/booking/name&#34;</span> <span class="p">},</span> <span class="nt">&#34;variant&#34;</span><span class="p">:</span> <span class="s2">&#34;shortText&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">]</span> <span class="p">}</span> <span class="p">}</span>
</span></span></code></pre></div><p>注意组件属性里的 <code>value</code> 不是值本身，而是一个 <code>path</code>，指向数据模型。这也是为什么第三段消息发不发得进去，决定你的表单是满的还是空的。</p>
<h2 id="跑起来之后坑一个接一个">跑起来之后，坑一个接一个</h2>
<p>下面每一条都是我真撞上、并且找到原因或绕法的，官方那两页文档一条都没写。</p>
<p>一、<code>updateDataModel</code> 跟 <code>updateComponents</code> 同一批发，会被静默丢弃。 这是最坑的一条。surface 正常出现，没有任何报错，所有用 <code>path</code> 绑定的属性一律空着。我把同样的字节晚一秒重发一次，字段立刻填上。logcat 里三条消息全是 parsed OK，surface 也变成了 1 个，但值就是没落：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">parsed OK -&gt; A2uiCreateSurfaceMessage surfaceId=booking
</span></span><span class="line"><span class="cl">parsed OK -&gt; A2uiUpdateComponentsMessage surfaceId=booking
</span></span><span class="line"><span class="cl">parsed OK -&gt; A2uiUpdateDataModelMessage surfaceId=booking
</span></span><span class="line"><span class="cl">activeSurfaces.size=1 ids=[booking]
</span></span></code></pre></div><p>所以我最终的写法是：先发 <code>createSurface</code> 和 <code>updateComponents</code>，等 surface 出现之后再推数据模型。这刚好也是流式 agent 的真实顺序，算歪打正着。</p>
<p>二、catalogId 是 v0_9，不是 v0_9_1。 文档站上那段「stable」示例写的是 <code>version: v0.9.1</code> 和 <code>.../v0_9_1/...</code>。库里的真实常量是 <code>protocol = v0.9</code> 和 <code>.../v0_9/catalogs/basic/catalog.json</code>。parser 两种版本号都收，但 <code>createSurface.catalogId</code> 必须跟你注册的那个 catalog 的 id 完全一致，对不上 surface 就永远不出现，同样不报错。我的做法是干脆不从文档抄字符串，直接从库的常量里取。</p>
<p>三、<code>processInput</code> 不是 <code>A2uiMessageProcessor</code> 的方法。 它是 <code>androidx.a2ui.model.processor</code> 包里的顶层扩展函数，必须 <code>import androidx.a2ui.model.processor.processInput</code>，照文档那行直接抄会编译不过。</p>
<p>四、失败是静默的。 <code>processInput</code> 内部会把 <code>A2uiException</code> 吞掉，转成 <code>processor.processError(...)</code>，只从 <code>outboundEvents</code> 发出去。你要是没收集这个流（文档里收集了，但没说漏掉的代价），协议错误和解析失败就完全看不到，屏幕上只有一个白框。调试这个库的第一件事，是把 <code>outboundEvents</code> 接上。</p>
<p>五、<code>material3-a2ui:1.0.0-alpha01</code> 跟当前稳定版 Compose BOM 不兼容。 它的 POM 硬要 <code>androidx.compose.*</code> 1.13.0-alpha03，material3 还卡了个 <code>[1.5.0-alpha28]</code> 的严格区间。用稳定 BOM 解析出来的是 1.12.x，装不上。最后我把 BOM 摘掉，手动钉版本。</p>
<p>六、compileSdk 37 minor 1 的写法没文档。 <code>compileSdk = 37</code> 找不到已经装好的 android-37.1。能用的写法是：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">compileSdk</span> <span class="p">{</span> <span class="n">version</span> <span class="p">=</span> <span class="n">release</span><span class="p">(</span><span class="mi">37</span><span class="p">)</span> <span class="p">{</span> <span class="n">minorApiLevel</span> <span class="p">=</span> <span class="mi">1</span> <span class="p">}</span> <span class="p">}</span>
</span></span></code></pre></div><p>七、surface 的根组件 id 必须叫 <code>root</code>。 它是个私有常量，写成别的名字，你的 Column 就不是根。</p>
<p>八、枚举取值全靠猜。 这些值写错就是 schema 失败，而文档里没有：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Text.variant           h1 h2 h3 h4 h5 caption body
</span></span><span class="line"><span class="cl">Button.variant         primary secondary borderless
</span></span><span class="line"><span class="cl">TextField.variant      shortText longText number obscured
</span></span></code></pre></div><p>我的办法是 <code>javap</code> 拆 AAR 里的枚举，一个个读出来。</p>
<p>另外还有一条跟 A2UI 无关但绕不开的：AGP 9.x 下再挂 <code>org.jetbrains.kotlin.android</code> 会被直接拒掉，官方说 AGP 9.0 起 Kotlin 支持已经内置，只需要保留 compose 插件。</p>
<h2 id="我的判断">我的判断</h2>
<p>能跑通，而且渲染出来的确实是原生 Material 3，不是套壳网页。但 alpha 阶段的文档跟不上代码：真实的 catalogId、<code>processInput</code> 的形态、消息时序、版本钉法，全得自己拆包看。这套东西现在适合做概念验证，别急着往生产里放。</p>
<p>KMP 侧要再等等。只有 aar 这一件事，就决定了 Compose Multiplatform 现在只能用 Android 那一头。</p>
<p>我下一步想试的是把 outbound 那条路走通：用户点了 Confirm booking，事件回到 agent，agent 根据结果再发一条 <code>updateComponents</code> 改界面。这条链路我只接了线，没真按过按钮，所以这一篇里不敢说它成不成。</p>
<p>你会让你的 AI Chat 客户端由 agent 直接生成界面吗？还是更愿意自己写死界面、只让 agent 填内容？评论区聊聊。</p>
<p>参考资料：</p>
<ul>
<li>A2UI 协议官网：https://a2ui.org/</li>
<li>Jetpack Compose 的 A2UI 渲染器（官方文档）：https://developer.android.com/develop/ui/compose/agentic</li>
<li>自定义 catalog：https://developer.android.com/develop/ui/compose/agentic/manage-catalogs</li>
<li>A2UI Compose 版本页：https://developer.android.com/jetpack/androidx/releases/a2ui-compose</li>
</ul>
<p>验证边界：两张截图、三段消息的解析日志、catalogId 与协议版本常量、消息时序的 A/B 对照、构建与安装，都在本机跑真机模拟器得到（Android 模拟器 API 36.1，APK 13,471,211 字节，构建 BUILD SUCCESSFUL）。组件属性名与枚举取值来自 <code>javap</code> 拆包。按钮点击回传 agent 那条链路没有实测；图像/视频/音频用的是占位渲染器，没有接真实加载库。</p>
]]></content:encoded></item><item><title>行级 localState 撑不住嵌套编辑：KMP + SQLDelight + Supabase 离线同步返工记</title><link>http://mings.in/posts/kmp-offline-sync/</link><pubDate>Mon, 28 Sep 2026 10:11:44 +0800</pubDate><guid>http://mings.in/posts/kmp-offline-sync/</guid><description>用户在地铁里改一份清单：新建两层、给其中一层改名、把不要的那一层删掉。整个过程离线，回到地面才上传。</description><content:encoded><![CDATA[<h2 id="一次保存里同时新建修改和删除">一次保存里，同时新建、修改和删除</h2>
<p>用户在地铁里改一份清单：新建两层、给其中一层改名、把不要的那一层删掉。整个过程离线，回到地面才上传。</p>
<p>你希望数据到服务端时是一笔事务。可如果客户端是按行标记同步状态的，这一笔就会被拆成几笔各自为政的请求，其中任何一笔失败，账号里就留下半截数据。</p>
<p>Nitivakya 的作者 premi jit chowdhury 最近把这次返工写成了文章。这个 App 用 KMP + SQLDelight + Supabase，原本只是每日一句古语，靠一张 favoriteEntity 上的 localState 字段（DIRTY / SYNCED / DELETED）就活得挺好。直到他加了 Actions，结构变成 Action 到 Action Levels 再到 Action Level Items 的嵌套树。</p>
<blockquote>
<p>A row-level flag tracks final row state, not multi-entity mutation intent.</p>
</blockquote>
<p>他的原话是，行级标记记录的是这一行的最终状态，不是多实体变更的意图。这句话是整个返工的起点。</p>
<h2 id="三处具体的塌方">三处具体的塌方</h2>
<p>他在帖子里列了三条失败，我先照抄再消化。</p>
<blockquote>
<p>2 separate table REST calls caused foreign-key 500 errors over shaky connections.
Full-snapshot uploads risked overwriting server-calculated streak progress.
Row-level flags couldn&rsquo;t express a simultaneous &ldquo;Create Level A + Update Level B + Delete Level C&rdquo; edit.</p>
</blockquote>
<p>子表先到、父表后到，服务端直接回外键约束错误，子行永久卡在 DIRTY。绕开分散请求、改成整棵快照上传，又会把服务端自己算的连续打卡记录覆盖掉。第三条最难绕：一个保存手势里的增删改混在一起，行标记根本表达不了。</p>
<p>我认同的判断是，问题不在于状态标错了，而在于行级标记这个粒度天然承载不了「这次编辑到底发生了什么」。</p>
<h2 id="他的新架构本地态给-uioutbox-管上传">他的新架构：本地态给 UI，outbox 管上传</h2>
<p>改完之后的形态是两层分开。本地实体表保留 localState，只用来给 Compose 渲染状态点；同步交给一张汇总的 outbox 表，里面存稀疏 JSON DTO，只带这次真正改动的字段。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">syncQueueEntity</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">id</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="n">AUTOINCREMENT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">userId</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">type</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">operation</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">entityId</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">status</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="s1">&#39;PENDING&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">createdAt</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">payload</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">UNIQUE</span><span class="p">(</span><span class="n">userId</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">entityId</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>那个唯一约束是关键，同一实体的多次编辑被收敛到一行。Kotlin 侧的 withSync 在同一个数据库事务里做三件事：执行本地写入、把新 payload 深合并进队列里的旧 payload、碰上先建后删就把队列行直接删掉。</p>
<p>入队用 ON CONFLICT 加一层状态机守卫，避免把「离线新建」误标成「更新」：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">syncQueueEntity</span><span class="w"> </span><span class="p">(</span><span class="n">userId</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="k">operation</span><span class="p">,</span><span class="w"> </span><span class="n">entityId</span><span class="p">,</span><span class="w"> </span><span class="n">createdAt</span><span class="p">,</span><span class="w"> </span><span class="n">payload</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="o">?</span><span class="p">,</span><span class="w"> </span><span class="o">?</span><span class="p">,</span><span class="w"> </span><span class="o">?</span><span class="p">,</span><span class="w"> </span><span class="o">?</span><span class="p">,</span><span class="w"> </span><span class="o">?</span><span class="p">,</span><span class="w"> </span><span class="o">?</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">ON</span><span class="w"> </span><span class="n">CONFLICT</span><span class="p">(</span><span class="n">userId</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">entityId</span><span class="p">)</span><span class="w"> </span><span class="k">DO</span><span class="w"> </span><span class="k">UPDATE</span><span class="w"> </span><span class="k">SET</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">operation</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">CASE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="k">WHEN</span><span class="w"> </span><span class="n">syncQueueEntity</span><span class="p">.</span><span class="k">operation</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;INSERT&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">excluded</span><span class="p">.</span><span class="k">operation</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;UPDATE&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;INSERT&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="k">ELSE</span><span class="w"> </span><span class="n">excluded</span><span class="p">.</span><span class="k">operation</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">END</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;PENDING&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">createdAt</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">syncQueueEntity</span><span class="p">.</span><span class="n">createdAt</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">payload</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">excluded</span><span class="p">.</span><span class="n">payload</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><p>上传时不再分表调用，而是一个 Supabase RPC 进 PostgreSQL。函数体里先 FOR UPDATE 锁住那一行，再依次做删除与 upsert，整个函数本身就是一个事务。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="k">REPLACE</span><span class="w"> </span><span class="k">FUNCTION</span><span class="w"> </span><span class="k">public</span><span class="p">.</span><span class="n">update_action</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_action_id</span><span class="w"> </span><span class="n">uuid</span><span class="p">,</span><span class="w"> </span><span class="n">p_updated_at</span><span class="w"> </span><span class="n">timestamptz</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_action_updates</span><span class="w"> </span><span class="n">jsonb</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_levels_upsert</span><span class="w"> </span><span class="n">jsonb</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_levels_delete</span><span class="w"> </span><span class="n">uuid</span><span class="p">[]</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_items_upsert</span><span class="w"> </span><span class="n">jsonb</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_items_delete</span><span class="w"> </span><span class="n">uuid</span><span class="p">[]</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">NULL</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">RETURNS</span><span class="w"> </span><span class="n">void</span><span class="w"> </span><span class="k">LANGUAGE</span><span class="w"> </span><span class="n">plpgsql</span><span class="w"> </span><span class="k">SECURITY</span><span class="w"> </span><span class="k">INVOKER</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">$$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">BEGIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">PERFORM</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">public</span><span class="p">.</span><span class="n">actions</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">p_action_id</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">auth</span><span class="p">.</span><span class="n">uid</span><span class="p">()</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">UPDATE</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">IF</span><span class="w"> </span><span class="n">p_levels_delete</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">THEN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">public</span><span class="p">.</span><span class="n">action_levels</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">ANY</span><span class="p">(</span><span class="n">p_levels_delete</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">END</span><span class="w"> </span><span class="k">IF</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">-- 接着是 levels 与 JSONB items 的原子 upsert
</span></span></span><span class="line"><span class="cl"><span class="k">END</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="err">$$</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><h2 id="我拿本机跑了一遍">我拿本机跑了一遍</h2>
<p>文章里的 SQL 我照着在 sqlite3 3.54.0 上跑了一遍（脚本 /tmp/kmp-article/outbox.sql 与 atomicity.sql），三段结论都对得上。</p>
<p>先在离线状态 INSERT，再对同一实体 UPDATE，operation 确实还是 INSERT，createdAt 也没被刷新，只有 payload 换成了新的。</p>
<p>而 INSERT 之后直接 DELETE，SQL 这一层兜不住：行还在，operation 变成 DELETE，payload 被 NULL 冲掉。作者说这种自我抵消是在 Kotlin 事务里做的，实测坐实了这个分工。</p>
<p>外键那条也复现了。先插子表得到 FOREIGN KEY constraint failed，放进事务里则回滚得干干净净，父表子表都没留下半行。这就是他坚持走 RPC 而不是分表 REST 的核心理由。</p>
<p>Kotlin 侧我用 Gradle 缓存里的 kotlinc（kotlin-compiler-embeddable 2.4.10）编译跑了 deepMerge 与抵消逻辑。四次离线编辑，新建 level-3、给 level-3 改名、新建 level-4、删掉 level-4，最后收敛成一行一条 payload：</p>
<pre tabindex="0"><code>队列行 entityId=action-101 operation=INSERT
合并后 payload={&#34;levelsUpsert&#34;:{&#34;level-3&#34;:{&#34;title&#34;:&#34;Deep Meditation&#34;,&#34;sequence&#34;:3},
&#34;level-4&#34;:{&#34;title&#34;:&#34;Breath Work&#34;,&#34;sequence&#34;:4}},&#34;levelsDelete&#34;:{&#34;level-4&#34;:{&#34;id&#34;:&#34;level-4&#34;}}}
入队次数=4，最终队列行数=1
</code></pre><p>换成新建 action-202 再删除，队列里一行都不剩，网络调用是零。</p>
<h2 id="我实测撞上的一个边界">我实测撞上的一个边界</h2>
<p>上面那条 payload 里，level-4 同时出现在 levelsUpsert 和 levelsDelete 里，它既被创建又被删除。</p>
<p>作者的 deepMerge 只做键级合并，不会跨这两个 map 去抵消同一个实体。所以「先建后删」的自动抵消只覆盖了 INSERT 加 DELETE 这一层，也就是整行取消；同一层里「建了又删」要靠服务端 RPC 按顺序处理，或者客户端在合并时自己剔除。</p>
<p>换句话说，稀疏 payload 省掉了整树上传的代价，但它把一部分冲突消解的活分给了两个地方：客户端的事务，和服务端函数的执行顺序。这两处只要有一处逻辑不齐，就会出现本地看着删了、服务端还留着的状态。</p>
<h2 id="拿去自查的四条">拿去自查的四条</h2>
<p>第一，你的行级标记记录的是最终状态还是变更意图。如果是前者，嵌套结构一上来就会露馅。</p>
<p>第二，子实体先于父实体到达服务端会怎样。如果答案是报错然后卡在 DIRTY，那你的同步链路上少了一个原子入口。</p>
<p>第三，整棵快照上传会不会覆盖服务端算出来的字段。打卡、积分、统计这类字段最容易被静默改写。</p>
<p>第四，插入又删除这种自我抵消，你是在客户端做的还是留给服务端。两边都做不算坏事，两边都没做就是数据缺口。</p>
<h2 id="最后">最后</h2>
<p>这次返工值得看的地方，不是 outbox 这个模式本身，而是作者把「什么时候该换架构」写清楚了：简单的一对一标记，行级 flag 够用；一旦出现嵌套的复合编辑，就该把变更意图当成一等公民存起来。</p>
<p>你怎么处理复合离线编辑？欢迎在评论区聊聊你的方案。</p>
<p>参考资料：</p>
<ul>
<li>原文：https://premjitchowdhury.medium.com/when-offline-sync-relying-only-on-local-state-breaks-and-how-i-fixed-it-in-nitivakya-kmp-245e05892ac3</li>
<li>原帖：https://www.reddit.com/r/Kotlin/comments/1wrdk0q/when_offline_sync_relying_only_on_local_state/</li>
<li>Supabase 数据库函数与 RPC：https://supabase.com/docs/guides/database/functions</li>
</ul>
<p>验证边界：文中 SQLite 的三段结论与 Kotlin 的合并逻辑在本机真跑过（sqlite3 3.54.0、kotlinc 2.4.10）；Supabase RPC 函数与 PostgREST 的真实行为没有连项目验证，函数体摘录自原文。</p>
]]></content:encoded></item><item><title>用 KMP 做一个自己的 PikPak 客户端：expect/actual 一次都没用上</title><link>http://mings.in/posts/kmp-piko/</link><pubDate>Sun, 27 Sep 2026 08:30:33 +0800</pubDate><guid>http://mings.in/posts/kmp-piko/</guid><description>前两天刷到一个小众软件的帖子，一个叫 Piko 的项目，开发者自荐：开源的 PikPak 第三方客户端，Android 和 Windows 双端，两端共用界面与业务代码。</description><content:encoded><![CDATA[<p>前两天刷到一个小众软件的帖子，一个叫 Piko 的项目，开发者自荐：开源的 PikPak 第三方客户端，Android 和 Windows 双端，两端共用界面与业务代码。</p>
<p>网盘客户端这东西不算新鲜，但「一套代码双端跑」这句话我今年听得太多了，多到有点麻木。所以真正让我停下来的不是它支持双端，是它把双端这件事做成了什么形状。</p>
<p>我把仓库拉下来数了一遍，然后在一个地方愣住了。</p>
<h2 id="先把结构摆出来">先把结构摆出来</h2>
<p>Piko 是 Kotlin Multiplatform 加 Compose Multiplatform，五个模块，实测的代码量是这样：</p>
<ul>
<li><code>shared</code> 状态与业务，<code>commonMain</code> 79 个文件</li>
<li><code>ui</code> 共享界面，<code>commonMain</code> 74 个文件，<strong>只有这一个 source set</strong></li>
<li><code>app</code> Android 入口，22 个文件</li>
<li><code>desktopApp</code> 桌面入口，29 个文件</li>
<li><code>cli</code> 开发用的命令行工具，不上线</li>
</ul>
<p><img alt="Piko 模块与 source set 分布" loading="lazy" src="/images/kmp-piko/01-modules.png"></p>
<p>前两个加起来 153 个文件、3 万 1 千多行 Kotlin，是两端共用的部分。后两个是两端的入口，加起来 51 个文件、8 千多行。</p>
<p>看到这里还算正常。不正常的是我接着敲的这条命令。</p>
<h2 id="一行-expectactual-都没有">一行 expect/actual 都没有</h2>
<p>在 KMP 里，平台差异的教科书答案就是 expect/actual：公共代码里写 <code>expect fun</code>，各平台 source set 里写 <code>actual fun</code>。我本来想数数它用了多少处，好写进文章当例子。</p>
<pre tabindex="0"><code>grep -rn &#34;^\s*expect &#34; --include=&#34;*.kt&#34; shared ui app desktopApp
expect 声明: 0
actual 实现: 0
</code></pre><p>我以为是自己的正则写错了，换了几种写法，结果一样。一个 3 万 1 千行共享代码、全仓库 4 万 6 千行 Kotlin 的项目，expect/actual 用了零次。</p>
<p>更奇怪的是 source set 的分布。它的 <code>shared</code> 里没有 <code>androidMain</code>，<code>ui</code> 里连 <code>desktopMain</code> 都没有，<code>shared</code> 唯一一个平台相关的 source set 叫 <code>jvmSharedMain</code>，里面只有一个文件、67 行。</p>
<p>这就不是「用得少」了，是压根换了一套做法。</p>
<h2 id="它的答案接口加运行时注入">它的答案：接口，加运行时注入</h2>
<p>作者在自己的架构笔记里写了一句话，我觉得比教程里任何一段都值钱：</p>
<blockquote>
<p>平台差异用接口，不用 expect/actual</p>
</blockquote>
<p>理由是这句注释，写在能力的接口定义上：</p>
<blockquote>
<p>实现要拿 Context、窗口与各自的播放后端，这些都是运行时对象，不是编译期的平台差异。</p>
</blockquote>
<p>这句话点破了我以前的糊涂账。expect/actual 解决的是「编译期差异」，同一个函数名，两个平台各写一份实现，编译器帮你挑。但客户端真正要分家的东西，几乎全是运行时对象：Android 要拿 Context、要注册 ActivityResult 启动器、播放要走 libmpv 的 SurfaceView；桌面端要窗口句柄、要 MPV 的原生 DLL、播放画面要零拷贝进 Skia。这些东西的共同点是，它们在编译期看起来是同一种东西，只是构造方式不同。</p>
<p>而构造方式不同，恰恰是依赖注入最擅长的事。</p>
<p>它是这么做的。共用的 <code>ui</code> 模块里定义一个接口，叫 <code>PikoPlatform</code>，把界面要跟平台要的能力全列在里面：剪贴板、系统取色、下载目录选择、本地文件的打开与分享、全屏对话框、应用内更新。</p>
<p>两个入口各实现一份，<code>AndroidPikoPlatform</code> 和 <code>DesktopPikoPlatform</code>，最后在入口处拼进根界面 <code>PikoApp(services, platform, appearance, videoPlayer)</code>。界面内部通过 <code>LocalPikoPlatform</code> 取用，不再回头找单例。</p>
<h2 id="平台没有的能力用-null-说出来">平台没有的能力，用 null 说出来</h2>
<p>接口方案最容易被嫌的地方是啰嗦：同样是「分享文件」，Android 有系统分享面板，桌面端没有对应物，那接口该怎么写。</p>
<p>它的处理是这样的。能力字段可以是可空的：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="cm">/** 为 null 表示该平台没有应用内更新。 */</span>
</span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">updater</span><span class="p">:</span> <span class="n">AppUpdateService</span><span class="p">?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="cm">/** 为 null 表示该平台不提供片段下载的画面预览，入口随之隐藏。 */</span>
</span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">videoPreview</span><span class="p">:</span> <span class="n">VideoPreviewSupport</span><span class="p">?</span>
</span></span></code></pre></div><p>也有的写成布尔：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="cm">/** 系统分享面板。桌面端没有对应物时为 false，界面不显示「分享」。 */</span>
</span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">canShare</span><span class="p">:</span> <span class="n">Boolean</span>
</span></span></code></pre></div><p>还有更细的：软键盘是否弹出，桌面端恒为 false；快捷键主修饰键，Windows 是 Ctrl，macOS 给的是 ⌘，由一个 <code>shortcutModifier</code> 属性吐出来。</p>
<p>这套约定带来的好处是，界面只需要问一次「你能不能」，然后自己决定藏不藏入口，不需要为了一个只有单端存在的功能，在公共代码里到处塞平台判断。这类判断一旦多起来，公共代码就名存实亡了。</p>
<h2 id="第三种手段中间-source-set">第三种手段：中间 source set</h2>
<p>接口解决的是「两端真的不一样」。但还有一类代码是「两端完全一样，只是要用 JDK 的 API」。Piko 里这类代码的样本是网络代理选择器 <code>PikoProxySelector</code>，67 行。</p>
<p>它要把一个进程级的 <code>ProxySelector</code> 装进 JVM，理由是 SDK 内部自己建 OkHttpClient，拿不到构造参数，只能从全局进去。这段代码在 Android 和桌面端一字不差。</p>
<p>如果按教科书走，这就是两份 <code>actual</code>，内容复制粘贴。它的做法是在中间加一层 source set，用构建脚本声明这个组里同时装两种 JVM 目标：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">applyDefaultHierarchyTemplate</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">common</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">group</span><span class="p">(</span><span class="s2">&#34;jvmShared&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">withCompilations</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="k">it</span><span class="p">.</span><span class="n">platformType</span> <span class="o">==</span> <span class="nc">KotlinPlatformType</span><span class="p">.</span><span class="n">jvm</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">                    <span class="k">it</span><span class="p">.</span><span class="n">platformType</span> <span class="o">==</span> <span class="nc">KotlinPlatformType</span><span class="p">.</span><span class="n">androidJvm</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p><code>jvmSharedMain</code> 这个目录就这么来的。写完一份，两个 JVM 系目标都编译它。这个手段我在这篇文章之前没在任何教程里见过，但它是三个手段里省事最多的一个。</p>
<p>所以到这儿，KMP 划边界的手段其实有三档：</p>
<ol>
<li>能共用就直接放 <code>commonMain</code>，这是绝大多数代码的位置</li>
<li>两端行为真的不同，用接口加运行时注入</li>
<li>两端代码相同、只是要用 JDK API，加一个中间 source set</li>
</ol>
<p>expect/actual 是第四档。它没被用，是因为前三档刚好覆盖了这个项目的全部需求。我现在的看法是，expect/actual 更像是给「纯声明式差异」准备的，比如一个常量、一个没有运行时状态的函数。真正复杂的客户端，落到那一档的东西比想象中少。</p>
<p><img alt="划平台边界的手段分层" loading="lazy" src="/images/kmp-piko/02-layers.png"></p>
<h2 id="那-3-万-1-千行共享代码长什么样">那 3 万 1 千行共享代码长什么样</h2>
<p><code>ui/src/commonMain</code> 是整个界面：主题、导航、网盘、传输、设置、回收站、登录，加上各种组件。入口是 <code>PikoApp</code>，两端各自把依赖拼好再传进去。</p>
<p>Android 这边在 <code>Application</code> 里装配：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">val</span> <span class="py">clientManager</span> <span class="p">=</span> <span class="n">PikoClientManager</span><span class="p">(</span><span class="n">AndroidPikoSessionStore</span><span class="p">(</span><span class="k">this</span><span class="p">,</span> <span class="n">sessionManager</span><span class="p">),</span> <span class="n">appScope</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">services</span> <span class="p">=</span> <span class="n">PikoServices</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">preferences</span> <span class="p">=</span> <span class="n">sessionManager</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">clientManager</span> <span class="p">=</span> <span class="n">clientManager</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">downloadManager</span> <span class="p">=</span> <span class="n">PikoDownloadCoordinator</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">storage</span> <span class="p">=</span> <span class="n">AndroidPikoDownloadStorage</span><span class="p">(</span><span class="k">this</span><span class="p">,</span> <span class="n">sessionManager</span><span class="p">,</span> <span class="n">appScope</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="n">segmentDownloader</span> <span class="p">=</span> <span class="n">AndroidPikoSegmentDownloader</span><span class="p">(</span><span class="k">this</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// ...
</span></span></span><span class="line"><span class="cl">    <span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="n">uploadSources</span> <span class="p">=</span> <span class="n">AndroidPikoUploadSources</span><span class="p">(</span><span class="k">this</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">platform</span> <span class="p">=</span> <span class="n">AndroidPikoPlatform</span><span class="p">(</span><span class="k">this</span><span class="p">)</span> <span class="p">{</span> <span class="n">appUpdater</span> <span class="p">}</span>
</span></span></code></pre></div><p>桌面端在 <code>main</code> 里装配，形状一样，实现换了一套：<code>DesktopPikoPlatform(settings)</code>、<code>DesktopPikoDownloadStorage</code>、<code>FilePikoSessionStore</code>。会话存储这个例子最能说明问题，公共接口只有 21 行：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">interface</span> <span class="nc">PikoSessionStore</span> <span class="p">:</span> <span class="n">SessionStore</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">loadLastAccount</span><span class="p">():</span> <span class="n">String</span><span class="p">?</span>
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">saveLastAccount</span><span class="p">(</span><span class="n">account</span><span class="p">:</span> <span class="n">String</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">loadCredentials</span><span class="p">(</span><span class="n">account</span><span class="p">:</span> <span class="n">String</span><span class="p">):</span> <span class="n">PikoCredentials</span><span class="p">?</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ...
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Android 侧走 DataStore，桌面端往 <code>~/.piko</code> 下写文件。界面和业务代码完全不知道这件事。</p>
<p>这里有个我觉得挺值得留意的对照：接口规定了两端要做什么，但管不住两端实现得差多远。我翻了桌面端那份实现，它把会话、账号、密码分开写在 <code>~/.piko</code> 的三个文件里，密码那一个是明文写的。自己的机器上自用没什么问题，多人共用的机器上就值得掂量一下。同一件事，同一份接口，两端的严谨程度可以差出一截。</p>
<h2 id="踩坑提醒">踩坑提醒</h2>
<p>它的架构笔记里记了不少实测出来的坑，我挑几个跨平台的记下来。</p>
<p><strong>两端的 Compose 版本可以对不上，同一行代码可能只在一端报错。</strong> 它桌面端的界面库钉在 Compose Multiplatform 1.12.0，Android 端由 BOM 决定。原因很具体：CMP 1.13 带的 skiko 把渲染后端包进了 <code>OnScreenRedrawer</code>，而它用的播放库要从里面直接拿 <code>Direct3DRedrawer</code> 做 D3D11 画面直通，一开播放器窗口就抛异常。所以这个项目里，升级 CMP 不是改一个数字的事，得等上游播放库跟上。</p>
<p><strong>Android 端的 Compose 反而要退回稳定版。</strong> 它的版本目录里写着：1.13 的 alpha 在列表快速滚动时崩溃，可交互的 ListItem 在测量阶段读副文本基线，为对齐而摆放的子节点去查找尚未入表的父节点。BOM 因此换成稳定版，ui 与 foundation 落到 1.12.x。</p>
<p><strong>改共用模块必须两端都编译。</strong> 作者把这句话直接写进了给协作者看的文件里，因为 <code>shared</code> 和 <code>ui</code> 的改动会同时波及两个入口模块，只编译一端看不出来。而两端的 material3 版本本来就不是一个，同一行代码完全可能只在一端报错。</p>
<p><strong>加一个偏好项要同时改三处。</strong> 公共接口一处，Android 的 DataStore 实现一处，桌面的设置存储一处。同类的还有全局导航栈：主界面的位置放在仓库层并持久化，所以目录选择器这类浮层必须自己维护一份路径栈，动了仓库层那份，主界面的位置会跟着跑。</p>
<p><strong>依赖要用 api 而不是 implementation，是有原因的。</strong> <code>shared</code> 用了 <code>api(libs.cmp.runtime)</code>，因为它对外暴露的状态类型直接是 Compose 的 <code>State</code>，消费方拿得到这些类型才能编译。反面例子是 kotlinx-serialization：SDK 那边以 implementation 声明，不会传递过来，调用方得自己引一份。</p>
<p><strong>一个许可证细节：源码是 MIT，但 Android 安装包整体是 GPLv3。</strong> 因为它内置的 mpv 与 FFmpeg 是以 GPL 构建的。自己用没关系，想二次分发要看清楚这一点。</p>
<h2 id="它跑起来是什么样">它跑起来是什么样</h2>
<p>Piko 的官方 README 里有各端的截图，我挑两张放这儿，上面是 Android，下面是同一个窗口里的桌面端。</p>
<p>两张图值得对比的不是内容，是导航。手机上导航栏在底边，桌面窗口里挪到了左侧，中间那层判断只看窗口宽度，不看是什么设备。所以桌面缩窄到一定程度会变成手机的样子，平板分屏走的是同一套判断。这层自适应也写在共用代码里。</p>
<p><img alt="Piko Android" loading="lazy" src="/images/kmp-piko/phone.jpg"></p>
<p><img alt="Piko Desktop" loading="lazy" src="/images/kmp-piko/desktop.jpg"></p>
<p>功能上它做得比我预期的多：磁力链接能按作品、分区、集数解析，字幕跟着视频归组，广告文件自动排除，保存之前可以先完整预览再勾选要的文件。播放进度跟 PikPak 官方客户端同步，下载和在线播放都是 8 条连接并发。还支持无损截取视频片段、上传、服务端解压、全盘搜索、查找重复、看网盘用量和本月流量。</p>
<p>值得一提的是它的开发方式，提交记录里能看到 Claude 的协作者署名，README 里也写了用 LLM 辅助写代码时的要求：理解新增代码的逻辑，并在真机上验证。作者在帖子里说配色这块自己审美欠缺，这套界面是 M3 生成加人工调的。</p>
<h2 id="最后">最后</h2>
<p>小项目要双端，我的建议是先把 <code>shared</code> 和 <code>ui</code> 切出来，让它俩干干净净地只剩 <code>commonMain</code>，平台实现全部塞进两个入口模块。这样每次想偷懒往共用模块里加平台判断的时候，都会先卡一下。</p>
<p><code>PikoProxySelector</code> 那 67 行给我印象最深。它本来最像需要 expect/actual 的地方，最后是加了一层 source set 解决的。手段够多的时候，你反而不会去动那个最笨的。</p>
<p>项目地址：https://github.com/NihilDigit/piko</p>
]]></content:encoded></item><item><title>你的协程崩了，堆栈却是空白的：用 Decoroutinator 找回丢失的调用链</title><link>http://mings.in/posts/decoroutinator/</link><pubDate>Wed, 23 Sep 2026 07:37:31 +0800</pubDate><guid>http://mings.in/posts/decoroutinator/</guid><description>线上报了个 IllegalStateException，你点开堆栈，看见这个：</description><content:encoded><![CDATA[<p>线上报了个 <code>IllegalStateException</code>，你点开堆栈，看见这个：</p>
<pre tabindex="0"><code>java.lang.IllegalStateException: user 42 not found
	at demo.ChainKt.fetchUser(Chain.kt:11)
	at demo.ChainKt$fetchUser$1.invokeSuspend(Chain.kt)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
	at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:233)
	at kotlinx.coroutines.DispatchedTaskKt.dispatch(DispatchedTask.kt:152)
	... 后面十几行全是协程内部
</code></pre><p>一行自己的代码，然后全是框架。你知道了出事的位置，但不知道<strong>是谁</strong>叫的它。<code>fetchUser</code> 在这个工程里被好几个地方调用，每一个都可能传进 42。</p>
<p>这不是偶发，是协程的常态。而修它只要加一个依赖。</p>
<h2 id="为什么调用链会消失">为什么调用链会消失</h2>
<p>协程的核心是把异步代码写成同步的样子。编译器把每个 suspend 函数改造成一个状态机，函数&quot;返回&quot;的时候并不是真的从 JVM 调用栈上返回，而是把结果交给 continuation，由调度器决定在哪、什么时候继续。</p>
<p>问题就在这个&quot;继续&quot;上。挂起的那一刻，中间那些帧已经从 JVM 栈上弹掉了；恢复执行时，是一个全新的栈——从调度器开始，直接跳到 <code>fetchUser</code> 的续体。<code>loadProfile</code> 和 <code>renderScreen</code> 在这个过程中没有任何帧留在栈上，异常自然也就找不到它们。</p>
<p>所以丢帧不是 bug，是这套机制的必然结果。Kotlin 团队知道这件事，也给过方案：打开 <code>kotlinx.coroutines.debug</code> 会启用 stacktrace recovery。</p>
<p>只是它救不回来。我在本地把这个例子跑了一遍，属性确认进了应用 JVM：</p>
<pre tabindex="0"><code>Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug=on
java.lang.IllegalStateException: user 42 not found
	at demo.ChainKt.fetchUser(Chain.kt:11)
	at demo.ChainKt$fetchUser$1.invokeSuspend(Chain.kt)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
</code></pre><p><code>loadProfile</code> 和 <code>renderScreen</code> 依然不在。这个官方机制只覆盖一部分场景，而挂起再恢复这条最常见的路径，恰好不在里面。</p>
<h2 id="decoroutinator-换了个思路">Decoroutinator 换了个思路</h2>
<p><a href="https://github.com/reformator14/stacktrace-decoroutinator">Stacktrace-decoroutinator</a> 的做法是<strong>把协程恢复的这套实现整个替换掉</strong>，作者自己的说法比较直接：</p>
<blockquote>
<p>Decoroutinator replaces the coroutine resumption implementation. It generates methods at runtime with names that match the entire coroutine call stack.</p>
</blockquote>
<blockquote>
<p>Decoroutinator 替换掉协程的恢复实现。它在运行时生成一批方法，方法名对应整条协程调用链。</p>
</blockquote>
<blockquote>
<p>These methods do nothing except call each other sequentially in coroutine call stack order, creating real JVM stack frames.</p>
</blockquote>
<blockquote>
<p>这些方法什么都不做，只是按协程调用链的顺序依次互相调用，从而造出真实的 JVM 栈帧。</p>
</blockquote>
<p>翻译成人话：它不试图从 continuation 里&quot;猜&quot;调用链，而是<strong>真的把那些帧造出来</strong>。既然异常要靠 JVM 栈来找调用者，那就让栈上有东西可找。</p>
<p>项目是 Apache-2.0，⭐374，拿过 Kotlin Foundation 的 grants，最新版 2.6.6 是 9 月 10 号发的，仓库 9 月中旬还有提交。对一个小工具来说，这个维护状态算是相当健康。</p>
<h2 id="先看效果再讲怎么装">先看效果，再讲怎么装</h2>
<p>我把同一个源文件放进三个模块，唯一差别是 Decoroutinator 开不开。第一次运行是原始版本：</p>
<pre tabindex="0"><code>$ ./gradlew :plain:run
java.lang.IllegalStateException: user 42 not found
	at demo.ChainKt.fetchUser(Chain.kt:11)
	at demo.ChainKt$fetchUser$1.invokeSuspend(Chain.kt)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
	at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:233)
	...
</code></pre><p>整个堆栈 21 行，属于 <code>demo.</code> 这个包的只有 4 帧，其中两帧还是同一个函数。</p>
<p>加上装饰器之后：</p>
<pre tabindex="0"><code>$ ./gradlew :decoroutinated:run
java.lang.IllegalStateException: user 42 not found
	at demo.ChainKt.fetchUser(Chain.kt:11)
	at demo.ChainKt$fetchUser$1.invokeSuspend(Chain.kt)
	at kotlin.coroutines.jvm.internal.DecoroutinatorBaseContinuationAccessorImpl.invokeSuspend(base-continuation-accessor.kt:15)
	at dev.reformator.stacktracedecoroutinator.common.internal.Provider.callInvokeSuspendIfResultIsNotCoroutineSuspended(provider-impl.kt:123)
	at dev.reformator.stacktracedecoroutinator.provider.SharedSpec.$decoroutinator$resume(provider-api.kt:106)
	at demo.ChainKt.loadProfile(Chain.kt:15)
	at demo.ChainKt.renderScreen(Chain.kt:21)
	at demo.MainKt$main$1.invokeSuspend(Main.kt:8)
	...
</code></pre><p>31 行，<code>demo.</code> 包 7 帧。关键的不是行数变多，是这两行回来了：</p>
<pre tabindex="0"><code>	at demo.ChainKt.loadProfile(Chain.kt:15)
	at demo.ChainKt.renderScreen(Chain.kt:21)
</code></pre><p><code>renderScreen</code> 第 21 行调了 <code>loadProfile</code>，<code>loadProfile</code> 第 15 行调了 <code>fetchUser</code>——完整的调用链，带行号。此时你一眼能看出是谁传进去的 42。</p>
<p>代码本身一个字没改。下面是装的三种方式。</p>
<h2 id="jvm-上一行插件就够">JVM 上，一行插件就够</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">plugins</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">kotlin</span><span class="p">(</span><span class="s2">&#34;jvm&#34;</span><span class="p">)</span> <span class="n">version</span> <span class="s2">&#34;2.3.21&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">application</span>
</span></span><span class="line"><span class="cl">  <span class="n">id</span><span class="p">(</span><span class="s2">&#34;dev.reformator.stacktracedecoroutinator&#34;</span><span class="p">)</span> <span class="n">version</span> <span class="s2">&#34;2.6.6&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>这是最省事的一条路。插件会在构建期往你的类里生成辅助方法，运行时不需要额外的 JVM 参数。</p>
<p>同一个库还有另外两种启用方式，都走 JVM instrumentation 而不是构建期生成：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 方式二：JVM 启动参数</span>
</span></span><span class="line"><span class="cl">-javaagent:/path/to/stacktrace-decoroutinator-jvm-agent-2.6.6.jar
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="c1">// 方式三：加依赖，然后在任何协程跑起来之前调一次
</span></span></span><span class="line"><span class="cl"><span class="k">import</span> <span class="nn">dev.reformator.stacktracedecoroutinator.jvm.DecoroutinatorJvmApi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nc">DecoroutinatorJvmApi</span><span class="p">.</span><span class="n">install</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// 之后照常跑你的代码
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>依赖坐标是 <code>dev.reformator.stacktracedecoroutinator:stacktrace-decoroutinator-jvm:2.6.6</code>。</p>
<p>方式三有个副作用值得先说：它靠<strong>动态加载</strong> Java agent 来实现，JVM 会给你打一行警告——</p>
<pre tabindex="0"><code>WARNING: A Java agent has been loaded dynamically
WARNING: Dynamic loading of agents will be disallowed by default in a future release
</code></pre><p>现在只是警告，未来版本默认会禁掉。如果你的环境对这点敏感，用方式一或方式二更稳。</p>
<h2 id="kmp-项目里插件不管用">KMP 项目里，插件不管用</h2>
<p>这部分是我这次踩得最实的坑，也是想写给 KMP 读者的主要原因。</p>
<p>看到插件用法，第一反应自然是把它挂到 multiplatform 模块上。我这么做了，结果<strong>堆栈一点没恢复</strong>——和没装一样。更麻烦的是当时还顺带报了另一个错：</p>
<pre tabindex="0"><code>Exception in thread &#34;main&#34; java.lang.NoClassDefFoundError:
dev/reformator/stacktracedecoroutinator/provider/BaseContinuationExtractor
	at demo.MainKt.main(Main.kt:6)
</code></pre><p>意思是插件确实改写了字节码，但把自己运行时需要的类留在了 classpath 外面。我照着插件在普通 <code>kotlin(&quot;jvm&quot;)</code> 模块里注入的那三个构件手动补上，<code>NoClassDefFoundError</code> 消失了——<strong>但堆栈依然没恢复</strong>。</p>
<p>原因不难推：这个插件只把自己接进普通 JVM 模块的编译任务，KMP 的 jvm target 编译任务不在它的接管范围内。所以字节码动了一点，真正负责生成&quot;伪造调用链&quot;的那部分没跑。</p>
<p>对 KMP 项目，正确做法是绕开构建期生成，改用运行期 instrumentation。两种都实测通过：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">kotlin</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">jvmToolchain</span><span class="p">(</span><span class="mi">21</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">jvm</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">  <span class="n">sourceSets</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">jvmMain</span><span class="p">.</span><span class="n">dependencies</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="c1">// 方式三需要它；用 agent 的话这一行可以不要
</span></span></span><span class="line"><span class="cl">      <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;dev.reformator.stacktracedecoroutinator:stacktrace-decoroutinator-jvm:2.6.6&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 方式二：agent 只加在运行参数上，源码和依赖都不用动
</span></span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">decoroutinatorAgent</span> <span class="k">by</span> <span class="n">configurations</span><span class="p">.</span><span class="n">creating</span>
</span></span><span class="line"><span class="cl"><span class="n">dependencies</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">decoroutinatorAgent</span><span class="p">(</span><span class="s2">&#34;dev.reformator.stacktracedecoroutinator:stacktrace-decoroutinator-jvm-agent:2.6.6&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="n">tasks</span><span class="p">.</span><span class="n">register</span><span class="p">&lt;</span><span class="n">JavaExec</span><span class="p">&gt;(</span><span class="s2">&#34;runKmp&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">mainClass</span><span class="p">.</span><span class="k">set</span><span class="p">(</span><span class="s2">&#34;demo.MainKt&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">classpath</span> <span class="p">=</span> <span class="n">files</span><span class="p">(</span><span class="n">kotlin</span><span class="p">.</span><span class="n">targets</span><span class="p">.</span><span class="n">getByName</span><span class="p">(</span><span class="s2">&#34;jvm&#34;</span><span class="p">).</span><span class="n">compilations</span><span class="p">.</span><span class="n">getByName</span><span class="p">(</span><span class="s2">&#34;main&#34;</span><span class="p">).</span><span class="n">output</span><span class="p">.</span><span class="n">allOutputs</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="n">configurations</span><span class="p">.</span><span class="n">getByName</span><span class="p">(</span><span class="s2">&#34;jvmRuntimeClasspath&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="n">jvmArgs</span><span class="p">(</span><span class="s2">&#34;-javaagent:</span><span class="si">${decoroutinatorAgent.singleFile.absolutePath}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>这两种在 KMP 模块里都跑出了恢复后的堆栈，<code>loadProfile</code>、<code>renderScreen</code> 都在，<code>demo.</code> 包 7 帧。</p>
<p>还有一句必须说清楚的：<strong>Native 和 JS 目标用不了，而且不是配置问题</strong>。这库的原理是在 JVM 上造栈帧，没有 JVM 就没有这套机制。作者在 issue #5 里被问过要不要做多平台版本，回复是暂时没有计划——那个 issue 从 2022 年开到现在还开着。</p>
<p>所以 KMP 项目的实际选择是：只给 JVM/Android 侧装，iOS 侧照旧。如果你主要在看 iOS 崩溃，这篇帮不上忙。</p>
<h2 id="几个已知的坑">几个已知的坑</h2>
<p>官方 README 里列了几个，都是真的会撞上的：</p>
<ul>
<li>Android 只支持插件方式，而且它依赖 <code>MethodHandle</code>，需要 API 26（Android 8）以上。低版本会<strong>自动关掉恢复</strong>而不是崩，所以放心引入。</li>
<li>开了 R8/ProGuard 会失效。混淆会把运行时需要的代码改掉名，得把 <code>decoroutinatorAndroidProGuardRules()</code> 加到 release 的规则里。</li>
<li>和 Jacoco 一起用时覆盖率会丢。这是 Jacoco 自己的限制，解决办法是让 Jacoco 的 agent 排在 Decoroutinator 前面。</li>
<li>Shadow 插件有已知冲突（<a href="https://github.com/GradleUp/shadow/issues/882">shadow#882</a>），要打 fat jar 的话得先看 workaround。</li>
<li>Robolectric 会把它的类放进单独的 classloader，导致测试报错，需要在 <code>packagesToNotAcquire</code> 里排掉 <code>dev.reformator.stacktracedecoroutinator.</code>。</li>
</ul>
<p>另外，如果只想在测试里用，官方推荐把测试放到单独的子项目里应用插件；不好拆的话有个开关：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">stacktraceDecoroutinator</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">androidTestsOnly</span> <span class="p">=</span> <span class="k">true</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>想知道运行时到底装上没有，可以查状态：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nc">DecoroutinatorCommonApi</span><span class="p">.</span><span class="n">getStatus</span> <span class="p">{</span> <span class="k">it</span><span class="p">()</span> <span class="p">}</span>
</span></span></code></pre></div><p>Android 那边它还能顺带把 <code>DebugProbes</code> 打包进去，配合 <code>embedDebugProbesForAndroid = true</code> 用，能在运行期 dump 协程状态。</p>
<h2 id="要不要装">要不要装</h2>
<p>我倾向装，尤其是团队里还有人在用&quot;加日志猜&quot;的方式定位协程问题时。</p>
<p>它做的事很单一：让 JVM 栈上有真实的帧。没有 API 变化，没有侵入式的重构，删掉就是一个依赖的事。代价是运行时多了一层 instrumentation，以及上面那串坑——其中大部分只在 Android 打包场景才遇到，纯 JVM 项目基本无感。</p>
<p>有一点值得留意：它改变的是<strong>恢复实现</strong>本身。所以上线前值得在预发环境跑一轮，确认没有行为差异，而不是直接在核心链路打开。</p>
<hr>
<p>完整的对照工程在 <a href="https://github.com/trewvip-arch/decoroutinator-kmp-demo">https://github.com/trewvip-arch/decoroutinator-kmp-demo</a></p>
<p>三个模块共用同一份源码，<code>plain</code> / <code>decoroutinated</code> / <code>kmp</code>，跑一遍就能看到本文所有堆栈输出的原始版本：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./gradlew :plain:run          <span class="c1"># 不装，链是断的</span>
</span></span><span class="line"><span class="cl">./gradlew :decoroutinated:run <span class="c1"># 插件方式，链完整</span>
</span></span><span class="line"><span class="cl">./gradlew :kmp:runKmp         <span class="c1"># KMP + agent，链完整</span>
</span></span><span class="line"><span class="cl">./gradlew :kmp:runKmpInstall  <span class="c1"># KMP + install()，链完整</span>
</span></span></code></pre></div><p>实测环境：Stacktrace-decoroutinator 2.6.6、Kotlin 2.3.21、kotlinx-coroutines 1.11.0、JDK 21、macOS。文中所有堆栈输出都是真跑出来的。</p>
]]></content:encoded></item><item><title>ADK for Kotlin 1.1 + Ktor 3.6：给 KMP 开发者的 AI agent 上手指南</title><link>http://mings.in/posts/adk-kotlin-quickstart/</link><pubDate>Tue, 22 Sep 2026 07:55:03 +0800</pubDate><guid>http://mings.in/posts/adk-kotlin-quickstart/</guid><description>Python 那边的 agent 框架已经换过好几轮了，LangChain、LlamaIndex，再到 Google 自己出的 ADK。Kotlin 这边一直没什么动静——不是没人想做，是缺一个官方托底的东西，大家不太敢往生产里放。</description><content:encoded><![CDATA[<p>Python 那边的 agent 框架已经换过好几轮了，LangChain、LlamaIndex，再到 Google 自己出的 ADK。Kotlin 这边一直没什么动静——不是没人想做，是缺一个官方托底的东西，大家不太敢往生产里放。</p>
<p>Google 的 <code>adk-kotlin</code> 1.0 在 9 月 7 号 GA，1.1.0 是 9 月 15 号，Ktor 3.6.0 的构件 9 月 16 号上的 Maven Central。我拿这三个搭了个能用的东西：<strong>一个能自己查证「某个 KMP 库到底支不支持 iOS」的 agent</strong>，服务端和客户端放在同一个 KMP 工程里。</p>
<p>先交代一件事。写这篇文章的过程中我踩了个坑，照着源码写的请求体在真服务器上直接返回 400，而且<strong>不带任何错误信息</strong>。折腾了小半个钟头才定位到，是版本问题。这个放在后面单独讲，因为它比教程本身更值得记住。</p>
<h2 id="这个-agent-解决什么问题">这个 agent 解决什么问题</h2>
<p>KMP 开发者最常问的一个问题是：「这个库支持 iOS 吗？」</p>
<p>README 经常不给答案，或者给的是过期的答案。真正权威的地方是库发布到 Maven Central 上的 <strong>Gradle module metadata</strong>——那个 <code>.module</code> 文件是构建过程生成的，里面列的就是这次编译真实产出的平台变体。</p>
<p>所以工具做的事很直接：把 <code>.module</code> 拉下来，解析出所有变体名，数一数里面有没有 iOS 相关的。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nd">@Tool</span>
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">checkLibraryPlatforms</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Maven group id, e.g. com.google.adk&#34;</span><span class="p">)</span> <span class="n">group</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Maven artifact id, e.g. google-adk-kotlin-core&#34;</span><span class="p">)</span> <span class="n">artifact</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Version to check, e.g. 1.1.0&#34;</span><span class="p">)</span> <span class="n">version</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">):</span> <span class="n">String</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">val</span> <span class="py">url</span> <span class="p">=</span> <span class="s2">&#34;https://repo1.maven.org/maven2/</span><span class="si">${group.replace(&#39;.&#39;, &#39;/&#39;)}</span><span class="s2">/&#34;</span> <span class="p">+</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;</span><span class="si">$artifact</span><span class="s2">/</span><span class="si">$version</span><span class="s2">/</span><span class="si">$artifact</span><span class="s2">-</span><span class="si">$version</span><span class="s2">.module&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ... 拉取、解析、统计 iOS 变体
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p><code>@Tool</code> 和 <code>@Param</code> 是 ADK 的注解，后面会说它为什么重要。</p>
<h2 id="结构为什么是两个模块">结构：为什么是两个模块</h2>
<pre tabindex="0"><code>adk-kotlin-agent-quickstart/
├── shared/          KMP 模块
│   └── src/
│       ├── commonMain/   Ktor 客户端 + 协议 DTO（jvm / iOS 共用）
│       ├── jvmMain/      CLI 入口 + CIO 引擎
│       └── iosMain/      Darwin 引擎
└── agent-server/    纯 JVM 模块：ADK agent + HTTP 服务
</code></pre><p>看起来可以塞进一个模块，但塞不进去。<code>google-adk-kotlin-webserver</code> 内部依赖的是 <strong>Ktor 2.3.13</strong>，而客户端要用 <strong>Ktor 3.6.0</strong>。放一起的话 Gradle 按「最高版本获胜」把服务端也拉到 Ktor 3，而 ADK 的代码是按 Ktor 2 的 API 编译的，运行时会直接炸。</p>
<p>这不是猜的。跑起来之后看类路径，服务端确实是这一串：</p>
<pre tabindex="0"><code>ktor-server-netty-jvm-2.3.13.jar
ktor-server-core-jvm-2.3.13.jar
ktor-client-core-jvm-2.3.13.jar
...
</code></pre><p>客户端那边是 3.6.0。物理隔开，各自安好。</p>
<h2 id="版本锁定">版本锁定</h2>
<p>KSP 必须和 Kotlin 严格配对，这是最容易埋雷的地方。我直接照抄了 <code>google/adk-kotlin</code> 仓库自己构建用的组合：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">versions</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">kotlin</span> <span class="p">=</span> <span class="s2">&#34;2.3.21&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">ksp</span> <span class="p">=</span> <span class="s2">&#34;2.3.9&#34;</span>          <span class="c"># 和 kotlin 一起升，别单独动</span>
</span></span><span class="line"><span class="cl"><span class="nx">adk</span> <span class="p">=</span> <span class="s2">&#34;1.1.0&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">ktor</span> <span class="p">=</span> <span class="s2">&#34;3.6.0&#34;</span>
</span></span></code></pre></div><p>服务端依赖三个：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">dependencies</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">implementation</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">adk</span><span class="p">.</span><span class="n">core</span><span class="p">)</span>        <span class="c1">// agents / models / tools / sessions / runners
</span></span></span><span class="line"><span class="cl">  <span class="n">ksp</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">adk</span><span class="p">.</span><span class="n">processor</span><span class="p">)</span>              <span class="c1">// 编译期生成工具 schema
</span></span></span><span class="line"><span class="cl">  <span class="n">implementation</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">adk</span><span class="p">.</span><span class="n">webserver</span><span class="p">)</span>   <span class="c1">// 把 agent 用 HTTP 暴露出去
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><h2 id="工具是怎么变成-schema-的">工具是怎么变成 schema 的</h2>
<p>ADK for Kotlin 和 Python 版最大的差别在这儿。Python 靠运行时反射，Kotlin 这边是 KSP <strong>编译期</strong>生成。</p>
<p><code>KmpSupportAgent</code> 里的这一行是关键：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">tools</span> <span class="p">=</span> <span class="n">KmpLibraryService</span><span class="p">().</span><span class="n">generatedTools</span><span class="p">()</span>
</span></span></code></pre></div><p><code>generatedTools()</code> 这个函数在整个工程里我一行都没写过。编译完之后，它出现在：</p>
<pre tabindex="0"><code>agent-server/build/generated/ksp/main/kotlin/com/example/adk/server/
├── CheckLibraryPlatformsTool.kt
├── ListAvailableVersionsTool.kt
└── KmpLibraryService_GeneratedTools.kt
</code></pre><p>后者的内容是：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">public</span> <span class="k">fun</span> <span class="nf">KmpLibraryService</span><span class="p">.</span><span class="n">generatedTools</span><span class="p">():</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">FunctionTool</span><span class="p">&gt;</span> <span class="p">=</span> <span class="n">listOf</span><span class="p">(</span> <span class="o">..</span><span class="p">.</span> <span class="p">)</span>
</span></span></code></pre></div><p>好处是运行时不需要反射，R8/混淆、Native 镜像、启动时间这几个方向上都干净。代价是工具签名改了必须重新编译，动态注册工具这种玩法就别想了。</p>
<h2 id="agent-本体">agent 本体</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">object</span> <span class="nc">KmpSupportAgent</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@JvmField</span>
</span></span><span class="line"><span class="cl">  <span class="k">val</span> <span class="py">rootAgent</span> <span class="p">=</span> <span class="n">LlmAgent</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">name</span> <span class="p">=</span> <span class="s2">&#34;kmp_library_platform_agent&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">model</span> <span class="p">=</span> <span class="n">Gemini</span><span class="p">(</span><span class="n">name</span> <span class="p">=</span> <span class="s2">&#34;gemini-3.1-flash-lite&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="n">instruction</span> <span class="p">=</span> <span class="n">Instruction</span><span class="p">(</span><span class="s">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">      You are an assistant for Kotlin Multiplatform developers.
</span></span></span><span class="line"><span class="cl"><span class="s">      When the user asks whether a library supports a platform, never answer
</span></span></span><span class="line"><span class="cl"><span class="s">      from memory. Call `checkLibraryPlatforms` and read the result.
</span></span></span><span class="line"><span class="cl"><span class="s">      ...
</span></span></span><span class="line"><span class="cl"><span class="s">    &#34;&#34;&#34;</span><span class="p">.</span><span class="n">trimIndent</span><span class="p">()),</span>
</span></span><span class="line"><span class="cl">    <span class="n">tools</span> <span class="p">=</span> <span class="n">KmpLibraryService</span><span class="p">().</span><span class="n">generatedTools</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>顺带说下 <code>@JvmField</code>。官方示例里每个 agent 的 <code>rootAgent</code> 都带着它，我一开始也照抄了，还以为它是必需的。其实它只是让 Kotlin <code>object</code> 的属性变成真正的静态字段（而不是 <code>INSTANCE.getRootAgent()</code>），方便 Java 调用方访问。我试着去掉它重新编译运行，服务端照样起来、<code>/list-apps</code> 照样返回——因为 <code>AdkServerConfig.inMemory(agent)</code> 是直接拿 agent 对象，全程不走反射。抄没问题，但别以为少了它就启动不了。</p>
<p>instruction 里我特意写清楚了一件事：<code>platform.type</code> 是个聚合标记，Kotlin/Native 的目标统一报 <code>native</code>，具体是哪个 Apple 目标只出现在<strong>变体名</strong>里。不写这句，模型容易看着 <code>native</code> 这个词就自己下结论，而它并不能从 <code>native</code> 推出到底有没有 iOS。</p>
<h2 id="起服务">起服务</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">val</span> <span class="py">config</span> <span class="p">=</span> <span class="nc">AdkServerConfig</span><span class="p">.</span><span class="n">inMemory</span><span class="p">(</span><span class="nc">KmpSupportAgent</span><span class="p">.</span><span class="n">rootAgent</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">AdkDevServer</span><span class="p">(</span><span class="n">config</span><span class="p">).</span><span class="n">start</span><span class="p">(</span><span class="n">wait</span> <span class="p">=</span> <span class="k">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p><code>AdkDevServer</code> 会顺带挂上开发界面。路由长这样：</p>
<table>
	<thead>
			<tr>
					<th>方法</th>
					<th>路径</th>
					<th>用途</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>GET</td>
					<td><code>/list-apps</code></td>
					<td>有哪些 agent，也就是 <code>appName</code> 能填什么</td>
			</tr>
			<tr>
					<td>GET</td>
					<td><code>/version</code></td>
					<td>服务端版本</td>
			</tr>
			<tr>
					<td>POST</td>
					<td><code>/apps/{appName}/users/{userId}/sessions</code></td>
					<td>建会话，返回会话 id</td>
			</tr>
			<tr>
					<td>POST</td>
					<td><code>/run</code></td>
					<td>跑一轮，一次性返回完整事件列表</td>
			</tr>
			<tr>
					<td>POST</td>
					<td><code>/run_sse</code></td>
					<td>跑一轮，SSE 流式返回</td>
			</tr>
			<tr>
					<td>GET</td>
					<td><code>/dev-ui</code></td>
					<td>开发界面</td>
			</tr>
	</tbody>
</table>
<p>实测三个能直接验：</p>
<pre tabindex="0"><code>$ curl -s localhost:8080/list-apps
[&#34;kmp_library_platform_agent&#34;]

$ curl -s localhost:8080/version
{&#34;version&#34;:&#34;1.1.0&#34;,&#34;language&#34;:&#34;kotlin&#34;,&#34;language_version&#34;:&#34;21.0.10&#34;}

$ curl -s -X POST -H &#34;Content-Type: application/json&#34; -d &#39;{}&#39; \
    localhost:8080/apps/kmp_library_platform_agent/users/demo-user/sessions
{&#34;id&#34;:&#34;b86f7324-81db-49df-8007-1700077ebdb4&#34;,&#34;appName&#34;:&#34;kmp_library_platform_agent&#34;,...}
</code></pre><p><code>/run_sse</code> 用的是标准 SSE，每个事件一行 <code>data: &lt;json&gt;</code>，后面跟一个空行。服务端的实现是手写的，因为 Ktor 2.x 没有 SSE 插件。源码里的注释挺有意思：</p>
<blockquote>
<p>Ktor 2.x has no SSE plugin, so stream manually. Use async respondBytesWriter, not respondTextWriter, whose blocking bridge parks via a coroutines internal gone in 1.11.0.</p>
</blockquote>
<blockquote>
<p>Ktor 2.x 没有 SSE 插件，所以手动流式输出。要用异步的 <code>respondBytesWriter</code>，不能用 <code>respondTextWriter</code>——它的阻塞桥接依赖了一个在 kotlinx-coroutines 1.11.0 里已经被删掉的内部实现。</p>
</blockquote>
<p>也就是说这一句注释本身在提醒你：升级 coroutines 版本可能把这个 API 弄坏。</p>
<h2 id="客户端写在-commonmain-里">客户端：写在 commonMain 里</h2>
<p>真正跨平台的部分在这里。请求构造和 SSE 解析全部写在 <code>commonMain</code>，不碰平台 API：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">run</span><span class="p">(</span><span class="n">prompt</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span> <span class="n">sessionId</span><span class="p">:</span> <span class="n">String</span><span class="p">?</span> <span class="p">=</span> <span class="k">null</span><span class="p">):</span> <span class="n">Flow</span><span class="p">&lt;</span><span class="n">Event</span><span class="p">&gt;</span> <span class="p">=</span> <span class="n">channelFlow</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">val</span> <span class="py">sid</span> <span class="p">=</span> <span class="n">sessionId</span> <span class="o">?:</span> <span class="n">createSession</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">  <span class="n">httpClient</span><span class="p">.</span><span class="n">preparePost</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">$baseUrl</span><span class="s2">/run_sse&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">contentType</span><span class="p">(</span><span class="nc">ContentType</span><span class="p">.</span><span class="nc">Application</span><span class="p">.</span><span class="n">Json</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setBody</span><span class="p">(</span><span class="n">RunRequest</span><span class="p">(</span><span class="n">appName</span><span class="p">,</span> <span class="n">userId</span><span class="p">,</span> <span class="n">sid</span><span class="p">,</span> <span class="nc">Content</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="n">prompt</span><span class="p">),</span> <span class="n">streaming</span> <span class="p">=</span> <span class="k">true</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="p">}.</span><span class="n">execute</span> <span class="p">{</span> <span class="n">response</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">channel</span> <span class="p">=</span> <span class="n">response</span><span class="p">.</span><span class="n">bodyAsChannel</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="p">(</span><span class="k">true</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">val</span> <span class="py">line</span> <span class="p">=</span> <span class="n">channel</span><span class="p">.</span><span class="n">readLine</span><span class="p">()</span> <span class="o">?:</span> <span class="k">break</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(!</span><span class="n">line</span><span class="p">.</span><span class="n">startsWith</span><span class="p">(</span><span class="s2">&#34;data:&#34;</span><span class="p">))</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">      <span class="k">val</span> <span class="py">payload</span> <span class="p">=</span> <span class="n">line</span><span class="p">.</span><span class="n">removePrefix</span><span class="p">(</span><span class="s2">&#34;data:&#34;</span><span class="p">).</span><span class="n">trim</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="n">payload</span><span class="p">.</span><span class="n">isEmpty</span><span class="p">())</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">      <span class="k">val</span> <span class="py">event</span> <span class="p">=</span> <span class="n">json</span><span class="p">.</span><span class="n">decodeFromString</span><span class="p">&lt;</span><span class="n">Event</span><span class="p">&gt;(</span><span class="n">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">send</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="n">event</span><span class="p">.</span><span class="n">turnComplete</span><span class="p">)</span> <span class="k">break</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>只有引擎是分平台的，用一个 <code>expect/actual</code> 收口：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="c1">// commonMain
</span></span></span><span class="line"><span class="cl"><span class="k">expect</span> <span class="k">fun</span> <span class="nf">createPlatformHttpClient</span><span class="p">(</span><span class="n">json</span><span class="p">:</span> <span class="n">Json</span><span class="p">):</span> <span class="n">HttpClient</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// jvmMain
</span></span></span><span class="line"><span class="cl"><span class="k">actual</span> <span class="k">fun</span> <span class="nf">createPlatformHttpClient</span><span class="p">(</span><span class="n">json</span><span class="p">:</span> <span class="n">Json</span><span class="p">)</span> <span class="p">=</span> <span class="n">HttpClient</span><span class="p">(</span><span class="n">CIO</span><span class="p">)</span> <span class="p">{</span> <span class="o">..</span><span class="p">.</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// iosMain
</span></span></span><span class="line"><span class="cl"><span class="k">actual</span> <span class="k">fun</span> <span class="nf">createPlatformHttpClient</span><span class="p">(</span><span class="n">json</span><span class="p">:</span> <span class="n">Json</span><span class="p">)</span> <span class="p">=</span> <span class="n">HttpClient</span><span class="p">(</span><span class="n">Darwin</span><span class="p">)</span> <span class="p">{</span> <span class="o">..</span><span class="p">.</span> <span class="p">}</span>
</span></span></code></pre></div><p>循环退出靠两个字段：<code>partial</code> 标记这是一个还没说完的流式分片，<code>turnComplete</code> 标记这一轮结束。没有 <code>turnComplete</code> 就得靠连接关闭来判断，那会拖到超时。</p>
<h2 id="那个-400">那个 400</h2>
<p>现在讲开头说的坑。</p>
<p>我照着自己的理解写请求体，用了 <code>app_name</code> / <code>user_id</code> / <code>new_message</code> 这套命名——毕竟 Python 版 ADK 就是这样，而且我确实在仓库源码里看到 <code>ApiModels.kt</code> 给每个多词属性都挂了一个别名：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nd">@JsonNames</span><span class="p">(</span><span class="s2">&#34;app_name&#34;</span><span class="p">)</span> <span class="k">val</span> <span class="py">appName</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="nd">@JsonNames</span><span class="p">(</span><span class="s2">&#34;user_id&#34;</span><span class="p">)</span> <span class="k">val</span> <span class="py">userId</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span></code></pre></div><p>请求发出去：</p>
<pre tabindex="0"><code>HTTP/1.1 400 Bad Request
Content-Length: 0
</code></pre><p>没有 body，没有堆栈，服务端日志里连一行 ERROR 都没有。试着换成 camelCase 再发一次，返回变成 <strong>500</strong>——这说明反序列化通过了，失败发生在更后面（模型调用）。也就是说：</p>
<ul>
<li>camelCase → 解析成功，一路走到 Gemini SDK</li>
<li>snake_case → 根本没进到业务逻辑</li>
</ul>
<p><code>@JsonNames</code> 在 kotlinx-serialization 里是「备用名」，理论上两种拼写都应该认。我甚至单独写了个最小复现，用同样的注解和同样的 Json 配置，snake_case 解析是<strong>成功</strong>的。所以问题不在 kotlinx。</p>
<p>问题在版本。我去翻了 tag：</p>
<pre tabindex="0"><code>v1.1.0 的 ApiModels.kt：
  data class AgentRunRequest(
    val appName: String,
    val userId: String,
    ...
  )
  → 一个 @JsonNames 都没有
</code></pre><p>那两个别名提交 <code>94dcdd13</code>（<code>feat: accept snake_case and camelCase in web server request bodies</code>）的日期是 <strong>2026-09-15</strong>，进的是 <code>main</code>，<strong>在 <code>v1.1.0</code> tag 之后</strong>。仓库里还有个测试文件写着「服务端读 snake_case」——它描述的是 main 的行为，不是你能下载到的 1.1.0 的行为。</p>
<p>拿真实的 <code>AgentRunRequest</code> 和真实的 <code>adkJson</code> 解码 snake_case，报错很明确：</p>
<pre tabindex="0"><code>kotlinx.serialization.MissingFieldException: Fields [appName, userId] are required
for type with serial name &#39;...AgentRunRequest&#39;, but they were missing
</code></pre><p>所以规则是：<strong>对 1.1.0，一律用 camelCase。</strong> snake_case 要等下一个版本。</p>
<p>这件事的教训不是「要看 tag」，而是「读 main 的源码不等于读你依赖的那个版本」。我如果没真的把请求发出去试一遍，这篇文章里的示例代码就是照抄一个未发布行为、一跑就 400 的代码。</p>
<p>真要说自查手法，<code>/version</code> 这个端点很有用——服务端会自报版本，然后去对照那个 tag 的源码，比对着 main 猜要靠谱。</p>
<h2 id="工具跑出来的样子">工具跑出来的样子</h2>
<p>这一段不需要模型，工具是纯 Kotlin，可以直接跑：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./gradlew :agent-server:runTools
</span></span></code></pre></div><p>先拿 ADK 自己开刀：</p>
<pre tabindex="0"><code>com.google.adk:google-adk-kotlin-core:1.1.0
platform.type values: androidJvm, common, jvm
variants mentioning iOS: 0
Verdict: no iOS target. This library cannot be consumed from an
iosArm64 / iosSimulatorArm64 source set.
</code></pre><p>再拿 Ktor 对照：</p>
<pre tabindex="0"><code>io.ktor:ktor-client-core:3.6.0
platform.type values: common, js, jvm, native, wasm
variants mentioning iOS: 9
  - iosArm64ApiElements-published
  - iosSimulatorArm64ApiElements-published
  - iosX64ApiElements-published
  ...（共 9 个，含 Api / Metadata / Sources 三类）
Verdict: iOS targets are published, so Apple targets can depend on it.
</code></pre><p>同一个函数、同一套判断逻辑，两个库的结论正好相反。这个反差下面单独说。</p>
<p>版本查询和错误路径也顺带看一眼：</p>
<pre tabindex="0"><code>com.google.adk:google-adk-kotlin-core — latest=1.1.0
recent: 0.5.0, 0.6.0, 0.7.0, 0.8.0, 0.9.0, 1.0.0, 1.0.1, 1.1.0

Could not fetch metadata for com.google.adk:does-not-exist:9.9.9 (HTTP 404).
The coordinates or version are probably wrong.
</code></pre><p>错误路径返回的是一句人话，不是抛异常。工具返回给模型的是字符串，异常直接把这一轮对话打断，模型连「这个库不存在」都说不出来。</p>
<h2 id="跨端这件事">跨端这件事</h2>
<p><code>shared</code> 这块是真跨平台。我实际编译过：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ ./gradlew :shared:compileKotlinIosSimulatorArm64 :shared:compileKotlinIosArm64
</span></span><span class="line"><span class="cl">BUILD SUCCESSFUL in 33s
</span></span></code></pre></div><p>同一份 <code>commonMain</code> 代码，JVM 和 iOS 都过。</p>
<p>而 <code>agent-server</code> 永远只能在 JVM（或者 Android）上。这不是配置问题——<code>google-adk-kotlin-core</code> 发布到 Maven Central 的 Gradle 变体只有 <code>common</code> / <code>androidJvm</code> / <code>jvm</code> 三个，<code>google-genai-kotlin</code> 也一样。<strong>没有 iOS 变体，也没有 Native 变体。</strong></p>
<p>所以如果你想在 iOS 上跑 agent，路只有两条：把它放在服务端，iOS 只做客户端（就像这个工程的结构）；或者等上游补 Native 支持。我为此在 ADK 仓库开了个 issue，编号 <a href="https://github.com/google/adk-kotlin/issues/504">#504</a>，目前还没回复。</p>
<p>顺带一说，这个「agent 只在服务端」的切分其实多数场景更合理——模型密钥不会进客户端包，工具执行和权限检查也都在你能控住的地方。</p>
<h2 id="怎么跑">怎么跑</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">GOOGLE_API_KEY</span><span class="o">=</span>...
</span></span><span class="line"><span class="cl">./run-stack.sh
</span></span></code></pre></div><p>这个脚本干四件事：把服务端装出来并起在 8080、轮询 <code>/list-apps</code> 等它真就绪、跑客户端、跑完收掉服务端并把客户端的退出码原样传出来。</p>
<p>（第一版这个脚本是漏的：它借 Gradle 生成的启动脚本起服务，而那个脚本把 java 当子进程跑，<code>kill</code> 只杀掉外壳，服务端会变成孤儿一直占着 8080。改成直接起 java，<code>$!</code> 才是服务端本体。）</p>
<p>也可以拆开：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./gradlew :agent-server:run     <span class="c1"># 终端 1</span>
</span></span><span class="line"><span class="cl">./gradlew :shared:runClient     <span class="c1"># 终端 2</span>
</span></span></code></pre></div><p>最后交代清楚我验证到哪一步了，别让你照着跑的时候产生落差。</p>
<p>跑通的：整个工程编译通过（含 KSP 代码生成）；服务端启动；<code>/list-apps</code>、<code>/version</code>、建会话都返回 200；客户端发出的 <code>/run_sse</code> 请求被服务端接受，并进入 <code>com.google.genai.kotlin</code> 的模型调用层。</p>
<p>没跑通的：<strong>一次真实的模型对话</strong>。这需要你自己的 Gemini API key，我这边没有。所以文章开头那个「查 iOS 支持情况」的完整对话输出，我没有贴——它得接上有效 key 才能看到，我不想贴一段没跑过的输出。</p>
<p>代码在 <a href="https://github.com/trewvip-arch/adk-kotlin-agent-quickstart">https://github.com/trewvip-arch/adk-kotlin-agent-quickstart</a></p>
<p>Apache-2.0。有问题直接在仓库里开 issue。</p>
]]></content:encoded></item><item><title>ADK for Kotlin 支持 iOS 吗？我拆开它的 Gradle 元数据查了一遍</title><link>http://mings.in/posts/adk-kmp-ios/</link><pubDate>Tue, 22 Sep 2026 07:11:05 +0800</pubDate><guid>http://mings.in/posts/adk-kmp-ios/</guid><description>之前介绍 ADK for Kotlin 1.0 的时候，我写过一句&amp;quot;未来如果 iOS 侧也支持，那就是一套代码三端复用&amp;quot;。当时这个词用得很滑——它跟&amp;quot;KMP 核心&amp;quot;这句话摆在一起，很容易让人读成&amp;quot;iOS 应该也能跑&amp;quot;。</description><content:encoded><![CDATA[<h2 id="先认个错">先认个错</h2>
<p>之前介绍 ADK for Kotlin 1.0 的时候，我写过一句&quot;未来如果 iOS 侧也支持，那就是一套代码三端复用&quot;。当时这个词用得很滑——它跟&quot;KMP 核心&quot;这句话摆在一起，很容易让人读成&quot;iOS 应该也能跑&quot;。</p>
<p>后来有读者来问：它既然是 Kotlin Multiplatform 写的，那 iOS 是不是本来就能用？</p>
<p>这个问题我当初不该带过。所以这次我自己去查了，查的方式不是看文档怎么说，而是直接拆它的构建产物。</p>
<p>结论先放这儿：<strong>不能。而且这不是加一行 target 就能解决的事。</strong></p>
<h2 id="查一个-kmp-库到底支持什么有个很硬的地方">查一个 KMP 库到底支持什么，有个很硬的地方</h2>
<p>先说方法，因为这个方法比这次结论更有用。</p>
<p>KMP 库在发布时会生成一份 <strong>Gradle module metadata</strong>（<code>.module</code> 文件），里面列着这个库实际编译出来的所有平台变体。它是构建系统的产物，不是谁写的宣传文案——一个库说自己&quot;支持多平台&quot;，但元数据里没有对应的变体，那构建时就是拿不到，编译直接失败。</p>
<p>所以想知道一个库的真实平台覆盖，一行命令就够：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 通用查法：把 group 换成包名路径</span>
</span></span><span class="line"><span class="cl">curl -sL <span class="s2">&#34;https://repo1.maven.org/maven2/com/google/adk/google-adk-kotlin-core/1.1.0/google-adk-kotlin-core-1.1.0.module&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">import sys, json
</span></span></span><span class="line"><span class="cl"><span class="s2">d = json.load(sys.stdin)
</span></span></span><span class="line"><span class="cl"><span class="s2">vs = d.get(&#39;variants&#39;, [])
</span></span></span><span class="line"><span class="cl"><span class="s2">plats = sorted({v[&#39;attributes&#39;].get(&#39;org.jetbrains.kotlin.platform.type&#39;, &#39;?&#39;) for v in vs})
</span></span></span><span class="line"><span class="cl"><span class="s2">names = [v[&#39;name&#39;] for v in vs]
</span></span></span><span class="line"><span class="cl"><span class="s2">ios = [n for n in names if &#39;ios&#39; in n.lower()]
</span></span></span><span class="line"><span class="cl"><span class="s2">print(&#39;platform.type :&#39;, &#39;, &#39;.join(plats))
</span></span></span><span class="line"><span class="cl"><span class="s2">print(&#39;含 ios 的变体 :&#39;, len(ios), &#39;个&#39;)
</span></span></span><span class="line"><span class="cl"><span class="s2">for n in ios[:4]: print(&#39;   -&#39;, n)
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;</span>
</span></span></code></pre></div><p>拿 ADK for Kotlin 跑一下，输出是：</p>
<pre tabindex="0"><code>platform.type : androidJvm, common, jvm
含 ios 的变体 : 0 个
</code></pre><h2 id="怎么读这个结果">怎么读这个结果</h2>
<p>有个地方容易看错，我自己第一次就差点搞混：<strong>iOS target 不会出现在 <code>platform.type</code> 里。</strong></p>
<p>Kotlin/Native 在 <code>platform.type</code> 上统一标记为 <code>native</code>，具体是哪个 Apple 平台，看的是<strong>变体名</strong>。所以正确的读法是两层一起看：</p>
<ul>
<li><code>platform.type</code> 有 <code>native</code> → 说明这个库编译了 Kotlin/Native 目标</li>
<li><strong>变体名里出现 <code>iosArm64</code> / <code>iosSimulatorArm64</code> / <code>iosX64</code></strong> → iOS 才是真能用</li>
</ul>
<p>拿一个确定支持 iOS 的库对照一下，比如 Ktor：</p>
<pre tabindex="0"><code>platform.type : common, js, jvm, native, wasm
含 ios 的变体 : 9 个
   - iosArm64ApiElements-published
   - iosArm64SourcesElements-published
   - iosSimulatorArm64ApiElements-published
   ...
</code></pre><p>64 个变体，9 个带 ios。这才是&quot;真支持 iOS&quot;的样子。</p>
<p>ADK 那边是 8 个变体，<code>platform.type</code> 只有 androidJvm / common / jvm，<strong>带 ios 的变体是 0 个</strong>。</p>
<p>我顺手验了一遍所有版本，包括最新的 1.1.0：都没有 iOS。</p>
<p>再看源码结构，结论是一致的。<code>core/src/</code> 下面的 source set 是这些：</p>
<pre tabindex="0"><code>androidMain
commonJvmAndroidMain      ← 注意这个名字
commonMain
jvmMain
（没有 iosMain）
</code></pre><p>而 <code>core/build.gradle.kts</code> 里的 target 声明，只有 <code>jvm()</code> 加 Android，一行 iOS 都没有。</p>
<p>Maven Central 上 <code>com.google.adk</code> 这个 group 下，<strong>iOS 相关的 artifact 是 0 个。</strong></p>
<h2 id="三道坎一道比一道难">三道坎，一道比一道难</h2>
<p>知道&quot;没有 iOS&quot;还不够，值得看看为什么没有。因为答案决定了&quot;自己动手&quot;要花多大代价。</p>
<h3 id="坎一他们特意劈了一层叫-commonjvmandroidmain">坎一：他们特意劈了一层叫 <code>commonJvmAndroidMain</code></h3>
<p>这个 source set 名字本身就是答案——JVM 和 Android 共享的那部分。它 <code>dependsOn(commonMain)</code>，里面装着：</p>
<table>
	<thead>
			<tr>
					<th>依赖</th>
					<th>iOS 上的状况</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>kxml2</code>（XML 解析）</td>
					<td>没有 Kotlin/Native 版本，得换库或写 actual</td>
			</tr>
			<tr>
					<td><code>snakeyaml</code>（YAML）</td>
					<td>同上</td>
			</tr>
			<tr>
					<td><code>opentelemetry-api</code>（Java 版遥测）</td>
					<td>iOS 要接 OpenTelemetry 的 Swift 实现，整层得做成可插拔</td>
			</tr>
			<tr>
					<td><code>reactive-streams</code></td>
					<td><strong>JVM 独有规范，Apple 平台根本没有对应物</strong></td>
			</tr>
	</tbody>
</table>
<p>那条 <code>reactive-streams</code> 是关键信号。它是 JVM 世界的响应式流规范，苹果那边不存在这个东西。而 ADK 的 Java 互操作签名就建立在这上面——源码注释里的原话是 &ldquo;the api-vs-implementation question for the Java-interop signatures&rdquo;。</p>
<p>作者其实很清楚代码分两层：一层是真正的跨平台公共逻辑，一层是&quot;JVM 和 Android 共享&quot;的逻辑。<strong>他们特意做了这个区分，就是因为它现在只想支持这两个平台。</strong></p>
<h3 id="坎二jvmmain-里也全是-jvm-专属">坎二：<code>jvmMain</code> 里也全是 JVM 专属</h3>
<p><code>gson</code>、<code>google-cloud-storage</code>、<code>mcp</code>（MCP 的 Java SDK）、<code>ktor</code>。Google Cloud Storage 和 MCP 的 Java SDK 都没有 iOS 版本，会话持久化和云存储那部分要另给 iOS 写一套。</p>
<h3 id="坎三这条最要命google-genai-kotlin-自己也不支持-ios">坎三（这条最要命）：<code>google-genai-kotlin</code> 自己也不支持 iOS</h3>
<p><code>commonMain</code> 的依赖里有这么一行：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">commonMain</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">api</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">google</span><span class="p">.</span><span class="n">genai</span><span class="p">.</span><span class="n">kotlin</span><span class="p">)</span>   <span class="c1">// com.google.genai:google-genai-kotlin:1.0.0
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>我拿同一个方法查了它：</p>
<pre tabindex="0"><code>平台变体: androidJvm, common, jvm
</code></pre><p><strong>Google 自家那个生成式 AI 的 Kotlin SDK，也没有 iOS target。</strong></p>
<p>而且它是 <code>api</code> 依赖，不是 <code>implementation</code>——意思是它出现在所有消费者的编译路径上，绕不过去。</p>
<p>所以真相是：<strong>想让 ADK for Kotlin 跑 iOS，你得先把 Google 的 GenAI Kotlin SDK 移植到 iOS。</strong> 那是另一个 SDK 的工程量，而且东西在 Google 手里，不在你能改的范围内。</p>
<h2 id="那自己编译支持-ios到底意味着什么">那&quot;自己编译支持 iOS&quot;到底意味着什么</h2>
<p>不是 fork 一下加三行 target。粗略拆一下：</p>
<table>
	<thead>
			<tr>
					<th>要做的事</th>
					<th>量级</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>补 iOS target + 各层 actual 实现（XML、YAML、遥测、持久化、网络）</td>
					<td>中到大</td>
			</tr>
			<tr>
					<td><code>reactive-streams</code> 换成 Flow，重设计 API（会破坏 Java 互操作承诺）</td>
					<td>中</td>
			</tr>
			<tr>
					<td>移植 google-genai-kotlin 到 iOS，或绕开它直连 REST</td>
					<td><strong>大</strong></td>
			</tr>
	</tbody>
</table>
<p>后两项都不在你的仓库里。尤其第三项——那是要往上动别人的东西。</p>
<h2 id="110-的方向反而把这件事说清楚了">1.1.0 的方向，反而把这件事说清楚了</h2>
<p>ADK for Kotlin 1.1.0 是 9 月 15 日发的（1.0 是 9 月 7 日 GA，1.0.1 是 9 月 10 日，节奏很快）。看它的新增特性：</p>
<ul>
<li><code>AppFunctionsToolset</code>，对接 Android 的 AppFunctions</li>
<li>Android AppFunctions 转换层</li>
<li>把 app 的 AppFunctions 说明注入模型指令</li>
<li>针对 Java 的 <code>BaseFutureSkillSource</code> 基类、长时运行工具的 Java 版示例</li>
<li><code>Plugin.onRunError</code> 回调</li>
</ul>
<p>修的问题也在同一片区域：未知工具名不再终止整个调用、workflow agent 的会话恢复修正、MCP server 拒绝请求时保持 turn 存活。</p>
<p>注意这些功能的重心。1.1.0 里最实在的新能力是 <strong>Android AppFunctions</strong> 那条线——让 agent 去调用 Android 应用暴露的函数。</p>
<p>这说明 iOS 的缺口不是疏忽，是<strong>战略选择</strong>。Google 的精力在 Android 侧：Gemini Nano、ML Kit、LiteRT-LM、Firebase AI、现在的 AppFunctions，全是为 Android 铺的。ADK for Kotlin 的 &ldquo;Kotlin Multiplatform&rdquo; 目前是 &ldquo;Kotlin Multiplatform（Android + JVM）&ldquo;的缩写——那个 common 层是给这两个平台共享用的，不是给 iOS 预留的。</p>
<h2 id="那做-ios-的人怎么办">那做 iOS 的人怎么办</h2>
<p>三条路，按我的推荐顺序。</p>
<p><strong>路线 A：Agent 放服务端，iOS 只做客户端（推荐）</strong></p>
<p>ADK for Kotlin 的 <strong>JVM 那条腿是完整可用的</strong>——这是它的主战场之一，不是残废版。</p>
<p>所以把 agent 逻辑跑在服务端 JVM 上，iOS 通过 HTTP 调过去。而 ADK 生态里正好有为此准备的东西：A2A（Agent-to-Agent）协议，<code>google-adk-kotlin-a2a</code> 这个模块就是干这个的（它同样只有 JVM 版，但服务端本来就用 JVM）。</p>
<p>代价是失去端侧能力——隐私、离线、低延迟这三样没法兼顾。
好处是今天就能用，零移植风险。</p>
<p>对大多数产品来说这个取舍是划算的：你的 agent 需要联网调模型，那它本来就不是纯离线的。</p>
<p><strong>路线 B：自己写一层&quot;ADK-lite for iOS&rdquo;</strong></p>
<p>只取 commonMain 的设计思路（编排、会话、记忆、工具 schema），在 iOS 侧用 KMP 生态里现成的积木重搭：</p>
<ul>
<li>Ktor 做网络（KMP 原生支持 iOS）</li>
<li>直接调 Gemini REST API，绕开 <code>google-genai-kotlin</code></li>
<li>SQLDelight 做持久化</li>
</ul>
<p>但这就不是&quot;用 ADK&quot;了，是照着它的设计自己实现一遍。业务逻辑能共享，框架得重写。</p>
<p><strong>路线 C：等，或者催</strong></p>
<p>1.0 到 1.1.0 只隔了 8 天，迭代速度不慢。如果 iOS 在路线图上，等是划算的。</p>
<p>顺便说一件我顺手做的事：我在仓库里开了个 issue 把这件事问清楚了——</p>
<blockquote>
<p><a href="https://github.com/google/adk-kotlin/issues/504">https://github.com/google/adk-kotlin/issues/504</a></p>
</blockquote>
<p>问了三件事：iOS 在不在路线图上、真正的阻塞点是什么（我怀疑是 GenAI Kotlin SDK 缺 native target，而不是 ADK 本身）、现阶段官方推荐什么架构。</p>
<p>之所以觉得这个 issue 有价值：这个仓库目前总共只有几个 issue，而其中一个上个月提的 feature request 两天内就被打了 <code>need attention</code> 标签。说明团队确实在看。有 iOS 需求的同学可以去点个赞，需求信号比什么都管用。</p>
<h2 id="比结论更值钱的是那个查法">比结论更值钱的是那个查法</h2>
<p>回到开头那个方法。</p>
<p>以后你评估任何 KMP 库——不管是 ADK、还是别的什么&quot;跨平台&quot;框架——别只看 README 和发布说明。README 会写 &ldquo;runs on all supported platforms&rdquo; 这种话，看着很全，但 &ldquo;supported&rdquo; 是谁定义的、支持到什么程度，文档通常不说。</p>
<p><strong>去看 <code>.module</code> 文件。</strong> 那里面列的是构建系统真正能拿到的东西：</p>
<ul>
<li>有 <code>iosArm64</code> / <code>iosSimulatorArm64</code> → iOS 真能用</li>
<li>有 <code>js</code> / <code>wasmJs</code> → Web 真能用</li>
<li>只有 <code>common</code> / <code>jvm</code> / <code>androidJvm</code> → 那就是只有 JVM 和安卓</li>
</ul>
<p>一行 curl 就能做完的事，能省掉你两周的重构。</p>
<p>KMP 这两年最大的认知陷阱就是这个：<strong>&ldquo;基于 KMP 构建&quot;和&quot;KMP 全平台可用&quot;是两件不同的事。</strong> 前者说的是代码怎么组织的，后者说的才是你能拿到什么。ADK for Kotlin 是个很好的例子——它的内核确实是精心设计过的跨平台架构，但它目前选择把 iOS 那扇门关着。</p>
<hr>
<p><em>核查方式：直接读取 <code>com.google.adk:google-adk-kotlin-core</code> 各版本（含 1.1.0）在 Maven Central 上的 Gradle module metadata、<code>core/build.gradle.kts</code> 与 <code>core/src/</code> 的 source set 结构、<code>com.google.genai:google-genai-kotlin</code> 的平台变体，并核对了 ADK for Kotlin v1.0.0 / v1.0.1 / v1.1.0 的 Release Notes。</em></p>
]]></content:encoded></item><item><title>KMP 的第四条腿：跑进 WebAssembly</title><link>http://mings.in/posts/kmp-wasm/</link><pubDate>Mon, 21 Sep 2026 08:25:56 +0800</pubDate><guid>http://mings.in/posts/kmp-wasm/</guid><description>Android、iOS、桌面，这是 KMP 这几年稳定覆盖的三块地方。第四条腿是 WebAssembly——上周发的 Chasm 2.0 就跟这个方向有关。</description><content:encoded><![CDATA[<h2 id="kmp-的版图">KMP 的版图</h2>
<p>Android、iOS、桌面，这是 KMP 这几年稳定覆盖的三块地方。第四条腿是 WebAssembly——上周发的 Chasm 2.0 就跟这个方向有关。</p>
<p>不过在看它之前，有个特别容易搞混的地方得先说清楚，不然容易对整条路线的判断跑偏。</p>
<h2 id="chasm一个用-kotlin-写的-wasm-运行时">Chasm：一个用 Kotlin 写的 Wasm 运行时</h2>
<p>它的自我介绍就一句话：Chasm is a WebAssembly runtime built on Kotlin Multiplatform。翻译过来是&quot;用 KMP 构建的 WebAssembly 运行时&quot;。</p>
<p>注意语序。它是&quot;用 Kotlin 写了个能跑 Wasm 的东西&quot;，不是&quot;把 Kotlin 编译成 Wasm&quot;。这两件事是反的，后面细说。</p>
<p>项目现在 227 star（我第一版写的时候是 221），支持到 Wasm 3.0 规范（除了 Memory64 和向量指令），实现了 Tail Call、Typed Function References、Wasm GC、Multiple Memories、Exception Handling、Wide Arithmetic 这几组提案。能跑 GC 和异常处理的运行时，已经不算玩具了。</p>
<p>2.0 是 9 月 16 日发的，作者明说了是 breaking changes：新增 Android target（min SDK 28）；JVM target 最低提到 JDK 17；重写 Host Function API，以前是传一个值列表进去、返回一个值列表，现在改成直接读写运行时的栈；移除了废弃的 macosX64 和 iosX64 target；Gradle 插件不再支持 AGP 8。</p>
<p>补一句进度：截至我写这段（9 月 21 日），2.0 之后仓库还没有新提交、也没有新 release，作者在收反馈的阶段。这个节奏是对的——大版本刚发，先稳住再迭代。</p>
<p>里面最让我注意的是作者交代的一句设计取舍：</p>
<blockquote>
<p>我想在每个平台的原生约束内做所有事，那意味着不用 FFI 之类的东西。</p>
</blockquote>
<p>做跨平台的人能体会这句话的重量。通常要让 JVM 调一个 Wasm 运行时，最省事的做法是套一层 C 接口，但他选了最费劲的那条路——每个平台写原生实现。代价是工作量翻倍，好处是不依赖 FFI、不引入 ABI 兼容的麻烦。</p>
<h2 id="别搞混这两个方向是反的">别搞混：这两个方向是反的</h2>
<p>我猜不少同学看到&quot;Kotlin&quot;和&quot;Wasm&quot;放一起，第一反应是&quot;KMP 支持 Wasm 了？&quot;</p>
<p>不是。这是两件方向相反的事。</p>
<p>Kotlin/Wasm 是官方的编译目标，做的事是把 Kotlin 源码编译成 .wasm，丢到浏览器里跑。方向是往外送。</p>
<p>Chasm 干的是相反的活：拿现成的 .wasm 文件，在自己的运行时里执行，跑在 JVM、Android、iOS 上。方向是往里接。</p>
<p>搞混这两个，对整条路线的理解就全歪了。</p>
<h2 id="kmp-的第四条腿指的是官方那条">KMP 的第四条腿，指的是官方那条</h2>
<p>官方文档写得很直白：Kotlin/Wasm 能把 Kotlin 编译成 WebAssembly，用来做基于 Compose Multiplatform 的 Web 应用，或者跑在浏览器外的独立 Wasm 虚拟机里（后者靠 WASI 访问平台能力）。</p>
<p>两个要点。</p>
<p>一是 Compose Multiplatform 的 Web 平台，编译目标就是 Kotlin/Wasm。也就是说 Compose 写的界面真的能跑在浏览器里，和 Android、iOS、桌面共用同一套 UI 代码。这才是&quot;第四条腿&quot;的实际含义。</p>
<p>二是它对浏览器有硬要求：得支持 WebAssembly 的垃圾回收（WasmGC）和异常处理这两个提案。我一开始以为 Safari 会是个大坑，查完官方资料才发现门槛比想象的低。具体版本是：</p>
<table>
	<thead>
			<tr>
					<th>浏览器</th>
					<th>最低版本</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Chrome / Chromium 系（Edge、Brave、Opera、三星浏览器）</td>
					<td>119</td>
			</tr>
			<tr>
					<td>Firefox</td>
					<td>120</td>
			</tr>
			<tr>
					<td>Safari / WebKit</td>
					<td>18.2</td>
			</tr>
			<tr>
					<td>Node（跑在浏览器外时）</td>
					<td>22.0</td>
			</tr>
	</tbody>
</table>
<p>Safari 那条值得单独解释。18.2 是 2024 年底的版本，对应 iOS 18.2 / macOS 15.2；而在 iOS 和 iPadOS 上 Safari 是跟着系统走的，用户不单独更新它。所以这条门槛翻译过来其实是「用户的设备别太老」，而不是「别用 Safari」。这条要是判断错了，选型结论会完全反过来。</p>
<p>还有个细节值得知道：异常处理提案现在有新旧两版。Kotlin 工具链两版都支持，但 <code>wasmJs</code> target 默认用的是旧版（legacy）——为了兼容面更宽。想换新版得显式配置。</p>
<h2 id="实测wasm-端的成本">实测：Wasm 端的成本</h2>
<p>拿本机一个真实的 KMP 项目测的（含 JS 和 Wasm 双 target）。先说写第一版时的数据，环境是 Kotlin 2.4.0 + CMP 1.11.1：</p>
<pre tabindex="0"><code>$ ./gradlew :app:webApp:wasmJsBrowserDistribution
→ BUILD SUCCESSFUL in 4m 10s
→ 50 actionable tasks: 50 executed
</code></pre><p>4 分 10 秒，还是依赖已缓存的情况。</p>
<p>产物是这些：</p>
<table>
	<thead>
			<tr>
					<th>文件</th>
					<th>体积</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>6e23e542&hellip;wasm（skiko 渲染）</td>
					<td>8.25 MiB</td>
			</tr>
			<tr>
					<td>212a0e38&hellip;wasm（业务代码）</td>
					<td>5.97 MiB</td>
			</tr>
			<tr>
					<td>webApp.js</td>
					<td>529 KiB</td>
			</tr>
			<tr>
					<td>index.html + styles.css</td>
					<td>约 1 KB</td>
			</tr>
	</tbody>
</table>
<p>两个 wasm 加起来 14.2 MiB，整个目录大概 15MB。</p>
<p>然后我把项目临时升到 <strong>Kotlin 2.4.20 + CMP 1.12.0</strong> 又跑了一遍：</p>
<pre tabindex="0"><code>$ ./gradlew :app:webApp:wasmJsBrowserDistribution
→ BUILD SUCCESSFUL in 3m 33s
→ 50 actionable tasks: 48 executed, 2 up-to-date
</code></pre><table>
	<thead>
			<tr>
					<th></th>
					<th>Kotlin 2.4.0 + CMP 1.11.1</th>
					<th>Kotlin 2.4.20 + CMP 1.12.0</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>构建耗时</td>
					<td>4m 10s</td>
					<td><strong>3m 33s</strong></td>
			</tr>
			<tr>
					<td>skiko wasm</td>
					<td>8.25 MiB</td>
					<td>8.24 MiB</td>
			</tr>
			<tr>
					<td>业务 wasm</td>
					<td>5.97 MiB</td>
					<td><strong>5.67 MiB</strong></td>
			</tr>
			<tr>
					<td>webApp.js</td>
					<td>529 KiB</td>
					<td>526 KiB</td>
			</tr>
			<tr>
					<td>wasm 合计</td>
					<td>14.22 MiB</td>
					<td><strong>13.91 MiB</strong></td>
			</tr>
	</tbody>
</table>
<p>变化不算剧烈，但方向是清楚的：构建快了半分钟多，业务 wasm 缩了 0.30 MiB（约 5%）。</p>
<p><strong>得说清楚这不是严格对等的比较</strong>——第二次跑有两个任务命中了缓存，所以 3m33s 里有一小部分是省下来的，不是代码变快了。但&quot;这条线在持续变快变小&quot;这个结论是站得住的。</p>
<p>对比一下桌面端（JVM 打包）的数据：那边是 133MB，差了差不多九倍。原因也简单——桌面那 133M 里 77M 是 JVM 运行时、20M 是 Skia，而浏览器里这两样都不用打包，没有 JVM，渲染直接交给浏览器的 Canvas 和 WebGL。</p>
<p>这就是 Wasm 端最实在的好处，不用背运行时的包袱。</p>
<p>不过 webpack 在构建时给了一串警告，说几个资源超出了建议上限 244 KiB，点名了 webApp.js 的 529 KiB 和两个 wasm。8.25 MiB 比它建议的数字大了三十多倍。意思是不做代码分割、不做按需加载的话，用户第一次打开页面就得下 15MB。弱网环境下这是实打实的体验问题。</p>
<h2 id="顺带说说这一个月-web-端补了什么">顺带说说这一个月 Web 端补了什么</h2>
<p>升版本的时候我顺手翻了两份 release notes，有几条对做中文应用的人特别有用，值得单独拎出来。</p>
<p><strong>CMP 1.12（8 月 25 日）的 Web 改动</strong>：</p>
<ul>
<li><strong>Noto 字体自动加载</strong>——遇到主题字体没覆盖的符号时，会自动去加载 Noto。做中文或多语言应用的人应该知道这意味着什么：以前生僻字直接显示成豆腐块</li>
<li>专门修了两个豆腐块问题：输入框首次绘制时未知符号的 tofu，以及同一段文本第二次及后续绘制时的 tofu</li>
<li>修了 <code>div#cmp_a11y_root</code> 尺寸为 0×0 的 bug。这个挺严重——它直接导致 Compose 渲染出来的内容，对基于命中测试的无障碍工具是不可见的</li>
<li>滚轮事件现在两个轴都传（修了斜向滚动）、Chrome 上加了精确的触控板手势识别、修了 <code>pointercancel</code> 之后指针状态残留的问题（系统手势、掌拒、设备断开都会触发这个）</li>
<li>移动浏览器上聚焦输入框导致软键盘被隐藏的问题也修了</li>
<li>另外 web target 补上了触觉反馈支持和 <code>Modifier.keepScreenOn</code></li>
</ul>
<p><strong>CMP 1.13.0-alpha01（9 月 10 日）</strong>：</p>
<ul>
<li>删掉了废弃的 <code>compose.web.targets(...)</code> Gradle 插件 API。如果你老的构建脚本里还留着这个，升级会直接报错</li>
<li>实验性支持 Web 上的系统窗口 insets</li>
<li>新增渲染 WebGL 纹理的实验性 API</li>
<li>浏览器或设备不支持 WebGL2 时，给一个看得懂的提示，而不是一个晦涩的崩溃</li>
</ul>
<p><strong>Kotlin 2.4.20（9 月 7 日）的 Wasm 后端动静更大</strong>——这次 release notes 里跟 Wasm 相关的条目有六十多条。挑三条你迟早会碰到的：</p>
<ul>
<li><strong>默认开启 range checks</strong>（越界检查）。安全性提升，代价是一点性能</li>
<li><strong>弃用并禁止 <code>wasmExports</code></strong>。如果你在用 JS 互操作直接摸 wasm 的导出，升级会挂</li>
<li>开始做「用 stack-switching 提案生成 suspend 函数」的原型。这条现在还用不上，但方向值得记住——它是 Wasm 未来处理异步的官方路线</li>
</ul>
<p>还有一条升级注意事项：这次为了重构可调用引用、生成统一的 fun interface 基类，<strong>破坏了 klib 的二进制兼容性</strong>。如果你的依赖里有别人发布的 wasmJs klib，可能需要对方也重新发布一次。这类破坏在多平台生态里比 JVM 那边敏感得多——升级前先跑一遍构建，别等到发版才发现。</p>
<h2 id="适合什么不适合什么">适合什么，不适合什么</h2>
<p>最划算的场景是已经有 KMP 项目、想白捡一个 Web 端，跟桌面端一样，加个 target 的事。内部工具、管理后台也行，不在乎首屏 15MB，在乎一套代码。</p>
<p>能真正发挥优势的是逻辑复杂、UI 相对固定的应用，共享的业务代码越多，这笔账越划算。</p>
<p>反过来，面向 C 端的营销页、轻量站点就别上了，15MB 首屏加上老浏览器不兼容，等于劝退。依赖 SEO 的产品也不行，Wasm 渲染出来的页面爬虫看不到内容。如果只需要 Web 一个平台，那直接用 React 或 Vue 更省事。</p>
<p>判断标准可以压成一句话：你的核心价值是不是&quot;已经写好的那套 Kotlin 代码&quot;？是就用 Wasm，不是就别为了技术统一硬上。</p>
<h2 id="附一个最小可跑的工程">附：一个最小可跑的工程</h2>
<p>三步能跑起来。</p>
<p>第一步，在 build.gradle.kts 里加 target：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">plugins</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">alias</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">plugins</span><span class="p">.</span><span class="n">kotlinMultiplatform</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">alias</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">plugins</span><span class="p">.</span><span class="n">composeMultiplatform</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">alias</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">plugins</span><span class="p">.</span><span class="n">composeCompiler</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">kotlin</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nd">@OptIn</span><span class="p">(</span><span class="n">org</span><span class="p">.</span><span class="n">jetbrains</span><span class="p">.</span><span class="n">kotlin</span><span class="p">.</span><span class="n">gradle</span><span class="p">.</span><span class="n">ExperimentalWasmDsl</span><span class="o">::</span><span class="k">class</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">wasmJs</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">browser</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="n">binaries</span><span class="p">.</span><span class="n">executable</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">sourceSets</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">commonMain</span><span class="p">.</span><span class="n">dependencies</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">implementation</span><span class="p">(</span><span class="n">compose</span><span class="p">.</span><span class="n">runtime</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">implementation</span><span class="p">(</span><span class="n">compose</span><span class="p">.</span><span class="n">foundation</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">implementation</span><span class="p">(</span><span class="n">compose</span><span class="p">.</span><span class="n">material3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>第二步，写入口，路径是 src/wasmJsMain/kotlin/main.kt：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">import</span> <span class="nn">androidx.compose.ui.ExperimentalComposeUiApi</span>
</span></span><span class="line"><span class="cl"><span class="k">import</span> <span class="nn">androidx.compose.ui.window.ComposeViewport</span>
</span></span><span class="line"><span class="cl"><span class="k">import</span> <span class="nn">kotlinx.browser.document</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nd">@OptIn</span><span class="p">(</span><span class="n">ExperimentalComposeUiApi</span><span class="o">::</span><span class="k">class</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">ComposeViewport</span><span class="p">(</span><span class="n">document</span><span class="p">.</span><span class="n">body</span><span class="o">!!</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">App</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>第三步，UI 放 commonMain，三端共用：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nd">@Composable</span>
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">App</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">MaterialTheme</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">var</span> <span class="py">count</span> <span class="k">by</span> <span class="n">remember</span> <span class="p">{</span> <span class="n">mutableStateOf</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="n">Column</span><span class="p">(</span><span class="nc">Modifier</span><span class="p">.</span><span class="n">padding</span><span class="p">(</span><span class="mi">24</span><span class="p">.</span><span class="n">dp</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">Text</span><span class="p">(</span><span class="s2">&#34;Hello, Wasm!&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">Button</span><span class="p">(</span><span class="n">onClick</span> <span class="p">=</span> <span class="p">{</span> <span class="n">count</span><span class="o">++</span> <span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="n">Text</span><span class="p">(</span><span class="s2">&#34;点了 </span><span class="si">$count</span><span class="s2"> 次&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>跑起来：</p>
<pre tabindex="0"><code>./gradlew wasmJsBrowserDevelopmentRun   # 开发模式，带热重载
./gradlew wasmJsBrowserDistribution     # 打生产包
</code></pre><p>ComposeViewport 是 Compose Multiplatform 在 Web/Wasm 上的挂载点，地位跟 Android 的 setContent、桌面的 application 差不多。所以&quot;第四条腿&quot;的接入成本就是加一个 target、写一个入口函数，UI 代码继续和别的端共用。</p>
<h2 id="chasm-怎么用">Chasm 怎么用</h2>
<p>回到开头那个项目，给个它的实际用法（README 里的最小示例）：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">plugins</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">id</span><span class="p">(</span><span class="s2">&#34;io.github.charlietap.chasm.gradle&#34;</span><span class="p">)</span> <span class="n">version</span> <span class="s2">&#34;3.0.0-2.0.0&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">chasm</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">modules</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">create</span><span class="p">(</span><span class="s2">&#34;ExampleService&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">binary</span> <span class="p">=</span> <span class="n">layout</span><span class="p">.</span><span class="n">projectDirectory</span><span class="p">.</span><span class="k">file</span><span class="p">(</span><span class="s2">&#34;src/main/resources/example.wasm&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">packageName</span> <span class="p">=</span> <span class="s2">&#34;com.example&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>加上这段，构建会去读你的 .wasm 文件，然后生成一个 Kotlin 类 ExampleService，里面每个导出函数都变成一个能直接调的 Kotlin 函数。等于把一个现成的 Wasm 模块当普通 Kotlin 库用。</p>
<p>它底层是个统一的 Wasm 虚拟机抽象：JVM 和 native 平台用 Chasm 自己的实现，浏览器里则复用宿主内置的 VM。</p>
<p>能解决什么问题？比如你有一份编译成 Wasm 的 C/C++/Rust 库，想在 iOS 应用里用，以前得写 FFI 胶水，现在直接当 Kotlin 库调。又比如做一个跨平台插件系统，让插件以 Wasm 形式分发，宿主负责执行。</p>
<p>这块是我觉得 Chasm 值得单独讲的真正原因——它展示了 Wasm 的另一半可能性。</p>
<h2 id="我的看法">我的看法</h2>
<p>Chasm 和 Kotlin/Wasm 同时出现在新闻里是件好事。一个把 Kotlin 送出去，一个把 Wasm 请进来，从两个方向把两者接上了。这种双向的连接，说明生态开始有厚度。</p>
<p>但 Wasm 真正的门槛不在技术，在体积和兼容性。15MB 的包、GC 和异常处理的浏览器要求，都是实打实的商业约束，不是工程洁癖。我测出 4 分 10 秒构建时间的时候就在想，这技术已经很成熟了——但成熟不等于适合你的产品。</p>
<p>还有一点，KMP 这四块地方的权重差得挺远。Android 和 iOS 是十年积累，桌面端五六年，Web/Wasm 才刚起步两三年。用同一个标准要求它们不合理，你在 Android 上习惯了的顺滑，到 Wasm 上大概率要重新适应。</p>
<p>想给项目加 Web 端的话，我的建议是先在现有项目里加个 Wasm target，跑个 Hello World 试试。就上面那份配置，半小时能验证完。重点是&quot;在现有项目里&quot;——你会立刻撞到真问题：依赖的库有没有 wasmJs 版本（这是最常见的卡点）、expect/actual 有没有漏掉 wasmJs 分支、构建时间多花多久。这些在玩具项目里遇不到，只有真实依赖才会暴露。花半小时撞墙，比花两周重构完发现库不支持划算得多。</p>
<p>你会给 KMP 项目加 Web 端吗？15MB 的首屏包能接受吗？评论区聊聊。</p>
<hr>
<p><em>参考资料：CharlieTap/chasm 仓库与 2.0.0 Release Notes、Kotlin 官方文档 Kotlin/Wasm 概览与「Supported versions and configuration」、WebAssembly 官方 roadmap（GC 支持版本）、Compose Multiplatform 1.12.0 与 1.13.0-alpha01 Release Notes、Kotlin 2.4.20 Release Notes、Reddit r/Kotlin 讨论帖。实测环境：Mac mini（M4，16GB），项目为含 JS/Wasm 双 target 的 KMP 应用，分别在 Kotlin 2.4.0 + CMP 1.11.1 与 Kotlin 2.4.20 + CMP 1.12.0 下各构建一次（升级为临时操作，测完已还原项目版本）。</em></p>
]]></content:encoded></item><item><title>M6 跑分追平 M3 Max，KMP 桌面端值不值得押注？</title><link>http://mings.in/posts/m6-kmp-desktop/</link><pubDate>Sun, 20 Sep 2026 08:33:10 +0800</pubDate><guid>http://mings.in/posts/m6-kmp-desktop/</guid><description>苹果 M6 的跑分这两天陆续曝光。单核比 M4 那款高约 24%，多核高约 48%，多核直接追平了 M3 Max——一颗中端定位的芯片，多核打平上一代旗舰，确实挺夸张。</description><content:encoded><![CDATA[<h2 id="跑分是真猛">跑分是真猛</h2>
<p>苹果 M6 的跑分这两天陆续曝光。单核比 M4 那款高约 24%，多核高约 48%，多核直接追平了 M3 Max——一颗中端定位的芯片，多核打平上一代旗舰，确实挺夸张。</p>
<p>GPU 那边，12 核在 Geekbench 上跑出单核 4071、多核 22783，比 M5 高 20% 左右。</p>
<p>然后我就看到有人开始讨论：芯片都这么快了，是不是可以把桌面端也搞起来？</p>
<p>我拿自己手上的项目试了试。结论有点扫兴：这件事基本跟芯片跑分没关系。</p>
<h2 id="我在机器上跑了什么">我在机器上跑了什么</h2>
<p>本机是 Mac mini，M4，10 核，16G 内存。项目是个 KMP + Compose Multiplatform 的 AI Chat 客户端，shared 放共享逻辑，desktopApp 是桌面入口。</p>
<p>打包就一条命令：</p>
<pre tabindex="0"><code>./gradlew :desktopApp:createDistributable
</code></pre><p>跑了两遍，第一遍 22 秒，第二遍 8 秒。都挺快——但这两次依赖早下好了，代码也没怎么动。真正的第一次冷构建（下依赖、编 Kotlin、生成运行时镜像）是要按分钟算的。</p>
<p>快是快，不过这不是重点。重点是最后一个数字：</p>
<pre tabindex="0"><code>$ du -sh lange.aichat.app
133M
</code></pre><p>一百三十三兆，一个聊天客户端。</p>
<p>拆开看才有意思：</p>
<table>
	<thead>
			<tr>
					<th>目录</th>
					<th>大小</th>
					<th>里面是什么</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Contents/runtime</td>
					<td>77M</td>
					<td>打包进去的 JRE</td>
			</tr>
			<tr>
					<td>├ lib/modules</td>
					<td>52M</td>
					<td>JRE 核心模块</td>
			</tr>
			<tr>
					<td>└ libjvm.dylib</td>
					<td>17M</td>
					<td>JVM 本体</td>
			</tr>
			<tr>
					<td>Contents/app</td>
					<td>55M</td>
					<td>应用和依赖</td>
			</tr>
			<tr>
					<td>├ libskiko-macos-arm64.dylib</td>
					<td>20M</td>
					<td>Skia 渲染引擎</td>
			</tr>
			<tr>
					<td>├ skiko-awt-runtime</td>
					<td>8.9M</td>
					<td>Skiko 的 JVM 绑定</td>
			</tr>
			<tr>
					<td>├ material3-desktop</td>
					<td>4.4M</td>
					<td></td>
			</tr>
			<tr>
					<td>├ foundation-desktop</td>
					<td>4.4M</td>
					<td></td>
			</tr>
			<tr>
					<td>└ ui-desktop / runtime / kotlin-stdlib</td>
					<td>3.3 / 1.9 / 1.8M</td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>77M 是 JVM 本身，再加 20M 的 Skia，光是让程序能跑起来的底座就 97M。你自己写的那部分，可能就几百 K。而且那 77M 已经是 jlink 裁过一遍的结果了，不是原始大小。</p>
<p>所以账很清楚：Compose Desktop 写起来舒服，但分发成本摆在那。除非把 JVM 这个前提换掉（GraalVM native-image 那条路），否则百兆上下就是常态。</p>
<h2 id="m6-快这么多用处到底在哪">M6 快这么多，用处到底在哪</h2>
<p>编译。Kotlin 编译、Gradle 并行任务、native 链接，这些都是吃多核的活，多核加 48% 能实打实缩短构建时间。</p>
<p>我顺手把同一个仓库里的另一个 target 也测了：</p>
<pre tabindex="0"><code>./gradlew :app:webApp:wasmJsBrowserDistribution
→ BUILD SUCCESSFUL in 4m 10s
</code></pre><p>4 分 10 秒，50 个任务全跑了一遍，CPU 和内存吃得满满的。天天要跑好几次构建的人，对这种多核提升感受最直接。</p>
<p>那最终用户呢？</p>
<p>一个聊天客户端，用户能感觉到的瓶颈是这些：冷启动——JVM 加载加 Compose 初始化，吃的是单核和 IO，多核帮不上多少；滚动和动画——看 Skia 的，GPU 那 20% 对 2D 界面来说过剩；首屏——看网络。</p>
<p>M6 再快一半，用户也不会觉得你的应用变快了。</p>
<h2 id="那到底该不该做桌面端">那到底该不该做桌面端</h2>
<p>我自己是这么看的。</p>
<p>已经有 KMP 项目、想多覆盖一个平台的，可以直接加。shared 逻辑本来就在那，无非多写一个入口。做内部工具、开发者工具的也能加，反正只给自己团队装，没人在乎包多大。</p>
<p>真正值得单独立项的是另一类需求：要在本地读写文件、要开多窗口常驻后台、要在本机跑模型。这些是桌面能做、手机做不了的。这种场景下 133M 根本不算事——用户装的是工具，不是玩具。</p>
<p>反过来，面向普通消费者的轻量小工具得谨慎。用户不会为一个查天气的小应用下 133M。对冷启动有硬要求的也一样，JVM 那条冷启动曲线绕不过去，除非上 GraalVM。</p>
<p>顺带一提，Compose Multiplatform 1.13 已经在做 AppCDS 和 AOT 了（1.13.0-alpha01 的更新里写了&quot;显著加快应用启动&quot;）。如果你现在正因为启动慢犹豫，可以再等一两个版本。</p>
<h2 id="最后">最后</h2>
<p>M6 很好。构建能快一截，这点对天天敲代码的人是真实收益。</p>
<p>但它改变不了桌面端的取舍。卡人的是包体积和冷启动，这两样芯片管不着。如果你的动机是&quot;吃到新硬件红利&quot;，那可能看错地方了——桌面端真正的红利在打包技术上（jlink、AppCDS、GraalVM），不在 CPU 跑分上。</p>
<p>如果你已经在做 KMP，桌面端其实是最便宜的那一端，shared 已经写好了，加个入口就多一个平台。这个理由跟芯片无关，但我觉得比跑分实在。</p>
<p>你会给项目加桌面端吗？能接受多大的包？评论区聊聊。</p>
<hr>
<p><em>测试环境：Mac mini（Apple M4，10 核，16GB），macOS 27.0，JDK 21.0.10，Kotlin 2.4.0 + Compose Multiplatform 1.11.x。M6 跑分来自 Geekbench 曝光成绩，及 cnBeta、Notebookcheck、凤凰网科技等公开报道。</em></p>
]]></content:encoded></item><item><title>AGENTS.md 来了，KMP 项目该怎么写这份说明书</title><link>http://mings.in/posts/agents-md-kmp/</link><pubDate>Sat, 19 Sep 2026 07:19:11 +0800</pubDate><guid>http://mings.in/posts/agents-md-kmp/</guid><description>先说昨天的新闻：Claude Code 2.1.277 开始支持 AGENTS.md（9 月 18 日）。</description><content:encoded><![CDATA[<h2 id="一场持续近一年的拉锯昨天结束了">一场持续近一年的拉锯，昨天结束了</h2>
<p>先说昨天的新闻：<strong>Claude Code 2.1.277 开始支持 AGENTS.md</strong>（9 月 18 日）。</p>
<p>The Register 的标题起得很直接——<strong>&ldquo;Anthropic 决定支持 OpenAI 的 markdown 指令规范&rdquo;</strong>。</p>
<p>如果你不是天天泡在 AI 编程工具里，可能会觉得：一个配置文件名，至于吗？</p>
<p><strong>至于。因为这场拉锯的起点，是 Shopify CEO 公开放的一句狠话。</strong></p>
<p>时间倒回 8 月 26 日。Shopify CEO <strong>Tobi Lütke</strong> 在 X 上表态：</p>
<blockquote>
<p>我正考虑在 Shopify 禁用 Claude Code，<strong>直到他们愿意读取 AGENTS.md、.agents/skills 等文件。</strong></p>
</blockquote>
<p>一家电商巨头，威胁要全公司禁用一款主流 AI 编程工具——<strong>原因只是它不肯读一个 markdown 文件。</strong></p>
<p>而他给出的理由，我觉得比新闻本身更有价值：</p>
<blockquote>
<p>AGENTS.md 和 CLAUDE.md 都会沿目录树递归生效。数千名开发者维护一个 monorepo，<strong>难免有目录漏掉一份文件，结果就是一部分开发者用上&quot;被切掉一块脑子&quot;的智能体</strong>。</p>
<p>我们用自动化解决了问题，但这是笔<strong>愚蠢的复杂度税</strong>，根本不该付。</p>
</blockquote>
<p><strong>&ldquo;被切掉一块脑子的智能体&rdquo;</strong>——这个说法，凡是维护过大型仓库的人应该都会心一沉。</p>
<h2 id="先把-agentsmd-说清楚">先把 AGENTS.md 说清楚</h2>
<p>AGENTS.md 的官方定义只有一句话：</p>
<blockquote>
<p><strong>Think of AGENTS.md as a README for agents.</strong></p>
</blockquote>
<p><strong>给 agent 看的 README。</strong></p>
<p>README 给人看——怎么快速上手、项目是做什么的。而 AGENTS.md 装的是 agent 需要、但不适合塞进 README 的东西：<strong>构建步骤、测试怎么跑、代码约定、几条铁律。</strong></p>
<p>它现在的地位比很多人想的高：</p>
<ul>
<li>已被 <strong>6 万+ 开源项目</strong>采用</li>
<li>起源于多家协作：<strong>OpenAI Codex、Amp、Google 的 Jules、Cursor、Factory</strong></li>
<li>现在由 <strong>Linux 基金会旗下的 Agentic AI Foundation 托管</strong></li>
</ul>
<p>官方给的最小示例长这样：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gh"># AGENTS.md
</span></span></span><span class="line"><span class="cl"><span class="gu">## Setup commands
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> Install deps: <span class="sb">`pnpm install`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Start dev server: <span class="sb">`pnpm dev`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Run tests: <span class="sb">`pnpm test`</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Code style
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> TypeScript strict mode
</span></span><span class="line"><span class="cl"><span class="k">-</span> Single quotes, no semicolons
</span></span></code></pre></div><p><strong>就这么简单。</strong> 没有 schema，没有特殊语法——就是一份 markdown。</p>
<p>而它真正好用的地方在于：<strong>一份文件跨多个 agent 通用</strong>。Codex、Cursor、Jules、Copilot、Claude Code 都读它，你不用为每个工具维护一份配置。</p>
<blockquote>
<p>顺带一提，Claude Code 之前坚持用自己的 <code>CLAUDE.md</code>。开发者从 2025 年 8 月就开始提需求，相关 issue 攒了近 <strong>5000 个 GitHub 点赞</strong>，最后被关了，回复是&quot;不予修复&quot;。<strong>直到 Shopify 威胁禁用，才等来了 9 月 18 日的支持。</strong></p>
</blockquote>
<h2 id="为什么一份文件会升级成平台工程问题">为什么&quot;一份文件&quot;会升级成平台工程问题</h2>
<p>很多人会想：不就是让 Claude Code 多读一个文件吗，为什么 Shopify 要上升到&quot;全公司禁用&quot;？</p>
<p><strong>因为在一个几千人的 monorepo 里，一个文件格式差异会变成失控。</strong></p>
<p>Lütke 说的那个机制值得抄下来：</p>
<pre tabindex="0"><code>monorepo/
├── AGENTS.md          ← 全局规则（技术栈、提交规范）
├── backend/
│   └── AGENTS.md      ← 后端特有（不要动 db/migrations）
├── mobile/
│   └── AGENTS.md      ← 移动端特有（打包、签名）
└── infra/
    └── AGENTS.md      ← 基础设施特有
</code></pre><p><strong>AGENTS.md 是&quot;就近生效&quot;的</strong>——agent 读到当前目录最近的那份，继承上层的规则。</p>
<p>问题就出在这儿：</p>
<ul>
<li>假设团队同时用 Cursor（读 AGENTS.md）和 Claude Code（只读 CLAUDE.md）</li>
<li><code>mobile/</code> 目录下写了 <code>AGENTS.md</code>，但没人记得再写一份 <code>CLAUDE.md</code></li>
<li>用 Cursor 的开发者：agent 知道&quot;打包前要跑签名脚本&quot;</li>
<li>用 Claude Code 的开发者：agent <strong>不知道</strong></li>
</ul>
<p><strong>同一份代码，同一个任务，两个 agent 给出的结果不一样。</strong> 而这区别不会被任何报警发现——<strong>你只是偶尔发现某个同事的 AI&quot;特别笨&quot;。</strong></p>
<p><strong>这就是 Lütke 说的&quot;复杂度税&quot;</strong>：不是不能解决，而是<strong>这个问题根本不该存在</strong>。</p>
<h2 id="落到实操一个-kmp--ai-chat-客户端怎么写">落到实操：一个 KMP + AI Chat 客户端怎么写</h2>
<p>好了，新闻讲完，接下来是正题。</p>
<p>假设我们在做的是一个 <strong>Kotlin Multiplatform + Compose Multiplatform 的 AI Chat 客户端</strong>——一套 Kotlin 代码，跑 Android 和 iOS。</p>
<p>这种项目写 AGENTS.md 有它的特殊性：<strong>同一个仓库里，三种代码的规则完全不同。</strong></p>
<p>先看目录树：</p>
<pre tabindex="0"><code>ai-chat/
├── AGENTS.md                      # 根：全局铁律 + 通用命令
├── settings.gradle.kts
├── gradle/
│   └── libs.versions.toml         # 版本目录（依赖版本唯一来源）
├── shared/                        # 共享业务逻辑（无 UI）
│   ├── AGENTS.md                  # 这一层的规矩
│   ├── build.gradle.kts
│   └── src/
│       ├── commonMain/kotlin/     # ← 三端共享
│       ├── androidMain/kotlin/    # ← Android 实现
│       ├── iosMain/kotlin/        # ← iOS 实现
│       └── commonTest/kotlin/
├── composeApp/                    # Compose Multiplatform UI
│   ├── AGENTS.md
│   └── src/
│       ├── commonMain/
│       ├── androidMain/
│       └── iosMain/
├── androidApp/                    # Android 壳工程
│   ├── AGENTS.md
│   └── src/main/AndroidManifest.xml
└── iosApp/                        # iOS 壳工程（Xcode）
    ├── AGENTS.md
    └── iosApp.xcodeproj
</code></pre><p><strong>注意：这个结构本身就是给 agent 的第一层信息。</strong> 如果 agent 分不清 <code>shared/</code> 和 <code>composeApp/</code> 的区别，它就会把 UI 代码写到逻辑层里。</p>
<h3 id="根-agentsmd只写全局铁律和通用命令">根 AGENTS.md：只写&quot;全局铁律&quot;和&quot;通用命令&quot;</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gh"># AGENTS.md
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">KMP + Compose Multiplatform 的 AI Chat 客户端。
</span></span><span class="line"><span class="cl">一套 Kotlin 代码，编译到 Android / iOS。
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 项目结构
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`shared/`</span>     — 共享业务逻辑，**不含任何 UI**
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`composeApp/`</span> — Compose Multiplatform UI（三端共用同一套界面）
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`androidApp/`</span> — Android 壳（Activity / Application / manifest）
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`iosApp/`</span>     — iOS 壳（Xcode 工程 / SwiftUI 入口）
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 构建与测试
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> 全量编译：      <span class="sb">`./gradlew build`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> 共享层编译：    <span class="sb">`./gradlew :shared:compileKotlinMetadata`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Android 装到机：<span class="sb">`./gradlew :androidApp:installDebug`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> 跑全部测试：    <span class="sb">`./gradlew allTests`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> 只跑一个类：    <span class="sb">`./gradlew :shared:allTests --tests &#34;*ChatRepository*&#34;`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> iOS 框架：      <span class="sb">`./gradlew :shared:linkDebugFrameworkIosSimulatorArm64`</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 铁律（违反会导致三端编译失败）
</span></span></span><span class="line"><span class="cl"><span class="k">1.</span> <span class="sb">`commonMain`</span> 里**禁止**出现 <span class="sb">`java.*`</span>、<span class="sb">`android.*`</span>、<span class="sb">`platform.*`</span>
</span></span><span class="line"><span class="cl"><span class="k">2.</span> 平台差异一律走 <span class="sb">`expect`</span> / <span class="sb">`actual`</span>，不要写 <span class="sb">`if (isAndroid)`</span>
</span></span><span class="line"><span class="cl"><span class="k">3.</span> <span class="gs">**不要为 iOS 复制一份逻辑**</span> —— 那就失去了用 KMP 的意义
</span></span><span class="line"><span class="cl"><span class="k">4.</span> 新依赖先加到 <span class="sb">`gradle/libs.versions.toml`</span>，模块里只引别名，**不要写死版本号**
</span></span><span class="line"><span class="cl"><span class="k">5.</span> 改 <span class="sb">`commonMain`</span> 之后，必须能同时编译 Android 和 iOS
</span></span></code></pre></div><p><strong>这里有几个写法上的讲究，我认为很关键：</strong></p>
<p><strong>① 命令要&quot;可复制执行&quot;，不要描述。</strong>
写 <code>./gradlew :shared:compileKotlinMetadata</code>，不要写&quot;编译一下共享模块&quot;。<strong>agent 拿到能直接跑的命令，才不用猜。</strong></p>
<p><strong>② 铁律要写&quot;违反的后果&quot;。</strong>
&ldquo;禁止在 commonMain 用 java.*&rdquo; 是个规定；但加上&quot;<strong>会导致三端编译失败</strong>&quot;，agent 才知道这是硬约束，不是风格偏好。</p>
<p><strong>③ 用&quot;唯一来源&quot;消灭歧义。</strong>
&ldquo;版本号只写在 <code>libs.versions.toml</code>&quot;——这一句能让 agent 不再到处硬编码版本。</p>
<h3 id="sharedagentsmd只写这一层特有的">shared/AGENTS.md：只写&quot;这一层特有的&rdquo;</h3>
<p><strong>子目录的 AGENTS.md 不该重复根目录的内容</strong>，只写&quot;进到这个目录才需要知道的事&quot;：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gh"># shared/ — 共享业务逻辑
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 三端目录的职责
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`commonMain/`</span> — 平台无关逻辑。要用平台能力，在这里先 <span class="sb">`expect`</span> 声明
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`androidMain/`</span> — <span class="sb">`expect`</span> 的 Android 实现（用 Android SDK / JVM API）
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`iosMain/`</span>     — <span class="sb">`expect`</span> 的 iOS 实现（用 Kotlin/Native + Foundation）
</span></span><span class="line"><span class="cl"><span class="k">-</span> **每加一个 <span class="sb">`expect`</span>，必须同时补齐 androidMain 和 iosMain 两份 <span class="sb">`actual`</span>**
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 目录约定
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`data/`</span>      — Repository、数据源、DTO
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`domain/`</span>    — 领域模型、UseCase
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`platform/`</span>  — expect/actual 的平台抽象
</span></span><span class="line"><span class="cl">                   （KeyValueStore、HttpEngine、AppInfo…）
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 网络（AI Chat 的核心）
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> 统一用 <span class="gs">**Ktor**</span>，禁止在共享层直接用 OkHttp 或 URLSession
</span></span><span class="line"><span class="cl"><span class="k">-</span> 流式响应统一返回 <span class="sb">`Flow&lt;ChatChunk&gt;`</span>，不要在 shared 里做 UI 状态管理
</span></span><span class="line"><span class="cl"><span class="k">-</span> 超时、重试、错误映射统一在 <span class="sb">`data/remote/`</span> 里处理
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 命令
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> 测试：      <span class="sb">`./gradlew :shared:allTests`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> iOS 框架：  <span class="sb">`./gradlew :shared:linkDebugFrameworkIosSimulatorArm64`</span>
</span></span></code></pre></div><p><strong>看见没，这一层装的全是&quot;KMP 特有&quot;的知识</strong>：</p>
<ul>
<li><code>expect</code>/<code>actual</code> 的配对规则（这是 KMP 最容易出错的地方）</li>
<li>网络层为什么必须用 Ktor（因为 OkHttp 在 iOS 上不存在）</li>
<li>流式响应为什么用 <code>Flow</code>（AI Chat 的核心特征）</li>
</ul>
<p><strong>这些是通用 README 里不会写、但 agent 每次都会踩的东西。</strong></p>
<h3 id="三端壳工程各自写别乱碰什么">三端壳工程：各自写&quot;别乱碰什么&quot;</h3>
<p><strong>androidApp/AGENTS.md</strong>：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gh"># androidApp/ — Android 壳
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 这里只放什么
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> Activity、Application、AndroidManifest.xml、res/ 资源
</span></span><span class="line"><span class="cl"><span class="k">-</span> **业务逻辑一律放 shared/，UI 一律放 composeApp/**
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 常用命令
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> 装机：    <span class="sb">`./gradlew :androidApp:installDebug`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> 看日志：  <span class="sb">`adb logcat -s AiChat:V`</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 注意
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> 新增权限必须改 AndroidManifest.xml，并**同步更新本文件**
</span></span><span class="line"><span class="cl"><span class="k">-</span> 改 minSdk 前先确认 shared/ 里用到的 API 兼容性
</span></span></code></pre></div><p><strong>iosApp/AGENTS.md</strong>：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gh"># iosApp/ — iOS 壳（Xcode 工程）
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 重要：这里不用 Gradle 构建
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> 这是 Xcode 工程，**不要尝试用 gradlew 构建它**
</span></span><span class="line"><span class="cl"><span class="k">-</span> 改了 shared/ 的代码后，先跑：
</span></span><span class="line"><span class="cl">    <span class="sb">`./gradlew :shared:linkDebugFrameworkIosSimulatorArm64`</span>
</span></span><span class="line"><span class="cl">  让 Xcode 拿到新框架，再去 Xcode 里 Run
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Swift 侧只做桥接
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> 生命周期、权限申请、导航容器
</span></span><span class="line"><span class="cl"><span class="k">-</span> 业务逻辑**全部回 shared**，不要在 Swift 里重写一遍
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## 常见坑
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> Info.plist 缺权限描述：模拟器运行才报错，Xcode 不提示
</span></span><span class="line"><span class="cl"><span class="k">-</span> 改了 framework 的 export 后要 Clean Build Folder
</span></span><span class="line"><span class="cl"><span class="k">-</span> Kotlin 的 sealed class 在 Swift 侧要遍历所有分支（写全，别用 default）
</span></span></code></pre></div><p><strong>为什么这三份要分开写？</strong></p>
<p>因为**&ldquo;该做什么&quot;和&quot;不该做什么&quot;在不同目录里完全相反**：</p>
<ul>
<li>在 <code>shared/</code>，你要写&quot;不要碰平台 API&rdquo;</li>
<li>在 <code>androidApp/</code>，你要写&quot;不要写业务逻辑&quot;</li>
<li>在 <code>iosApp/</code>，你要写&quot;不要用 Gradle 构建这个目录&quot;</li>
</ul>
<p><strong>同一个仓库，规则互相冲突。</strong> 一份根 AGENTS.md 写不下这些——<strong>这就是&quot;就近生效&quot;存在的意义。</strong></p>
<h3 id="顺带一提如果团队里工具是混用的">顺带一提：如果团队里工具是混用的</h3>
<p>AGENTS.md 只是&quot;约定&quot;，不同工具读它的方式仍有差异。官方给了一些配置参考：</p>
<ul>
<li><strong>Aider</strong>：在 <code>.aider.conf.yml</code> 里加一行 <code>read: AGENTS.md</code></li>
<li><strong>Gemini CLI</strong>：在 <code>.gemini/settings.json</code> 里配置 <code>{ &quot;context&quot;: { &quot;fileName&quot;: &quot;AGENTS.md&quot; } }</code></li>
<li><strong>老文件迁移</strong>：<code>mv AGENT.md AGENTS.md &amp;&amp; ln -s AGENTS.md AGENT.md</code>（软链保留兼容）</li>
</ul>
<p>放到 KMP 项目里，还有一种情况是<strong>工具版本不齐</strong>——比如有人的 Claude Code 还没升到 2.1.277。这时候可以在关键目录放一个 <code>CLAUDE.md -&gt; AGENTS.md</code> 的软链顶上。</p>
<p><strong>但请记住 Lütke 那句话：这就是在付&quot;复杂度税&quot;。</strong></p>
<p><strong>能统一升级工具，就别维护两份文件</strong>——软链是过渡方案，不是终局。</p>
<h2 id="几个我踩过的坑">几个我踩过的坑</h2>
<p><strong>① 别把 AGENTS.md 写成 README 的翻译版。</strong>
README 是给人看的&quot;项目介绍&quot;，AGENTS.md 是给 agent 的&quot;操作手册&quot;。区别在于：<strong>README 讲 what，AGENTS.md 讲 how，而且要能直接执行。</strong></p>
<p><strong>② 别写&quot;应该是&quot;&ldquo;建议&quot;&ldquo;尽量&rdquo;。</strong>
agent 对模糊表述的处理方式是不可预测的。要么是硬约束（写清后果），要么就不要写。</p>
<p><strong>③ 别让它过期。</strong>
<strong>过期的命令比没有命令更糟</strong>——agent 会照着跑，然后卡在一个已经不存在的任务上。改构建脚本时顺手改 AGENTS.md，应该成为习惯。</p>
<p><strong>④ 别贪多。</strong>
官方那个最小示例就十几行，够用了。上下文是有预算的，<strong>你写给 agent 的每一行，都在挤占它理解代码的空间。</strong></p>
<p><strong>⑤ 目录树画出来。</strong>
这个投入产出比最高。<strong>一张准确的目录树，能省掉 agent 十次 <code>ls</code>。</strong></p>
<h2 id="我的看法">我的看法</h2>
<p><strong>第一，AGENTS.md 的胜负，本质是&quot;平台中立&quot;对&quot;工具绑定&quot;的胜利。</strong></p>
<p>Anthropic 拖了近一年才支持，最后还是支持了。原因很简单：<strong>当 6 万个项目用同一个格式，你不支持，就是在给用户加税。</strong></p>
<p>Shopify 那句&quot;复杂度税&rdquo;，戳中的是所有大仓库的真实痛点。</p>
<p><strong>第二，对 KMP 项目来说，AGENTS.md 的价值可能比对普通项目更高。</strong></p>
<p>因为 KMP 的坑<strong>特别&quot;目录相关&quot;</strong>：</p>
<ul>
<li>这段代码在 <code>commonMain</code> 还是 <code>iosMain</code>？规则完全不同</li>
<li>这个 API 三端都有吗？</li>
<li>改完要不要重新 link iOS 框架？</li>
</ul>
<p><strong>这些判断，如果每次都靠人在对话里说一遍，成本很高；写进目录级的 AGENTS.md，就变成了一次性投入。</strong></p>
<p><strong>第三，这件事的长期意义可能比看起来大。</strong></p>
<p>当 AI 编程工具从&quot;补全代码&quot;走向&quot;独立完成任务&quot;，<strong>&ldquo;仓库里有没有一份说清楚规矩的文件&rdquo;</strong>，会越来越像&quot;这个项目有没有自动化测试&quot;——<strong>是工程成熟度的标志之一。</strong></p>
<p>而且写它还有个附带好处：<strong>写 AGENTS.md 的过程，本身就是一次团队规范的梳理。</strong> 你会被迫把那些&quot;只有老员工才知道&quot;的隐含规则写下来——<strong>这件事的价值，可能比让 AI 聪明一点更大。</strong></p>
<hr>
<p>想问问你：</p>
<p><strong>你现在的项目里有 AGENTS.md 吗？</strong> 如果还没有，你觉得最难写的是哪部分——是构建命令，还是那些&quot;只可意会&quot;的约定？</p>
<p><strong>或者你在 KMP 项目里踩过什么坑，后来靠文档解决了？</strong> 评论区聊聊，我可以整理成下一篇。</p>
<hr>
<p><em>参考资料：AGENTS.md 官方规范（agents.md）、The Register（9/18）、36Kr / CSDN 关于 Shopify 与 Anthropic 争议的报道（8/26）、Claude Code 2.1.277 更新说明。</em></p>
]]></content:encoded></item><item><title>Kotlin 2.4 来了，鸿蒙开始适配 2.4：KMP 的两条战线</title><link>http://mings.in/posts/kmp-24-ohos/</link><pubDate>Fri, 18 Sep 2026 12:34:38 +0800</pubDate><guid>http://mings.in/posts/kmp-24-ohos/</guid><description>前段时间我写过一篇鸿蒙 KMP 的文章，当时查到一个关键信息：社区发布的正式版，是基于 KMP 2.2.21 / CMP 1.9.2 做的鸿蒙适配。</description><content:encoded><![CDATA[<h2 id="一条分支名暴露了正在发生的事">一条分支名，暴露了正在发生的事</h2>
<p>前段时间我写过一篇鸿蒙 KMP 的文章，当时查到一个关键信息：社区发布的正式版，是基于 <strong>KMP 2.2.21 / CMP 1.9.2</strong> 做的鸿蒙适配。</p>
<p>前两天再去翻那个仓库，发现它的分支列表里多了一行字：</p>
<blockquote>
<p><strong>develop-2.4</strong>
框架 KMP <strong>2.4.21</strong> _ CMP <strong>1.12.0</strong> 版本对应的文档集合</p>
</blockquote>
<p>而且这个分支<strong>21 小时前刚更新过</strong>。</p>
<p>另外还有一条：</p>
<blockquote>
<p><strong>main-2.2</strong>（默认分支）
框架 KMP <strong>2.2.21</strong> _ CMP <strong>1.9.2</strong> 版本对应的文档集合</p>
</blockquote>
<p>一边是已经发出去的正式版，一边是正在推进的新基线。</p>
<p><strong>KMP 在鸿蒙这条线上，开始跟上游的最新版本对齐了。</strong></p>
<p>而几乎同一时间，上游 JetBrains 刚发布 Kotlin 2.4.20。</p>
<p>两条线，撞在了一个时间点上。</p>
<h2 id="先把上游这条线讲清楚kotlin-2420-改了什么">先把上游这条线讲清楚：Kotlin 2.4.20 改了什么</h2>
<p>如果你只扫一眼更新日志，可能会觉得&quot;哦，又是个修修补补的版本&quot;——两百多条变更里，大半是 bugfix。</p>
<p>但里面有几个点，对<strong>写 KMP 的人</strong>来说很实在。</p>
<p><strong>第一，Swift 互操作终于&quot;能打了&quot;。</strong></p>
<p>这次 Swift export 支持把 Kotlin 的 <code>sealed class</code> 直接映射成 Swift 的 <code>enum</code>：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="c1">// 不用再写 default 了</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">name</span> <span class="p">=</span> <span class="k">switch</span> <span class="n">shape</span><span class="p">.</span><span class="n">sealedType</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="p">.</span><span class="n">circle</span><span class="p">(</span><span class="kd">let</span> <span class="nv">t</span><span class="p">):</span>    <span class="s">&#34;圆&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="p">.</span><span class="n">rectangle</span><span class="p">(</span><span class="kd">let</span> <span class="nv">t</span><span class="p">):</span> <span class="s">&#34;方&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>   <span class="c1">// ← 穷尽检查</span>
</span></span></code></pre></div><p>以前处理这种多态类型，Swift 侧要么写一堆 <code>if let</code>，要么被迫加一个永远走不到的 <code>default</code> 分支——<strong>穷尽性检查直接失效</strong>。现在编译器能帮你查漏了。</p>
<p>还有<strong>跨语言继承</strong>：Kotlin 类型可以继承 Swift 的类/协议（含纯 Swift 库），不用再包一层。</p>
<p>这两件事指向同一个方向：<strong>KMP 在 iOS 侧一直被人诟病的&quot;互操作摩擦&quot;，正在被一点点磨平。</strong></p>
<p><strong>第二，klib 增量编译进 Beta。</strong></p>
<pre tabindex="0"><code>kotlin.incremental.native=true
</code></pre><p>改一行 Kotlin 代码，不用再等整个 native 模块重编。这个体验改善，做过 Kotlin/Native 的人应该懂。</p>
<p><strong>第三，Kotlin/JS 的测试工具链从 Karma 换成了 Playwright</strong>——算是跟上时代。</p>
<p><strong>所以上游这条线的主旋律是：把跨语言的接缝处理得更顺。</strong></p>
<h2 id="再看鸿蒙这条线它现在走到哪了">再看鸿蒙这条线：它现在走到哪了</h2>
<p>要理解鸿蒙这边，得先看它的<strong>版本号规则</strong>——这是昨天刚发布的正式版公告里写的：</p>
<blockquote>
<p>版本号采用「<strong>上游基线 + 鸿蒙适配序号</strong>」：2.2.21-1.0.0 / 1.9.2-1.0.0 表示基于上游 KMP 2.2.21、CMP 1.9.2 的鸿蒙适配正式版。</p>
</blockquote>
<p>这个规则很关键。它说明了一件事：</p>
<blockquote>
<p><strong>鸿蒙适配不是另起炉灶，而是在追上游的版本。</strong></p>
</blockquote>
<p>所以你会看到 <code>2.2.21-1.0.0</code> 这样的编号——前面的 <code>2.2.21</code> 是它对应的上游 Kotlin 版本，后面的 <code>1.0.0</code> 是鸿蒙适配自己的迭代号。</p>
<p>已经发布并进了 Maven 仓的有：</p>
<table>
	<thead>
			<tr>
					<th>组件</th>
					<th>版本</th>
					<th>状态</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>kotlin</td>
					<td>2.2.21-1.0.0</td>
					<td>Maven 已发布</td>
			</tr>
			<tr>
					<td>compose-multiplatform</td>
					<td>1.9.2-1.0.0</td>
					<td>Maven 已发布</td>
			</tr>
			<tr>
					<td>compose-multiplatform-core</td>
					<td>1.9.2-1.0.0</td>
					<td>Maven 已发布</td>
			</tr>
			<tr>
					<td>skiko</td>
					<td>0.9.22.2-1.0.0</td>
					<td>Maven 已发布</td>
			</tr>
			<tr>
					<td>akinterop</td>
					<td>1.0.1</td>
					<td>Maven 已发布</td>
			</tr>
			<tr>
					<td>KMP OHOS Support</td>
					<td>1.0.0</td>
					<td>插件市场已发布</td>
			</tr>
	</tbody>
</table>
<p>而 <code>develop-2.4</code> 这条新分支，目标基线就是 <strong>KMP 2.4.21 / CMP 1.12.0</strong>。</p>
<p>开发节奏公告里也写了：</p>
<blockquote>
<p><strong>develop</strong> 分支：用于每月开发态代码合入，<strong>每月准出后合入 main 分支</strong>。</p>
</blockquote>
<p>翻译一下：<strong>每月推进一版，成熟了才合进正式线。</strong> 不是拍脑袋发版，而是有节奏的滚动。</p>
<h2 id="这条线上最近在忙什么">这条线上最近在忙什么</h2>
<p>翻了 <code>develop-2.4</code> 的提交记录，最近的动作挺有意思。</p>
<p>最新一条是把 <strong>CAPI 用户手册适配到 API 26</strong>——把 OHOS CAPI 版本更新到 API26，并且同步更新了「模块、最低 API 与典型依赖库」，还专门加了一节说明**「CAPI 升级至 API 26 后导致的特殊变更」**。</p>
<p>另一条是更新 <code>akinterop</code> 到 1.0.1、<code>sqldelight</code> 到 2.2.1-1.1.0。</p>
<p><strong>这两个方向，恰好对应鸿蒙跨平台落地的两个真实卡点：</strong></p>
<ul>
<li><strong>CAPI 跟着鸿蒙系统版本走</strong>——系统升级，桥接层就得跟着升，这是&quot;不断档&quot;的问题</li>
<li><strong>三方库的适配版本号</strong>——生态库能不能跟上，决定了项目敢不敢用</li>
</ul>
<h2 id="顺带一提cmp-112-上游给的亮点">顺带一提：CMP 1.12 上游给的亮点</h2>
<p>既然 <code>develop-2.4</code> 这条线要跟到 CMP 1.12.0，那 1.12 上游改了什么，也值得看一眼。</p>
<p>CMP 1.12.0 是 8 月 25 日发布的，最亮的一条在 Desktop 侧：</p>
<blockquote>
<p>MCP server was introduced for Compose Hot Reload that enables <strong>AI agents to interact with a running Compose application in real time</strong></p>
</blockquote>
<p>翻译一下：<strong>给 Compose 热重载加了个 MCP server，让 AI agent 能实时和一个正在运行的 Compose 应用交互。</strong></p>
<p>这条挺微妙的——Compose 和 AI 的结合点，正在从&quot;用 AI 写 Compose 代码&quot;，走向&quot;<strong>让 AI 直接操作跑着的应用</strong>&quot;。</p>
<p>当然它也带了一批破坏性调整，迁移时要留心：</p>
<ul>
<li><code>NativeCanvas</code>、<code>NativePaint</code> 这些 typealias 的弃用级别被提到了 <strong>ERROR</strong></li>
<li><code>SwingPanel</code> 的 <code>background</code> 参数弃用</li>
</ul>
<p><strong>从 1.9.2 跳到 1.12.0，不只是版本号变了——迁移成本也是要算进去的。</strong></p>
<h2 id="两件事凑在一起说明了什么">两件事凑在一起，说明了什么</h2>
<p>把这两条线放在一起看，我看到的是一个挺清晰的图景：</p>
<p><strong>KMP 的&quot;一码多端&quot;，正在多条战线上同时推进。</strong></p>
<ul>
<li><strong>上游</strong>在打磨跨语言互操作的体验（Swift export、增量编译）</li>
<li><strong>鸿蒙这条社区线</strong>在追上游版本（2.2 → 2.4）、补系统适配（CAPI → API 26）、扩三方库生态</li>
</ul>
<p>它们不是同一个团队在做，但走的是<strong>同一个方向</strong>——让&quot;写一套 Kotlin，跑更多平台&quot;这件事，摩擦更小一点。</p>
<p>对开发者来说，判断标准其实很简单：</p>
<blockquote>
<p><strong>当你评估一个新平台时，问的不再是&quot;支持不支持&quot;，而是&quot;上游跟到哪个版本了、库够不够用、性能顶不顶得住&quot;。</strong></p>
</blockquote>
<p><strong>从&quot;能不能跑&quot;到&quot;跟得上吗&quot;，这本身就是个阶段性的变化。</strong></p>
<h2 id="但社区的真实声音比公告更值得听">但社区的真实声音，比公告更值得听</h2>
<p>公告总是漂亮的。我特意去翻了社区讨论区，那才是这条线真正的体温。</p>
<p><strong>最热的一帖，是问&quot;能不能尽快合并到 JetBrains 官方分支&quot;</strong>（30 天前发起，7 条回复）。</p>
<p>发起人的原话挺直接的：</p>
<blockquote>
<p>我现有的纯 KMP&amp;CMP 应用项目添加鸿蒙平台<strong>存在大量库不支持问题</strong>，编译过程还出现几个已适配的库存在内部依赖冲突，希望尽快合并上游方便整个 KMP 社区的库开发者新增 ohos 的库版本，<strong>CPF 社区对库支持的进度过于慢了</strong>。</p>
</blockquote>
<p>他具体点了三个库：<code>androidx-datastore</code>、<code>androidx-room3</code>、<code>androidx-sqlite</code>——&ldquo;这三个基本就是大家普遍使用的持久化方式的基础依赖库了&rdquo;。</p>
<p>然后是最扎心的部分：</p>
<blockquote>
<p>它们都没有 ohos <strong>x86</strong> 的支持。开发者在 win 平台几乎无法直接开发测试一个正常功能的应用，起码你得一直接一台鸿蒙手机充当测试平台……</p>
</blockquote>
<p>他还举了个具体的坑：从某个镜像仓库拉到的 <code>room3</code> alpha 版本，生成代码会报 <code>androidx.sqlite</code> 缺少实现；而用上游的 <code>room3 3.0.1</code> 就没问题。</p>
<p><strong>官方回复了，态度挺实在：</strong></p>
<blockquote>
<p>您好，<strong>关于 ohos x86 的支持，已在规划中</strong>，感谢您的关注。</p>
</blockquote>
<p>其他帖子也各有各的真实：</p>
<ul>
<li><strong>&ldquo;skia 渲染模式下页面帧生产被拖至约 3 秒一帧，疑似 VsyncOnDemand 延迟注销机制异常&rdquo;</strong>——性能问题</li>
<li><strong>&ldquo;material3 导致移植已有项目阻塞&rdquo;</strong>——组件库的坑</li>
<li><strong>&ldquo;当前 webView 有支持吗？&rdquo;</strong>——能力覆盖</li>
<li><strong>&ldquo;大家有项目真实上线吗？有遇到什么坑吗&rdquo;</strong>——底下有人回了四个字：<strong>&ldquo;正在做语音助手 KMP 版本适配&rdquo;</strong></li>
</ul>
<p><strong>这几条加起来，才是这条线的真实状态：能跑通、有人在用、但生态和性能还在补课。</strong></p>
<h2 id="为什么跟上上游是件慢活">为什么&quot;跟上上游&quot;是件慢活</h2>
<p>看到社区那句&quot;进度过于慢了&quot;，可能会有人觉得是投入不够。</p>
<p>但这里有个技术现实值得说清楚：<strong>鸿蒙适配改的是编译器后端，不是写个插件。</strong></p>
<p>翻 CPF-KMP-CMP 的仓库列表，你会看到这些名字：</p>
<p><code>kotlin</code>、<code>compose-multiplatform</code>、<code>compose-multiplatform-core</code>、<code>skiko</code>、<code>akinterop</code>、<code>cicd-toolkit</code>……</p>
<p><strong>它们是 Kotlin 编译器、Compose 框架、图形渲染层的鸿蒙分支。</strong></p>
<p>这意味着这份工作至少包括：</p>
<ul>
<li>给 Kotlin/Native 加一个新的<strong>编译目标</strong>（target）</li>
<li>把 Compose 的渲染管线接到鸿蒙的 <strong>Render Service</strong></li>
<li>让 Skiko 封装的 Skia 图形库能在鸿蒙上跑起来</li>
</ul>
<p><strong>而每次上游发新版本，这些改动都要重新对齐一遍。</strong></p>
<p>这也是&quot;上游基线 + 适配序号&quot;这个版本号规则真正的含义——<strong>它精确描述了这份工作的性质：持续的追踪，而不是一次性的移植。</strong></p>
<p>再看那句&quot;每月开发态代码合入，每月准出后合入 main&quot;，感觉就不一样了：</p>
<p><strong>这不是慢，这是在用一个能持续的速度，做一件很重的事。</strong></p>
<h2 id="我的几点看法">我的几点看法</h2>
<p><strong>第一，&ldquo;适配&quot;这件事的本质是&quot;跟上游&rdquo;，不是&quot;造轮子&quot;。</strong></p>
<p>从版本号规则（上游基线 + 适配序号）就能看出来——鸿蒙这条线的核心工作是把上游的编译器/UI 框架搬到鸿蒙上跑通，而不是自己另搞一套 API。</p>
<p><strong>这意味着它的天花板和上游绑在一起</strong>：上游把 Swift 互操作做顺了，那是 iOS 的红利；上游把编译器改进了，鸿蒙这边跟进才能吃到。</p>
<p><strong>第二，社区抱怨的那三条，其实指向同一个问题。</strong></p>
<p>x86 支持、三方库版本、性能——表面看是三件事，本质都是**&ldquo;生态成熟度&rdquo;**。</p>
<p>而生态成熟度有个残酷的特点：<strong>它不能靠热情解决，只能靠时间和真实项目打磨。</strong> 官方回&quot;已在规划中&quot;，是实话，也是实话里透出的无奈。</p>
<p><strong>第三，如果你在评估要不要上这条线，我给个判断框架。</strong></p>
<p>别问&quot;支持鸿蒙吗&quot;——<strong>问这三个问题</strong>：</p>
<ol>
<li><strong>上游跟到哪个版本了？</strong>（现在是 2.2.21 稳定、2.4.21 开发中）</li>
<li><strong>我依赖的库适配了吗？</strong>（尤其是持久化、网络这类基础库——<code>room3</code>、<code>datastore</code> 是公认痛点）</li>
<li><strong>我的开发机是什么？</strong>（如果需要 x86 模拟器，现在还得等；Mac M 系列 + 真机的组合比较稳）</li>
</ol>
<p><strong>三个都过了，再动手；有一个卡住，就先把方案记在备忘录里。</strong></p>
<h2 id="最后">最后</h2>
<p>Kotlin 2.4 和鸿蒙 2.4，看起来是两件不相干的事。</p>
<p>但它们其实在回答同一个问题：</p>
<blockquote>
<p><strong>一套 Kotlin 代码，能跑多少地方、跑得多好？</strong></p>
</blockquote>
<p>上游在把跨语言的接缝磨平，鸿蒙这条线在把版图铺开。</p>
<p><strong>这两件事，都在让那个数字变大。</strong></p>
<hr>
<p>想问问你：</p>
<p><strong>你手上的 KMP 项目，会因为&quot;鸿蒙适配跟到 2.4 了&quot;而重新考虑吗？</strong></p>
<p><strong>或者你已经踩过这条线的坑了？</strong> 评论区聊聊，我挺想知道真实项目的反馈。</p>
<hr>
<p><em>参考资料：CPF-KMP-CMP 社区仓库（AtomGit/GitCode）分支与 Release 公告、社区讨论区公开帖、JetBrains Kotlin 2.4.20 发布说明。</em></p>
]]></content:encoded></item><item><title>Kotlin 2.4.20 发布了：这次 KMP 的 iOS 互操作，真的能打了</title><link>http://mings.in/posts/kotlin-2420/</link><pubDate>Fri, 18 Sep 2026 07:02:39 +0800</pubDate><guid>http://mings.in/posts/kotlin-2420/</guid><description>9 月 7 日，JetBrains 发布了 Kotlin 2.4.20。</description><content:encoded><![CDATA[<h2 id="swift-互操作从能跑到好用">Swift 互操作：从&quot;能跑&quot;到&quot;好用&quot;</h2>
<p>9 月 7 日，JetBrains 发布了 Kotlin 2.4.20。</p>
<p>如果你只是扫一眼更新日志，可能会觉得&quot;哦，又是个修修补补的版本&quot;——毕竟两百多条变更里，大半是 bugfix。</p>
<p>但对写 KMP 的人来说，这个版本有几个东西是真香的。尤其是 Swift 互操作这块，终于补上了长期以来的几块短板。</p>
<p>先说结论：<strong>这次最值得关注的是三个方向——Swift 互操作能力、编译速度、跨平台一致性</strong>。下面一个个拆。</p>
<hr>
<h2 id="一swift-export-支持-sealed-classios-同学的-switch-终于不用写-default-了">一、Swift export 支持 sealed class：iOS 同学的 switch 终于不用写 default 了</h2>
<p>用 KMP 写过 iOS 的人，大概率都遇到过这个别扭的场景。</p>
<p>你在 Kotlin 里定义了一个很优雅的 sealed interface：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">sealed</span> <span class="k">interface</span> <span class="nc">Shape</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Circle</span> <span class="p">:</span> <span class="n">Shape</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Rectangle</span> <span class="p">:</span> <span class="n">Shape</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">createCircle</span><span class="p">():</span> <span class="n">Shape</span> <span class="p">=</span> <span class="n">Circle</span><span class="p">()</span>
</span></span></code></pre></div><p>逻辑上天衣无缝，Kotlin 侧 pattern matching 一把梭。</p>
<p>但到了 Swift 那边，Kotlin 的 sealed 类型只能当成一个普通类来用，你根本没法做穷尽匹配，只能写一堆 <code>if</code> 判断，最后再兜一个 <code>default</code>。</p>
<p>更难受的是：<strong>哪天 Kotlin 侧加了新的子类，Swift 侧的编译不会报错</strong>——于是线上就可能出现一个没人处理的 case。</p>
<p>2.4.20 把这个问题解决了。sealed 类型现在会映射成 Swift 的 enum，编译器会生成一个 <code>sealedType()</code> 方法：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">shape</span> <span class="p">=</span> <span class="n">createCircle</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">name</span> <span class="p">=</span> <span class="k">switch</span> <span class="n">shape</span><span class="p">.</span><span class="n">sealedType</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="kd">let</span> <span class="p">.</span><span class="n">circle</span><span class="p">(</span><span class="n">type</span><span class="p">):</span> <span class="s">&#34;It&#39;s a </span><span class="si">\(</span><span class="n">type</span><span class="p">.</span><span class="n">value</span><span class="si">)</span><span class="s">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="kd">let</span> <span class="p">.</span><span class="n">rectangle</span><span class="p">(</span><span class="n">type</span><span class="p">):</span> <span class="s">&#34;It&#39;s a </span><span class="si">\(</span><span class="n">type</span><span class="p">.</span><span class="n">value</span><span class="si">)</span><span class="s">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>注意这里——<strong>没有 default</strong>。</p>
<p>Swift 编译器会检查穷尽性，而且因为映射成了 enum，Xcode 的自动补全也跟着好用了。哪天 Kotlin 侧加了新的 sealed 子类，Swift 侧会直接编译报错提醒你处理。</p>
<p>这比&quot;运行时才发现漏了 case&quot;强太多了。</p>
<h2 id="二跨语言继承用纯-swift-库不用再绕路了">二、跨语言继承：用纯 Swift 库，不用再绕路了</h2>
<p>第二个变化更实际：Swift export 支持跨语言继承了。</p>
<p>什么意思？说个真实场景——你想在 iOS 侧用 CryptoKit 这个纯 Swift 库，但它没法直接导入 Kotlin。</p>
<p>以前的做法要么是写一层 Objective-C 桥接，要么干脆放弃，在 Kotlin 侧找个替代实现。现在可以直接这么干：</p>
<p>Kotlin 侧声明接口和一个开放基类：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">interface</span> <span class="nc">CryptoProvider</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">fun</span> <span class="nf">hashMD5</span><span class="p">(</span><span class="n">input</span><span class="p">:</span> <span class="n">String</span><span class="p">):</span> <span class="n">String</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">processHash</span><span class="p">(</span><span class="n">provider</span><span class="p">:</span> <span class="n">CryptoProvider</span><span class="p">,</span> <span class="n">input</span><span class="p">:</span> <span class="n">String</span><span class="p">):</span> <span class="n">String</span> <span class="p">=</span>
</span></span><span class="line"><span class="cl">    <span class="n">provider</span><span class="p">.</span><span class="n">hashMD5</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">open</span> <span class="k">class</span> <span class="nc">SwiftBase</span>
</span></span></code></pre></div><p>Swift 侧继承基类、实现接口，然后把对象传回 Kotlin：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">CryptoKit</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">final</span> <span class="kd">class</span> <span class="nc">IosCryptoProvider</span><span class="p">:</span> <span class="n">SwiftBase</span><span class="p">,</span> <span class="n">CryptoProvider</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">func</span> <span class="nf">hashMD5</span><span class="p">(</span><span class="n">input</span><span class="p">:</span> <span class="nb">String</span><span class="p">)</span> <span class="p">-&gt;</span> <span class="nb">String</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">guard</span> <span class="kd">let</span> <span class="nv">data</span> <span class="p">=</span> <span class="n">input</span><span class="p">.</span><span class="n">data</span><span class="p">(</span><span class="n">using</span><span class="p">:</span> <span class="p">.</span><span class="n">utf8</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="s">&#34;failed&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Insecure</span><span class="p">.</span><span class="n">MD5</span><span class="p">.</span><span class="n">hash</span><span class="p">(</span><span class="n">data</span><span class="p">:</span> <span class="n">data</span><span class="p">).</span><span class="n">description</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">provider</span> <span class="p">=</span> <span class="n">IosCryptoProvider</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="bp">print</span><span class="p">(</span><span class="n">processHash</span><span class="p">(</span><span class="n">provider</span><span class="p">:</span> <span class="n">provider</span><span class="p">,</span> <span class="n">input</span><span class="p">:</span> <span class="s">&#34;Hello, world!&#34;</span><span class="p">))</span>
</span></span></code></pre></div><p>Kotlin 收到这个 Swift 对象时，就把它当成接口的普通实现来调用。</p>
<p>这就是官方说的 reverse import 模式：<strong>契约写在 Kotlin，实现在 Swift</strong>。对那些必须依赖平台原生库的场景，这条路终于顺了。</p>
<h2 id="三增量编译进-beta改一行代码不用再等一杯咖啡">三、增量编译进 Beta：改一行代码，不用再等一杯咖啡</h2>
<p>这条可能是最能改善日常开发体验的。</p>
<p>Kotlin/Native 的编译速度一直是 KMP 的痛点之一。klib 的增量编译其实从 1.9.20 就有了，但一直在打磨。这次正式进入 Beta，官方说 debug 构建的编译时间大幅减少。</p>
<p>打开方式很简单，在 <code>gradle.properties</code> 里加一行：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="cl"><span class="na">kotlin.incremental.native</span><span class="o">=</span><span class="s">true</span>
</span></span></code></pre></div><p>官方还提到，他们计划在后续版本里默认开启这个选项。</p>
<p>顺带一提，导出 XCFramework 的时候，<code>assembleSharedXCFramework</code> 任务现在会自动生成 <code>Package.swift</code> 文件。以前发布带 SwiftPM 依赖的 XCFramework，得自己处理 SwiftPM 包的分发问题，现在省事了。</p>
<hr>
<h2 id="四wasm-侧几个不容易注意但很关键的改动">四、Wasm 侧：几个不容易注意但很关键的改动</h2>
<p>如果你的项目有 web 端，这几点值得留意。</p>
<p><strong>伴生对象初始化顺序对齐 JVM</strong></p>
<p>以前 Kotlin/Wasm 里，父类和子类的 companion object 初始化顺序可能和 JVM 相反。跨平台项目里，这种差异最容易变成&quot;本地跑得好好的，换个平台就出鬼&quot;的 bug。现在统一按 JVM 的行为来了。</p>
<p><strong>Wasmtime 运行时支持</strong></p>
<p><code>wasmWasi</code> 目标以前只能用 Node.js 跑，还得先做一层 JavaScript 引导。现在可以直接用 Wasmtime 这个独立的 WebAssembly 运行时：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">kotlin</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">wasmWasi</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">wasmtime</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p><strong>新增编译模式</strong></p>
<p>以前只有 monolith 模式（整个项目和依赖一起编译，生成单个二进制，体积最小但每次都全量）。现在多了两个多模块模式，可以按需选择：</p>
<ul>
<li>开发时用 <code>multimodule-closed-world</code>，只重编译改动的模块，迭代更快</li>
<li>生产构建仍用 monolith，保证产物最小</li>
</ul>
<p>配置方式：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="cl"><span class="na">kotlin.wasm.compilationMode</span><span class="o">=</span><span class="s">multimodule-open-world</span>
</span></span></code></pre></div><p><strong>注意一个 breaking change</strong>：<code>@JsFun</code> 声明里现在用顶层 <code>require()</code> 会直接报错。如果之前这么写过，得迁移到 <code>@JsModule</code> 注解或者 <code>import()</code> 表达式。</p>
<h2 id="五kotlinjskarma-退役playwright-接棒">五、Kotlin/JS：Karma 退役，Playwright 接棒</h2>
<p>Kotlin/JS 的浏览器测试一直用 Karma 当启动器，但 Karma 项目已经停止维护两年了。</p>
<p>2.4.20 引入了新的测试 DSL：底层换成 Playwright 驱动（支持 Chromium、Firefox、WebKit 三家），测试运行器用 Mocha，打包工具暂时还是 webpack（官方说后续会换 Vite）。</p>
<p>用法是在 <code>browser {}</code> 里加一个 <code>test {}</code> 块：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">kotlin</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">js</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">browser</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nd">@OptIn</span><span class="p">(</span><span class="n">ExperimentalJsTestDsl</span><span class="o">::</span><span class="k">class</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">test</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="n">timeout</span> <span class="p">=</span> <span class="mi">2</span><span class="p">.</span><span class="n">seconds</span>
</span></span><span class="line"><span class="cl">                <span class="n">chromium</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">                <span class="n">firefox</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">                <span class="n">webkit</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>另外，suspend lambda 现在可以导出成 JavaScript 的 async 函数了——这个对跨端调用挺实用。</p>
<h2 id="六标准库和其他">六、标准库和其他</h2>
<p>几个顺手能用的：</p>
<ul>
<li><strong>协程堆栈恢复</strong>：新增 <code>StackTraceRecoverable</code> 接口。协程之间抛异常时，能看清异常从哪来、被谁重新抛出。自带复杂构造参数的异常，实现一下 <code>copyForStackTraceRecovery()</code> 就行。</li>
<li><strong>集合工具</strong>：新增了检查集合元素相等性和唯一性的函数。</li>
<li><strong>kotlin.test</strong>：新增断言函数重载。</li>
<li><strong>Gradle</strong>：完全兼容 Gradle 7.6.3 到 9.7.0；Problems API 现在带编译器诊断 ID 并做了分组，排错时能快速定位问题来源。</li>
<li><strong>编译器</strong>：JVM 21+ 上 <code>when</code> 表达式的 invokedynamic 编译从实验转正（默认开启）；新增 <code>kotlinr</code> 运行命令和独立的 native image。</li>
</ul>
<hr>
<h2 id="这些变化对-kmp-开发者意味着什么">这些变化对 KMP 开发者意味着什么</h2>
<p>把上面串起来看，我觉得 2.4.20 释放了三个信号：</p>
<p><strong>1. iOS 互操作正在从&quot;能用&quot;走向&quot;好用&quot;</strong></p>
<p>sealed class 映射成 enum、跨语言继承支持纯 Swift 库——这两条直击 KMP 在 iOS 侧最常被吐槽的点。以前大家说 KMP &ldquo;Android 体验好，iOS 差点意思&rdquo;，现在这个差距在肉眼可见地缩小。</p>
<p><strong>2. 编译体验被当成正经事在解决</strong></p>
<p>klib 增量编译进 Beta、Wasm 多模块编译模式，都是在解决同一件事：<strong>让日常开发不用等</strong>。编译速度是 KMP 最现实的劝退点之一，这方向对了。</p>
<p><strong>3. 跨平台一致性越来越被重视</strong></p>
<p>Wasm 的伴生对象初始化顺序对齐 JVM，看起来是个小改动，但这类&quot;平台间行为不一致&quot;恰恰是跨平台项目最难缠的问题。官方在主动收窄这些差异。</p>
<p>顺带提一句另一条新闻：Google 前几天发布了 <strong>ADK for Kotlin 1.0</strong>，可以用 Kotlin 写生产级 AI Agent。Kotlin 和 AI Agent 这两个圈子，今年的交集肉眼可见地多起来了。</p>
<hr>
<h2 id="升级建议">升级建议</h2>
<ul>
<li>一般项目：直接升。这个版本以修复为主，风险不大。</li>
<li><strong>KMP 项目</strong>：强烈建议升，Swift export 那几个改动是实打实的体验提升。</li>
<li><strong>用 Wasm 的项目</strong>：升级前先检查 <code>@JsFun</code> 里的 <code>require()</code> 调用，这是唯一的 breaking change。</li>
<li><strong>想试增量编译</strong>：先加 <code>kotlin.incremental.native=true</code> 试试水，有问题及时反馈给官方。</li>
</ul>
<p>Kotlin 的版本节奏一直很稳，不搞大跃进。这种&quot;每次解决几个真问题&quot;的迭代方式，其实对生产项目最友好。</p>
<hr>
<p><em>参考资料：<a href="https://kotlinlang.org/docs/whatsnew2420.html">What&rsquo;s new in Kotlin 2.4.20</a> · <a href="https://github.com/JetBrains/kotlin/releases/tag/v2.4.20">GitHub Release</a></em></p>
]]></content:encoded></item><item><title>Kotlin 大神拒绝了 80% 的工作机会：我为什么坚决不用 AI 写代码</title><link>http://mings.in/posts/jake-wharton-ai/</link><pubDate>Thu, 17 Sep 2026 07:24:40 +0800</pubDate><guid>http://mings.in/posts/jake-wharton-ai/</guid><description>如果你是个资深工程师，找工作时会写什么要求？</description><content:encoded><![CDATA[<h2 id="一个反常识的求职标准">一个反常识的求职标准</h2>
<p>如果你是个资深工程师，找工作时会写什么要求？</p>
<p>钱多、事少、技术栈新、团队靠谱——大概就这些。</p>
<p>Jake Wharton 不一样。这位被国内开发者称为&quot;J 神&quot;的 Android / Kotlin 开源大神，给自己列了九项择业标准。</p>
<p><strong>第一条是：不加入 AI 公司，不参与以 AI 为核心的产品，也不接受公司强制工程师使用 AI。</strong></p>
<p>他估算了一下，这一条直接排除了约 80% 的潜在雇主。</p>
<p>先别急着说他&quot;清高&quot;。他并非没有选择——美国资深 Kotlin 工程师的基本年薪通常 20 万美元起，外加每年约 10 万美元的股权（四年归属）。进入 AI 赛道回报可能更高。</p>
<p>但他还是这么干了。</p>
<p>InfoQ 前几天发了这场两小时访谈的完整稿，一万三千多字。我读完最有感触的不是他的结论，而是他给的理由。</p>
<h2 id="他是谁kotlin-成为-android-官方语言的推手之一">他是谁：Kotlin 成为 Android 官方语言的推手之一</h2>
<p>不熟悉 Kotlin 圈的人可能不知道这个名字的重量。</p>
<p>Jake Wharton 是 OkHttp、Okio、Retrofit 这些明星开源库的作者或核心贡献者——写过 Android 的人大概率都用过他的库。</p>
<p>更关键的是：<strong>他是 Google Kotlin 团队的第一位工程师。</strong></p>
<p>在加入 Google 之前，他在 Square（后来的 Cash App）工作。当时为了说服公司内部采用 Kotlin，他写了一份论证文档。这份文档后来被他公开，间接推动了整个 Android 社区对 Kotlin 的兴趣。</p>
<p>后来 Google 决定让 Kotlin 成为 Android 官方一等语言，他加入了那个团队。</p>
<p>他说，这个机会&quot;几乎不可能拒绝&quot;——因为没有什么比加入负责整个 Android 工具链的公司、并推动 Kotlin 成为官方语言，是更大的认可了。</p>
<p><strong>所以他不是&quot;反技术&quot;的人，是把职业生涯押在语言和工具链上的人。</strong></p>
<p>那问题就来了：这样一个人，为什么对 AI 编程如此抗拒？</p>
<h2 id="理由一ai-的推广方向和过去所有工具都相反">理由一：AI 的推广方向，和过去所有工具都相反</h2>
<p>这是我读完最认同的一段。Jake Wharton 原话：</p>
<blockquote>
<p>传统上，一项开发工具通常由工程师自下而上推动。可能是一名工程师喜欢上一门新语言，比如 Kotlin；也可能是一款新 IDE，比如 IntelliJ IDEA；或者是一套新的构建系统。工程师在实际工作中发现它有价值，热情逐渐感染其他人，然后大家再去说服工程管理者批准采用它。</p>
<p>但 AI 工具的推广方向似乎完全相反。为什么要由一家公司的 CEO 告诉工程师，日常工作应该使用什么工具？</p>
</blockquote>
<p>这个观察很锋利。</p>
<p>回想 Kotlin 是怎么起来的：<strong>一群工程师真的觉得它好用，在项目里偷偷开始用，然后慢慢说服老板。</strong> 从下往上，自发的。</p>
<p>而 AI 编程工具的落地路径，在不少公司是这样的：<strong>高管看到新闻，要求全员必须用 AI 提效，纳入考核。</strong> 从上往下，行政式的。</p>
<p><strong>工具的合法性，过去来自它真的好用；现在，有时候来自它被规定必须用。</strong></p>
<p>这个转变，值得每个工程师想一想。</p>
<h2 id="理由二交出的是能力不只是工作">理由二：交出的是能力，不只是工作</h2>
<p>关于&quot;AI 会让工程师变成齿轮&quot;这个说法，他的完整表述是这样的：</p>
<blockquote>
<p>AI 可以帮助工程师处理重复劳动，但最不负责任的用法，是让 AI 生成超出使用者能力范围的代码。如果没有大模型就无法完成任务，开发者往往也无法真正理解、验证和维护结果。</p>
<p>一旦形成依赖，工程师交出去的不只是部分工作，还包括自己的能力和议价权，最终可能变成一枚更容易被低成本替换的齿轮。</p>
</blockquote>
<p>注意这句话的边界——<strong>他没有说&quot;不要用 AI&quot;，他说的是&quot;不要用 AI 生成超出你能力范围的代码&quot;。</strong></p>
<p>这两句话差别很大。</p>
<p>前者的意思是&quot;AI 会取代你&quot;，后者说的是一个更具体、也更隐蔽的风险：</p>
<p>如果一个需求，你原本需要花两天想明白怎么设计，现在让 AI 十秒钟吐出来一段能跑但你不完全理解的代码——</p>
<p><strong>代码是有了，但你对这个系统的理解，其实退化了。</strong></p>
<p>几次之后，你维护的东西里，有一块是你自己看不懂的。</p>
<p>到那时候，你的价值锚点在哪里？</p>
<h2 id="理由三版权和许可证这笔账还没算清">理由三：版权和许可证，这笔账还没算清</h2>
<p>这条他讲得很直接：</p>
<blockquote>
<p>在我看来，它们建立在大规模使用受版权保护内容的基础上。模型训练忽视了艺术家的版权，也忽视了开源代码的许可证。</p>
<p>能够在网上访问一项内容，并不意味着你就拥有重新组合、改写它，甚至逐字输出它的权利。</p>
</blockquote>
<p>对一个把大量心血投入开源项目的人来说，这个立场很好理解——<strong>他的代码被人拿去训练模型，然后模型生成的东西反过来又卖给他，这笔账在伦理上确实没算清。</strong></p>
<p>他还补了个细节：现在开源维护者要应对的负担里，多了一项——<strong>大量低质量的 AI 生成 PR</strong>。</p>
<p>这个我信。维护过开源项目的人应该都有体感。</p>
<h2 id="但他并不认为-ai-会消失">但他并不认为 AI 会消失</h2>
<p>别误读他。访谈里他对 AI 的能力判断，其实相当克制也很清醒：</p>
<blockquote>
<p>&ldquo;AI 已经强大得令人不安&rdquo;，但这并不意味着它能够稳定、无误地承担整个软件工程过程。</p>
<p>对他而言，AI 最多只能是工具箱中的一件工具，而不能成为工程能力的地基。</p>
</blockquote>
<p>&ldquo;不能成为地基&rdquo;——这个比喻我觉得很准。</p>
<p><strong>工具箱里的工具，是你可以不用的；地基，是没有就站不住的。</strong></p>
<p>他还提到一个反直觉的观点：AI 能写代码，不等于开源库失去价值。因为开源的价值不只是&quot;给你一段能用的代码&quot;，而是<strong>持续维护带来的长期红利</strong>——</p>
<blockquote>
<p>可能有人今天修复了一个你暂时不会遇到的边缘 Bug，两年后你的业务重构，本来会触发这个问题，但因为你使用的是同一个开源库，它已经被提前解决。</p>
</blockquote>
<p>这段逻辑很严密：<strong>AI 给你的是当下的一份代码，开源给你的是一个持续演进的公共基础设施。</strong> 这两者不是一回事。</p>
<h2 id="顺带聊了-kotlin-圈的事这几条和我们相关">顺带聊了 Kotlin 圈的事，这几条和我们相关</h2>
<p>访谈后半段聊 Kotlin，有几条对你我这种写 Kotlin 的人挺有价值：</p>
<p><strong>1. &ldquo;Kotlin 很像是 Java 十年后想要成为的样子&rdquo;</strong></p>
<p>他解释：Java 必须避免破坏已有代码，所以语言演进慢；Kotlin 能把很多未来可能进 Java 的现代特性提前给开发者用，还能兼容停留在 Java 8 / 11 的老代码库。</p>
<p><strong>2. 会 Kotlin 已经不是职业优势了</strong></p>
<p>这句听着扎心，但他的解释很公道：</p>
<blockquote>
<p>我不确定它现在还能算是一项优势。从某种意义上说，这反而体现了 Kotlin 的成功：它已经成为一项默认要求，而不再是一种稀缺、难以找到的能力。</p>
</blockquote>
<p>因为 Google 把 Kotlin 定成 Android 官方语言，现在学 Android 就直接学 Kotlin，用人单位默认你会。</p>
<p><strong>但——</strong></p>
<blockquote>
<p>如果开发者掌握的不只是常规 Kotlin，而是 Kotlin Multiplatform 等相对进阶的能力，那么它仍然可能构成差异化优势，让你从其他 Kotlin 开发者中脱颖而出。</p>
</blockquote>
<p><strong>&ldquo;KMP 还是差异化优势&rdquo;</strong> 这句话，大概是这段访谈对我们最直接的信号。</p>
<p><strong>3. Meta 把 Kotlin 格式化工具捐了</strong></p>
<p>就在他录制这期节目的那一周，Meta 把自己的 Kotlin 格式化工具贡献给了 Kotlin Foundation，未来可能成为官方格式化工具。</p>
<p><strong>4. 2026 年的 Kotlin 由三方共治</strong></p>
<p>理想状态是 JetBrains、Google 和社区三方共同参与。Kotlin Foundation 成立的目的之一，就是让这门语言不再完全隶属于 JetBrains——避免任何一家公司单独控制它。</p>
<h2 id="我的两难一边写-ai-客户端一边听他反-ai">我的两难：一边写 AI 客户端，一边听他反 AI</h2>
<p>说点私人的。</p>
<p>我最近在写一本关于 <strong>AI Chat 客户端</strong>的小册子——从零搭一个跨平台的 AI 聊天应用。用的技术栈就是 KMP。</p>
<p>所以读这篇访谈时，我有种奇妙的错位感：<strong>我一边在用 AI 能力做产品，一边听行业里最受尊敬的人之一说，AI 正在毁掉工程。</strong></p>
<p>但冷静下来想，我其实不觉得矛盾。</p>
<p><strong>他反对的从来不是&quot;AI 这个工具&quot;，而是三件事：</strong></p>
<ol>
<li>把 AI 当成强制性的组织纪律，而不是可选择的工具</li>
<li>用 AI 替代思考，让自己失去验证和维护的能力</li>
<li>建立在版权和许可证问题上的商业模式</li>
</ol>
<p>这三条里，只有第二条是我需要时刻警惕的——尤其是在我自己写代码、写教程的时候。</p>
<p><strong>工具会越来越强，这是趋势，拦不住也不用拦。但&quot;我的判断力还在不在自己手上&quot;，这件事只能自己负责。</strong></p>
<p>这大概就是他那句&quot;不能成为地基&quot;的意思。</p>
<h2 id="最后">最后</h2>
<p>这场访谈里，他还有一句话我印象很深：</p>
<blockquote>
<p>我不认为过去几年软件的整体质量得到了提高，也不认为这类工具长期来看会改善软件质量。但一些公司高管看到的却是另一幅图景。</p>
</blockquote>
<p>到底哪幅图景是对的？现在谁也不知道，可能需要五年才能验证。</p>
<p><strong>但有一个风险是确定的：如果一个人把自己的能力全部外包出去，那无论 AI 有没有那么强，他都已经没有议价权了。</strong></p>
<p>你怎么看这件事？你现在的工作里，AI 参与了多少？</p>
<p>欢迎在评论区聊聊——尤其想知道，有没有人跟我一样，一边用 AI 做产品，一边对它的边界心里发毛。</p>
<hr>
<p><em>参考资料：<a href="https://www.infoq.cn/article/QPRcUdPGic5r8Ws8R1gM">Jake Wharton 访谈全文（InfoQ，2026-09-11）</a> · <a href="https://www.youtube.com/watch?v=9WjPdgOK-68">访谈原视频</a></em></p>
]]></content:encoded></item><item><title>Kotlin 开发者终于能写生产级 AI Agent 了：Google ADK for Kotlin 1.0 深度解读</title><link>http://mings.in/posts/adk-kotlin-10/</link><pubDate>Thu, 17 Sep 2026 07:24:40 +0800</pubDate><guid>http://mings.in/posts/adk-kotlin-10/</guid><description>做 Kotlin 的人这两年大概都有个心结。</description><content:encoded><![CDATA[<h2 id="一个-kmp-核心的-agent-框架意味着什么">一个 KMP 核心的 Agent 框架，意味着什么</h2>
<p>做 Kotlin 的人这两年大概都有个心结。</p>
<p>AI Agent 的浪潮一波接一波，但翻开源码一看——LangChain、CrewAI、AutoGen，清一色 Python。想在自己熟悉的 Kotlin 生态里搭一套 Agent，要么自己拿 HTTP 客户端手搓，要么用 Python 写个服务再从 Kotlin 调，中间隔着进程和序列化的双重麻烦。</p>
<p>9 月 9 日，Google 把这个空缺补上了：<strong>Agent Development Kit (ADK) for Kotlin 1.0 正式 GA</strong>。</p>
<p>而且这次不是简单地把 Python 版翻译一遍——它的底层是一个 <strong>Kotlin Multiplatform (KMP) 核心</strong>。</p>
<p>这个细节很关键，后面细说。</p>
<h2 id="先看它到底是个什么东西">先看它到底是个什么东西</h2>
<p>ADK for Kotlin 的定位很清楚：<strong>与后端模型、会话存储、记忆系统完全解耦</strong>。</p>
<p>说人话就是：Agent 的逻辑用 Kotlin 写一遍，模型可以接 Gemini，也可以接别的；会话存内存、存 Room、还是存云端，随你换。</p>
<p>1.0 版本已经和 ADK Python / Java 版功能对齐，把 Python 生态里那些成熟的多智能体模式搬了过来：</p>
<ul>
<li><strong>层级多智能体</strong>：Agent 可以链式编排，把任务委派给专门的子 Agent</li>
<li><strong>上下文压缩</strong>：历史对话太长时自动摘要，把 token 控制在窗口内</li>
<li><strong>Human-in-the-loop</strong>：敏感操作可以暂停，等用户确认后再继续执行</li>
<li><strong>会话可恢复</strong>：Agent 交互状态能序列化保存，跨会话恢复</li>
<li><strong>Java 互操作</strong>：现存的 Java 应用可以直接调用 Kotlin 写的 Agent</li>
<li><strong>Vertex AI 集成</strong>：企业级的 Session、RAG Memory、Memory Bank 服务</li>
</ul>
<p>这里有一条我觉得特别值得说的：<strong>上下文压缩</strong>。</p>
<p>写过 AI Chat 的人都懂，多轮对话的历史管理是个绕不开的活儿。做滑动窗口会丢信息，全量塞进去会爆 token。ADK 把它做成了框架内置能力，不用自己造轮子。</p>
<h2 id="技术亮点为什么说它是kotlin-原生">技术亮点：为什么说它是&quot;Kotlin 原生&quot;</h2>
<p>这是我觉得 ADK for Kotlin 最有意思的地方。</p>
<h3 id="用注解定义工具编译期生成-schema">用注解定义工具，编译期生成 schema</h3>
<p>Python 的 Agent 框架普遍靠运行时反射来解析函数签名、生成工具描述。ADK for Kotlin 走了另一条路——<strong>用 KSP（Kotlin Symbol Processing）在编译期生成</strong>。</p>
<p>你的代码写起来就是普通的 Kotlin 函数：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">InfrastructureDiagnosticsService</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nd">@Tool</span>
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">getServiceMetrics</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Target service or database cluster&#34;</span><span class="p">)</span> <span class="n">serviceName</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Time window in minutes&#34;</span><span class="p">)</span> <span class="n">windowMinutes</span><span class="p">:</span> <span class="n">Int</span><span class="p">?</span> <span class="p">=</span> <span class="mi">15</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">):</span> <span class="n">ServiceMetrics</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// 查监控后端：Datadog、Prometheus、Cloud Monitoring
</span></span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">ServiceMetrics</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">serviceName</span> <span class="p">=</span> <span class="n">serviceName</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">cpuUsagePercent</span> <span class="p">=</span> <span class="mf">91.4</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">connectionPoolUsagePercent</span> <span class="p">=</span> <span class="mf">98.5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">p99LatencyMs</span> <span class="p">=</span> <span class="mi">2450</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">errorRatePercent</span> <span class="p">=</span> <span class="mf">4.2</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>编译期 KSP 会自动生成 <code>generatedTools()</code> 扩展函数。</p>
<p><strong>这意味着三件事</strong>：类型安全（schema 和函数签名不会对不上）、支持 suspend 函数（协程天然融入）、零运行时反射（启动快、体积小，对 Android 尤其友好）。</p>
<p>对写惯了编译期校验的 Kotlin 开发者来说，这个设计取向应该会很有共鸣——<strong>能在编译期发现的问题，就别拖到运行时</strong>。</p>
<h3 id="skill-机制渐进式披露">Skill 机制：渐进式披露</h3>
<p>ADK 里有个叫 Skill 的概念，我觉得设计得挺巧。</p>
<p>你把标准作业流程写成 <code>SKILL.md</code> 放在 resources 里：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">database-incident-triage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">description</span><span class="p">:</span><span class="w"> </span><span class="l">Standard operating procedure for diagnosing database latency spikes.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">allowed-tools</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">getServiceMetrics, fetchRecentDeployments, notifyOnCall]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gh"># Database Incident Triage SOP
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">1.</span> <span class="gs">**Telemetry**</span>: Call <span class="sb">`getServiceMetrics`</span> to inspect CPU, latency, and pool saturation.
</span></span><span class="line"><span class="cl"><span class="k">2.</span> <span class="gs">**Correlation**</span>: Call <span class="sb">`fetchRecentDeployments`</span> to check for recent changes.
</span></span><span class="line"><span class="cl"><span class="k">3.</span> <span class="gs">**Safety Rules**</span>: Never restart primary nodes during peak hours.
</span></span><span class="line"><span class="cl"><span class="k">4.</span> <span class="gs">**Notify**</span>: Broadcast diagnosis to <span class="sb">`#production-alerts`</span>.
</span></span></code></pre></div><p>Agent 只在需要时才把这份 SOP 加载进上下文，附带的资源文件（比如 <code>assets/mitigation_rules.txt</code>）也是按需拉取。</p>
<p>官方管这叫 <strong>progressive disclosure</strong>——<strong>token 花在刀刃上，别一上来就把所有知识灌给模型</strong>。</p>
<h2 id="上手实战一个数据库故障诊断-agent">上手实战：一个数据库故障诊断 Agent</h2>
<p>官方给的示例很实在。假设线上数据库告警了，我们要个 Agent 来帮忙分诊：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">object</span> <span class="nc">IncidentTriageDemoAgent</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">rootAgent</span> <span class="p">=</span> <span class="n">LlmAgent</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">name</span> <span class="p">=</span> <span class="s2">&#34;incident_triage_agent&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">model</span> <span class="p">=</span> <span class="n">Gemini</span><span class="p">(</span><span class="n">name</span> <span class="p">=</span> <span class="s2">&#34;gemini-3.8-flash&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="n">instruction</span> <span class="p">=</span> <span class="n">Instruction</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="s">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">            You are an SRE on-call diagnostic assistant.
</span></span></span><span class="line"><span class="cl"><span class="s">            1. Load the matching triage SOP using `load_skill`.
</span></span></span><span class="line"><span class="cl"><span class="s">            2. Follow the playbook steps strictly.
</span></span></span><span class="line"><span class="cl"><span class="s">            3. Use your diagnostics tools to inspect telemetry and notify the team.
</span></span></span><span class="line"><span class="cl"><span class="s">            &#34;&#34;&#34;</span><span class="p">.</span><span class="n">trimIndent</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// 编译期生成的函数工具（零反射）
</span></span></span><span class="line"><span class="cl">        <span class="n">tools</span> <span class="p">=</span> <span class="n">InfrastructureDiagnosticsService</span><span class="p">().</span><span class="n">generatedTools</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// 动态技能工具集
</span></span></span><span class="line"><span class="cl">        <span class="n">toolsets</span> <span class="p">=</span> <span class="n">listOf</span><span class="p">(</span><span class="n">SkillToolset</span><span class="p">(</span><span class="n">NewFileSystemSource</span><span class="p">(</span><span class="n">resolveSkillsDir</span><span class="p">()))),</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>跑起来：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">main</span><span class="p">()</span> <span class="p">=</span> <span class="n">runBlocking</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">runner</span> <span class="p">=</span> <span class="n">InMemoryRunner</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">agent</span> <span class="p">=</span> <span class="nc">IncidentTriageDemoAgent</span><span class="p">.</span><span class="n">rootAgent</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">appName</span> <span class="p">=</span> <span class="s2">&#34;IncidentTriageApp&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">alert</span> <span class="p">=</span> <span class="s2">&#34;ALERT [P1]: Database latency spike detected on &#39;users-postgres-cluster&#39;!&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">events</span> <span class="p">=</span> <span class="n">runner</span><span class="p">.</span><span class="n">runAsync</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">userId</span> <span class="p">=</span> <span class="s2">&#34;oncall-sre&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">sessionId</span> <span class="p">=</span> <span class="nc">UUID</span><span class="p">.</span><span class="n">randomUUID</span><span class="p">().</span><span class="n">toString</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">        <span class="n">newMessage</span> <span class="p">=</span> <span class="nc">Content</span><span class="p">.</span><span class="n">fromText</span><span class="p">(</span><span class="nc">Role</span><span class="p">.</span><span class="n">USER</span><span class="p">,</span> <span class="n">alert</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">).</span><span class="n">toList</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="n">event</span> <span class="k">in</span> <span class="n">events</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">event</span><span class="p">.</span><span class="n">content</span><span class="o">?.</span><span class="n">parts</span><span class="o">?.</span><span class="n">firstOrNull</span><span class="p">()</span><span class="o">?.</span><span class="n">text</span><span class="o">?.</span><span class="n">let</span> <span class="p">{</span> <span class="n">println</span><span class="p">(</span><span class="s2">&#34;Agent: </span><span class="si">$it</span><span class="s2">&#34;</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>注意这里用的是 <code>runBlocking</code> + 流式事件——<strong>协程是这套框架的一等公民</strong>，不是硬塞进去的。</p>
<p>Agent 接到告警后会自动走完一套流程：加载 SOP → 调 <code>getServiceMetrics()</code> 发现连接池饱和 98.5% → 调 <code>fetchRecentDeployments()</code> 定位到 25 分钟前的一次部署 → 推送诊断到告警频道 → 建议回滚。</p>
<p>这就是一个完整的<strong>工具调用 + 领域知识 + 自主决策</strong>的闭环。</p>
<h2 id="android-端侧不只有云端">Android 端侧：不只有云端</h2>
<p>如果你做移动端，ADK for Kotlin 的 Android 扩展可能更让你兴奋。</p>
<p>移动端 AI 的难点在于平衡：云端推理能力强，但延迟、隐私、离线可用性都是问题。ADK for Kotlin 1.0 给了一套模块化的 Android 方案：</p>
<ul>
<li><strong>LiteRT-LM</strong>：端侧跑小模型</li>
<li><strong>ML Kit（beta）</strong>：本地推理能力</li>
<li><strong>Firebase AI Logic</strong>：云端 + 端侧的混合编排</li>
<li><strong>Room 持久化</strong>：会话历史存 SQLite，进程被杀也不丢</li>
<li><strong>AppSearch 索引记忆</strong>：端侧全文检索的记忆服务</li>
<li><strong>FileArtifactService</strong>：生成的文件（对账单、回执）直接落本地存储</li>
</ul>
<p>官方那个&quot;金融助手&quot;示例把这些串起来了：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">createAndroidRunner</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">:</span> <span class="n">Context</span><span class="p">,</span> <span class="n">agent</span><span class="p">:</span> <span class="n">LlmAgent</span><span class="p">):</span> <span class="n">InMemoryRunner</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">InMemoryRunner</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">agent</span> <span class="p">=</span> <span class="n">agent</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">appName</span> <span class="p">=</span> <span class="s2">&#34;AndroidFinancialApp&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// SQLite 持久化：进程死亡也能恢复会话
</span></span></span><span class="line"><span class="cl">        <span class="n">sessionService</span> <span class="p">=</span> <span class="nc">RoomSessionService</span><span class="p">.</span><span class="n">fromContext</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// 端侧全文索引记忆
</span></span></span><span class="line"><span class="cl">        <span class="n">memoryService</span> <span class="p">=</span> <span class="nc">AppSearchMemoryService</span><span class="p">.</span><span class="n">fromContext</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// 应用私有文件存储
</span></span></span><span class="line"><span class="cl">        <span class="n">artifactService</span> <span class="p">=</span> <span class="nc">FileArtifactService</span><span class="p">.</span><span class="n">fromExternalFilesDir</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>而且它把 HITL 做得很实用。转账这种敏感操作，定义工具时加个标记就行：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nd">@Tool</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">name</span> <span class="p">=</span> <span class="s2">&#34;transferFunds&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">description</span> <span class="p">=</span> <span class="s2">&#34;Transfers money to another account. Requires explicit user approval.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">requireConfirmation</span> <span class="p">=</span> <span class="k">true</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">transferFunds</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Recipient account ID&#34;</span><span class="p">)</span> <span class="n">recipientId</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Amount in USD&#34;</span><span class="p">)</span> <span class="n">amount</span><span class="p">:</span> <span class="n">Double</span>
</span></span><span class="line"><span class="cl"><span class="p">):</span> <span class="n">String</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
</span></span></code></pre></div><p>Agent 执行到这里会自动<strong>暂停</strong>，等用户在 UI 上点确认再继续。</p>
<p><strong>这一条对做金融、医疗类 App 的同学，基本是刚需。</strong></p>
<h2 id="对-kmp-开发者意味着什么">对 KMP 开发者意味着什么</h2>
<p>回到开头说的那个&quot;KMP 核心&quot;。</p>
<p>ADK for Kotlin 的核心引擎是跨平台的，跟模型后端、会话存储、记忆系统全都解耦。这个架构选择的含义是：<strong>Agent 的业务逻辑可以共享</strong>。</p>
<p>想象一下：你的 Agent 编排逻辑、工具定义、SOP 技能文件，在 Android 上能跑，在服务端 JVM 上也能跑，未来如果 iOS 侧也支持，那就是一套代码三端复用——这不就是 KMP 一直在讲的故事吗。</p>
<p>结合我们之前聊的 <strong>Kotlin 2.4.20</strong>（Swift export 的 sealed class 映射、跨语言继承），Kotlin 在跨端 + AI 这两条线上的拼图，今年开始明显拼上了。</p>
<p>对做 AI Chat 客户端的开发者来说，还有个更直接的启发：<strong>框架级的能力（会话持久化、上下文压缩、HITL）已经有成熟实现了</strong>，不用再从零造。</p>
<h2 id="怎么开始">怎么开始</h2>
<p>加依赖就行：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">dependencies</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// 核心引擎 + KSP 处理器
</span></span></span><span class="line"><span class="cl">    <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;com.google.adk:google-adk-kotlin-core:1.0.0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ksp</span><span class="p">(</span><span class="s2">&#34;com.google.adk:google-adk-kotlin-processor:1.0.0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1">// Android 端侧扩展（可选）
</span></span></span><span class="line"><span class="cl">    <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;com.google.adk:google-adk-kotlin-mlkit-android:1.0.0-beta&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;com.google.adk:google-adk-kotlin-litertlm:1.0.0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;com.google.adk:google-adk-kotlin-firebase-android:1.0.0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>官方资源：</p>
<ul>
<li>📖 文档与入门：adk.dev</li>
<li>🐙 GitHub：github.com/google/adk-kotlin</li>
<li>📱 Android 指南：Building Android agents with ADK</li>
<li>☕ Java 互操作示例：ADK Kotlin Java sample</li>
</ul>
<h2 id="小结">小结</h2>
<p>ADK for Kotlin 1.0 的意义，我觉得不在于它又多了个 Agent 框架，而在于：</p>
<p><strong>Kotlin 生态终于有了一套编译期安全、协程原生、跨端可复用的 Agent 开发方案。</strong></p>
<p>它没有把 Python 的套路硬搬过来，而是按 Kotlin 的语言特性重新设计了一遍——KSP 代替反射、协程代替回调、KMP 核心代替单平台实现。</p>
<p>对做 KMP 的人来说，这是个值得认真看看的东西。</p>
<hr>
<p><em>参考资料：<a href="https://developers.googleblog.com/en/announcing-adk-for-kotlin-10-building-production-ready-ai-agents-in-kotlin-android-and-beyond/">Announcing ADK for Kotlin 1.0 - Google Developers Blog</a>（2026-09-09，作者 Guillaume Laforge）</em></p>
]]></content:encoded></item><item><title>KMP 上鸿蒙了：一套 Compose 代码，现在能跑四个平台</title><link>http://mings.in/posts/kmp-harmony/</link><pubDate>Wed, 16 Sep 2026 21:26:40 +0800</pubDate><guid>http://mings.in/posts/kmp-harmony/</guid><description>先说个数字：四。</description><content:encoded><![CDATA[<h2 id="一件悄悄发生的版图扩张">一件悄悄发生的&quot;版图扩张&quot;</h2>
<p>先说个数字：<strong>四</strong>。</p>
<p>Android、iOS、桌面 —— 这是 Kotlin Multiplatform 这几年稳定覆盖的主力阵地（Web / Wasm 也在推进中）。写一套共享逻辑，加一套 Compose UI，几个平台一起跑。</p>
<p>而昨天，这个名单要加上第四个成员了：<strong>鸿蒙</strong>。</p>
<p>而且这次不是&quot;理论上能跑&quot;，是<strong>正式版本发布</strong>——组件进了 Maven 仓，Android Studio 插件市场上能搜到工具链插件，文档在 AtomGit 上。</p>
<p>最让我意外的是发布方那句话：</p>
<blockquote>
<p>已经在其他平台使用 CMP 的开发团队，界面业务代码可直接复用同一套 Compose 写法，不必为鸿蒙完全重新写一套 ArkUI。</p>
</blockquote>
<p><strong>不用重写 ArkUI。</strong> 这五个字对做跨端的人来说，分量挺重的。</p>
<h2 id="先把事实说清楚">先把事实说清楚</h2>
<p>这次发布的是 <strong>KMP&amp;CMP 鸿蒙首版本</strong>，2026 年 9 月 15 日发布。</p>
<p>几个关键信息：</p>
<ul>
<li><strong>谁做的</strong>：CPF-KMP-CMP SIG 社区伙伴共建（注意，是社区 SIG，不是 JetBrains 官方，后面我会细说这个区别）</li>
<li><strong>基于什么</strong>：源社区 <strong>KMP 2.2.21 / CMP 1.9.2</strong></li>
<li><strong>目标</strong>：把&quot;逻辑跨端共享、UI 原生渲染&quot;这套开发范式带到鸿蒙上</li>
<li><strong>获取方式</strong>：组件已发布至 Maven 仓；工具链插件 <strong>KMP OHOS Support</strong> 在 Android Studio 插件市场搜索安装</li>
</ul>
<p>官方对适用人群的定位也很清楚：</p>
<blockquote>
<p>对已经使用 KMP/CMP 做跨端业务的团队，该版本有助于降低迁到鸿蒙的改造成本。鸿蒙开发团队也可借助这套能力，做 Kotlin 与 ArkTS 混合开发，拓展技术选型。</p>
</blockquote>
<p>翻译一下就是：<strong>KMP 团队想上鸿蒙，现在成本低了；鸿蒙团队想用 Kotlin，现在有路子了。</strong></p>
<p>顺便说个背景——这事其实铺垫了三个月。今年 6 月社区版就开了 Beta，8 月 InfoQ 还报道过一版实测数据（渲染内存降 95%、GC 卡顿率降 90%）。现在算是正式落地。</p>
<h2 id="亮点一compose-直接跑鸿蒙不用重写-ui">亮点一：Compose 直接跑鸿蒙，不用重写 UI</h2>
<p>这是我觉得最有价值的一条。</p>
<p>在鸿蒙上开发，官方路线是 <strong>ArkTS + ArkUI</strong>。对已经在做 KMP/CMP 的团队来说，这意味着鸿蒙端要<strong>单独维护一套 UI</strong>——业务逻辑能共享，但界面得重写一遍。</p>
<p>这次发布把这条路铺平了：</p>
<blockquote>
<p>CPF-KMP-CMP 在鸿蒙侧提供了 compose.ui、compose.foundation、compose.material、compose.material3 的平台实现，可延续熟悉的声明式 UI 习惯开发鸿蒙端界面。</p>
</blockquote>
<p><strong>compose.material3 都在。</strong> 也就是说，你写惯了的那套 Compose 代码，在鸿蒙上是能认的。</p>
<p>对一个已经在 Android/iOS 上跑 CMP 的项目来说，这意味着<strong>鸿蒙端的接入成本，从&quot;重写一套 UI&quot;降到&quot;加一个 target&quot;</strong>。</p>
<p>差别有多大？做过跨端的人应该懂。</p>
<h2 id="亮点二kotlin--arkts-双向互操作">亮点二：Kotlin ↔ ArkTS 双向互操作</h2>
<p>第二条对存量项目很关键。</p>
<p>现实情况是：很多鸿蒙项目<strong>已经用 ArkTS 写了一堆东西</strong>（系统能力、业务模块），不可能整体推翻重写成 Kotlin。</p>
<p>这次的解法是 <strong>akinterop</strong>：</p>
<blockquote>
<p>共享逻辑可继续写 Kotlin，系统侧或存量模块仍用 ArkTS 时，用 akinterop 做双向互操作：注解 / 装饰器声明意图，工具链生成桥接，少写 C 胶水。基础类型、容器与异常可跨侧传递，存量工程可渐进接入，不必为互通推倒重来。</p>
</blockquote>
<p><strong>&ldquo;渐进接入，不必推倒重来&rdquo;</strong> —— 这句话是给存量项目的定心丸。</p>
<p>注解声明意图、工具链生成桥接——比手写 C 胶水的体验好太多，写过 JNI 的人都懂那种痛。</p>
<h2 id="亮点三统一渲染这条是技术核心">亮点三：统一渲染（这条是技术核心）</h2>
<p>这条比较硬核，但我觉得是整件事里最有技术含量的一环。</p>
<p>先说问题：<strong>传统的跨平台 UI 方案，通常要自建一套渲染管线。</strong></p>
<p>自建有什么坏处？报道里说得很直接：</p>
<blockquote>
<p>传统跨平台 UI 方案常自建渲染管线，这容易带来额外初始化与合成开销，也容易出现启动慢、动画和系统表现不一致等问题。</p>
</blockquote>
<p>自渲染意味着你的 UI 不走系统那条路——启动要多一步初始化，动画节奏跟系统对不齐，看起来就&quot;不像原生&quot;。</p>
<p>这次的方案是<strong>反过来的</strong>：</p>
<blockquote>
<p>CPF-KMP-CMP 将 Compose 绘制能力对接鸿蒙 Render Service / RenderNode 系统渲染能力，由鸿蒙系统统一完成合成与刷新调度（统一渲染需 API 19 及以上）。</p>
</blockquote>
<p><strong>把绘制能力交给鸿蒙系统自己去合成</strong>，收益是连锁的：</p>
<ul>
<li><strong>更快首帧</strong>：少了自渲染的初始化等待，启动和跳转更利落</li>
<li><strong>精细化局部刷新</strong>：状态变了才更新，还支持局部录制——<strong>只重绘变化的区域</strong></li>
<li><strong>原生组件友好混排</strong>：Compose 页面能和 ArkUI、C-API 混排，地图、视频这类原生能力更容易嵌进来</li>
<li><strong>对齐系统动画</strong>：帧节奏走系统的 DisplaySync，动画体验跟鸿蒙原生应用一致</li>
</ul>
<p>最后一条尤其重要。跨平台应用&quot;一眼假&quot;的原因，往往不是布局不对，而是<strong>动画的节奏感不对</strong>——帧率调度跟系统不在一个节拍上。</p>
<blockquote>
<p>⚠️ 注意一个限制：<strong>统一渲染需要 API 19 及以上</strong>。老设备上会退化。</p>
</blockquote>
<h2 id="性能账省了-20-编译时间降了-30-堆内存">性能账：省了 20% 编译时间，降了 30% 堆内存</h2>
<p>这次给了几个挺具体的数字，不像很多&quot;性能优化&quot;的模糊说法：</p>
<p><strong>编译侧（并行编译）：</strong></p>
<blockquote>
<p>将 LLVM IR 切分后多线程跑局部优化再合并，缩短大规模工程的编译等待；典型场景下约可节省 20%+ 耗时。</p>
</blockquote>
<p><strong>内存侧（CommonGC）：</strong></p>
<blockquote>
<p>采用并发标记—复制回收算法，降低内存碎片、减少 GC 停顿时间，典型场景下 KN 堆占用约可下降 30%。</p>
</blockquote>
<p>另外还有两个工程化的优化很实用：</p>
<ul>
<li><strong>模块化拆分</strong>：单一 KMP 工程可配置成多个 <code>.so</code>，配合分布式编译进一步缩短构建</li>
<li><strong>Debug 增量编译</strong>：macOS / Linux 上支持基于静态库缓存的增量编译——<strong>只重编改动的部分，不必每次全量重链</strong></li>
</ul>
<p>（做 KMP 的人看到&quot;全量重链&quot;四个字应该会有点应激反应）</p>
<p>还有个细节我挺喜欢——<strong>后台不空转</strong>：</p>
<blockquote>
<p>统一渲染路径支持按需申请 VSync 信号，当页面没有内容变化时，主动抑制刷新绘制；同时支持 LTPO 硬件特性，应用退到后台之后自动停止无效绘制循环……该行为为框架默认行为，不需要业务额外编写代码。</p>
</blockquote>
<p><strong>默认行为，不需要业务写代码</strong>。这种&quot;框架替你操心&quot;的设计，比让你手动去 <code>onPause</code> 里关动画友好多了。</p>
<h2 id="上手三步跑起来">上手：三步跑起来</h2>
<p>流程比我想象的简单：</p>
<blockquote>
<p>(1) DevEco Studio：用 6.0.0+ 下载 HarmonyOS SDK API 17+，打开工程里的 harmonyApp 配好调试签名。
(2) Android Studio：日常写 KMP&amp;CMP 代码。
(3) Android Studio：选设备和 harmonyApp 点 Run（推包时保持 DevEco 运行）。</p>
</blockquote>
<p><strong>注意第 3 步那个括号</strong>——推包时要保持 DevEco Studio 开着。这类&quot;两个 IDE 配合&quot;的细节最容易踩坑，官方提前说了。</p>
<p>文档在 <strong>AtomGit</strong>（搜 <code>CPF-KMP-CMP/docs</code>），想深入看的可以去翻。</p>
<h2 id="为什么鸿蒙这块拼图特别重要">为什么鸿蒙这块拼图特别重要</h2>
<p>说句实在话：对国内的 KMP 开发者，鸿蒙这块拼图可能比 Web/Wasm 重要得多。</p>
<p>原因很现实——<strong>在国内做移动端，鸿蒙已经不太是&quot;要不要考虑&quot;的问题了，而是&quot;什么时候必须做&quot;的问题。</strong> 尤其在政企、金融、车机、IoT 这些领域，推进速度比很多人预期的快。</p>
<p>而过去 KMP 团队面对鸿蒙的选择挺尴尬的：</p>
<ul>
<li><strong>共享逻辑能复用，但 UI 得重写</strong>（ArkUI）→ 相当于鸿蒙端要额外养一个人</li>
<li>或者<strong>干脆放弃 KMP</strong>，鸿蒙端单独起个工程 → 共享逻辑的优势全丢了</li>
</ul>
<p>这次发布的意义就在这儿：<strong>它把&quot;鸿蒙端 UI 也要重写&quot;这个成本项，从等式里拿掉了一大半。</strong></p>
<p>对一个小团队来说，这可能就是从&quot;做不起&quot;到&quot;做得起&quot;的差别。</p>
<h2 id="说点冷静的这事该怎么看">说点冷静的：这事该怎么看</h2>
<p>热点归热点，我得把几个边界说清楚，免得你判断失误。</p>
<p><strong>第一，这是社区 SIG 项目，不是 JetBrains 官方。</strong></p>
<p>发布方是 <strong>CPF-KMP-CMP SIG</strong>，不是 JetBrains 也不是华为官方发布。这不代表它不好——恰恰相反，这种&quot;生态底座&quot;通常就是靠社区 SIG 一点点啃出来的——但<strong>你心里的预期要摆正</strong>：</p>
<ul>
<li>长期维护性取决于社区活跃度，不像官方 API 那样有明确承诺</li>
<li>遇到问题，答案在社区（Issue/Discussion），不在官方文档里</li>
</ul>
<p><strong>第二，API 19+ 才能用统一渲染。</strong></p>
<p>如果目标设备里有大量低版本鸿蒙，那这项最大的性能红利你吃不到，得评估一下自己的设备分布。</p>
<p><strong>第三，但它解决的是个真问题。</strong></p>
<p>跨平台上鸿蒙，过去的选项基本是两个：</p>
<ul>
<li>要么<strong>鸿蒙端单独写 ArkUI</strong>，维护两套 UI</li>
<li>要么<strong>放弃鸿蒙</strong>，等官方支持</li>
</ul>
<p>现在多了第三条：<strong>共享逻辑照写，Compose UI 复用，ArkTS 存量用 akinterop 桥接</strong>。</p>
<p>对一个已经投了 KMP 的团队来说，这个第三条路的性价比，是很难忽视的。</p>
<h2 id="放回这几天的脉络里看">放回这几天的脉络里看</h2>
<p>有意思的是，这事跟咱们前两天聊的两个话题，正好串成了一条线。</p>
<p><strong>Jake Wharton 那篇</strong>里，他说得很清楚：</p>
<blockquote>
<p>会 Kotlin 已经不是职业优势了……但掌握 Kotlin Multiplatform 这类相对进阶的能力，仍然可能构成差异化优势。</p>
</blockquote>
<p><strong>Shopify 那篇</strong>的结论是：AI 把&quot;重复实现&quot;变便宜之后，&ldquo;共享 UI 派&quot;受冲击最大，而&quot;共享逻辑 + 原生 UI&quot;这条路更抗打。</p>
<p>然后是今天：<strong>KMP 的版图，又扩了一块——而且是块很大的版图。</strong></p>
<p>三件事指向同一个方向：<strong>跨平台的价值，正在从&quot;省人力&quot;转向&quot;架构治理 + 生态覆盖&rdquo;。</strong></p>
<p>一个平台一个平台地啃下来，本来就是 KMP 这条路线的核心叙事。鸿蒙这块拼图补上，对国内开发者尤其是——<strong>它可能是很多人第一次真正有理由认真看 KMP。</strong></p>
<hr>
<p>最后留个问题给大家：</p>
<p><strong>如果鸿蒙能用 Compose 写 UI，你会为它单独维护一套 ArkUI，还是直接把 KMP 项目扩到鸿蒙？</strong></p>
<p>还有——你手上的项目，鸿蒙是&quot;必须做&quot;还是&quot;再看看&quot;？</p>
<p>评论区聊聊，我挺想知道大家的真实处境。</p>
<hr>
<p><em>参考资料：</em></p>
<ul>
<li><em><a href="https://news.mydrivers.com/1/1151/1151178.htm">KMP&amp;CMP 鸿蒙首版本正式发布（快科技/驱动之家，2026-09-15）</a></em></li>
<li><em>文档集合：AtomGit 搜索 <code>CPF-KMP-CMP/docs</code></em></li>
</ul>
]]></content:encoded></item></channel></rss>