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

в 8:19, , рубрики: iOS, ios development, iOS разработка, баннерная реклама, Блог компании FunCorp, мобильные приложения, монетезация, Монетизация мобильных приложений, разработка мобильных приложений, разработка под iOS

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

Сегодня мы открываем цикл статей о том, о чём обычно не говорят на технических конференциях и митапах. Этот и последующие посты расскажут, как устроен механизм монетизации в популярном в США развлекательном 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
  • контент со звуком. Звуки не запрещены рекламными сетями, как и анимации, но если звук играет без взаимодействия с интерфейсом — это воспринимается пользователями как баг приложения и негативно влияет на пользовательский опыт
  • привлечение внимания. Хороший баннер должен привлекать внимание пользователя, но не всегда это происходит честным образом: иногда в баннеры попадают мерцающие видео. Ещё один нечестный способ заставить пользователя тапнуть на баннер — имитировать интерфейс приложения, например так:

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

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

Автоклики

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

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

<a href="https://ifunny.co" id="testbutton">test</a>
<script>document.getElementById('testbutton').click();
</script>

Это работало долго во многих версиях 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>&&, WebCore::GraphicsContext3D::Attributes) + 512
8 WebCore WebCore::WebGLRenderingContext::WebGLRenderingContext(WebCore::HTMLCanvasElement*, WTF::PassRefPtr<WebCore::GraphicsContext3D>, 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<JSC::Exception>&) + 160
23 WebCore WebCore::JSEventListener::handleEvent(WebCore::ScriptExecutionContext*, WebCore::Event*) + 980
24 WebCore WebCore::EventTarget::fireEventListeners(WebCore::Event&, WebCore::EventTargetData*, WTF::Vector<WebCore::RegisteredEventListener, 1ul, WTF::CrashOnOverflow, 16ul>&) + 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 в простое, когда реклама для пользователя отсутствует:

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

Решения

Но несмотря на все проблемы, описанные выше, 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;
Метод для записи взаимодействия с рекламным баннером.
<source lang="objectivec">- (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% доходов компании, постоянно ведётся работа над альтернативными способами монетизации и уже накоплен некоторый опыт в нативной рекламе и использовании рекламных аукционов на рынке США.

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

Автор: Андрей Мухаметов

Источник


* - обязательные к заполнению поля