Powering Class Story With Texture

GATSBY_EMPTY_ALT

One of the most effective products we've launched is Story. As a part of our mission to help teachers, parents and students create incredible classrooms, we released Story as a way to refocus the conversation around the incredible "ah ha!" moments students experience. ClassDojo launched Class Story in September 2015, since then we've added Student Story, Story for Parents, School Story, and just recently, Story for student accounts. We decided to use Texture to power Story so we can continue to deliver unique experiences to classrooms worldwide.

We initially used UIKit to power our first version of story: Class Story. The spec only allowed photo, text, or photo and text posts for teachers So we used UITableView and UITableViewCell for the views. We use Core Data to store all of the data, so we can leverage convenient UIKit classes like NSFetchedResultsController to make the data-binding easier. However, as the product grew, we started running into common issues with our UIKit-based solution. Notably:

  • Out of bounds exceptions on NSFetchedResultsControllerDelegate calls
  • Scroll slowdown on older devices
  • NSIndexPath wrangling to handle cells above and below Core-Data backed cells
  • Height calculation issues on cells
  • Auto Layout issues
  • Preloading older data as the user scrolled to the bottom of a feed (i.e. infinite scrolling)

We needed to find a better set of solutions to tackle each issue. But it wasn't as if we were the first team ever to try and figure out how to develop a feed - there are several open-source solutions for handling this use-case. Our game plan was to examine such solutions and see if they were better than refactoring and staying with UIKit. We looked at a few of these libraries (ComponentKit, IGListKit, and Texture - formerly AsyncDisplayKit) and built a few test apps to see what kinds of costs/benefits each provided. We ultimately ended up settling on Texture because of its high-performance scrolling, ability to handle complex user-interactions, large contributor base and intelligent preloading for infinite scroll.

Texture gave a few wins right out of the box: height calculation, autolayout constraint issues, and infinite scrolling were effectively solved for us. Texture implements a flexbox-styled layout system that infers height from a layout specification object each view can choose to implement. Layout and render calculations are all performed on background threads, which allows the main thread to remain available for user-interactions and animations. A convenient delegate method, -tableView:(ASTableView *)tableView willBeginBatchFetchWithContext:(ASBatchContext *)context allows us to detect when the user is going to scroll to the bottom of the screen and fire the network requests necessary to provide a smooth, infinite-scroll, experience.

Handling NSIndexPath calculations required a bit of additional wrangling. Quite a few of our feeds include auxiliary cells at the top or bottom of the feed. These cells usually have some kind of call to action inviting users down some deeper user flow we are trying to encourage. Implementing these cells in UIKit and Core Data caused us to implement some rather annoying index calculations. Most of these calculations arose because of constraints around NSFetchedResultsController. The fetched results controller is a convenient class that allows handling of predicates, Core Data update handlers, and data fetching. The problem is that the fetched results controller always assumes that its indices start from row 0. Adding additional cells above or below require calculations on detecting whether or not a given index is for an auxiliary cell and, if not, requires a transformation on the index so that NSFetchedResultsController knows how to handle it.

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
  if ([self isIndexPathForFirstHeaderCell:indexPath]) {
      // calculate cell here
      return cell;
  } 

   NSIndexPath *adjustedIndexPath = [self adjustedIndexPathForFetchedResultsController:indexPath];
   id data = [self.fetchedResultsController objectAtIndex:adjustedIndexPath];
   // calculate cell here
   return cell;
}

There is one main problem with this approach: if the order of the data models backing the auxiliary cells is modified, we have to remember to recalculate the indicies. If those calculations are incorrect, the wrong data would show up for a given cell (at best) or the app would crash (at worst).

In order to fix this issue, we opted to check for the class of the backing data and having the delegate methods map it to the appropriate logic.

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

  id backingData = [self dataForIndexPath:indexPath];
  class dataClass = [backingData class];

  if (dataClass == [AuxiliaryData class]) {
      // Configure and return AuxiliaryCell
  } else if (dataClass == [ManagedObjectCustomClass class]) {
    // Configure and return managed object table view cell
  }
}

This approach gives us two advantages. First, it solves the issue of index calculation not matching up with the backing data object. Checking the class allows the data binding to be index agnostic; instead of indexes determining views, the data determines the appropriate view. This became especially important in crushing bugs regarding interactivity: because the cell indexes directly match the backing data, there isn’t a chance that tapping a cell would trigger the logic for the cell above, or the cell below. Secondly, this approach eliminated a common bug where a newly added or deleted auxiliary cell caused Core Data backed cells to be visible or not. With the index calculation, it was possible that a cell previously held by a Core Data object would now have an NSIndexPath associated with an auxiliary cell (or vice-versa). By checking the class of the data, we don’t have to worry about the timing in which the indicies are updated and whether or not it matches up in time with Core Data automatic updates.

Texture also comes with a rather nifty preloading system. It provides a convenient method that notifies the system when the user is n screen-lengths away from the bottom of the screen (n is defaulted to 2). The method gives a simple context object which needs to be notified when data is being fetched and when the fetching is completed.

- (void)tableView:(ASTableView *)tableView willBeginBatchFetchWithContext:(ASBatchContext *)context {
    
    [context beginBatchFetching];

    // do fetch work

    [context completeBatchFetching:YES];
}

We decided to use the NSFetchedResultsControllerDelegate along with Texture’s ASTableNode batchUpdate system to have this batch fetch system automatically update the upcoming cells offscreen. In order to do this properly, the context can’t be given the completeBatchFetching: signal until both the data fetch and the animation update has been completed. To do this, we use a single property to keep track of the current state.

typedef NS_OPTIONS(NSUInteger, CDBFeedItemSyncMoreState) {
    CDBFeedItemSyncMoreStateNone = 0,
    CDBFeedItemSyncMoreStateRequestCompleted = 1 << 0,
    CDBFeedItemSyncMoreStateViewUpdateCompleted = 1 << 1,
};

- (void)setSyncMoreState:(CDBFeedItemSyncMoreState)syncMoreState {
    
    BOOL requestFinished = syncMoreState & CDBFeedItemSyncMoreStateRequestCompleted;
    BOOL viewsUpdated = syncMoreState & CDBFeedItemSyncMoreStateViewUpdateCompleted;
    
    if (requestFinished && viewsUpdated) {
        
        if (self.batchContext) {
            [self.batchContext completeBatchFetching:YES];
            self.batchContext = nil;
        }
        _syncMoreState = CDBFeedItemSyncMoreStateNone;
    } else {
        
        _syncMoreState = syncMoreState;
    }
}

When the data fetch request completes, it simply sets the syncMoreState property to

self.syncMoreState = self.syncMoreState|CDBFeedItemSyncMoreStateRequestCompleted

When the view update completes, it updates the syncMoreState property as well

- (void)updateForChanges:(CDUFetchedResultsUpdateModel *)updateModel {
        
    __weak typeof(self) weakSelf = self;
    [self.tableNode handleFetchedResultsUpdates:updateModel completion:^(BOOL finished) {
        
        __strong typeof(weakSelf) strongSelf = weakSelf;
        
        if (weakSelf.batchContext) {
            
            weakSelf.syncMoreState = weakSelf.syncMoreState|CDBFeedItemSyncMoreStateViewUpdateCompleted;
        }        
    }];
}

This approach allowed us to connect the existing functionality in UIKit with the performance and syntactic-sugar that Texture provides.

Here is a side-by-side comparison on an iPad Gen 3 - iOS 8:

However, Texture did come without its own set of challenges. In particular, Texture’s reliance on background dispatch pools created interesting threading problems with our Core Data stack. Texture tries to call init and view loading methods on background threads where possible, but often those were the same places where Core Data objects were being referenced. Because our NSFetchedResultsControllers always run on the main thread, these background dispatch pools often caused concurrency warnings when a Texture view attempted to use an NSManagedObject.

To address this, we strictly enforce read-only operations on the main queue NSManagedObjectContext. By limiting the main queue to only read-only operations, we are able to get the latest data from the NSFetchedResultsController while preventing any update/insert/delete operations that may cause instability in other parts of the app. Overall, this is the least satisfactory aspect of Texture and is an ongoing area of exploration for us.

Like any library, Texture provides a series of tradeoffs. It allows us to leverage existing UIKit technology and GCD, but exposed us to an entirely new set of potential concurrency issues. However, we’ve found Texture provides a smoother scrolling experience, better performance on older devices, and allows us to have a higher velocity in developing new functionality. Overall, if your application includes an Instagram/Pinterest style feed, we encourage you try out Texture for yourself!

  • Programming
  • iOS
  • Mobile
  • Story
Next Post
Previous Post