How to Build a Twitter Search Client in Ruby Motion
This post is inspired by a StackOverflow question asking how to parse JSON returned from Twitter using BubbleWrap. My answer is on there, but I wanted to provide some insight into how I arrived at it. Full source for this project is on GitHub in my Ruby Motion examples project.
The original poster assumed that BubbleWrap, and specifically BW::HTTP, was in error. Initially I concurred, but decided to write a simple Twitter search client to verify the assumption that BubbleWrap was to blame. Spoiler alert: BubbleWrap works just fine. But here are some twists, turns and a bit of example code you might like along the way.
Here was the initial poster’s code:
def create_tweets
BW::JSON.parse(twitter_search_results)["results"].each do |result|
@tweets << Tweet.new(result)
end
@tweets
end
Initially, I looked at the reported error message:
-[__NSCFString bytes]: unrecognized selector sent to instance 0xea1f800
2012-11-08 17:01:32.685 Hello[39940:c07] json.rb:20:in `parse:':
NSInvalidArgumentException:-[__NSCFString bytes]: unrecognized selector
sent to instance 0xea1f800 (RuntimeError)
An unrecognized selector. Hmmm. That can’t be good. The is an exercise in figuring out why that might be true. A peek at the BubbleWrap code in question was not enlightening. It looked as though there was, indeed, a subtle problem with BubbleWrap, so I tried implementing a simple Twitter search client with JSONKit CocoaPod instead of BubbleWrap.
The Simplest App That Can Work
I have a favorite skeleton app I create using:
$ motion create twitter
I’ll put everything in app_delegate.rb, so I open that and change the code (some trickery in the definition of window
courtesy of @colinta, I believe) to look as follows:
class AppDelegate
def application(application, didFinishLaunchingWithOptions:launchOptions)
window.makeKeyAndVisible
true
end
def window
@window ||= begin
w = UIWindow.alloc.initWithFrame UIScreen.mainScreen.bounds
@controller = TweetViewController.new
w.rootViewController = @controller
w
end
end
end
class TweetViewController < UITableViewController
@@cell_identifier = nil
def viewWillAppear(animated)
super
self.view.backgroundColor = UIColor.whiteColor
end
def tableView(tableView, numberOfSectionsInTableView:section)
1
end
def tableView(tableView, numberOfRowsInSection:section)
1
end
def tableView(tableView, cellForRowAtIndexPath:indexPath)
cell = tableView.dequeueReusableCellWithIdentifier(cell_identifier)
if not cell
cell = UITableViewCell.alloc.initWithStyle UITableViewCellStyleValue2,
reuseIdentifier:cell_identifier
cell.textLabel.text = 'hello'
end
cell
end
def cell_identifier
@@cell_identifier ||= 'TwitterTableViewCell'
end
end
Run this:
$ rake
and you’ll see an empty table with just the one text label “hello” in the simulator. You’ll see I put empty delegate methods for the tableView
because I knew I’d need them soon.
Tracking Down and Installing A CocoaPod
A simple Google search turns up JSONKit, but there’s nothing that tells you want JSONKit does or how to install it. So I turned to the Ruby Motion motion-cocoapods page.
Soon, I had modified my Rakefile as follows:
# -*- coding: utf-8 -*-
$:.unshift("/Library/RubyMotion/lib")
require 'motion/project'
require 'bundler'
Bundler.require
Motion::Project::App.setup do |app|
# Use `rake config' to see complete project settings.
app.name = 'twitter'
app.pods do
pod 'JSONKit'
end
end
You’ll see a couple of things here. First, I’ve moved to Bundler. That means that somewhere I created a Gemfile. Righto! And here it is:
gem 'bubble-wrap', :git => 'https://github.com/rubymotion/BubbleWrap.git'
gem 'motion-cocoapods'
A simple:
$ bundle install
and all the gems I need are installed. I installed BubbleWrap because, ultimately, this was about testing BubbleWrap to see whether it had a bug. But back to the Rakefile. As you can see, there is a new part of the app setup section where I specify the JSONKit pod:
app.pods do
pod 'JSONKit'
end
That’s all there is to it…or is that all? Now that the question is what the heck methods does JSONKit implement? To answer that question,
I have to run rake again, which pulls the CocoaPod down and compiles it into the vendor directory. Now that it’s there, I can prowl around
the header files (the ones ending in .h) to see what I can see. Ahhhh, vendor/Pods/JSONKit/JSONKit.h seems like a likely candidate. Let’s
see what’s there.
Skimming down, I see:
@interface NSString (JSONKitDeserializing)
// other stuff..
@interface NSString (JSONKitDeserializing)
// ...
- (id)mutableObjectFromJSONStringWithParseOptions:
(JKParseOptionFlags)parseOptionFlags
error:(NSError **)error;
Adding a Call to JSONKit
Oh, goodie, Objective-C, so what does this mean in Ruby Motion? Well, it turns out, this is what it means:
parsed = response.body.to_str.objectFromJSONStringWithParseOptions(
JKParseOptionValidFlags,
error: error_ptr
)
I could have chosen the variant without the error_ptr, but I wanted to make certain I found out where
the problem was in parsing the JSON. As you can see, the Objective-C adds a method to NSString that
is usable in Ruby Motion as String#objectFromJSONStringWithParseOptions(parse_options, error: error_ptr).
So let’s modify our code a bit to use this:
class AppDelegate
class TweetViewController < UITableViewController
# ...
def viewWillAppear(animated)
super
self.view.backgroundColor = UIColor.whiteColor
# New Stuff
@feed = []
timer_fired(self)
@timer = NSTimer.scheduledTimerWithTimeInterval(2,
target:self,
selector:'timer_fired:',
userInfo:nil, repeats:true)
###########
end
# New Stuff
def viewWillDisappear(animated)
@timer.valid = false
@timer = nil
end
def timer_fired(sender)
@twitter_accounts = %w(dhh google)
query = @twitter_accounts.map{ |account| "from:#{account}" }.join(" OR ")
url_string = "http://search.twitter.com/search.json?q=#{query}"
error_ptr = Pointer.new(:object)
BW::HTTP.get(url_string) do |response|
parsed = response.body.to_str.objectFromJSONStringWithParseOptions(
JKParseOptionValidFlags,
error: error_ptr
)
if parsed.nil?
error = error_ptr[0]
puts error.userInfo[NSLocalizedDescriptionKey]
@timer.valid = false
else
parsed.each do |item|
puts item.inspect
end
end
end
end
###########
# etc.
end
In this step, I’ve done a few things:
- Added a timer to ping Twitter every two seconds
- Added an empty array to contain tweets
- Added a handler to respond to timer events by fetching tweets
Really, the only code you need to focus on here is timer_fired, because that’s were we are getting Twitter’s JSON.
Really, what I’m trying to do here is make sure the JSON Twitter is shipping back conforms to what the original
poster of the StackOverflow question expected. So, running this from rake, I get a screenful of JSON.
["since_id_str", "0"]
["page", 1]
["refresh_url", "?since_id=267714169787265024&q=from%3Adhh%20OR%20from%3Agoogle"]
["results", [{"from_user_name"=>"DHH", "to_user_name"=>"Dave Clayton", etc...
This tells me all I need to know about the original problem. Twitter ships some metadata before the results. As we are only interested in results, we can skip to them and use the results directly.
parsed.each do |item|
next if item[0] != 'results'
@feed = []
item[1].each do |tweet|
@feed << {:from => tweet['from_user'], :text => tweet['text']}
self.view.reloadData
puts "from: #{tweet['from_user']}"
puts "tweet: #{tweet['text']}"
end
end
Adding a Little UI Sugar
Now that I have populated my @feed variable, I need to do something to make the tweets appear in
the UITableView:
# ...
def tableView(tableView, numberOfRowsInSection:section)
@feed.length
end
def tableView(tableView, cellForRowAtIndexPath:indexPath)
cell = tableView.dequeueReusableCellWithIdentifier(cell_identifier)
if not cell
cell = UITableViewCell.alloc.initWithStyle UITableViewCellStyleSubtitle,
reuseIdentifier:cell_identifier
cell.selectionStyle = UITableViewCellSelectionStyleNone
tweet = @feed[indexPath.row]
cell.textLabel.text = tweet[:from]
cell.detailTextLabel.text = tweet[:text]
url = NSURL.URLWithString tweet[:image]
data = NSData.dataWithContentsOfURL url
image = UIImage.imageWithData data
cell.imageView.image = image
end
cell
end
And that’s really it! A Twitter search app in only a few minutes.
Did I Solve The Original Problem?
Well, yes. The initial BubbleWrap issue that got me started on this whole thing was that the original poster was using code like this:
BW::JSON.parse(twitter_search_results)["results"].each do |result|
@tweets << Tweet.new(result)
end
If you look at my code, above, you’ll see that sending :[] with a string argument
to an array would presume that BW::JSON.parse(twitter_search_results)
returned a Hash. In fact, it returns an Array, which requires that you
index it using an unsigned integer. That’s why the somewhat unintuitive
crash with the message:
NSInvalidArgumentException:-[__NSCFString bytes]: unrecognized selector
Having used JSONKit for this, you can just as easily replace it with:
parsed = BW::JSON.parse response.body.to_str
and then iterate parsed or do the same with a block if you prefer that syntax.
Here is a screenshot of the finished app:
