2

I've implemented what seems to be the only way of communicating from javascript to objective-c on iOS using the UIWebView delegate shouldStartLoadWithRequest() method.

It seemed to work fine at first, but now I notice that if I make multiple calls from javascript to objective-c within a short period of time, the second call is usually ignored (The application is a piano keyboard, each keypress triggers a call to native code, when dealing with multiple touches the native code doesn't get called for every finger).

This is my objective-c code to respond to javascript calls. It's pretty derpy I know but I just wanted something that works for now.

- (BOOL)webView:(UIWebView *)webView2 shouldStartLoadWithRequest:(NSURLRequest *)request 
        navigationType:(UIWebViewNavigationType)navigationType 
{  
  // Intercept custom location change, URL begins with "js-call:"
  NSString * requestString = [[request URL] absoluteString];
  if ([requestString hasPrefix:@"js-call:"]) 
  {
    // Extract the selector name from the URL
    NSArray * components = [requestString componentsSeparatedByString:@":"];
    NSString * functionCall = [components objectAtIndex:1];
    NSArray * params = [functionCall componentsSeparatedByString:@"%20"];
    NSString * functionName = [params objectAtIndex:0];

    // Parse playnote event
    if ([functionName isEqualToString:@"playNote"])
    {
      NSString * param = [params objectAtIndex:1];
      NoteInstanceID note_id = [m_audioController playNote:[param intValue]];
      NSString * jscall = [NSString stringWithFormat:@"document.PlayNoteCallback(%i);", note_id];
      NSLog(@"playNote: %i", (int)note_id);
      [m_webView stringByEvaluatingJavaScriptFromString:jscall];
    }

    // Parse stopnote event
    if ([functionName isEqualToString:@"stopNote"])
    {
      NSString * param = [params objectAtIndex:1];
      NoteInstanceID note_id = [param intValue];
      NSLog(@"stopNote: %i", (int)note_id); 
      [m_audioController stopNote:note_id];
    }

    // Parse log event
    if ([functionName isEqualToString:@"debugLog"])
    {
      NSString * str = [requestString stringByReplacingOccurrencesOfString:@"%20" withString:@" "];
      NSLog(@"%s", [str cStringUsingEncoding:NSStringEncodingConversionAllowLossy]);
    }

    // Cancel the location change
    return NO;
  }

  // Accept this location change
  return YES;

}

From javascript I call objective-c methods by setting the src attribute of a single hidden iframe. This will trigger the delegate method in objective-c and then the desired native code will get called.

$("#app_handle").attr("src", "js-call:playNote " + key.data("pitch"));

app_handle is the id of the iframe in question.

To sum up, the basics of my method work, but multiple calls within a short period of time do not work. Is this just an artifact of the terrible method in which we are forced to communicate from javascript to objective-c? Or am I doing something wrong? I know PhoneGap does something similar to achieve the same goal. I'd rather not use PhoneGap, so if they don't have this problem then I'd love to figure out what they are doing to make this work.

Update:

I just found this: send a notification from javascript in UIWebView to ObjectiveC Which confirms my suspicions about calls made in quick succession getting lost. Apparently I need to either lump my calls together or manually delay the calls so that the url request has returned by the time I make another call.

Community
  • 1
  • 1
phosphoer
  • 433
  • 6
  • 16
  • 1
    The issue is that the first window.src set has to have completed before you can send another one, ultimately because the runloop is single-threaded and synchronous. There's no way around that except queuing and/or batching calls until the first one has finished. Getting this perfect is actually pretty tricky, especially when you take into account the delay before sending the first onClick on Mobile Safari, so your best bet is to look over the source to one of the open source bridges—or, better yet, just use one. – abarnert May 15 '12 at 22:39

2 Answers2

4

The accepted answer does not solve the problem since location changes that arrive before the first is handled are still ignored. See the first comment.

I suggest the following approach:

function execute(url) 
{
  var iframe = document.createElement("IFRAME");
  iframe.setAttribute("src", url);
  document.documentElement.appendChild(iframe);
  iframe.parentNode.removeChild(iframe);
  iframe = null;
}

You call the execute function repeatedly and since each call executes in its own iframe, they should not be ignored when called quickly.

Credits to this guy.

talkol
  • 12,564
  • 11
  • 54
  • 64
1

Rather than queuing everything on the JavaScript side, it's probably much easier and faster to move your complex logic (like the calls to the audio handler) off of the main thread using GCD. You can use dispatch_async() to queue up your events. If you put them into a serial queue, then they'll be certain to run in order, but you'll get back to the javascript faster. For instance:

  • Create a queue for your object during initialization:

    self.queue = dispatch_queue_create("player", NULL);

  • In your callback:

    if ([functionName isEqualToString:@"stopNote"])
    {
      NSString * param = [params objectAtIndex:1];
      NoteInstanceID note_id = [param intValue];
      NSLog(@"stopNote: %i", (int)note_id); 
      dispatch_async(self.queue, ^{[m_audioController stopNote:note_id]});
    }
    
Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • 3
    This makes the problem smaller, but doesn't make it go away. The handler still takes a non-zero amount of time, and a second change to .src/.location that comes in during that time will still not trigger. So ultimately you do need to queue/batch on the JS side. – abarnert May 15 '12 at 22:40
  • Do you find this in practice is too slow if you send everything to a background thread and return immediately? (Haven't tested this; I'm curious myself.) – Rob Napier May 15 '12 at 22:47
  • Me personally? No. But I've never built an app where users will expect to be able to make two clicks very close together, and notice if the second one gets lost. But for his use case—trying to emulate a (polyphonic) piano keyboard—people probably will have that expectation. – abarnert May 15 '12 at 23:08
  • This is problematic because previously I was using the return value of [m_audioController playNote] to do things. Interesting suggestion though. – phosphoer May 15 '12 at 23:20
  • You can still use the return value. You will just need to dispatch the `stringByEvaluatingJavaScriptFromString` back to the main thread. – Rob Napier May 15 '12 at 23:49
  • Sorry I don't quite understand. If I put [m_audiocontroller playNote] on a different thread, how could I possibly capture the return value immediately after when it is an asynchronous call. I need the return value of that function to send it back to javascript. – phosphoer May 15 '12 at 23:56
  • 2
    You would send it back to the javascript when it was done. The current JavaScript code is blocking on this method, but note how you're actually sending the information back to the JavaScript. You don't send it with a `return`, you send it by evaluating a completely different piece of JavaScript. I'm saying you should be able to evaluate that completely different piece of JavaScript after the background thread is complete as well as you can do it in the middle of `shouldStartLoad` (probably better since you're not trying to reenter the JS). – Rob Napier May 16 '12 at 00:09