Steve's Ruby Motion Blog...

...about programming and whatever else comes into my head.

 

November 11th 2012

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:

Finished App

blog comments powered by Disqus