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