...

пятница, 13 июля 2018 г.

Улучшаем загрузку контента без котиков


Быстрая и качественная доставка контента пользователям — важнейшая задача, которой мы постоянно занимаемся, работая над приложением iFunny. Отсутствие элементов ожидания даже при плохом соединении — к этому стремится любой сервис для просмотра медиа-контента.
У нас было несколько итераций по работе с префетчингом контента. В каждой новой мажорной версии мы изобретали что-то новое и смотрели, как это работает на пользователях. В очередной итерации по работе с префетчингом было решено сначала отладить метрики, на которые он влияет, на локальном стенде, а уже потом отдать результат пользователям.
В этой статье я расскажу про то, как выглядит префетчинг в iFunny сейчас и о том, как автоматизировали процесс исследования для дальнейшего тюнинга его настроек.

Cтандартный префетчинг


В iOS 10 Apple предоставила возможность запускать префетчинг из коробки. Для этого у класса UICollectionView появилось поле:
@property (nonatomic, weak, nullable) id<UICollectionViewDataSourcePrefetching> prefetchDataSource;
@property (nonatomic, getter=isPrefetchingEnabled) BOOL prefetchingEnabled;

Чтобы включить нативный префетчинг, достаточно присвоить полю prefetchDataSource объект, реализующий протокол UICollectionViewDatasourcePrefetching, и выставить в YES второе поле.
Для реализации протокола префетчинга нужно описать два его метода:
- (void)collectionView:(UICollectionView *)collectionView prefetchItemsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;
- (void)collectionView:(UICollectionView *)collectionView cancelPrefetchingForItemsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;

В первом методе можно выполнять любую полезную работу по подготовке контента.
В случае с iFunny это выглядело вот так:
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];
}];

Второй метод является опциональным, но в случае с лентой iFunny он не вызывался системой совсем.
Префетчинг работает, но у нас метод вызывался только для контента, следующего за активным.
В целом работа стандартного префетчинга для UICollectionView очень сильно зависит от того, как реализован вид коллекции. Кроме того, так как мы совсем не знаем реализацию работы стандартного префетчинга — невозможно гарантировать его стабильную работу. Поэтому мы реализовали свой механизм префетчинга, который всегда работал, так как нам нужно.

Наш алгоритма префетчинга


Перед тем, как разработать алгоритм префетчинга, мы выписали все особенности ленты iFunny:
  1. Лента может состоять из разных видов контента: картинки, видео, веб-аппы, нативная реклама.
  2. Лента работает с пагинацией.
  3. Большая часть пользователей листает ленту только вперёд.
  4. В iFunny через LTE происходит 20% пользовательских сессий.

Исходя из этих условий, у нас получился простой алгоритм:
  1. В ленте есть 1 активный элемент, все остальные неактивны.
  2. У активного элемента всегда необходимо загружать контент до конца.
  3. Каждый элемент контента в ленте имеет свой вес.
  4. На текущее интернет-соединение можно грузить элементов на сумму N.
  5. При каждом скроллинге ленты мы меняем активный элемент и вычисляем, какие элементы загружаются, а загрузку всего остального отменяем.

Архитектура в коде этого алгоритма содержит несколько базовых классов и протокол:
  • IFPrefetchedCollectionProtocol
@protocol IFPrefetchedCollectionProtocol

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

@end

Этот протокол необходим для получения параметров коллекции и контента в объекты класса:
  • IFContentPrefetcher
@interface IFContentPrefetcher : NSObject

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

@end

Класс реализует логику алгоритма по префетчингу контента:
  • IFPrefetchOperation
@interface IFPrefetchOperation : NSObject

@property (nonatomic, readonly) NSUInteger cost;

- (void)fetchMinumumBuffer;
- (void)fetchEntireBuffer;
- (void)pause;
- (void)cancel;

- (BOOL)isEqualOperation:(IFPrefetchOperation *)object;

@end

Это базовый класс атомарной операции, в которой описывается полезная работа по префетчингу конкретного контента и указывается его параметр — вес.
Для запуска алгоритма мы описали две операции:
  1. Картинка. Имеет вес 1. Всегда загружается полностью;
  2. Видео. Имеет вес 2. Загружается полностью только, когда активно. В неактивном состоянии грузятся первые 200 КБ.

В качестве метрики для оценки работы алгоритма мы выбрали количество показов UI-элемента лоадеров на 1000 просмотренных элементов контента.
На стандартном префетчинге эта метрика у нас была около 30 показов/1000 элементов. После внедрения нового алгоритма эта метрика снизилась до 25 показов/1000 элементов.
Таким образом, снизилось количество показов лоадера на 20% и немного повысилось общее количество просмотренного пользователями контента.
Далее приступили к подбору оптимальных параметров для Featured — самой популярной ленты в iFunny.

Подбор параметров для префетчинга


Разработанный алгоритм префетчинга имеет входные параметры:
  1. Общая стоимость загрузки.
  2. Стоимость загрузки каждого элемента.

Мы по-прежнему будем мерить количество лоадеров.
В качестве вспомогательных инструментов для упрощения сбора данных будем использовать:
  1. Серые тесты с набором фреймворков KIF, OHHTTPStubs.
  2. sh-скрипты и xcodebuild для запуска тестов с разными параметрами.
  3. Профиль сети 3G, доступный в настройке Developer — Network Link Conditioner.

Разберём, как нам помог каждый из этих инструментов.

Тесты


Чтобы эмулировать то, как пользователи просматривают контент, мы решили использовать фреймворк KIF, знакомый разработчикам под iOS на Objective-C.
KIF отлично работает для Objective-C и Swift, после некоторых нетрудных манипуляций, описанных в документации KIF:
https://github.com/kif-framework/KIF#use-with-swift
Для тестирования ленты мы выбрали Objective-C, в том числе и для того чтобы можно было подменить нужные нам методы в сервисе аналитики.
Разберём по частям код простого теста, который у нас получился:
 - (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;
        }];
}

В методе настройки теста обязательно чистим кеш, чтобы при каждом запуске контент грузился из сети, и полностью очищаем папку Caches в приложении.
Для обеспечения стабильности данных в каждом из тестов мы воспользовались библиотекой OHHTTPStubs, которая позволяет легко подменять ответы на сетевые запросы в несколько простых шагов:
  1. Определить параметры запроса. Для нас это url запроса ленты Featured к API — http://fun.co/rp/?feed=featured&limit=30
  2. Записать необходимый ответ и сохранить его в файл, прикрепить к таргету с тестом.
  3. Определить параметры ответа. В коде выше это заголовок Content-Type и код ответа.
  4. Оформить всё в инструкции для OHHTTPStubs.

Более подробно о работе с OHHTTPStubs можно почитать в документации:
http://cocoadocs.org/docsets/OHHTTPStubs/

Сам тест выглядит так:

- (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];
}

С помощью KIF мы получаем ленту и далее скролим 1000 элементов контента с ожиданием в 1 секунду.
Метод setupCustomPrefetchParams мы разберём чуть позже.
Чтобы определить количество показанных лоадеров, мы воспользуемся Objective-C runtime и подменим метод из сервиса для аналитики на метод теста:
+ (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([self class], 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];
    }
}

Теперь у нас есть автоматический тест, в котором приложение всегда получает одинаковый контент и скроллит одинаковое количество элементов. А по его результатам записывает в лог строчку со статистикой выполнения.
Так как на загрузку контента в основном влияет интернет соединение, то тест с одним набором параметров нужно повторить не один раз, а несколько.

Автоматизация запуска


Для автоматизации и параметризации тестов мы решили воспользоваться запуском через xcodebuild с передачей нужных параметров.
Для передачи параметров в код нам нужно прописать имя аргумента в настройках таргета для тестов в Prepocessor Macros:

Чтобы обращаться к параметру из Objective-C кода нужно объявить два макроса:
#define STRINGIZE(x) #x
#define BUILD_PARAM(x) STRINGIZE(x)

Теперь при запуске из терминала с помощью xcodebuild:
xcodebuild test  -workspace iFunny.xcworkspace -scheme iFunnyUITests -destination 'platform=iOS,id=DEVICE_ID' MAX_PREFETCH_COST="5" VIDEO_COST="2" IMAGE_COST="2"

В коде можно прочитать передаваемые параметры:
- (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)];
}

Теперь всё готово к тому, чтобы запускать эти тесты в автономном режиме с помощью shell скриптов.
Запуск xcodebuild с набором параметров 10 раз подряд:
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

Также мы сгенерировали скрипт с запуском различных наборов параметров. Всё тестирование длилось несколько дней. Полученные данные были сведены в единую таблицу, и мы сравнили их с текущим рабочим вариантом.
В итоге, самым лучшим для Featured ленты iFunny оказался простой префетчинг пяти элементов, без учёта формата контента (видео это или картинка).

По итогу


В статье описан подход, которых позволит исследовать и мониторить любую критически важную часть приложения, без изменения основного кода проекта.
Вот что поможет проводить такие исследования:
  • Использование фреймворков тестирования для монотонных действий.
  • Автоматизация через xcodebuild для параметризации запусков.
  • Рантайм Objective-C для изменения нужных логик там, где это возможно.

На основе такого подхода к тестированию приложения мы стали добавлять мониторинг важных модулей на локальном стенде и уже подготовили несколько тестов, которые периодически запускаем для проверки качества приложения.

P. S. По результатам наших тестов новые настройки префетчинга относительно продакшн-варианта выигрывают около 8%, в реальности же получили снижение показа лоадеров на 3%, а это значит, что мы стали доставлять улыбки в iFunny на 3% чаще :)

P. P. S. Останавливаться на достигнутом не собираемся, продолжим совершенствовать префетчинг контента и дальше.

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий