Bevy + iOS + Ads = ❤️

Posted 2023-10-14 05:19:42 ‐ 9 min read

Show iOS ads using bevy!

Thanks to bevy examples and Nikl for providing us helpful starting point!

A few prerequisites…

This blog entry is aimed at intermediate Rust or iOS developers, I'll keep the wording short and accurate1, but heavy in helpful external links.

We need a computer running MacOS to compile to iOS.

We will begin our journey from Nikl's Bevy game template: check it out, clone it locally.

1

As best as I can: help me by sending issues or PRs, words are hard!

Cross compilation

In folder mobile, run make run2.

😱 It's not working! We probably need more pre-requisites, keep reading!

We want to cross-compile Rust in there, check out Rust platform support: we want to install those targets: aarch64-apple-ios and/or aarch64-apple-ios-sim.

We need to install Xcode and its command lines utilities xcode-select --install in order to make iOS builds.

In folder mobile, run make run2.

2

That uses a Makefile, I call it a multi entry-point shell script.


Are you okay? This time it should at least build a xcodeproj.

It's okay, we went through a lot of technical details already!

Take time to read though the links then send me an issue if you're lost!


When we open our xcodeproj, we should be able to launch Nikl's Bevy game minimal game: a button and a moving sprite.

If we want to install on a real device, we do not need apple development program for testing purposes: let xcode error messages guide us to automatically handle the signing certificates.

What is going on?

By looking at the logs, I was certainly overwhelmed.

In Rust, RUST_LOGS is often used to control logs, within Xcode we'll need to add it to the scheme environment's variables.

TODO: add my rust_log specifics.

Ads!

Let's show some ads!

Dependencies

We'll implement Applovin's MaxAds, most of these techniques will apply to other native (objective-C/swift or C) SDKs.

Cocoapods will help us manage our dependencies, once we run pod install, we need to make sure we're starting our xcworkspace and not the xcodeproj.

Naïve implementation

Applovin provides boilerplate code to quickly implement our ads, we'll start with interstitials.

We're starting with the most naïve implementation: when we want to display an add to the user, we load it then display it.

The code provided by Applovin is a good start, we'll tweak a few lines (⚠️):

ExampleViewController.h:

#import <AppLovinSDK/AppLovinSDK.h>

#ifndef ExampleViewController_h
#define ExampleViewController_h

@class ExampleViewController;

@interface ExampleViewController : UIViewController<MAAdDelegate>
@property (nonatomic, strong) MAInterstitialAd *interstitialAd;
@property (nonatomic, assign) NSInteger retryAttempt;

- (void)createInterstitialAd;

@end
#endif /* ExampleViewController_h */

ExampleViewController.m:

#import "ExampleViewController.h"
#import <AppLovinSDK/AppLovinSDK.h>

@interface ExampleViewController()<MAAdDelegate>
@property (nonatomic, strong) MAInterstitialAd *interstitialAd;
@property (nonatomic, assign) NSInteger retryAttempt;
@end

@implementation ExampleViewController

- (void)createInterstitialAd
{
    self.interstitialAd = [[MAInterstitialAd alloc] initWithAdUnitIdentifier: @"YOUR_AD_UNIT_ID"];
    self.interstitialAd.delegate = self;

    // Load the first ad
    [self.interstitialAd loadAd];
}

#pragma mark - MAAdDelegate Protocol

- (void)didLoadAd:(MAAd *)ad
{
    // Interstitial ad is ready to be shown. '[self.interstitialAd isReady]' will now return 'YES'

    // Reset retry attempt
    self.retryAttempt = 0;
    // ⚠️ Add this line
    [self.interstitialAd showAd];
}

- (void)didFailToLoadAdForAdUnitIdentifier:(NSString *)adUnitIdentifier withError:(MAError *)error
{
    // Interstitial ad failed to load
    // We recommend retrying with exponentially higher delays up to a maximum delay (in this case 64 seconds)
    
    self.retryAttempt++;
    NSInteger delaySec = pow(2, MIN(6, self.retryAttempt));
    
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySec * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
        [self.interstitialAd loadAd];
    });
}

- (void)didDisplayAd:(MAAd *)ad {}

- (void)didClickAd:(MAAd *)ad {}

- (void)didHideAd:(MAAd *)ad
{
    // Interstitial ad is hidden. Pre-load the next ad
    // ⚠️ Comment this line
    // [self.interstitialAd loadAd];
}

- (void)didFailToDisplayAd:(MAAd *)ad withError:(MAError *)error
{
    // Interstitial ad failed to display. We recommend loading the next ad
    [self.interstitialAd loadAd];
}

@end
Then expose an objective-C function to create that new ViewController:

bindings.h:

#import <UIKit/UIKit.h>

void main_rs(void);

void display_ad(UIWindow* window, UIViewController* viewController);

main.m:

#import <stdio.h>

#import "bindings.h"
#import "ExampleViewController.h"

int main() {
    NSLog(@"AppLovinSdkKey:");
    [ALSdk shared].mediationProvider = @"max";

        [ALSdk shared].userIdentifier = @"USER_ID";

        [[ALSdk shared] initializeSdkWithCompletionHandler:^(ALSdkConfiguration *configuration) {
            // Start loading ads
            NSLog(@"initialization complete");
        }];
    main_rs();
    return 0;
}

ExampleViewController* shownAd = nil;
UIViewController* originalViewController = nil;


void display_ad(UIWindow* window, UIViewController* viewController) {
    originalViewController = viewController;
    ExampleViewController *adVC = [[ExampleViewController alloc] init];
    window.rootViewController = adVC;
    [adVC createInterstitialAd];
}

void close_ad() {
    if (shownAd == nil) {
        NSLog( @"Ad not showing.");
        return;
    }
    UIWindow* currentWindow = shownAd.view.window;
    currentWindow.rootViewController = originalViewController;
    shownAd = nil;
}

We also need to be able to call Objective-C from Rust, this topic is well covered in the rust book:

extern "C" {
    pub fn display_ad(ui_window: *mut c_void, ui_view_controller: *mut c_void);
}

To get the raw window handle, it's less obvious, we need to add a new dependency: raw-window-handle.

It's not exactly a new dependency, it's used by winit, but accessing the raw-window-handle is considered an unstable implementation detail. Make sure you use their same version: cargo tree -i raw-window-handle is your friend.

// ⚠️ to retrieve `.raw_window_handle()` and it's `ui_window`
use raw_window_handle::{HasRawWindowHandle, RawWindowHandle};

fn bevy_display_ad(windows: NonSend<WinitWindows>, window_query: Query<Entity, With<PrimaryWindow>>) {
    let entity = window_query.single();
    let raw_window = windows.get_window(entity).unwrap();
    match raw_window.raw_window_handle() {
        RawWindowHandle::UiKit(ios_handle) => {
            let old_view_controller = ios_handle.ui_view_controller;
            let ui_window: *mut c_void = ios_handle.ui_window;
            info!("UIWindow to be passed to bridge {:?}", ui_window);
            let result = panic::catch_unwind(|| unsafe {
                // ⚠️ calling into Objective-C!
                display_ad(ui_window, old_view_controller);
            });
            match result {
                Ok(_) => {
                    info!("Ad added in Bevy UIWindow successfully");
                }
                Err(_) => info!("Panic trying to add Ad in UIWindow"),
            }
        }
        _ => info!("Unsupported window."),
    }
}

If we run now, we should find some logs complaining about a missing Applovin SDK Key.

We can consider that key a secret, a strategy is to load it from an environment variable, alongside RUST_LOG.

How to input an environment variable to Xcode ?

I don't know, you tell me! (for real!)

But let's find something else, we can leverage build configuration files which we will keep secret.

Optionally, we can add them to the gitignore, and also generate them at compile time with our environment information.

We'll want to have a way to make sure our information is correctly set:

NSLog([[NSBundle mainBundle] objectForInfoDictionaryKey:@"AppLovinSdkKey"]);

Think of the user!

'When it takes forever for the ads to load.', a cartoon doodle depicting a guy holding his head, with fire around.

Loading the ad when we want to display it is not optimal for user experience: it infers a loading time. We want to load it beforehand to display it instantly when needed.

We'll refactor our code to have 2 functions:

  • init(): handling sdk initialization + starting automatic loading of ads.
  • display_ad() when we need to display an ad.
extern "C" {
    pub fn init_ads(ui_window: *mut c_void, ui_view_controller: *mut c_void);
    pub fn display_ad_objc();
}

Our ExampleViewController has a major problem: inheriting from UIVIewController ties us to logic not relevant to our use case.

We'll rename it and inherit from the simpler NSObject.

// ⚠️ From UIViewController to NSObject
@interface AdApplovinController : NSObject<MAAdDelegate>
{
    // 🤐 other unchanged code
    // ...
    
    - (void)showAd;
}
void init_ads() {
    adController = [[AdApplovinController alloc] init];
    [adController createInterstitialAd];
    [ALSdk shared].mediationProvider = @"max";

    //[ALSdk shared].userIdentifier = @"USER_ID";
    [ALSdk shared].settings.verboseLoggingEnabled = YES;

    [ALSdk shared].settings.consentFlowSettings.enabled = YES;
    // ⚠️ We want our website there.
    [ALSdk shared].settings.consentFlowSettings.privacyPolicyURL = [NSURL URLWithString: @"https://example.com"];

    [[ALSdk shared] initializeSdkWithCompletionHandler:^(ALSdkConfiguration *configuration) {
        // Start loading ads
        NSLog(@"initialization complete");
    }];
}

void display_ad_objc() {
    [adController showAd];
}

Consent.

Ads are a complicated topic, from espionnage to kids' safety, a handful of constraints are to be respected.

Tracking

Apple uses IDFA (IDentifier For Advertisers, or advertising identifier), which you have to ask explicitly the user to get access to: Did you fill the NSUserTrackingUsageDescription bundle property already?

Applovin provides a built-in term flow, which is helpful to quickly get a working implementation. That's what we used with [ALSdk shared].settings.consentFlowSettings.enabled = YES;

To optimize our user retention, we want to make the best onboarding experience: welcoming them with a bland native popup talking about tracking isn't ideal, and Applovin was discussing removing this built-in consent. We're keeping it for now, but we'll discuss later™ how to customize this flow (init the ads after the onboarding phase, show custom consent pages)

  • call Rust from objective-C
Main thread

We might have some errors related to main threads, we can dispatch messages to main thread in objective-C.

    dispatch_async(dispatch_get_main_queue(), ^{
        // Your code.
    });
  • App Tracking Transparency
  • next steps: ergonomics
    • be able to send a callback so we’re informed when the ad is over
    • error handling
    • no unsafe : new lib using ffi, not exposing "difficult" ffi concepts
  • Next steps: cross platform
    • cfg platform