<?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>Compose Multiplatform on Trevor's Blog</title><link>http://hitrevor.com/tags/compose-multiplatform/</link><description>Recent content in Compose Multiplatform on Trevor's Blog</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Thu, 12 Nov 2026 08:40:00 +0800</lastBuildDate><atom:link href="http://hitrevor.com/tags/compose-multiplatform/index.xml" rel="self" type="application/rss+xml"/><item><title>你写的圆角，CMP 桌面端画成了尖角</title><link>http://hitrevor.com/posts/cmp-stroke-join/</link><pubDate>Thu, 12 Nov 2026 08:40:00 +0800</pubDate><guid>http://hitrevor.com/posts/cmp-stroke-join/</guid><description>Stroke(join = Round) 在 CMP 桌面端、iOS、Web 上全被画成 Miter，Android 一直没事。根因是一个从不执行的 setter。两个版本各渲一套图逐像素对比，差 1,619 个像素。</description><content:encoded><![CDATA[<p>写 <code>Stroke(join = StrokeJoin.Round)</code>，画出来是尖的。这个 bug 在 Compose Multiplatform 的桌面端、iOS、Web 上都成立，Android 一直没事。</p>
<p>1.13.0-beta02 把它修了。发布日期 2026 年 11 月 4 日，一条 release note 就一句话：让 <code>Stroke(join = Round)</code> 和 <code>Stroke(miter = 0f)</code> 不再被忽略。</p>
<p>我在本机照着验了一遍，两个版本各渲一套图，逐像素对比。</p>
<h2 id="先说怎么复现">先说怎么复现</h2>
<p>不用起窗口。Compose 桌面端有个 <code>ImageComposeScene</code>，给它宽高和密度，把 Compose 树渲成 <code>Image</code>，再编码成 PNG。</p>
<p>一个 360×300 的画布，密度给 2，出来是 720×600 的图，够看清楚拐角。</p>
<p>我也就两个值在变：依赖版本，和 <code>Stroke</code> 的参数。</p>
<p><img alt="工程与出图方式" loading="lazy" src="/images/cmp-stroke-join/fig1.png"></p>
<h2 id="同一份代码两种形状">同一份代码，两种形状</h2>
<p>上面这一组是 <code>join = Round</code>，宽度 30，端点也给了圆头。</p>
<p><img alt="join 与 miter 在两版下的四种渲染" loading="lazy" src="/images/cmp-stroke-join/fig2.png"></p>
<p>左边三条拐角全是尖的，顶上还带着小刺。右边是圆的，尖刺没了。</p>
<p>下面一组是 <code>miter = 0f</code>，我特意画了个锐角的 V 形。左边保住了长长的尖角，右边被切平了。</p>
<p>按 Skia 的规矩，miter limit 给 0 就等于不要尖角，所有接头退化成斜切。1.12.2 里这个 0 是白写的。</p>
<h2 id="数字比图更硬">数字比图更硬</h2>
<p>我把同版本内的两张图互相做差，再把两版对应的图做差。</p>
<p><img alt="差异像素的位置和数量" loading="lazy" src="/images/cmp-stroke-join/fig3.png"></p>
<p>在 1.12.2 里，<code>join=Round</code> 和 <code>join=Miter</code> 两张图逐像素完全一样，PNG 字节数也一样，都是 8,905 字节，哈希一个字符不差。</p>
<p><code>miter=0f</code> 和 <code>miter=4f</code> 同样完全一样，都是 4,176 字节。</p>
<p>换成 1.13.0-beta02，同样是这两组对比，一个差 1,619 个像素，一个差 414 个。整张图 108,000 个像素，前者占 1.5%。</p>
<p>差异全在三个拐角的尖端上，就是上图品红色那几块。</p>
<h2 id="根因是一个不执行的-setter">根因是一个不执行的 setter</h2>
<p>Compose 的 <code>Paint</code> 是个包装，里面装着 Skia 的 <code>Paint</code>。属性写成这样：</p>
<p><img alt="修之前与修之后的默认值" loading="lazy" src="/images/cmp-stroke-join/fig4.png"></p>
<p><code>strokeJoin</code> 有自定义 setter，赋值的时候会把 Skia 那份一起改掉。但属性初始化不走 setter，只填了 Kotlin 的字段。</p>
<p>于是包装层说默认是 Round，Skia 那边实际是 Miter，两边分叉了。</p>
<p>真正致命的是后面这一步。绘制的时候，赋值之前有个判断：值跟当前字段一样就跳过。你写 <code>join = Round</code>，字段里本来就是 Round，判断为真，setter 根本没被调用，Skia 从头到尾不知道你改过。</p>
<p><code>miter = 0f</code> 同理，字段默认就是 0f。</p>
<p>顺带说一句 <code>filterQuality</code>，它连 setter 都没有，直接是个普通字段，值改了也不会通知 Skia。这类字段在这套包装里不止一个，只是不影响形状的那些，没人会注意到。</p>
<p>这个 issue 是 Tim Haywood 三个月前在 YouTrack 上开的，CMP-10688，标题直接写「incorrectly renders StrokeJoin.Round as Miter」。他把根因、Skia 源码位置、潜在修法都写清楚了，甚至连「把 Kotlin 字段删掉、直接代理到 Skia」这个方向都提了。</p>
<p>修的人是 Ivan Matkov，PR 是 compose-multiplatform-core 的 #3528，10 月 26 日合并进 release/1.13 分支，前后审了两轮。从 issue 到合并，中间三个月。</p>
<h2 id="修法反而是把默认值改对">修法反而是把默认值改对</h2>
<p>PR 的改动小得让人意外：把三个默认值改掉，让它和 Android 对齐。</p>
<p><code>strokeJoin</code> 从 Round 改成 Miter，<code>strokeMiterLimit</code> 从 0f 改成 4f，<code>filterQuality</code> 从 Medium 改成 Low。</p>
<p>改完，包装层的字段和 Skia 的实际状态一致了。你写 Round 跟字段里的 Miter 不同，判断为假，setter 正常执行，参数就传下去了。</p>
<p>所以这不算修绘制代码，是把谎报的账目对齐。</p>
<h2 id="对照组的-0-说明没改坏别的">对照组的 0 说明没改坏别的</h2>
<p>我最担心的是这种底层改动顺手把别的地方带歪。所以留了对照组：<code>join = Miter</code> 的图，两版互比。</p>
<p>0 个像素差。一个字节都不差。</p>
<p>1.13.0-beta02 里那一列圆角和斜切的改动，只落在受影响的路径上。</p>
<h2 id="为什么这么久了才被看见">为什么这么久了才被看见</h2>
<p>我猜三个原因叠在一起。</p>
<p>一是默认的 <code>join</code> 就是 Miter。多数图表、图标、进度条根本不传这个参数，形状对了，就没人往下查。</p>
<p>二是显式传 <code>Round</code> 的地方，画出来只是「不够圆」，不像崩溃那样刺眼。设计师提一句「这个角好像有点尖」，工程师十有八九会怀疑是自己路径画错了。</p>
<p>三是它只在非 Android 上发生。同一份代码在真机上是对的，在桌面预览里是尖的，人很容易归因到预览工具的渲染误差，而不是认定框架有 bug。</p>
<h2 id="只有非-android-受影响">只有非 Android 受影响</h2>
<p>diff 落在 <code>compose/ui/ui-skiko/src/nonAndroidMain/</code>。Android 走的是 <code>android.graphics.Paint</code>，那边的默认值本来就是 Miter、4f、Low。</p>
<p>所以这个 bug 有一个很讨厌的性质：你在 Android 真机上看不出问题，一跑桌面端或者 iOS 就变形了。写跨平台 UI 的人在自家机器上看到的，和别人看到的不一定是同一个形状。</p>
<h2 id="第三个默认值我量不出差异">第三个默认值我量不出差异</h2>
<p><code>filterQuality</code> 从 Medium 改成 Low，理论上影响图片缩放的采样质量。我拿一张 256 像素的棋盘图，分别缩到 40 和 80，又拿 8 像素放大到 320。</p>
<p>三次对比，全是 0 个像素差。</p>
<p>原因是 <code>drawImage</code> 这个 API 自己带 <code>filterQuality</code> 参数，默认值不经过 <code>Paint</code>。只有你自己造 <code>Paint</code> 去画的时候，这个改动才有意义。所以它是隐患，不是现象。</p>
<p>查了一圈没找到能观测的路径，我就照实写了，不给它编一个场景。</p>
<p><img alt="三个默认值改了什么" loading="lazy" src="/images/cmp-stroke-join/fig5.png"></p>
<h2 id="你现在该做什么">你现在该做什么</h2>
<p>项目还在 1.12 或者更早，并且用了 <code>join = Round</code>、<code>miter = 0f</code> 这类看起来没生效的参数，那就是中招了。</p>
<p>两条路。要么升到 1.13.0-beta02，接受它还在预发布。要么先把形状改成不依赖这两个参数的画法，比如自己描一条圆角折线。</p>
<p>想确认自己有没有中招，不用读代码。写一个 <code>ImageComposeScene</code>，同一个形状渲两遍，一遍 <code>join = Round</code> 一遍 <code>join = Miter</code>，两张 PNG 做差。差 0 个像素就是中招，差几千个才是正常的。这个方法对我这套图很灵，对任何「参数看起来没生效」的绘制问题也应该灵。</p>
<p>升之前有件事得知道：1.13 里 <code>Paint()</code> 加了个前置条件，直接 new 会抛 <code>No Compose UI graphics implementation is registered</code>。要么先建一个 scene，要么走正常的绘制流程。我第一次跑就撞在这，改成先渲一张图热个身才好。</p>
<p>至于这个 bug 在线上躺了多久，我不知道，issue 是三个月前开的，代码可能更早。这种「参数写对了但没生效」的坑，只能靠像素级的对比才看得见。</p>
]]></content:encoded></item><item><title>Jake 的办法：服务端跑 Compose，只推 HTML</title><link>http://hitrevor.com/posts/compose-kotlinx-html/</link><pubDate>Thu, 12 Nov 2026 08:10:00 +0800</pubDate><guid>http://hitrevor.com/posts/compose-kotlinx-html/</guid><description>Jake Wharton 用 Molecule 在服务端跑 Compose、用 kotlinx.html 出 HTML、用 SSE 推送、用 Idiomorph 合并。我照着搭了一遍，发现输入框里的字保不保得住，全在三个默认值上。</description><content:encoded><![CDATA[<p>Compose for HTML 这件事，官方还在做同构渲染的路上。</p>
<p>Jake Wharton 不想等。2026 年 8 月他写了一篇《Compose &amp; kotlinx.html》，说自己用 Molecule 在家里攒了一套：服务端跑 Compose，产出 HTML 字符串，用 SSE 推给浏览器，客户端拿 Idiomorph 合并进 DOM。</p>
<p>他那句话我记了很久。说客户端框架能解决内容是 stale，但「房子漏水，你也可以通过换房子来解决」。这话刻薄，但说到了点子上：换一整套渲染栈，代价往往比修那根管子大得多。</p>
<p>我照着搭了一遍，想看看这套土办法能走多远。顺便验了一件事：同一次推送，客户端换一行处理代码，输入框里的字活不活得下来。</p>
<p>结论先放这儿：保住率从 0% 到 100%，差别全在三个默认值上。</p>
<h2 id="他的链路只有四段">他的链路只有四段</h2>
<p>数据源是 StateFlow，Molecule 把 <code>@Composable</code> 函数当状态机跑，kotlinx.html 把结果渲染成字符串，SSE 推给浏览器，Idiomorph 在客户端合并。</p>
<p>关键在于，服务端不区分「首次渲染」和「后续推送」。同一个 <code>userList(users)</code> 函数，首页和增量更新都调它。</p>
<p><img alt="链路与版本栈" loading="lazy" src="/images/compose-kotlinx-html/fig1.png"></p>
<p>我搭的时候用的是 Kotlin 2.4.21、Ktor 3.6.1、kotlinx-html 0.12.0、Molecule 2.2.0，Compose runtime 1.12.2，跑在 JVM 21 上。</p>
<h2 id="一个函数两处调用">一个函数，两处调用</h2>
<p>这是整套东西里最舒服的地方。</p>
<p>省掉的不只是模板引擎。</p>
<p>首屏：<code>appendHTML().div { userList(users) }</code>。</p>
<p>推送：<code>moleculeFlow(Immediate) { val users by Store.users.collectAsState(); fragment(users) }</code>。</p>
<p>两边调的是同一个函数。没有模板，没有 JSON 到视图的映射层，也没有「前端要再实现一遍」的问题。</p>
<p>一处改，两处生效。</p>
<h2 id="输入框里的字归谁">输入框里的字归谁</h2>
<p>这是我最想验的一点。</p>
<p>页面上有一行输入框，它不在 StateFlow 里，纯粹是浏览器自己的状态。我往里敲一句话，然后触发一次推送，看它还活着没有。</p>
<p>第一次用 <code>content.innerHTML = html</code>，字没了。原因简单：整个内容区被重建，输入框是新的，值是空的，连光标位置和未提交的输入法候选都没了。这是覆盖式更新最典型的代价，也是所有前端框架最早要解决的那个问题。</p>
<p>换成 Idiomorph，我一开始用的是 <code>morphStyle: 'innerHTML'</code>。页面一动不动。不报错，就是不动。</p>
<p>改成 <code>outerHTML</code>，还是没保住。查了半天才发现有个 <code>ignoreActiveValue</code> 选项，默认关着。</p>
<p><img alt="同样一次推送，两种落地结果" loading="lazy" src="/images/compose-kotlinx-html/fig2.png"></p>
<p>加上之后，保住了。但只在一种情况下保住。</p>
<h2 id="保护的是焦点不是所有字段">保护的是焦点，不是所有字段</h2>
<p>我把每种组合各跑三次，结果很干脆。</p>
<p>innerHTML，无论焦点在哪，三次都清空。morph 不加 <code>ignoreActiveValue</code>，三次都清空。加了选项但焦点已经移到按钮上，还是三次都清空。</p>
<p>只有一种情况保住：焦点停在那个输入框里。</p>
<p><img alt="四种组合各跑三次" loading="lazy" src="/images/compose-kotlinx-html/fig3.png"></p>
<p>道理不难想：Idiomorph 保护的是当前活跃元素，别的输入框会照服务端 HTML 覆盖，而服务端那个 <code>&lt;input&gt;</code> 没有 <code>value</code> 属性。</p>
<p>换句话说，这套方案默认「表单状态归服务端管」，只在用户正在敲字的那一格上让一步。</p>
<p>Jake 原文说的是「没有变化的表单字段会保留」。这句话在「用户正在打字」这个场景里成立，超出了就不成立。</p>
<h2 id="连接数就是渲染次数">连接数就是渲染次数</h2>
<p>我在服务端加了个计数器，数 composition 跑了几次。</p>
<p>空载时是 0，没人订阅就不渲染。开十条常驻 SSE 连接，变成 10。状态改一次，变成 20。这三行数字是我这次实验里最想让人看见的部分。</p>
<p>也就是说，每次数据变化，服务端要为每条连接各跑一遍组合函数。十条连接十次，一百条连接一百次。</p>
<p><img alt="10 条连接下的渲染次数" loading="lazy" src="/images/compose-kotlinx-html/fig4.png"></p>
<p>单条连接收到的载荷是 1,903 字节，大约占整页的 42%，每多一行用户再加 97 字节。整块推，不做 diff，diff 全交给浏览器。</p>
<p>内存上反而测不出差别。JVM 基线就占 147 MB，加到五十条连接也只涨到 149 MB，把这部分开销盖过去了。真要压测，得从每条连接的线程栈和 GC 压力看。</p>
<h2 id="延迟这边没什么戏">延迟这边没什么戏</h2>
<p>本机端到端，从点按钮到 DOM 更新完，inner 模式 30 到 107 毫秒，morph 模式 18 到 138 毫秒。</p>
<p>两组数字重叠，没有谁明显更快。</p>
<p>这也合理：两种方式都要走一遍 SSE，都要重画一次 DOM，差别只在合并策略。</p>
<p>所以别指望用这个换性能。它换的是别的东西。</p>
<h2 id="他说的生产化其实就是别这么干">他说的生产化，其实就是别这么干</h2>
<p>Jake 在文末写了三条，我照着理解一下。</p>
<p>一条长连接，别每块内容各开一条。推的东西要有个信封，说明这次更新的是页面哪一块。多块内容共用一条连接，服务端才能只渲染一次。</p>
<p>用 SSE，别用 WebSocket。SSE 能走 HTTP/2 的多路复用，事件名和载荷天然分开，正好当那个信封。而且连接断了浏览器会自己重连，不用你写退避重试。</p>
<p>第三条最含糊也最关键：组合函数跑在连接里，还是跑在连接外。前者是每个用户一份，后者是所有人共享一份。前者简单，后者省。</p>
<p>我这次的实验是前者，所以十条连接就是十次渲染。要按他说的改，得先想清楚状态是谁的。</p>
<h2 id="四个坑三个是静默的">四个坑，三个是静默的</h2>
<p>第一个，Molecule 2.x 的 API 变了。<code>launchMolecule</code> 现在是 <code>CoroutineScope</code> 的扩展，返回 <code>StateFlow</code>，要拿 <code>Flow</code> 得改用 <code>moleculeFlow(mode) { }</code>。照老文档写编译不过，这个还算友好。</p>
<p>第二个，Ktor 的 SSE 要显式 <code>install(SSE)</code>。只写 <code>sse(&quot;/path&quot;) {}</code> 能编译，启动时抛 <code>Application plugin SSE is not installed</code>，那行报错埋在几十行启动日志中间。</p>
<p>第三个，Idiomorph 的 <code>morphStyle</code> 写错不报错。片段自带外层 div 时必须是 <code>outerHTML</code>，写成 <code>innerHTML</code> 页面静止，你以为推送没到，其实到了。</p>
<p>第四个，<code>ignoreActiveValue</code> 默认关。不开它，morph 和 innerHTML 在你这个场景里没有区别。</p>
<p><img alt="四个坑" loading="lazy" src="/images/compose-kotlinx-html/fig5.png"></p>
<p>三个静默失败，够我一下午。</p>
<h2 id="我的判断">我的判断</h2>
<p>这套方案成立。</p>
<p>但成立的条件很窄。</p>
<p>低流量的后台页面，几十个用户，不需要前端框架，用 Kotlin 一处写完。它比引一套 React 省事得多，而且服务端返回的就是可读的 HTML，断网也能看见内容。对内部工具来说，这套组合的收益是实打实的。</p>
<p>但一到「多人在线、状态高频变化」的场景，N 条连接 N 次渲染这件事就变成硬伤。Jake 自己在文末也提醒了，要 multiplex、要换 SSE、要想清楚组合跑在连接内还是连接外。</p>
<p>他还有一句更重要的话：这只是「生产化的起点」，不是终点。</p>
<p>我倒是从这四条坑里学到一个更通用的判断：凡是「不报错但没效果」的默认值，都值得先查一遍文档再动手。这套方案里有三个，别的地方也有。Molecule 的入口签名变了没报错，Ktor 缺插件在启动时才炸，Idiomorph 的 morphStyle 写错只是页面不动，一个比一个安静。</p>
<p>你要是在写内部工具，值得花半小时照抄一遍。要是在写对外的产品页，还是让前端框架去管吧。</p>
]]></content:encoded></item><item><title>Compose 桌面跑通了 Liquid Glass</title><link>http://hitrevor.com/posts/liquid-glass/</link><pubDate>Fri, 06 Nov 2026 08:05:00 +0800</pubDate><guid>http://hitrevor.com/posts/liquid-glass/</guid><description>苹果的 Liquid Glass，安卓一直没有官方答案。我把 Kyant0/Backdrop 装进一个 Compose Multiplatform 桌面工程，在 macOS 上真的跑通了：跨端适配只写了 280 行，因为 AGSL 是 SkSL 的子集。</description><content:encoded><![CDATA[<p>去年苹果在 iOS 26 上放出 Liquid Glass 的时候，安卓这边最热闹的讨论只有一句：我们什么时候能有。</p>
<p>官方的答案是没有。Jetpack Compose 到今天也没有内置这种材质，你想在界面上放一块会折射、会随背景变色的玻璃，要么自己写着色器，要么找一个库。</p>
<p>AndroidLiquidGlass 就是这样一个库，作者 Kyant0，4,509 星。名字里带 Android，但它在描述里的定位是 Compose Multiplatform，主分支直接叫 kmp。</p>
<p>我一直好奇一件事：这种重度依赖平台图形能力的视觉效果，跨端到底能还原几成。</p>
<p>安卓上画玻璃靠的是 AGSL 着色器，桌面靠 Skia。两条完全不同的图形栈，一个库想同时喂饱它们，通常的结局是凑合能用，效果打折。</p>
<p>所以我做了件笨事：在本机建了个最小的 Compose Multiplatform 桌面工程，把库加进去，在 macOS 上让玻璃真的画出来，然后一个效果一个效果拆开看。</p>
<p>结论放前面：能跑，效果也不是凑合。整库 2,550 行，跨端适配只写了 280 行，原因在源码里，后面会说到。</p>
<h2 id="先认清它是谁">先认清它是谁</h2>
<p>仓库叫 <code>Kyant0/AndroidLiquidGlass</code>，4,509 星、364 个 fork，Apache-2.0。名字里带 Android，但描述写的是 Compose Multiplatform Liquid Glass effect，主分支叫 kmp。</p>
<p>库现在的正式名字是 Backdrop，Maven 坐标 <code>io.github.kyant0:backdrop</code>，最新版 2.0.1，八月二十六号发的。</p>
<p>README 里有句话值得抄下来：这个库不包含任何高层组件，组件要你自己搭。它给的是效果原语，不是一行调用就出来的现成按钮。</p>
<p>体量也小：桌面端 jar 136 KB，连它依赖的 shapes 库 46 KB 一起，总共不到两百 KB。</p>
<p><img loading="lazy" src="/images/liquid-glass/fig1.png"></p>
<h2 id="最小工程就三步">最小工程就三步</h2>
<p>新建一个 Gradle 工程，装 Kotlin 2.4.10、Compose Multiplatform 1.12.0 两个插件，然后加依赖：</p>
<p><code>implementation(&quot;io.github.kyant0:backdrop:2.0.1&quot;)</code></p>
<p>代码就一个模式，记住「谁被采样」和「谁去采样」必须配对：</p>
<p>后台的内容用 <code>Modifier.layerBackdrop(backdrop)</code> 注册成采样源，前面的面板用 <code>Modifier.drawBackdrop(backdrop, shape, effects)</code> 去取。</p>
<p>我用一个画了四个色块加一层细网格的 Canvas 当背景，前面放一个圆角二十六的面板，效果里依次调 vibrancy、blur、lens。窗口一开，玻璃就出来了。</p>
<p><img loading="lazy" src="/images/liquid-glass/fig2.png"></p>
<h2 id="它长什么样">它长什么样</h2>
<p>我把四种效果拆开各跑一格，同一张背景，同一个面板，只改效果栈。</p>
<p>第一格只给形状，什么都不加，你会发现面板里透出的网格是清晰的。这一点很关键：这说明它不是预先烘焙一张模糊图，而是每帧真的去采样后面的内容。</p>
<p>第二格加 8dp 模糊，网格糊成一片。第三格叠 vibrancy，颜色明显更艳。第四格再加 lens，面板边缘开始出现那圈像水滴一样的折射。</p>
<p><img loading="lazy" src="/images/liquid-glass/fig3.png"></p>
<p>vibrancy 到底是什么，官方文档写得很实在：就是把饱和度乘 1.5，等价于 <code>colorControls(saturation = 1.5f)</code>。</p>
<h2 id="它其实给了五种-backdrop">它其实给了五种 backdrop</h2>
<p>文档里 Backdrops 那一页列了五种，用途分得很细。</p>
<p><code>rememberLayerBackdrop</code> 最常用，把某个 Composable 的内容录成采样源，必须配 <code>layerBackdrop</code>。</p>
<p><code>rememberCombinedBackdrop</code> 把多个 backdrop 合成一个，用来做标签栏和滑块这种跨区域采样。<code>rememberCanvasBackdrop</code> 不依赖布局坐标，可以往一块空 backdrop 上画自己的东西。<code>emptyBackdrop</code> 就是什么都不画。</p>
<p>还有一个 <code>rememberBackdrop</code>，让你用自定义命令直接画。</p>
<p>分这么细的原因是不这么分就做不出高级效果。比如底部标签栏的玻璃要能采到「整页内容」，而不是只采自己后面那一小块，那就得先合成。</p>
<p>这部分是我觉得它比同类库扎实的地方：不是给一个 API 让你套，是把采样这件事的各种边界都拆出来给你。</p>
<h2 id="一个必须自己补的坑">一个必须自己补的坑</h2>
<p>我第一版在浅色背景上跑，面板几乎是白的，字全看不清。</p>
<p>原因不怪库：玻璃本来就应该透，浅色背景上它自然也是浅色，白字自然就没了。iOS 上系统会替你压一层材质，这库不管这层。</p>
<p>解法是自己加。<code>drawBackdrop</code> 有个 <code>onDrawSurface</code> 参数，我在里面画了一层 30% 的黑：</p>
<p><code>onDrawSurface = { { drawRect(Color(0x30000000)) } }</code></p>
<p>加完两边都正常了，浅色深色各一张对比图。</p>
<p><img loading="lazy" src="/images/liquid-glass/fig4.png"></p>
<h2 id="为什么桌面能跑">为什么桌面能跑</h2>
<p>这是我最想弄清的一点。库名叫 Android，实现里也真的用了 <code>android.graphics.RuntimeShader</code>，那桌面凭什么能画？</p>
<p>翻源码就明白了。整个库分三块：commonMain 2,270 行写所有逻辑，androidMain 146 行、skikoMain 134 行负责两端落地。</p>
<p>着色器是 AGSL 字符串。安卓侧把它塞进系统的 RuntimeShader；Skia 侧直接喂给 <code>RuntimeEffect.makeForShader</code>，因为 AGSL 本来就是 SkSL 的子集。</p>
<p>也就是说 Android 上那套玻璃着色器，原样搬到 Skia 上就能编译。</p>
<p>桌面、iOS、wasm 走的都是这条 skiko 路径。</p>
<p>两端的支持判断也写在源码里：安卓要 <code>SDK_INT &gt;= 31</code> 才有 RenderEffect，要 <code>&gt;= 33</code> 才有 RuntimeShader；Skia 那边两个函数直接返回 true，不设门槛。</p>
<h2 id="还有两个不那么顺的地方">还有两个不那么顺的地方</h2>
<p>第一个在文档里，但容易被忽略：lens 效果要求 <code>shape</code> 必须是 <code>CornerBasedShape</code>。我用 <code>RoundedCornerShape</code> 所以没事，换成自定义 Shape 就得自己处理。</p>
<p>第二个在源码里，文档没提：Skia 实现不支持整型数组 uniform。<code>setIntUniform(name, values: IntArray)</code> 在桌面端会直接抛 <code>UnsupportedOperationException</code>，安卓侧是好的。这类差异平时碰不到，一旦你写自定义着色器就会撞上。</p>
<p>顺带说一句文档的标记方式。官方 API 页写着「效果本质上是 RenderEffect，Android 12 起生效，涉及 RuntimeShader 的要 Android 13」。这句在桌面语境下不成立，我在 macOS 上用 lens 一点问题没有。它那句是站在安卓角度说的，别当成平台限制。</p>
<h2 id="性能和我踩的边界">性能和我踩的边界</h2>
<p>我这台 M 系列 Mac 上，两块玻璃一起动，帧率稳定在 60，正好顶到刷新率。加到九块玻璃同屏、每块都带动态 lens，掉到 58.7 帧。</p>
<p>也就是说这个效果的开销，在这个量级上还没成为瓶颈。</p>
<p>编译方面，第一次带依赖解析跑了 25 秒，之后靠 Gradle 缓存基本一秒内。</p>
<p>这里也把截图方式说清楚：macOS 没给屏幕录制权限，系统级截屏只能拍到壁纸。所以我改用 SkiaLayer 自带的 <code>screenshot()</code>，直接拿窗口画面写 PNG，像素是准的。</p>
<p><img loading="lazy" src="/images/liquid-glass/fig5.png"></p>
<h2 id="社区情况">社区情况</h2>
<p>4,509 星不是白来的，这个效果确实抓人。但节奏摆在那：提交停在八月二十六号，文档站的更新时间写着「5 个月前」。</p>
<p>40 个 open issue，官方 FAQ 里第一条是「为什么效果不生效」，答案是大概率忘了加 <code>layerBackdrop</code>。第二条更直接，是一个安卓端的 SIGSEGV 崩溃，在 RenderThread 里炸，要照着教程去改。</p>
<p>这库是好东西，但它是个人项目，不是官方组件。指望它像 Compose Foundation 那样稳定，会失望。</p>
<h2 id="我的判断">我的判断</h2>
<p>想在 Compose 桌面上做点 macOS 味的玻璃面板，这个库现在就能用，成本两百 KB 加二十行代码。</p>
<p>但别把它当成品组件用。它给的是效果，交互、可访问性、深色浅色的适配全是你自己的活。苹果那套东西好看，一半功劳在系统替你处理了这些边角。</p>
<p>我打算把它用在自己的小工具上试试。真正想验证的是那件事：一个 Android 原生库，靠 AGSL 和 SkSL 的这层血缘，在桌面上跑得跟原生一样。</p>
<p>你的跨平台项目里，会为了一个视觉效果去引一个个人维护的库吗？</p>
]]></content:encoded></item><item><title>Compose 1.13 发布了，但真正值得写的是 1.12</title><link>http://hitrevor.com/posts/compose-113/</link><pubDate>Sat, 24 Oct 2026 16:26:16 +0800</pubDate><guid>http://hitrevor.com/posts/compose-113/</guid><description>1.13 这版只有一个新功能，其余全是修问题。真正重的东西藏在 1.12 里，热重载接上了 MCP。</description><content:encoded><![CDATA[<p>10 月 23 日，Compose Multiplatform 1.13.0-beta01 发了。</p>
<p>标题里这句话不是标题党。这一版改的东西不少，但没一个是新方向，真正能把跨端这件事往前推一步的东西，两个月前就已经悄悄上过了。</p>
<p>先说结论，1.13 只有一处算新功能，剩下全是修问题。一句话带过就够了。</p>
<h2 id="113-就这点东西">1.13 就这点东西</h2>
<p>桌面端 ComposePanel 在 SwingGraphics 渲染模式下，现在能跟着显示器刷新率走帧。默认关，要开就设这个属性。</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">System</span><span class="p">.</span><span class="n">setProperty</span><span class="p">(</span><span class="s2">&#34;skiko.swing.frame.pacing&#34;</span><span class="p">,</span> <span class="s2">&#34;true&#34;</span><span class="p">)</span>
</span></span></code></pre></div><p>iOS 加了 UIKitInteropSafeAreaInsetsPolicy，能控制嵌进来的 UIKit 组件感知多少安全区。</p>
<p>多端通用只有一条，AnnotatedString 能渲染 Bullet 注解了。</p>
<p>其余全是修 bug。iOS 四个，Web 集中在 Safari 的选中和焦点，桌面端只修了一个 Windows 的缩放卡顿。</p>
<p>好，1.13 的部分到此结束。</p>
<h2 id="真正该看的是两个月前那行字">真正该看的是两个月前那行字</h2>
<p>8 月 25 日，1.12.0 发布。Highlight 只有一行。</p>
<p>Compose Hot Reload 加了个 MCP server，AI agent 可以连到正在运行的 Compose 应用上实时操作。</p>
<p>这行字当时几乎没引起讨论。但把它的工具清单摊开看，就知道这不是个小更新。</p>
<h2 id="一个-agent-拿到手的到底是什么">一个 agent 拿到手的到底是什么</h2>
<p>工程里跑一个 Gradle 任务 hotMcpServer，服务起来，连上应用，等启动，检测关闭，断了自己重连。</p>
<p>接进来的 agent，能调这些工具。</p>
<p>状态和重载这一组，status 看应用和重载状态，连带上一次报错的内容一起给。reload 和 await_reload 触发热重载并等它完成。restart 和 reset_ui 重启应用或者重置界面。</p>
<p>观察这一组，take_screenshot 截图，能指定保存路径。list_windows 和 resize_window 看窗口、改窗口大小。get_semantic_tree 读语义树，弹窗、底部弹层、popup 都算在里面。get_ui_error 和 get_logs 读当前错误和应用日志。</p>
<p>操作这一组，click、type_text、scroll，直接模拟用户输入。</p>
<p><img alt="Compose Hot Reload 暴露给 agent 的工具清单" loading="lazy" src="/images/compose-113/cmp113-tools.png"></p>
<p>这三组拼起来，第一次让 agent 在 Compose 里有了完整的闭环。</p>
<h2 id="闭环是怎么闭上的">闭环是怎么闭上的</h2>
<p>以前 agent 写 Compose 代码，写到界面上就断了。它能改文件，能编译，但看不到界面长什么样，点不了按钮，也不知道这次改动到底对不对。</p>
<p>人得接手。这就是那一大段等待。</p>
<p>现在这个循环是这样跑的。agent 改代码，调 reload 触发热重载，take_screenshot 看一眼，get_semantic_tree 读一遍结构确认布局没塌，get_logs 确认没抛异常。不对就再改一轮。</p>
<p>整个过程人不用进去。</p>
<p>一个 agent 自己把这一圈走完，这就是闭环。</p>
<p>1.3.0-beta02 又补上了缺的那块。可滚动的节点现在带 scrollX 或 scrollY，给出当前偏移、最大偏移，还有内容反向滚动时的 reversed 标记。</p>
<p>这条看着像管道工活。但对 agent 很关键。以前它只知道这里有个可滚动容器，现在知道滚到哪了、还能不能继续往下滚。不然它截图截到的永远是第一屏。</p>
<h2 id="这跟-flutter-是两条路">这跟 Flutter 是两条路</h2>
<p>上个月 Flutter 团队那篇官方文章，核心判断是代码生成早就不是瓶颈，验证才是。</p>
<p>他们的解法是框架天生就轻。热重载 200 毫秒，widget test 500 毫秒，状态检查直接读 Widget 树，全是确定的、结构化的数据。</p>
<p>JetBrains 没走这条路。它不去优化框架本身的速度，而是把运行中的应用整个打开给 agent，让 agent 自己动手验。</p>
<p>一个靠轻，一个靠开门。</p>
<p>我倾向后者更通用。它跟框架无关，只要求运行时能被程序访问。但这也意味着天花板很明确，取决于那个 MCP server 能暴露多少东西。现在十几个工具够用，往后要处理的界面更复杂，肯定不够。</p>
<h2 id="所以现在能做什么">所以现在能做什么</h2>
<p>如果你手上有 Compose 桌面工程，今晚就能试。</p>
<p>前提是 Compose Hot Reload 1.2.0 以上。工程里跑 gradlew hotMcpServer，把这个端点接到你的 agent 客户端里。让它改一个 Composable，重载，读语义树确认按钮还在原位。</p>
<p>上面这些工具清单和闭环路径，是 1.12 和 Hot Reload 1.3.0-beta02 的 release notes 加上仓库源码读出来的，不是我在自己机器上跑过一遍。</p>
<p>这点说清楚，免得你以为是实测。</p>
<p>按我的经验，这条路第一次接最容易卡在两个地方。MCP server 只在 dev 模式下起，你的 client 得连对端口。还有一个坑，get_semantic_tree 在弹层上的表现，官方文档写得很简，实际用之前先单独试一次。</p>
<p>第一次看到它自己把这一圈走完，感受跟看它生成代码完全不同。</p>
<p>问题给到你们。你手上的工程，试过让 agent 直接接管热重载和验证吗？还是仍然自己改完自己点？</p>
<p>再问一句，你觉得这种把运行时开放给 agent 的路子，Web 端和移动端会不会比桌面走得慢？桌面有 JVM 能直接接，移动那边得另想办法。</p>
]]></content:encoded></item></channel></rss>