Codename One 优化性能并新增原生拖放与跨设备连续性
[LOADING...]
有个老笑话讲三种谎言:谎言、该死的谎言和基准测试。Codename One 在我们的基准测试中表现非常好。但它们准确吗?有代表性吗?
什么是 Codename One? Codename One 是一个开源框架,可以用单一 Java 或 Kotlin 代码库构建原生 iOS、Android、桌面和 Web 应用。更多信息请访问 codenameone.com。
性能就像编程里的床笠。你刚把那个该死的角套好,床的其余部分却一片狼藉。
本周我们集中精力把更多角套到位,也完成了很多工作。下周我们还会讨论前方的工作,但现在已经到了我可以自信地说 Codename One 相当快的地步。不是最好(还不到),但很快。
好的基准测试分数有一个麻烦:它对下一次操作几乎说明不了什么。我们的 map 在查找已存在的键时很快。查询缺失的键时,却暴露出一次查找可能要走过数十万个槽位。某个对较大进程来说看似合理的收集器设置,却让小进程保留了垃圾,却换不来可测量的吞吐量。一个渲染基准测试没有理由注意到,某个开关在还没有人要求绘制之前就生成了模糊的图形。
这些成本会在一个应用中相遇。打开屏幕会构造样式并布局组件。加载它的数据会填充 map,并把数字装箱。显示图像会创建值得缓存的结果,直到内存变得紧张。我们一起打通了这些层次,因为让一个循环更快,并不能告诉我们屏幕是否启动得更早,或者留下的内存是否更少。
上周,构建器让我们能够接管原本属于每个应用平台项目的原生集成责任。本周我们在运行时内部运用同样的触达能力:改变集合、对象表示、UI 内部实现和生成的 JavaScript,同时让应用代码保持 Java。
两个重要新增功能让用户能够把工作推进得更远。原生拖放 让他们可以在你的应用和其他应用之间移动文件和内容。跨设备连续性 让他们可以在另一台设备上继续任务,并且在操作系统关闭进程后恢复它。两者现在在 Codename One 中都有 Java API。
即将到来:更少等待、更多空间,以及可随行的工作
明天的 Go 教会了我们什么关于 Java 垃圾回收 会问:收集器为什么要保留买不到速度的内存,以及如何阻止一次缓存刷新让你重新解码每一张图像。周日的 更快的 Map:追逐瑞士速度 会延续我们与 Go map 设计的比较,走向更快的查找,以及不再需要各自堆对象的数字。
周一带来 更快的启动,更少的 JavaScript 开销。一个边距计算在等待 AppKit,而一个阻塞方法让不相关的 JavaScript 调用挂起。周二的 原生拖放遇上跨设备连续性 会让新的集成 API 发挥作用:把文档提供给另一个应用、在另一台设备上恢复草稿,并避免账户变更恢复成错误用户的屏幕。
周三的 像你的网站一样的 Javadoc 展示如何让你自己的 Java API 参考拥有站点的布局和搜索。我们在 9 月 17 日以 Android 17:无需最后一刻手忙脚乱 收尾:尽早捕捉 SDK 变更,让操作系统处理位置授权,并从应用代码中移除更多安全敏感的胶水代码。
更少垃圾,为你的应用留出更多空间
作为 Java 开发者,HotSpot 是我们倾向于拿来比较的标准。把 ParparVM 与 Go 比较,让我把目光投向了那个熟悉的参照之外。我最初的怀疑是栈分配:也许 Go 只是创建了更少的堆对象,这正是让 Valhalla 的值对象 有趣的那类开销。
但 Go 的回收策略也很重要。ParparVM 是一个具有封闭世界构建的 AOT 运行时,因此我们可以把分配和回收放在一起实验。我们把应用翻译成 C,掌控其对象表示,并知道哪些部分必须与收集器协作。
我们从允许新垃圾的额度开始。让更多垃圾累积可以节省回收工作,但那些额外内存应当换来一些东西。
在我们的触发条件实验中,它换来的东西很少。默认的 24 MB 分配下限对应着 98 MB 的已加载常驻内存。遍历更低的触发设置后,吞吐量和 p99 都处于运行间噪声范围内。随后采用更低下限的配置把已加载 RSS 降到了 38 MB。PR #5717 记录了这个实验。
[LOADING...]
每个触发设置下的常驻内存。最终调优配置达到 38 MB;RSS 包括 Java 堆之外的内存。
活跃集估算漏掉了活跃对象
下一步很诱人:根据活跃数据量推导出下限。这样小进程可以更早回收,而大进程可以保留更多分配余量。
我们试过,然后又撤回了。清扫计数器描述的是已退休页面,而不是整个活跃对象群体。部分页面以及 mutator 仍在其中分配的页面不在计数之内;大对象走另一条路径。把这个数字喂给比例策略,会让策略看到不完整的图景。有一次尝试保留了旧的活跃集估算太久,以至于抑制了把页面归还给操作系统所需的清扫。
合并后的代码让部署可以选择自己的最小值:
运行时构建可以选择更小的下限。在处理更多工作负载和不完整的活跃集计数器期间,我们保留了默认值。
并行标记是调查的另一部分。在记录的分配循环中,ParparVM 的中位数和 p99 与 Go 相当。最坏暂停时间仍然对 Go 有利:约 20 毫秒,而四个 ParparVM 标记线程时为 0.3 到 0.9 秒,一个标记线程时为 2.2 到 3.3 秒。并行标记仍是实验性的。分配线程可以帮助消耗标记工作,而不是仅仅在超前上限处等待,但这种协助属于并行路径,而不属于保持不变的串行默认路径。
有用的图像应比冷图像存活更久
如果收集器把可丢弃的缓存数据当作永久可达,那么更早回收也帮不上忙。ParparVM 旧的 WeakReference 把其 referent 放在一个被翻译器像普通强字段一样标记的字段中。只要包装对象存活,那个本应弱引用的对象也会存活。
iOS 移植版用强引用表和低内存警告来近似软引用。当内存压力到来时,flushSoftRefMap() 会替换该表。这可以释放一批缓存图形,但也会丢弃用户即将滚动回去查看的图像。
PR #5732 为收集器提供了真正的弱引用和软引用。它把 referent 作为一种特殊边来发现,决定软引用对象是否应存活,然后清除符合回收条件的引用。软引用保留使用自上次成功 get() 以来的年龄。反复使用的图像往往会保持较新;冷图像则失去优先级。
在更新后的 ParparVM 路径上,面向应用的契约如下:
这段摘录使用了 java.lang.ref.SoftReference、com.codename1.ui.Image 和 java.io.IOException,并假设只有一个调用线程。局部变量 result 在使用期间让成功读取的对象保持强可达。未命中时会重建图像。只有当这种重建可行时,软缓存才合适。
困难的情况是在回收期间发生读取。线程可能在收集器已经扫描过它的根之后调用 get()。如果释放 referent 时没有注意到那次读取,就会把悬空的原生指针交给 Java 代码。引用读取现在参与标记屏障,收集器在决定可以清扫之前会先排空新工作。
在模拟的 160 MB 预算测试中,按排名保留在 82.1 MB 时产生了 97.44% 的命中率,相比之下,压力时清除在 91.0 MB 时为 87.99%。下一步是把 iOS 缓存表和框架调用点迁移到新的引用机制上,然后测量其对真实屏幕的影响。
收集器与缓存文章 会详细跟进这两个实验。降低垃圾余量并保留有用的缓存结果,解决的是同一个内存预算的不同侧面。
在不改变 Java map 的情况下追逐瑞士速度
我们的开放寻址 HashMap 把条目存储在数组中。键的哈希决定其第一个槽位。如果该槽位被另一个键占用,map 就会到别处探测,直到找到该键或空槽位。
密集整数键恰好让第一次探测看起来非常出色。整数哈希保留原值,而常见的 h ^= h >>> 16 扩散会让小整数留在其原始位置附近。从零开始插入连续整数,被占用的槽位会形成一段很长的连续区间。成功查找常常直接落在其键上。缺失键进入这段连续区间后,在线性探测下必须走到它的末端。删除留下的墓碑也不能终止搜索。
在 100,000 个条目时,测得的未命中平均为 16,742 次探测。在一百万个条目时,平均为 222,721 次。命中保持在一次探测。一个由命中主导的基准测试对这种失败几乎无话可说。
保留第一次探测,改变后续探测
打乱每个哈希修复了未命中问题,但在测量的案例中让密集构造和扫描慢了 1.8 到 2.2 倍。我们想保留那个第一个槽位的局部性。
PR #5722 保留第一次探测,并使用与 CPython 字典探测相关的递推式改变冲突序列:
调用方在两次探测之间把无符号扰动值右移五位。随着这些哈希位进入递推式,冲突可以逃离被占用的连续区间。一旦扰动值变为零,递推式仍会遍历 2 的幂大小表。查找、插入、扩容和删除必须在这个序列上达成一致。
最后一行变差了。这个权衡应当与惊人的未命中结果并列看待,因为这项改动并没有让每个 map 操作都快上数百倍。
Go 的 Swiss maps 是另一个有用的比较对象。ParparVM 已经把紧凑元数据与键、值数组分开保存,因此我们的结构更接近 Go,而不是那种围绕每个条目一次分配构建的 map。合并后的查找仍是标量扰动探测。与 SIMD 相关的改进在字符串键路径上:不相等的缓存哈希会尽早拒绝匹配,兼容的 UTF-16 存储会走原生 memcmp,平台可在其中使用优化过的向量比较。我们的探测序列和字符串比较处理的是查找的不同阶段。这里的计时比较的是新旧 ParparVM 实现。
Hashtable 也不再为每个映射分配一个 Entry。IdentityHashMap 需要不同的修复:ParparVM 的 identity hash 来自对齐地址,因此照搬适合 HotSpot 已经打乱过的 identity hash 的索引表达式,会留下太多为零的低位。把高位折叠下来改善了其测量分布。
紧凑的表仍可能充满小分配
填充 map 的 JSON 解析器可能为每个数字分配一个包装对象。我们早期的性能工作为 Integer 消除了大部分这种成本。PR #5735 把这种表示扩展到 Short、Character、Float、Long 和 Double。
在这个 64 位运行时中,对齐的对象地址会留下三个空闲低位。收集器的根扫描已经能把设置了这些位的字与普通对象指针区分开。我们把它们用作类型标签,并把值放在其余 61 位中。map 槽位直接包含数字的表示,而不是指向单独包装对象的指针。
Short、Character 和 Float 在其完整范围内都适用。Long 适用于从 -2^60 到 2^60 - 1。当 Double 的低三位尾数为零时适用。Byte 和 Boolean 已经有有界缓存。使用 equals() 进行值相等比较;应用代码不应从运行时的分配选择中推断身份。
类似 JSON 的分配统计从 每个 map 24.02 次装箱分配降至 5.24 次。这是收集器永远不必做的工作。这扩展了我们“穷人版 Valhalla”的工作:把一个已知的包装值直接放进它的引用字中。标签随之携带,包括当这个字位于堆集合中时。Long 和 Double 为不适合的值保留堆回退。
map 与装箱文章 会涵盖覆盖测量和分派风险。类型标签必须选择正确的 hashCode 和 equals 实现。对每个标签复用 Integer 快路径会产生一个快速但不正确的 map。## 更快的启动始于你的应用从未需要的工作
在第一帧之前,屏幕会询问许多组件它们有多大。内边距和外边距的转换需要屏幕缩放比例。我们的原生 Mac 路径会同步询问 AppKit 哪个屏幕包含该窗口,尽管自从上一个组件询问以来,答案通常并没有改变。
PR #5686 在窗口创建或移动时同时发布屏幕标识和缩放比例。布局会原子性地读取该状态。将这对信息一起发布还可以防止读取者将一个屏幕与另一个屏幕的缩放比例组合在一起。
在记录的启动性能分析中,旧查询占了 35 毫秒的阻塞事件调度线程时间。安装窗口观察器又增加了一次同步等待,尽管调用者不需要返回值。该路径占了 37 毫秒。Mac 性能分析为我们提供了两个要从启动中移除的特定等待。
同一份性能分析发现,一个无竞争的锁在尝试获取其互斥量之前,会宣布它即将挂起。该宣布可能会等待 GC 握手。运行时现在会先尝试获取锁,只在真正需要等待时才进入挂起协议。
布局在做本属于绘制的工作
UIManager 会扫描整个主题表,为每个不同的 UIID 查找深色变体。现在我们每次主题生成时构建一次索引。基准测试中首次使用深色变体查找的时间从 111,955 纳秒降至 17,378 纳秒。当新主题改变答案时,索引会失效。
Switch.getPreferredSize() 有一个更令人惊讶的开销:生成开关图形,包括高斯模糊。布局查询可能会为一个从未被绘制的组件支付该开销。现在首选尺寸来自绘制代码使用的相同尺寸;像素的生成会等到需要时才进行。
Metal 路径还避免将每张图片带回 CPU,并避免在纹理旁边保留解码后的 EncodedImage 副本。圆角可以在着色器中绘制。移除图像表示可以节省其构造开销以及启动后它将占用的内存。
JavaScript 正在准备挂起那些不可能阻塞的方法
Web 编译器有一个类似的问题,源于一个过于宽泛的假设。一个可能阻塞的 Java 方法会变成 JavaScript 生成器,以便执行可以暂停并稍后恢复。我们的分析使用了方法名和描述符,但没有区分其接收者类。
考虑这两个不相关的类:
对 CounterTask 的调用无法到达 WaitingTask.run()。但是将 run() 视为一个阻塞签名会使不相关的调用成为挂起点,然后将该属性传播到它们的调用者。
PR #5755 重用了可达性分析已经计算出的接收者类型。它会考虑那些实际可能接收该调用的实现。未解析的分派保持保守,并且分析和代码生成共享同一模型,因此它们在 yield* 何处合法的问题上保持一致。
yield 位置减少了 25.3%,而包大小仅减少了 1.2%。计算字节数几乎不会注意到分派的变化。Node 吞吐量测量在几个工作负载中发现了增益,但 iteratorWalk 耗时 增加了 13.7%,目前尚无确定原因。这已列入进一步调查的清单。
原生和 JavaScript 性能文章 跟踪了这些路径及其测试。共同的问题是,在试图让等待机制更快之前,首先要问一个操作是否根本需要等待。
跨设备连续性:从你离开的地方继续
在手机上开始写作,然后在平板电脑上继续。在操作系统回收进程后重新打开应用,并返回到你正在编辑的草稿。跨设备连续性让应用能够保留用户关心的任务,而不是让当前进程成为它唯一的家。
保存在字段中的 Form 无法做到这一点。字段会随进程消失,另一台设备无法从第一台设备内存中的对象重建屏幕。它需要工作的描述。
PR #5663 添加了 com.codename1.continuity。它将应用状态和路由器栈保存为检查点,新进程可以据此重建。路由标识屏幕;StateProvider 提供恢复工作所需的数据。对于草稿编辑器,这可以小到只是文本:
这里 draftField 是应用的 TextArea。注册提供者即可启用连续性。导航会安排检查点;编辑可以调用 Continuity.checkpoint()。保持负载小,排除凭据,并决定敏感草稿是否应包含在其中。
恢复发生在应用知道哪个账户处于活动状态之后:
Apple Handoff 可以将活动提供给另一台已登录的设备。应用拥有的 StateRelay 可以通过你现有的账户和服务器携带状态,包括跨 Android、Apple 设备和浏览器。Codename One 不运营该中继,也不选择其账户隔离策略。iCloud 键值同步是一个单独的包,有其自己的权利要求。
原生拖放:把它带到你的应用之外
将报告拖到另一个应用程序中。将文件拖放到你自己的导入屏幕中。将选择内容作为格式化的 HTML 提供给编辑器,或作为纯文本提供给只需要文字的应用。这些是操作系统拖放会话,目标在你的表单之外。
PR #5662 在我们现有的轻量级 API 旁边添加了原生拖放。表单内拖动仍然像以前一样工作。新 API 使用 ClipboardContent 将组件连接到操作系统拖放会话,因此可用于复制和粘贴的相同表示可以提供给拖放接收者。
此摘录假设存在现有的导出 paths、名为 inbox 的接收组件,以及一个应用 queueImport,它验证文件并将昂贵的解析移出事件调度线程。MIME 接受不是信任决策。
提供者的时机取决于移植平台。JavaSE 可以延迟请求表示。Android 在开始拖动之前组装完整的 ClipData,因此每个提供者都会在此时运行。iOS 在拖动开始时解析文件列表以计数项目。让提供者在那时足够廉价,或者提前准备并缓存昂贵的导出。取消拖动并不能保证其数据从未生成。
初始实现覆盖 JavaSE、Android 和 UIKit 移植平台,在 Android、iPadOS 和 Mac Catalyst 上具有跨应用行为。iPhone 具有更窄的交互模型。原生 AppKit、Windows、Linux 和 JavaScript 尚未实现此原生拖放路径。在依赖它之前,请检查 NativeDragAndDrop.isSupported()。
原生拖放和连续性文章 包括平台矩阵、回调线程和注销序列。收到的检查点仍必须通过当前账户的授权检查;完成的移动必须在删除源之前确认。移动工作不应意味着失去对它的所有权。
给你的 Javadoc 一个它应得的网站
我们一直在改进开发者指南,但 API 参考仍然将标准 Javadoc 的布局和样式表带入我们网站内的一个包装器中。搜索和深色模式使这种分离尤为明显。
PR #5743 改变了 doclet 输出的内容。Javadoc 提供 Java API 模型和文档树。新的 maven/javadoc-hugo-doclet 模块写入带有结构化 front matter 的 Hugo 内容,用于签名和成员。Hugo 使用网站自己的模板和 Markdown 渲染器来渲染它。
该工具在独立于 Java 8 构建的模块中使用 Java 25。Markdown 文档注释本身是在 JDK 23 中引入的。doclet 为 Hugo 保留它们的散文,并将识别出的部分(如参数和返回值)提取到成员数据中。我们仍然从相同的源代码生成标准的 javadocs.zip 归档。
[LOADING...]
9 月 10 日的实时网站截图。API 方法现在出现在网站搜索中,并按类型分组。完整的开发者指南保留自己的导航和浏览器搜索,而不是加入此索引。
这是可重用的源代码,适合希望自己的网站生成器拥有 API 页面的 Java 开发者。doclet 模块、Hugo 布局 和构建集成可在仓库中找到。你仍然需要根据你的网站调整模板和 URL 约定。
迁移还对照旧输出检查了 2,272 个页面和 29,583 个片段。如果多年来指向该成员的链接停止工作,那么更漂亮的成员页面也没什么用。doclet 文章 包括完整示例、构建命令以及奇偶校验测试发现的锚点缺陷。
无需最后一刻忙乱的 Android 17
PR #5731 添加了 API 37 编译检查和生成应用组装。这在更改默认目标 SDK 之前很重要:针对旧平台编译无法告诉我们新平台移除了哪些被引用的类。
该检查会针对两个平台 jar 编译相同的移植源代码,并比较错误。它会移除重复的 android.jar 条目,这样旧的 jar 就无法悄悄提供新平台中缺失的符号。使用已移除的指纹 API 进行的故障注入测试证明,该检查能够捕获它旨在发现的故障。
构建器假设也需要注意。像 android-37.2 这样的平台名称不再是整数后缀。去掉标点符号会将 37.2 变成 372,这会以完全错误的原因通过最低版本检查。构建器现在正确提取主版本级别,并使用属于所选 SDK 根目录的 SDK 管理器。在继续此准备期间,正常的 SDK 下限仍为 36。
在用户需要时请求位置
PR #5738 添加了 Android 17 系统位置按钮,在较旧平台上使用普通的 Codename One 按钮和现有的权限流程:
searchNearby 属于应用程序。拒绝权限或结果不可用可能会产生 null。该组件公开了 isSystemRendered(),以便应用可以验证哪条路径处于活动状态。
操作系统将支持的控件绘制到托管表面中,并将请求与可见的用户操作和会话范围的位置授予绑定。该实现通过反射使用平台会话 API,避免了 AndroidX 依赖,否则会强制每个使用该按钮的应用使用更新的 Android Gradle 插件。构建器在找到该组件时会添加 USE_LOCATION_BUTTON;服务于应用的构建器中仍然需要存在单独的云构建器镜像。
该 PR 在 Android 17 模拟器上演练了系统按钮、人工点击、同意和返回位置,并在 API 36 上回退。常规截图套件仍在 API 36 上运行;更广泛的 API 37 覆盖是剩余迁移工作的一部分。Android 和安全文章 将这些检查分开,并链接到当前平台指南。
停止让每个应用解析自己的密钥装甲
PR #5707 添加了 PublicKey.fromPem 和 PrivateKey.fromPem,用于文本或文件字节:
这些使用 Codename One 安全类和应用的现有输入流。解析器去除装甲,解码 Base64,验证完整的 DER 元素,并识别密钥容器。支持的 PKCS#1 RSA 和 SEC1 EC 输入会被重新包装为下游期望的容器。加密密钥、证书、错误的公钥/私钥方向以及截断或尾随的 DER 都会被拒绝并给出解释。
这从应用胶水代码中移除了格式处理。它不决定公钥是否受信任,也不决定私钥应存储在哪里。
移除任务是结束会话的一部分
PR #5746 添加了 CN.exitAndClearTask()。在 Android 上,它会在遵循进程退出路径之前从最近任务中移除该任务。仅杀死进程可能会将任务留在切换器中。
对于使用连续性的应用,注销时框架调用如下:
清除已保存状态而不禁用连续性会留下一条路径,让到达的活动再次恢复它。移除任务而不撤销凭据会让会话保持活动。这些调用处理恢复和任务生命周期;你的应用仍然拥有凭据撤销和账户数据。其他移植平台保留其现有的退出行为。## 更少的运行时工作,更少的安全出错点 垃圾收集器与引用相关的改动,让我们能够区分垃圾数据与有用的缓存数据。映射改动修复了一种病态搜索,并移除了其内容中的包装器分配。样式构建、布局和 JavaScript 生成现在在应用继续执行自身任务之前所做的工作更少。仍有回归问题需要调查,缓存集成也尚待完成,因此我们下周将继续性能工作。
连续互通与原生拖放扩展了用户使用该应用能够完成的操作。doclet 将 API 契约放到网站搜索能够找到它们的地方。Android 准备工作将平台破坏性问题提前移入我们的检查中,而不是等到强制迁移将其带入客户的发布计划。
应用可以使用普通 Java 引用,而 Codename One 运行时负责处理缓存读取与垃圾收集之间的竞态。它可以读取 PEM 密钥,而无需维护自己的 DER 转换。它可以通过操作系统同意界面请求位置,并在账户退出时显式关闭状态恢复。
默认安全编程取决于这些细节。我们统一维护 API、转译器、运行时和构建器,这样应用团队就不必各自在原生代码中重新探索它们。应用的授权与数据规则仍由开发者负责。共享实现应让他们有更多时间把这些规则做对。
分享此页面
- Bluesky
- Mastodon
- Hacker News
- 邮件
- 复制链接
- 分享...
发现错误,或有内容要补充?在 GitHub 上编辑此页面
[LOADING...]
作者
Shai Almog
作者、开发者关系、博主、开源黑客、Java 摇滚明星、会议演讲者、讲师和创业者。
相关文章
Java [LOADING...]
Shai Almog2026年9月25日 59 次浏览
VoIP、VPN 及其背后的构建系统
Java Java [LOADING...]
Shai Almog2026年8月22日 615 次浏览
第三代 GUI 构建器:所有表单共用一个工作区
Java Java [LOADING...]
Shai Almog2026年8月3日 565 次浏览
Codename One 的 JavaScript 移植版现已免费且开源
Java Java [LOADING...]
Shai Almog2026年8月29日 175 次浏览
跨所有移植版本的 SQLite:一个契约,一种加密文件格式
参与讨论
Java [LOADING...]
Shai Almog2026年9月25日 59 次浏览
VoIP、VPN 及其背后的构建系统
Java [LOADING...]
Shai Almog2026年8月22日 615 次浏览
第三代 GUI 构建器:所有表单共用一个工作区
Java [LOADING...]
Shai Almog2026年8月3日 565 次浏览
Codename One 的 JavaScript 移植版现已免费且开源
Java [LOADING...]
Shai Almog2026年8月29日 175 次浏览