LoginSignup
215

More than 5 years have passed since last update.

Electron 開発で得た知見まとめ

Last updated at Posted at 2016-02-24

はじめに

Electron + Riot + Redux でアプリケーションを作って得た知見まとめです。

制作物は Twitter の収録再生プレイヤー。
録画や録音しておいた Twitter 連動番組などを再生する際、放送当時のツイートをリアルタイムっぽく表示させる品です。

midnightSuyama/tweet-rec

実装

electron

atom/electron

Twitter の収録はウインドウ有無に依存しないよう main プロセスで行い、renderer プロセスでは表示に専念する方針とした。
ウインドウを閉じても常駐するタイプのアプリケーション。

ウインドウの表示

BrowserWindow を使用。
ウインドウサイズについて、大概の場合はコンテンツ領域を基準に考えるはずなので useContentSize は忘れずに。

ウインドウを閉じた際には解放しておきたいので closed イベントでは null を入れておき、表示が必要なタイミングではウインドウ有無の判定材料とした。

mainWindow.on('closed', () => {
  mainWindow = null
})

if (mainWindow) {
  if (mainWindow.isMinimized()) mainWindow.restore()
  mainWindow.focus()
} else {
  mainWindow = new BrowserWindow()
}

また、electron.appwindow-all-closed イベントは default_app.js でアプリケーションが終了するようになっているので、常駐させるためにはこのイベントを上書きする。

app.on('window-all-closed', () => {})

メニューの表示

Menu を使用。
基本的には、公式のサンプルをベースにするだけで標準的なメニューが出来上がる。
OS X 特有のメニューも process.platform で切り分け、MenuItemrole で楽々。

if (process.platform === 'darwin') {
  template.unshift({
    submenu: [
      { label: `About ${app.getName()}`, role: 'about' },
      { type: 'separator' },
      { label: 'Services', role: 'services', submenu: [] },
      { type: 'separator' },
      { label: `Hide ${app.getName()}`, accelerator: 'Command+H', role: 'hide' },
      { label: 'Hide Others', accelerator: 'Command+Alt+H', role: ideothers' },
      { label: 'Show All', role: 'unhide' },
      { type: 'separator' },
      { label: 'Quit', accelerator: 'Command+Q', click: () => app.quit() }
    ]
  })
}

タスクトレイ / メニューバーの表示

Tray を使用。
今回はウインドウを閉じても常駐する仕様なので、ここのメニューには終了するための手段を用意しておく。

アイコンの画像サイズは OS によって異なるので process.platform で切り分け、表示領域の背景色を考慮して Windows は白、OS X は黒で作成するのが吉。
このアイコン画像である NativeImage のパスは直書きしてしまうとパッケージング後の実行環境でズレが生じるため、__dirname を使って指定しておかないとハマる。

let image = path.join(__dirname, '..', 'res', (process.platform === 'darwin' ? 'tray-22.png' : 'tray-32.png'))

二重起動の防止

起動時に app.makeSingleInstance を実行して、既に起動中であれば自身を終了させた。

var shouldQuit = app.makeSingleInstance((argv, workingDirectory) => {
})
if (shouldQuit) app.quit()

ズームの防止

<meta name="viewport"> は効かなかったので、webFramewebFrame.setZoomLevelLimits を使った。

webFrame.setZoomLevelLimits(1, 1)

riot

riot/riot

各コンポーネントを app.js に集約させることで、
window.loadURL で読み込む HTML は下記のようになった。

<body>
  <app></app>
  <script src="../bower_components/riot/riot.min.js"></script>
  <script src="app.js"></script>
  <script>riot.mount('app')</script>
</body>

*.tag のコンパイルには gulp-riot を用い、gulp で app.js を生成することにした。

gulp.task('riot', () => {
  gulp.src('src/tags/*.tag')
    .pipe(plumber())
    .pipe(riot())
    .pipe(concat('app.js'))
    .pipe(babel())
    .pipe(uglify())
    .pipe(gulp.dest('dist'))
})

HTML エスケープ

Riot は HTML エスケープがデフォルト有効で、アンエスケープ文字が存在しないため、装飾範囲については別タグの innerHTML で対応させる。
今回の例で言うと、ツイートを twitter-text で装飾している箇所。
また、ここでは a タグにブラウザへの導線となる onclick イベントを付与しておいた。

<twitter-text>
  <script>
    const twitterText = require('twitter-text')
    this.root.innerHTML = twitterText.autoLink(twitterText.htmlEscape(opts.text))

    let a = this.root.getElementsByTagName('a')
    for (let i=0; i<a.length; i++) {
      a[i].setAttribute('onclick', 'electron.shell.openExternal(this.href); return false;')
    }
  </script>
</twitter-text>

redux

rackt/redux

いろいろ模索した結果、action は両プロセスで共有、
reducer, store は main プロセスに置き middleware を厚くする方針で落ち着いた。

プロセス間で状態を保つために main プロセスでは、
subscribe で renderer プロセスへ共有を行い、ipcMain で受け取った action はそのまま store へ流す。

store.subscribe(() => {
  if (mainWindow) {
    mainWindow.webContents.send('update-state', store.getState())
  }
})

ipcMain.on('dispatch-action', (event, action) => {
  store.dispatch(action)
})

さらに、ウインドウ作成時にも反映させるため did-finish-load イベントでも共有するようにしておく。

mainWindow.webContents.on('did-finish-load', () => {
  mainWindow.webContents.send('update-state', store.getState())
})

そして、renderer プロセスでは共有された状態を反映させ、発行する action は全て main プロセスへ送るようにする。

ipcRenderer.on('update-state', (event, state) => {
  global.state = state
  this.update()
})

ipcRenderer.send('dispatch-action', action)

これで経路が確立できたものの、action がプロセス間をまたぐため Redux Thunk などの遅延評価が使えない。
そのため、この役回りは middleware 層へ action.type に応じた処理を記述し、対象外のものはそのまま通すことで代用した。

store => next => action => {
  switch(action.type) {
    default:
      next(action)
  }
}

状態の保管

今回の状態管理は main プロセスが中心、ウインドウには依存しないため LocalStorage や IndexedDB を使うわけにはいかず、JSON ファイルで保存することにした。
app.getPath('cache') で OS ごとのキャッシュパスが取得できるので、subscribe で保存しておき、復元は起動時に読み込むだけ。

const stateCachePath = path.join(app.getPath('cache'), app.getName(), 'state.json')

store.subscribe(() => {
  mkdirp.mkdirpAsync(path.dirname(stateCachePath)).then(() => {
    fs.writeFile(stateCachePath, JSON.stringify(store.getState()))
  })
})

photon

connors/photon

npm には存在していないため、bower でインストール。
npm で見つかる photon は同名の別物で、photonkit で登録する話は停滞中っぽい。

今回はこの CSS をベースに全体に関わる修正をしつつ、各コンポーネント固有の修正は Riot の <style scoped> を活用した。
現 photon では disabled 時の表示変化が無いため、この部分のみ全体に関わる修正として記述した。

button:disabled {
  opacity: 0.5;
}

input:disabled {
  opacity: 0.5;
}

input type

普段は使用を躊躇する input type の類も、Electron では Chromium 環境に限られるため、今回は range, date, time を使用し、CSS ライブラリは photon のみで完結できた。
特に range はシークバーとして扱った結果、使い勝手も良く重宝した。

<input type="range" name="seek" value={ currentTime } min={ beginTime } max={ endTime } step="1000" onchange={ seekOnChange }>

gsap

greensock/GreenSock-JS

React と同様に Riot もアニメーションは担当範囲外のため、アニメーション要素には TweenMax を使用。
できる限りコンポーネントは疎にしておきたいので、別コンポーネントの表示アニメーションの呼び出しは Riot の mixin を利用した。

riot.mixin('schedule', {
  showSchedule: () => this.show(),
  hideSchedule: () => this.hide()
})
this.on('mount', () => {
  this.mixin('schedule')
  this.update()
})

dotenv

motdotla/dotenv

実は main プロセスで呼び出しておくと process.env は renderer プロセスにも伝わるので、初期値の共有としても便利。
.env のパスは __dirname を使って明示的に指定しておかないとパッケージング後に読み込めないので要注意。

require('dotenv').config({ path: __dirname + '/../.env' })

oauth

ciaranj/node-oauth

Twitter について、普段はコールバック経由で認証することが多いものの、今回はピンコード方式を採用した。
使い慣れたブラウザを開き、Twitter にログイン済みであれば認可するだけ、ピンコードを見ながら Electron 製アプリケーションへ入力、という流れは UX 的に良いかもしれない。
node-oauth では authorize_callback を null にすることでピンコード方式となる。

const OAuth = require('oauth').OAuth
const oauth = new OAuth(
  'https://api.twitter.com/oauth/request_token',
  'https://api.twitter.com/oauth/access_token',
  process.env.twitter_consumer_key,
  process.env.twitter_consumer_secret,
  '1.0',
  null,
  'HMAC-SHA1'
)

Twitter Streaming API

リツイートについて、text には RT @screen_name: が付与されるだけだと思っていたものの、付与された結果 140 文字を超えたものは三点リーダーで省略されており、この現象は今回初めて気づいた。
retweeted_status.text には全文が入っているので、リツイートに対しては下記のように組み立てることにした。

`RT @${data.retweeted_status.user.screen_name}: ${data.retweeted_status.text}`

あと、ハッシュタグのストリーミングで取得困難な人をたまに見受けるので、定番のものを貼り付けておきます。

node-notifier

mikaelbr/node-notifier

renderer プロセスの Notification ではなく、main プロセスから通知を行うために使用。
このモジュールには各 OS 向けの実行ファイルが含まれており、後述のパッケージングで注意するべき要素になる。

babel

babel/babel

基本的に *.js は es2015 で記述して gulp-babel で変換して使用している。
そのため、今回作ったクラスも export default class で定義して import from で読み込もうとした所、default が入ってこない。
これは babel の仕様が変わったためで、babel-plugin-add-module-exports を使うことで解消した。

テスト

今回のテストは単体限定で薄く書き Electron との関連性は無いため、使用したものだけを以下羅列。

mocha

mochajs/mocha

定番のテストフレームワーク、gulp-mocha で定期実行させた。
es2015 で記述するためには babel-register を忘れずに。

chai

chaijs/chai

mocha のアサーションとして assert を。

rewire

jhnns/rewire

private 要素へのアクセスに。

rosie

rosiejs/rosie

factory_girl にインスパイアされたファクトリ。

faker

marak/Faker.js

ダミーデータの生成に。

パッケージング

electron-packager

maxogden/electron-packager

対象フォルダの electron-packager, electron-prebuilt.git 以外を含めてパッケージングするため、対策を怠ると最終的なファイルサイズが肥大化してしまう。
肥大化の原因は node_modules にあるので、今回は Browserify で必要なコードをまとめ、--ignorenode_modules を加えることで 100 MB 程度縮小された。

electron-packager . tweet-rec --platform=win32,darwin --arch=x64 --version 0.36.7 --out release --app-version 1.0.0 --icon res/icon --ignore '^/(src|test|node_modules|release)/' --asar --asar-unpack-dir vendor --overwrite

browserify

substack/node-browserify

main プロセスと renderer プロセス、それぞれ分けて実行し、結果はライセンス表記を残して圧縮した。

main

browserify --im --no-detect-globals --node dist/main.js | uglifyjs -o dist/main.js --comments

renderer

browserify --im --no-detect-globals --node dist/app.js | uglifyjs -o dist/app.js --comments

vendor

モジュールの中には JavaScript 以外のプログラムに依存しているものもあり、今回の例で言うと node-notifier が該当する。
当然ながら Browserify の対象外のため、必要なファイルはパスが合う位置に node_modules から移しておく。

cp -r node_modules/node-notifier/vendor .

また、このファイルは asar に含めることはできないので、--asar-unpack-dir で除外する。

icon

electron-packager--icon は拡張子を省略すると OS ごとに判別してくれるので、各アイコン画像のファイル名は統一しておく。
ただし、開発環境が Windows 以外の場合は wine に依存するため要インストール。
下記は OS X 環境でのアイコン生成例。

ico

ImageMagickconvert を実行。

convert icon.png -define icon:auto-resize icon.ico

icns

*.iconset フォルダに Optimizing for High Resolution 記載のファイルを納めて iconutil を実行。

iconutil -c icns icon.iconset

まとめ

以前、何かの勉強会で「サザエ実況はガチ」と聞いたとき、
ちょっと何を言っているのかわからなかったのですが、
#sazae を追ってみて理解できた。格が違う。
脚本家の名前で一喜一憂するとか想像すらしていなかったし、
今回得た知見で最も大きい収穫はコレかもしれない。

#sazae 実況の民、しゅごい。

screenshot.png

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
215