Electron を試す 3 - 簡易音楽プレーヤー
これまでのシリーズで Electron の開発環境が固まってきので実際にアプリを作成してみたい。サンプルとしてある程度の複雑さがほしいから以前に nw.js を使ってみる 5 - 簡易音楽プレーヤーで実装したものを移植することにした。
設計方針
移植にあたり単純に動かすだけなら 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 で音楽再生する場合は FFmpeg モジュールをパッケージに含める必要がある。しかし同梱されているものは対応フォーマットが少ない。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 を回避できている。より望ましい方法があれば Twitter などで指摘していただけるとありがたい。
音楽再生は NW.js のサンプルだと Web Audio API の AudioContext.decodeAudioData
で復号した音声データを AudioBuffer
に割り当てて AudioBufferSourceNode
により管理していた。しかしこの方法だと一時停止の管理が面倒などのデメリットがある。
そのため再生は HTMLMediaElement (Audio クラス) で管理。それを createMediaElementSource
で GainNode
などと関連付けるようにする。信号処理をせず単に音楽ファイルを再生するだけならば、こちらの方がずっと簡単で扱いやすい。
サンプル プロジェクト
今回実装したサンプルを以下に公開。
機能としては NW.js のものと変わらず。音楽の取り込みと削除、再生、一時停止、前後の曲移動などをサポートしている。