Electronデスクトップアプリ開発入門(3)
Electron APIデモから学ぶ実装テクニック ― ネイティブUIと通信
Electron API Demosで紹介されている、Electronアプリの実装テクニックを紹介。今回はネイティブUIと通信の実装方法を基礎から説明する。
はじめに
前回は、Electronのウィンドウ管理やメニューの表示の仕方を説明した。今回は、OSネイティブ機能の呼び出しやIPC(Inter-process Communication:プロセス間通信)によるプロセス間通信を解説する。
なお、今回もElectron API Demosというデモアプリを使う。デモアプリを動かす方法は、前回の説明を参照してほしい。
APIデモアプリ
ネイティブUI(NATIVE USER INTERFACE)
ここでは、OSが持つファイルマネージャーやダイアログなどのネイティブUIを呼び出す機能を解説している。
外部リンクとシステムのファイルマネージャーを開く(Open external links or system file manager)
リスト1は、OSのシェル機能にアクセスしてファイルマネージャーとWebブラウザーを開くサンプルコードである。
const shell = require('electron').shell
const os = require('os')
shell.showItemInFolder(os.homedir())
shell.openExternal('http://electron.atom.io')
|
showItemInFolder
メソッドにフォルダー(ここではOSのホームディレクトリ)を指定するとOSのファイルマネージャー(WindowsではExplorer、macOSではFinder)が開く。また、openExternal
メソッドでURLを指定すると、OSで設定されている標準のWebブラウザーが開いて、そのURLのページに遷移する。
システムダイアログを呼び出す(Use system dialogs)
[ファイルを開く]や[保存]のダイアログなど、システムが標準で持っているダイアログを呼び出す。
●[ファイル/フォルダーを開く]ダイアログ(Open a File or Directory)
リスト2は、OS標準の[ファイル/フォルダーを開く]ダイアログを開くサンプルコードだ(メインプロセス側)。
const { app, BrowserWindow } = require('electron');
const ipc = require('electron').ipcMain
const dialog = require('electron').dialog
ipc.on('open-file-dialog', function(event) {
dialog.showOpenDialog({
properties: ['openFile', 'openDirectory']
}, function(files) {
if (files) event.sender.send('selected-directory', files)
})
})
var mainWindows = null;
app.on('ready', function() {
mainWindow = new BrowserWindow({ width: 400, height: 100 });
mainWindow.loadURL(`file://${__dirname}/index.html`);
mainWindow.on('closed', () => {
mainWindow = null;
});
});
|
IPCの'open-file-dialog'チャネルが呼び出されると(=メッセージ受信)、dialog
モジュールのshowOpenDialog
メソッドを使って[ファイルかフォルダーを開く]ダイアログを表示して*1、選択されたフォルダー(もしくはファイル)をIPCの'selected-directory'チャネルで返している(=メッセージ送信)。
- *1 WindowsとLinuxは、ファイルとディレクトリを同時に選択できないため、'openDirectory'オプションが指定されているリスト2の場合、[フォルダーの選択]ダイアログが表示されてフォルダーのみが選択可能なので注意してほしい。
レンダラープロセス側は、ボタンがクリックされたらIPCでメインプロセス側を呼び出すようにしてある(リスト3)。
<html>
<body>
<span id="selected-file"></span>
<button id="select-directory">...</button>
<script>
const ipc = require('electron').ipcRenderer
const selectDirBtn = document.getElementById('select-directory')
selectDirBtn.addEventListener('click', function(event) {
ipc.send('open-file-dialog')
})
ipc.on('selected-directory', function(event, path) {
document.getElementById('selected-file').innerHTML = `You selected: ${path}`
})
</script>
</body>
</html>
|
ボタンがクリックされたら、IPCの'open-file-dialog'チャネルでメインプロセスを呼び出して(=メッセージ送信)、IPCの'selected-directory'チャネルで、選択されたフォルダー(もしくはファイル)のパスを受け取っている(=メッセージ受信)。
このコードを実行してみよう(図1)。
表示されたウィンドウの[...]ボタンをクリックすると[ファイル/フォルダーを開く]ダイアログが表示される。フォルダー(もしくはファイル)を選択すると、その名前がウィンドウ上に表示される。
あとは、選択されたフォルダー(もしくはファイル)を読み込んで表示するコードを書けばよい。
●[保存]ダイアログ(Save Dialog)
[(ファイルの)保存]ダイアログも[ファイル/フォルダーを開く]ダイアログと同じように記述できる(リスト4)。
const { app, BrowserWindow, dialog } = require('electron');
const ipc = require('electron').ipcMain
ipc.on('save-dialog', function(event) {
const options = {
title: 'Save an Image',
filters: [
{ name: 'Images', extensions: ['jpg', 'png', 'gif'] }
]
}
dialog.showSaveDialog(options, function(filename) {
event.sender.send('saved-file', filename)
})
})
var mainWindows = null;
app.on('ready', function() {
mainWindow = new BrowserWindow({ width: 400, height: 150 });
mainWindow.loadURL(`file://${__dirname}/index.html`);
mainWindow.on('closed', () => {
mainWindow = null;
});
});
|
IPCの'save-dialog'チャネルが呼び出されると(=メッセージ受信)、showSaveDialog
メソッドで[(ファイルの)保存]ダイアログを表示して、指定されたファイル名を'save-file'チャネルで呼び出し元に返す(=メッセージ送信)。
レンダラープロセス側のコードを見てみよう。
<html>
<body>
<button id="save-dialog">Save</button>
<div id="file-saved"></div>
<script>
const ipc = require('electron').ipcRenderer
const saveBtn = document.getElementById('save-dialog')
saveBtn.addEventListener('click', function(event) {
ipc.send('save-dialog')
})
ipc.on('saved-file', function(event, path) {
if (!path) path = 'No path'
document.getElementById('file-saved').innerHTML = `Path selected: ${path}`
})
</script>
</body>
</html>
|
[Save]ボタンがクリックされると、IPCの'save-dialog'チャネルでメインプロセスを呼び出して(=メッセージ送信)、IPCの'saved-file'チャネルで、指定されたファイル名を受け取る(=メッセージ受信)。
このコードを実行すると、表示されたウィンドウの[Save]ボタンをクリックすると、ファイルを保存するためのダイアログが表示される。
ファイル名が取得できたら、あとはファイルを保存するコードを記述すればよい。
●エラーダイアログ(Error Dialog)
エラーダイアログの表示は、インタラクティブにする必要がないため、起動したらすぐにダイアログを表示することにする。
const app = require('electron').app
const dialog = require('electron').dialog
app.on('ready', () => {
dialog.showErrorBox('An Error Message', 'Demonstrating an error message.')
})
|
リスト6のコードを実行すると、エラーダイアログが表示される(図3)。
●情報ダイアログ(Information Dialog)
情報ダイアログの表示も、エラーダイアログの表示と同様、メインプロセスだけで完結させることにする。
const { app, dialog } = require('electron');
app.on('ready', () => {
const options = {
type: 'info',
title: 'Information',
message: "This is an information dialog. Isn't it nice?",
buttons: ['Yes', 'No']
}
dialog.showMessageBox(options, function(index) {
console.log(index);
})
})
|
options
で、表示するメッセージと、表示するボタンを指定して、showMessageBox
メソッドでダイアログを表示する。
クリックされたボタンのインデックスがindex
に返ってくるのでコンソールに表示している。
リスト7のコードを実行すると、情報ダイアログが表示される(図4)。
macOSの場合は、[Yes]が右に表示されるが、index
は0で返されるので注意してほしい。
トレイにアプリを表示する(Put your app in the tray)
通知領域(システムトレイ)にアイコンとコンテキストメニュー(前回説明)を追加する(リスト8)。
const path = require('path')
const electron = require('electron')
const BrowserWindow = electron.BrowserWindow
const ipc = electron.ipcMain
const app = electron.app
const Menu = electron.Menu
const Tray = electron.Tray
let appIcon = null
ipc.on('put-in-tray', function(event) {
const iconName = process.platform === 'win32' ? 'windows-icon.png' : 'iconTemplate.png'
const iconPath = path.join(__dirname, iconName)
appIcon = new Tray(iconPath)
const contextMenu = Menu.buildFromTemplate([{
label: 'Remove',
click: function() {
event.sender.send('tray-removed')
}
}])
appIcon.setContextMenu(contextMenu)
})
ipc.on('remove-tray', function() {
appIcon.destroy()
})
app.on('window-all-closed', function() {
if (appIcon) appIcon.destroy()
})
var mainWindows = null;
app.on('ready', function() {
mainWindow = new BrowserWindow({ width: 400, height: 200 });
mainWindow.loadURL(`file://${__dirname}/index.html`);
mainWindow.on('closed', () => {
mainWindow = null;
});
});
|
IPCの'put-in-tray'チャネルのメッセージを受信したときに、Tray
クラスをインスタンス化して、システムトレイにアイコンを表示している。
また、'remove-tray'チャネルのメッセージを受信したときに、appIcon.destroy
メソッドでアイコンを削除している。
リスト8のコードをブロックごとに解説していこう。
const iconName = process.platform === 'win32' ? 'windows-icon.png' : 'iconTemplate.png'
const iconPath = path.join(__dirname, iconName)
|
IPCの'put-in-tray'チャネルのメッセージを受信したときに、Tray
クラスをインスタンス化して、システムトレイにアイコンを表示している。まずリスト9に示すように、process.platform
でアプリがWindows('win32')上で動作しているかどうかで分岐して、Windows用のアイコン(32px×32px、windows-icon.png)か、それ以外のアイコン(16px×16px、windows-icon.png)かを切り替えている。
なお、これらのアイコン画像はGitHub上のElectron API Demosのソースに含まれており、具体的には以下の3種類が使われている。@2x
は2倍サイズを意味し、つまり例えばWindows用のアイコンは64px×64pxが使われているということになる。
さらに、決定したアイコン名からファイルパスを作成している。なお、__dirname
という記述から分かるように、アイコンはアプリのルートと同じディレクトリに置いている。
appIcon = new Tray(iconPath)
|
リスト10では、アイコンのパスを指定して、Tray
クラスをインスタンス化している。これによりシステムトレイにアイコンが表示される。
const contextMenu = Menu.buildFromTemplate([{
label: 'Remove',
click: function() {
event.sender.send('tray-removed')
}
}])
appIcon.setContextMenu(contextMenu)
|
トレイアイコンをクリックしたときに表示されるメニューは、「コンテキストメニュー」と同じように定義をして、setContextMenu
メソッドでセットすればよい。リスト11では[Remove]というメニューを表示して、クリックされたらIPCで'tray-removed'チャネルのメッセージを送信している。
appIcon.destroy()
|
IPCで'remove-tray'チャネルのメッセージを受信した場合、またはウィンドウが全てクローズされた場合には、destroy
メソッドを呼び出して、トレイアイコンを削除している。
レンダラープロセス側のコードも見ていこう(リスト13)。
<html>
<body>
<button id="put-in-tray">Put in tray</button>
<div id="tray-countdown"></div>
</body>
<script>
const ipc = require('electron').ipcRenderer
const trayBtn = document.getElementById('put-in-tray')
let trayOn = false
trayBtn.addEventListener('click', function(event) {
if (trayOn) {
trayOn = false
document.getElementById('tray-countdown').innerHTML = ''
ipc.send('remove-tray')
} else {
trayOn = true
const message = 'Click demo again to remove.'
document.getElementById('tray-countdown').innerHTML = message
ipc.send('put-in-tray')
}
})
// Tray removed from context menu on icon
ipc.on('tray-removed', function() {
ipc.send('remove-tray')
trayOn = false
document.getElementById('tray-countdown').innerHTML = ''
})
</script>
</html>
|
ボタンがクリックされたときに、トレイアイコンが表示されていたらIPCの'remove-tray'チャネルのメッセージを送信して非表示にし(=トレイアイコンを削除)、表示されていなければIPCで'put-in-tray'チャネルのメッセージを送信して表示している。
トレイアイコンをすでに表示している場合(=trayOn
フラグがtrue)、IPCで'remove-tray'チャネルのメッセージを送信して、前述のメインプロセス側でトレイアイコンを削除している。表示されていない場合は、ICPで'put-in-tray'チャネルのメッセージを送信してアイコンを表示している。
Linuxの場合は、ディストリビューションによって表示できるかが変わるが、筆者のUbuntu 16.04環境ではlibappindicator1をインストールすることで表示できた。インストールコマンドはリスト14のとおり。
sudo apt-get install libappindicator1
|
その他のTray機能はAPIリファレンスを参照してほしい。
通信(COMMUNICATION)
すでに前回から何度も登場しているが、ここではIPC(Inter-Process Communication:プロセス間通信)用のipcMain
/ipcRenderer
モジュールを使った、メインプロセスとレンダラープロセス間の通信(同期・非同期)について解説する。
非同期メッセージ(Asynchronous messages)
非同期メッセージでは、レンダラープロセス側から送られてきたメッセージを、リスト15のようにメインプロセス側のチャネルで受け、そのメッセージに対する処理をし終わったら、呼び出し元にメッセージとしてsend
して、非同期にその処理結果を返す。
const path = require('path')
const electron = require('electron')
const BrowserWindow = electron.BrowserWindow
const app = electron.app
const ipc = require('electron').ipcMain
ipc.on('asynchronous-message', function(event, arg) {
event.sender.send('asynchronous-reply', 'pong')
})
var mainWindows = null;
app.on('ready', function() {
mainWindow = new BrowserWindow({ width: 400, height: 200 });
mainWindow.loadURL(`file://${__dirname}/index.html`);
mainWindow.on('closed', () => {
mainWindow = null;
});
});
|
IPCの'asynchronous-message'チャネルでメッセージを受信すると、非同期で処理を実行し、event.sender.send
メソッドを使って送信元に処理結果を送信している。
レンダラープロセス側でメッセージを送信し、メインプロセス側の処理結果を受信するコードの例も見てみよう(リスト16)。
<html>
<body>
<button id="async-msg">Ping</button>
<div id="async-reply"></div>
<script>
const ipc = require('electron').ipcRenderer
const asyncMsgBtn = document.getElementById('async-msg')
asyncMsgBtn.addEventListener('click', function() {
ipc.send('asynchronous-message', 'ping')
})
ipc.on('asynchronous-reply', function(event, arg) {
const message = `Asynchronous message reply: ${arg}`
document.getElementById('async-reply').innerHTML = message
})
</script>
</body>
</html>
|
ボタンがクリックされたらIPCの'asynchronous-message'チャネルに'ping'というメッセージを送信している。
非同期処理が完了すると'asynchronous-reply'チャネルに処理結果が返される。
このように非同期メッセージでは、メインプロセスとレンダラープロセスの間で呼び出し送信チャネル名と応答チャネル名を決めておき、送信チャネルのメッセージに対する処理が完了したら、応答チャネルに結果を受け取るようにする。
同期メッセージ(Synchronous messages)
通常は非同期で通信すればよいが、場合によっては同期で通信したいケースもあるだろう。同期メッセージでは、処理が完了してから同期的に応答を返す。
レンダラープロセス側では、先ほどのように非同期メッセージとしてsend
せずに、同期的にevent.returnValue
プロパティに値をセットする(リスト17)。
const path = require('path')
const electron = require('electron')
const BrowserWindow = electron.BrowserWindow
const app = electron.app
const ipc = require('electron').ipcMain
ipc.on('synchronous-message', function(event, arg) {
event.returnValue = 'pong'
})
var mainWindows = null;
app.on('ready', function() {
mainWindow = new BrowserWindow({ width: 400, height: 200 });
mainWindow.loadURL(`file://${__dirname}/index.html`);
mainWindow.on('closed', () => {
mainWindow = null;
});
});
|
IPCの'synchronous-message'チャネルのメッセージを受信すると、event.returnValue
に処理結果を返している。
同期メッセージを送信するメインプロセス側では、同期メッセージを送信するためのsendSync
メソッドを使い、その戻り値として処理結果を受け取る(リスト18)。
<html>
<body>
<button id="sync-msg">Ping</button>
<div id="sync-reply"></div>
<script>
const ipc = require('electron').ipcRenderer
const syncMsgBtn = document.getElementById('sync-msg')
syncMsgBtn.addEventListener('click', function() {
const reply = ipc.sendSync('synchronous-message', 'ping')
const message = `Synchronous message reply: ${reply}`
document.getElementById('sync-reply').innerHTML = message
})
</script>
</body>
</html>
|
ボタンがクリックされると、IPCの'synchronous-message'チャネルで同期メッセージを送信(sendSync
)して、(応答チャネルではなく)戻り値として処理結果を受信する。
非表示ウィンドウとの対話(Communicate with an invisible window)
メインプロセスに負荷をかけないように、非表示のウィンドウ(レンダラープロセス)を起動して、それに処理をさせるテクニックを紹介しよう。
まずは通常通り、メインプロセス側でユーザー操作用のウィンドウを表示する(リスト19)。
const path = require('path')
const electron = require('electron')
const BrowserWindow = electron.BrowserWindow
const app = electron.app
var mainWindows = null;
app.on('ready', function() {
mainWindow = new BrowserWindow({ width: 400, height: 200 });
mainWindow.loadURL(`file://${__dirname}/index.html`);
mainWindow.on('closed', () => {
mainWindow = null;
});
});
|
次に、ユーザー操作用ウィンドウ(レンダラープロセス:index.html、リスト20)側で、ボタンクリック時に別の非表示ウィンドウ(レンダラープロセス:invisible.html、後述のリスト24)を起動する。
<html>
<body>
<button id="invis-msg">Invisible message</button>
<div id="invis-reply"></div>
<script>
const BrowserWindow = require('electron').remote.BrowserWindow
const ipcRenderer = require('electron').ipcRenderer
const path = require('path')
const invisMsgBtn = document.getElementById('invis-msg')
const invisReply = document.getElementById('invis-reply')
invisMsgBtn.addEventListener('click', function(clickEvent) {
const windowID = BrowserWindow.getFocusedWindow().id
const invisPath = 'file://' + path.join(__dirname, '/invisible.html')
let win = new BrowserWindow({
width: 400,
height: 400,
show: false
})
win.loadURL(invisPath)
win.webContents.on('did-finish-load', function() {
const input = 100
win.webContents.send('compute-factorial', input, windowID)
})
})
ipcRenderer.on('factorial-computed', function(event, input, output) {
const message = `The factorial of ${input} is ${output}`
invisReply.textContent = message
})
</script>
</body>
</html>
|
ボタンがクリックされたら、非表示ウィンドウを起動して、IPCで非同期メッセージをsend
している。また、処理が終わったら結果をIPCで受信している。
リスト20のコードをブロックごとに解説していく。
invisMsgBtn.addEventListener('click', function(clickEvent) {
……省略……
const invisPath = 'file://' + path.join(__dirname, '/invisible.html')
let win = new BrowserWindow({
width: 400,
height: 400,
show: false
})
win.loadURL(invisPath)
……省略……
})
|
リスト21では、ボタンがクリックされたら、show: false
で非表示にした新規ウィンドウを作成し、ロード(=起動)している。
const windowID = BrowserWindow.getFocusedWindow().id
……省略……
win.webContents.on('did-finish-load', function() {
const input = 100
win.webContents.send('compute-factorial', input, windowID)
|
リスト22では、非表示ウィンドウのロードが終了(=WebContentsオブジェクトのイベントであるdid-finish-load
イベントをハンドル)したら、パラメーターを指定してIPCの'compute-factorial'チャネルにメッセージを送信している。またその際、処理結果の戻し先として使えるよう、フォーカスされているウィンドウ(この例ではユーザー操作用のウィンドウ)のIDを渡している。
ipcRenderer.on('factorial-computed', function(event, input, output) {
const message = `The factorial of ${input} is ${output}`
invisReply.textContent = message
})
|
リスト23では、非表示ウィンドウでの計算結果をIPCの'factorial-computed'チャネルで受信して、テキストコンテンツとして表示している。
それでは、処理を実行する非表示ウィンドウのコードを見ていこう(リスト24)。
<html>
<script type="text/javascript">
const ipc = require('electron').ipcRenderer
const BrowserWindow = require('electron').remote.BrowserWindow
ipc.on('compute-factorial', function(event, number, fromWindowId) {
const result = factorial(number)
const fromWindow = BrowserWindow.fromId(fromWindowId)
fromWindow.webContents.send('factorial-computed', number, result)
window.close()
})
function factorial(num) {
if (num === 0) return 1
return num * factorial(num - 1)
}
</script>
</html>
|
IPCで'compute-factorial'チャネルのメッセージを受信すると、計算(factorial)して、パラメーターとして渡されたIDのウィンドウに処理結果を返すため、BrowserWindow.fromId
メソッドでそのウィンドウ(この例では呼び出し元であるユーザー操作用のウィンドウ)を取得して、IPCの'factorial-computed'チャネルに処理結果を返している。最後にwindow.close
メソッドでウィンドウを閉じている。
このように非表示ウィンドウに処理を任せることで、別プロセスに複雑な処理をさせて、UIウィンドウの負荷を軽減させることもできる。
まとめ
今回は、ダイアログやシステムトレイなどOSのネイティブ機能呼び出しとElectronの特徴的な機能であるIPC通信によるプロセス間通信の手順について解説した。次回は、システム情報として実行環境の情報取得やクリップボードへのアクセス、PDFへの出力方法、デバッグ方法などを解説していく。
1. Electronとは? アーキテクチャ/API/インストール方法/初期設定
Windows/macOS/Linuxで実行できるデスクトップアプリをWeb技術で作ろう! Electronの概要から開発を始めて動かすところまでを解説する。
2. Electron APIデモから学ぶ実装テクニック ― ウィンドウ管理とメニュー
Electron API Demosで紹介されている、Electronアプリの実装テクニックを紹介。今回はウィンドウ管理とメニューの実装方法を基礎から説明する。
3. 【現在、表示中】≫ Electron APIデモから学ぶ実装テクニック ― ネイティブUIと通信
Electron API Demosで紹介されている、Electronアプリの実装テクニックを紹介。今回はネイティブUIと通信の実装方法を基礎から説明する。
4. Electron APIデモから学ぶ実装テクニック ― システムとメディア
Electron API Demosで紹介されている、Electronアプリの実装テクニックを紹介。今回はシステムとメディアの実装方法を基礎から説明する。