これまでのシリーズで Electron の開発環境が固まってきので、実際にアプリを作成してみたい。サンプルとしてある程度の複雑さがほしいから、以前に nw.js を使ってみる 5 – 簡易音楽プレーヤーで実装したものを移植することにした。
Electron を試す
もくじ
- 設計方針
- Main/Renderer プロセスの役割分担
- IPC による Main/Renderer 連携
- IPC イベント名の共有
- Main プロセスの IPC 実装
- Renderer プロセスの IPC 実装
- 音楽再生
- サンプル プロジェクト
設計方針
移植にあたり、単純に動かすだけなら NW.js 版の実装を Renderer プロセス部分へまるごとコピーするだけでよい。
しかし今回は Electron らしく Main/Rendrer を分割し、ダイアログ表示や音楽ファイルのメタデータ読み込みは Main プロセスで実行し、Main/Rendrer 間の連携は IPC に限定する。
remote を利用すれば Main プロセス部分の機能を Renderer プロセスから簡単に呼び出せるけれど、これもおこなわない。便利な反面、Main/Renderer が密結合になりやすく、特に双方の Object を参照しはじめると破棄などの管理が厄介そうである。
よって単純なメッセージ処理である IPC だけを使用し、なるべく疎結合に設計する。
その他、音楽再生を AudioBufferSourceNode から MediaElementAudioSourceNode に移行、Flux/React 周りを整理、などの変更を加える。
Main/Renderer プロセスの役割分担
NW.js と Electron の大きな違いとして、プロセス管理の方法があげられる。NW.js の場合、Node と Web ブラウザ部分の処理が一体となっていた。Electron では前者が Main、後者を Renderer として区別する。
Main プロセスがアプリのエントリー ポイントになり、ここから表示された BrowserWindow 内で Renderer プロセスが動作する。この管理方法によって複数ウィンドウ対応や、それらで共有されるデータを Main プロセスで横断的に制御、といった機能を実現している。
これを踏まえて役割分担するならば、Node を利用した処理やプラットフォーム固有のダイアログは Main、それ以外の処理を Renderer という感じで管理するのがよいだろう。
今回のアプリでは以下のように実装をわけた。
- Main プロセス
- メイン ウィンドウ制御
- メイン メニュー制御
- ファイル選択ダイアログ制御
- メッセージ ボックス制御
- 音楽ファイルのメタデータ読み込み
- Renderer プロセス
- 音楽再生
- IndexeDB による音楽データ管理
- UI
IPC による Main/Renderer 連携
IPC の持つ機能については electron/ipc-main-process.md を参照のこと。サンプルをみるに、ひとつの Main プロセスと複数の Renderer プロセスで利用することを前提に設計しているようで、処理の流れは以下のようになる。
- Main プロセスで on ハンドラを実装
- Renderer プロセスから send で Main プロセスにメッセージを送信
- Main プロセスの on ハンドラでメッセージを処理
- 必要なら on ハンドラから sender.send で送信元となる Renderer プロセスに処理結果などを送信
重要なのは Main プロセスから能動的に Renderer プロセスへメッセージ送信することはなく、Renderer からのリクエストに応じる設計となっている点。
Renderer プロセスからのパラメータにコールバック関数を仕込むとか、Main プロセス側で on イベントの sender を保持するとかすれば任意のタイミングで Renderer にメッセージ送信できそうだが、それでは remote を使う場合と同様に参照の破棄などの厄介な問題を抱え込む原因になる。
一般的な Web アプリにおける client-server モデルのように、両者は疎でステートレスにしたほうが管理しやすくなるため、
- Main プロセスはリクエストに応答するだけ
- Renderer はリクエストして応答を待つだけ
という設計にするのが妥当と判断した。この原則を破りたくなったときは、設計を見直す機会と考える。
IPC イベント名の共有
IPC の send と on のイベント名は共有される必要がある。よってイベント名は Main/Renderer 間で一意な定数として実装するのがよいだろう。本シリーズ第一回で作成した electron-starter もこれを想定した構成になっている。
/ └ src/ └ js/ ├ common/ ├ main/ └ renderer/
JavaScript 部分は Main プロセスが main、Renderer プロセスを renderer へ格納し、両者から共有されるものは common に配置。そして common に Constants.js を以下のように定義する。これを Main/Renderer プロセスから import すれば IPC イベント名を共有できる。
export const IPCKeys = { RequestShowMessage: 'requestShowMessage', FinishShowMessage: 'finishShowMessage', RequestShowOpenDialog: 'requestShowOpenDialog', FinishShowOpenDialog: 'finishShowOpenDialog', RequestReadMusicMetadata: 'requestReadMusicMetadata', FinishReadMusicMetadata: 'finishReadMusicMetadata' };
イベント名は Renderer からのリクエストに Request、Main による応答なら Finish や Response といった接頭語をつけると分かりやすいだろう。前述の IPC 設計を踏襲しているなら、イベント名からメッセージの送信方向を判断しやすくもなる。
Main プロセスの IPC 実装
Main プロセスの IPC 実装は以下のようにした。
エントリー ポイントで直にハンドラを実装するのではなく、独立したクラスにしている。もしリクエストに系ができるほどの規模になったら、その単位でクラスを分割することになるだろう。
import IPC from 'ipc'; import Dialog from 'dialog'; import Fs from 'original-fs'; import MusicMetadata from 'musicmetadata'; import { IPCKeys } from '../common/Constants.js'; export default class MainIPC { constructor( mainWindow ) { this._mainWindow = mainWindow; IPC.on( IPCKeys.RequestShowMessage, this._onRequestShowMessage.bind( this ) ); IPC.on( IPCKeys.RequestShowOpenDialog, this._onRequestShowOpenDialog.bind( this ) ); IPC.on( IPCKeys.RequestReadMusicMetadata, this._onRequestReadMusicMetadata.bind( this ) ); } _onRequestShowMessage( ev, args ) { if( !( args ) ) { ev.sender.send( IPCKeys.FinishShowMessage, new Error( 'Invalid arguments.' ), null ); return; } const options = args[ 0 ]; const button = Dialog.showMessageBox( this._mainWindow, options ); ev.sender.send( IPCKeys.FinishShowMessage, button, null ); } _onRequestShowOpenDialog( ev, args ) { if( !( args ) ) { ev.sender.send( IPCKeys.FinishShowOpenDialog, new Error( 'Invalid arguments.' ), null ); return; } const options = args[ 0 ]; const paths = Dialog.showOpenDialog( this._mainWindow, options ); ev.sender.send( IPCKeys.FinishShowOpenDialog, paths, null ); } _onRequestReadMusicMetadata( ev, args ) { if( !( args ) ) { ev.sender.send( IPCKeys.FinishReadMusicMetadata, new Error( 'Invalid arguments.' ), null ); return; } const filePath = args[ 0 ]; if( !( filePath ) ) { return; } this._readMusicMetadata( filePath, ( err, music ) => { ev.sender.send( IPCKeys.FinishReadMusicMetadata, err, music ); } ); } _readMusicMetadata( filePath, callback ) { const stream = Fs.createReadStream( filePath ); MusicMetadata( stream, { duration: true }, ( err, metadata ) => { if( err ) { return callback( err ); } callback( null, { path: filePath, title: metadata.title || '', artist: ( 0 < metadata.artist.length ? metadata.artist[ 0 ] : '' ), album: metadata.album || '', duration: metadata.duration } ); } ); } }
コンストラクタで _on〜 系メソッドをイベント ハンドラとして登録する。Main プロセス側なのでリクエスト系のみとなっている。
ダイアログ系は頻繁に使用されそうだから独立したリクエストにしている。これぐらい API むき出しなら remote にしてもよさそうだが、Renderer には Electron 部分もなるべく晒したくないので、このようにした。
_onRequestReadMusicMetadata は音楽ファイルのメタデータ読み込みリクエスト。NW.js のサンプルでは musicmetadata を window.require で参照していたが、Main プロセスは Node としてビルド ( Browserify に --node
オプション指定 ) しているため、通常の import で参照してもよい。
リクエストは成否に関わらず送信元へ処理結果を IPC メッセージとして返す。結果が必要なときだけ Renderer 側でハンドラを実装する。
Renderer プロセスの IPC 実装
Renderer 側の IPC 実装は以下のようになる。
import { IPCKeys } from '../common/Constants.js'; export default class RendererIPC { constructor( context ) { this._contex = context; this._ipc = window.require( 'ipc' ); this._listners = {}; this._ipc.on( IPCKeys.FinishShowMessage, this._onFinishShowMessage.bind( this ) ); this._ipc.on( IPCKeys.FinishShowOpenDialog, this._onFinishShowOpenDialog.bind( this ) ); this._ipc.on( IPCKeys.FinishReadMusicMetadata, this._onFinishReadMusicMetadata.bind( this ) ); } send( channel, ...args ) { this._ipc.send( channel, args ); } addListener( channel, listener ) { if( this._listners[ channel ] === undefined ) { this._listners[ channel ] = []; } this._listners[ channel ].push( listener ); } removeListener( channel, listener ) { if( this._listners[ channel ] === undefined ) { return; } const listeners = this._listners[ channel ]; this._listners[ channel ] = listeners.filter( ( f ) => { return ( f !== listener ); } ); } _onFinishShowMessage( args ) { const listners = this._listners[ IPCKeys.FinishShowMessage ]; if( !( listners ) ) { return; } const button = ( args ? args[ 0 ] : undefined ); listners.forEach( ( listner ) => { listner( button ); } ); } _onFinishShowOpenDialog( args ) { const listners = this._listners[ IPCKeys.FinishShowOpenDialog ]; if( !( listners ) ) { return; } listners.forEach( ( listner ) => { listner( args ); } ); } _onFinishReadMusicMetadata( err, music ) { const listners = this._listners[ IPCKeys.FinishReadMusicMetadata ]; if( !( listners ) ) { return; } listners.forEach( ( listner ) => { listner( err, music ); } ); } }
Renderer から IPC の実態を隠蔽したいので、ハンドラと共に IPC のメソッド呼び出しも Wrap したクラスを実装。
また、Main プロセスの返した処理結果を Flux Store などへ通知するためのイベント ハンドラ管理も加えている。この辺、自前で実装してしまったが EventEmitter 管理にすればよかったかも。
Flux Store から Main プロセスにリクエストを送信したい場合は、このクラスの send メソッドを呼び出す。このようにすることで、デバッグ実行時だけリクエストとレスポンスにログを仕掛けて監視できる、などのメリットがある。
音楽再生
NW.js で音楽再生する場合、Using MP3 & MP4 (H.264) using the video & audio tags. で解説されているように FFmpeg モジュールをパッケージに含める必要がある。NW.js にも同梱されているものは対応フォーマットが少なく、MP3 や AAC 再生が必要なら Chrome 同梱のものに置き換えることになる。
一方、Electron は Support for proprietary codecs – Issue #633 を見るに標準で MP3 や AAC を再生できるようだ。実際、アプリ開発のサンプルとしていくつか AAC を試したところ、確かに再生できた。
ただし Apple Lossless には非対応らしく、Audio クラスで再生しようとするとエラーになる。AAC と Apple Lossless は共に拡張子が m4a なので、canPlayType メソッドによる判定では後者だけ弾くことはできない。
そこで Audio クラスの loadedmetadata と error をハンドリングして、前者を通れば再生可能、後者が発生したらサポート外という判定をおこなうことにした。とりあえずこれで Apple Lossless を回避できている。より望ましい方法があれば、コメント欄などで指摘していただけるとありがたい。
音楽再生は NW.js のサンプルだと Web Audio API の AudioContext の decodeAudioData で復号した音声データを AudioBuffer に割り当てて AudioBufferSourceNode により管理していた。しかしこの方法だと一時停止の管理が面倒などのデメリットがある。
そのため再生は HTMLMediaElement ( Audio クラス ) で管理し、それを createMediaElementSource で GainNode などと関連付けるようにする。信号処理をせず、単に音楽ファイルを再生するだけならば、こちらの方がずっと簡単で扱いやすい。
サンプル プロジェクト
今回実装したサンプルを以下に公開する。
機能としては NW.js のものと変わらず、音楽の取り込みと削除、再生、一時停止、前後の曲移動などをサポートしている。