[Flutter] AdMob 배너 광고, 네이티브 광고 추가 방법 (Android)

AdMob 광고 종류 비교

적응형 배너 (Adaptive Banner) 고정 크기 배너 (Fixed Size Banner)
디바이스 화면 크기에 맞춰 자동으로 크기가 조절된다
정해진 픽셀 값을 그대로 유지한다
네이티브 템플릿 광고 (Native Templates) 네이티브 플랫폼 설정 광고 (Platform Setup)
텍스트, 색상, 버튼 스타일 등을 커스텀할 수 있다 XML 레이아웃 기반으로 완전한 커스텀이 가능하다

처음에는 간단하게 배너 광고를 추가했지만,

앱 전체의 UI에 자연스럽게 녹아들 수 있는 스타일을 원해서 네이티브 광고로 전환했다.

 

플러터에서는 두 가지의 네이티브 광고 방식을 제공하는데

이 중에서도 가장 자유도가 높은 플랫폼 네이티브 방식을 선택했다.

 

Flutter AdMob 연동 기초 설정

적응형 배너 (Adaptive Banner) ca-app-pub-3940256099942544/9214589741
고정 크기 배너 (Fixed Size Banner) ca-app-pub-3940256099942544/6300978111
네이티브 광고 ca-app-pub-3940256099942544/2247696110

애드몹에서 광고 단위를 생성하면 앱 ID와 광고 단위 ID가 제공되는데

개발 단계에서는 반드시 테스트 광고 단위 ID를 사용해야 한다.
개발 단계에서 실제 광고 단위 ID를 사용하면 계정이 정지될 수 있다고 한다.

 

<manifest>
    <application>
        <!-- Sample AdMob app ID: ca-app-pub-3940256099942544~3347511713 -->
        <meta-data
            android:name="com.google.android.gms.ads.APPLICATION_ID"
            android:value="ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy"/>
    <application>
<manifest>

`android/app/src/main/AndroidManifest.xml`에 자신의 애드몹 앱 ID를 추가한다.

 

flutter pub add google_mobile_ads

플러터 앱에 애드몹 광고를 추가하기 위해 google_mobile_ads 패키지를 설치한다.

 

import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

// 배너 광고 유닛 ID
String admobBannerUnitId = kReleaseMode
    ? 'YOUR_ADMOB_BANNER_UNID_ID'
    : 'ca-app-pub-3940256099942544/6300978111'; 

// 네이티브 광고 유닛 ID
String admobNativeUnitId = kReleaseMode
    ? 'YOUR_ADMOB_NATIVE_UNIT_ID'
    : 'ca-app-pub-3940256099942544/2247696110'; 
      
void main() async {
  // Flutter 초기화
  WidgetsFlutterBinding.ensureInitialized();

  // 구글 애드몹 초기화
  MobileAds.instance.initialize();

  runApp(const MyApp());
}

`main.dart`에서 애드몹 SDK 초기화하고,

디버그, 릴리즈 환경에 따라 광고 유닛 ID를 가져온다.

 

배너 광고 추가하기

import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import '../main.dart';

/// 배너 광고 위젯
class BannerAdWidget extends StatefulWidget {
  const BannerAdWidget({super.key});

  @override
  State<BannerAdWidget> createState() => _BannerAdWidgetState();
}

class _BannerAdWidgetState extends State<BannerAdWidget> {
  BannerAd? _bannerAd;
  AdSize? _adSize;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _loadAd();
    });
  }

  @override
  void dispose() {
    _bannerAd?.dispose();
    super.dispose();
  }

  // 광고 생성 및 로드
  Future<void> _loadAd() async {
    if (!mounted) return;

    final screenWidth = MediaQuery.of(context).size.width.truncate();

    // Padding 추가
    final availableWidth = screenWidth - 42;
    final size = await AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(
      availableWidth,
    );

    if (size == null) {
      return;
    }

    if (mounted) {
      setState(() {
        _adSize = size;
      });
    }

    BannerAd(
      adUnitId: admobBannerUnitId,
      request: const AdRequest(),
      size: size,
      listener: BannerAdListener(
        onAdLoaded: (ad) {
          if (mounted) {
            setState(() {
              _bannerAd = ad as BannerAd;
            });
          }
        },
        onAdFailedToLoad: (ad, err) {
          debugPrint("Ad failed to load with error: $err");
          ad.dispose();
        },
      ),
    ).load();
  }

  @override
  Widget build(BuildContext context) {
    if (_adSize == null) {
      return const SizedBox();
    }

    return SizedBox(
      width: _adSize!.width.toDouble(),
      height: _adSize!.height.toDouble(),
      child: _bannerAd != null ? AdWidget(ad: _bannerAd!) : const SizedBox(),
    );
  }
}

적응형 배너와 고정 크기 배너를 구현할 때는 동일한 코드에 유닛 ID만 교체해 주면 된다.

배너 광고는 구현이 가장 간단하지만, 사이즈를 제외한 요소들을 커스텀 할 수 없다는 단점이 있다.

 

네이티브 템플릿 광고 추가하기

import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import '../main.dart';

// 네이티브 템플릿 광고 위젯
class NativeTemplatesAdWidget extends StatefulWidget {
  const NativeTemplatesAdWidget({super.key});

  @override
  State<NativeTemplatesAdWidget> createState() =>
      _NativeTemplatesAdWidgetState();
}

class _NativeTemplatesAdWidgetState extends State<NativeTemplatesAdWidget> {
  NativeAd? _nativeAd;
  bool _nativeAdIsLoaded = false;
  final double _adAspectRatioMedium = (370 / 355);

  @override
  void initState() {
    super.initState();
    _loadAd();
  }

  @override
  void dispose() {
    _nativeAd?.dispose();
    super.dispose();
  }

  /// 네이티브 광고 로드
  void _loadAd() async {
    setState(() {
      _nativeAdIsLoaded = false;
    });

    _nativeAd = NativeAd(
      adUnitId: admobNativeUnitId,
      listener: NativeAdListener(
        onAdLoaded: (ad) {
          debugPrint('$NativeAd loaded.');
          if (mounted) {
            setState(() {
              _nativeAdIsLoaded = true;
            });
          }
        },
        onAdFailedToLoad: (ad, error) {
          debugPrint('$NativeAd failedToLoad: $error');
          ad.dispose();
        },
      ),
      request: const AdRequest(),
      nativeTemplateStyle: NativeTemplateStyle(
        templateType: TemplateType.small,
        callToActionTextStyle: NativeTemplateTextStyle(
          textColor: Colors.white,
          style: NativeTemplateFontStyle.monospace,
          size: 16.0,
        ),
        primaryTextStyle: NativeTemplateTextStyle(
          textColor: Colors.black,
          style: NativeTemplateFontStyle.bold,
          size: 16.0,
        ),
      ),
    )..load();
  }

  @override
  Widget build(BuildContext context) {
    if (_nativeAdIsLoaded && _nativeAd != null) {
      return SizedBox(
        height: MediaQuery.of(context).size.width * _adAspectRatioMedium,
        width: double.infinity,
        child: AdWidget(ad: _nativeAd!),
      );
    }
    return const SizedBox.shrink();
  }
}

네이티브 템플릿 광고도 간단하게 구현할 수 있고, 기본적인 색상·텍스트 스타일 변경도 가능하다.

 

네이티브 플랫폼 설정 광고 추가하기

<?xml version="1.0" encoding="utf-8"?>
<com.google.android.gms.ads.nativead.NativeAdView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:id="@+id/tv_list_tile_native_ad_attribution_small"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="#F19938"
            android:text="Ad"
            android:textColor="#FFFFFF"
            android:textSize="12sp" />

        <ImageView
            android:id="@+id/iv_list_tile_native_ad_icon"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:layout_gravity="center_vertical"
            android:layout_marginStart="16dp"
            android:layout_marginLeft="16dp"
            android:scaleType="fitXY"
            tools:background="#EDEDED" />

        <TextView
            android:id="@+id/tv_list_tile_native_ad_attribution_large"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:layout_gravity="center_vertical"
            android:layout_marginStart="16dp"
            android:layout_marginLeft="16dp"
            android:background="#F19938"
            android:gravity="center"
            android:text="Ad"
            android:textColor="#FFFFFF"
            android:visibility="invisible" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:layout_marginStart="80dp"
            android:layout_marginLeft="80dp"
            android:layout_marginEnd="16dp"
            android:layout_marginRight="16dp"
            android:orientation="vertical">

            <TextView
                android:id="@+id/tv_list_tile_native_ad_headline"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:lines="1"
                android:maxLines="1"
                android:textColor="#000000"
                android:textSize="16sp"
                tools:text="Headline" />

            <TextView
                android:id="@+id/tv_list_tile_native_ad_body"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:lines="1"
                android:maxLines="1"
                android:textColor="#828282"
                android:textSize="14sp"
                tools:text="body" />

        </LinearLayout>

    </FrameLayout>

</com.google.android.gms.ads.nativead.NativeAdView>

`android\app\src\main\res\layout` 디렉토리에 `native_ad_custom.xml` 파일을 생성한다.

 

package com.example.name ✅패키지 이름 수정

import com.rainypoint.alarm.R
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.google.android.gms.ads.nativead.NativeAd
import com.google.android.gms.ads.nativead.NativeAdView
import io.flutter.plugins.googlemobileads.GoogleMobileAdsPlugin

class ListTileNativeAdFactory(val context: Context) : GoogleMobileAdsPlugin.NativeAdFactory {

    override fun createNativeAd(
        nativeAd: NativeAd,
        customOptions: MutableMap<String, Any>?
    ): NativeAdView {
        val nativeAdView = LayoutInflater.from(context)
            .inflate(R.layout.native_ad_custom, null) as NativeAdView

        with(nativeAdView) {
            val attributionViewSmall =
                findViewById<TextView>(R.id.tv_list_tile_native_ad_attribution_small)
            val attributionViewLarge =
                findViewById<TextView>(R.id.tv_list_tile_native_ad_attribution_large)

            val iconView = findViewById<ImageView>(R.id.iv_list_tile_native_ad_icon)
            val icon = nativeAd.icon
            if (icon != null) {
                attributionViewSmall.visibility = View.VISIBLE
                attributionViewLarge.visibility = View.INVISIBLE
                iconView.setImageDrawable(icon.drawable)
            } else {
                attributionViewSmall.visibility = View.INVISIBLE
                attributionViewLarge.visibility = View.VISIBLE
            }
            this.iconView = iconView

            val headlineView = findViewById<TextView>(R.id.tv_list_tile_native_ad_headline)
            headlineView.text = nativeAd.headline
            this.headlineView = headlineView

            val bodyView = findViewById<TextView>(R.id.tv_list_tile_native_ad_body)
            with(bodyView) {
                text = nativeAd.body
                visibility = if (nativeAd.body.isNullOrEmpty()) View.INVISIBLE else View.VISIBLE
            }
            this.bodyView = bodyView

            setNativeAd(nativeAd)
        }

        return nativeAdView
    }
}

`android\app\src\main\kotlin\com\example\name` 경로(본인의 패키지 이름)에 `ListTileNativeAdFactory.kt` 파일을 생성한다.

 

package com.example.name ✅패키지 이름 수정

import io.flutter.embedding.android.FlutterActivity
import com.rainypoint.alarm.ListTileNativeAdFactory
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugins.googlemobileads.GoogleMobileAdsPlugin

class MainActivity : FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        GoogleMobileAdsPlugin.registerNativeAdFactory(
            flutterEngine, "adFactoryExample", ListTileNativeAdFactory(context)
        )
    }

    override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
        super.cleanUpFlutterEngine(flutterEngine)

        GoogleMobileAdsPlugin.unregisterNativeAdFactory(flutterEngine, "adFactoryExample")
    }
}

`android\app\src\main\kotlin\com\example\name` 경로의 `MainActivity.kt` 파일을 수정한다.

 

import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import '../main.dart';

class NativePlatformAdWidget extends StatefulWidget {
  const NativePlatformAdWidget({super.key});

  @override
  State<NativePlatformAdWidget> createState() => _NativePlatformAdWidgetState();
}

class _NativePlatformAdWidgetState extends State<NativePlatformAdWidget> {
  late NativeAd _ad;
  bool isLoaded = false;

  @override
  void initState() {
    super.initState();

    _ad = NativeAd(
      adUnitId: admobNativeUnitId,
      factoryId: "adFactoryExample",
      request: const AdRequest(),
      listener: NativeAdListener(onAdLoaded: (ad) {
        setState(() {
          _ad = ad as NativeAd;
          isLoaded = true;
        });
      }, onAdFailedToLoad: (ad, error) {
        ad.dispose();
      }),
    );
    _ad.load();
  }

  @override
  void dispose() {
    super.dispose();
    _ad.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (isLoaded == true) {
      return SizedBox(
        height: 60,
        width: double.infinity,
        child: AdWidget(ad: _ad),
      );
    } else {
      return const SizedBox(height: 0);
    }
  }
}

플랫폼 네이티브 광고는 네이티브 코드를 직접 수정해야 하지만,

아이콘 크기, 텍스트, 버튼, 배경, 표시 방식 등을 완전히 커스텀할 수 있기 때문에 앱 UI에 최적화된 형태로 적용할 수 있다.

 

구현 결과

Adaptive Banner Platform Setup 기본 Platform Setup UI 수정

네이티브 플랫폼 방식을 통해 광고를 UI 구성 요소의 일부처럼 보이도록 자연스럽게 배치함으로써

사용자 경험을 최대한 해치지 않기 위해 노력했다.

 

참고 문서