Cocoa/OSX - Strange behavior in NSSavePanel that n

2019-09-11 00:00发布

问题:

I've a NSSavePanel instance with a strange behavior: whenever I open it and click on a directory's arrow (the little expand button) it shows an indeterminate loading icon on the left-bottom corner that never ends, and not shows the directory/file tree. An image can see as follow:

In that example, I've clicked in "workspace" directory. And the panel not shows the sub-itens. Even strange is that after I click it again (redrawing the directory) and then click again (re-open the directory), it properly shows all files.

My code is as follows:

// here, I'm creating a web service client, and then calling a method to download a report, and passing the same class as delegate
- (IBAction) generateReport:(id)sender {

    // SOME STUFF HERE...

    WSClient *client = [[[WSClient alloc] init] initWithDelegate:self];
    [client GenerateReport:@"REPORT" withParams:params];
}

- (void) GenerateReport:(NSString *)from withParams:(NSDictionary *)parameters {

    // SOME STUFF HERE...

    NSOperationQueue *queue = [[NSOperationQueue alloc] init];
    [NSURLConnection sendAsynchronousRequest:request queue:queue completionHandler:^(NSURLResponse *response, NSData *data, NSError *error) {
    if (!error) {
        dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
            dispatch_async(dispatch_get_main_queue(), ^(void) {
                NSLog(@"GenerateReport: [success]");
                [self.delegate successHandlerCallback:data from: from];
            });
        });
    }
}];

// this is the callback
- (void) successHandlerCallback:(NSData *) data from: (NSString *) from {
    NSString savePath = [savePath stringByReplacingOccurrencesOfString:@"file://" withString:@""];
    NSString *filePath = [NSString stringWithFormat:@"%@", savePath];
    [data writeToFile:filePath atomically:YES];
}

// and this is to build a panel to let user chose the directory to save the file
- (NSURL *) getDirectoryPath {
    NSSavePanel *panel = [NSSavePanel savePanel];
    [panel setNameFieldStringValue:[self getDefaultFileName]];
    [panel setDirectoryURL:[NSURL fileURLWithPath:[[NSString alloc] initWithFormat:@"%@%@%@", @"/Users/", NSUserName(), @"/Downloads"]]];
    if ([panel runModal] != NSFileHandlingPanelOKButton) return nil;
    return [panel URL];
}

Can someone give a hint on where I'm missing?

UPDATE: To me, it seens to be something related with dispatch_async!

Thanks in advance!

回答1:

Actually, this is a bad interaction between NSSavePanel and the Grand Central Dispatch main queue and/or +[NSOperationQueue mainQueue]. You can reproduce it with just this code:

dispatch_async(dispatch_get_main_queue(), ^{
    [[NSSavePanel savePanel] runModal];
});

First, any time you perform GUI operations, you should do so on the main thread. (There are rare exceptions, but you should ignore them for now.) So, you were right to submit the work to the main queue if it was going to do something like open a file dialog.

Unfortunately, the main queue is a serial queue, meaning that it can only run one task at a time, and NSSavePanel submits some of its own work to the main queue. So, if you submit a task to the main queue and that task runs the save panel in a modal fashion, then it monopolizes the main queue until the save panel completes. But the save panel is relying on the ability to submit its own tasks to the main queue and have them run.

As far as I'm concerned, this is a bug in Cocoa. You should submit a bug report to Apple.

The correct solution is for NSSavePanel to submit any tasks it has to the main thread using a re-entrant mechanism like a run-loop source, -performSelectorOnMainThread:..., or CFRunLoopPerformBlock(). It needs to avoid using the main queue of GCD or NSOperationQueue.

Since you can't wait for Apple to fix this, the workaround is for you to do the same. Use one of the above mechanisms to submit your task that may run the save panel to the main queue.



回答2:

I just found the way: I was making all the calls within the dispatch_async on the main thread queue. By the fact that the download is still running at the callback moment, it conflicted with the thread that opens the panel. I fixed all issues by just placing the correct lines, changing from:

dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
    dispatch_async(dispatch_get_main_queue(), ^(void) {
        NSLog(@"GenerateReport: [success]");
        [self.delegate successHandlerCallback:data from: from];
    });
});

to:

dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
    NSLog(@"GenerateReport: [success]");
    [self.delegate successHandlerCallback:data from: from];
});

and in the callback, just updating the fields. In the end, I found that all of that were a main/background thread misunderstand by me.