⚠️ Chapitre hors scope du module, proposé en bonus pour les plus curieux. Il ne fait pas partie des objectifs évalués, mais reflète la réalité du marché et vous permettra de mettre en place une monétisation dans vos projets personnels.
Dans ce chapitre bonus, vous allez découvrir comment gagner de l'argent avec une application mobile, de la théorie jusqu'à l'implémentation concrète dans un projet Ionic-Vue.
La monétisation est une réalité du marché : même les applications gratuites doivent générer des revenus pour être viables. Comprendre comment ça fonctionne — et comment l'intégrer proprement dans un projet — est une compétence précieuse, que vous soyez futur développeur freelance, employé, ou entrepreneur.
À la fin de ce chapitre, vous serez capables de :
@capacitor-community/admob dans un projet Ionic-Vue ;Il existe plusieurs façons de gagner de l'argent avec une application mobile. Elles ne s'excluent pas et peuvent souvent être combinées.
| Modèle | Description | Exemples |
|---|---|---|
| Freemium | Gratuit avec fonctionnalités premium payantes | Spotify, Duolingo |
| Publicité (ads) | Affichage de pubs, revenus via impressions/clics | Jeux mobiles gratuits |
| Abonnement | Paiement récurrent mensuel ou annuel | Netflix, Strava |
| Achat unique (paid app) | L'utilisateur achète l'app une seule fois | Certains jeux/outils |
| Achats intégrés (IAP) | Contenu ou fonctionnalités achetables dans l'app | V-Bucks Fortnite, skins |
| Affiliate / partenariats | Liens sponsorisés, recommandations rémunérées | Apps de voyage, finance |
💬 Exemple : Clash Royale est gratuit, mais génère des centaines de millions via les achats intégrés (gemmes, coffres). Candy Crush, lui, se finance surtout via les pubs et quelques IAP.
Le choix du modèle dépend du type d'app, de l'audience cible et de la valeur perçue.
Règle générale :
⚠️ Attention à l'UX :
Un mauvais modèle de monétisation peut détruire une bonne application.
… → avis négatifs et désinstallations massives.
Dans ce chapitre, nous nous concentrons sur deux approches pratiques :
Google AdMob est la régie publicitaire mobile de Google, et la plus utilisée dans le monde.
Elle sert d'intermédiaire entre :
En tant que développeur, vous :
💬 Exemple : pour 1 000 impressions d'une bannière, vous touchez en moyenne entre 1€ et 3€ selon la région et la thématique de l'app. Les pubs vidéo rewarded rapportent généralement beaucoup plus.
Il existe plusieurs formats, chacun adapté à un contexte précis.
Banner)💬 Exemple : une app de calculatrice affiche une bannière en bas en permanence.
Interstitial)💬 Exemple : dans un jeu mobile, une interstitielle s'affiche entre deux niveaux ou après un "Game Over".
RewardedAd)💬 Exemple : "Regardez une vidéo pour obtenir 50 pièces d'or !" dans un jeu mobile.
| Format | Intrusivité | Revenus | Déclenchement recommandé |
|---|---|---|---|
| Bannière | Faible | Faibles | Permanent, contenu statique |
| Interstitiel | Élevée | Moyens | Transitions naturelles (fin de niveau, changement de page) |
| Rewarded | Nulle (volontaire) | Élevés | Sur action explicite de l'utilisateur |
Google publie des guidelines officielles pour chaque format. Ne pas les respecter peut entraîner la suspension de votre compte AdMob ou le refus de votre app sur le store.
Nous allons voir les règles essentielles, format par format.
ADAPTIVE_BANNER : il s'adapte à la largeur de l'écran et est le format recommandé par Google depuis 2023.Ces règles sont issues de la documentation officielle AdMob.
À propos du format
Certaines interstitielles peuvent avoir un délai jusqu'à 5 secondes avant d'afficher "Fermer". Avec les high-engagement ads, ce délai peut monter à 12 secondes (voire ~30 secondes via certains réseaux).
✅ Implémentations recommandées :
Afficher après une transition naturelle (fin de niveau, changement de section).
Laisser l'utilisateur terminer son action avant la pub.
Pré-charger la pub en arrière-plan avant d'en avoir besoin (prepareInterstitial()).
Se poser ces questions avant chaque affichage :
❌ Implémentations interdites :
💬 Règle des intervalles : implémentez toujours un cooldown (par ex. 3 à 5 minutes) entre deux interstitielles.
Rewarded).💬 Les pubs rewarded ont les meilleurs eCPM et un engagement maximal.
| Format | eCPM moyen (UE/US) | Engagement | Risque UX |
|---|---|---|---|
| Bannière | 1€ – 3€ / 1 000 impressions | Faible | Très faible |
| Interstitiel | 5€ – 15€ / 1 000 impressions | Moyen | Élevé si mal placé |
| Rewarded | 10€ – 30€ / 1 000 impressions | Très élevé | Nul (volontaire) |
💬 Ces valeurs sont indicatives et varient selon la région, la thématique et la qualité de l’audience.
Les achats intégrés (IAP) permettent à l'utilisateur d'acheter du contenu ou des fonctionnalités directement dans l'app.
Types principaux :
| Type | Description | Exemple |
|---|---|---|
| Consommable | Acheté et "consommé" (disparaît après usage) | 100 pièces d'or, 5 vies |
| Non-consommable | Acheté une fois, disponible à vie | Supprimer les pubs, déverrouiller un niveau |
| Abonnement | Accès récurrent, renouvelable automatiquement | Accès premium mensuel |
Les stores (Google Play, App Store) sont obligatoirement impliqués :
💬 Sur un achat à 1.00 CHF, vous recevez 0.70 à 0.85 CHF selon les politiques en vigueur.
Quand l'utilisateur achète :
⚠️ Validation côté serveur : recommandée pour les achats sensibles (abonnements, monnaie premium). RevenueCat gère cela automatiquement.
Pour Ionic + Capacitor, la solution la plus fiable en production est RevenueCat.
| Sans RevenueCat | Avec RevenueCat |
|---|---|
| Deux APIs différentes (StoreKit / Billing) | Une seule API unifiée |
| Receipts à valider soi-même | Validation côté serveur automatique |
| Pas de dashboard | Dashboard revenus / rétention / MRR / LTV |
| Gestion complexe des abonnements | Gestion automatique |
| Gratuit | Gratuit jusqu'à 2 500$/mois gérés |
Concepts :
premium_monthly)premium)💬 RevenueCat écoute les stores, valide les reçus, expose une API unifiée.
Contrairement à AdMob, les IAP :
Prérequis :
👉 Dans cet exercice, concentrez-vous sur AdMob. Les IAP sont là pour vos projets personnels quand vous aurez les comptes et stores configurés.
| Élément | Obligatoire ? | Description |
|---|---|---|
| Politique de confidentialité | ✅ Oui | Obligatoire dès que vous collectez des données (AdMob le fait) |
| Consentement RGPD (UMP) | ✅ Oui (UE) | Formulaire de consentement pour les pubs personnalisées |
| Déclaration des pubs dans le store | ✅ Oui | Google / Apple demandent de déclarer l'usage d'AdMob |
| Compte AdMob créé et vérifié | ✅ Oui | Doit être lié au projet avant publication |
| Compte développeur Google Play | ✅ Oui (Android) | 25$ unique |
| Compte développeur Apple | ✅ Oui (iOS) | 99$/an |
| Informations fiscales | ✅ Oui | Obligatoires pour recevoir les revenus |
| Déclaration COPPA (enfants) | ✅ Si <13 ans | Pubs non-personnalisées obligatoires |
| Bouton "Restaurer les achats" | ✅ Oui (iOS + IAP) | Exigé par Apple |
Depuis 2024, Google exige pour les pubs en UE :
Si l’utilisateur refuse :
npa: true) ;L’implémentation du formulaire est intégrée dans useAdMob.ts via requestConsentInfo() et showConsentForm().
AdMob fournit des IDs de test officiels :
| Plateforme | ID de test Banner | ID de test Interstitiel | ID de test Rewarded |
|---|---|---|---|
| Android | ca-app-pub-3940256099942544/6300978111 |
ca-app-pub-3940256099942544/1033173712 |
ca-app-pub-3940256099942544/5224354917 |
| iOS | ca-app-pub-3940256099942544/2934735716 |
ca-app-pub-3940256099942544/4411468910 |
ca-app-pub-3940256099942544/1712485313 |
⛔ Ne jamais utiliser vos IDs de prod pour tester :
Toujours utiliser les IDs de test en développement.
ionic start quizflash-admob tabs --type=vue --capacitor
cd quizflash-admob
Vérifiez votre version de Capacitor :
npx cap --version
Installez les plugins :
npm install @capacitor-community/admob@6
npm install @revenuecat/purchases-capacitor
Ajoutez Android et synchronisez :
npx cap add android
ionic build
npx cap sync
⚠️
npx cap add androidune seule fois ; ensuiteionic build+npx cap sync.
Dans android/app/src/main/AndroidManifest.xml, à l'intérieur de <application> :
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="@string/admob_app_id"/>
Assurez-vous que launchMode est compatible avec les IAP :
<activity
android:name="com.yourapp.MainActivity"
android:launchMode="singleTop" />
Dans android/app/src/main/res/values/strings.xml :
<string name="admob_app_id">ca-app-pub-3940256099942544~3347511713</string>
💬 App ID de test à remplacer par le vôtre en prod.
useAdMob.ts (1/3)// src/composables/useAdMob.ts
import {
AdMob,
BannerAdOptions, BannerAdSize, BannerAdPosition,
AdmobConsentStatus,
} from '@capacitor-community/admob'
// ─── IDs publicitaires ──────────────────────────────────────────
const IS_TESTING = true
const AD_IDS = {
banner: IS_TESTING ? 'ca-app-pub-3940256099942544/6300978111' : 'VOTRE_ID_BANNER',
interstitial: IS_TESTING ? 'ca-app-pub-3940256099942544/1033173712' : 'VOTRE_ID_INTERSTITIAL',
rewarded: IS_TESTING ? 'ca-app-pub-3940256099942544/5224354917' : 'VOTRE_ID_REWARDED',
}
// ─── Cooldown interstitiel ──────────────────────────────────────
let lastInterstitialTime = 0
const INTERSTITIAL_COOLDOWN_MS = 3 * 60 * 1000 // 3 minutes
export function useAdMob() {
useAdMob.ts (2/3) async function initialize(): Promise<void> {
await AdMob.initialize()
const consentInfo = await AdMob.requestConsentInfo()
if (consentInfo.isConsentFormAvailable && consentInfo.status === AdmobConsentStatus.REQUIRED) {
await AdMob.showConsentForm()
}
const trackingInfo = await AdMob.trackingAuthorizationStatus()
if (trackingInfo.status === 'notDetermined') {
await AdMob.requestTrackingAuthorization()
}
}
async function showBanner(): Promise<void> {
const options: BannerAdOptions = {
adId: AD_IDS.banner,
adSize: BannerAdSize.ADAPTIVE_BANNER,
position: BannerAdPosition.BOTTOM_CENTER,
}
await AdMob.showBanner(options)
}
async function hideBanner(): Promise<void> { await AdMob.hideBanner() }
async function removeBanner(): Promise<void> { await AdMob.removeBanner() }
useAdMob.ts (3/3) async function prepareInterstitial(): Promise<void> {
await AdMob.prepareInterstitial({ adId: AD_IDS.interstitial })
}
async function showInterstitial(): Promise<boolean> {
const now = Date.now()
if (now - lastInterstitialTime < INTERSTITIAL_COOLDOWN_MS) {
console.log('[AdMob] Cooldown actif — interstitielle ignorée.')
return false
}
try {
await AdMob.showInterstitial()
lastInterstitialTime = now
return true
} catch (e) {
console.warn('[AdMob] Interstitiale non disponible :', e)
return false
}
}
async function showRewardedAd(): Promise<{ type: string; amount: number } | null> {
try {
await AdMob.prepareRewardVideoAd({ adId: AD_IDS.rewarded })
const reward = await AdMob.showRewardVideoAd()
return { type: reward.type, amount: reward.amount }
} catch (e) {
console.warn('[AdMob] Rewarded non disponible :', e)
return null
}
}
return {
initialize,
showBanner, hideBanner, removeBanner,
prepareInterstitial, showInterstitial,
showRewardedAd,
}
}
main.ts
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { IonicVue } from '@ionic/vue'
import { useAdMob } from '@/composables/useAdMob'
async function bootstrap() {
const app = createApp(App).use(IonicVue).use(router)
const { initialize } = useAdMob()
await initialize()
router.isReady().then(() => {
app.mount('#app')
})
}
bootstrap()
<!-- src/views/Tab1Page.vue -->
<template>
<ion-page>
<ion-header>
<ion-toolbar>
<ion-title>QuizFlash 🧠</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-card>
<ion-card-header>
<ion-card-title>
Question {{ currentQuestion + 1 }} / {{ questions.length }}
</ion-card-title>
</ion-card-header>
<ion-card-content>
<p>{{ questions[currentQuestion].text }}</p>
<ion-button expand="block" @click="nextQuestion">
Question suivante →
</ion-button>
<ion-button
expand="block"
fill="outline"
color="warning"
@click="getHint"
>
💡 Obtenir un indice (regarder une vidéo)
</ion-button>
</ion-card-content>
</ion-card>
<ion-toast
:is-open="toastOpen"
:message="toastMessage"
:duration="3000"
@didDismiss="toastOpen = false"
/>
</ion-content>
</ion-page>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
IonCard, IonCardHeader, IonCardTitle, IonCardContent,
IonButton, IonToast
} from '@ionic/vue'
import { useAdMob } from '@/composables/useAdMob'
const { showBanner, removeBanner, prepareInterstitial, showInterstitial, showRewardedAd } = useAdMob()
const questions = [
{ text: 'Quelle est la capitale de la Suisse ?' },
{ text: 'Combien font 7 × 8 ?' },
{ text: 'En quelle année a eu lieu la Révolution française ?' },
{ text: 'Quel est le symbole chimique de l\'or ?' },
{ text: 'Combien de côtés a un hexagone ?' },
]
const currentQuestion = ref(0)
const toastOpen = ref(false)
const toastMessage = ref('')
function showToast(msg: string) {
toastMessage.value = msg
toastOpen.value = true
}
async function nextQuestion() {
currentQuestion.value = (currentQuestion.value + 1) % questions.length
const shown = await showInterstitial()
if (!shown) await prepareInterstitial()
}
async function getHint() {
showToast('⏳ Chargement de la vidéo...')
const reward = await showRewardedAd()
if (reward) {
showToast(`🎉 Indice débloqué ! (récompense : ${reward.amount} ${reward.type})`)
} else {
showToast('❌ Vidéo non disponible, réessayez plus tard.')
}
}
onMounted(async () => {
await showBanner()
await prepareInterstitial()
})
onUnmounted(async () => {
await removeBanner()
})
</script>
useIAP.ts (1/2)// src/composables/useIAP.ts
import { Purchases, PurchasesOffering } from '@revenuecat/purchases-capacitor'
import { ref, toRaw } from 'vue'
export function useIAP() {
const offering = ref<PurchasesOffering | null>(null)
const isPremium = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
async function loadOffering(): Promise<void> {
isLoading.value = true
error.value = null
try {
const result = await Purchases.getOfferings()
offering.value = result.current ?? null
} catch (e: any) {
error.value = e?.message ?? 'Impossible de charger les offres'
} finally {
isLoading.value = false
}
}
async function checkPremiumStatus(): Promise<void> {
try {
const { customerInfo } = await Purchases.getCustomerInfo()
isPremium.value = customerInfo.entitlements.active['premium'] !== undefined
} catch (e: any) {
console.warn('[IAP] Impossible de vérifier le statut premium :', e)
}
}
useIAP.ts (2/2) async function purchasePackage(packageToPurchase: any): Promise<boolean> {
isLoading.value = true
error.value = null
try {
const { customerInfo } = await Purchases.purchasePackage({
aPackage: toRaw(packageToPurchase)
})
isPremium.value = customerInfo.entitlements.active['premium'] !== undefined
return isPremium.value
} catch (e: any) {
if (e?.code !== 'PURCHASE_CANCELLED') {
error.value = e?.message ?? 'Erreur lors de l\'achat'
}
return false
} finally {
isLoading.value = false
}
}
async function restorePurchases(): Promise<void> {
isLoading.value = true
error.value = null
try {
const { customerInfo } = await Purchases.restorePurchases()
isPremium.value = customerInfo.entitlements.active['premium'] !== undefined
} catch (e: any) {
error.value = e?.message ?? 'Impossible de restaurer les achats'
} finally {
isLoading.value = false
}
}
return {
offering,
isPremium,
isLoading,
error,
loadOffering,
checkPremiumStatus,
purchasePackage,
restorePurchases,
}
}
<!-- src/views/Tab2Page.vue -->
<template>
<ion-page>
<ion-header>
<ion-toolbar>
<ion-title>Premium ⭐</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div v-if="isLoading" class="ion-text-center ion-padding">
<ion-spinner />
<p>Chargement des offres...</p>
</div>
<ion-card v-else-if="error" color="danger">
<ion-card-content>{{ error }}</ion-card-content>
</ion-card>
<ion-card v-else-if="isPremium" color="success">
<ion-card-header>
<ion-card-title>⭐ Vous êtes Premium !</ion-card-title>
</ion-card-header>
<ion-card-content>
Vous avez accès à toutes les fonctionnalités.
</ion-card-content>
</ion-card>
<template v-else-if="offering">
<ion-card>
<ion-card-header>
<ion-card-title>Passer Premium</ion-card-title>
</ion-card-header>
<ion-card-content>
<p>✅ Sans publicité</p>
<p>✅ Accès illimité au contenu</p>
<p>✅ Fonctionnalités exclusives</p>
</ion-card-content>
</ion-card>
<ion-list>
<ion-item
v-for="pkg in offering.availablePackages"
:key="pkg.identifier"
button
@click="purchasePackage(pkg)"
>
<ion-label>
<h2>{{ pkg.product.title }}</h2>
<p>{{ pkg.product.description }}</p>
</ion-label>
<ion-note slot="end">{{ pkg.product.priceString }}</ion-note>
</ion-item>
</ion-list>
<ion-button expand="block" fill="clear" @click="restorePurchases">
Restaurer mes achats
</ion-button>
</template>
<ion-card v-else>
<ion-card-content class="ion-text-center">
<p>Aucune offre disponible.</p>
<p><small>RevenueCat doit être configuré avec un vrai compte et des produits publiés sur le store.</small></p>
</ion-card-content>
</ion-card>
</ion-content>
</ion-page>
</template>
💬 Les conséquences peuvent aller jusqu'à la dépublication de l'app et clôture des comptes.
🎯 Objectif : intégrer une bannière de test dans le projet Ionic développé durant l’atelier.
@capacitor-community/admob@6 dans votre projet existant.npx cap add android si nécessaire, puis ionic build et npx cap sync.AndroidManifest.xml et strings.xml avec l’App ID de test.useAdMob.ts en vous basant sur celui du cours.margin si votre app a des tabs.🏆 Bonus : ajoutez une interstitielle déclenchée après une action, avec un cooldown de 2 minutes.