Using NSProgress with nested NSOperations
Asked Answered
G

1

8

I've been investigating NSProgress but have found the existing documentation, class reference and tutorials to be lacking. I'm mainly wondering if my NSProgress is applicable to my use case. The class reference documentation alternatively refers to suboperations or subtasks, I may be mistaken but I interpreted suboperations to mean a case where an NSOperation manages a group of other NSOperations. An example of my use case is as follows:

  • Create an Upload All Items in Group operation for each group that exists.
  • Add each of these operations to an NSOperationQueue.
  • Each Upload All Items in Group operation will create an Upload Item operation for each item in their group. These all get added to an NSOperationQueue managed by the operation.

I would have expected NSProgress to support this, and allow me to propagate progress from the nested operations (Upload Item operation) to the parent operation, and then finally to the main thread and the UI. But I've had difficulty implementing this, it seems as though NSProgress is meant more for long operations that execute all their code on one background thread, but have separate "sections" that make it easy to determine when progress has been made, if this is the case then the use of the term suboperation is a bit misleading as it brings to mind the use of nested NSOperations.

Thank you for any help you can provide, and let me know if additional details are needed.

Grower answered 4/10, 2013 at 18:38 Comment(0)
D
13

NSProgress knows nothing about NSOperations -- the two things are orthogonal -- but that doesn't mean it can't be used with them. The idea behind nesting NSProgress "tasks" is that the inner task doesn't know anything about the outer task, and the outer task doesn't need direct access to the inner task's NSProgress to pull in updates for it. I cooked up a little example:

// Outer grouping
NSProgress* DownloadGroupsOfFiles(NSUInteger numGroups, NSUInteger filesPerGroup)
{
    // This is the top level NSProgress object
    NSProgress* p = [NSProgress progressWithTotalUnitCount: numGroups];

    for (NSUInteger i = 0; i < numGroups; ++i)
    {
        // Whatever DownloadFiles does, it's worth "1 unit" to us.
        [p becomeCurrentWithPendingUnitCount: 1];

        DownloadFiles(filesPerGroup);

        [p resignCurrent];
    }

    return p;
}

// Inner grouping
void DownloadFiles(NSUInteger numberOfFiles)
{
    NSProgress* p = [NSProgress progressWithTotalUnitCount: numberOfFiles];
    NSOperationQueue* opQueue = [[NSOperationQueue alloc] init];

    // Make the op queue last as long as the NSProgress
    objc_setAssociatedObject(p, NULL, opQueue, OBJC_ASSOCIATION_RETAIN);

    // For each file...
    for (NSUInteger i = 0; i < numberOfFiles; ++i)
    {
        // Whatever this DownloadOperation does is worth 1 "unit" to us.
        [p becomeCurrentWithPendingUnitCount: 1];

        // Make the new operation
        MyDownloadOperation* op = [[MyDownloadOperation alloc] initWithName: [NSString stringWithFormat: @"File #%@", @(i+1)]];
        [opQueue addOperation: op];

        [p resignCurrent];
    }
}

// And then the DownloadOperation might look like this...
@interface MyDownloadOperation : NSOperation
@property (nonatomic, readonly, copy) NSString* name;
- (id)initWithName: (NSString*)name;
@end

@implementation MyDownloadOperation
{
    NSProgress* _progress;
    NSString* _name;
}

- (id)initWithName:(NSString *)name
{
    if (self = [super init])
    {
        _name = [name copy];
        // Do this in init, so that our NSProgress instance is parented to the current one in the thread that created the operation
        _progress = [NSProgress progressWithTotalUnitCount: 1];
    }
    return self;
}

- (void)dealloc
{
    _name = nil;
    _progress = nil;
}

- (void)main
{
    // Fake like we're doing something that takes some time

    // Determine fake size -- call it 768K +- 256K
    const NSUInteger size = 512 * 1024 + arc4random_uniform(512*1024);
    const NSUInteger avgBytesPerSec = 1024 * 1024;
    const NSTimeInterval updatePeriod = 1.0/60.0;

    // Make sure all the updates to the NSProgress happen on the main thread
    // in case someone is bound to it.
    dispatch_async(dispatch_get_main_queue(), ^{
        _progress.totalUnitCount = size;
        _progress.completedUnitCount = 0;
    });

    NSUInteger bytesRxd = 0;
    do
    {
        // Sleep for a bit...
        usleep(USEC_PER_SEC * updatePeriod);

        // "Receive some data"
        NSUInteger rxdThisTime = updatePeriod * avgBytesPerSec;

        // Never report more than all the bytes
        bytesRxd = MIN(bytesRxd + rxdThisTime, size);

        // Update on the main thread...
        dispatch_async(dispatch_get_main_queue(), ^{
            [_progress setCompletedUnitCount: bytesRxd];
        });
    } while (bytesRxd < size);
}

@end

One thing to note is that if NSProgress is being used to convey status to the UI, then you will want to make sure that every time you update the NSProgress object, you do so from the main thread, otherwise you'll get lots of weird crashes.

Alternately you could just use NSURLConnection to download files, and then have a delegate like this:

@interface MyURLConnectionProgressReporter : NSObject <NSURLConnectionDownloadDelegate>
@property (nonatomic, readwrite, assign) id<NSURLConnectionDownloadDelegate> delegate;
@end

NSProgress* DownloadABunchOfFiles(NSArray* arrayOfURLs)
{
    arrayOfURLs = arrayOfURLs.count ? arrayOfURLs : @[ [NSURL URLWithString: @"http://www.google.com"] ];

    NSProgress* p = [NSProgress progressWithTotalUnitCount: arrayOfURLs.count];

    for (NSURL* url in arrayOfURLs)
    {
        [p becomeCurrentWithPendingUnitCount: 1];

        MyURLConnectionProgressReporter* delegate = [[MyURLConnectionProgressReporter alloc] init];
        NSURLConnection* conn = [[NSURLConnection alloc] initWithRequest: [NSURLRequest requestWithURL: url] delegate: delegate];
        [conn start];

        [p resignCurrent];
    }

    return p;

}

@implementation MyURLConnectionProgressReporter
{
    NSProgress* _progress;
}

static void EnsureMainThread(dispatch_block_t block);

- (id)init
{
    if (self = [super init])
    {
        _progress = [NSProgress progressWithTotalUnitCount: 1];
        EnsureMainThread(^{
            _progress.kind = NSProgressKindFile;
            [_progress setUserInfoObject:NSProgressFileOperationKindDownloading forKey:NSProgressFileOperationKindKey];
        });
    }
    return self;
}

- (id)forwardingTargetForSelector:(SEL)aSelector
{
    id retVal = [super forwardingTargetForSelector:aSelector];
    if (!retVal && [self.delegate respondsToSelector: _cmd])
    {
        retVal = self.delegate;
    }
    return retVal;
}

- (void)p_updateWithTotalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes
{
    // Update our progress on the main thread...
    EnsureMainThread(^{
        if (!expectedTotalBytes)
            _progress.totalUnitCount = -1;
        else
            _progress.totalUnitCount = MAX(_progress.totalUnitCount, expectedTotalBytes);

        _progress.completedUnitCount = totalBytesWritten;
    });
}

- (void)connection:(NSURLConnection *)connection didWriteData:(long long)bytesWritten totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes
{
    // Update our progress
    [self p_updateWithTotalBytesWritten: totalBytesWritten expectedTotalBytes: expectedTotalBytes];

    // Then call on through to the other delegate
    if ([self.delegate respondsToSelector: _cmd])
    {
        [self.delegate connection:connection didWriteData:bytesWritten totalBytesWritten:totalBytesWritten expectedTotalBytes:expectedTotalBytes];
    }
}

- (void)connectionDidResumeDownloading:(NSURLConnection *)connection totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes
{
    // Update our progress
    [self p_updateWithTotalBytesWritten: totalBytesWritten expectedTotalBytes: expectedTotalBytes];

    // Then call on through to the other delegate
    if ([self.delegate respondsToSelector: _cmd])
    {
        [self.delegate connectionDidResumeDownloading:connection totalBytesWritten:totalBytesWritten expectedTotalBytes:expectedTotalBytes];
    }
}

- (void)connectionDidFinishDownloading:(NSURLConnection *)connection destinationURL:(NSURL *) destinationURL
{
    // We're done, so we want (_progress.completedUnitCount == _progress.totalUnitCount)
    EnsureMainThread(^{
        _progress.completedUnitCount = _progress.totalUnitCount;
    });

    if ([self.delegate respondsToSelector: _cmd])
    {
        [self.delegate connectionDidFinishDownloading:connection destinationURL:destinationURL];
    }
}

static void EnsureMainThread(dispatch_block_t block)
{
    if (!block)
        return;
    else if ([NSThread isMainThread])
        block();
    else
        dispatch_async(dispatch_get_main_queue(), block);
}

@end

Hope that helps.

Dearborn answered 6/10, 2013 at 21:1 Comment(6)
Shouldn't you call [p becomeCurrentWithPendingUnitCount: numGroups]; outside the first for loop?Rive
@Rive That would make the relationship between the sub-progresses (potentially) unequal in terms of their proportion of the parent progress. Put differently, if you want each file to represent 1 unit of progress in the parent, then you need to do it this way. If you are sure that the sub-progresses are specified in some mutually shared unit like bytes, (probably a safe assumption here, but not everywhere) and you want to expose that unit as part of the parent progress reporting, then yes, you could move it outside.Dearborn
"One thing to note is that if NSProgress is being used to convey status to the UI, then you will want to make sure that every time you update the NSProgress object, you do so from the main thread, otherwise you'll get lots of weird crashes." According to the NSProgress documentation, it seems that if you call progressWithTotalUnitCount: on the correct (main thread), you can update it from other threads. Is that not your experience?Tetragon
That is not my experience. I just tried it on the latest Yosemite beta, and KVO notifications are posted on the thread on which the set operation happens, regardless of -progressWithTotalUnitCount: having been called on the main thread. There is no built in marshaling, AFAICT.Dearborn
Looking at the NSProgress class reference, I see they say, "You can invoke this method on one thread and then message the returned NSProgress on another thread." So you can message the progress object on other threads, but that doesn't mean it'll notify observers on the thread that created it. To me, this indicates that NSProgress protects its own internal state against concurrent mutation from multiple threads, but doesn't promise/imply any special marshaling of KVO notifications. This assessment is supported by the test I did before posting my previous comment.Dearborn
It's usually easier to dispatch the UI updating code on the main thread from within the observeValueForKeyPath:... method, rather than remembering to update your NSProgress properties on the main thread from deeper in your code. The downside is that while this works great for KVO, it doesn't help you with bindings. All the Apple examples are done this way (using KVO instead of bindings).Stickseed

© 2022 - 2024 — McMap. All rights reserved.