Hatena::ブログ(Diary)

naoyaのはてなダイアリー

August 31, 2012

RubyMotion

ちょっと前に RubyMotion を触ってみてこれは面白いなと思いブログにでも書こうかと思った矢先にドラゴンクエスト10が発売してしまい、あれよあれよといううちに一ヶ月経ってしまいました。

それはさておき「るびも」こと RubyMotion ─ いや、るびもと呼んでいるのは自分だけですけど。Ruby で iOS のネイティブアプリが書けるというツールチェイン。コンパイラ、テストスイート、プロジェクト作成用スクリプトその他を含みます。主に CUI はターミナルでのコンパイルを想定していて、Xcode で開発するのに比べるとだいぶ *nix してるわーという気分になれる代物です。iOS アプリなのに Ruby! iOS アプリなのに CUI! ・・・ これだけでワクテカな方も多いかなと思います。

以下そんなるびもちゃんRubyMotion 様をざっと紹介していきたいと思います。なお、あらかじめ断っておきますが RubyMotion は無料ではなく有料でその利用には年間ライセンス (16,000 円程度) が必要ですのでご注意くださいませ。

RubyMotion のコードはどんなものか

iOS のアプリが Ruby で書ける・・・LL厨歓喜と言ったところですが実際のコードの様子はどんなものでしょうか。実例を見るのが早いですね。

% motion create HelloWorld
    Create HelloWorld
    Create HelloWorld/.gitignore
    Create HelloWorld/Rakefile
    Create HelloWorld/app
    Create HelloWorld/app/app_delegate.rb
    Create HelloWorld/resources
    Create HelloWorld/spec
    Create HelloWorld/spec/main_spec.rb

とこんな感じで CUI でプロジェクトを作成しアプリケーション開始の起点になる app_delegate.rb を書きます。エディタは emacs でも vim でも Sublime Text 2 (ST2) でも、Ruby が書きやすいものを好みで。ちなみに ST2 は諸々 RubyMotion 対応が有志の手で進められています。

もとい、app_delegate.rb

class AppDelegate
  def application(application, didFinishLaunchingWithOptions:launchOptions)
    @window = UIWindow.alloc.initWithFrame(UIScreen.mainScreen.bounds).tap do |w|
      w.rootViewController = UINavigationController.alloc.initWithRootViewController(HelloViewController.new)
      w.makeKeyAndVisible
    end
    true
  end
end

UIWindow を作ってその中に UINavigationController を設定して・・・と。ここまで「なんか見覚えのあるコードだなあ」と思った方もいるでしょうし「UINavigationController とか Ruby っぽくない長ったらしいクラス名のそれ何だ」と思った方もいるでしょう。その辺りは後ほど。

HelloWorld を表示させるビューを管理する、HelloViewController クラスも作ります。以下、hello_view_controller.rb

class HelloViewController < UIViewController
  def viewDidLoad
    super

    navigationItem.title = "Hello"
    view.backgroundColor = UIColor.whiteColor

    UILabel.new.tap do |label|
      label.frame = [[10, 10], [320, 20]]
      label.font  = UIFont.boldSystemFontOfSize(16)
      label.text  = "Hello, World!"
      view.addSubview(label)
    end
  end
end

そしてできたら、ほのぼのrake。

% rake
     Build ./build/iPhoneSimulator-5.1-Development
   Compile ./app/hello_view_controller.rb
   Compile ./app/app_delegate.rb
    Create ./build/iPhoneSimulator-5.1-Development/HelloWorld.app
      Link ./build/iPhoneSimulator-5.1-Development/HelloWorld.app/HelloWorld
    Create ./build/iPhoneSimulator-5.1-Development/HelloWorld.app/Info.plist
    Create ./build/iPhoneSimulator-5.1-Development/HelloWorld.app/PkgInfo
    Create ./build/iPhoneSimulator-5.1-Development/HelloWorld.dSYM
  Simulate ./build/iPhoneSimulator-5.1-Development/HelloWorld.app
(main)>

これでアプリがビルドされてシミュレータが立ち上がります。

f:id:naoya:20120831193918p:image:w200

無事、Hello, World! とでました。

RubyMotion の特徴

ここまでのソースコードでお分かりの通り、Ruby で iOS アプリが書けるといっても puts "Hello, World" でいい感じのアプリができるとそういうわけではなく、基本的には iOS の普通の開発の様式つまりは Cocoa フレームワーク (iOS SDK) の API に従って書く。先に出てきた UINavigationController は iOS SDK に含まれる、iOS アプリのナビゲーションバーを使うためのクラスというわけでした。

iOS アプリをスクリプト言語で・・・というと真っ先に思い出すのが Titanium Mobile ですが、Titanium Mobile は Titanium 独自のSDKを内包していてアプリケーションを作るときは基本そのSDKの様式に従ってコードを書きます。Titanium Mobile の SDK は iOS SDK そのものを使うよりもより簡単に、よりウェブ開発に近いような形で記述できるように配慮されているので、誤解を恐れずに言えば Titanium Mobile では「iOS SDK そのものよりも簡単に」アプリが作れます。

一方の RubyMotion のそれはシンタックスこそ Ruby ですが実際には iOS SDK をそのまま呼び出しているのに等しいので、決して「iOS SDK そのものを使うよりも簡単に」アプリが開発できるわけではない。ただ例によってそこは Ruby なので、より短いコードでアプリが書けます。Objective-C の鉤括弧に付き合う必要はない。NSArray や NSDictionary は Ruby の Array と Hash にそれぞれマッピングされているので、そこは Ruby のリテラルで書くことができたりと、最初は「結局 Objective-C で書くのとそんなに大差ないのでは・・・」と思っているのが書き慣れていくうちにだんだんと「とはいえ Ruby で書ける方がやっぱりいいな」という気分になれる・・・というものです。

RubyMotion が iOS SDK をそのままなぞっているのは、コンパイルの恩恵 ─ LLVM のバイトコードに直接コンパイルされる・・・つまり速い、という形で享受されます。Objective-C のコードのジェネレータになっているとか、あるいは Ruby VM が載っていてインタプリタとしてコードを解釈するとかではなく、Ruby から直接 LLVM のバイトコードです。気になるメモリ管理は RubyMotion が内部で独自にリファレンスカウンタ方式のそれを使っています。

もう少し凝ったもの ─ RSSリーダーを RubyMotion で

Hello, World だけでは味気ないのでもうすこしだけ気の利いたコードを見てみます。簡単な RSS リーダーです。

f:id:naoya:20120831193919p:image:w200 f:id:naoya:20120831193920p:image:w200

RSSを取ってきて UITableView で表示し、セルをタップすると UIWebView の画面に遷移するというよくあるサンプルです。RSS をそのまま parse してもいいのですが楽をするためにまず RSS を JSON に変換するサーバーを書いてしまいます。この辺は node.js で。xml2js を使えば RSS -> JSON への変換は3行です。

express = require 'express'
http = require 'http'

request = require 'request'
xml2js  = require 'xml2js'

app = express()

app.configure ()->
  app.set 'port', process.env.PORT || 3000
  app.use express.logger('dev')
  app.use express.bodyParser()
  app.use express.methodOverride()

app.configure 'development',() ->
  app.use express.errorHandler()

http.createServer(app).listen app.get('port'), () ->
  console.log "Express server listening on port " + app.get('port')

app.get '/', (req, res) ->
  parser = new xml2js.Parser()
  parser.addListener 'end', (result) ->
    res.send result

  request 'http://d.hatena.ne.jp/naoya/rss2', (err, response, body) ->
    if not err and response.statusCode is 200
      try
        parser.parseString body
      catch e
        console.log e

ローカルにこのサーバを立ち上げておいて、iOS アプリからはその URL をエンドポイントとして参照します。

ではアプリのコードを。app_delegate.rb は先ほどとほとんど一緒。

class AppDelegate
  def application(application, didFinishLaunchingWithOptions:launchOptions)
    @window = UIWindow.alloc.initWithFrame(UIScreen.mainScreen.bounds).tap do |w|
      w.rootViewController = UINavigationController.alloc.initWithRootViewController(ItemsViewController.new)
      w.makeKeyAndVisible
    end
    true
  end
end

本丸の、items_view_controller.rb。ここが HTTP で JSON を取ってきて整形して TableView に表示する、本丸です。

class ItemsViewController < UITableViewController
  def viewDidLoad
    super

    @feed = nil

    self.navigationItem.title = "RSS Reader"
    self.view.backgroundColor = UIColor.whiteColor

    BW::HTTP.get('http://localhost:3000/') do |response|
      if response.ok?
        @feed = BW::JSON.parse(response.body.to_str)
        view.reloadData
      else
        App.alert(response.error_message)
      end
    end
  end

  def tableView(tableView, numberOfRowsInSection:section)
    if @feed.nil?
      return 0
    else
      @feed[:channel][:item].size
    end
  end

  def tableView(tableView, cellForRowAtIndexPath:indexPath)
    cell = tableView.dequeueReusableCellWithIdentifier('cell') ||
      UITableViewCell.alloc.initWithStyle(UITableViewCellStyleDefault, reuseIdentifier:'cell')

    cell.accessoryType  = UITableViewCellAccessoryDisclosureIndicator
    cell.textLabel.font = UIFont.boldSystemFontOfSize(14)
    cell.textLabel.text = @feed[:channel][:item][indexPath.row][:title]

    return cell
  end

  def tableView(tableView, didSelectRowAtIndexPath:indexPath)
    WebViewController.new.tap do |c|
      c.item   = @feed[:channel][:item][indexPath.row]
      navigationController.pushViewController(c, animated:true)
    end
  end
end

どうということのないコードですが、途中の BW::HTTP という箇所がひとつポイントです。これは BubbleWrap (https://github.com/rubymotion/BubbleWrap) という RubyMotion 向けのライブラリを用いて HTTP 通信を行う処理です。これを見せたいがためにHTTP 通信の発生するサンプルを用意しました。

iOS アプリで非同期 HTTP 通信のコードを書いた経験がある方はよくご存じかと思いますが、素で書こうとするとなかなかに面倒くさい。そこでサードパーティのライブラリ (ASIHTTPRequest や AFNetworking など) を使うわけですが。RubyMotion でも同様に素で HTTP を書くとちょっと面倒なところ、BubbleWrap を使えば非常に簡単に書けるし、さらにコードブロックを使って Ruby らしい記述で書ける。

この BubbleWrap のように、RubyMotion 用のラッパーライブラリの開発がコミュニティを中心に盛んに行われているのが RubyMotion の楽しいところ。自分は他にもビューのレイアウトを css 風に記述できる Teacup (https://github.com/rubymotion/teacup)、BubbleWrap 同様にさまざまなシンタックスシュガーを提供する sugarcube (http://fusionbox.org/projects/rubymotion-sugarcube/)、iOS のフォーム周りを DSL で記述できる Formotion (https://github.com/clayallsopp/formotion) などを良く使います。(たとえ BubbleWrap を使わなくても、Grand Central Dispatch を用いたマルチスレッドのコードも、Ruby 風にすっきり書けるようになっているので並行処理の精神的面倒くささはかなり取り除かれているという感想です。)

RubyMotion 単独では iOS SDK そのままのコードを書くことになり、Ruby であることのメリットが十分享受できているとは言い難いところ、BubbleWrap などのサードパーティライブラリを導入するとずっとそのメリットが大きくなります。(※ただし、既存の rubygem をそのまま使うことはできません。) 個人的には RubyMotion がより上位のフレームワークを提供することはせずに Ruby から LLVM バイトコードへのコンパイルのみに徹している薄い設計を採用したことが、このようなサードパーティライブラリ開発を容易にしていることに繋がっているように思い、ここが RubyMotion の設計思想の一つの利点だなと感じます。

さて、最後に、WebView。ここは至って普通のコードです。

class WebViewController < UIViewController
  attr_accessor :item

  def viewDidLoad
    super

    self.navigationItem.title = self.item[:title]

    @webview = UIWebView.new.tap do |v|
      v.frame = self.view.bounds
      v.scalesPageToFit = true
      v.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(self.item[:link])))
      v.delegate = self
      view.addSubview(v)
    end

  end
end

以上、シンプルなRSSリーダーを RubyMotion で作る、でした。

そのほか雑感

所感を交えつつここまで紹介してきましたが、もう 2, 3 点思うところを述べます。

RubyMotion は結局 iOS SDK の使い方がわからなければ使えません。RubyMotion を使ってもやっぱり iOS SDK のリファレンスのお世話になりっぱなしです。「より簡単に書ける」ということを期待する向きにはすこし期待外れかもしれませんがここは実際にはメリットかもしれません。というのは、結局 iOS SDK のアーキテクチャそのままなので、Objective-C と RubyMotion との間での知識移転が容易なんです。RubyMotion の開発での知見が Objective-C そのままで開発するときにもほぼそのまま役に立つ。かく言う自分がそんなに Objective-C でゴリゴリとアプリを作った経験があるわけではなかったので、RubyMotion を通じて Cocoa フレームワークのいろいろな API を覚えていった感じでした。

もう一つ、Objective-C でできることはほぼ RubyMotion でできるという安心感が得られるというのもあります。Titanium Mobile のように別のSDKを使う形になると、Titanium SDK の API が用意していないことをやりたくなったときに結局低レイヤの実装を自分で用意する必要がでてきたりする。そういう煩わしさは RubyMotion にはありません。

そして速度。Titanium Mobile をはじめ PhoneGap そのほか、iOS アプリをより手軽に開発できる系のものはいろいろ触ってみましたが、いずれも速度面で満足のいく実装はなかなかありませんでした。PhoneGap などの HTML5 系は論外として、Titanium Mobile も結局中で JavaScript を VM で動かしているので体感でわかる程度に遅さを感じることがありました。LLVM に直接コンパイルする RubyMotion にはその点でも期待が持てます。

・・・ なんだか Titanium Mobile との比較みたいな話が多くなってしまいました。しかも RubyMotion べた褒め・・・とはいえ、RubyMotion も (MacRuby から数えると結構枯れてはいるものの) まだまだ若い実装なので、結構な地雷を踏むこともありますし、デバッグメッセージがいまいちなど欠点はそれなりにあります。結局この手の上位のフレームワークあるいはミドルウェアというのは、それを利用したときのリスクが許容範囲かどうか次第ですね。RubyMotion は今のところ、自分が個人で開発するものに関しては十分許容範囲内。慣れるとなかなか、Objective-C で同じ物を作ろう・・・という気にはなりません。

参考URL

最後に、RubyMotion に興味を持たれた方のために参考 URL をいくつか並べておきます。

  • RubyMotion JP
    • 有志の RubyMotion 愛好家によるサイト。まだできたばかりです
    • チュートリアルなどの和訳がここにまとまってます。
  • The RubyMotion Way (英語)
    • RubyMotion がどんなものかをオーバービュー的に知りたい方へ
    • Hacker News で話題になってました
  • Page not found · GitHub Pages
    • satococoa さんによる FAQ
    • 実際の所どうなの? といういろいろ気になる部分への回答があると思います
  • RubyMotion ではじめるGoogle Analytics for iOS - Watson’s Blog
    • ちょっと一つだけ粒度が違う感じのURLを紹介しますが、Objective-C で書かれたサードパーティのライブラリを利用する例です
    • Objective-C のライブラリも使えるんだよ、ということで。(ちなみに Cocoapods も使えますよ)