Electron を試す 6 - 複数ウィンドウの管理
Electron アプリにおける複数ウィンドウの生成と連携について考察してみる。
BrowserWindow とプロセス
Electron のウィンドウは Main プロセスより BrowserWindow
として生成される。
import Path from 'path';
function createWindow() {
const w = new BrowserWindow( {
width: 400,
height: 400,
minWidth: 400,
minHeight: 400,
resizable: true
} );
const filePath = Path.join( __dirname, 'index.html' );
w.loadURL( 'file://' + filePath );
}
生成されたウィンドウ上で実行される JavaScript は Renderer に属する。Main はアプリ内でひとつだが BrowserWindow
= Renderer はいくつでも生成可能。
ipc
Main と Renderer 間で連携するなら ipc 機能を利用することになるだろう。これはプロセス別に ipcMain と ipcRenderer が用意されている。まずは ipcMain
について。
const ipcMain = require( 'electron' ).ipcMain;
// w = BrowserWindow ( Renderer ) への送信
// 対象を明示的に選ぶ必要あり
w.webContents.send( 'testMessage', 'test' );
// 受信と返信
ipcMain.on( 'requsetMessage', ( ev, message ) => {
console.log( message ); // prints "ping"
ev.sender.send( 'responseMessage', 'pong' );
} );
Main - Renderer の関係は 1:N となる。そのため Renderer への送信は対象を明示的に選ぶ必要あり。受信は逆に N:1 となるため Main 側はひとつのハンドラーを実装するだけでよい。
ipcRenderer
の処理は以下のようになる。Main は単一なので送受信における対象の選択は不要。
const ipcRenderer = require( 'electron' ).ipcRenderer;
// 送信
ipcRenderer.send( 'requsetMessage', 'ping' );
// 受信 ( ipcMain と同様に返信も可能 )
ipcRenderer.on( 'responseMessage', ( ev, message ) => {
console.log( message );
}
基本的に Renderer - Renderer で直に送受信することはない。どうしても実現したければ remote で Renderer から BrowserWindow
を生成、その webContent
に対して送受信する。ただし Renderer で生成した BrowserWindow
から ipcRenderer.send
を実行した場合、送信先は生成元の Renderer ではなく Main になる。
現時点の API リファレンスには
Send an event to the main process asynchronously via a
channel
, you can also send arbitrary arguments. The main process handles it by listening for thechannel
event withipcMain
.
とある。簡単なサンプルを作成して検証してみたところ、この仕様どおりに動作することを確認できた。
そのため生成元 Renderer に対して送信する場合は ipc ハンドラーの sender
を利用した返信にするか、そのパラメーターに生成元 Renderer のコールバック関数を渡して ipc とは別に管理することになるだろう。この方法だと ipcRenderer.send
との使い分けを常に意識しなければならない。ただでさえ面倒なイベント処理のデバッグが一層、厄介になる。
よって ipc の送受信は Main - Renderer 間に限定、Renderer - Renderer で通信する場合は Main に仲介させる設計を採用する。
複数ウィンドウの管理
実際に Main - Renderer を 1:N で管理しつつ Renderer - Renderer の送受信を検証するためのサンプルを実装してみる。
はじめに Main 側へ複数の BrowserWindow
を一元管理するための仕組みを実装。ウィンドウを統括するので WindowManager
というクラスにしておく。
import Path from 'path';
import BrowserWindow from 'browser-window';
import Util from '../common/Util.js';
export default class WindowManager {
constructor() {
this._windows = new Map();
this._ipc = require( 'electron' ).ipcMain;
this._ipc.on( 'RequestCreateNewWindow', this._onRequestCreateNewWindow.bind( this ) );
this._ipc.on( 'RequestSendMessage', this._onRequestSendMessage.bind( this ) );
this._ipc.on( 'RequestGetWindowIDs', this._onRequestGetWindowIDs.bind( this ) );
}
createNewWindow() {
const w = new BrowserWindow( { width: 400, height: 400, minWidth: 400, minHeight: 400, resizable: true } );
const id = w.id;
w.on( 'closed', () => {
if( DEBUG ) { Util.log( 'Window was closed, id = ' + id ); }
this._windows.delete( id );
this._notifyUpdateWindowIDs( id );
} );
const filePath = Path.join( __dirname, 'window-main.html' );
w.loadURL( 'file://' + filePath + '#' + w.id );
this._windows.set( id, w );
return w;
}
_notifyUpdateWindowIDs( excludeID ) {
const windowIDs = [];
for( let key of this._windows.keys() ) {
windowIDs.push( key );
}
this._windows.forEach( ( w ) => {
if( w.id === excludeID ) { return; }
w.webContents.send( IPCKeys.UpdateWindowIDs, windowIDs );
} );
}
_onRequestCreateNewWindow( ev ) {
const createdWindow = this.createNewWindow();
ev.sender.send( IPCKeys.FinishCreateNewWindow );
this._notifyUpdateWindowIDs( createdWindow.id );
}
_onRequestSendMessage( ev, id, message ) {
const w = this._windows.get( id );
if( w ) {
w.webContents.send( 'UpdateMessage', message );
}
ev.sender.send( 'FinishSendMessage' );
}
_onRequestGetWindowIDs( ev ) {
const windowIDs = Array.from( this._windows.keys() );
ev.sender.send( 'FinishGetWindowIDs', windowIDs );
}
}
生成されたウィンドウは Map で管理。生成された BrowserWindow
の id
プロパティに一意な識別子が割り当てられるため、これを key
にする。Map
の value
は BrowserWindow
本体。この生成と破棄に連動して Map
を増減させれば常に安全なウィンドウ一覧を参照できる。
Renderer - Renderer を実現するためには対象を選択する仕組みが必要。Renderer の識別は BrowserWindow.id
を利用可能。Map
の key
を Array
に変換してウィンドウが増減する際に各 Renderer へ通知している。
増減の対象となったウィンドウは ipc を受け取れない可能性があるため送信先から除外する。新規ウィンドウについては自身の JavaScript が読み込まれた後、つまり Renderer が確実に開始されているタイミングで明示的に一覧を取得しにゆく。
Renderer が自身に割り当てられた id
を知るため loadURL
へ指定する URL 末尾に #id
を追加している。URL としては hash 扱いとなるため読み込みには影響しない。これは window.location.hash
として取得可能。Main から送信される id
一覧は自身も含むため、そこから自身のを除外することで適切な ipc 送信先を得られる。
次は Renderer。ipc 関連は Flux における Store で処理している。Flux ライブラリは azu/material-flux を採用。詳しくは material-flux を試すを参照のこと。
import { Store } from 'material-flux';
import { Keys } from '../action/MainWindowAction.js';
export default class MainWindowStore extends Store {
constructor( context ) {
super( context );
this._windowID = window.location.hash;
if( this._windowID ) {
this._windowID = Number( this._windowID.replace( '#', '' ) );
}
this.state = {
message: null,
windowIDs: []
};
this.register( Keys.createNewWindow, this._actionCreateNewWindow );
this.register( Keys.sendMessage, this._actionSendMessage );
context.ipc.on( 'FinishCreateNewWindow', this._onFinishCreateNewWindow.bind( this ) );
context.ipc.on( 'FinishSendMessage', this._onFinishSendMessage.bind( this ) );
context.ipc.on( 'FinishGetWindowIDs', this._onFinishGetWindowIDs.bind( this ) );
context.ipc.on( 'UpdateWindowIDs', this._onUpdateWindowIDs.bind( this ) );
context.ipc.on( 'UpdateMessage', this._onUpdateMessage.bind( this ) );
context.ipc.send( 'RequestGetWindowIDs' );
}
get message() {
return this.state.message;
}
get windowIDs() {
return this.state.windowIDs;
}
_actionCreateNewWindow() {
this.context.ipc.send( 'RequestCreateNewWindow' );
}
_actionSendMessage( id, message ) {
this.context.ipc.send( 'RequestSendMessage', id, message );
}
_onFinishCreateNewWindow() {
console.log( '_onFinishCreateNewWindow');
}
_onFinishSendMessage() {
console.log( '_onFinishSendMessage');
}
_onFinishGetWindowIDs( ev, windowIDs ) {
this.setState( { windowIDs: windowIDs.filter( ( id ) => id !== this._windowID ) } );
}
_onUpdateWindowIDs( ev, windowIDs ) {
this.setState( { windowIDs: windowIDs.filter( ( id ) => id !== this._windowID ) } );
}
_onUpdateMessage( ev, message ) {
this.setState( { message: message } );
}
}
この処理が実行されるタイミングは Renderer プロセスが有効なのでコンストラクタから id
一覧を取得している。その他については前述の Main 側 ipc と対応しているので特筆すべきことはない。
他の Renderer へテキストを送信できるようにした。id
一覧から対象を選択、ipc で RequestSendMessage
を送信することで Main 側が送信先の判定とテキスト伝搬を実行してくれる。
メイン ウィンドウに従属する単一ウィンドウ
アプリケーション情報や設定ダイアログなど、常に単一表示したいウィンドウの管理を考える。
このようなウィンドウは単一のメイン ウィンドウに従属するだろう。これと等価以上の関係であればメイン ウィンドウ、そうではないなら主従関係になるはず。
表示も単一なので Map
による辞書的な管理よりウィンドウ単位で変数化したほうが扱いやすい。今回サンプルなら WindowManager
のフィールドにインスタンスを格納することになるだろう。
注意点として主側が閉じられたときは従も閉じること。従属関係があるならウィンドウ表示だけでなくデータ階層もそうなっている可能性が高い。そのため主の破棄に従も連動しないとデータ破損を引き起こす可能性がある。
この記事のために実装したサンプルと本シリーズ中で作成した音楽プレーヤーのサンプルでは、このような管理を実装している箇所がある。詳しくはそちらを参照のこと。
サンプル プロジェクト
本記事の検証用に作成したサンプル プロジェクトを公開。
アプリを起動して New Window ボタンを押すとウィンドウが追加される。
複数ウィンドウが表示されている状態なら Target Window 欄から他のウィンドウを選択可能になる。このときテキスト ボックスに文字を入力してから Send Message ボタンを押すと、対象ウィンドウにテキストが送信されて My Message 欄にそれが表示される。
ウィンドウのタイトルバーに表示される数値は BrowserWindow
に割り当てられた id
。これは Send Message 欄と対応しているので、送信先ウィンドウを識別するのに使える。
アプリケーション メニューから About Multiple Windows を選ぶと About Dialog が表示される。このウィンドウに対しても id
が割り当てられるため、その後に New Windows でウィンドウを追加すると連番が崩れるけれど id
自体はユニークなので実用上は問題ない。