Electron + Mithrilで、ふつうのデスクトップアプリを作る

  • 290
    Like
  • 0
    Comment
More than 1 year has passed since last update.

最近は、Mithrilのお陰で、シングルページアプリケーションが大分作りやすくなりました。仕事でも使ってます。あ、ドキュメントの日本語訳もありますよ。本もあります!

社内ツールを作るのにMithrilとElectronで作ってみたのですが、ふつうのデスクトップアプリを作るのにちょっと手間が多いので(これはMithrilを使わなくても)、ふつうを実現するためのフレームワークについて考えて実装してみました。特にまだ名前はありません。

Electronとは?

Electronはウェブ的なスキルがあれば、それが簡単にデスクトップで動くようになるという仕組みです。元々はatom-shellと呼ばれていました。類似のものに、NW.js(元node-webkit)があります。また、QtでHTML5アプリケーションというのを新規作成で選ぶと、ブラウザコンポーネントが真ん中に貼り付けられたひな形ができるのですが、これも似ているといえば似てます。

Electronは2つのプロセスを意識してコードを書く必要があります。

スクリーンショット 2015-08-20 20.53.18.png

OSの機能を直接つかったりするのはブラウザプロセス側です。重い処理はブラウザプロセス側でやるのが推奨されていたりもします。プロセス間は通信の手段がいろいろあります。

「ふつう」とは?

ビューアアプリでなければ、何らかのファイルを編集するのがアプリケーションの主な役割になります。ファイルを編集する場合、だいたい次のような機能があることが期待されます。

  • ファイルに関するもろもろの操作
    • 以前作ったファイルを開く
    • 新規作成
    • 保存する
    • 別名で保存
    • ファイル名が決まったらウインドウのタイトルに表示
  • 閉じた時の動作
    • もし変更されていたら、保存してから終了するか確認する。
    • もし一度も保存されていなければファイル名入力をさせる。
  • 編集中の操作
    • Ctrl+Z(Cmd+Z)でアンドゥ、Ctrl+Y(Cmd+Y)でリドゥ。
    • 何らかの変更があったらウインドウのタイトルで分かるようにして欲しい

これらの「ふつう」のアプリになるためのコードはなるべく再利用して、毎回作るアプリではそんなにコードを書かなくても「ふつう」になるようにしたいですよね?

なお、このエントリーではSDI型のデスクトップアプリを想定しています。MDI型だったり、Evernoteみたいに1つのウインドウで複数のドキュメントを扱うケースは取り扱っていません(レンダラープロセスでやる仕事が増えるので)

シングルページアプリケーション側と疎な構造にしたい

OSのメニュー等はブラウザプロセスで行います。そのため、メニューを更新したり、メニューに応答するコードをレンダラプロセスに置こうとすると、そちらと通信を行わなければならず、コードが煩雑になります。そこで、なるべくブラウザプロセスとやりとりしなくても良い仕組みにします。実際に作ってみたコードはこちらのgistにあります。

スクリーンショット 2015-08-20 21.13.27.png

  1. ブラウザプロセスでMithrilを使い、MVCの構築します。レンダラ内のモデルが変更されたら、変更内容(undoメニューに出す)、ラウターとともに、モデルをJSON形式でApplicationContextに送ります。
  2. 何かレンダラの内容を更新しなければならない場合は、ApplicationContextから指令を送ってブラウザ内のコンテンツをリセットします。更新は次のメニュー操作で発生します。
    1. アンドゥ・リドゥ操作
    2. ファイルの読み込み

これだけあれば、十分なはず。現在の最新のJSONデータはヒストリーの配列の中に入っているため、ファイルの保存や終了時のユーザとのやりとりなんかは、レンダラプロセスに何も通知せずに、ブラウザプロセス内で処理が完結します。

それぞれ、modifiedload というipcのチャンネルでプロセス間通信をするものとします。

使い方(ブラウザプロセス)

Electronのメニューは、一度作ったら要素の追加も削除もできない仕組みです。つまり、変更があるたびに完全再作成が必要です。アプリ側で項目を追加したいことも多いと思うので、コールバック関数にしています。更新が必要な時(アンドゥに出す項目が変わるなど)に呼ばれます。presetMenuというのに、buildFromTemplateで使える、「ふつうのアプリケーション」のためのメニューが入っているので、これを使ってメニューを更新します。

function updateMenu(context, focusedWindow, presetMenu) {
    var menu = [
        {
            label: "ファイル",
            submenu: [
                presetMenu.fileNew,
                presetMenu.fileOpen,
                {type: "separator"},
                presetMenu.fileSaveAs,
                presetMenu.fileSave,
                {type: "separator"},
                presetMenu.fileQuit
            ]
        },
        {
            label: "編集",
            submenu: [
                presetMenu.editUndo,
                presetMenu.editRedo
            ]
        }
    ];
    Menu.setApplicationMenu(Menu.buildFromTemplate(menu));
}

次に、ApplicationContextを初期化します。引数は順番に

  1. アプリ名(ウインドウタイトルに出る)
  2. デフォルトのラウター(リセット時にこれを送る)
  3. ファイルを開くメニューで使うフィルタ

です。後は普通にウインドウを作って、registerWindowメソッドで登録します。

var app = require("app");
var BrowserWindow = require('browser-window');
var ApplicationContext = require("./applicationcontext");
var Menu = require('menu');

app.on("ready", function () {
    var context = new ApplicationContext("ふつうアプリ", "/default", 
       {name: "ファイル", extensions: ["appjson"]}
    );
    context.updateMenu = updateMenu;
    var mainWindow = new BrowserWindow({width: 1030, height: 720});
    mainWindow.loadUrl("file://" + __dirname + "/index.html");
    context.registerWindow(mainWindow);
});

使い方(レンダラプロセス側)

Mithril側では、モデルを用意しますが、モデルのルートとなるオブジェクトはシングルトンで管理するものとします。

// データモデル
// Mithril wayにのっとって、JSONを引数で受け取るコンストラクタ関数にします。
function Model(data) {
    this.sugoiData = m.prop((data && data.sugoiData) || "すごいデータ");
}

// インスタンス
Model._instance = null;

Model.getInstance = function() {
    if (!Model._instance) {
        Model._instance = new Model({});
    }
    return Model._instance;
};

ここは細かい実装はお好きにアレンジしてください。

次に、ApplicationContextとのやりとりのインタフェースを作ります。

var ipc = require('ipc');

// 編集結果をブラウザプロセス側に送る
Model.prototype.sendSnapshot = function (label) {
    // やんなくてもいいかもしれないけど年のため
    var isolatedData = JSON.parse(JSON.stringify(this));
    var defaultRoute = m.route();
    // データ変更前後でラウターの変更があれば、オプションで新URL、もしくは旧URLを渡す
    var oldRoute = (opts && opts.oldRoute) ? opts.oldRoute : defaultRoute;
    var newRoute = (opts && opts.newRoute) ? opts.newRoute : defaultRoute;
    ipc.send("modified", isolatedData, label, oldRoute, newRoute);
};

// ブラウザプロセスから変更指令がきた
ipc.on("load", function (json, route) {
    Model._instance = new Model(json);
    // 編集中の内容は破棄(これは各自実装してください)
    ViewModel.reset();
    // ラウターが変わっていたら更新
    if (route !== m.route()) {
        m.route(route);
    }
    // 再描画
    m.redraw();
});

通信部分はこれで完了です。新・旧のラウターが必要な理由としては、アンドゥで古いデータに戻る時は旧ページに、リドゥで新しいデータに戻る時は新ページに遷移した方が都合が良いだろう、ということでこうしています。

アプリ内でモデルを変更する時は、必ずsendSnapshot()メソッドを呼んで、変更内容に名前を付けてヒストリーに追加するようにします。

// イベントハンドラの中。
var model = Model.getInstance();
model.sugoiData("最新のすごいデータ");
Model.sendSnapshot("最新のデータに更新");

モデル側に数10行、後はモデル変更時の処理に1行ずつ"sendSnapshot()"呼び出しを追加することで、ウェブアプリが「ふつうのアプリケーション」になります。

注意点としては、ブラウザプロセス側からモデルのインスタンスが入れ替えられる可能性があるため、必ずモデルを扱う時は、Model.getInstance()経由でデータを取ってきてから処理するようにします。

今後

もうすでに便利に使っているので特に大きく構造に手を加える予定はないですが、最近開いたファイル一覧表示をファイルメニューに追加というのはやりたいですね。後はupdateMenu()はデフォルト実装を用意しておけば、もっとスタートダッシュを早くできるかな、と。

あ、他のMVCはあまり詳しくないですが、JSONでまとめてデータを取ってきたり、それを反映できるなら他のライブラリと一緒に使うのもできます。ただ、編集中のUIの状態(ただしデータとして保存されることはない)をビューモデルとして、モデルと分離して管理するMithrilの構造のお陰でだいぶシンプルにできた、というのはあるので、そのあたりが要注意ポイントかもしれません。

あと、今作っているのはそんなにデータの分量が多くないのでJSONそのまま持っていますが、そのうちメモリ使用量削減をしてもいいかもとは思ってます。

  • JSONを文字列化する(1/2〜1/3ぐらいになる?)
  • 文字列をlz4で圧縮(さらに1/10ぐらいになる)
  • あるいは圧縮しないで、バイナリ差分アルゴリズム(git内部で使われているdeltaというやつ)を使って最新のものから戻すdiffを持ち続ける