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

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のパフォーマンス分析ツールで、どのようにあなたのアプリバンドルが読み込まれているのか覗き見ることが出来ます。

Cmd-Eまたは赤いRecordボタンを押して、ランタイムパフォーマンスの記録を開始して、アプリを再読込します。すると以下のようなタイムラインが表示されます:

Developer Tools’ Performance Analytics

上図のように、タイムライン上ではモジュールの `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を生成する方法

以下のステップを行います:

  1. ツールのインストール
  2. electron-link でJSソースコードを前処理する
  3. V8 snapshotsを mksnapshot で生成する
  4. スナップショットをElectron内で読み込む

このチュートリアルに向けてサンプルのプロジェクトを作りました。以下のリポジトリをご参照ください:

ツールのインストール

以下のパッケージが必要です:

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コンポーネント群の読み込みなどは効果がありそうですね。

Read more

Inkdrop v6 Canary版リリースしました — 新Markdownエディタやその他新機能盛り沢山

Inkdrop v6 Canary版リリースしました — 新Markdownエディタやその他新機能盛り沢山

Inkdrop v6.0.0 Canary版リリースしました — 新Markdownエディタやその他新機能盛り沢山 こんにちはTAKUYAです。 v6.0.0 の最初の Canary バージョンをリリースしました 😆✨ v6では、アプリのコア機能の改善がたくさん盛り込まれています! * リリースノート(英語): https://forum.inkdrop.app/t/inkdrop-desktop-v6-0-0-canary-1/5339 CodeMirror 6 ベースの新しいエディタ フローティングツールバー v5ではツールバーがエディタの上部に固定されており、使っていないときもスペースを占有していました。 v6では、テキストを選択したときだけ表示されるフローティングツールバーに変わりました。 GitHub Alerts 構文のサポート Alerts の構文が正しい色と左ボーダーでハイライトされるようになりました。 ネストされたアラートや引用にも対応しています。 また、アラートタイプの入力を支援する補完機能も追加されました。 スラッシュコマンド 空行で /

By Takuya Matsuyama
AIのお陰で最近辛かった個人開発がまた楽しくなった

AIのお陰で最近辛かった個人開発がまた楽しくなった

AIのお陰で最近辛かった個人開発がまた楽しくなった こんにちは、TAKUYAです。日本語ではお久しぶりです。僕はInkdropというプレーンテキストのMarkdownノートアプリを、デスクトップとモバイル向けにマルチプラットフォームで提供するSaaSとして、かれこれ9年にわたり開発運営しています。 最近、その開発にClaude Codeを導入しました。エージェンティックコーディングを可能にするCLIのAIツールです。 最初の試行は失敗に終わったものの、徐々に自分のワークフローに馴染ませることができました。そして先日、アプリ開発がまた「楽しい」と感じられるようになったのです。これは予想外でした。 本稿では、自分がエージェンティック・コーディングをワークフローに取り入れた方法と、それが個人開発への視点をどう変えたかを共有します。 * 翻訳元記事(英語): Agentic coding made programming fun again 自分のアプリに技術的負債が山ほどあった ご想像のとおり、9年も続くサービスをメンテするのは本当に大変です。 初期の頃は新機能の追加も簡単で

By Takuya Matsuyama
個人開発を7年以上続けて分かった技術選択のコツ

個人開発を7年以上続けて分かった技術選択のコツ

個人開発を7年以上続けて分かった技術選択のコツ InkdropというMarkdownノートアプリを作り続けて7年になる。 お陰さまでその売上でずっと生活できている。 これまで個人開発でどう継続していくかについて「ユーザの退会理由をあれこれ考えない」とか「アプリの売上目標を立てるのをやめました」とか、ビジネス面あるいはメンタル面からいろいろ書いてきた。 今回は、技術面にフォーカスして、どう継続して開発していくかについてシェアしたい。 TL;DR * 最初はとにかく最速でリリースする事を最優先する * 迷ったら「ときめく方」を選べ * 程よいところで切り上げて開発を進める * 使っているモジュールがdeprecatedされるなんてザラだと覚悟する * 古いから悪いとは限らない * シンプルにしていく * 老舗から継続の秘訣を学ぶ * 運ゲー要素は排除しきれない 最初はとにかく最速でリリースする事を目標に技術選定する 開発計画とビジネス計画は切っても切り離せない。 コーディングに傾倒するあまり完璧主義に陥って結局リリース出来ないまま頓挫してしまう個人開発者は多い

By Takuya Matsuyama
子育て中の個人開発者の一日

子育て中の個人開発者の一日

子育て中の個人開発者の一日 どうもTAKUYAです。 久しぶりに生活まわりの事を書きたい。自分はInkdropというMarkdownノートアプリを売って生きている。 子供も無事順調に成長しており、あと数ヶ月で3歳になるというところで、イヤイヤ期もやっと終わりが見えてきた。 生活パターンもなんとなく定着しつつあるので、ここで一旦どんなルーティンなのか書き出してみる。ちなみに当方今年で40歳。 平日の1日の流れ * 06:30 妻と子供起床、朝食 * 07:10–30 俺起床、朝食 * 07:40 布団を畳んで子供を着替えさせる。妻はその間に化粧や通勤の準備 * 08:00 ストレッチと軽い筋トレ(腕立て50回、スクワット100回) * 08:10 妻と子供を見送る。15分前後瞑想 * 08:30 散歩 * 09:00 作業開始(カフェまたは家) * 11:00 昼飯 * 12:00 ダラダラする * 12:30 作業再開(だいたい家)

By Takuya Matsuyama