Improving content download without cats



    Fast and high-quality delivery of content to users is the most important task we are constantly working on when working on the iFunny application. The lack of elements of waiting, even with a bad connection - this is what any service aspires to view media content.

    We had several iterations on working with content prefetching. In each major major version, we invented something new and looked at how it works on users. In the next iteration of working with prefetching, it was decided to first debug the metrics that it affects on the local stand, and only then give the result to the users.

    In this article, I will tell you about what iFunny prefetching looks like now and how to automate the research process for further tuning its settings.

    Standard prefetching


    In iOS 10, Apple provided the ability to run prefetching out of the box. For this, the UICollectionView class has a field:

    @property (nonatomic, weak, nullable) id<UICollectionViewDataSourcePrefetching> prefetchDataSource;
    @property (nonatomic, getter=isPrefetchingEnabled) BOOL prefetchingEnabled;

    To enable native prefetching, just assign an object that implements the UICollectionViewDatasourcePrefetching protocol to the prefetchDataSource field and set the second field to YES.

    To implement the prefetching protocol, it is necessary to describe two of its methods:

    - (void)collectionView:(UICollectionView *)collectionView prefetchItemsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;
    - (void)collectionView:(UICollectionView *)collectionView cancelPrefetchingForItemsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;

    In the first method, you can perform any useful work on the preparation of content.

    In the case of iFunny, it looked like this:

    NSMutableArray<NSURL *> *urls = [NSMutableArray new];
    for (NSIndexPath *indexPath in indexPaths) {
    	NSObject<IFFeedItemProtocol> *item = [self.model itemAtIndex:indexPath.row];
    	NSURL *downloadURL = item.downloadURL;
    	if (downloadURL) {
    		[urls addObject:downloadURL];
    	}
    }
    [self.downloadManager updateActiveURLs:urls];
    [urls enumerateObjectsUsingBlock:^(NSURL *_Nonnull url, NSUInteger idx, BOOL *_Nonnull stop) {
    	[self.downloadManager downloadContentWithURL:url.absoluteString forView:nil withOptions:0];
    }];

    The second method is optional, but in the case of iFunny tape, it was not called at all by the system.

    Prefetching works, but our method was called only for the content following the active one.
    In general, the work of standard prefetching for a UICollectionView depends very much on how the collection view is implemented. In addition, since we do not know the implementation of the standard prefetching at all, it is impossible to guarantee its stable operation. Therefore, we have implemented our prefetching mechanism, which has always worked, as we need.

    Our prefetching algorithm


    Before developing the prefetching algorithm, we wrote out all the features of the iFunny tape:

    1. The tape may consist of different types of content: images, video, web app, native advertising.
    2. The tape works with pagination.
    3. Most users only scroll forward.
    4. In iFunny via LTE, 20% of user sessions occur.

    Based on these conditions, we have a simple algorithm:

    1. There is 1 active element in the tape, all others are inactive.
    2. The active element must always download content to the end.
    3. Each content item in a feed has its own weight.
    4. On the current Internet connection, you can load items worth N.
    5. At each scrolling of the tape, we change the active element and calculate which elements are loaded, and the rest of the rest is canceled.

    The architecture in the code of this algorithm contains several base classes and a protocol:

    • IFPrefetchedCollectionProtocol

    @protocolIFPrefetchedCollectionProtocol@property (nonatomic, readonly) NSUInteger prefetchItemsCount;
    - (NSObject<IFFeedItemProtocol> *)itemAtIndex:(NSInteger)index;
    @end

    This protocol is required to get collection and content parameters into class objects:

    • IFContentPrefetcher

    @interfaceIFContentPrefetcher : NSObject@property (nonatomic, weak) NSObject<IFPrefetchedCollectionProtocol> *collection;
    @property (nonatomic, assign) NSInteger activeIndex;
    @end

    The class implements the logic of the content prefetching algorithm:

    • IFPrefetchOperation

    @interfaceIFPrefetchOperation : NSObject@property (nonatomic, readonly) NSUInteger cost;
    - (void)fetchMinumumBuffer;
    - (void)fetchEntireBuffer;
    - (void)pause;
    - (void)cancel;
    - (BOOL)isEqualOperation:(IFPrefetchOperation *)object;
    @end

    This is the base class of the atomic operation, which describes the useful work of prefetching specific content and indicates its parameter - weight.

    To run the algorithm, we described two operations:

    1. Picture. Has a weight of 1. Always loaded completely;
    2. Video. Has a weight of 2. Loaded fully only when active. In the inactive state, the first 200 KB are loaded.

    As a metric for evaluating the operation of the algorithm, we chose the number of hits of the UI element of loaders per 1000 viewed content elements.

    On standard prefetching, this metric we had about 30 impressions / 1000 items. After the implementation of the new algorithm, this metric dropped to 25 hits / 1000 items.

    Thus, the number of loader hits has decreased by 20% and the total amount of content viewed by users has slightly increased.

    Then we started the selection of optimal parameters for Featured - the most popular tape in iFunny.

    Selection of parameters for prefetching


    The developed prefetching algorithm has input parameters:

    1. The total cost of the download.
    2. The cost of loading each item.

    We will still measure the number of loaders.

    As auxiliary tools to simplify data collection we will use:

    1. Gray tests with a set of frameworks KIF, OHHTTPStubs.
    2. sh-scripts and xcodebuild to run tests with different parameters.
    3. 3G network profile available in the Developer - Network Link Conditioner setting.

    Let us examine how each of these tools helped us.

    Tests


    To emulate how users are browsing content, we decided to use the KIF framework, familiar to developers on iOS with Objective-C.

    KIF works great for Objective-C and Swift, after some easy manipulations described in the KIF documentation:
    https://github.com/kif-framework/KIF#use-with-swift

    We chose Objective-C to test the tape, including number and in order to be able to replace the methods we need in the service of analytics.

    Let's sort in parts the code of a simple test, which we got:

     - (void)setUp {
        [super setUp];
        [self clearCache];
        [[NSURLCache sharedURLCache] removeAllCachedResponses];
        [OHHTTPStubs stubRequestsPassingTest:^BOOL(NSURLRequest *_Nonnull request) {
            return [request.URL.absoluteString isEqualToString:@"http://fun.co/rp/?feed=featured&limit=30"];
        }
            withStubResponse:^OHHTTPStubsResponse *_Nonnull(NSURLRequest *_Nonnull request) {
                NSString *path = OHPathForFile(@"featured.json", self.classForCoder);
                OHHTTPStubsResponse *response = [[OHHTTPStubsResponse alloc] initWithFileAtPath:path statusCode:200 headers:@{ @"Content-Type" : @"application/json" }];
                return response;
            }];
    }

    In the test setup method, we need to clear the cache so that each time you start the content is loaded from the network, and completely clear the Caches folder in the application.

    To ensure the stability of the data in each of the tests, we used the OHHTTPStubs library, which allows you to easily replace the answers to network requests in a few simple steps:

    1. Define request parameters. For us, this is the URL of the Featured API request feed - http://fun.co/rp/?feed=featured&limit=30
    2. Record the necessary answer and save it to a file, attach it to the target with the test.
    3. Define response parameters. In the code above, this is the Content-Type header and the response code.
    4. Check out everything in the instructions for OHHTTPStubs.

    More information about working with OHHTTPStubs can be found in the documentation:
    http://cocoadocs.org/docsets/OHHTTPStubs/

    The test itself looks like this:

    - (void)testFeed {
        KIFUIViewTestActor *feed = [viewTester usingLabel:@"ScrolledFeed"];
        [feed waitForView];
        [self setupCustomPrefetchParams];
        for (NSInteger i = 1; i <= 1000; i++) {
            [feed waitForCellInCollectionViewAtIndexPath:[NSIndexPath indexPathForRow:i inSection:0]];
            [viewTester waitForTimeInterval:1.0f];
        }
        [self appendStatisticLine];
    }

    With KIF, we get the tape and then scroll 1000 content items with a wait of 1 second.

    Method setupCustomPrefetchParams we will analyze later.

    To determine the number of loaders shown, we will use the Objective-C runtime and substitute the method from the analytics service for the test method:

    + (void)load {
        [self swizzleSelector:@selector(trackEventLoaderViewedVideo:)
                      ofClass:[IFAnalyticService class]];
    }
    + (void)swizzleSelector:(SEL)originalSelector
                    ofClass:(Class) class {
        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod([selfclass], originalSelector);
        BOOL didAddMethod = class_addMethod(class,
                                            originalSelector,
                                            method_getImplementation(swizzledMethod),
                                            method_getTypeEncoding(swizzledMethod));
        if (didAddMethod) {
            class_replaceMethod(class,
                                originalSelector,
                                method_getImplementation(originalMethod),
                                method_getTypeEncoding(originalMethod));
        }
        else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    }
    - (void)trackEventLoaderViewedVideo : (BOOL)onVideo {
        if (onVideo) {
            [IFTestFeed trackLoaderOnVideo];
        }
        else {
            [IFTestFeed trackLoaderOnImage];
        }
    }

    Now we have an automatic test in which the application always receives the same content and scrolls the same number of elements. And according to its results, it writes a line with execution statistics to the log.

    Since the download of content is mainly influenced by the Internet connection, the test with one set of parameters needs to be repeated not once, but several.

    Startup automation


    To automate and parameterize tests, we decided to use the launch via xcodebuild with passing the necessary parameters.

    To pass parameters to the code, we need to enter the name of the argument in the settings of the target for the tests in Prepocessor Macros:



    To access the parameter from the Objective-C code, you need to declare two macros:

    #define STRINGIZE(x) #x#define BUILD_PARAM(x) STRINGIZE(x)

    Now when starting from the terminal using xcodebuild:

    xcodebuild test  -workspace iFunny.xcworkspace -scheme iFunnyUITests -destination 'platform=iOS,id=DEVICE_ID' MAX_PREFETCH_COST="5" VIDEO_COST="2" IMAGE_COST="2"

    In the code you can read the parameters passed:

    - (void)setupCustomPrefetchParams {
        NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];
        formatter.numberStyle = NSNumberFormatterNoStyle;
        [IFAppController instance].prefetchParams.goodNetMaxCost = [formatter numberFromString:@BUILD_PARAM(MAX_PREFETCH_COST)];
        [IFAppController instance].prefetchParams.videoCost = [formatter numberFromString:@BUILD_PARAM(VIDEO_COST)];
        [IFAppController instance].prefetchParams.imageCost = [formatter numberFromString:@BUILD_PARAM(IMAGE_COST)];
    }

    Now everything is ready to run these tests offline using shell scripts.

    Run xcodebuild with a set of parameters 10 times in a row:

    max=10
    for i in `seq 1 $max`
    do
        xcodebuild test  -workspace iFunny.xcworkspace -scheme iFunnyUITests -destination 'platform=iOS,id=DEVICE_ID' MAX_PREFETCH_COST="$1" VIDEO_COST="$2" IMAGE_COST="$3"done

    We also generated a script with the launch of various parameter sets. All testing lasted several days. The data obtained were summarized in a single table, and we compared them with the current working version.

    As a result, the best for iFunny Featured tape turned out to be simple prefetching of five elements, without taking into account the format of the content (video or picture).

    According to the results


    The article describes the approach that will allow you to explore and monitor any critical part of the application, without changing the main project code.

    Here is what will help to conduct such research:

    • Use testing frameworks for monotonous actions.
    • Automation via xcodebuild for parameterization of launches.
    • Runtime Objective-C to change the necessary logics where it is possible.

    Based on this approach to testing the application, we began to add monitoring of important modules on the local stand and have already prepared several tests that we periodically run to check the quality of the application.

    PS: According to our tests, the new settings prefetchinga relatively production-variant gain about 8%, in reality, were reduced display loaders by 3%, which means that we have to deliver smiles iFunny 3% more often :)

    PPS: Stop on We are not going to achieve what we have achieved; we will continue to improve the content prefetching further.

    Also popular now: