アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

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 機能を利用することになるだろう。これはプロセス別に ipcMainipcRenderer が用意されている。まずは 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 the channel event with ipcMain.

とある。簡単なサンプルを作成して検証してみたところ、この仕様どおりに動作することを確認できた。

そのため生成元 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 で管理。生成された BrowserWindowid プロパティに一意な識別子が割り当てられるため、これを key にする。MapvalueBrowserWindow 本体。この生成と破棄に連動して Map を増減させれば常に安全なウィンドウ一覧を参照できる。

Renderer - Renderer を実現するためには対象を選択する仕組みが必要。Renderer の識別は BrowserWindow.id を利用可能。MapkeyArray に変換してウィンドウが増減する際に各 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 自体はユニークなので実用上は問題ない。