使用Rust构建一个快速的Electron APP

 

前言

当我研发Finda时,我非常希望它能够做到快速,最好能在16毫秒内相应所有用户输入。
经过认真研究,我们惊讶地发现Finda是用Electron构建的,该框架经常被批评速度缓慢。
在本文中,我将重点说明如何在充分利用Electron易于打包、可以访问复杂操作系统指定API、针对浏览器的视觉功能等优点的同时,借助Rust来最大限度地减少不可预知的延迟现象和解决内存使用过多问题。

 

关于设计的注意事项

在深入了解技术细节之前,我们首先要了解Finda自身的设计目标。
Finda支持单一交互:用户输入内容,它就能找到相应的事物,包括浏览器标签、文字编辑器缓冲区、本地文件、浏览器历史记录、打开的窗口等。
演示视频请参考:https://d189ym6tlc5mr2.cloudfront.net/video/2018_02_16_finda_demo.mp4
我们最后的目标是,想要让Finda感觉不像是应用程序,更像是Command-Tab(macOS默认应用程序切换工具),只作为操作系统的一部分,在需要时立即出现,在完成相应功能后就可以消失。
过程中无需菜单、窗口、按钮或任何类型的本地用户界面。针对于Finda的互动,我们只需要以下几点:
1、不管在哪个应用程序的界面上,全局快捷方式都可以直接将Finda全屏显示;
2、捕获输入的按键;
3、呈现搜索结果。
在不使用的情况下,Finda应该隐藏在后台。

 

不使用Electron的替代方案

鉴于上述要求,我重新考虑了我的选项。
Native OS X:我很早就想到了这一方案,其原因有两个:
1、我想要将Finda移植到Windows和Linux上,因为beta测试者在问他们是否可以为他们现有平台购买一个版本。
2、为了使用XCode进行本地开发,我必须升级macOS,这一升级过程几乎肯定会在一定程度上破坏我电脑的环境。
Game-like:我之前曾经基于此方案写过一个像素着色器,经过实际使用,游戏的速度非常快,也许这一方案能够有效。经过研究,我决定尝试使用ggez( https://github.com/ggez/ggez ),这是一个基于SDL的Rust游戏库,非常棒。
对于我这样图形方面的新手来说,我发现这个API是非常友好的。然而我很快就意识到,恐怕要制作一个完整的应用程序,还是需要相当多的基础工作的。
例如,可以给定文本字符串、字体大小和字体。但是,当用户键入时,Finda将突出显示匹配项:
https://keminglabs.com/blog/building-a-fast-electron-app-with-rust/highlighting.mp4
这就意味着我需要处理多个字体和颜色,并跟踪每个绘制的子字符串的边界框,以设置好所有内容。
除了渲染之外,我发现操作系统集成方面也存在着一些困难点:
1、建立一个没有标题栏、最小化、最大化、关闭按钮的无边框窗口;
2、后台运行应用程序,不在Dock中显示;
3、通过Quartz Event Services( https://developer.apple.com/documentation/coregraphics/quartz_event_services?language=objc )获得一个“全局热键”。
关于第三个困难点,在4小时之后,我设法获得了关键代码,但我发现我需要通过单独的一组循环来查找活动键盘映射,于是就放弃了这一想法。
上述都不是真正的“游戏问题”,并且这看起来并不像切换到另一个框架,例如GLUT(OpenGL,https://www.opengl.org/resources/libraries/glut/ )会比ggez(SDL)要好。
Electron:之前我已经使用Electron构建过应用程序,而且我知道它会符合Finda的要求。浏览器最初是为了布局文本而设计的,Electron提供了广泛的窗口选项( https://github.com/electron/electron/blob/master/docs/api/browser-window.md )和全局快捷方式的一行API( https://github.com/electron/electron/blob/master/docs/api/global-shortcut.md )。

 

结构

Electron用语用户界面层,Rust作为二进制执行并处理所有其他内容
当Finda打开,并按下一个键时:
1) 浏览器调用一个文档onKeyDown监听器,该监听器将JavaScript keydown事件翻译为表示事件的普通JavaScript对象,就像是:

{name: "keydown", key: "C-g"}

2) 这个JavaScript对象被传递给Rust(之后会传递更多),Rust返回另一个表示整个应用程序状态的普通JavaScript对象:

{ 
  query: "search terms",
  results: [{label: "foo", icon: "bar.png"}, ...],
  selected_idx: 2,
  show_overlay: false,
  ...
}

3) 然后将这个JavaScript对象传递给React.js,它使用<divs> 和<ols>将器实际呈现给DOM。
在这个架构中,有两点需要注意:
首先,Electron没有维护任何一种状态。从它的角度来看,整个应用程序都是最近事件的函数。这一点是可能的,因为Rust始终维持Finda的内部状态。
其次,这些步骤发生在每个用户交互(keyup和keydown)过程中。因此,为了满足性能要求,所有三个步骤必须在16ms内完成。

 

INTEROP

其中比较有趣的是第二个步骤,如果从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> {
  let scope = call.scope;

  let event = &call.arguments.require(scope, 0)?.check::<JsObject>()?;

  let event_type: String = event
      .get(scope, "name")?
      .downcast::<JsString>()
      .unwrap()
      .value();

JavaScript有几种语义不能完美映射到Rust的语言语义(例如,参数对象和动态变量)。
因此,Neon不会试图将JS调用映射到Rust函数签名,而是将函数传递给一个Call对象,从中可以提取细节。 由于我已经编写了这个函数的调用(JS)端,我知道第一个参数是这里唯一的参数,它是一个JavaScript对象,并且始终有一个与字符串值关联的名称键。
然后,可以使用此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),
)?;

在这里,我们首先创建JavaScript对象,该对象将返回到Electron并将一些键与某些基本类型相关联。
返回结果(一个对象类型数组)需要更多的限制:数组大小需要事先声明、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中)中,我们可以看到,这是一个单一keypress的典型曲线:


其中的每个步骤都被标记:1)将按键转换为事件,2)在Rust中处理事件,3)使用React渲染结果。
首选需要注意的是顶部的绿色条,这表明所有这些都在14毫秒之内完成。
其次注意的是Rust的Interop,在其中高亮显示的Native.step()调用仅在不到1毫秒之内就进行完成。
我尝试在查询中添加一个字母,那么这一特殊的keydown事件会导致在Finda中进行如下步骤,而这些步骤都是在1毫秒内完成的:
1、对所有我打开的窗口、Emacs缓冲区、浏览器约20000页标题及URL、~/work/、~/Downloads/和~/Dropbox/文件夹进行正则表达式搜索。
2、根据质量启发式(匹配数量、是否出现在词语边界等)对所有这些结果进行排序。
3、将前50个结果转换为JavaScript并返回。
如果你不相信能有这么快的速度,可以自己下载并尝试。针对不同的事件,其性能数据也有所不同,但这种追踪是非常典型的:Rust需要几毫秒来完成实际工作,大部分时间都是在进行渲染,并且整个JavaScript执行都会在16毫秒内完成。

 

对性能的继续研究

考虑到这些性能指标,我们可以通过删除React(也可能是整个DOM)来缩短响应时间,而不是使用<canvas>元素手动处理布局并进行渲染。
然而,如果不考虑人类是否能够区分出15毫秒的响应和5毫秒的响应之间的区别,还是存在一些严重的收益递减情况的。很可能有某些低级别的操作系统、图形驱动程序、LCD硬件影响了响应时间。
另外,在Electron中,除了易于使用的内置分析 工具之外,DOM和CSS提供了大量的Runtime延展性。打开Inspector后,就有不同的字体、颜色和间距来区分:https://keminglabs.com/blog/building-a-fast-electron-app-with-rust/devtools.mp4
对于像Finda这样的完全数据驱动的应用程序来说,具有视觉剪影和播放的能力至关重要。这样一来,就可以通过在图形设计工具周围推动像素,来实现基于搜索的交互。
对我而言,如果没有Electron和Rust,我就无法制作出Finda的原型并发布。这二者都是非常棒的技术,在此要感谢所有为他们做出贡献的人。

 

总结

Electron可以轻松构建和分发桌面应用程序,让我摆脱繁琐的字体渲染细节、低级操作系统热键和窗口API。
Rust使得编写过程快速而安全,低级别的数据结构在Rust中就变得很容易,并且我在其引导下,开始以JavaScript/ClojureScript hat的方式来思考内存和性能的相关问题。
最后,我要感谢Nikita Prokopov、Saul Pwanson、Tom Ballinger、Veit Heller、Julia Evans和Bert Muthalaly对本文提出的反馈意见。

原文链接:

(完)