Hi, I'm Hwee-Boon Yar

I write, ship and sell software products. iOS/OS X app developer based in Singapore, working remotely. This is my blog.

.

Need to run a code review on your codebase? Hire me

Update 2013/6/27: RM3 is now fixed as described by Laurent Sansonetti in this RubyMotion Groups post.

Update 2014/01/02: Also be careful to use #weak! on procs where appropriate to make the self reference stored in procs weak.

There's been a bit of a storm over a memory-related crash issue in RubyMotion lately. See Why I'm not using RubyMotion in Production for some background reading.

The basic problem is that RubyMotion blocks (incorrectly) do not retain objects it closes over. Due to the nature of RubyMotion as well as the nature of Cocoa Touch, which is now heavily block-based, this is a major issue.

There's a few basic ways to workaround this. But not all of them work.

Let's start with a basic scenario to work on.

Imagine you have a function that runs some code asynchronously (this often imply network access such as sending an email, or just POST-ing some data). Often you'll want to wrap Apple's -beginBackgroundTaskWithExpirationHandler: and -endBackgroundTask: around it so the task has a good chance of completing even if the user goes to the home screen or switch to another app. We'll create a #long_running_async_task function that simulates this. #long_running_async_task accepts a block and when the task is completed, it will invoke the block to perform any necessary clean up. We'll call #beginBackgroundTaskWithExpirationHandler right before kickstarting the task and make use of the block to run #endBackgroundTask.

Note that we can't completely avoid block-based APIs since APIs such as #beginBackgroundTaskWithExpirationHandler have no non-block alternatives.

So we have:

class AppDelegate
  def application(application,
                    didFinishLaunchingWithOptions:launchOptions)
    run_task_with_local_vars
  end

  #This is a asynchrous task that calls a block upon completion.
  #Imagine a network fetch or a Parse API call.
  def long_running_async_task(&block)
    gcdq = Dispatch::Queue.new('myqueue')
    gcdq.async {
      p 'Start'
      p 'running...'
      Random.rand(10)+10.times do
        1000.times do
          1000*1000
        end
      end
      p 'End, going to run completion block'
      block.call
      p 'After running completion block'
    }
  end

  def run_task_with_local_vars
    task_id =
        UIApplication.sharedApplication.
            beginBackgroundTaskWithExpirationHandler(proc {
      return if task_id == UIBackgroundTaskInvalid
      UIApplication.sharedApplication.endBackgroundTask(task_id)
    })

    long_running_async_task do
      p 'In block'
      p "In block with task_id #{task_id}"
      if task_id != UIBackgroundTaskInvalid
        p "End task with #{task_id}"
        UIApplication.sharedApplication.endBackgroundTask(task_id)
      end
    end
  end
end

If you run the code above, you'll see that the app crashes right after printing "In block". This is due to the retain bug.

As many have suggested, a possible workaround is to use instance variables. So we have:

class AppDelegate
  def application(application,
                    didFinishLaunchingWithOptions:launchOptions)
    run_task_with_ivar

    true
  end

  #This is a asynchrous task that calls a block upon completion.
  #Imagine a network fetch or a Parse API call.
  def long_running_async_task(&block)
    gcdq = Dispatch::Queue.new('myqueue')
    gcdq.async {
      p 'Start'
      p 'running...'
      Random.rand(10)+10.times do
        1000.times do
          1000*1000
        end
      end
      p 'End, going to run completion block'
      block.call
      p 'After running completion block'
    }
  end

  def run_task_with_ivar
    @task_id =
        UIApplication.sharedApplication.
            beginBackgroundTaskWithExpirationHandler(proc {
      return if @task_id == UIBackgroundTaskInvalid
      UIApplication.sharedApplication.endBackgroundTask(@task_id)
    })

    long_running_async_task do
      p 'In block'
      p "In block with task_id #{@task_id}"
      if @task_id != UIBackgroundTaskInvalid
        p "End task with #{@task_id}"
        UIApplication.sharedApplication.endBackgroundTask(@task_id)
      end
    end
  end
end

The code above (using ivars) works fine, but if you change #application:didFinishLaunchingWithOptions: to the following and run again?

def application(application,
                    didFinishLaunchingWithOptions:launchOptions)
  run_task_with_ivar
  run_task_with_ivar
  run_task_with_ivar
  run_task_with_ivar

  true
end

You'll see that the app crashes again with an error message like “Can't endBackgroundTask: no background task exists with identifier 3, or it may have already been ended. Break in UIApplicationEndBackgroundTaskError() to debug.”. This is because the ivar containing the task ID gets overriden as each task is running. (You might need to run it a few times, could be random since we are simulating tasks that don't take the same amount of time to complete). This is very common, for e.g. if user fires off several emails in succession and goes to the homescreen, you'll want this code to work. So only employing ivars don't work.

What we need to do is to wrap block based operations into their own classes. We'll create a class WorkaroundTask that provides some basic book-keeping for calling #beginBackgroundTaskWithExpirationHandler and #endBackgroundTask. And create a subclass of WorkaroundTask for each block operation that is affected and move the task code (in our example code that is in #long_running_async_task) into the subclass.

We get:

class AppDelegate
  def application(application,
                    didFinishLaunchingWithOptions:launchOptions)
    task = MyTask.new
    task.any_info_needed = 'info for task A'
    task.run

    true
  end
end

#Class to work around Proc not capturing as well as not retaining
#task_id correctly. Subclass to sue. Note that we call #endBackgroundTask,
#reset the @task_id, release itself when we complete the task or when
#the expiry handler fires (if we already ended the task, the expiry
#handler will not fire)
class WorkaroundTask
  def dealloc
    p "Dealloc #{self}"
    super
  end

  def begin_task
    retain

    @task_id =
        UIApplication.sharedApplication.
            beginBackgroundTaskWithExpirationHandler(proc {
      if @task_id == UIBackgroundTaskInvalid
        return
      end
      UIApplication.sharedApplication.endBackgroundTask(@task_id)
      @task_id = UIBackgroundTaskInvalid
      release
    })
  end

  def end_task
    if @task_id != UIBackgroundTaskInvalid
      p "End task with #{@task_id}"
      UIApplication.sharedApplication.endBackgroundTask(@task_id)
      @task_id = UIBackgroundTaskInvalid
      release
    end
  end
end

class MyTask < WorkaroundTask
  attr_accessor :any_info_needed

  def run
    begin_task

    gcdq = Dispatch::Queue.new('myqueue')
    gcdq.async {
      p "Start and we can use this info in the task: #{any_info_needed}"
      p 'running...'
      Random.rand(10)+10.times do
        1000.times do
          1000*1000
        end
      end
      p 'End, going to run completion block'
      end_task
      p 'After running completion block'
    }
  end
end

Notice that the tasks runs correctly and #dealloc runs, indicating that clean up is done correctly.

Now if you modify #application:didFinishLaunchingWithOptions: again to run multiple tasks?

def application(application,
                didFinishLaunchingWithOptions:launchOptions)
  task = MyTask.new
  task.any_info_needed = 'info for task A'
  task.run

  task = MyTask.new
  task.any_info_needed = 'info for task B'
  task.run

  task = MyTask.new
  task.any_info_needed = 'info for task C'
  task.run

  task = MyTask.new
  task.any_info_needed = 'info for task D'
  task.run

  true
end

You'll notice that this works correctly too. So there you have it. Let's go back to creating wonderful apps that delight our customers!

Thanks to Colin T.A. Gray and Matt Green for pointing me in the right direction.

Updated 2013/07/05: Handle expiry handler correctly

Updated 2013/07/12: RM3 is now fixed

Like this post? Follow me @hboon on Twitter.