Баннерная реклама в iOS-приложении

cej2t9yugiyn0bnzyhfnwtckqmq.jpeg

Сегодня мы открываем цикл статей о том, о чём обычно не говорят на технических конференциях и митапах. Этот и последующие посты расскажут, как устроен механизм монетизации в популярном в США развлекательном iOS-приложении iFunny, разработкой которого мы занимаемся.
Реклама — один из основных способов монетизации бесплатных приложений. Но это сейчас, а какие варианты были в 2011 году, когда появился iFunny? Сервис изначально строился как крепкий, устойчивый бизнес, поэтому с самого первого дня компания решила не заигрывать с пользователями и не заниматься играми с условной капитализацией.

На тот момент основным вариантом монетизации было создать бесплатную урезанную версию сервиса, а затем пытаться продать основной функционал. Потребитель был молод, неопытен и не был готов расставаться с суммами больше одного доллара.

Несложная математика показывала, что при конверсии 10% получить ARPU больше 10 центов — задача практически невыполнимая.

Тогда пришлось задуматься, как ещё можно монетизировать продукт. Рекламная модель уже очень хорошо работала в вебе, и можно было предположить, что скоро она расцветёт и на телефонах.
Вообще началом мобильной рекламной модели монетизации можно считать появление AdWhirl — сервиса, который позволял интегрировать SDK рекламных сетей и ротировать их. Его появление позволило поднять FillRate в среднем до 50% по рынку и сделать доход от рекламной модели хотя бы сопоставимым с однодолларовой продажей. Сам принцип имплементации всех возможных источников спроса и организации конкуренции между ними стал основным драйвером роста рекламной индустрии и продолжает эксплуатироваться по сей день.

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

MoPub и компания


С 2012 года мы перешли с AdWhirl на MoPub.

MoPub — это мобильная рекламная платформа с возможностью надстройки своих собственных модулей, которая включает в себя несколько больших инструментов:

  • MoPub marketplace — собственная рекламная биржа;
  • медиатор рекламных сетей для работы с внешними сетями;
  • механизм заказов, позволяющий самостоятельно размещать баннеры в собственном приложении и настраивать их показы.


Основные достоинства MoPub:

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


Есть у MoPub и недостатки:

  • не принимаются пул-реквесты на GitHub и вообще отсутствует реакция на них;
  • панель управления очень сложная, и для разработчика при отладке требуется некоторое время, чтобы вникнуть в её структуру.


Сила в правде


Как говорил герой одного русского фильма: «Сила в правде». В этой части я расскажу о трудностях, с которыми нам, как разработчикам приложения, пришлось столкнуться после первых миллионов скачиваний iFunny, роста аудитории и рекламного трафика от более, чем 100 партнёров.

Контент


Рекламный рынок — очень закрытая «каста» технологических компаний, но при этом агрегаторы имеют большую сеть партнёров: от крупных компаний, которые работают с миллионными бюджетам, до мелких фирм, заточенных под конкретные целевые аудитории.

Эта закрытость и разрозненность партнёров, несмотря на премодерацию баннеров и достаточно жёсткие правила по рекламному контенту, позволяет не самым честным продавцам рекламы публиковать креативы, которые являются запрещённым или портят пользовательский опыт в приложении.

Можно выделить несколько основных категорий «непотребного» контента в рекламных баннерах:

  • порно-контент. В последнее время его появляется всё меньше, но тем не менее он имеет место быть. Мы не можем публиковать данный контент в статье, поэтому картинки тут не будет;
  • системные алерты в баннерах, пример можно посмотреть у одного из пользователей twitter.com/IfunnyStates/status/1029393804749668352
  • контент со звуком. Звуки не запрещены рекламными сетями, как и анимации, но если звук играет без взаимодействия с интерфейсом — это воспринимается пользователями как баг приложения и негативно влияет на пользовательский опыт;
  • привлечение внимания. Хороший баннер должен привлекать внимание пользователя, но не всегда это происходит честным образом: иногда в баннеры попадают мерцающие видео. Ещё один нечестный способ заставить пользователя тапнуть на баннер — имитировать интерфейс приложения, например так:

    p53ulkue4iz7ofr14t3i7xyrqpq.png


Кстати, в России обычный тап по этому баннеру может оформить платную подписку у некоторых операторов сотовой связи, и вы даже не узнаете об этом, пока не увидите детализацию. Это также нечестный способ работы с рекламой, но у операторов в США нет такой возможности.

Автоклики


Как показывает мой опыт, это крайне негативный для пользователей кейс. Используя возможности JavaScript, WKWebView или UIWebView, а также дыры внутри реализации рекламных библиотек, можно сделать рекламу, которая будет сама открывать контент баннера и уводить пользователя из приложения.

Для того чтобы повторить такую проблему на примере с MoPub, достаточно добавить в баннер javascript-код следующего содержания:

test


Это работало долго во многих версиях MoPub, вплоть до версии 4.13.

Исследуя реализацию MoPub, можно было генерировать более сложные ссылки, которые позволяли не только открывать рекламу на полный экран, но и отправлять пользователя в AppStore на определённое приложение и даже не учитывать показ баннера.

Кстати, в примечаниях к релизу версии 4.13.0 MoPub SDK для iOS нет информации об этом фиксе, так как это была достаточно серьёзная дыра в SDK, и нечестные партнёры MoPub эксплуатировали её достаточно активно. Как показывают логи, о которых расскажу дальше, ежедневно приходилось блокировать до 2 миллионов попыток открытия баннера без пользовательского взаимодействия с ним.

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

Краши


Краши — это всегда плохо. Ещё хуже, когда они случаются из-за зависимости с закрытым кодом, и повлиять на них можно только косвенно. За годы работы с рекламой в iFunnу выделили для себя несколько типов крашей, которые можно разделить на несколько групп.

  • Системные


Сюда относятся исключения в сетевой библиотеке, WKWebView (UIWebView), OpenGL.
Прямо повлиять на этот тип крашей очень сложно, но на некоторые повлиять всё же удалось, предварительно изучив работу WebView-компонента с WebGL.

Так выглядит стектрейс таких крашей:

1 libGPUSupportMercury.dylib gpus_ReturnNotPermittedKillClient + 12
2 AGXGLDriver gldUpdateDispatch + 7132
3 libGPUSupportMercury.dylib gpusSubmitDataBuffers + 172
4 AGXGLDriver gldUpdateDispatch + 12700
5 WebCore WebCore::GraphicsContext3D::reshape(int, int) + 524
6 WebCore WebCore::WebGLRenderingContextBase::initializeNewContext() + 712
7 WebCore WebCore::WebGLRenderingContextBase::WebGLRenderingContextBase(WebCore::HTMLCanvasElement*, WTF::RefPtr&&, WebCore::GraphicsContext3D::Attributes) + 512
8 WebCore WebCore::WebGLRenderingContext::WebGLRenderingContext(WebCore::HTMLCanvasElement*, WTF::PassRefPtr, WebCore::GraphicsContext3D::Attributes) + 36
9 WebCore WebCore::WebGLRenderingContextBase::create(WebCore::HTMLCanvasElement*, WebCore::WebGLContextAttributes*, WTF::String const&) + 1272
10 WebCore WebCore::HTMLCanvasElement::getContext(WTF::String const&, WebCore::CanvasContextAttributes*) + 520
11 WebCore WebCore::JSHTMLCanvasElement::getContext(JSC::ExecState&) + 212
12 JavaScriptCore llint_entry + 27340
13 JavaScriptCore llint_entry + 24756
14 JavaScriptCore llint_entry + 24756
15 JavaScriptCore llint_entry + 24756
16 JavaScriptCore llint_entry + 25676
17 JavaScriptCore llint_entry + 24756
18 JavaScriptCore llint_entry + 24656
19 JavaScriptCore vmEntryToJavaScript + 260
20 JavaScriptCore JSC::JITCode::execute(JSC::VM*, JSC::ProtoCallFrame*) + 164
21 JavaScriptCore JSC::Interpreter::executeCall(JSC::ExecState*, JSC::JSObject*, JSC::CallType, JSC::CallData const&, JSC::JSValue, JSC::ArgList const&) + 348
22 JavaScriptCore JSC::profiledCall(JSC::ExecState*, JSC::ProfilingReason, JSC::JSValue, JSC::CallType, JSC::CallData const&, JSC::JSValue, JSC::ArgList const&, WTF::NakedPtr&) + 160
23 WebCore WebCore::JSEventListener::handleEvent(WebCore::ScriptExecutionContext*, WebCore::Event*) + 980
24 WebCore WebCore::EventTarget::fireEventListeners(WebCore::Event&, WebCore::EventTargetData*, WTF::Vector&) + 616
25 WebCore WebCore::EventTarget::fireEventListeners(WebCore::Event&) + 324
26 WebCore WebCore::EventContext::handleLocalEvents(WebCore::Event&) const + 108
27 WebCore WebCore::EventDispatcher::dispatchEvent(WebCore::Node*, WebCore::Event&) + 876
28 WebCore non-virtual thunk to WebCore::HTMLScriptElement::dispatchLoadEvent() + 80
29 WebCore WebCore::ScriptElement::execute(WebCore::CachedScript*) + 360
30 WebCore WebCore::ScriptRunner::timerFired() + 456
31 WebCore WebCore::ThreadTimers::sharedTimerFiredInternal() + 144
32 WebCore WebCore::timerFired(__CFRunLoopTimer*, void*) + 24
33 CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__ + 24
34 CoreFoundation __CFRunLoopDoTimer + 868
35 CoreFoundation __CFRunLoopDoTimers + 240
36 CoreFoundation __CFRunLoopRun + 1568
37 CoreFoundation CFRunLoopRunSpecific + 440
38 WebCore RunWebThread(void*) + 452
39 libsystem_pthread.dylib _pthread_body + 236
40 libsystem_pthread.dylib _pthread_start + 280
41 libsystem_pthread.dylib thread_start + 0

Причём происходят они исключительно при уходе в фон. Это связно с тем, что движок OpenGL не должен работать, когда приложение находится в фоновом режиме.

Фикс здесь оказался достаточно простым:

При уходе в фон нужно забрать скриншот баннера.

Удалить рекламную View с экрана, чтобы WebView-компонент перестал использовать OpenGL.
При выходе из фона вернуть всё как было.

В коде на Objective-C это выглядит так:

- (void)onWillResignActive {
    if (self.adView.superview) {
        UIGraphicsBeginImageContext(self.adView.bounds.size);
        [self.adView.layer renderInContext:UIGraphicsGetCurrentContext()];
        UIImage *adViewScreenShot = UIGraphicsGetImageFromCurrentImageContext();
        UIGraphicsEndImageContext();
 
        adViewThumbView = [[UIImageView alloc] initWithImage:adViewScreenShot];
        adViewThumbView.backgroundColor = [UIColor clearColor];
        adViewThumbView.frame = self.adView.frame;
 
        NSInteger adIndex = [self.adView.superview.subviews indexOfObject:self.adView];
        [self.adView.superview insertSubview:adViewThumbView atIndex:adIndex];
        [self.adView removeFromSuperview];
    }
}
 
- (void)onDidBecomeActive {
    if (self.adView && adViewThumbView) {
        NSInteger adIndex = [adViewThumbView.superview.subviews indexOfObject:adViewThumbView];
        [adViewThumbView.superview insertSubview:self.adView atIndex:adIndex];
        [adViewThumbView removeFromSuperview];
        adViewThumbView = nil;
    }
}


  • Интеграционные


Это проблемы, которые происходят на стыке iFunny, Mopub и провайдера рекламы.
Как правило, они возникают после обновления библиотеки провайдеров и из-за новых способов взаимодействия с ними.

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

Обращение к нему дважды, как происходило в реализации, периодически вызывало фриз главного потока, поэтому пришлось обернуть инициализацию в dispatch_once.

QA-отдел iFunny умеет хорошо тестировать рекламные библиотеки, поэтому эта проблема была найдена в ходе тестирования обновления.

  • Неожиданные


Этот тип крашей вообще не поддаётся контролю, так как происходит без каких-либо изменений в клиенте.

Связаны они с обновлением бэкенда у партнёров и отсутствием обратной совместимости. Такие краши часто происходят у крупных провайдеров рекламы, но быстро исправляются, так как действуют на большое количество приложений одновременно.

Были случаи, когда crash free iFunny за сутки опускалось со стандартных 99,8% до 80%, а количество гневных комментариев в сторе исчислялось десятками.

Производительность


Баннерная реклама, как правило, использует WebView-компоненты для отображения рекламы, поэтому каждый показанный баннер — это инициализация нового WebView со всеми его зависимостями.

Кроме того, часть партнёров использует WebView и для общения с собственными бэкендом, так как баннерная реклама на мобильных устройствах — это потомок рекламы в вебе.

Бывает, что после обновления находятся утечки памяти внутри новой библиотеки. После появления в Xcode инструмента Memory Graph находить утечки в сторонних библиотеках стало гораздо легче, поэтому сейчас удаётся оперативно сообщать о них партнёрам.

Ниже — гифка работы iFunny в простое, когда реклама для пользователя отсутствует:

oqegulmbyh7j3zejhcmf4g_oage.gif

Решения


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

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

Система логирования


Сейчас система логирования исключений в iFunny распространилась на всё приложение: для этого используется собственный бэкенд с базой на ClickHouse и отображением в Grafana.

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

Для определения факта переадресации в iFunny есть несколько связанных компонент. Расскажу подробнее о каждой из них.

IFAdView


Это наследник от класса MPAdView (он отвечает за показ рекламы в MoPub).

В этом классе переопределён метод hitTest: withEvent:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *hitView = [super hitTest:point withEvent:event];
    if (hitView) {
        [[IFAdsExceptionManager instance] triggerTouchView];
    }
    return hitView;
}


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

IFURLProtocol


Наследуемся от NSURLProtocol и описываем метод:

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    __weak NSString *wRequestURL = request.URL.absoluteString;
    dispatch_async(dispatch_get_main_queue(), ^{
        if (wRequestURL == nil)
            return;
        if ([wRequestURL hasPrefix:@"itms-appss://itunes.apple.com"] ||
            [wRequestURL hasPrefix:@"itms-apps://itunes.apple.com"] ||
            [wRequestURL hasPrefix:@"itmss://itunes.apple.com"] ||
            [wRequestURL hasPrefix:@"http://itunes.apple.com"] ||
            [wRequestURL hasPrefix:@"https://itunes.apple.com"]) {
            [[IFAdsExceptionManager instance] adsTriggerItunesURL:wRequestURL];
        }
    });
 
    return NO;
}


Это триггер на открытие AppStore из приложения, мы перечисляем все доступные URL для этого.

IFAdsExceptionManager


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

Чтобы было понятно, какие есть триггеры, опишу каждый метод интерфейса этого класса.

- (void)triggerTouchView;
Метод для записи взаимодействия с рекламным баннером.
- (void)triggerItunesURL:(NSString *)itunesURL;


Триггер, который определяет, что происходит редирект в iTunes.

- (void)triggerResignActive;


Триггер для определения потери активности приложением. В нём происходит сравнение двух предыдущих триггеров.

- (void)resetTriggers;


Сброс триггеров. Вызываем при уходе в фон или когда открываем AppStore сами, например, когда отправляем пользователя поставить оценку в старых версиях iOS.

@property (nonatomic, strong) FNAdConfigurationInfo *lastRequestedConfiguration;
@property (nonatomic, strong) FNAdConfigurationInfo *lastLoadedConfiguration;
@property (nonatomic, strong) FNAdConfigurationInfo *lastFailedConfiguration;


Свойства для записи последней успешно или неуспешно запрошенной и загруженной рекламы. Нужны для формирования сообщения в лог.

Видно, что алгоритм получился достаточно простым, но эффективным. Он позволяет отслеживать нам не только автооткрытия из MoPub, но и из других сетей.

В последнее время реклама с автооткрытием часто открывает SKStoreProductViewController, поэтому сейчас мы работаем над определением автооткрытия этого контроллера. Алгоритм определения этого исключения будет несколько сложнее, но здесь нам поможет Objective-C Runtime.

Локальный стенд


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

Стенд состоит из:

  • билд-агента
  • устройства
  • набора тестов для каждого провайдера


Одно из интересных решений, которое используется на стенде, — IDFA из жалоб пользователей для получения реальной рекламы.

Примерно с 2016 года мы перестали получать реальную рекламу, таргетированную на США, используя только VPN, поэтому приходится подменять IDFA устройства на IDFA реальных пользователей.

Делается это достаточно легко с использованием Objective-C Runtime и свизлинга.
Нужно подменить метод advertisingIdentifier у класса ASIdentifierManager.

Здесь мы делаем это через категорию:

@interface ASIdentifierManager (IDFARewrite)
@end
 
@implementation ASIdentifierManager (IDFARewrite)
 
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        if (AdsMonitorTests.customIDFA != nil) {
            [self swizzleIDFA];
        }
 
    });
}
 
+ (void)swizzleIDFA {
    Class class = [self class];
 
    SEL originalSelector = @selector(advertisingIdentifier);
    SEL swizzledSelector = @selector(swizzled_advertisingIdentifier);
 
    Method originalMethod = class_getInstanceMethod(class, originalSelector);
    Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
 
    BOOL didAddMethod =
        class_addMethod(class,
                        originalSelector,
                        method_getImplementation(swizzledMethod),
                        method_getTypeEncoding(swizzledMethod));
 
    if (didAddMethod) {
        class_replaceMethod(class,
                            swizzledSelector,
                            method_getImplementation(originalMethod),
                            method_getTypeEncoding(originalMethod));
    }
    else {
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }
}
#pragma mark - Method Swizzling
 
- (NSUUID *)swizzled_advertisingIdentifier {
    NSUUID *result = AdsMonitorTests.customIDFA;
    return result;
}
 
@end


Для передачи с билд-агента пользовательского IDFA в билд используется метод, описанный в статье.

В заключении хочется сказать, что баннерная реклама отлично работает в США, и за семь лет её активного использования как основного способа монетизации в iFunny научились с ней хорошо работать.

Но несмотря на то, что баннеры приносят 75% доходов компании, постоянно ведётся работа над альтернативными способами монетизации и уже накоплен некоторый опыт в нативной рекламе и использовании рекламных аукционов на рынке США.

В общем, рассказать есть о чём.

© Habrahabr.ru