跳转到内容
Yuuki学习手记
GitHub

抓住让界面卡住的那一步:渲染与性能实验

一边滚动旧消息,一边输入中文,模型还在继续吐字。先把这几件事放在一起,再找出哪一步拖住了界面。本文把对 Zed、Raycast、Warp 流畅体验的好奇,连接到已实现的五份 UI 样例与四组后续实验。 研究日期:2026-09-26。五份样例已完成本机构建;下文所有运行性能结果仍为「未测」。 你可以立即从 Rust + GPUI 开始,亲手学习绘制、状态更新与系统能力集成;Swift、React 原生桥、WKWebView 和 Electron 是并行对照入口。 选择顺序由你的兴趣与想理解的问题决定,不要求先完成 SwiftUI 或证明其他框架不足。

相关入口:文档导航 · 产品实现调研 · Mac 本地 Harness 架构 · 第一个 Mac 闭环 · 直接开始 GPUI · 五条路线的收益与代价。

路线已实现的界面路径学习入口
Swift 原生SwiftUI 内容与 AppKit 窗口Swift 示例
Rust + GPUIRust 元素树与 Metal 绘制GPUI 示例
React → AppKitNode 中的 React custom renderer,经 JSON 映射到原生控件原生桥示例
React + WKWebViewSwift 宿主与系统 WebKit,ReactDOM 绘制WebView 示例
Electron + React随应用打包的 Chromium、Node 与 ReactDOMElectron 示例

它们共享 1,000 / 10,000 条历史档位、全部/工具过滤、每 50 ms 一条且每轮最多 200 条的模拟流,历史上限为 10,200 条。流式负载不调用模型。更新部分读取并校验清单,比较版本后展示状态;语音部分连接同一个 Apple Speech helper,由你主动启动后处理系统授权。

这些是已写入代码并生成本机应用的功能,尚未完成界面交互、麦克风识别或性能测量。更新下载、签名校验与安装也未实现。本轮按你的要求跳过测试验证与 computer-use,后文的输入法、动态文本、120 秒回放和 Instruments 实验均为后续扩展,不是已交付样例的验收结果。

当前 GPUI 产物采用 dev/debug;后续做公平比较需使用其 release 构建,并统一其他路线的优化配置。布局、行高和控件能力也尚未完全一致,因此现有样例适合体验架构与改代码,不能直接据此给框架排性能名次。

1. 先理解「流畅」是哪一种感觉

Section titled “1. 先理解「流畅」是哪一种感觉”

你感受到的流畅,可能来自低输入延迟、均匀的帧间隔、稳定的布局,也可能来自恰当的预加载。 启动器瞬间出现、输入法正确跟手、滚动没有跳动、日志持续输出时还能点击取消,是不同的性能问题。 平均 FPS 不能独自回答它们;一个应用可以多数时间达到高帧率,却在每次打开面板时停顿。 Apple 区分交互响应延迟与运动中的 hitch,并介绍了应用、渲染服务和显示器共同参与的流水线。来源:界面响应模型

先给自己的工作台确定四种观察对象:

  • 唤起:快捷键到工作台可见、输入框可用,需要多久?
  • 操作:打字、选择任务、滚动、取消,是否及时反馈?
  • 吞吐:同时接收日志与 Agent 文本时,处理速度能否跟上?
  • 安静:没有工作时,是否仍在重绘、轮询、解析或唤醒 CPU?

「极致简单」在这里意味着少做无用的工作、明确数据归属、可解释地降级,而不是先删掉所有边界处理。

2. 一次输入如何成为屏幕上的变化

Section titled “2. 一次输入如何成为屏幕上的变化”

下面是理解性能用的分解,真实实现可能合并、并行或重叠这些阶段:

阶段发生的工作常见浪费
输入与排队系统接收键盘、鼠标事件,送到应用事件队列主线程被同步 I/O 或长任务占住
状态更新确定哪条任务、哪段文本、哪个选择状态改变一个字符触发整个会话集合更新
布局求解控件位置、约束、换行和行高重复测量整段历史、布局反馈循环
文本处理字符到字形的 shaping、字体回退、排版对未变化文本重复解析与 shaping
绘制准备生成图层内容、绘制指令或 GPU command重建全部资源、过多离屏效果
GPU 执行栅格化、纹理采样、混合等大面积无效绘制、资源等待
系统合成与其他窗口、系统 UI 合成为最终画面图层与透明效果增加合成负担
显示在可用的显示时机呈现、扫描输出已完成一帧仍需等待呈现时机

CPU 负责输入、业务状态和大量布局工作;GPU 不会替你修复一次遍历百万条消息的操作。 文字绘制也不是「把字符串交给 GPU」这么简单:解析、shaping、字形栅格化与最终绘制是不同步骤。 自定义渲染器也仍然需要窗口、事件循环、输入法、可访问性和系统显示链路。 因此,先定位慢在哪一阶段,再决定是否更换控件或渲染方式。

60 Hz 的刷新间隔约为 16.67 ms,120 Hz 约为 8.33 ms;这是数学上的间隔,不是应用独享的 CPU 时间。 输入可能在一个周期中间到达,CPU 与 GPU 可能流水重叠,系统还有提交截止时间、排队与合成。 一帧的工作预算,不等于从物理按键到像素变化的总延迟。 ProMotion、节能设置、外接显示器和实际刷新策略都会影响观察;设备型号也不能替代测量。 Apple 的响应性文档给出粗略时间指导,同时明确它们不是适用于所有交互的硬性保证。来源:改善响应性

词它说明什么它不能直接说明什么
原生应用使用系统平台能力,或以本机程序交付;必须说明所指范围必然使用系统控件、必然低内存
原生控件AppKit/UIKit 等系统控件参与布局与交互所有绘制都在 CPU、没有 GPU
自定义 UI应用自行规定组件、布局或绘制路径不需要文本系统、输入法与可访问性工作
GPU 加速某些绘制/合成工作由 GPU 完成业务代码、搜索、解析和 I/O 自动变快
WebView用 WebKit 等浏览器组件承载内容或 UI整个应用都是 Electron,或者一定慢
声明式 UI根据状态描述界面,框架协调更新每次变化都必须重画所有像素

SwiftUI、AppKit、自定义 Metal、WKWebView 可以在同一个应用里承担不同职责。 「Rust 对 Swift」「原生对 React」都不足以定义一场公平性能比较。 要先约定同样的工作量、文本、窗口尺寸、更新频率、视觉效果和输入行为。

Zed:昂贵结果复用,比语言标签更值得先学

Section titled “Zed:昂贵结果复用,比语言标签更值得先学”

Zed 的 GPUI 使用定制图形 primitive 与 shader;其 2023 年工程文章也强调系统文本 API 的作用。 文章描述了 shaping 结果缓存与字形图集,让未变化的文字不必每帧重新做昂贵处理。 这说明自定义 GPU UI 仍会利用系统文本能力;并非从字体到按钮都完全另起炉灶。 先把这个思想应用到「不变的历史消息、已完成代码块、未变化的 diff」,再考虑自己写 shader。 这属于对该时期公开实现的学习,不能把文章中的帧率当作自己的产品承诺。来源:GPUI 的图形与文本处理

Zed 后续还专门优化 Metal pipeline;做到了自定义 GPU 并不代表性能工作已经结束。 先阅读问题如何被测到、如何验证修复,再看具体代码技巧。来源:优化 Metal pipeline

Raycast v1 的公开扩展架构是 React 自定义 reconciler 生成受控 UI 树,经通信交给 Swift/AppKit 渲染。 扩展写 React,不意味着它当时在网页里画界面;组件边界也让宿主控制交互与表现。 可借鉴的是「让扩展表达意图和数据,宿主决定有限的展示方式」。来源:2023 年扩展架构

Raycast 2026 年 v2 技术文章披露混合架构:Swift/AppKit 宿主、WKWebView 的 React/TypeScript UI、Node 业务层与 Rust 数据能力。 因此,今天感到 Raycast 流畅,不能仅凭过去的文章归因于「全 AppKit、没有 WebView」。 它的实践也包括预绘、节流、窗口调整等专项工作;本文不把官方自述内存数字当作独立基准。来源:新 Raycast 技术说明

Warp:活跃数据和历史数据不必付同一种成本

Section titled “Warp:活跃数据和历史数据不必付同一种成本”

Warp 的 BlockList 同时表示命令输出块和富内容块;它把活跃可修改区域与历史 scrollback 分开存储。 活跃区域使用可随机修改的 GridStorage,历史使用紧凑的 FlatStorage;全屏终端的 alt screen 又单独处理。 对 Agent UI 的启示:只有当前生成中的消息经常变化,已完成历史不必继续按活跃编辑区维护。 这是适合先在自己的数据模型中实践的优化,不要求采用 WarpUI。来源:Warp Block Model

以下是实验设计建议,并非已经验证的本项目实现。 SwiftUI 会根据状态、环境和可观察数据的依赖更新视图;更新过慢和更新过频都值得测量。来源:SwiftUI 性能分析

  • 状态粒度:任务列表只观察摘要;文本尾部只观察正在变化的消息,避免全局字符串牵动所有行。
  • 身份稳定:消息、任务、block 使用持久 ID;不要在每次 body 计算时重新生成 UUID。
  • 生命周期:选中项、滚动位置和编辑草稿应跟随实体身份,不能因插入一行而移到另一项。
  • 计算位置:搜索排序、文件读取、Markdown 解析不要塞进 body 或同步主线程回调。
  • 并发边界:写了 async 不等于工作已经离开 MainActor;记录实际执行与提交位置。
  • 提交粒度:后台处理形成小的状态变化,再回到 UI 所属执行上下文提交。

List/Table 的 identity 和行内容会影响框架需要做多少工作;优先读 Apple 的实际示例再优化。来源:Demystify SwiftUI performance

长历史首先比较系统 List 与 LazyVStack 的实际行为,不预设任何一个永远更快。 「Lazy」不等于数据自动分页,也不等于全部缓存都会有界;模型、解析树、附件仍可能一直占内存。 可变行高要覆盖中文、英文长行、emoji、代码、折叠块、窗口缩放和字体变化。 高度缓存若存在,至少考虑内容版本、可用宽度、字体/字号等影响因子,避免用旧高度裁掉文字。 追加消息时测试两种行为:用户在底部时跟随;用户已上翻时保持阅读位置并提示有新内容。 只有 trace 证明系统列表仍是瓶颈,才把那一部分替换成 AppKit 列表或更专门的渲染面。

6. 流式输出:先稳住数据,再稳住像素

Section titled “6. 流式输出:先稳住数据,再稳住像素”

模型输出的一个网络 chunk 既不一定是一个 token,也不一定是完整字符、句子或 Markdown block。 解码、消息合并、Markdown 解析与 UI 发布应明确分层,避免每次网络回调都重算全部历史。 原始事件日志保存事实;UI 可以合并显示更新,但不能悄悄丢掉工具完成、审批、错误或取消等事件。

建议实验以下策略,每次只变一个因素:

  • 活跃尾部按一定时间窗或数据量合并,再发布 UI;具体阈值通过输入延迟与积压测量选择。
  • 完成的消息保留解析结果;只重新处理仍可能受后续输入影响的范围。
  • Markdown 允许跨块引用等依赖,不能假定每行都独立;先允许活跃尾部显示纯文本,完成后统一解析。
  • 语法高亮按代码块和版本缓存,避免每个字符导致所有历史代码重新高亮。
  • 手动滚动、输入与取消保持响应,内容刷新没有必要抢占所有可用的主线程时间。

背压指消费者跟不上时,对上游生产速度或接收量施加限制;不能用无限增加队列长度来代替。要记录生产速率、消费速率、队列字节数、最老事件等待时间。 生产快于显示时,可先写有界批次到持久层,UI 从游标追赶;持久层本身也需要容量与失败处理。 不能暂停的子进程输出应持续排空或转存,避免因 UI 慢而让管道填满;转存失败必须显式终止或报错。 淘汰的是可重建的展示缓存,事实事件、工具结果和审计信息按明确保留策略处理。 验收「内存有界」时必须说清是哪一层:UI 窗口、队列、解析缓存、磁盘历史各自的上限不同。

没有新事件就不解析、不重排、不重绘;隐藏窗口也不应为了不可见的光标、动画或计时器持续刷新。 用事件通知替代频繁轮询;任务确实需要后台运行时,记录其成本,不把它混入「空闲 UI」结果。 Mac mini 关心长期常驻开销,MacBook Air 还关心电池与热状态,两者都值得测量。 App Nap 不是替应用主动停止无用工作的理由。来源:Apple Mac 能耗指南 Activity Monitor 的 Energy Impact 是比较线索,不是可直接换算成续航小时或瓦特的实验结论。来源:Activity Monitor 能源栏

工具链是否可用见第一个 Mac 闭环;本文没有启动 Instruments 或运行基准。 每个实验复制下表并填实值;空白项不通过猜测补齐。

环境项本轮记录
日期、实验 ID、代码 commit/版本待记录
Mac 型号、芯片、内存待记录
macOS、Xcode、Swift、Rust、GPUI、React、WebKit/Chromium、Instruments 版本按路线记录;不要把系统版本当工具版本
Release/Debug、优化选项、是否连接 debugger待记录
屏幕、分辨率、缩放、实际/设置刷新率待记录
窗口尺寸、字号、主题、动态效果设置待记录
电源、低电量模式、热状态、主要后台负载待记录
fixture hash、事件数、字节数、随机种子、回放速率待记录
冷/热缓存、预热次数、重复次数、观测工具待记录

主测量采用同一个 Release 构建;Debug 和 Instruments 附加运行用于定位,不能不加说明混入结果。 先固定机器与工作负载,再分别测 Mac mini 与 MacBook Air;跨设备数值不能代替同机前后对照。 保存原始样本和 trace,而不只保留平均值;报告中位数、p95、最大值与样本数。 例如固定 100 个样本时,将数值排序并取第 95 个作为 nearest-rank p95,报告所用算法。 100 次只是起始采样方案,不代表已经足够估计所有长尾;异常样本保留并解释,不静默删除。

指标初始目标或验收方式当前测得
热唤起到可见且可输入p95 < 100 ms;属产品初始目标,需明确测量终点未测
冷启动独立报告分布,先建立基线,不混进热唤起未测
输入回显流式负载下仍可用;建立延迟分布后设门槛未测
滚动/动画 hitch报告次数、持续时间、观察区间与显示刷新条件未测
活跃输出显示延迟事件到 UI 提交、事件到可见分别记录未测
内存报告 RSS/footprint 的具体口径、峰值与稳定区间未测
队列不无界增长;记录峰值字节数与最老事件年龄未测
空闲无周期性业务重算;记录 CPU、唤醒与能耗线索未测

系统刷新率、应用提交次数、实际呈现帧数、卡顿次数分别记录,不把 120 Hz 屏幕写成「应用 120 FPS」。 第一轮没有证据时不承诺固定内存值、零卡顿或所有设备都达到同一帧率。

9. 如何使用 Instruments 得到可解释的证据

Section titled “9. 如何使用 Instruments 得到可解释的证据”
  1. 在实际安装的 Xcode 中选择 Profile,确认构建配置和目标设备;模板名称以安装版本为准。
  2. 先用 Time Profiler 找 CPU 热点、主线程阻塞和重复调用,不从一张峰值截图推断全程表现。
  3. 使用可用的 SwiftUI、Hangs/Hitches 工具关联更新原因与卡顿;旧工具版本没有某条 lane 时如实记录。
  4. 需要时另录 Allocations/内存观察,不在一次采集中叠满工具,以免观察开销淹没差异。
  5. 只有 trace 指向绘制/GPU,才加 Metal/System Trace 等图形工具;普通搜索变慢先看 CPU。
  6. 优化后用同一 fixture、同一流程复测,同时检查输入法、滚动锚点与内容正确性。

Apple WWDC25 介绍了新 SwiftUI instrument 的更新分组和长更新观察方式;这是工具资料,不说明本机已安装对应版本。来源:SwiftUI 与 Instruments GPU 观察另见 Apple 的图形性能文档,不把 GPU idle 简单理解为「没有瓶颈」。来源:分析 Metal 性能

建议将来加少量 OSSignposter 区间:hotkeyReceived → windowReady、eventReceived → statePublished、parseBegin → parseEnd。来源:OSSignposter 每个区间带可关联的实验/会话/批次 ID;避免把用户正文和秘密写入 trace。 windowReady 或 SwiftUI body 完成只是应用内代理指标,不证明这一刻像素已经显示。 可见终点需与画面记录或可用的呈现 trace 对齐;高帧率外部录像可做抽样核对,也要报告采样精度。 屏幕录制本身有开销,不能把录制帧间隔当作应用全部真实帧间隔。

10. 实验一:只有一个 Mac 启动面板

Section titled “10. 实验一:只有一个 Mac 启动面板”

后续扩展:在你选中的样例上增加启动面板交互。 五份样例已经提供窗口和固定事件回放,可从 GPUI 或其他路线直接继续。 增加输入框、固定命令列表、选择状态和打开/隐藏动作;Swift 路线可用必要的 AppKit 接入窗口,GPUI 路线则沿其窗口、焦点和输入 API 实现。 使用固定的本地条目,暂不把网络和模型首次响应时间混进界面基线。 测热唤起前,预先打开一次并完成加载;正式采样 100 次,间隔固定,另外记录首次冷启动。 同时测英文输入、中文输入法组合、方向键选择、Escape 隐藏,以及隐藏后再次唤起的焦点。 验收:功能正确、测量端点明确、原始样本齐全;初始性能目标为热唤起 p95 < 100 ms,未达则定位原因。 产物:环境表、可复现操作步骤、延迟样本、一个 trace;当前状态:基础样例已构建,本节完整启动面板与测量未完成。 学习问题:慢在创建窗口、激活应用、第一次布局、加载数据,还是显示等待?

11. 实验二:固定长历史,加一条持续生成的消息

Section titled “11. 实验二:固定长历史,加一条持续生成的消息”

现有五份样例已使用共同的固定事件数据,支持 1,000 / 10,000 条历史、过滤与追加回放。当前回放每 50 ms 一条、每轮最多 200 条;下面是进一步增加文本和负载类型的实验设计。 扩展 fixture:固定种子、固定字节内容、持久事件 ID 与相对时间;把 fixture hash 写进报告。 继续使用 1,000 / 10,000 两档,并增加代码块、长行、短消息、错误和工具结果的明确比例。 这些数量是压力档位,不是产品容量承诺;必须同时记录总字节数和可见区域大小。 另配 120 秒活跃流,分别以 10/50/100 个事件每秒回放,保存每个事件的原始大小与时间。 回放中的固定步骤:底部观察、上翻历史、输入文字、缩放窗口、回到底部、取消、重新打开会话。 回放时钟与最终状态校验分开:硬件调度可改变到达时刻,但每次逻辑事件顺序和最终内容必须相同。 验收:不丢完成/错误/取消事件,最终内容与 fixture 一致,历史阅读位置不跳,界面仍可操作。 产物:各档位 CPU/内存/队列/更新延迟记录与 trace;当前状态:基础历史和短回放已实现,120 秒多速率回放、富文本负载及性能记录未完成。 学习问题:历史越长,追加一个字符的成本是否也增长?主线程忙在解析、布局还是绘制?

12. 实验三:一次只改变一种优化

Section titled “12. 实验三:一次只改变一种优化”

保留实验二的朴素实现为 A,用相同 fixture 做 B;每项按 A/B/A/B 顺序交替,至少五组完整回放。 第一组只拆分活跃消息状态与历史状态,观察未变化行的更新是否减少。 第二组只加入流式合并发布,观察处理吞吐改善是否换来过大的可见延迟。 第三组只加入已完成消息解析缓存,检查缩放、字体变化后内容与高度仍正确。 第四组测试有界展示窗口/队列与历史分页,记录内存稳定性及跳转旧历史的代价。 根据 trace 决定是否再比较 List、LazyVStack 或局部 AppKit,不同时更换模型、列表和渲染器。 验收:同负载下改善可复现,正确性与输入响应没有退步;差异小于运行波动时结论写「尚不确定」。 产物:每项优化的一页证据,包含收益、成本、失败样本和是否保留;当前状态:未实现、未测。 学习问题:这次减少了多少工作,还是只是把工作搬到另一个线程或更晚发生?

13. 实验四:从现有 GPUI MVP 直接开始

Section titled “13. 实验四:从现有 GPUI MVP 直接开始”

这一节与前三组实验并行,不以完成它们为前提。Rust + GPUI MVP 已使用发布版 GPUI 0.2.2 构建出真实 Metal 应用,你可以直接阅读、运行和修改。 先沿一次点击阅读 Entity、Context、Task 与 notify(),再观察 uniform_list 如何只创建可见行。接着追踪 Rust 如何启动 Swift helper、消费 JSONL 并清理进程,把渲染与 Apple 平台集成放在同一个具体项目中理解。

本机通过 runtime_shaders 避开构建时缺少 Metal 编译工具的问题;这会将着色器编译移到运行时,启动代价尚未测量。记录 GPUI 版本、该 feature、构建配置,以及可访问性与中文输入支持的实现差距。详见五条路线对照。

需要做正式性能对照时,再统一同一台 Mac、fixture、窗口大小、字体、行高、历史数量、流速和操作步骤。当前样例已有共同业务负载,但不是完整的等呈现实验;后续增加 Markdown、输入框或高亮时,也应在参与比较的路线保持相同工作量。 验收方式属于后续计划:先确认相同内容与行为,再比较性能;能力不齐时明确差距,不宣称框架胜出。 产物:现有独立 GPUI 应用与实现成本说明;后续增加等负载报告。当前状态:MVP 已实现并构建,交互与性能未测。 学习问题:真正获得的是更低延迟、更少内存、更强控制,还是更多实现与维护责任?

你应能沿着一次操作讲清:哪个事件改变哪份状态,哪些视图因此更新,哪里发生昂贵工作,哪里可以复用。 你应有可反复回放的固定负载,而不是依赖「今天模型输出得比较快」判断界面进步。 你应能区分用户感觉、应用内代理指标、系统 trace 和真正的可见结果,并知道各自测不到什么。 Harness 接入后继续沿用这套事件和观察方法,把模型/工具耗时与客户端表现分别统计。

学习顺序可以从兴趣直接展开:想接触 Rust 与渲染,就从 GPUI MVP 和 Zed 的文本缓存、pipeline 资料开始;想理解系统控件,就从 Swift 示例和 Apple 的响应流水线、SwiftUI identity 开始。React 原生桥、WKWebView 与 Electron 是另外三种并行入口。 读过一条路线后,给它增加一种任务状态或修改一行的展示,再到另一条路线做相同改动。随后按你遇到的问题选择实验一、二或三;GPUI 不需要等到最后。 Raycast 两代架构和 Warp 数据模型提供阅读对照,已有样例的具体成本见路线对照。 本轮可以立刻开始的动作是:打开现有 GPUI 项目,追踪一条模拟事件如何改变状态并成为画面中的一行,然后亲手改动它。