Optimizing AdMob App Open Ads and iOS ATT Implementation in Flutter
Integrating Google AdMob, specifically App Open Ads, into an iOS application requires careful orchestration with Apple's App Tracking Transparency (ATT) framework. In our Video2Gif project, we encountered specific challenges regarding popup conflicts, cold/warm start behaviors, and ad fill rates.
This article documents the technical hurdles we faced and the architectural solutions we implemented to ensure a compliant and reliable ad experience.
1. Core Challenges and Troubleshooting
1.1 The "Vanishing" ATT Prompt
Symptom: Calls to AppTrackingTransparency.requestTrackingAuthorization() would return TrackingStatus.notDetermined immediately without displaying any UI to the user.
Root Cause: On iOS 15+, system-level permission requests (such as "Local Network" or "Notification" permissions) often trigger automatically at launch. If the ATT request is fired concurrently, the system prioritizes its own dialogs and suppresses the ATT prompt.
Solution: We introduced a deterministic delay (2-3 seconds) post-launch before requesting ATT authorization. This buffer allows system dialogs to clear, ensuring the ATT prompt is successfully presented.
1.2 App Open Ads Not Displaying
Symptom: Ads failed to appear during both cold starts (app launch) and warm starts (returning from background).
Root Cause:
- Cold Start: AdMob initialization depends on ATT consent. If the ATT request is delayed (as per 1.1), the app's home screen often renders before the ad SDK is ready and an ad is loaded.
- Warm Start: Flutter's
main()function only runs on cold start. To show ads when a user resumes the app, we must explicitly listen to theAppLifecycleState.resumedstate.
1.3 TestFlight Ad Fill Issues
Symptom: Ads rendered correctly in local Debug/Release builds but failed to load in TestFlight builds.
Root Cause: AdMob treats TestFlight as a production environment. Newly created Ad Units often experience a "learning phase" or propagation delay, resulting in temporary "No Fill" errors despite correct implementation.
2. Technical Implementation Strategy
2.1 Optimal Timing for ATT Requests
Directly invoking permission requests in main() or initState is unreliable.
- Best Practice: Use
WidgetsBinding.instance.addPostFrameCallbackcombined withFuture.delayed. - Duration: A 3-second delay proved to be the "sweet spot" for avoiding conflicts with iOS local network permission dialogs.
2.2 Lifecycle Management
App Open Ads are designed for the "resume" scenario. We implemented WidgetsBindingObserver on our primary screen to detect when the app enters the foreground (didChangeAppLifecycleState) and trigger the ad display logic.
2.3 Preloading Strategy
Latency is critical for App Open Ads.
- Preload: Always load the next ad immediately after the current one is dismissed.
- Expiration: Check
isAdAvailablebefore showing, as ad objects typically expire after 4 hours.
3. Solution Code
We adopted a "Delayed Request + Lifecycle Listener" pattern.
3.1 Home Screen Logic (CreateScreen.dart)
This component orchestrates the permission request and listens for app resumes.
class _CreateScreenState extends ConsumerState<CreateScreen> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
// 1. Register lifecycle observer
WidgetsBinding.instance.addObserver(this);
// 2. Delay ATT request to avoid system dialog conflicts
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(seconds: 3));
_checkAndRequestATT();
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// 3. Handle Warm Start
if (state == AppLifecycleState.resumed) {
// Re-check permissions and show ad if available
_checkAndRequestATT().then((_) {
AdManager.showAppOpenAdIfAvailable();
});
}
}
Future<void> _checkAndRequestATT() async {
final status = await AdManager.initialize();
// Logic to handle status...
}
}
3.2 Ad Manager (AdManager.dart)
A singleton-like class to encapsulate initialization and ad display logic.
class AdManager {
static Future<TrackingStatus> initialize() async {
// 1. Request ATT first
final status = await AppTrackingTransparency.requestTrackingAuthorization();
// 2. Initialize AdMob (handles restricted data if consent denied)
await MobileAds.instance.initialize();
// 3. Preload the first ad
loadAppOpenAd();
return status;
}
static void showAppOpenAdIfAvailable() {
if (!isAdAvailable) {
loadAppOpenAd();
return;
}
if (_isShowingAppOpenAd) return;
_appOpenAd!.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
ad.dispose();
_appOpenAd = null;
loadAppOpenAd(); // Immediate preload for next time
},
);
_appOpenAd!.show();
}
}
4. Pre-Release Checklist
Before submitting to the App Store, verify:
-
Info.plistcontainsNSUserTrackingUsageDescription. -
Info.plistcontainsGADApplicationIdentifier. - ATT dialog appears after the 3-second delay on fresh install.
- App Open Ad displays when resuming the app from the background.
- Correct Ad Unit IDs are used (Test IDs for Debug, Production IDs for Release).