Electron製アプリの起動速度を1,000ミリ秒速くする方法

Electron製アプリの起動速度を1,000ミリ秒速くする方法
JavaScriptの読み込みはめっちゃ遅い / require() は必要になるまで呼び出すな / V8 snapshotsを使う
英語で先に書いてから日本語訳しています ⇒ English version is available here.
こんにちは、TAKUYAと申します。InkdropというMarkdownノートアプリを開発しています。このアプリはElectronで作っています。Electronは、NodeJSとChromium(ブラウザ)ベースでクロスプラットフォームなデスクトップアプリが開発できるフレームワークです。このフレームワークは基本的に素晴らしいです。なぜならあなたはネイティブ用のフレームワークを学ぶ必要がなく、JavaScript、HTMLとCSSでアプリが作れるからです。もしあなたがウェブデベロッパーだったら、手軽にアプリが作れるでしょう。一方で、多くの人がElectronの弱い点についても言及しています。それはアプリの起動速度が遅い傾向にある、という点です。僕のアプリも、ユーザさんから「起動速度が遅い」という苦情を受けて、同じ問題に直面していました。起動速度が遅いとイライラしますよね。でも今回、この問題が解決できて僕はすごく嬉しいです。アプリのTTI(Time to Interactive = 操作できるようになるまでの時間)は4秒から3秒に縮めることに成功しました(手持ちのmac上で計測)。あえて「1秒」ではなく「1,000ミリ秒」高速化したとここでは言わせてください。なぜならこの違いは大きく、そしてその結果を得るためにメチャクチャ苦労したからです。以下の比較映像を覧ください:

前のバージョンよりも明らかに速いことがお分かりになると思います。まずアプリのメインウインドウの表示が少し速く、更にアプリバンドルがブラウザウィンドウで読み込み完了するまでの時間も速くなっています。現在ベータテスト中ですが、すでにユーザさんからは喜びのメッセージを頂いています。嬉しい。早くリリースしたい。
他の多くのElectronアプリ開発者も、このような起動速度が遅い問題に悩まれているのではないでしょうか。本稿では、自分がどのようにして解決したのかシェアしたいと思います。さあ、Electronアプリを高速化しましょう!
JavaScriptの読み込みはめっちゃ遅い
なぜElectronのアプリの起動は遅い傾向にあるのでしょうか?起動における大きなボトルネックはJavaScriptの読み込みプロセスにあります。Develoer Toolsのパフォーマンス分析ツールで、どのようにあなたのアプリバンドルが読み込まれているのか覗き見ることが出来ます。
- パフォーマンス分析ツールの使い方: Get Started With Analyzing Runtime Performance | Chrome DevTools
Cmd-Eまたは赤いRecordボタンを押して、ランタイムパフォーマンスの記録を開始して、アプリを再読込します。すると以下のようなタイムラインが表示されます:

上図のように、タイムライン上ではモジュールの `require` にとても時間がかかっている事が分かります。どれぐらい時間がかかるかは、あなたのアプリの依存モジュール/ライブラリの数や大きさによって違います。
拙作アプリの場合は、プラグイン機能や、拡張可能なMarkdownエディタ、Markdownレンダラ、その他いろいろな機能を実現するために大量の依存モジュールがありました。スピードのためにそれらを削ぎ落とすのは難しく見られました。
もしあなたのプロジェクトは比較的新しくてシンプルであれば、パフォーマンスを意識してどのライブラリを使うか注意深く選定しましょう。より少ない依存は常により良いパフォーマンスをもたらします。
require() は必要になるまで呼び出すな
大きな読み込み時間を避けるために出来る最初の対策は、依存モジュールの require()
コールを、本当に必要になる時まで遅らせることです。
改善後、僕のアプリのメインウインドウは少しだけ速く表示されるようになりました。これはなぜかというと、 jsdom
というモジュールをメインプロセス内で読み込んでいました。これはHTMLをパースするために追加したのですが、非常に巨大で読み込みに数百ミリ秒かかる事が分かりました。
この問題の解決法は主に2つあります。
1. より軽い代替手段を使う
もしライブラリの読み込みが重いと感じたら、より小さい代替ライブラリを探しましょう。僕の場合、最終的に jsdom
じゃなくても DOMParser
というWeb APIを使えばHTMLのパースが出来ることに気づきました。HTMLのパースはライブラリ無しで以下のようにできます:
const dom = new DOMParser().parseFromString(html, 'text/html')
2. コードの評価時点でrequireしない
ライブラリはコード冒頭で require
するのが通例です:
import { JSDOM } from 'jsdom'
export function parseHTML(html) { const dom = new JSDOM(html); // ...}
その代わり、用意した処理が呼び出された時に初めて require
されるようにするのです:
var jsdom = null
function get_jsdom() { if (jsdom === null) { jsdom = require('jsdom') } return jsdom}
export function parseHTML(html) { const { JSDOM } = get_jsdom() const dom = new JSDOM(html); // ...}
こうすれば、dependencyを落とさなくても起動時間を短縮することが出来るでしょう。ただし、Webpackなどでアプリバンドルをビルドしている人は注意です。依存ライブラリをアプリバンドルに同梱しないように設定する必要があります。
V8 snapshotsを使う
これで僕のアプリの起動は200〜300msぐらい速くなりました。それでも、レンダラプロセスの読み込みは依然として遅いままです。多くの依存モジュールは起動後すぐに必要になるので、読み込みを遅らせるのは難しいです。
ChromiumはあなたのJSファイルやモジュール群を読み込んで評価して実行する必要があります。これはたとえローカルのファイルシステムからの読み込みであっても、あなたの想像以上に時間がかかります。拙作アプリの場合は1〜2秒かかります。多くのネイティブアプリはその必要がありません。なぜならそれらはすでにバイナリコードであり、OSはプログラムを機械語に翻訳しなくてもそのまま実行できるからです。
ChromiumのJavaScriptエンジンはV8です。そしてV8には高速化テクニックがあります — — V8 snapshotsです。V8 snapshotsは、Electronアプリの任意のJavaScriptコードを実行した結果の状態、つまりGC後にメモリに残ったデータのヒープをシリアライズしてバイナリファイルに保存できます。
Atom Editorは3年前にこのV8 snapshotsを使って起動速度の改善に成功しました:
Atomチームはこの手法によって500ミリ秒高速化しています。これは期待できそうです。
V8 snapshotsのはたらき
結論から先にいうと、この方法はめっちゃ上手く行きました。例えば、 remark-parse
というライブラリの読み込みは1ms以下になりました。
v8 snapshots無しの場合:

v8 snapshotsから読み込んだ場合:

Cool!!!
そして browser-main.js
の読み込み評価は…

だったのが、

と高速化しました。効果絶大ですね。
こちらがPreferencesウインドウを表示させた時の比較映像です。V8 snapshotsがどれぐらいアプリバンドルの読み込みを高速化したのかが分かると思います:

ではどうやって実際にV8 snapshotsからモジュール群を読み込んでいるのでしょうか?あなたのカスタムV8 snapshotsを同梱したElectronアプリでは、 snapshotResult
という変数がglobalスコープに存在します。この変数には、プリロードされたJavaScript実行結果のキャッシュデータが入っています:

これらのモジュール群は require()
を呼ばなくても使えるという訳です。これがV8 snapshotsアリだと速い理由です。
では次のセクションで、実際にカスタムV8 snapshotsを作成する方法について説明します。
V8 snapshotsを生成する方法
以下のステップを行います:
- ツールのインストール
electron-link
でJSソースコードを前処理する- V8 snapshotsを
mksnapshot
で生成する - スナップショットをElectron内で読み込む
このチュートリアルに向けてサンプルのプロジェクトを作りました。以下のリポジトリをご参照ください:
- inkdropapp/electron-v8snapshots-example: An example for using custom v8 snapshots in an Electron app
ツールのインストール
以下のパッケージが必要です:
- electron: Runtime
- electron-link: Preprocess the JavaScript source files
- electron-mksnapshot: Download the
mksnapshot
binaries
mksnapshot
はV8 snapshotsを生成するためのツールで、electron-link
を使って前処理したJavaScriptのファイルを食わせます。electron-mksnapshot
はElectronと互換性のある mksnapshot
用のバイナリファイルをダウンロードします。もしあなたは古いElectronを使っている場合は、ELECTRON_CUSTOM_VERSION
環境変数を設定してElectronのバージョンを指定しながらインストールします:
# Install mksnapshot for Electron v8.3.0ELECTRON_CUSTOM_VERSION=8.3.0 npm install
バイナリのダウンロードは時間がかかります。以下のようにElectronのミラーをELECTRON_MIRROR
環境変数で指定してやると少し速くなります:
# Electron mirror for ChinaELECTRON_MIRROR="https://npm.taobao.org/mirrors/electron/"
electron-linkでJavaScriptファイルを前処理する
electron-link
はJavaScriptのファイルをスナップショット化可能なように変換してくれるツールです。なぜ変換が必要かというと、V8 context内ではNodeJSのビルトインモジュールとかネイティブモジュールが読み込めないからです。もしあなたのアプリがシンプルなら、アプリのエントリポイントを渡せばよいでしょう。自分の場合はアプリが複雑すぎて、エントリポイントを渡しても生成に失敗してしまいました。なので、以下のようなスナップショット生成用のJSファイルを別途用意しました。必要なライブラリを require
するだけの簡単なファイルです:
// snapshot.jsrequire('react')require('react-dom')// ...require more libraries
そして snapshot.js
という名前でプロジェクトディレクトリに保存します。次に、以下のように electron-link
を使ってこのJSファイルを前処理します:
const vm = require('vm')const path = require('path')const fs = require('fs')const electronLink = require('electron-link')
const excludedModules = {}
async function main () { const baseDirPath = path.resolve(__dirname, '..')
console.log('Creating a linked script..') const result = await electronLink({ baseDirPath: baseDirPath, mainPath: `${baseDirPath}/snapshot.js`, cachePath: `${baseDirPath}/cache`, shouldExcludeModule: (modulePath) => excludedModules.hasOwnProperty(modulePath) })
const snapshotScriptPath = `${baseDirPath}/cache/snapshot.js` fs.writeFileSync(snapshotScriptPath, result.snapshotScript)
// Verify if we will be able to use this in `mksnapshot` vm.runInNewContext(result.snapshotScript, undefined, {filename: snapshotScriptPath, displayErrors: true})}
main().catch(err => console.error(err))
スナップショット化可能なJSファイルが<PROJECT_PATH>/cache/snapshot.js
に出力されます。この前処理されたJSには、依存ライブラリのソースが直接含まれています。ちょうどWebpackでバンドルを生成した時のような感じです。違いは、禁止モジュール(NodeJSの path
など)のrequireが遅延されて、V8 context内で読み込まれないようになっている点です。(詳しくは electron-linkのドキュメントを参照)
mksnapshotで
V8 snapshotsを生成する
これでスナップショット生成可能なスクリプトが出来ました。以下のスクリプトを実行してV8 snapshotsを生成します:
const outputBlobPath = baseDirPathconsole.log(`Generating startup blob in "${outputBlobPath}"`)childProcess.execFileSync( path.resolve( __dirname, '..', 'node_modules', '.bin', 'mksnapshot' + (process.platform === 'win32' ? '.cmd' : '') ), [snapshotScriptPath, '--output_dir', outputBlobPath])
スクリプトの全文はこちらを参照してください。
ついにv8_context_snapshot.bin
というスナップショットのファイルが手に入りました。
スナップショットをElectron内で読み込む
早速生成したV8 snapshotsをElectronアプリ内に読み込んでみましょう。ElectronはデフォルトのV8 snapshotsをバイナリ内に持っています。これを自分のものに置き換えます。以下がV8 snapshotsの存在するパスです:
- macOS:
node_modules/electron/dist/Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Resources/
- Windows/Linux:
node_modules/electron/dist/
v8_context_snapshot.bin
を上記ディレクトリにコピーしてください。こちらがコピースクリプトです。アプリを起動すると、snapshotResult
という変数がglobalスコープにあるはずです。コンソールにsnapshotResult
と打って確かめてください。
これでElectronアプリ内にカスタムのスナップショットを読み込むことに成功しました。さて、どうやって使えばよいでしょうか。
以下のように、デフォルトの require
関数をオーバーライドする必要があります:
const path = require('path')
console.log('snapshotResult:', snapshotResult)if (typeof snapshotResult !== 'undefined') { console.log('snapshotResult available!', snapshotResult)
const Module = require('module') const entryPointDirPath = path.resolve( global.require.resolve('react'), '..', '..', '..' ) console.log('entryPointDirPath:', entryPointDirPath)
Module.prototype.require = function (module) { const absoluteFilePath = Module._resolveFilename(module, this, false) let relativeFilePath = path.relative(entryPointDirPath, absoluteFilePath) if (!relativeFilePath.startsWith('./')) { relativeFilePath = `./${relativeFilePath}` } if (process.platform === 'win32') { relativeFilePath = relativeFilePath.replace(/\\/g, '/') } let cachedModule = snapshotResult.customRequire.cache[relativeFilePath] if (snapshotResult.customRequire.cache[relativeFilePath]) { console.log('Snapshot cache hit:', relativeFilePath) } if (!cachedModule) { console.log('Uncached module:', module, relativeFilePath) cachedModule = { exports: Module._load(module, this, false) } snapshotResult.customRequire.cache[relativeFilePath] = cachedModule } return cachedModule.exports }
snapshotResult.setGlobals( global, process, window, document, console, global.require )}
上記をライブラリ群の読み込み前に実行するようご注意ください。正しく動作すれば、”Snapshot cache hit: react” のような出力がコンソールに出るはずです。
サンプルプロジェクトでは、以下のような結果が得られるはずです:

やったね!あなたのアプリの依存モジュールはV8 snapshotsから読み込まれました。
アプリの初期化処理もスナップショット化する
依存モジュールの読み込みだけでなく、アプリのインスタンス生成処理もスナップショット化できます。Atomがやっているように。アプリの初期化処理の一部は、例えばユーザ設定の読み込みなどのように動的ですが、静的な処理があるでしょう。それらはスナップショット化可能です。事前に初期化タスクを終えた結果をスナップショットに含ませることで、アプリの起動速度は更に高速化出来ます。しかしそれはあなたのコードベースに依存します。例えば、Reactコンポーネント群の読み込みなどは効果がありそうですね。