Google Ads in a Layered Flutter App Using BLoC State Management

A guide to adding Google Ads to your Flutter App quickly and consistently

Alejandro Ferrero
Flutter España
20 min readJun 18, 2023

--

Welcome to another useful and practical blog post about Flutter. On this occasion, we’ll explore the technical aspects of integrating Google Ads and AdMob into your Flutter App while emphasizing the importance of maintainable and scalable architectural and state management patterns. If you’re looking for more info about said patterns, you may want to give this a quick look, Flutter: beyond Widgets.

Google Ads can be a valuable tool for monetizing your Flutter App. They allow you to earn revenue by displaying relevant ads to your users from a vast network of advertisers. Google Ads uses sophisticated targeting algorithms to show ads to users who are more likely to be interested in the advertised products or services, ensuring a wide range of ad campaigns and formats. On top of all that, it provides a suite of developer-friendly tools and APIs for effortlessly integrating ads into your Flutter App. You can leverage the Google Mobile Ads SDK for Flutter to display ads in various formats, such as banners, interstitials, rewarded, or native ads using the latest native templates in Dart.

However, developers postpone or even avoid the implementation of Google Ads in their Flutter Apps due to a series of more than valid reasons:

  • Prioritizing Core Functionality — Developers often focus on implementing core features and functionality of their App before integrating ads. They may want to ensure the App’s primary purpose is fully developed and optimized before introducing ads, as ads can sometimes require additional effort and considerations.
  • Integration Complexity — Integrating ads into a Flutter App involves technical steps such as installing the necessary SDKs, setting up ad units, handling ad requests and responses, and managing ad placements. This integration process may require time and effort, especially for developers new to ad monetization or who have limited experience with ad networks.
  • User Experience Considerations: Developers want to ensure that the ads they integrate do not negatively affect the user experience. Intrusive or poorly placed ads can lead to user frustration and may even result in uninstalling the App.
  • Impact on App Performance: Developers may be concerned about the potential negative effects of ads on their App’s performance. Ads, if not implemented correctly, can increase the App’s loading time, consume excessive resources, or cause UI glitches. Addressing these performance concerns requires careful optimization and testing, which may cause some developers to postpone the integration.

Ultimately, this post will minimize your concerns about the points listed above by guiding you in seamlessly integrating Google Ads and AdMob into your Flutter App, all while prioritizing a solid architectural foundation and state management best practices. At the end of this post, you’ll have an advertised version of the default Flutter App.

Advertised Flutter Counter App

Ready? Then buckle up because we’re about to dive into the exciting world of ad monetization and unleash the full revenue potential of your Flutter App.

Setup

Let’s start with all the configurations to get them out of the way before we look into the more exciting stuff. If you prefer to go straight into the sample project’s repository and dissect the code yourself or use it as a reference as you read the guide, here’s the link.

Firstly, let’s create a Flutter App using very_good_cli. We could’ve used “flutter create” instead, but VGV’s cli tool will help us speed up the process as it structures the code in a way that’s more aligned with our needs. For instance, it already includes flutter_bloc and very_good_analysis as dependencies and provides a modified version of the famous Flutter Counter App leveraging the Page-View pattern.

Secondly, go to Google’s official Get Started guide to integrate the Google Ads SDK into your Flutter App, create an AdMob account to register an Android and iOS App, and add the platform-specific configurations for each App. Notice you can skip the Initialize the Mobile SDK part for now as we’ll address it later on.

Once that’s done, add the ad units you’ll want to include in your App. For the sake of this guide, I created 5 different ads for each platform (Android, iOS). Be sure to create the same type of ad unit on both platforms and name them the same to keep track of them more easily within your code. Here’s the list of Ads you’ll find in the sample App:

Banner Ads

  • counterPageBottomBanner
  • counterPageTopBanner

Native Ad

  • counterPageNativeAd

Interstitial Ads

  • counterPagePlusCheckInterstitial
  • counterPageMinusCheckInterstitial

Lastly, make sure your pubspec.yaml file looks as follows.

name: flutter_ads
description: A sample Flutter App to show how to implement Google Ads in a Flutter App Using a Layered Architecture and BLoC State Management.
version: 1.0.0+1
publish_to: none

environment:
sdk: ">=3.0.0 <4.0.0"
flutter: 3.10.5

dependencies:
ads_client:
path: packages/ads_client
ads_repo:
path: packages/ads_repo
bloc: ^8.1.2
equatable: ^2.0.5
flutter:
sdk: flutter
flutter_bloc: ^8.1.3
flutter_localizations:
sdk: flutter
google_mobile_ads: ^3.0.0
intl: ^0.18.0

dev_dependencies:
bloc_test: ^9.1.2
flutter_test:
sdk: flutter
mocktail: ^0.3.0
very_good_analysis: ^5.0.0

flutter:
uses-material-design: true
generate: true

ads_client

Alrighty, let’s get down to business. At the root of your project’s directory, create a “packages” folder, navigate into it, and execute the following command to create a flutter package

very_good create flutter_package ads_client

Then, make sure to add the google_mobile_ads dependency to this package’s pubspec.yaml file.

Let’s now walk through the code exhibited by this package.

Under the src directory, we need to add an ads.dart file including an enum called Ads that’ll act as a helper enum listing all the Counter Page Ads to be displayed. Notice it supports iOS and Android ads for both production and debug builds, thanks to the required ios, iosTest, android, and androidTest fields. Most importantly, make sure the enum value name matches the name of the ad units created in the previous section, and add their corresponding ad IDs based on the target platform. Regarding the test ad IDs, you can simply copy the strings for each iOSTest and androidTest field as they’re static and they come straight from the official guides for each platform (Android, iOS).

/// {@template ads}
/// Helper enum listing all the Counter Ads to be displayed.
///
/// It supports iOS and Android ads for both production and debug builds.
/// {@endtemplate}
enum Ads {
/// Banner Ad positioned at the bottom of the Counter Page.
counterPageBottomBanner(
iOS: 'ca-app-pub-7287154298486616/4742754588',
android: 'ca-app-pub-7287154298486616/1278152293',
iOSTest: 'ca-app-pub-3940256099942544/2934735716',
androidTest: 'ca-app-pub-3940256099942544/6300978111',
),

/// Banner Ad positioned at the top of the Counter Page.
counterPageTopBanner(
iOS: 'ca-app-pub-7287154298486616/6438979635',
android: 'ca-app-pub-7287154298486616/6054221858',
iOSTest: 'ca-app-pub-3940256099942544/2934735716',
androidTest: 'ca-app-pub-3940256099942544/6300978111',
),

/// Interstitial Ad displayed in the Counter Page
/// when users increment the counter to a given positive threshold.
counterPagePlusCheckInterstitial(
iOS: 'ca-app-pub-7287154298486616/5864264568',
android: 'ca-app-pub-7287154298486616/8488813505',
iOSTest: 'ca-app-pub-3940256099942544/4411468910',
androidTest: 'ca-app-pub-3940256099942544/1033173712',
),

/// Interstitial Ad displayed in the Counter Page
/// when users increment the counter to a given negative threshold.
counterPageMinusCheckInterstitial(
iOS: 'ca-app-pub-7287154298486616/2498120224',
android: 'ca-app-pub-7287154298486616/9228794506',
iOSTest: 'ca-app-pub-3940256099942544/4411468910',
androidTest: 'ca-app-pub-3940256099942544/1033173712',
),

/// Native Ad displayed in the Counter Page.
counterPageNative(
iOS: 'ca-app-pub-7287154298486616/6454457353',
android: 'ca-app-pub-7287154298486616/1923405151',
iOSTest: 'ca-app-pub-3940256099942544/3986624511',
androidTest: 'ca-app-pub-3940256099942544/2247696110',
);

const Ads({
required this.iOS,
required this.android,
required this.iOSTest,
required this.androidTest,
});

/// iOS Ad id.
final String iOS;

/// Android Ad id.
final String android;

/// iOS Ad id for testing.
final String iOSTest;

/// Android Ad id for testing.
final String androidTest;
}

Next, let’s analyze the AdsClient class.

As soon as we create an instance of this class, we initialize all the desired ads based on the platform the Flutter App is running on as well as the type of build — if it’s in debug mode, we don’t want to show real ads, but rather test ads just to make sure everything is working as expected. It’s worth mentioning that we’re not initializing any ads here, as that would be detrimental to the App’s performance on startup. We’re simply populating a Map of <Ads, String> which includes the values of the enum we mentioned before as keys and the corresponding ad ID string as a value.

AdsClient() {
_ads = _initializeAds();
}

late final Map<Ads, String> _ads;

Map<Ads, String> _initializeAds() {
final ads = <Ads, String>{};
for (final ad in Ads.values) {
if (Platform.isIOS) {
if (kDebugMode) {
ads[ad] = ad.iOSTest;
} else {
ads[ad] = ad.iOS;
}
} else {
if (kDebugMode) {
ads[ad] = ad.androidTest;
} else {
ads[ad] = ad.android;
}
}
}

return ads;
}

Moreover, let’s discuss the ad-populating methods. From the code below, we can observe that the three functions behave similarly. We first declare an adCompleter as a Completer<Ad?> which we’ll need to await until the desired Ad is loaded and can be returned. However, notice how _populateInterstitialAd requires a VoidCallback onAdDismissedFullScreenContent parameter, which is a callback function that’ll be executed as soon as the interstitial ad is dismissed. This type of parameter injection will play a crucial role once we hook up the ad initializing process with the App’s state management as you’ll see in the upcoming sections. Lastly, we ensure that error handling is properly implemented for all types of ads so initializing them will never crash our App, giving us the chance to handle the exception as we deem appropriate. For the sake of this demo, I’ve only included three types of ads, but a similar approach could be implemented for the other types of ads such as rewarded, rewarded interstitial, App open, and so on.

Future<BannerAd> _populateBannerAd({
required String adUnitId,
AdSize? size,
}) async {
try {
final adCompleter = Completer<Ad?>();
await BannerAd(
adUnitId: adUnitId,
size: size ?? AdSize.banner,
request: const AdRequest(),
listener: BannerAdListener(
onAdLoaded: adCompleter.complete,
onAdFailedToLoad: (ad, error) {
// Releases an ad resource when it fails to load
ad.dispose();
adCompleter.completeError(error);
},
),
).load();

final bannerAd = await adCompleter.future;
if (bannerAd == null) {
throw const AdsClientException('Banner Ad was null');
}
return bannerAd as BannerAd;
} catch (error, stackTrace) {
Error.throwWithStackTrace(AdsClientException(error), stackTrace);
}
}

Future<NativeAd> _populateNativeAd({
required String adUnitId,
TemplateType? templateType,
}) async {
try {
final adCompleter = Completer<Ad?>();
await NativeAd(
adUnitId: adUnitId,
listener: NativeAdListener(
onAdLoaded: adCompleter.complete,
onAdFailedToLoad: (ad, error) {
// Releases an ad resource when it fails to load
ad.dispose();
adCompleter.completeError(error);
},
),
request: const AdRequest(),
nativeTemplateStyle: NativeTemplateStyle(
templateType: templateType ?? TemplateType.medium,
),
).load();
final nativeAd = await adCompleter.future;
if (nativeAd == null) {
throw const AdsClientException('Native Ad was null');
}
return nativeAd as NativeAd;
} catch (error, stackTrace) {
Error.throwWithStackTrace(AdsClientException(error), stackTrace);
}
}

Future<InterstitialAd> _populateInterstitialAd({
required String adUnitId,
required VoidCallback onAdDismissedFullScreenContent,
}) async {
try {
final adCompleter = Completer<Ad?>();
await InterstitialAd.load(
adUnitId: adUnitId,
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
onAdDismissedFullScreenContent();
},
);
adCompleter.complete(ad);
},
onAdFailedToLoad: adCompleter.completeError,
),
);

final interstitialAd = await adCompleter.future;
if (interstitialAd == null) {
throw const AdsClientException('Interstitial Ad was null');
}
return interstitialAd as InterstitialAd;
} catch (error, stackTrace) {
Error.throwWithStackTrace(AdsClientException(error), stackTrace);
}
}

To wrap up the AdsClient class, we need to include all the dedicated public methods that’ll allow us to initialize these ads on demand. Notice how each method corresponds to a single ad defined in the Ads enum. Ultimately, this approach is super consistent and intuitive since it only requires developers to know which populating method needs to be used based on the type of ad (banner, native, interstitial…) and the Ads enum value to access the correct key/value pair from the _ads Map.

/// Gets a banner Ad positioned at the top of the Counter Page.
Future<BannerAd> getCounterPageTopBannerAd() async {
try {
return await _populateBannerAd(adUnitId: _ads[Ads.counterPageTopBanner]!);
} catch (error, stackTrace) {
Error.throwWithStackTrace(AdsClientException(error), stackTrace);
}
}

/// Gets a banner Ad positioned at the bottom of the Counter Page.
Future<BannerAd> getCounterPageBottomBannerAd() async {
try {
return await _populateBannerAd(
adUnitId: _ads[Ads.counterPageBottomBanner]!,
);
} catch (error, stackTrace) {
Error.throwWithStackTrace(AdsClientException(error), stackTrace);
}
}

/// Gets an interstitial Ad displayed in the Counter Page
/// when users increment the counter to a given positive threshold.
Future<InterstitialAd> getCounterPagePlusCheckInterstitialAd({
required VoidCallback onAdDismissedFullScreenContent,
}) async {
try {
return await _populateInterstitialAd(
adUnitId: _ads[Ads.counterPagePlusCheckInterstitial]!,
onAdDismissedFullScreenContent: onAdDismissedFullScreenContent,
);
} catch (error, stackTrace) {
Error.throwWithStackTrace(AdsClientException(error), stackTrace);
}
}

/// Gets an interstitial Ad displayed in the Counter Page
/// when users increment the counter to a given negative threshold.
Future<InterstitialAd> getCounterPageMinusCheckInterstitialAd({
required VoidCallback onAdDismissedFullScreenContent,
}) async {
try {
return await _populateInterstitialAd(
adUnitId: _ads[Ads.counterPageMinusCheckInterstitial]!,
onAdDismissedFullScreenContent: onAdDismissedFullScreenContent,
);
} catch (error, stackTrace) {
Error.throwWithStackTrace(AdsClientException(error), stackTrace);
}
}

/// Gets a native Ad displayed in the Counter Page.
Future<NativeAd> getCounterPageNativeAd() async {
try {
return await _populateNativeAd(adUnitId: _ads[Ads.counterPageNative]!);
} catch (error, stackTrace) {
Error.throwWithStackTrace(AdsClientException(error), stackTrace);
}
}

ads_repo

Moving onto the repository layer now. Much in the same fashion as we did with the previous package, let’s create another package with the following command.

very_good create flutter_package ads_repo

Then, make sure to add the google_mobile_ads and the previously created ads_client as dependencies to this package’s pubspec.yaml file.

This package will expose two files holding the repo patterns to be returned by the AdsRepo class’ methods, and the implementation of said class.

The ads_repo_patterns.dart defines signatures of all the values to be returned to the upper layers — for more info on this subject, check out this blog post, Error Handling in Layered Architectures with Dart Patterns.

import 'package:google_mobile_ads/google_mobile_ads.dart';

/// Base pattern for all return patterns at the Repository level.
typedef RepoPattern<O, S> = ({RepoFailure<O>? failure, S? value});

/// Pattern representing a failure returned a given Repository.
typedef RepoFailure<O> = ({Object error, StackTrace stackTrace, O? optional});

/// Pattern returned by any AdsRepo method that returns a [BannerAd].
typedef BannerAdPattern = RepoPattern<void, BannerAd>;

/// Pattern returned by any AdsRepo method that returns a [InterstitialAd].
typedef InterstitialAdPattern = RepoPattern<void, InterstitialAd>;

/// Pattern returned by any AdsRepo method that returns a [NativeAd].
typedef NativeAdPattern = RepoPattern<void, NativeAd>;

As for the AdsRepo class implementation, we make sure to require an instance of the AdsClient class as a parameter to the repo’s constructor. This type of dependency injection pattern goes a long way when implementing a proper layered architecture and 100% code coverage in your code base. Lastly, we make sure to add as many public methods as we did at the client level. In this case, these methods will act as middle man forwarding the right response back to the upper layers and appropriately handling any thrown exceptions.

/// Gets a banner Ad positioned at the top of the Counter Page.
Future<BannerAdPattern> getCounterPageTopBannerAd() async {
try {
final bannerAd = await _adsClient.getCounterPageTopBannerAd();
return (failure: null, value: bannerAd);
} catch (e, st) {
return (
failure: (
error: e,
stackTrace: st,
optional: 'Exception caught in getCounterPageTopBannerAd'
),
value: null,
);
}
}

/// Gets a banner Ad positioned at the bottom of the Counter Page.
Future<BannerAdPattern> getCounterPageBottomBannerAd() async {
try {
final bannerAd = await _adsClient.getCounterPageBottomBannerAd();
return (failure: null, value: bannerAd);
} catch (e, st) {
return (
failure: (
error: e,
stackTrace: st,
optional: 'Exception caught in getCounterPageBottomBannerAd'
),
value: null,
);
}
}

/// Gets an interstitial Ad displayed in the Counter Page
/// when users increment the counter to a given positive threshold.
Future<InterstitialAdPattern> getCounterPagePlusCheckInterstitialAd({
required VoidCallback onAdDismissedFullScreenContent,
}) async {
try {
final interstitialAd =
await _adsClient.getCounterPagePlusCheckInterstitialAd(
onAdDismissedFullScreenContent: onAdDismissedFullScreenContent,
);
return (failure: null, value: interstitialAd);
} catch (e, st) {
return (
failure: (
error: e,
stackTrace: st,
optional: 'Exception caught in getCounterPagePlusCheckInterstitialAd',
),
value: null,
);
}
}

/// Gets an interstitial Ad displayed in the Counter Page
/// when users increment the counter to a given negative threshold.
Future<InterstitialAdPattern> getCounterPageMinusCheckInterstitialAd({
required VoidCallback onAdDismissedFullScreenContent,
}) async {
try {
final interstitialAd =
await _adsClient.getCounterPageMinusCheckInterstitialAd(
onAdDismissedFullScreenContent: onAdDismissedFullScreenContent,
);
return (failure: null, value: interstitialAd);
} catch (e, st) {
return (
failure: (
error: e,
stackTrace: st,
optional:
'Exception caught in getCounterPageMinusCheckInterstitialAd',
),
value: null,
);
}
}

/// Gets a native Ad displayed in the Counter Page.
Future<NativeAdPattern> getCounterPageNativeAd() async {
try {
final nativeAd = await _adsClient.getCounterPageNativeAd();
return (failure: null, value: nativeAd);
} catch (e, st) {
return (
failure: (
error: e,
stackTrace: st,
optional: 'Exception caught in getCounterPageNativeAd'
),
value: null,
);
}
}

ads_bloc

Taking one step upwards in our layered architecture ladder, we find the business logic layer, where we’ll place our AdsBloc along with its events and state. This bloc’s responsibility will be to handle the request and dismissal of our App ads in a deterministic, non-blocking, and consistent manner. In other words, it will handle the state of all our ads to make sure the UI looks and reacts to ad-related updates as expected. For that, will create an ads folder under the lib directory, and include a bloc folder with three files: ads_bloc.dart, ads_event.dart, ads_state.dart.

Let’s first look into the bloc events. Essentially, we declare a request and dismissal event for each one of the ads we want to display in our App. Notice how the request event for interstitial ads includes a required parameter, the callback function mentioned in the ads_client section which will allow us to execute some code before dismissing the interstitial ad.

part of 'ads_bloc.dart';

@immutable
abstract class AdsEvent extends Equatable {
const AdsEvent();

@override
List<Object> get props => [];
}

class AdsCounterPageBottomBannerAdRequested extends AdsEvent {}

class AdsCounterPageBottomBannerAdDisposed extends AdsEvent {}

class AdsCounterPageTopBannerAdRequested extends AdsEvent {}

class AdsCounterPageTopBannerAdDisposed extends AdsEvent {}

class AdsCounterPageNativeAdRequested extends AdsEvent {}

class AdsCounterPageNativeAdDisposed extends AdsEvent {}

class AdsCounterPagePlusCheckInterstitialAdRequested extends AdsEvent {
const AdsCounterPagePlusCheckInterstitialAdRequested({
required this.onAdDismissedFullScreenContent,
});

final VoidCallback onAdDismissedFullScreenContent;

@override
List<Object> get props => [onAdDismissedFullScreenContent];
}

class AdsCounterPagePlusCheckInterstitialAdDisposed extends AdsEvent {}

class AdsCounterPageMinusCheckInterstitialAdRequested extends AdsEvent {
const AdsCounterPageMinusCheckInterstitialAdRequested({
required this.onAdDismissedFullScreenContent,
});

final VoidCallback onAdDismissedFullScreenContent;

@override
List<Object> get props => [onAdDismissedFullScreenContent];
}

class AdsCounterPageMinusCheckInterstitialAdDisposed extends AdsEvent {}

Next, let’s analyze the bloc’s state. The AdsState class simply stores the state of the AdsBloc, by keeping a reference to the 5 different ads we want to show in our App. Notice these values could be null at any given point, so we added some getters to enhance readability at the UI level. It’s worth noting we only have a single immutable state that exposes a series of copyWith methods that’ll allow us to emit new states with the desired fields.

// ignore_for_file: public_member_api_docs, sort_constructors_first
part of 'ads_bloc.dart';

class AdsState extends Equatable {
const AdsState({
this.counterPageBottomBannerAd,
this.counterPageTopBannerAd,
this.counterPageNativeAd,
this.counterPagePlusCheckInterstitialAd,
this.counterPageMinusCheckInterstitialAd,
});

final BannerAd? counterPageBottomBannerAd;
final BannerAd? counterPageTopBannerAd;
final NativeAd? counterPageNativeAd;
final InterstitialAd? counterPagePlusCheckInterstitialAd;
final InterstitialAd? counterPageMinusCheckInterstitialAd;

bool get didCounterPageBottomBannerAdLoad =>
counterPageBottomBannerAd != null;
bool get didCounterPageTopBannerAdLoad => counterPageTopBannerAd != null;
bool get didCounterPagePlusCheckInterstitialAdAdLoad =>
counterPagePlusCheckInterstitialAd != null;
bool get didCounterPageMinusCheckInterstitialAdAdLoad =>
counterPageMinusCheckInterstitialAd != null;
bool get didCounterPageNativeAdLoad => counterPageNativeAd != null;

@override
List<Object?> get props => [
counterPageBottomBannerAd,
counterPageTopBannerAd,
counterPageNativeAd,
counterPagePlusCheckInterstitialAd,
counterPageMinusCheckInterstitialAd,
];

AdsState copyWith({
BannerAd? counterPageBottomBannerAd,
BannerAd? counterPageTopBannerAd,
NativeAd? counterPageNativeAd,
InterstitialAd? counterPagePlusCheckInterstitialAd,
InterstitialAd? counterPageMinusCheckInterstitialAd,
}) {
return AdsState(
counterPageBottomBannerAd:
counterPageBottomBannerAd ?? this.counterPageBottomBannerAd,
counterPageTopBannerAd:
counterPageTopBannerAd ?? this.counterPageTopBannerAd,
counterPageNativeAd: counterPageNativeAd ?? this.counterPageNativeAd,
counterPagePlusCheckInterstitialAd: counterPagePlusCheckInterstitialAd ??
this.counterPagePlusCheckInterstitialAd,
counterPageMinusCheckInterstitialAd:
counterPageMinusCheckInterstitialAd ??
this.counterPageMinusCheckInterstitialAd,
);
}

AdsState copyWithoutCounterPageBottomBannerAd() {
return AdsState(
counterPageTopBannerAd: counterPageTopBannerAd,
counterPageNativeAd: counterPageNativeAd,
counterPagePlusCheckInterstitialAd: counterPagePlusCheckInterstitialAd,
counterPageMinusCheckInterstitialAd: counterPageMinusCheckInterstitialAd,
);
}

AdsState copyWithoutCounterPageTopBannerAd() {
return AdsState(
counterPageBottomBannerAd: counterPageBottomBannerAd,
counterPageNativeAd: counterPageNativeAd,
counterPagePlusCheckInterstitialAd: counterPagePlusCheckInterstitialAd,
counterPageMinusCheckInterstitialAd: counterPageMinusCheckInterstitialAd,
);
}

AdsState copyWithoutCounterPageNativeAd() {
return AdsState(
counterPageTopBannerAd: counterPageTopBannerAd,
counterPageBottomBannerAd: counterPageBottomBannerAd,
counterPagePlusCheckInterstitialAd: counterPagePlusCheckInterstitialAd,
counterPageMinusCheckInterstitialAd: counterPageMinusCheckInterstitialAd,
);
}

AdsState copyWithoutCounterPagePlusCheckInterstitialAd() {
return AdsState(
counterPageBottomBannerAd: counterPageBottomBannerAd,
counterPageTopBannerAd: counterPageTopBannerAd,
counterPageNativeAd: counterPageNativeAd,
counterPageMinusCheckInterstitialAd: counterPageMinusCheckInterstitialAd,
);
}

AdsState copyWithoutCounterPageMinusCheckInterstitialAd() {
return AdsState(
counterPageBottomBannerAd: counterPageBottomBannerAd,
counterPageTopBannerAd: counterPageTopBannerAd,
counterPageNativeAd: counterPageNativeAd,
counterPagePlusCheckInterstitialAd: counterPagePlusCheckInterstitialAd,
);
}
}

Last but not least, let’s dive into the implementation of the AdsBloc class. This class helps us handle triggered events and emit the corresponding bloc state. Observe how we have a single private method (handler) per BlocEvent, enforcing logic decoupling and proper separation of concerns, even if the implementation of each method is quite repetitive and systematic. Lastly, we leverage the same dependency injection approach mentioned earlier by requiring an instance of AdsRepo as a constructor parameter, which will allow us to communicate with the repository level.

import 'dart:async';

import 'package:ads_repo/ads_repo.dart';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

part 'ads_event.dart';
part 'ads_state.dart';

class AdsBloc extends Bloc<AdsEvent, AdsState> {
AdsBloc({
required AdsRepo adsRepo,
}) : _adsRepo = adsRepo,
super(const AdsState()) {
on<AdsCounterPageBottomBannerAdRequested>(
_counterPageBottomBannerAdRequested,
);
on<AdsCounterPageBottomBannerAdDisposed>(
_counterPageBottomBannerAdDisposed,
);
on<AdsCounterPageTopBannerAdRequested>(_counterPageTopBannerAdRequested);
on<AdsCounterPageTopBannerAdDisposed>(_counterPageTopBannerAdDisposed);
on<AdsCounterPageNativeAdRequested>(_counterPageNativeAdRequested);
on<AdsCounterPageNativeAdDisposed>(_counterPageNativeAdDisposed);
on<AdsCounterPagePlusCheckInterstitialAdRequested>(
_counterPagePlusCheckInterstitialAdRequested,
);
on<AdsCounterPagePlusCheckInterstitialAdDisposed>(
_counterPagePlusCheckInterstitialAdDisposed,
);
on<AdsCounterPageMinusCheckInterstitialAdRequested>(
_counterPageMinusCheckInterstitialAdRequested,
);
on<AdsCounterPageMinusCheckInterstitialAdDisposed>(
_counterPageMinusCheckInterstitialAdDisposed,
);
}
final AdsRepo _adsRepo;

FutureOr<void> _counterPageBottomBannerAdRequested(
AdsCounterPageBottomBannerAdRequested event,
Emitter<AdsState> emit,
) async {
final pattern = await _adsRepo.getCounterPageBottomBannerAd();
switch (pattern) {
case (failure: null, value: final BannerAd ad):
return emit(state.copyWith(counterPageBottomBannerAd: ad));
case (failure: final RepoFailure<String> failure, value: null):
addError(failure.error, failure.stackTrace);
}
}

FutureOr<void> _counterPageBottomBannerAdDisposed(
AdsCounterPageBottomBannerAdDisposed event,
Emitter<AdsState> emit,
) {
state.counterPageTopBannerAd?.dispose();
emit(state.copyWithoutCounterPageBottomBannerAd());
}

FutureOr<void> _counterPageTopBannerAdRequested(
AdsCounterPageTopBannerAdRequested event,
Emitter<AdsState> emit,
) async {
final pattern = await _adsRepo.getCounterPageTopBannerAd();
switch (pattern) {
case (failure: null, value: final BannerAd ad):
return emit(state.copyWith(counterPageTopBannerAd: ad));
case (failure: final RepoFailure<String> failure, value: null):
addError(failure.error, failure.stackTrace);
}
}

FutureOr<void> _counterPageTopBannerAdDisposed(
AdsCounterPageTopBannerAdDisposed event,
Emitter<AdsState> emit,
) {
state.counterPageTopBannerAd?.dispose();
emit(state.copyWithoutCounterPageTopBannerAd());
}

FutureOr<void> _counterPagePlusCheckInterstitialAdRequested(
AdsCounterPagePlusCheckInterstitialAdRequested event,
Emitter<AdsState> emit,
) async {
if (state.didCounterPagePlusCheckInterstitialAdAdLoad) return;
final pattern = await _adsRepo.getCounterPagePlusCheckInterstitialAd(
onAdDismissedFullScreenContent: event.onAdDismissedFullScreenContent,
);
switch (pattern) {
case (failure: null, value: final InterstitialAd ad):
return emit(state.copyWith(counterPagePlusCheckInterstitialAd: ad));
case (failure: final RepoFailure<String> failure, value: null):
addError(failure.error, failure.stackTrace);
}
}

FutureOr<void> _counterPagePlusCheckInterstitialAdDisposed(
AdsCounterPagePlusCheckInterstitialAdDisposed event,
Emitter<AdsState> emit,
) {
state.counterPageMinusCheckInterstitialAd?.dispose();
emit(state.copyWithoutCounterPagePlusCheckInterstitialAd());
}

FutureOr<void> _counterPageMinusCheckInterstitialAdRequested(
AdsCounterPageMinusCheckInterstitialAdRequested event,
Emitter<AdsState> emit,
) async {
if (state.didCounterPageMinusCheckInterstitialAdAdLoad) return;
final pattern = await _adsRepo.getCounterPageMinusCheckInterstitialAd(
onAdDismissedFullScreenContent: event.onAdDismissedFullScreenContent,
);
switch (pattern) {
case (failure: null, value: final InterstitialAd ad):
return emit(state.copyWith(counterPageMinusCheckInterstitialAd: ad));
case (failure: final RepoFailure<String> failure, value: null):
addError(failure.error, failure.stackTrace);
}
}

FutureOr<void> _counterPageMinusCheckInterstitialAdDisposed(
AdsCounterPageMinusCheckInterstitialAdDisposed event,
Emitter<AdsState> emit,
) {
state.counterPageMinusCheckInterstitialAd?.dispose();
emit(state.copyWithoutCounterPageMinusCheckInterstitialAd());
}

FutureOr<void> _counterPageNativeAdRequested(
AdsCounterPageNativeAdRequested event,
Emitter<AdsState> emit,
) async {
final pattern = await _adsRepo.getCounterPageNativeAd();
switch (pattern) {
case (failure: null, value: final NativeAd ad):
return emit(state.copyWith(counterPageNativeAd: ad));
case (failure: final RepoFailure<String> failure, value: null):
addError(failure.error, failure.stackTrace);
}
}

FutureOr<void> _counterPageNativeAdDisposed(
AdsCounterPageNativeAdDisposed event,
Emitter<AdsState> emit,
) {
state.counterPageNativeAd?.dispose();
emit(state.copyWithoutCounterPageNativeAd());
}
}

App UI

It’s Flutter time!

Let’s create a widgets folder under de ads directory and place various files to handle the creation of the widgets that’ll draw the banner and native ads in our Counter App’s UI.

counter_page_bottom_banner_ad.dart

import 'package:flutter/material.dart';
import 'package:flutter_ads/ads/ads.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

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

static const height = 72.0;

@override
State<CounterPageBottomBannerAd> createState() =>
_CounterPageBottomBannerAdState();
}

class _CounterPageBottomBannerAdState extends State<CounterPageBottomBannerAd> {
late AdsBloc adsBloc;

@override
void dispose() {
adsBloc.add(AdsCounterPageBottomBannerAdDisposed());
super.dispose();
}

@override
Widget build(BuildContext context) {
adsBloc = context.read<AdsBloc>()
..add(AdsCounterPageBottomBannerAdRequested());

return BlocBuilder<AdsBloc, AdsState>(
buildWhen: (pre, cur) =>
pre.counterPageBottomBannerAd != cur.counterPageBottomBannerAd,
builder: (context, state) {
if (!state.didCounterPageBottomBannerAdLoad) {
return const SizedBox.shrink();
}
return SizedBox(
width: double.infinity,
height: CounterPageBottomBannerAd.height,
child: Stack(
children: [
AdWidget(ad: state.counterPageBottomBannerAd!),
RemoveAdButton(
onTap: () {
adsBloc.add(AdsCounterPageBottomBannerAdDisposed());
},
),
],
),
);
},
);
}
}

counter_page_top_banner_ad.dart

import 'package:flutter/material.dart';
import 'package:flutter_ads/ads/ads.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

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

static const height = 72.0;

@override
State<CounterPageTopBannerAd> createState() => _CounterPageTopBannerAdState();
}

class _CounterPageTopBannerAdState extends State<CounterPageTopBannerAd> {
late AdsBloc adsBloc;

@override
void dispose() {
adsBloc.add(AdsCounterPageTopBannerAdDisposed());
super.dispose();
}

@override
Widget build(BuildContext context) {
adsBloc = context.read<AdsBloc>()
..add(AdsCounterPageTopBannerAdRequested());

return BlocBuilder<AdsBloc, AdsState>(
buildWhen: (pre, cur) =>
pre.counterPageTopBannerAd != cur.counterPageTopBannerAd,
builder: (context, state) {
if (!state.didCounterPageTopBannerAdLoad) {
return const SizedBox.shrink();
}
return SizedBox(
width: double.infinity,
height: CounterPageTopBannerAd.height,
child: Stack(
children: [
AdWidget(ad: state.counterPageTopBannerAd!),
RemoveAdButton(
onTap: () {
adsBloc.add(AdsCounterPageTopBannerAdDisposed());
},
),
],
),
);
},
);
}
}

counter_page_top_banner_ad.dart

import 'package:flutter/material.dart';
import 'package:flutter_ads/ads/ads.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

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

@override
State<CounterPageNativeAd> createState() => _CounterPageNativeAdState();
}

class _CounterPageNativeAdState extends State<CounterPageNativeAd> {
late AdsBloc adsBloc;

@override
void dispose() {
adsBloc.add(AdsCounterPageNativeAdDisposed());
super.dispose();
}

@override
Widget build(BuildContext context) {
adsBloc = context.read<AdsBloc>()..add(AdsCounterPageNativeAdRequested());

return BlocBuilder<AdsBloc, AdsState>(
buildWhen: (pre, cur) =>
pre.counterPageNativeAd != cur.counterPageNativeAd,
builder: (context, state) {
if (!state.didCounterPageNativeAdLoad) return const SizedBox.shrink();
return ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 320, // minimum recommended width
minHeight: 320, // minimum recommended height
maxHeight: 320,
),
child: Stack(
children: [
AdWidget(ad: state.counterPageNativeAd!),
RemoveAdButton(
alignment: Alignment.topLeft,
margin: const EdgeInsets.only(left: 16, top: 16),
onTap: () {
adsBloc.add(AdsCounterPageNativeAdDisposed());
},
),
],
),
);
},
);
}
}

remove_ad_button.dart

import 'package:flutter/material.dart';

class RemoveAdButton extends StatelessWidget {
const RemoveAdButton({
required this.onTap,
this.margin = const EdgeInsets.only(left: 16),
this.alignment = Alignment.centerLeft,
super.key,
});

final VoidCallback onTap;
final EdgeInsets margin;
final Alignment alignment;

@override
Widget build(BuildContext context) {
return Align(
alignment: alignment,
child: Padding(
padding: margin,
child: InkWell(
borderRadius: BorderRadius.circular(60),
onTap: onTap,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Colors.red[400],
),
child: const Icon(
Icons.clear_rounded,
size: 24,
color: Colors.white,
),
),
),
),
);
}
}

Looking at the widgets above, we notice they all follow a pretty similar implementation:

  • The moment the widget is built, an AdsBloc event is triggered to request the corresponding ad.
  • The BlocBuilder triggers the widget’s rebuild based on whether the ad state has changed. If it’s null, it won’t be displayed, else, it will.
  • A RemoveAddButton is added on top of the ad to allow the user to remove the ad on demand. Notice the onTap function will trigger the corresponding ad dismissal bloc event.
  • If the widget is disposed for any reason, an ad-dismissal bloc event will be triggered to make avoiding any memory leaks and negative performance impact.

On startup, our app will first execute the code in our custom-made main_common.dart file, where we ensure the WidgetsFlutterBinding is initialized, and then initialize the MobileAds.instance, the AdsClient, and AdsRepo. Lastly, we inject the repo instance into the App class.

import 'package:ads_client/ads_client.dart';
import 'package:ads_repo/ads_repo.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ads/app/app.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

Future<Widget> mainCommon() async {
WidgetsFlutterBinding.ensureInitialized();
await MobileAds.instance.initialize();

final adsClient = AdsClient();
final adsRepo = AdsRepo(adsClient: adsClient);

return App(adsRepo: adsRepo);
}

Looking into the app.dart file, we notice the only difference from the default counter app is that we wrap the home route page with a RepositoryProvider so that the CounterPage has access to the _adsRepo instance.

import 'package:ads_repo/ads_repo.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ads/counter/counter.dart';
import 'package:flutter_ads/l10n/l10n.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class App extends StatelessWidget {
const App({required AdsRepo adsRepo, super.key}) : _adsRepo = adsRepo;

final AdsRepo _adsRepo;

@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
appBarTheme: const AppBarTheme(color: Color(0xFF13B9FF)),
colorScheme: ColorScheme.fromSwatch(
accentColor: const Color(0xFF13B9FF),
),
),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: RepositoryProvider(
create: (context) => _adsRepo,
child: const CounterPage(),
),
);
}
}

At last, let’s look into the actual UI of our new Counter App with Google ads.

Counter App with Google Ads

The UI above was created by tweaking the default Flutter Counter App and adding the ad widgets we reviewed earlier. Check out the code below.

import 'package:ads_repo/ads_repo.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ads/ads/ads.dart';
import 'package:flutter_ads/counter/counter.dart';
import 'package:flutter_ads/l10n/l10n.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class CounterPage extends StatelessWidget {
const CounterPage({super.key});

@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (_) => CounterCubit(),
),
BlocProvider(
create: (context) => AdsBloc(adsRepo: context.read<AdsRepo>()),
),
],
child: const CounterView(),
);
}
}

class CounterView extends StatelessWidget {
const CounterView({super.key});

@override
Widget build(BuildContext context) {
final l10n = context.l10n;

return Scaffold(
appBar: AppBar(title: Text(l10n.counterAppBarTitle)),
body: const Stack(
children: [
Positioned(
top: 0,
right: 0,
left: 0,
child: CounterPageTopBannerAd(),
),
Positioned(
bottom: 0,
right: 0,
left: 0,
child: CounterPageBottomBannerAd(),
),
Center(
child: CounterPageNativeAd(),
),
Center(child: CounterText()),
],
),
floatingActionButton: const Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
PlusCounterButton(),
SizedBox(height: 8),
MinusCounterButton(),
],
),
);
}
}

class MinusCounterButton extends StatelessWidget {
const MinusCounterButton({
super.key,
});

@override
Widget build(BuildContext context) {
final adsBloc = context.read<AdsBloc>();
final count = context.select((CounterCubit cubit) => cubit.state);

return BlocListener<AdsBloc, AdsState>(
listenWhen: (pre, cur) =>
pre.counterPageMinusCheckInterstitialAd == null &&
cur.counterPageMinusCheckInterstitialAd != null,
listener: (context, state) {
state.counterPageMinusCheckInterstitialAd?.show();
},
child: FloatingActionButton(
onPressed: () {
context.read<CounterCubit>().decrement();
if (count % 5 == 0 && count < 0) {
adsBloc.add(
AdsCounterPageMinusCheckInterstitialAdRequested(
onAdDismissedFullScreenContent: () {
adsBloc.add(
AdsCounterPageMinusCheckInterstitialAdDisposed(),
);
},
),
);
}
},
child: const Icon(Icons.remove),
),
);
}
}

class PlusCounterButton extends StatelessWidget {
const PlusCounterButton({
super.key,
});

@override
Widget build(BuildContext context) {
final adsBloc = context.read<AdsBloc>();
final count = context.select((CounterCubit cubit) => cubit.state);

return BlocListener<AdsBloc, AdsState>(
listenWhen: (pre, cur) =>
pre.counterPagePlusCheckInterstitialAd == null &&
cur.counterPagePlusCheckInterstitialAd != null,
listener: (context, state) {
state.counterPagePlusCheckInterstitialAd?.show();
},
child: FloatingActionButton(
onPressed: () {
context.read<CounterCubit>().increment();
if (count % 5 == 0 && count > 0) {
adsBloc.add(
AdsCounterPagePlusCheckInterstitialAdRequested(
onAdDismissedFullScreenContent: () {
adsBloc.add(AdsCounterPagePlusCheckInterstitialAdDisposed());
},
),
);
}
},
child: const Icon(Icons.add),
),
);
}
}

class CounterText extends StatelessWidget {
const CounterText({super.key});

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final count = context.select((CounterCubit cubit) => cubit.state);

return ClipOval(
child: Container(
constraints: const BoxConstraints(
minWidth: 200,
minHeight: 200,
),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(50),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$count',
style: theme.textTheme.displayLarge?.copyWith(
color: Colors.white,
),
),
],
),
),
);
}
}

Notice how we use the Page-View pattern to provide the bloc right above the AppView widget so that it has access to it down the widget tree. Moreover, we’ve modified the plus and minus counter buttons so that they display an interstitial add as soon as the counter value is divisible by 5. Notice how the ad dismissal callback function is nothing but the bloc event that triggers the dismissal of the corresponding interstitial ad. What an annoying Easter Egg, eh?

Interstitial Ads

And of course, thanks to our handy RemoveAdButton, we can remove the banner and native ads on demand so they clean up the Counter Page.

Remove Banner and Native Ads on demand

Final Remarks

We’ve seen how to implement Google Ads into a Flutter App in a consistent, systematic, and intuitive manner while relying on best practices and patterns for layered architectures, dependency injection, and state management with bloc. I want to emphasize that the number of ads and how/when/where they’re shown to the user is up to the developer. However, it’s worth noting that while Google Ads can be a powerful monetization tool, it’s important to strike a balance between generating revenue and providing a good user experience. Ensure that the ads are not intrusive or disruptive to your app’s functionality and content, as maintaining a positive user experience is crucial for long-term success.

And that’s it! I would love to hear your feedback about this blog post or discuss any related topics, so feel free to drop a comment or reach out to me on Twitter.

--

--

Alejandro Ferrero
Flutter España

🤤 Founder of Foodium 💙 Lead Mobile Developer at iVisa 💻 Former Google DSC Lead at PoliMi 🔗 Blockchain enthusiast 🇪🇸 🇺🇸 🇮🇹 🇦🇩 Been there, done that