company_banner

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



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

    В общем, рассказать есть о чём.
    FunCorp
    248,00
    Разработка развлекательных сервисов
    Поделиться публикацией

    Комментарии 0

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

    Самое читаемое