譯文聲明本文是翻譯文章,文章原作者Kevin J. Lynagh,文章來源:keminglabs.com

原文地址:keminglabs.com/blog/bui

前言

當我研發Finda時,我非常希望它能夠做到快速,最好能在16毫秒內相應所有用戶輸入。

經過認真研究,我們驚訝地發現Finda是用Electron構建的,該框架經常被批評速度緩慢。在本文中,我將重點說明如何在充分利用Electron易於打包、可以訪問複雜操作系統指定API、針對瀏覽器的視覺功能等優點的同時,藉助Rust來最大限度地減少不可預知的延遲現象和解決內存使用過多問題。

關於設計的注意事項

在深入了解技術細節之前,我們首先要了解Finda自身的設計目標。

Finda支持單一交互:用戶輸入內容,它就能找到相應的事物,包括瀏覽器標籤、文字編輯器緩衝區、本地文件、瀏覽器歷史記錄、打開的窗口等。演示視頻請參考:d189ym6tlc5mr2.cloudfront.net 。我們最後的目標是,想要讓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( github.com/ggez/ggez ),這是一個基於SDL的Rust遊戲庫,非常棒。對於我這樣圖形方面的新手來說,我發現這個API是非常友好的。然而我很快就意識到,恐怕要製作一個完整的應用程序,還是需要相當多的基礎工作的。例如,可以給定文本字元串、字體大小和字體。但是,當用戶鍵入時,Finda將突出顯示匹配項:keminglabs.com/blog/bui這就意味著我需要處理多個字體和顏色,並跟蹤每個繪製的子字元串的邊界框,以設置好所有內容。

除了渲染之外,我發現操作系統集成方面也存在著一些困難點:

1、建立一個沒有標題欄、最小化、最大化、關閉按鈕的無邊框窗口;2、後台運行應用程序,不在Dock中顯示;3、通過Quartz Event Services( developer.apple.com/doc )獲得一個「全局熱鍵」。關於第三個困難點,在4小時之後,我設法獲得了關鍵代碼,但我發現我需要通過單獨的一組循環來查找活動鍵盤映射,於是就放棄了這一想法。上述都不是真正的「遊戲問題」,並且這看起來並不像切換到另一個框架,例如GLUT(OpenGL,opengl.org/resources/li )會比ggez(SDL)要好。Electron:之前我已經使用Electron構建過應用程序,而且我知道它會符合Finda的要求。瀏覽器最初是為了布局文本而設計的,Electron提供了廣泛的窗口選項( github.com/electron/ele )和全局快捷方式的一行API( github.com/electron/ele )。

結構

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後,就有不同的字體、顏色和間距來區分:keminglabs.com/blog/bui 。對於像Finda這樣的完全數據驅動的應用程序來說,具有視覺剪影和播放的能力至關重要。這樣一來,就可以通過在圖形設計工具周圍推動像素,來實現基於搜索的交互。對我而言,如果沒有Electron和Rust,我就無法製作出Finda的原型並發布。這二者都是非常棒的技術,在此要感謝所有為他們做出貢獻的人。

總結

Electron可以輕鬆構建和分發桌面應用程序,讓我擺脫繁瑣的字體渲染細節、低級操作系統熱鍵和窗口API。

Rust使得編寫過程快速而安全,低級別的數據結構在Rust中就變得很容易,並且我在其引導下,開始以JavaScript/ClojureScript hat的方式來思考內存和性能的相關問題。最後,我要感謝Nikita Prokopov、Saul Pwanson、Tom Ballinger、Veit Heller、Julia Evans和Bert Muthalaly對本文提出的反饋意見。

原文鏈接:

本文翻譯自:keminglabs.com

如若轉載,請註明出處:keminglabs.com安全客 - 有思想的安全新媒體

推薦閱讀:

相关文章