Hyperloopというフレームワークがあります。
OpalでReactをラップしたフレームワークとなっております。
つまりRubyでWebのフロントエンドを作るためのフレームワークですね。
ところで、WebフロントエンドがあればElectronアプリ化ができます。
つまりHyperloop(生Opalでも良いけど) + Electronで、Rubyを使ったマルチプラットフォーム・デスクトップアプリの開発が可能では?
という事で、やってみました。
対象読者
- Rubyを使ったことがある
- Reactを使ったことがある
Hyperloopアプリ
まず普通にHyperloopアプリを作ってみましょう。
いつもは足し算アプリを作るのですが、今回は気分を変えてTODOアプリにしてみました。JSフレームワークのサンプルっぽいですよね。
Hyperloopの導入
Opalアプリの環境はRubyと同じくBundlerで整える事ができます。
まず前提としてRubyが入っていないといけません。
もしRubyが入っていなければ、何らかの手段でRubyを入れてください。直に入れても、rvmやrbenvを使ってもDockerを使っても良いと思います(個人的にはrbenvかDockerをおすすめします)。
$ ruby -v
ruby 2.4.2p198 (2017-09-14 revision 59899) [x86_64-linux]
適当なディレクトリを切り、その中にさらにディレクトリを作りましょう。
外側がElectron用、内側がHyperloop用です。
$ mkdir -p todo-app/hyperloop
$ cd $_
Gemfileを書きます。
# frozen_string_literal: true
source "https://rubygems.org"
gem "opal"
gem "hyperloop"
そしてbundle install
$ bundle install --path vendor/bundle
これで土台ができました。一般的なRubyのアプリケーションと同じように作れることがわかりますね。
次はJavaScript部分の準備です。
まず、Nodeとnpmが使える必要があります。これも、もし無ければ何らかの手段で用意してください。
$ node -v
v9.3.0
$ npm -v
5.6.0
Hyperloopを使うには、React、jQuery、そしてOpalのライブラリがブラウザから読み込めなければなりません。
単なるWebアプリであればCDNとかを利用しても良いのですが、今回は将来的にデスクトップアプリにする予定なので、JavaScriptのソースをローカルに置くことにします。パッケージ管理は、単にファイルを取ってきて手元に置きたいだけなのでbowerを使ってみました(npmとかでもいけると思います)。
$ npm install -g bower
また、CSSフレームワークとしてspectreを使うことにします。これもbowerで入れます。
{
"main": "dist/bundle.js",
"private": true,
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
],
"dependencies": {
"react": "^15.6.0",
"jquery": "^3.2.0",
"hyperloop-js": "git://github.com/ruby-hyperloop/hyperloop-js.git",
"spectre.css": "^0.4.5"
}
}
bower.jsonを置いたら、必要なライブラリを入れましょう。
$ bower install
フロントエンド・アプリケーション
では、まず単体で動くTODOアプリを作っていきます。
最初にアプリが動くページを作ります。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>TODO List</title>
<!-- React and JQuery -->
<script type="text/javascript" src="bower_components/react/react.min.js"></script>
<script type="text/javascript" src="bower_components/react/react-dom.min.js"></script>
<script type="text/javascript" src="bower_components/jquery/dist/jquery.min.js"></script>
<!-- Opal and Hyperloop -->
<script type="text/javascript" src="bower_components/hyperloop-js/opal-compiler.min.js"></script>
<script type="text/javascript" src="bower_components/hyperloop-js/hyperloop.min.js"></script>
<link rel="stylesheet" type="text/css" href="bower_components/spectre.css/docs/dist/spectre.min.css">
</head>
<body>
<div data-hyperloop-mount="Root" />
<script src="dist/bundle.js"></script>
</body>
</htm>
アプリそのものはSPAとして作り、全てこのHTML上で動かします。
必要なファイルをそれぞれ参照しておきましょう。
ここで注目していただきたいのは、bodyタグ内の
<body>
<div data-hyperloop-mount="Root" />
<script src="dist/bundle.js"></script>
</body>
の部分です。Hyperloopでは、 data-hyperloop-mount
という属性で、マウントするクラス、つまりコンポーネントを指定します。
なのでこの後、Rootという名前のコンポーネントを作ることになります。
Hyperloopアプリケーション
Hyperloopディレクトリの下に、さらにsrcというディレクトリを掘って、その中に、Ruby(実際にはOpalとして処理されるけど)のソースコードを置いていきましょう。
hyperloop
├ src
│ ├ app.rb
│ ├ todo_list.rb
│ └ todo.rb
└ index.html
(上の図には入っていませんが、実際にはGemfileとかbower.jsonとかもあるはずです。)
トップダウンに作っていきます。
まずルートとなるapp.rbファイルを書きましょう。
require_relative "todo_list"
class Root
include Hyperloop::Component::Mixin
def render
DIV(class: "container") do
DIV(class: "columns") do
DIV(class: "column col-8 col-mx-auto") do
H1 { "Todo List" }
TodoList()
end
end
end
end
end
先述したことを覚えているでしょうか。Rootクラスを作りました。
これが、index.htmlのdiv要素にマウントされるコンポーネントです。
Hyperloopでコンポーネントを作る方法は、継承とmixinの2通りがあります。
個人的に継承よりmixinが好きなので、そちらを使いました。Hyperloop::Component::Mixinモジュールをincludeします。
さて、Reactのコンポーネントにはrender関数が必要です。
renderクラスメソッドを使うか、単にrenderメソッドを作りましょう。
Reactでは普通、JSX記法でHTMLを記述しますが、HyperloopではRubyのDSLで記述します。どのような記法かは、コードを見て頂けると何となく分かるかと思います。
def render
DIV(class: "container") do
DIV(class: "columns") do
DIV(class: "column col-8 col-mx-auto") do
H1 { "Todo List" }
TodoList()
end
end
end
end
ここで、 TodoList()
は別のコンポーネントを利用している部分です。
さて、これだけだと何もわからないので、TodoListコンポーネントを見てみましょう。
require_relative "todo"
class TodoList
include Hyperloop::Component::Mixin
state todos: []
state desc: false
def add_todo
if @desc && !@desc.empty?
mutate.todos << Todo.new(@desc)
mutate.desc(true)
end
end
def complete_todo(i)
mutate.todos[i] = state.todos[i].toggle
end
def delete_todo(i)
mutate.todos.delete_at i
end
def render
TABLE(class: "table table-striped table-hover") do
THEAD do
TR do
TH { "#" }
TH do
attrs = {
class: "form-input",
style: { width: "100%" },
type: "text"
}
if state.desc
attrs[:value] = ""
mutate.desc(false)
end
INPUT(attrs).on(:input) { |e|
e.prevent_default
@desc = e.target.value
}
end
TH do
BUTTON(class: "btn") { ">>" }.on(:click) { add_todo }
end
end
end
TBODY do
state.todos.each.with_index do |todo, i|
TR do
TD do
SPAN { "#{(i+1).to_s} / " }
INPUT(
class: "form-checkbox",
type: :checkbox,
checked: todo.status
).on(:change) {
complete_todo i
}
end
TD do
if todo.status
DEL { todo.desc }
else
todo.desc
end
end
TD do
BUTTON(class: "btn") { "×" }.on(:click) { delete_todo i }
end
end
end
end
end
end
end
このコンポーネントがTODOリストのメインですね。
Hyperloop::Component::Mixinモジュールをincludeしているのは先と同じです。
state todos: []
state desc: false
stateを作っている部分です。Hyperloopではstateクラスメソッドでstateと初期値とを設定します。ここではtodosとdescという2つのstateを作っています。
def add_todo
if @desc && !@desc.empty?
mutate.todos << Todo.new(@desc)
mutate.desc(true)
end
end
stateを使う時は state
か mutate
を介します。
state.hoge
で hoge
というstateを取り出し、また muteta.hoge(value)
で新しい値と入れ替えます。その他、mutateで取り出した値を破壊的に変更する事でも、値を書き換える事ができます。
stateが書き換えられると、再レンダリングされます。
INPUT(attrs).on(:input) { |e|
e.prevent_default
@desc = e.target.value
}
要素にイベントを引っ掛けるにはonメソッドを使います。これも、見ての通りですね。
TODOリスト
さて、TODOリストアプリでできることは何でしょう。
- TODOを追加する
- TODOの状態を完了、あるいは未完了にする
- TODOを削除する
この3つができれば、最低限TODOアプリとして使えそうですね。
(保存する、とかもちゃんとしたアプリとして使うならば当然必要だと思うのですが、今回はそれは置いておきます。)
上記したコードで、それらの処理を行っている箇所を見てみましょう。
def add_todo
if @desc && !@desc.empty?
mutate.todos << Todo.new(@desc)
mutate.desc(true)
end
end
def complete_todo(i)
mutate.todos[i] = state.todos[i].toggle
end
def delete_todo(i)
mutate.todos.delete_at i
end
この3つのメソッドが、それぞれの処理、つまりTODOの追加、状態変更、削除に対応しています。
mutateの説明は先章で解説しましたね。
まず、TODOの追加の処理を追います。
def add_todo
if @desc && !@desc.empty?
mutate.todos << Todo.new(@desc)
mutate.desc(true)
end
end
ifで囲んであるのは、空文字列を追加してしまわないようにです。
Todoというクラスのインスタンスを作っています。
このクラスのコードを見てみましょう。
class Todo
attr_reader :desc, :status
def initialize(desc, status = false)
@desc = desc
@status = status
end
def toggle
self.class.new(@desc, !@status)
end
end
このクラスは、 コンポーネントではありません 。単なるRubyのクラスです。
いわゆる不変データを扱う為の構造で、初期化時にのみ設定可能な、読み出し専用のインスタンス変数を持ちます。descとstatusという2つのインスタンス変数は、それぞれTODOの説明と、状態(完了、未完了)を表します。
不変データなので、完了・未完了を変更する場合は、インスタンスの状態を変更するのではなく、新しいクラスを作ってそれを返しています(toggleメソッドがそれ)。
さて、Todoクラスがどういうものかは分かりました。
TODOの追加処理に戻ります。
def add_todo
if @desc && !@desc.empty?
mutate.todos << Todo.new(@desc)
mutate.desc(true)
end
end
mutate.todos
で取り出した値に対するメソッド呼び出しは、値をくるむObservableでフックされ、再レンダリングを引き起こします。なので、 <<
メソッドを使って配列に値を追加すると、状態が新しい配列に自動更新されるわけですね。
その下では、 mutate.desc(true)
の呼び出しでstateの値を入れ替えています。これは、TODOの入力欄を空にする為の処理ですね。
def complete_todo(i)
mutate.todos[i] = state.todos[i].toggle
end
def delete_todo(i)
mutate.todos.delete_at i
end
これはTODOの状態を切り替えるメソッドと削除するメソッドです。
complete_todoは、メソッド名に反して、完了状態のTODOを未完了状態に戻すこともやります。
引数にはTODOのインデックスを取ります(このコンポーネント内では、TODOの判別は全てインデックスで行います)。
先述した通り、 mutate
で呼び出した状態に対するメソッド呼び出しは、フックされて、再レンダリングを引き起こします。
これらのメソッドを、render内の任意のイベントコールバックの中から呼び出して、TODOの追加、変更、削除を行います。
SPAとして動かしてみる
ここまでで、ブラウザ上で動くSPAとしては完成しています。
試しに動かしてみましょう。
まず、RubyのソースコードをOpalとしてJavaScriptにトランスパイルします。
$ mkdir dist
$ bundle exec opal -I. -c src/app.rb > dist/bundle.js
これで、 dist/bundle.js
にバンドルされたJavaScriptファイルが書き出されます。
そして、同じディレクトリで1行サーバを立てましょう。何を使っても良いのですが、Rubyだと以下のコマンドで8080ポートにバインドされたサーバが立ち上がります。
$ ruby -run -e httpd . -p 8080
さて、ブラウザで localhost:8080
にアクセスして確認してみましょう。
無事、アプリが表示され、動いているでしょうか? TODOの追加、状態変更、削除を一通り使ってみましょう。
ここまでで、単にWebアプリでHyperloopを使う方法については、一通り身についたかと思います。
Electronアプリ
この記事のタイトルは「HyperloopとElectronでアプリを作ってみる」なので、上で作ったWebアプリをElectronアプリにしてみましょう。
とはいえ、そう難しいものではないです。
準備
まず、electronが無ければ入れておきます。
$ npm i -g electron
Electronアプリのルートディレクトリ(今回だとHyperloopアプリの1つ上のディレクトリ)に移動し、package.jsonとmain.jsとを設置します。
{
"name": "hyperloop-electron",
"version": "0.1.0",
"main": "main.js"
}
const { app, BrowserWindow, Menu } = require('electron')
const path = require('path')
const url = require('url')
let win
function createWindow () {
win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: { nodeIntegration: false }
})
win.loadURL(url.format({
pathname: path.join(__dirname, 'hyperloop/index.html'),
protocol: 'file:',
slashes: true
}))
win.webContents.openDevTools()
win.on('closed', () => {
win = null
})
}
app.on('ready', createWindow)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (win === null) {
createWindow()
}
})
動かしてみる
それから徐にelectronコマンドを叩きます。
$ electron .
これで、ElectronアプリとしてTODOアプリが起動したかと思います。
デバグ用のコンソールが出ているかと思いますが、これは win.webContents.openDevTools()
の部分をコメントアウトすれば消えます。
別の記事で紹介したツールを利用すれば、実行ファイルとしてまとめる事も簡単にできます。
無事に、HyperloopとElectronでデスクトップアプリを作る事ができました。
まとめ
OpalのフレームワークであるHyperloopで、つまり ほぼRuby で、デスクトップアプリを作る事ができました。Rubyを使って楽しくデスクトップアプリを作る事ができれば、嬉しい事この上ないですね。
今回の記事で書けなかった(書かなかった)事で、重要だろうと思われる項目には以下のようなものがあります。
- メニューやショートカットの設定
- IOの取り扱い
このあたりはHyperloopというフレームワークの中では完結しない事柄なので、私の技倆不足もあり解説ができなかった所ですが、実用的なアプリケーションを作り上げるには避けて通れないものだと思いますので、Electronを本格的に利用してみようと思われる方は是非とも調査していただけたらと思います。