2026年、RubyでAndroidアプリを書く:JRuby 10とRuboto
Rubotoを使うと、AndroidアプリをRubyで書けます。端末の上でJRubyが動き、ActivityはAndroid APIを直接呼び出すRubyのクラスです。ただ、このプロジェクトは止まっていました。本家の最後のコミットは2023年5月で、対象はJRuby 9.4です。
もう一度使えるようにしたくて、フォークしました:github.com/Largo/ruboto。このフォークは JRuby 10.1(Ruby 3.4互換)で、Android 6.0からAndroid 16までのスマートフォン、タブレット、Wear OSのウォッチで動きます。Android Studioは不要です。必要なのはJDK、Android SDK、Rubyだけです。
これで作った最初の本格的なアプリは、Frameoのデジタルフォトフレームで動いています。詳しくは後ほど。
クイックスタート
JDK 17以降、Android SDK(ANDROID_HOMEを設定)、Gradle、Rubyが必要です。
git clone https://github.com/Largo/ruboto.git
cd ruboto && bundle install && cd ..
# アプリを生成。最新のJRubyのjarもlibs/にダウンロードされます。
ruby -Iruboto/lib ruboto/bin/ruboto gen app --package org.example.todo --name Todo --path todo
# サンプルのTodoリストをアプリとして使う
cp ruboto/assets/samples/todo_activity.rb todo/app/src/main/resources/todo_activity.rb
cd todo && gradle assembleDebug
adb install app/build/outputs/apk/debug/app-debug.apk
ruboto gen appはbundle execではなく、普通のrubyで実行してください。Bundlerがjruby-jars gemを隠してしまい、JRubyがアプリにコピーされなくなります。
アプリ全体はapp/src/main/resourcesにあるRubyファイル1つです。編集して、ビルドして、インストールし直す。開発サイクルはそれだけです。
コードの見た目
ActivityはRubyのクラスです。ruboto/widgetには、レイアウト用の小さなDSLがあります。
require 'ruboto/widget'
require 'ruboto/toast'
ruboto_import_widgets :Button, :EditText, :LinearLayout, :ListView
class TodoActivity
def onCreate(bundle)
super
set_title 'Ruboto Todo'
@todos = []
self.content_view =
linear_layout orientation: :vertical, fits_system_windows: true do
@new_todo = edit_text hint: 'What needs doing?'
button text: 'Add', on_click_listener: proc { add_todo }
@list = list_view list: @todos.dup
end
end
def add_todo
@todos << @new_todo.text.to_s
@list.reload_list(@todos.dup)
toast 'Added'
end
end
DSLを使わず、Javaと同じようにAndroidのクラスを呼ぶこともできます。java_import 'android.widget.TextView'して、TextView.new(self)。Android APIはすべて使えるので、ラッパーができるのを待つ必要はありません。
直したところ
JRuby 10はモダンなJVM向けに書かれています。AndroidのランタイムであるARTはそうではありません。Rubotoは、アプリにJRubyをインストールするときに、JRubyに同梱されているASMライブラリでJRubyのjarを書き換えます。主なポイント:
- 実行時のクラス生成ができない。 ARTは実行時に生成したバイトコードを読み込めません。JRubyは普段、メソッドの呼び出し、動的スコープ、Javaインターフェースの実装でこれを使います。フォークでは呼び出し用のクラスをビルド時に生成し、固定のスコープクラスを使い、Javaインターフェースは
java.lang.reflect.Proxyで実装します。ブロック、Proc、Runnable.impl { }、include SomeJavaInterfaceはどれもコールバックとして使えます。 - 足りないJDKクラス。
java.lang.ModuleとSwitchPointはAndroidにありません。ClassValueはAndroid 14からで、JRuby 10.1は起動時にこれを必要とします。InputStream.readAllBytesやBigInteger.TWOなど、端末より新しいJDKメソッドの呼び出しはバックポートに差し替えます。 - Android 6から12。 JRuby 10はあちこちで
java.lang.invokeを使い、VarHandleが公開APIになったのはAndroid 13からです。--min-sdkを下げると、Rubotoはこれらの呼び出しを、リフレクションとsun.misc.Unsafeで作った小さな代替に移します。Android 8未満では、core library desugaringでjava.nio.fileとjava.timeを補います。 - Fiber。 JRubyはFiberと
Enumerator#nextを仮想スレッドで動かします。Androidにはないので、普通のスレッドで動かします。 - 標準ライブラリ。
stringio、strscan、json、psychのJava部分はJRubyの中にjarとして入っていて、Androidは実行時にjarを読み込めません。そのクラスをアプリに取り込みます。Ruby 3.4からbigdecimal、csv、logger、base64、rexmlなどはbundled gemになり、普段はRubyGems経由で見つかります。Rubotoはこれらをロードパスにコピーします。 - RubyGemsはオフ。 APKの中のgemspecを読めないからです。それでもピュアRubyのgemは使えます。gemを展開して、
libフォルダをapp/src/main/resourcesに入れるだけです。Todoのサンプルはhumanize gemでこれをやっています。
C拡張のあるgemは動きません。opensslもありません。RubyでJavaクラスを継承する(class MyList < java.util.ArrayList)には今でも実行時のクラス生成が必要で、失敗します。HTTPSには、Rubyからjava.net.HttpURLConnectionを使えば問題ありません。
変更はすべてテストしています。Android SDKなしで動くJVMテストと、端末上のスモークテストです。スモークテストはRubyの機能、標準ライブラリ、bundled gem、ソケット、net/http、Java連携を65項目で確認し、Android 13とAndroid 6のエミュレータで通ります。
Frameoのフォトフレーム
Frameoは安いWi-Fiフォトフレームです。私のものは1920×1200の画面、Cortex-A7を4コア持つ32ビットのRockchip RK312x、RAM 1 GB、Android 6.0.1です。Frameoのアプリだけが動き、ほかには何もありません。でも隠しスイッチがあります。設定 › 情報 › ベータプログラム、それからADBアクセス。これでadb installが使えます。
Frameoのクラウドを使わないフォトフレームが欲しかったので、自分で書きました。Rubyで約1300行です。
- 写真を全画面で表示し、15秒から30分ごとにクロスフェードで切り替えます。シャッフルでも順番でも。
- ポート8080の小さなWebサーバーで、同じWi-Fiのスマートフォンやパソコンから写真をアップロード、表示、削除できます。パスワードで保護しています。写真はブラウザでアップロード前に画面サイズに縮小し、フレームでもう一度チェックします。
- 写真はストレージの4分の1までしか使わず、常に1 GBの空きを残します。
- 小さなオーバーレイに、2つの都市の時刻と、MeteoSwissの今日の天気を表示します。
- 画面をタップすると、Webアドレス、パスワード、次の写真とアプリを閉じるボタンが出ます。
Webサーバーはjava.net.ServerSocketの上に書いた約160行のRubyです。接続を1つずつ処理し、アップロードはそのままストレージに書き込みます。1 GBのRAMをFrameoのアプリと分け合うからです。写真はバックグラウンドのExecutorでAndroidのBitmapFactoryを使ってデコードし、HandlerのメッセージでUIスレッドに渡します。
@handler = Handler.new(Looper.getMainLooper, proc { |message| handle_message(message) })
@decoder = java.util.concurrent.Executors.newSingleThreadExecutor
def display(name)
path = PhotoFrame.store.path(name)
@decoder.execute(proc do
bitmap = decode(path)
post_ui(:fade_to, [name, bitmap]) if bitmap
end)
end
Frameoのファームウェアは、起動時にアプリが自分で立ち上がることを許しません。回避策は、android.intent.category.HOMEのintent filterで、Activityをホーム画面アプリとして登録することです。Androidがどのホームアプリを使うか聞いてくるので、選んでおけば再起動後に私のアプリが立ち上がります。「Close photo frame」ボタンはFrameoのアプリにホームのintentを送り、Frameoは自分のスライドショーを再開します。
速くはありません。このハードウェアではJRubyの起動にスプラッシュ画面の裏で約20秒かかります。Android 13未満ではjava.lang.invokeの代替がリフレクションを使うので、インスタンス変数やJavaの呼び出しも遅くなります。でもフォトフレームなら問題ありません。一度起動したら何週間も動き続けます。JRubyが立ち上がってしまえば、1920×1200の写真のクロスフェードもアップロードもスムーズです。
フォークがAndroid 6に対応したのは、このアプリのためです。それまでの最低バージョンはAndroid 13でした。
Wear OSとIRB
同じアプリがWear OSでも動きます。これでウォッチ用のEPUBリーダーを作りました。フォークにはIRBのサンプルが2つあります。スマートフォン用は、プロンプトでgem installしてピュアRubyのgemを入れられます。ウォッチ用は画面に合わせたもので、音声入力ボタンがあります。ウォッチでRubyを打つのはつらいからです。
Wear OSで知っておくべきことが2つあります。スワイプで戻っても、古いonBackPressedは呼ばれません。android:enableOnBackInvokedCallback="true"を設定し、on_back_invoked_dispatcherにProcを登録してください。それから、Javaのフィールドは名前がそのままです。display_metrics.widthPixelsは動きますが、width_pixelsは動きません。JRubyがsnake_caseの別名を作るのはメソッドだけです。
使うべきか
個人用のツール、キオスク端末、自分の持っているデバイス、午後だけで書きたいアプリなら、使えます。Rubyを書けば、Android APIがすべて使え、KotlinもComposeもAndroid Studioもいりません。AndroidのJRubyはインタプリタで動き、起動に時間がかかり(ウォッチや古いフォトフレームで約20秒)、デバッグ版APKは約20 MBです。100万人が使うアプリなら、私は今でもKotlinを選びます。
フォークはgithub.com/Largo/rubotoにあります。READMEには、クイックスタートの全文、古いAndroidバージョン、gem、Javaコールバックについての注意、IRBのサンプルがあります。IssueやPull Requestは歓迎します。