Главная » Хабрахабр » Баннерная реклама в iOS-приложении

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

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

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

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

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

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

MoPub и компания

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

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

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

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

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

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

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

Сила в правде

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

Контент

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

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

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

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

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

Автоклики

Как показывает мой опыт, это крайне негативный для пользователей кейс. Используя возможности 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 на определённое приложение и даже не учитывать показ баннера.

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

Так как молодая аудитория iFunny интересна рекламодателям, то партнёры охотно идут навстречу и убирают из ротации подобную рекламу. В случае с MoPub получилось найти и повторить проблему достаточно легко, но другие сети, с которыми работает 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
} - (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 в простое, когда реклама для пользователя отсутствует:

Решения

Но несмотря на все проблемы, описанные выше, 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, но и из других сетей. Видно, что алгоритм получился достаточно простым, но эффективным.

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

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

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

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


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

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Дайджест свежих материалов из мира фронтенда за последнюю неделю №338 (5 — 11 ноября 2018)

Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.     Медиа    |    Веб-разработка    |    CSS    |    Javascript    |    Браузеры    |    Занимательное Медиа • Подкаст «Веб-стандарты», Выпуск №146: Веб-приложения на десктопе, безопасность и фронтопсы, Test262, Babel и Webpack, вопросы к HolyJS.• Подкаст «Frontend Weekend» #78 – ...

Расходы на Tesla

В закрытой группе Tesla Model 3 на Facebook (37,457 участников) один из её членов поднял интересную тему: Сколько на данный момент составили Ваши расходы с момента покупки автомобиля?» «Вопрос для всех владельцев Tesla. Всего написали уже более 100 комментариев примерно, ...