4

I have an app that successfully uses the synchronous methods to download files (NSData's initWithContentsOfURL and NSURLConnection's sendSynchronousRequest), but now I need to support large files. This means I need to stream to disk bit by bit. Even though streaming to disk and becoming asynchronous should be completely orthoganal concepts, Apple's API forces me to go asynchronous in order to stream.

To be clear, I am tasked with allowing larger file downloads, not with re-architecting the whole app to be more asynchronous-friendly. I don't have the resources. But I acknowledge that the approaches that depend on re-architecting are valid and good.

So, if I do this:

NSURLConnection* connection = [ [ NSURLConnection alloc ] initWithRequest: request delegate: self startImmediately: YES ];

.. I eventually have didReceiveResponse and didReceiveData called on myself. Excellent. But, if I try to do this:

NSURLConnection* connection = [ [ NSURLConnection alloc ] initWithRequest: request delegate: self startImmediately: YES ];
while( !self.downloadComplete )
    [ NSThread sleepForTimeInterval: .25 ];

... didReceiveResponse and didReceiveData are never called. And I've figured out why. Weirdly, the asynchronous download happens in the same main thread that I'm using. So when I sleep the main thread, I'm also sleeping the thing doing the work. Anyway, I have tried several different ways to achieve what I want here, including telling the NSURLConnection to use a different NSOperationQueue, and even doing dispatch_async to create the connection and start it manually (I don't see how this couldn't work - I must not have done it right), but nothing seems to work. Edit: What I wasn't doing right was understanding how Run Loops work, and that you need to run them manually in secondary threads.

What is the best way to wait until the file is done downloading?

Edit 3, working code: The following code actually works, but let me know if there's a better way.

Code executing in the original thread that sets up the connection and waits for the download to complete:

dispatch_queue_t downloadQueue = dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0 );
dispatch_async(downloadQueue, ^{
    self.connection = [ [ NSURLConnection alloc ] initWithRequest: request delegate: self startImmediately: YES ];
    [ [ NSRunLoop currentRunLoop ] run ];
});

while( !self.downloadComplete )
    [ NSThread sleepForTimeInterval: .25 ];

Code executing in the new thread that responds to connection events:

-(void)connection:(NSURLConnection*) connection didReceiveData:(NSData *)data {
    NSUInteger remainingBytes = [ data length ];
    while( remainingBytes > 0 ) {
        NSUInteger bytesWritten = [ self.fileWritingStream write: [ data bytes ] maxLength: remainingBytes ];
        if( bytesWritten == -1 /*error*/ ) {
            self.downloadComplete = YES;
            self.successful = NO;
            NSLog( @"Stream error: %@", self.fileWritingStream.streamError );
            [ connection cancel ];
            return;
        }
        remainingBytes -= bytesWritten;
    }
}

-(void)connection:(NSURLConnection*) connection didFailWithError:(NSError *)error {
    self.downloadComplete = YES;
    [ self.fileWritingStream close ];
    self.successful = NO;
}

- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
    self.downloadComplete = YES;
    [ self.fileWritingStream close ];
    self.successful = YES;
}
Greg Smalter
  • 6,571
  • 9
  • 42
  • 63

3 Answers3

5

... didReceiveResponse and didReceiveData are never called. And I've figured out why. Weirdly, the asynchronous download happens in the same main thread that I'm using. It doesn't create a new thread. So when I sleep the main thread, I'm also sleeping the thing doing the work.

Exactly. The connection is driven by the run loop; if you sleep the thread, the run loop stops, and that prevents your connection from doing its thing.

So don't do anything special. Let the app sit there, with the run loop running. Maybe put a little spinner on the screen to entertain the user. Go about your business if you can. If at all possible, let the user continue to use the application. Your delegate method will be called when the connection is complete, and then you can do what you need to do with the data.

When you move your code to a background thread, you'll again need a run loop to drive the connection. So you'll start create a run loop, schedule your connection, and then just return. The run loop will keep running, and your delegate method will again be called when the connection completes. If the thread is done, you can then stop the run loop and let the thread exit. That's all there is to it.

Example: Let's put this in concrete terms. Let's say that you want to make a number of connections, one at a time. Stick the URL's in a mutable array. Create a method called (for example) startNextConnection that does the following things:

  • grabs an URL from the array (removing it in the process)

  • creates an URL request

  • starts a NSURLConnection

  • return

Also, implement the necessary NSURLConnectionDelegate methods, notably connectionDidFinishLoading:. Have that method do the following:

  • stash the data somewhere (write it to a file, hand it to another thread for parsing, whatever)

  • call startNextConnection

  • return

If errors never happened, that'd be enough to retrieve the data for all the URLs in your list. (Of course, you'll want startNextConnection to be smart enough to just return when the list is empty.) But errors do happen, so you'll have to think about how to deal with them. If a connection fails, do you want to stop the entire process? If so, just have your connection:didFailWithError: method do something appropriate, but don't have it call startNextConnection. Do you want to skip to the next URL on the list if there's an error? Then have ...didFailWithError: call startNextRequest.

Alternative: If you really want to keep the sequential structure of your synchronous code, so that you've got something like:

[self downloadURLs];
[self waitForDownloadsToFinish];
[self processData];
...

then you'll have to do the downloading in a different thread so that you're free to block the current thread. If that's what you want, then set up the download thread with a run loop. Next, create the connection using -initWithRequest:delegate:startImmediately: as you've been doing, but pass NO in the last parameter. Use -scheduleInRunLoop:forMode: to add the connection to the download thread's run loop, and then start the connection with the -start method. This leaves you free to sleep the current thread. Have the connection delegate's completion routine set a flag such as the self.downloadComplete flag in your example.

Caleb
  • 124,013
  • 19
  • 183
  • 272
  • But I do have to do something special. If I do nothing, as soon as I return from the method that created the connection (which will be immediately), the next step is to download another file, and another. So, I'll end up with 100 files downloading at once, which is not what I want. – Greg Smalter May 15 '12 at 21:21
  • So don't make the next request until the first connection completes. You'll know when that happens because your delegate method will be called. In fact, you can have your `connectionDidFinishLoading:` method grab the next URL and kick off another connection, so the next connection will start as soon as the previous one completes. – Caleb May 15 '12 at 21:28
  • "Don't make the next request until the first connection completes" is my entire question. How do I wait for it? – Greg Smalter May 15 '12 at 21:31
  • @GregSmalter Okay, added a bit more explanation to the answer above. Hope it helps. Keep in mind that a GUI framework like Cocoa Touch is event-driven... apps spend most of their time waiting for something to happen, and then responding when something finally does. Same thing here. You don't need to *do* anything, just let it happen! ;-) – Caleb May 15 '12 at 21:46
  • I'm to trying to be difficult, but my question is "how do I wait for a file download" and your answer is "don't wait." There are several other reasons why waiting is a better solution for me. In particular, when all files are done downloading, I start doing something else entirely. So I'd have to launch that from connectionDidFinishLoading as well. It creates an impossibly difficult program structure. I think your original approach is a good approach that works in a lot of situations, but not my situation. And I think I can only convince you of that if I explain my whole app to you. – Greg Smalter May 16 '12 at 02:54
  • @GregSmalter The answer is still the same. It sounds like you want to keep writing synchronous code, like `a; b; c;...`. But given your stated need to fetch these URLs asynchronously, you're going to have to rearrange your code a bit. If you want to wait until your download is finished before beginning your next task, you'll need to use the completion routine to trigger that task because *that's how you know when the download is done*. I'm not saying "don't wait" in the sense of "start the next task immediately;" I just mean that you shouldn't block the run loop. – Caleb May 16 '12 at 06:40
  • I didn't state that I need to fetch the URLs asynchronously. I edited my question for clarity. I am accepting your answer because your comments about Run Loops led me to the working code, and because your answer provides a comprehensive set of considerations for anyone doing this. So, instead of answering my own question, I added the latest code to my question (feel free to point out things that are still wrong with it - I am particularly interested in whether I even have to hold a reference to the connection at all, and who, the original or the new thread, should instantiate the objects). – Greg Smalter May 16 '12 at 14:51
  • 1
    Your new code runs the run loop potentially forever, keeping a useless thread around consuming memory and kernel resources. You can't rely on the completion of the connection to remove the last run loop source from the run loop thus allowing `-run` to return. Also, looping around a sleep is still a form of polling and you shouldn't do that. – Ken Thomases May 16 '12 at 14:58
1

I hesitate to provide this answer because the others are correct that you really should structure your app around the asynchronous model. Nevertheless:

NSURLConnection* connection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:NO];
NSString* myPrivateMode = @"com.yourcompany.yourapp.DownloadMode";
[connection scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:myPrivateMode];
[connection start];
while (!self.downloadComplete)
    [[NSRunLoop currentRunLoop] runMode:myPrivateMode beforeDate:[NSDate distantFuture]];

Do not do this on the main thread. Your app is just as likely to be terminated for blocking the main thread as for downloading too big a file to memory.

By the way, given that you're downloading to a file instead of memory, you should consider switching from NSURLConnection to NSURLDownload.

Ken Thomases
  • 88,520
  • 7
  • 116
  • 154
  • I did consider using NSURLDownload, but it is my understanding that it is not available in iOS. Also, it is very possible your answer would work for me (and other people viewing this), but I arrived at a solution based on what Caleb said before I saw your answer, so I have not tried it. – Greg Smalter May 16 '12 at 14:53
0

I think your sleepForInterval is blocking the NSURLConnection's activity -

No run loop processing occurs while the thread is blocked.

From the NSThread documentation.


I think you might have to rethink how you're setting your downloadComplete variable. Consider using your connectionDidFinishLoading:connection delegate method to determine when the download is complete instead of your loop + sleep?

- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{        
    self.downloadComplete = YES;

    // release the connection, and the data object
    [connection release];
    [receivedData release];
}

From the NSURLConnection guide.

You can use the connection:connection didFailWithError:error delegate method to ensure you're dealing with situations where the download does not complete.

Michael Robinson
  • 29,278
  • 12
  • 104
  • 130
  • I don't think I can use sendAsynchronousRequest:queue:completionHandler: because it appears to give you the whole file in one big chunk (the NSData passed to the completion handler). – Greg Smalter May 15 '12 at 20:41
  • Why do you not want the data received this way? – Michael Robinson May 15 '12 at 20:50
  • Because the iPad runs out of RAM and crashes. I need to stream this to file, not get the entire file in one big NSData block and then write it in one shot. – Greg Smalter May 15 '12 at 20:52
  • OK I understand why now. Updated my answer – Michael Robinson May 15 '12 at 21:00
  • I already have implemented the connectionDidFinishLoading method. That's how the boolean that the sleep loop depends on is set. So, it's more of an in-addition-to than an instead-of. I updated my question with the code I'm talking about. – Greg Smalter May 15 '12 at 21:07
  • I see your new answer and agree that connectionDidFinishLoading should be implemented. But, I fail to see how it blocks the original thread from continuing until the download is complete. – Greg Smalter May 15 '12 at 21:08