原文地址:keminglabs.com/blog/buildi…
原文作者:keminglabs.com/
发布时间:2018年3月18日
当我构建Finda时,我希望它能够快速,特别是在16毫秒内响应所有用户的输入。
考虑到这个目标,你可能会惊讶于Finda是用Electron构建的,而Electron是一个经常被人诟病的框架,因为它的速度是相反的。
在这篇文章中,我将解释Finda如何使用Rust来利用Electron的优势(易于打包、访问复杂的操作系统特定API、浏览器的可视化功能),同时将其不可预测的延迟和内存使用量的缺点降到最低。
设计考虑
在深入探讨技术细节之前,先了解一下Finda本身的设计目标。
Finda支持单一的交互。你输入东西,它就能找到东西 -- 浏览器标签,文本编辑器缓冲区,本地文件,浏览器历史记录,打开的窗口,等等。
我们的目标是让Finda感觉不像一个应用程序,而更像Command -Tab,只是操作系统本身的一部分,在需要的时候立即出现,然后很快消失。
不需要菜单,多个窗口,按钮,或者任何类型的本地UI。真的,Finda的交互只需要。
- 一个 "全局快捷键 "来提升Finda的全屏,不管当前的焦点程序是什么。
- 捕捉输入法
- 呈现搜索结果
否则,芬达就应该隐身在后台寒暄。
非Electron替代品
鉴于这些最低要求,我考虑了我的选择。
原生OS X:我很早就决定反对这个选项,原因有二。
1)我想选择将Finda移植到Windows和(我不相信我会这么说)Linux上,因为测试者问他们是否可以为各自的平台购买一个版本。
2)要使用XCode进行原生开发,我必须升级OS X,这几乎肯定会以新的不同方式破坏我的电脑,而不是它目前的破坏方式(我已经和平解决了)。
游戏式的:我这辈子只写过一个像素着色器(从白色到黑色的渐变),但嘿,游戏速度很快,也许这也行?我探索了几个选项,决定使用ggez尝试一个原型,这是一个建立在SDL之上的奇妙的Rust游戏库。
我发现对于像我这样的图形新手来说,API是可以接近的(设置颜色和绘制矩形,而不是由结构体组成的管道,这些结构体需要字节的着色器......),但很快我就意识到,这些基元仍然需要我做相当多的工作才能编译成一个体面的应用程序。
例如,可以给定字符串、字体大小和字体来渲染文本。但是Finda会在你打字的时候高亮显示匹配的内容。
这就意味着我需要处理多种字体和颜色 以及跟踪每个绘制的子串的边界框来布局所有内容
除了渲染之外,我在操作系统集成方面也遇到了困难。我发现很难。
- 建立一个 "无色 "窗口(没有标题栏和最小化/最大化/关闭红绿灯按钮的窗口)。
- 在后台运行应用程序,而不出现在Dock中。
- 通过Quartz事件服务实现 "全局热键"(4个小时后,我设法得到了键码,但当我意识到我需要通过一套单独的圈圈来查找活动键图,将这些键码转化为快捷键时,我放弃了。
这些都不是真正意义上的 "游戏 "问题,而且换成GLUT(OpenGL)这样的框架似乎也不会比gez(SDL)好到哪里去。
Electron:我之前用Electron构建过应用,我知道它能满足Finda的要求。浏览器最初是为了排版文本而设计的,Electron提供了广泛的窗口选项和全局快捷键的单行API。
唯一未知的是性能,这也是本文余下部分的主题。
架构
架构上的tl;dr是,Electron被用作用户界面层,而Rust二进制处理其他一切。
当Finda被打开并按下一个键时。
- 浏览器调用一个document onKeyDown监听器, 它将JavaScript keydown事件翻译成一个普通的JavaScript对象来表示该事件; 类似:
{name: "keydown", key: "C-g"}
- 这个JavaScript对象被传递给Rust(后面会详细介绍),Rust返回另一个代表整个应用程序状态的纯JavaScript对象。
{
query: "search terms",
results: [{label: "foo", icon: "bar.png"}, ...],
selected_idx: 2,
show_overlay: false,
...
}
3)然后将这个JavaScript对象传递给React.js,React.js实际上是使用<divs>
s和<ols>
s将其渲染到DOM。
关于这个架构有两点需要注意。
首先,Electron并不维护任何一种状态--从它的角度来看,整个应用都是最近事件的函数。这只是因为Rust方面维护了Finda的内部状态。
其次,这些步骤发生在每一次用户交互(keyup和keydown)中。所以,为了满足性能要求,这三个步骤加在一起必须在16ms以内完成。
互操作
有趣的地方在第2步--从JavaScript中调用Rust是什么样子的呢?
我正在使用超赞的Neon库用Rust构建一个Node.js模块。
从Electron方面来看,感觉就像调用其他类型的包一样。
var Native = require("Native");
var new_app = Native.step({name: "keydown", key: "C-g"});
这个函数的Rust方面有点复杂。让我们分块进行。
pub fn step(call: Call) -> JsResult<JsObject> {
让scope = call.scope.reguments.require(scope, 0)?
let event = &call.arguments.require(scope, 0)?.check::<JsObject>()?
let event_type.<JsObject>(); let event_type: String = event
.get(scope, "name")?
.downcast::<JsString>()
.unwrap()
.value();
JavaScript有几个语义没有整齐地映射到Rust的语言语义上(例如,参数对象和臭名昭著的this动态变量)。
因此,Neon不试图将JS调用映射到Rust函数签名,而是将你的函数传递给一个单一的Call对象,从中可以提取细节。由于我已经编写了这个函数的调用(JS)方面,我知道第一个也是唯一的参数将是一个JavaScript对象,它总是有一个与字符串值相关联的name
键。
然后,这个event_type
字符串可以用来引导其余的 "翻译 "从JavaScript对象到适当的Finda::Event
枚举变体。
match event_type.as_str() {
"blur" => finda::step(&mut app, finda::Event::Blur),
"hide" => finda::step(&mut app, finda::Event::Hide),
"show" => finda::step(&mut app, finda::Event::Show),
"keydown" => {
let s = event
.get(scope, "key")?
.downcast::<JsString>()
.unwrap()
.value();
finda::step(&mut app, finda::Event::KeyDown(s));
}
...
这些分支中的每一个都会调用finda::step
函数,该函数实际上会根据事件更新应用程序的状态--改变查询并返回相关结果,打开选定的结果,隐藏Finda,等等。
(我将在未来的博客文章中写更多关于Rust的细节--如果你想得到通知,请注册我的邮件列表或关注@lynaghk)。
在该应用状态被更新后,需要将其返回到Electron端进行渲染。这个过程看起来很相似,但方向不同。将Rust数据结构转化为JavaScript数据结构:
let o = JsObject::new(scope);
o.set("show_overlay", JsBoolean::new(scope, app.show_overlay))?;
o.set("query", JsString::new(scope, &app.query).unwrap())?;
o.set(
"selected_idx",
JsNumber::new(scope, app.selected_idx as f64),
)?;
在这里,我们首先创建将返回给Electron的JavaScript对象,然后将键与一些基元类型关联起来。
返回结果(一个对象数组)需要多走一些弯路。数组的大小必须事先声明,并明确枚举Rust结构,但总体来说还不错。
let rs = JsArray::new(scope, app.results.len() as u32);
for (idx, r) in app.results.iter().enumerate() {
let jsr = JsObject::new(scope);
jsr.set("label", JsString::new(scope, &r.label).unwrap())?;
if let Some(ref icon) = r.icon {
jsr.set("icon", JsString::new(scope, &icon.pathname).unwrap())?;
}
rs.set(idx as u32, jsr)?;
}
o.set("results", rs)?;
最后,在这个函数的最后,返回JavaScript对象。
Ok(o)
而Neon则处理所有细节,将其传递给JavaScript端的调用者。
性能验证
那么,所有这些机器在实践中的表现如何呢?下面是一个典型的单次按键跟踪,可以在Chrome DevTools(Electron内置)的 "性能 "选项卡中看到。
每一步都有标签:1)将按键转化为事件,2)在Rust中处理事件,3)用React渲染结果。
首先要注意的是顶部的绿条,它显示所有这些都发生在14ms以内。
第二件要注意的事情是哦,dang Rust真快! Rust interop(第2节,flamegraph中高亮显示的是实际的Native.step()调用)在不到一毫秒的时间内完成。
这个特定的keydown事件对应于在我的查询中添加一个字母,这意味着在这一毫秒内Finda:
- 对我所有打开的窗口、Emacs缓冲区、浏览器历史记录中的约20,000个页面标题和URL,以及我的
~/work/
、~/Downloads/
和~/Dropbox/
文件夹中的所有文件名进行搜索。 - 根据质量启发式统计(匹配数量、是否出现在词的边界等)对所有这些结果进行排序。
- 将前50个结果翻译成JavaScript并返回。
如果你不相信我说它有这么快,你可以自己下载试试。
(当然,我的 2013 年 Macbook Air 的 SSD 速度还不够快,甚至无法在一毫秒内列举所有这些文件--Finda 透明地建立和维护一个索引。更多内容将在以后的文章中介绍。)
大部分时间都花在了渲染上。大约8毫秒用于React(flamegraph底部的渲染条),4毫秒用于浏览器本身的布局(紫色)、绘制(绿色)和从磁盘/缓存加载缩略图(最右边的黄色)。
具体的性能数字在不同的事件中有所不同,但这个跟踪是典型的。Rust需要几毫秒来完成 "实际工作",大部分时间被渲染占用,而整个JavaScript的执行始终在16毫秒内完成。
性能反思
考虑到这些性能数据,有一种观点是通过放弃React(也许完全放弃DOM)来减少响应时间,而用<canvas>
元素手动处理布局和渲染。
然而,有一些严重的收益递减。抛开人类是否能区分15ms的响应和5ms的响应不谈,很可能是一些低级别的操作系统/图形驱动/LCD物理学主导了这个规模下的实际响应时间。
另一个角度是,我们有额外的预算--我们可以把预算 "浪费 "在一个较慢的渲染路径上,如果这个路径有其他好处的话。而在Electron的情况下,确实如此:除了我们刚刚看到的易于使用的内置剖析工具外,DOM和CSS还提供了精彩的运行时可塑性。打开检查器,很容易就能玩出不同的字体、颜色和间距。
对于像Finda这样一个完全由数据驱动的应用来说,拥有视觉草图和在生产媒介中玩耍的能力是至关重要的--通过在图形设计工具上推送像素来有效地设计一个基于搜索的交互原型是不可能的。
也许一个对OpenGL了如指掌的人可以在Emacs中完成这样的草图绘制,或者一个游戏开发人员可以在Unity中把它组装起来。
对我来说,如果没有Electron和Rust,我就无法想象、原型和发布Finda。它们是非常了不起的技术,所以非常感谢所有为它们做出贡献的人。
综合
事实证明,Electron和Rust很适合Finda的设计限制。
Electron使构建和发布桌面应用变得容易,使我免于字体渲染和低级操作系统热键和窗口API的繁琐细节。
Rust让我可以轻松地编写快速、安全、低级的数据结构,并鼓励我以一种通常戴着JavaScript / ClojureScript帽子时都会忽略的方式来思考内存和性能。
它们结合在一起,形成了一个强大的组合。试试Finda,亲自体验一下它的速度。
如果你在自己的项目中尝试了这种Rust/Electron的混合方法,请给我写信! 如果你在自己的项目中尝试了这种Rust/Electron混合方法,请给我写信!我很乐意听到你的工作,并尽我所能提供帮助。
更多阅读
-
查看Dan Luu在测量从1970年代到今天的计算机的输入延迟方面的出色工作。
-
查看 Neon Github repo 或 @rustneon,了解如何使用 Rust 构建 Node.js 扩展。
-
Xi Editor是一个开源的Rust文本编辑器,强调性能。
谢谢
感谢Nikita Prokopov、Saul Pwanson、Tom Ballinger、Veit Heller、Julia Evans和Bert Muthalaly对本文的周到反馈。
喜欢这篇文章吗?订阅Kevin的邮件列表。
通过www.DeepL.com/Translator (免费版)翻译