Ohhnews

分类导航

$ cd ..
foojay原文

掌控像素:按你的时间表实现原生保真度

#codename one#原生保真度#像素测试#移动主题#跨平台开发

掌控像素:按自己的节奏实现原生保真度

[LOADING...]

iOS 或 Android 更新可能会改变你已上线的界面,而你一行代码都没改。如果你的应用使用 UIKit、SwiftUI、Compose 或 Material 组件构建 UI,那么 Apple 或 Google 拥有这些组件实现的所有权。Codename One 的做法不同。它将我们的轻量级组件实现静态链接到你的原生应用之中。你测试的 UI,就是用户在下一次 OS 更新后仍会看到的 UI。更新仍然可能破坏平台 API 或权限契约,但无法将我们的按钮实现替换成新的。

轻量并不意味着 Java 绘制循环落后于平台。在 iOS 上,组件通过我们的 Metal 管线绘制。本文中流动的 Liquid Glass 标签镜片,就是帧现有命令缓冲区上的一个 Metal 着色器,没有像素传回 CPU。同时,上周的 ParparVM 工作 将我们的提前编译虚拟机在十个基准测试中达到了与热后的 Java 25 几何平均持平的水平。其中六个测试结果等于或领先于 HotSpot。

代价是,我们的 UI 不会免费继承 Apple 或 Google 的最新设计重做。我们必须研究它,复现那些有意义的细节,然后测试结果。这是我们承担的工作,以便你可以专注于你的应用,而不是为 Apple 和 Google 的设计团队打工。你决定你的应用何时采用新外观。操作系统不会在升级日替你决定。

ParparVM 和主题保真度分支是并行开发的。我们希望它们出现在同一个版本中,但每个分支都变得太大,无法安全合并在一起。保真度工作耗时更长。PR #5274 一个 PR 就报告了 1147 个文件中有 53,000 行新增。生成的访问注册表、资源、截图和原生 golden 文件占据了其中很大一部分,但规模仍然是真实的。随后的五个 PR 修复了第一轮暴露的问题。

拥有组件栈还允许我们在像素层面之下工作。PR #5363 添加了可移植的无障碍层次结构、虚拟语义节点、模拟器审计和平台偏好检测。这花了 57 个文件中的 6,965 行新增,因为真正的无障碍不是给按钮贴上标签那么简单。周一的文章将对这项工作详加介绍。

我们永远不会让 Codename One 的每一个组件像素在每个设备上都与原生 widget 逐像素对齐。这是徒劳的目标。有用的目标是达到 98% 或 99% 的人无法分辨哪一边是 Codename One,并且当我们后来发现某个改动让我们远离这个水平时,我们能注意到它。目前并非所有组件都达到这个水平。这些改动让我们更接近了,而现在差距是通过测量而非争论来确定的。

主题很现代,但尚未完成

我们在五月引入了 iOS Modern 和 Android Material 3 主题。它们为新项目提供了一个现代化的起点,但最初的 iOS 实现使用了半透明颜色,而 iOS 26 使用的是实时玻璃材质。几个控件仍然带有通用的 Codename One 几何形状。Material 浮动操作按钮就是一个很好的例子:它继承了旧的圆形和由图标派生的大小,而不是 Material 3 固定的 56dp 圆角矩形。

这是 Android 浮动操作按钮在保真度调整前后的对比。左侧是旧渲染,右侧是当前 Material 3 渲染。

[LOADING...]

iOS 主题的变化同样明显。按钮变成了全胶囊,状态字形换成了 SF Symbols,大小和间距朝着 iOS 26 的参考值靠近。左侧是五月份的渲染,右侧是当前渲染。

[LOADING...]

为什么 iOS 上的浮动操作按钮仍然是圆形?iOS 没有原生浮动操作按钮的对应物。将 Material 的圆角矩形引入 iOS 主题会降低其原生性,而不是增强。Android 遵循 Material 3。iOS 则保持已确立的圆形强调操作,直到有我们可以瞄准的 iOS 组件出现。

原生应用提供了参考答案

保真度测试套件包含两个独立的参考应用。一个使用 UIKit 和 Swift 编写,另一个使用 Android 的 Material 组件。我们在本地固定的原生工具链上运行这些应用,捕获真实控件在浅色和深色外观下的状态,包括正常、按下、选中和禁用状态。

玻璃材质需要背景内容。单纯的白色图块会让半透明填充看起来像模糊,因为没有可供模糊的内容。玻璃测试会将两种实现放置在同一张照片和渐变背景之上。这给了镜片边缘、折射、饱和度和背景模糊以真实的细节来扭曲。有色调的圆角矩形无法在这种对比中蒙混过关。

原生捕获被提交为版本化的 golden 集合:

scripts/fidelity-app/goldens/
  ios-26-metal/
  ios-26-metal-anim/
  ios-26-metal-frames/
  android-m3/
  android-m3-anim/

CI 永远不会凭空生成原生端。它会在匹配的主题下渲染 Codename One 组件,与已提交的原生图像进行比较,并报告视觉和几何差异。如果某个分数低于其记录的基线,单向门禁将失败。

[LOADING...]

iOS golden 集合固定于 iOS 26。Android 集合固定于 API 36 上 160dpi 的 Material 3。当 iOS 27 到来时,我们将捕获一套新的 ios-27 集合,添加一个主题变体和一个 CI 矩阵行,然后同时测试两个版本,直到我们有意退役旧版本。我们不会无声地替换 iOS 26 的参考答案,然后称之为改进。

百分比的含义及其局限性

当前 Android 基线包含 54 对对比。其中位数容忍视觉分数为 95.5%。最差的一对是深色描边按钮的按下状态,为 91.25%。iOS 基线包含 68 对,中位数为 94.4%。最差的一对是深色标签栏,为 83.45%。

这些百分比对于回归门禁很有用。但它们不能替代人眼,而且对于更大、信息密度更高的组件,评分更为严苛。按钮只有一个标签和一个紧凑的外轮廓。标签栏有三个图标、三个标签、一个镜片、一个长玻璃表面和更多的边缘。一微小的重复不匹配会影响标签图像中的更大比例。即使标签栏看起来比得分超过 90% 的按钮更接近原生参考,它的得分仍可能只有 83.45%。

Thomas 在 PR 讨论 中提到了这一点。一个单选按钮在人眼看来可能完全相同,但仍然会因抗锯齿而丢分。一个大的标签栏可能隐藏了错误的图标,而数千个匹配的背景像素却掩盖了问题。一个高叠加分数也可能隐藏了错误的宽度或圆角半径。

我们因此修改了测试套件。现在它会分别报告边界框偏移、宽度和高度比率、中心偏移以及估算的圆角半径,与视觉分数分开。比较模式也来自显式的 material: normal|glass|lens 声明,而不是图像启发式规则。人工审查仍然是流程的一部分,尤其是对于运动和半透明效果,因为单一分数可能隐藏错误的细节。

以下是自动报告中的四组当前 iOS 对比对。左侧是原生,右侧是 Codename One,由一条细垂直线分隔。深色选择器暴露了局限性:选中行很接近,但非选行的对比度仍需改进。

[LOADING...]

Android 报告使用相同的布局和分隔线。浮动操作按钮现在具有 Material 3 几何形状,而标签排版和描边按钮仍显示较小的差异,基线会跟踪这些差异。

[LOADING...]

测试套件有意止步于组件边界。屏幕间距、层次结构和组合属于应用设计决策,无论你使用 SwiftUI、Compose 还是 Codename One。主题应提供适用于不同设备的合理默认值。最终布局仍属于构建产品的开发者。

标签栏变成了一个渲染项目

iOS 26 的标签选择并非一个在图标下方滑动的染色药丸。在触摸驱动的过渡过程中,选择状态会变成一个放大镜。它在标签栏上移动,折射其下的背景和字形,在飞行过程中拉伸,并以一个小弹簧过冲结束。

以下动画比较了原生参考与 Codename One。顶部行是浅色外观,底部行是深色外观。左侧是原生,右侧是 Codename One。

[LOADING...]

下面的静态对比冻结了有用的阶段,以便你检查几何形状。

[LOADING...]

第一次原生录制让我们走向了错误的方向。我们自动更改了 selectedIndex 并捕捉到了一个扁平托盘在标签栏上滑动。这就是 UIKit 显示的程序化选择效果。完整的 Liquid Glass 镜片只有在真正触摸后才会出现。我们最终发现了这个差异,并构建了一个小的 XCUITest 驱动程序,在模拟器录制的同时实际点击标签栏。上面的动画来自那个触摸驱动的参考。

共享的运动逻辑位于 TabSelectionMorph 中。它根据旧标签、新标签和当前触摸进度计算每个帧的药丸和镜片几何形状。结果还携带了放大倍率、颜色分离、色调和弹簧稳定效果。Tabs 负责绘制它。SwitchThumbDroplet 对玻璃开关拇指执行相同的任务,包括在移动过程中的拉伸和垂直挤压。

公共主题表面有意小于内部模型。主题选择 tabsMorphPreset: ios26subtle,然后调整持续时间、镜片强度和弹簧百分比。来自第一个实现的十三个底层运动常量被移除,因为它们使得太容易调整一个截图而破坏截图之间的路径。

测试将动画冻结在 0%、10%、25%、50%、75%、90% 和 100%。它检查帧是否不同,移动是否单调,过冲是否在边界内。提交的中间帧是 Codename One 的 golden 图像,而不是原生中间帧的比较。我们仍然从录制的视频中审查原生运动,因为中间帧尚未自动比较。

玻璃材质是一种类型化的材质,而非一堆常量

第一次玻璃材质传递将饱和度、模糊、缩放、偏移、折射和反射值暴露为不相关的主题常量。效果并不好。工具栏、面板、按钮和移动镜片可能会被意外地调成不同的材质。

GlassRecipe 现在定义了四种有边界的材质意图:

$ java
GlassRecipe.plainBlur();
GlassRecipe.liquidChrome(dark);  // 边缘栏和工具栏
GlassRecipe.liquidPill(dark);    // 浮动标签栏
GlassRecipe.liquidPanel(dark);   // 通用玻璃表面

主题为 UIID 分配一个配方。Component 解析它并通过 Graphics.glassRegion(...) 传递参数。端口接收材质配方,而不是在绘制期间读取 iOS 特定的常量。

在 iOS 上,移动镜片是帧现有命令缓冲区上的一个 Metal 片段着色器。它不会将像素从 GPU 内存传回 CPU。较大的玻璃补丁确实需要背景像素,因此它使用以边界、配方参数和实际背景字节哈希为键的缓存。在性能分析的套件运行中,当背景变化时,完整构图平均约 90ms,而稳定的缓存命中平均为 5.3ms,有 475 次命中和 253 次未命中。这是开发阶段的检测数据,不是通用的应用基准测试,但它给了我们一个具体的缓存策略。

JavaSE 最初没有实现 glassRegion。它默默地将标签栏降级为纯模糊,导致镜片只放大一个灰色板块。JavaScript 丢弃了材质参数,使用纯色调加均匀缩放。PR #5388 在两个端口上实现了材质,并在 JavaScript、JavaSE、iOS CPU 参考和 Metal 着色器之间固定了 14 个常量。现在,在所有七个探针点上,JavaScript 镜片产生的像素 CRC 与 JavaSE 相同。

仍然存在一个平台权衡。iOS 路径使用原生 Metal 着色器。JavaSE 和 JavaScript 在软件中复制像素。它们没有相同的 GPU 路径,因此复杂的移动背景在那里可能看起来不够平滑。组件及其状态模型保持可移植。实现成本并不相同。

CSS 必须与主题共同演进

几个差异无法通过修改颜色来修复。框架和 CSS 编译器获得了新的词汇:

$ style
#Constants {
    tabsMorphPreset: "ios26";
    buttonReleaseFadeDurationInt: 180;
    tabsEqualWidthBool: true;
}

RaisedButton {
    cn1-background-type: cn1-pill-border;
    border: 0.1mm solid rgba(136,254,255,0.58);
    cn1-stroke-gradient: #ffffff;
    cn1-stroke-gradient-angle: 135deg;
}

RoundBorder 现在支持渐变描边。按钮获得了一个可选的释放叠加效果,使得 iOS Modern 可以在 180ms 内淡出按住状态,而不是瞬间消失。DialogInteractionDialog 获得了可选的居中标题布局,同时保持命令行与卡片边缘齐平。标签页获得了等宽单元格和正确缩放的 Material 指示器。Style 获得了字符间距。资源格式修订将渐变、滤镜和渐变描边带入了发布的 .res 文件中。

图标问题也需要一个平台层面的答案。即使在语义正确的情况下,Material 图标放在 iOS 控件内也显得不对劲。FontImage.createSFOrMaterial(...) 在 iOS 上选择 Apple SF Symbol,在其他地方选择 Material 回退方式。工具栏命令现在可以在视觉上隐藏文本,同时保留命令标题用于无障碍访问。

后续 PR 与标题 PR 同等重要:

  • PR #5373 恢复了第一次保真度传递意外替换为固定颜色的应用强调颜色绑定。该回归还暴露了一个可以在重试后接受默认颜色的测试,因此该测试也得到了修复。
  • PR #5379 添加了 iOS 26 按钮胶囊、180ms 释放淡出和渐变描边。
  • PR #5376 添加了可选的居中对话框标题布局,并修复了两个对话框类中的边缘到边缘命令网格。
  • PR #5387 收紧了工具栏图标、微调器对比度和内边距、滑块拇指、进度轨道、禁用的开关和玻璃面板边角。
  • PR #5388 使 JavaSE 和 JavaScript 的玻璃渲染重新与共享模型保持一致。

该测试套件还发现了一个 14 年之久的 iOS 渐变错误。屏幕上的 fillLinearGradientGlobal 路径自 2012 年以来水平轴和垂直轴就是颠倒的。可变图像路径是正确的。一个有控制的渐变背景终于使差异变得可追溯。## 本周大部分工作是看不见的后端改造

虽然主题差异很容易拍照,但我们的大部分时间都花在了替换 Codename One 账户登录系统上。

新的账户安全页面将控制项集中在一处。现在你可以使用 Google 或 GitHub 登录、启用基于时间的两因素认证应用、注册通行密钥、查看活跃会话、撤销单个会话,或退出所有其他设备。通行密钥可以使用指纹、面部、PIN 或你的平台支持的其他设备解锁方式。

这项工作不会改变你应用中的任何一个像素,但它改变了我们保护构建凭据、签名资产和账户数据的方式。同时,它也将桌面设置工具中的多项账户职责移除。周六的文章会解释那个较小的工具。

三个可能导致大问题的小改动

另外三个 PR 值得一提,因为每个修复都可能节省数小时的排查时间。

编辑时对齐文本不再跳动。 PR #5374 让 Android 内联编辑器和 JavaSE Swing 编辑器遵守 getAbsoluteAlignment()。现在,当轻量级字段将控制权交给原生编辑器时,右对齐的数字会保持在右侧。多行 Swing 文本区域未作改动,因为 Swing 在那里没有按行水平对齐功能。

本地工具失败时可提供“获取帮助”。 PR #5383 在安装、项目创建、配置、本地运行或构建提交失败后添加了 mvn cn1:get-help。不会自动收集遥测数据,也不会在你按下发送按钮之前发送任何信息。报告内容包括失败步骤、命令、Java 和代理环境,以及截断的错误跟踪信息。如果你提供了邮箱,支持人员可以异步回复。该 UI 不承诺全天候人工值守。

陈旧的类在上传前会先报错。 PR #5390 使用 ASM 扫描暂存的应用 jar,并验证你自己的包引用所涉及的类确实存在。旧版错误会在大约 11000 行构建日志之后才以“缺少生成的 Objective-C 头文件”的形式出现。新版错误在客户端运行,并告诉你执行 mvn clean

The compiled application classes are inconsistent.
 - com.example.Gone (referenced from com.example.Caller)
Run 'mvn clean' and rebuild.

该发布系列的其他内容

保真度工作可能已经占满了整周,但还有五个改动需要单独说明:

如果你使用现代主题,你能发给我们的最有用的东西仍然是一张带有精确组件、状态、外观和设备的截图。“棘轮”机制能阻止已知像素变得更糟,但它无法告诉我们你最关心的缺失屏幕是哪一个。

原文发表在 Own Your Pixels: Native Fidelity on Your Schedule 上,首发于 foojay