
Aujourd'hui, nous ouvrons une série d'articles sur ce dont on ne parle généralement pas lors des conférences et réunions techniques. Cet article et les suivants vous expliqueront comment fonctionne le mécanisme de monétisation dans l'application de divertissement iFunny iOS populaire aux États-Unis, que nous développons.
La publicité est l'un des principaux moyens de monétiser les applications gratuites. Mais maintenant, quelles options étaient en 2011 lorsque iFunny est apparu? Le service a été initialement conçu comme une entreprise solide et durable, donc dès le premier jour, la société a décidé de ne pas flirter avec les utilisateurs et de ne pas s'engager dans des jeux à capitalisation conditionnelle.
À cette époque, la principale option de monétisation était de créer une version gratuite du service, puis d'essayer de vendre la fonctionnalité principale. Le consommateur était jeune, inexpérimenté et n'était pas prêt à se séparer de montants supérieurs à un dollar.
Des mathématiques simples ont montré qu'avec une conversion de 10%, obtenir un ARPU de plus de 10 cents est une tâche presque impossible.
Ensuite, j'ai dû réfléchir à la façon dont vous pouvez monétiser le produit. Le modèle publicitaire a déjà très bien fonctionné sur le Web, et on peut supposer qu'il fleurira bientôt également sur les téléphones.
En général, le début du modèle de monétisation de la publicité mobile peut être considéré comme l'apparition d'AdWhirl - un service qui vous a permis d'intégrer le SDK des réseaux publicitaires et de les faire pivoter. Son apparence a permis d'élever FillRate à une moyenne de 50% sur le marché et de rendre les revenus du modèle publicitaire au moins comparables à des ventes d'un dollar. Le principe même de la mise en œuvre de toutes les sources possibles de demande et de l'organisation de la concurrence entre elles est devenu le principal moteur de croissance de l'industrie publicitaire et continue d'être exploité à ce jour.
Mais plus le système est complexe, moins il devient stable, ce qui est absolument inacceptable pour les grands services du niveau iFunny. Commençant à s'orienter dans cette direction en 2011, la société a créé l'un des mécanismes les plus efficaces pour travailler avec la bannière mobile et la publicité native et a augmenté ses revenus par utilisateur de 40 fois, ce qui a permis de développer non seulement des projets internes, mais aussi d'investir dans d'autres entreprises.
MoPub et entreprise
Depuis 2012, nous sommes passés d'AdWhirl à MoPub.
MoPub est une plateforme de publicité mobile avec la possibilité d'ajouter ses propres modules, qui comprend plusieurs excellents outils:
- Marché MoPub - propre bourse de publicité;
- médiateur de réseaux publicitaires pour travailler avec des réseaux externes;
- un mécanisme de commande qui vous permet de placer indépendamment des bannières dans votre propre application et de personnaliser leurs affichages.
Les principaux avantages de MoPub:
- capable de travailler avec la plupart des réseaux publicitaires;
- mécanisme clair de connexion de nouveaux réseaux tiers;
- open source
- un grand nombre de paramètres de base et de ciblage;
- une grande communauté autour du réseau, il y a même une conférence qui lui est propre.
MoPub présente également des inconvénients:
- les demandes de pool sur GitHub ne sont pas acceptées et il n'y a aucune réaction à celles-ci;
- le panneau de contrôle est très complexe, et pour le développeur, lors du débogage, il faut un certain temps pour se plonger dans sa structure.
Le pouvoir de la vérité
Comme l'a dit le héros d'un film russe: "La force est en vérité". Dans cette partie, je parlerai des difficultés auxquelles nous, en tant que développeurs d'applications, avons dû faire face après le premier million de téléchargements d'iFunny, la croissance de l'audience et du trafic publicitaire de plus de 100 partenaires.
Le contenu
Le marché publicitaire est une «caste» très fermée d'entreprises technologiques, mais en même temps, les agrégateurs disposent d'un large réseau de partenaires: des grandes entreprises qui travaillent avec des millions de budgets aux petites entreprises adaptées à des publics cibles spécifiques.
Cette proximité et cette fragmentation des partenaires, malgré la pré-modération de la bannière et des règles plutôt strictes sur le contenu publicitaire, ne permettent pas aux vendeurs de publicité les plus honnêtes de publier des créations interdites ou de gâcher l'expérience utilisateur dans l'application.
Il existe plusieurs catégories principales de contenu «obscène» dans les bannières publicitaires:
- contenu porno. Récemment, il apparaît de moins en moins, mais néanmoins il a lieu d'être. Nous ne pouvons pas publier ce contenu dans l'article, donc l'image ne sera pas ici
- alertes système dans des bannières, un exemple peut être consulté sur l'un des utilisateurs twitter.com/IfunnyStates/status/1029393804749668352
- contenu avec son. Les sons ne sont pas interdits par les réseaux publicitaires, ainsi que les animations, mais si le son joue sans interagir avec l'interface, cela est perçu par les utilisateurs comme un bug d'application et affecte négativement l'expérience utilisateur
- attirer l'attention. Une bonne bannière devrait attirer l'attention de l'utilisateur, mais cela ne se produit pas toujours de manière honnête: des vidéos vacillantes tombent parfois dans les bannières. Une autre façon malhonnête d'amener l'utilisateur à appuyer sur la bannière consiste à simuler l'interface de l'application, par exemple comme ceci:

Soit dit en passant, en Russie, une simple pression sur cette bannière peut émettre un abonnement payant pour certains opérateurs mobiles, et vous ne le saurez même pas avant d'avoir vu les détails. C'est aussi une façon malhonnête de travailler avec la publicité, mais les opérateurs aux États-Unis n'ont pas une telle opportunité.
Clics automatiques
Comme mon expérience le montre, il s'agit d'un cas extrêmement négatif pour les utilisateurs. En utilisant les capacités de JavaScript, WKWebView ou UIWebView, ainsi que des trous dans la mise en œuvre des bibliothèques de publicité, vous pouvez créer des annonces qui ouvriront le contenu de la bannière lui-même et conduiront l'utilisateur hors de l'application.
Afin de répéter ce problème en utilisant l'exemple de MoPub, ajoutez simplement le code javascript du contenu suivant à la bannière:
<a href="https://ifunny.co" id="testbutton">test</a> <script>document.getElementById('testbutton').click(); </script>
Cela a fonctionné longtemps dans de nombreuses versions de MoPub, jusqu'à la version 4.13.
En explorant la mise en œuvre de MoPub, il a été possible de générer des liens plus complexes qui permettraient non seulement d'ouvrir des publicités en plein écran, mais aussi d'envoyer l'utilisateur vers l'AppStore vers une application spécifique et même de ne pas prendre en compte l'affichage de la bannière.
Soit dit en passant, dans les notes de publication de la version 4.13.0 du SDK MoPub pour iOS, il n'y a aucune information sur ce correctif, car il s'agissait d'un trou assez grave dans le SDK, et les partenaires malhonnêtes de MoPub l'ont exploité assez activement. Comme le montrent les journaux, dont je parlerai plus tard, chaque jour, je devais bloquer jusqu'à 2 millions de tentatives d'ouverture de la bannière sans interaction de l'utilisateur avec elle.
Dans le cas de MoPub, il s'est avéré facile de trouver et de répéter le problème, mais d'autres réseaux avec lesquels iFunny travaille ont un code fermé, et vous devez faire face aux nouveaux clics automatiques en bloquant les bannières ou même en déconnectant les réseaux pendant un certain temps.
iFunny travaille en étroite collaboration avec tous les partenaires publicitaires et les informe de ces bannières. Étant donné que le jeune public d'iFunny est intéressant pour les annonceurs, les partenaires sont prêts à les rencontrer et à retirer cette publicité de la rotation.
Crash
Le crash est toujours mauvais. Pire encore, lorsqu'ils surviennent en raison d'une dépendance avec une source fermée, et que vous ne pouvez les influencer qu'indirectement. Au fil des années de travail avec la publicité chez iFunnu, plusieurs types d'accidents ont été identifiés pour eux-mêmes, qui peuvent être divisés en plusieurs groupes.
Il s'agit notamment des exceptions dans la bibliothèque réseau, WKWebView (UIWebView), OpenGL.
Il est très difficile d'affecter directement ce type de plantages, mais il était encore possible d'affecter certains d'entre eux, après avoir étudié le fonctionnement du composant WebView avec WebGL.
Voici à quoi ressemble la stackrace de ces plantages:
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
De plus, ils se produisent exclusivement en laissant en arrière-plan. Cela est dû au fait que le moteur OpenGL ne devrait pas fonctionner lorsque l'application est en arrière-plan.
Le correctif s'est avéré assez simple:
Lorsque vous quittez en arrière-plan, vous devez prendre une capture d'écran de la bannière.
Supprimez la vue publicitaire de l'écran afin que le composant WebView cesse d'utiliser OpenGL.
Lorsque vous quittez l'arrière-plan, retournez tout comme il était.
Dans le code Objective-C, cela ressemble à ceci:
- (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; } }
Ce sont des problèmes qui se produisent à la jonction d'iFunny, de Mopub et du fournisseur de publicité.
En règle générale, ils surviennent après la mise à jour de la bibliothèque du fournisseur et en raison de nouvelles façons d'interagir avec eux.
Le dernier cas de ce type remonte à juin de cette année, après la prochaine mise à jour de l'une des bibliothèques utilisées. Une nouvelle façon d'initialiser la bibliothèque a suggéré d'utiliser singleton pour configurer les paramètres réseau.
Y revenir deux fois, comme cela s'est produit dans la mise en œuvre, a périodiquement provoqué une frise du thread principal, j'ai donc dû encapsuler l'initialisation dans dispatch_once.
Le département QA d'iFunny est capable de bien tester les bibliothèques de publicité, donc ce problème a été trouvé lors des tests de la mise à jour.
Ce type de plantages ne peut pas être contrôlé du tout, car il se produit sans aucune modification dans le client.
Ils sont associés à la mise à jour du backend des partenaires et au manque de compatibilité ascendante. De tels plantages se produisent souvent chez les grands fournisseurs de publicité, mais sont rapidement résolus, car ils affectent un grand nombre d'applications en même temps.
Il y a eu des cas où l'iFunny sans crash par jour est passé de 99,8% à 80%, et le nombre de commentaires en colère dans l'histoire était de plusieurs dizaines.
Performances
La bannière publicitaire, en règle générale, utilise des composants WebView pour afficher la publicité, donc chaque bannière affichée est une initialisation d'une nouvelle WebView avec toutes ses dépendances.
En outre, certains partenaires utilisent également WebView pour communiquer avec leur propre backend, car la bannière publicitaire sur les appareils mobiles est une descendante de la publicité sur le Web.
Il arrive qu'après la mise à niveau, il y ait des fuites de mémoire à l'intérieur de la nouvelle bibliothèque. Après l'apparition de l'outil Memory Graph dans Xcode, il est devenu beaucoup plus facile de trouver des fuites dans des bibliothèques tierces, de sorte que les partenaires peuvent désormais être rapidement informés à leur sujet.
Voici le GIF de iFunny inactif lorsqu'il n'y a pas de publicité pour l'utilisateur:

Des solutions
Mais malgré tous les problèmes décrits ci-dessus, iFunny est stable et fait chaque jour sourire des millions de ses utilisateurs.
Au fil des années de travail actif dans le domaine de la publicité, l'équipe de développement dispose de plusieurs outils qui permettent de surveiller avec succès les problèmes publicitaires et d'y répondre à temps.
Système d'enregistrement
Maintenant, le système de journalisation des exceptions dans iFunny s'est étendu à l'ensemble de l'application: pour cela, nous utilisons notre propre backend avec une base sur ClickHouse et nous affichons dans Grafana.
Mais la première tâche pour travailler avec des journaux dans l'application était précisément l'enregistrement de situations exceptionnelles dans la publicité.
Il existe plusieurs composants associés pour déterminer si un appel est transféré vers iFunny. Je vais vous en dire plus sur chacun d'eux.
IFAdView
Il s'agit du descendant de la classe MPAdView (il est responsable de l'affichage des publicités sur MoPub).
La méthode hitTest: withEvent est remplacée dans cette classe:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { UIView *hitView = [super hitTest:point withEvent:event]; if (hitView) { [[IFAdsExceptionManager instance] triggerTouchView]; } return hitView; }
Ainsi, nous avons mis le déclencheur sur le fait que l'utilisateur a interagi avec la publicité.
IFURLProtocol
Nous héritons de NSURLProtocol et décrivons la méthode:
+ (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; }
Il s'agit d'un déclencheur pour ouvrir l'AppStore à partir de l'application, nous listons toutes les URL disponibles pour cela.
IFAdsExceptionManager
Une classe qui collecte des déclencheurs et génère un enregistrement d'exception dans le journal.
Pour clarifier ce que sont les déclencheurs, je décrirai chaque méthode de l'interface de cette classe.
- (void)triggerTouchView; . <source lang="objectivec">- (void)triggerItunesURL:(NSString *)itunesURL;
Un déclencheur qui détermine si une redirection se produit dans iTunes.
- (void)triggerResignActive;
Un déclencheur pour déterminer la perte d'activité d'une application. Il compare les deux déclencheurs précédents.
- (void)resetTriggers;
Réinitialiser les déclencheurs. Nous l'appelons lorsque nous quittons l'arrière-plan ou lorsque nous ouvrons l'AppStore nous-mêmes, par exemple, lorsque nous envoyons l'utilisateur pour évaluer dans les anciennes versions d'iOS.
@property (nonatomic, strong) FNAdConfigurationInfo *lastRequestedConfiguration; @property (nonatomic, strong) FNAdConfigurationInfo *lastLoadedConfiguration; @property (nonatomic, strong) FNAdConfigurationInfo *lastFailedConfiguration;
Propriétés pour l'enregistrement des dernières annonces demandées et téléchargées avec succès ou sans succès. Nécessaire pour former un message dans le journal.
On peut voir que l'algorithme s'est avéré assez simple, mais efficace. Il nous permet de suivre non seulement les découvertes automatiques de MoPub, mais aussi d'autres réseaux.
Récemment, les annonces avec ouverture automatique ouvrent souvent SKStoreProductViewController, alors maintenant nous travaillons sur la définition de l'ouverture automatique de ce contrôleur. L'algorithme pour définir cette exception sera un peu plus compliqué, mais Objective-C Runtime nous aidera ici.
Stand local
Basé sur le système de journalisation, iFunny a également commencé à développer un stand local afin de recevoir et déboguer les publicités que les utilisateurs voient en temps réel.
Le stand se compose de:
- agent de construction
- appareils
- suite de tests pour chaque fournisseur
L'une des solutions intéressantes utilisées sur le stand est l'IDFA à partir des plaintes des utilisateurs pour de la vraie publicité.
Depuis environ 2016, nous avons cessé de recevoir de vraies annonces ciblant les États-Unis en utilisant uniquement des VPN, nous devons donc remplacer les appareils IDFA par des IDFA pour les vrais utilisateurs.
Cela se fait assez facilement en utilisant le runtime Objective-C et le swizzling.
Vous devez remplacer la méthode advertisingIdentifier de la classe ASIdentifierManager.
Ici, nous le faisons à travers la catégorie:
@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
La méthode décrite dans l'
article est utilisée pour transférer l'IDFA utilisateur vers la génération à partir de l'agent de génération.
En conclusion, je tiens à dire que la bannière publicitaire fonctionne très bien aux États-Unis, et pendant sept ans de son utilisation active comme principale méthode de monétisation, iFunny a appris à bien travailler avec elle.
Mais malgré le fait que les bannières génèrent 75% des revenus de l'entreprise, des travaux sont en cours sur les méthodes alternatives de monétisation et une certaine expérience a déjà été acquise dans la publicité native et l'utilisation d'enchères publicitaires sur le marché américain.
En général, il y a quelque chose à dire.