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 なんかと組み合わせていくと、より夢がひろがりんぐな感じがしますね。