Bronze Challenge: Pruning the cache


#1

Here’s my code.

In BNRFeedStore.m:

 // This is the store callback code
        if (!err) {
            [channelCopy addItemsFromChannel:obj];
            
            
            // Chap 29 Bronze Challenge: Pruning the cache
            int itemsCacheSize = [[[NSUserDefaults standardUserDefaults] objectForKey:@"itemsCacheSize"] intValue];
            NSLog(@"number of items %d and cache size: %d", [[channelCopy items] count], itemsCacheSize);
            if ([[channelCopy items] count] > itemsCacheSize) {
                // remove objects from index 'itemsCacheSize included to index [items count] - itemsCacheSize
                [[channelCopy items] removeObjectsInRange:NSMakeRange(itemsCacheSize, [[channelCopy items] count] - itemsCacheSize)];
            }
            NSLog(@"number of items %d", [[channelCopy items] count]);
            // end
            
            [NSKeyedArchiver archiveRootObject:channelCopy toFile:cachePath];
        }

It works, but I lose the animated insertion of rows when the cache reach its limit.


#2

@jemennuie I tried your solution with a few changes and got the rows to animate when a new post comes in. In ListViewController.m under fetchEntries, I added an else clause underneath insertRowsAtIndexPaths. If the itemDelta is negative, then delete any rows from the end:

                                [[self tableView] insertRowsAtIndexPaths:rows
                                             withRowAnimation:UITableViewRowAnimationTop];               
                           } else { // Bronze
                               NSMutableArray *rows = [NSMutableArray array];
                               for (int i = newItemCount; i < currentItemCount; i++) {
                                   NSIndexPath *ip = [NSIndexPath indexPathForRow:i
                                                                        inSection:0];
                                   [rows addObject:ip];
                               }
                               [[self tableView] deleteRowsAtIndexPaths:rows
                                withRowAnimation:UITableViewRowAnimationBottom];
                           }

In BNRFeedStore.m under fetchRSSFeedWithCompletion, I cached the array after deleting the last items:

if (!err) { // Bronze
            [channelCopy addItemsFromChannel:obj];
            int maxCacheSize = 100;
            NSLog(@"Count of items %i", [[channelCopy items] count]);
            if ([[channelCopy items] count] > maxCacheSize) {
                [[channelCopy items] removeObjectsInRange:NSMakeRange(maxCacheSize, [[channelCopy items] count] - maxCacheSize)];
                [NSKeyedArchiver archiveRootObject:channelCopy toFile:cachePath];
            } else {
                [NSKeyedArchiver archiveRootObject:channelCopy toFile:cachePath];
            }

To test removing items, I would set the maxCacheSize variable below the count of the [channelCopy items] array. To test adding items, I would send a test post to the forum, then tap on the Apple tab and tap back to the BNR tab.


#3

@BryanLuby
itemDelta can’t be negative.
The ‘else’ part of your code will be called only if itemDelta equals 0.
ItemDelta equals 0 when currentItemCount == newItemCount and your forLoop could be rewritten like this:

for (int i = newItemCount; i < newItemCount; i++) {
NSIndexPath *ip = [NSIndexPath indexPathForRow:i inSection:0];
[rows addObject:ip];
}

The code inside the forLoop will never execute and there will be no rows to delete.

I have a possible solution without using itemDelta. (animated insertions and deletions of rows), but the app crashes in some situations.


#4

Here is my solution, which politely handles each case that I’ve tried:

In BNRFeedStore, prune the list of BNRItems to the desired size before cacheing it.

BNRFeedStore.m:

...
            [channelCopy addItemsFromChannel:obj];
            
            NSUInteger itemCount = [[channelCopy items] count];
            NSUInteger maxCacheSize = 100;
            if (itemCount > maxCacheSize) {
                [[channelCopy items] removeObjectsInRange:NSMakeRange(maxCacheSize, itemCount - maxCacheSize)]; 
            }
            
            [NSKeyedArchiver archiveRootObject:channelCopy toFile:cachePath];

In ListViewController, itemDelta can no longer be determined by the difference in array lengths, but now can be tallied by counting objects in the newly fetched channel that weren’t in the cached channel. If there are new items, deleting the purged rows from the bottom of the table and inserting new rows at the top need to be done atomically.

ListViewController.m:

                int itemDelta = 0;
                for (RSSItem *item in [obj items]) {
                    if (![[channel items] containsObject:item]) {
                        itemDelta++;
                    }
                }
                
                channel = obj;
                
                if (itemDelta > 0) {
                   [[self tableView] reloadData];
                    NSInteger numRows = [[self tableView] numberOfRowsInSection:0];
                    
                    NSMutableArray *delRows = [NSMutableArray array];
                    NSMutableArray *addRows = [NSMutableArray array];
                    for (int i = 0; i < itemDelta; i++) {
                        [delRows addObject:[NSIndexPath indexPathForRow:numRows-i-1 inSection:0]];
                        [addRows addObject:[NSIndexPath indexPathForRow:i inSection:0]];
                    }

                    [[self tableView] beginUpdates];
                    [[self tableView] deleteRowsAtIndexPaths:delRows withRowAnimation:UITableViewRowAnimationBottom];
                    [[self tableView] insertRowsAtIndexPaths:addRows withRowAnimation:UITableViewRowAnimationTop];
                    [[self tableView] endUpdates];
                }
            }

#5

(1.) I’m not sure it’s a good idea to change the code in ListViewController for the bronze challenge. The client (i.e., the ListViewController) probably doesn’t want to change its code to accommodate how much data the data store caches.

(2.) When the time comes to remove items from a channel object to satisfy the cache limit, I noticed that everyone removed items from channelCopy, then passed channelCopy to the client’s completion block. So the client’s merged channel object will be missing some of the original cached items that were returned immediately to the client. I don’t think the client would expect this or want this to happen. Instead, separate the destinies of the channelCopy from the channel object that gets pruned. That means add a fourth channel object to the data store’s fetchRSSFeedWithCompletion: method.

[connection setCompletionBlock:^(RSSChannel *obj, NSError *err) {
        if (!err) {
            [channelCopy addItemsFromChannel:obj];
            
            // limit cached items
            int maxNumOfCachedItems = 100;
            RSSChannel *cachedCopy = [channelCopy copy];  // 4th channel object introduced here
            if ([[cachedCopy items] count] > maxNumOfCachedItems) {
                // remove items
                [cachedCopy removeItemsFromIndex:maxNumOfCachedItems - 1 toIndex:[[cachedCopy items] count] - 1];
            }
            
            // here, I am caching the channel object that had items removed
            [NSKeyedArchiver archiveRootObject:cachedCopy toFile:cachePath];  
        }
        
        block(channelCopy, err);  // no items were deleted from this channel object
    }];

#6

I totally agree with zzzzzzzzzzz.
Joe explained several times in the chapter that it’s not up to the ListViewController to deal with what is brought back or put into cache.

I did almost the same thing as zzzzzzzzzzz, but without creating a method to remove items (I was confident I could find a way to do it with a loop)

[code][connection setCompletionBlock:^(RSSChannel *obj, NSError *err) {
// This is the store’s callback code
if (!err) {
[channelCopy addItemsFromChannel:obj];

        // BRONZE Challenge - pruning the cache
        // Add a 4th channel as a copy of channelCopy
        RSSChannel *limitedCacheChannel = [channelCopy copy];
        NSLog(@"limitedCacheChannel (before): %i", [[limitedCacheChannel items] count]);

        // Limit this copy to 100 entries
        int maxNumOfCachedEntries = 100;
        
        // Check if number of cached items is superior to maxNumOfCachedEntries
        if ([[limitedCacheChannel items] count] > maxNumOfCachedEntries)
            
            // Remove items with index ranging from maxNumOfCachedEntries to channel items count - 1,
            // in reverse order, starting from the end
            for (int i = [[limitedCacheChannel items] count] - 1; i > maxNumOfCachedEntries - 1; i--) {
                [[limitedCacheChannel items] removeObjectAtIndex:i];
                NSLog(@"removing item at index %i", i);
            }
        NSLog(@"limitedCacheChannel (after): %i", [[limitedCacheChannel items] count]);
        // Cache this limited channel
        [NSKeyedArchiver archiveRootObject:limitedCacheChannel
                                    toFile:cachePath];
    }
    
    // This is the controller's callback code
    block(channelCopy, err);
}];

[/code]


#7

Why is nobody looking at the properties of NSFetchRequest?
I also used the completion block right before it archives… and using an NSRange took care of it…

NSRange cacheRangeLimiter = NSMakeRange(100, [channelCopy.items count]);
if ([cachedChannel.items count] > 100)
{
[cachedChannel.items removeObjectsInRange:cacheRangeLimiter];
}


#8

This challenge was really interesting for me.
Designing cache seems one of the most difficult parts for the client application.

To prune the cache, I took the almost same way which zzzzzzzzzzz described.
And also modify the TableView update code to use reloadSections:withRowAnimation: in order to avoid the bug when the itemDelta become negative.

BNRFeedStore.m

[connection setCompletionBlock:^(RSSChannel* obj, NSError* err){ if (!err) { [channelCopy addItemsFromChannel:obj]; if ([[channelCopy items] count] > maxCacheSize) { RSSChannel* archiveChannel = [channelCopy copy]; [[archiveChannel items] removeObjectsInRange:NSMakeRange(maxCacheSize, [[archiveChannel items] count] - maxCacheSize)]; [NSKeyedArchiver archiveRootObject:archiveChannel toFile:cachePath]; } else { [NSKeyedArchiver archiveRootObject:channelCopy toFile:cachePath]; } } //this is controller's callback code block(channelCopy, err); }];

ListViewController.m

[code] if (rssType == ListViewControllerRSSTypeBNR) {
channel = [[BNRFeedStore sharedStore] fetchRSSFeedWithCompletion:^(RSSChannel* obj, NSError* err){
[[self navigationItem] setTitleView:currentTitleView];
if (!err) {
int currentItemCount = [[channel items] count];

			channel = obj;
			
			int newItemCount = [[channel items] count];
			int itemDelta = newItemCount - currentItemCount;
			
			if (itemDelta != 0){
				[[self tableView] reloadSections:[NSIndexSet indexSetWithIndex:0]
								withRowAnimation:UITableViewRowAnimationAutomatic];
			}
		}
	}];
	[[self tableView] reloadData];[/code]

Thanks.


#9

Hi,

The TableView animation triggered by reloadSections:withRowAnimation: with UITableViewRowAnimationAutomatic is not as nice as that triggered by insertSections:withRowAnimation: with UITableViewRowAnimationTop for me.

So, I tried to use insertSections:withRowAnimation: without losing the flexibility of changing max cache size.
It took me almost a week due to a lot of pitfalls which caused hard debugging, so I want to share my experience for anyone who might encounter similar problem.
Any comments or advice are really welcome.

I took the following steps.

[ul]1) Change the type of items collection in RSSChannel from NSMutableArray to NSMutableOrderedSet, in order to get the diff between cache and web response easily

  1. Modify tableViewController’s callback to get the diff by using minusOrderedSet:, and also modify addItemsFromChannel: to use unionOrderedSet: instead of fast enumeration
    [list]- creating diff might be a responsibility of the store but I didn’t touch the store just to avoid interface change.[/ul]
  2. Remove the App from iOS Simulator or iOS Device
    [ul]- I don’t know why but the type change didn’t become effective unless the App was removed
  • Without removing it, exception, such as “_NSArrayM unionOrderedSet: unrecognized selector send…” were thrown[/ul]
  1. Overriding hash method for RSSItem seems required
    [ul]- Before overriding hash method, I encountered intermittent crash caused by the strange result of the unionOrderedSet and minusOrderedSet[/ul]
  2. Sort the orderedSet for the web response before executing unionOrderedSet:
    [ul]- Although there is no written document, sort order seems some impact to the result of unionOrderedSet: (still investigating) [/ul]
    [/list:u]

Here is the code.

RSSChannel.h

RSSChannel.m[code]

  • (id)init{
    self = [super init];
    if (self) {
    items = [[NSMutableOrderedSet alloc] init];
    }
    return self;
    }

-(void)addItemsFromChannel:(RSSChannel *)otherChannel{
[otherChannel sortItems]; //Without this code, the result of unionOrderedSet was not what expected. I couldn’t figure out reason.
[[self items] unionOrderedSet:[otherChannel items]];
[self sortItems];
}

  • (void)sortItems {
    [[self items] sortUsingComparator:^NSComparisonResult(id obj1, id obj2) {
    return [[obj2 publicationDate] compare:[obj1 publicationDate]];
    }];
    }
    [/code]
    RSSItem.m

-(NSUInteger)hash{ return [[self link] hash]; }
ListViewController.m

[code] if (rssType == ListViewControllerRSSTypeBNR) {
channel = [[BNRFeedStore sharedStore] fetchRSSFeedWithCompletion:^(RSSChannel* obj, NSError* err){
[[self navigationItem] setTitleView:currentTitleView];
if (!err) {

			//Create diff between items currently set in the TableView and items returned by the store
			RSSChannel* diffChannel = [obj copy];
			[[diffChannel items] minusOrderedSet:[channel items]];

			//Get the indexSet for the diff in the items orderedset returned by the store
			NSIndexSet* diffIndexes = [[obj items] indexesOfObjectsPassingTest:^BOOL(id obj, NSUInteger idx, BOOL *stop) {
				return [[diffChannel items] containsObject:obj];
			}];
			
			//Convert the diff indexSet to array of NSIndexPath  
			NSMutableArray* paths = [NSMutableArray array];
			[diffIndexes enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) {
				[paths addObject:[NSIndexPath indexPathForRow:idx inSection:0]];
			}];
			
			channel = obj;
			
			[[self tableView] insertRowsAtIndexPaths:paths withRowAnimation:UITableViewRowAnimationTop];
			
		}
	}];

[/code]

Thanks.


#10

I originally added code to the VC to only add new items coming from the callback. It worked but decided to try adding a 4th channel instead for cleaner code.

I kept my cache at 10 which caused duplicate posts in the view. This bug was caused by adding posts older than those in cache.

So I updated the addItemsFromChannel: in RSSChannel

[code]- (void)addItemsFromChannel:(RSSChannel *)otherChannel
{
NSDate *oldestItemDate = [[items lastObject] publicationDate]; // bronze challenge ch29
if (!oldestItemDate) {
oldestItemDate = [NSDate distantPast];
}

for (RSSItem *i in [otherChannel items]) {
    if (![[self items] containsObject:i]) { // auto calls rssitem isequal
        
        // bronze challenge ch29 begin
        switch ([oldestItemDate compare:[i publicationDate]]) {
            // prevent adding items older than cache
            case NSOrderedAscending:    // ok to add
                [[self items] addObject:i];
                break;
            case NSOrderedSame:         // same date
                break;
            case NSOrderedDescending:   // older than current oldest item date
                break;
        }
        // bronze challenge ch29 end
    }
}

[[self items] sortUsingComparator:^NSComparisonResult(id obj1, id obj2) {
    return [[obj2 publicationDate] compare:[obj1 publicationDate]];
}];

}
[/code]