ProMotion

最近 RubyMotion ユーザーの間で ProMotion という名前を良く聞くようになった。http://rubymotion-wrappers.com/ の説明を観ると

A full featured RubyMotion framework that makes iPhone development less like Objective-C and more like Ruby, designed to get up and running fast.

となっていて、RubyMotion 向けのフレームワーク、ということらしい。

ドキュメントにあるサンプルコードは以下のようになっていて、

class AppDelegate < PM::Delegate
  def on_load(app, options)
    open RootScreen.new(nav_bar: true)
  end
end

class RootScreen < PM::Screen
  title "Root Screen"

  def push_new_screen
    open NewScreen
  end
end

class NewScreen < PM::TableScreen
  title "Table Screen"

  def table_data
    [{
      cells: [
        { title: "About this app", action: :tapped_about },
        { title: "Log out", action: :log_out }
      ]
    }]
  end
end

なるほど確かに "less Objective-C and more like Ruby" ではある。

RSSリーダー : ビフォア・アフター

ProMotion の使用感を試すために、WEB+DB PRESS の連載記事で作った RSS リーダーの実装を、ProMotion で実装し直してみた。どの程度コードに差が出るだろうか?

ビフォア
class AppDelegate
  def application(application, didFinishLaunchingWithOptions:launchOptions)
    @window = UIWindow.alloc.initWithFrame(UIScreen.mainScreen.bounds)
    @window.rootViewController =
      UINavigationController.alloc.initWithRootViewController(MainViewController.new)
    @window.makeKeyAndVisible
    true
  end
end

class MainViewController < UITableViewController
  FeedURL = 'http://headlines.yahoo.co.jp/rss/all-c_sci.xml'

  def viewDidLoad
    super
    navigationItem.title = 'RSS Motion'

    @ptrview = SSPullToRefreshView.alloc.initWithScrollView(tableView, delegate:self)
    @items ||= []

    fetch_rss(FeedURL) do |items|
      @items = items
      view.reloadData
    end
  end

  def viewDidUnload
    super
    @ptrview = nil
  end

  def fetch_rss (url, &cb)
    items = []
    BW::HTTP.get(url) do |res|
      if res.ok?
        xml = res.body.to_str
        parser = BW::RSSParser.new(xml, true)
        parser.parse do |item|
          items.push(item)
        end
      else
        App.alert(res.error_message)
      end
      cb.call(items)
    end
  end

  def pullToRefreshViewDidStartLoading(ptrview)
    @ptrview.startLoading
    fetch_rss(FeedURL) do |items|
      @items = items
      @ptrview.finishLoading
      view.reloadData
    end
  end

  def tableView(tableView, numberOfRowsInSection:section)
    return @items.size
  end

  def tableView(tableView, cellForRowAtIndexPath:indexPath)
    cell = tableView.dequeueReusableCellWithIdentifier('cell') ||
      UITableViewCell.alloc.initWithStyle(UITableViewCellStyleDefault, reuseIdentifier:'cell')
    cell.accessoryType  = :disclosure.uitablecellaccessory
    cell.textLabel.font = :bold.uifont(14)
    cell.textLabel.text = @items[indexPath.row].title
    return cell
  end

  def tableView(tableView, didSelectRowAtIndexPath:indexPath)
    navigationController << WebViewController.new.tap do |c|
      c.url = @items[indexPath.row].link
    end
  end
end

class WebViewController < UIViewController
  attr_accessor :url

  def viewDidLoad
    super

    view << UIWebView.new.tap do |wv|
      wv.scalesPageToFit = true
      wv.frame = self.view.bounds
      wv.loadRequest(NSURLRequest.requestWithURL(url.nsurl))
      wv.delegate = self
    end

    @indicator = UIActivityIndicatorView.gray.tap do |iv|
      iv.center = [view.frame.size.width / 2, view.frame.size.height / 2 - 42]
    end

    view << @indicator
  end

  def webViewDidStartLoad(webview)
    @indicator.startAnimating
  end

  def webViewDidFinishLoad(webview)
    @indicator.stopAnimating
    navigationItem.title =
      webview.stringByEvaluatingJavaScriptFromString('document.title')
  end
end
アフター
class AppDelegate < PM::Delegate
  def on_load(app, options)
    open ItemsScreen.new(
      nav_bar: true,
      feed_url: 'http://headlines.yahoo.co.jp/rss/all-c_sci.xml'
    )
  end
end

class ItemsScreen < PM::TableScreen
  attr_accessor :feed_url
  title "RssProMotion"
  refreshable callback: :on_refresh,
    pull_message: "Pull to refresh",
  refreshing: "Refreshing data..."

  def fetch_feed
    BW::HTTP.get(self.feed_url) do |res|
      items = []
      if res.ok?
        BW::RSSParser.new(res.body.to_str, true).parse do |item|
          items.push(item)
        end
      else
        App.alert(res.error_message)
      end

      @items = [{
        cells: items.map do |item|
          {
            title: item.title,
            action: :tapped_item,
            arguments: item,
          }
        end
      }]
      end_refreshing
      update_table_data
    end
  end

  def on_load
    fetch_feed
  end

  def on_refresh
    fetch_feed
  end

  def table_data
    @items ||= []
  end

  def tapped_item(item)
    open WebScreen.new(url: item.link, title: item.title)
  end
end

class WebScreen < PM::WebScreen
  attr_accessor :url

  def on_load
    @indicator ||= add UIActivityIndicatorView.gray, {
      center: [view.frame.size.width / 2, view.frame.size.height / 2 - 42]
    }
  end

  def content
    self.url.nsurl
  end

  def load_started
    @indicator.startAnimating
  end

  def load_finished
    @indicator.stopAnimating
  end
end

と、こんな形になった。

完全に同じ実装ではないので比較はフェアではないけれども、コードの雰囲気がだいぶ変わるということはよく分かる。特に UITableView 周りが PM::TableScreen で置き換えると非常にすっきりしている。

全般的に iOS SDK の API を直接呼んでいるような箇所が減って、ProMotion の API を呼んでいることがコードの削減に寄与している感じ。

感想

説明を読むと "ProMotion is a RubyMotion framework" ということで「フレームワーク」であることを訴えているけど、実際には RubyMotion のフレームワーク・・・というか iOS のフレームワーク、つまりは Cocoa Touch や UIKit をうまくなぞっている設計になっていて、下地になっているフレームワークのパラダイムを変えるとかそういうものではないと思った。こうこう書くとネガティブのようだけど、そうではなく逆で、ポジティブです。

ベースのフレームワークのパラダイムを変更するようなフレームワーク on フレームワーク実装は、抽象化のレベルが上がっていくと下地になるフレームワークでならできるけどその上位のフレームワークを使ってしまうとできない、みたいなことが発生して、総合的には生産性向上に寄与しないなんてことがよくある。ProMotion は薄いラッパというかシンタックスシュガーの集まりによって RubyMotion のフレームワークを覆っているような感じで、例えば普段通り UIView にアクセスしたければ self.view でアクセスできたりと、下のレイヤのフレームワークを隠していない。よって ProMotion でできることは ProMotion で、そうではないところは今まで通りの呼び方で、という風に無理なく抽象度の高い API を使うことができるようになっている。

上記のコードでもそうしている通り BubbleWrap や sugarcube のように RubyMotion のより粒度の細かいイディオムをより Ruby っぽく書けるようにするシンタックスシュガーライブラリとの相性が良くて、ProMotion で外側を、BubbleWrap や sugarcube、あるいは NanoStoreInMotion などなどで中身をそれぞれ覆ってやるとコードの可読性をかなり上げられる。一方で Objective-C API を抽象化しすぎはしないコードになるので、裏で実際にどんな API が呼ばれているかわからないみたいな不安は残らない。悪くない。

ProMotion はまだまだ開発初期段階といったところでスピードが速い。例えばまだ rubygems にアップロードされていないバージョン (version-1.0 ブランチ) を使ってなんぼみたいなフレームワークである。ので、プロダクションみたいなのにはちょっと・・・という考え方もあるとは思うが、まあ、そんな stable な人生を求めるならそもそも RubyMotion を使ってないわけで、「るびもすと」を名乗るなら積極的に使って損はないものであるというのが総合的な感想であります。対象アプリの規模の大小に限らず適用しやすいフレームワークなので、今後は自分も基本的には ProMotion を使って書いていこうかなと思いました。

ちなみに神こと @ainame さんも先日 ProMotion でアプリを作ってその様子をまとめてくださっているので興味のある方は以下のエントリも参照のこと。

teacup や Pixate なんかと組み合わせていくと、より夢がひろがりんぐな感じがしますね。