Metrike / Ads SDK / iOS

Metrike Ads SDK — iOS

Guia de integração para publishers. Cobre o framework único MetrikeAds, os seis formatos de anúncio, o lado do publisher e do advertiser da conversão in-app, e as limitações declaradas do V1.

Status: V1 demo-grade com API production-shaped (nomes finais, interop Objective-C, crash-safety, semver). Ver §11 do design spec (docs/superpowers/specs/2026-09-02-ios-sdk-design.md) para o corte completo V1 × V1.1. Este SDK é o porte iOS do SDK Android (item 9, shipped 2026-09-01) — o mesmo contrato de servidor (identidade, beacons, VAST/VMAP, mk_cid), reescrito a partir do protocolo, não traduzido linha a linha do Kotlin.


O zip acima é o artefato binário publicado no cdx — na prática você não baixa este arquivo manualmente: o SPM resolve e faz o download sozinho a partir da URL declarada no Package.swift do repositório público (§1). O link existe para quem precisa auditar o binário ou montar uma integração manual fora do SPM.

Código-fonte de exemplo:

Diferente do Android, não existe um APK/build assinado para instalar direto no aparelho — não há download público de app fora da App Store/TestFlight nesse ecossistema. O deliverable de "app de exemplo" aqui é o próprio código-fonte: clone o repositório acima e rode Example/ no Xcode (inclusive no Simulator) — ver §11.


1. Instalação

Distribuído via Swift Package Manager (SPM) — nada de CocoaPods/Carthage no V1. Repo público github.com/metrikehub/metrike-ads-ios — contém só Package.swift, o app de exemplo e o README. O framework em si é fonte fechado (decisão de negócio: não expor os algoritmos de medição): o Package.swift declara um binaryTarget que resolve o .xcframework zipado publicado no cdx, com checksum.

No Xcode: File → Add Package Dependencies → cole a URL do repo. Uma linha, nenhum arquivo pra editar:

https://github.com/metrikehub/metrike-ads-ios

O Package.swift do lado do repositório público (para quem prefere declarar a dependência via manifest, ex. em CI):

// Package.swift do app hospedeiro
dependencies: [
    .package(url: "https://github.com/metrikehub/metrike-ads-ios", from: "0.1.1")
]
// target do app hospedeiro
.target(
    name: "MeuApp",
    dependencies: [
        .product(name: "MetrikeAds", package: "metrike-ads-ios")
    ]
)

E o que esse pacote público resolve por baixo (não precisa copiar isto — é só para entender o mecanismo): um binaryTarget apontando para o zip do cdx, com checksum publicado a cada release:

// Package.swift do repositório público (referência, não edite)
.binaryTarget(
    name: "MetrikeAds",
    url: "https://cdx.metrike.com.br/sdk/ios/MetrikeAds-0.1.1.zip",
    checksum: "<checksum publicado no release>"
)

2. Inicialização e identidade automática

// Swift — identidade automática (recomendado)
Metrike.initialize()

// Swift — com override do nome do app
let config = MetrikeConfig()
config.appName = "MeuApp"
Metrike.initialize(config: config)
// Objective-C — ATENÇÃO: use mk_initialize, não initialize. Todo NSObject já
// responde a +initialize (hook do runtime Objective-C, chamado
// automaticamente na primeira vez que a classe recebe qualquer mensagem) --
// [Metrike initialize] COMPILA (é herdado de NSObject) mas não roda uma
// linha sequer do SDK. Por isso Metrike.swift renomeia esse método
// especificamente para o seletor mk_initialize (@objc(mk_initialize)) do
// lado Swift — é a chamada errada mais fácil de copiar sem perceber.
[Metrike mk_initialize];

// com override — MetrikeConfig não tem inicializador nomeado (é um
// NSObject com propriedades @objc mutáveis, não um struct/data class com
// argumentos default) — configure propriedade por propriedade. Este
// overload recebe um argumento, então o seletor já é distinto de
// +initialize e não precisa de nenhum rename.
MetrikeConfig *config = [MetrikeConfig new];
config.appName = @"MeuApp";
[Metrike initializeConfig:config];

Chame uma vez, cedo — application(_:didFinishLaunchingWithOptions:) no AppDelegate é o lugar recomendado (é onde o app de exemplo faz).

Do lado Objective-C, o seletor é mk_initialize, não initialize — ver o comentário no snippet acima. [Metrike initialize] compila silenciosamente sem inicializar nada (colisão com o hook +initialize do próprio NSObject); toda chamada subsequente do SDK vira no-op porque isInitialized() nunca fica true.

O que é coletado automaticamente (via Bundle.main, sem nenhuma declaração manual):

| Campo | Origem | |---|---| | app_bundle | Bundle.main.bundleIdentifier — autoritativo | | app_name | CFBundleDisplayName/CFBundleName (ou o appName do MetrikeConfig, se informado) | | app_version | CFBundleShortVersionString | | sdk_version | versão do SDK (metrike-ios/x.y.z) | | device_type | phone / tablet, via UIDevice.current.userInterfaceIdiom | | visitor_id | UUID gerado pelo SDK, persistido em UserDefaults — equivalente do cookie _mk_vid da tag web. Não existe com Metrike.setTrackingEnabled(false) (§5) | | session_id | UUID novo por processo |

Esses quatro campos de identidade (app_bundle/app_name/app_version/ sdk_version) acompanham toda requisição — serve, beacons, tracker VAST, clique e conversão passam pelo mesmo seam de identidade (UrlBuilder), sem exceção.

O que NÃO é coletado:

Metrike.isInitialized() reporta se initialize() já rodou. Toda API pública é uma borda defensiva (guard/optionals) — Swift não tem um catch-all de exceção como a JVM, então a postura aqui é código defensivo sistemático nas bordas em vez de um wrapper mágico: uma falha interna nunca derruba o app hospedeiro, na pior hipótese uma chamada vira no-op.

visitor_id é persistido em UserDefaults (não Keychain) — reinstalou, novo visitante, igual Android/web. Sobreviver a reinstall seria mais rastreio, não menos; foi uma escolha deliberada.


3. Formatos de anúncio

| Formato | Classe | Endpoint | Base | |---|---|---|---| | Banner inline | MetrikeBannerView | /serve | UIView + WKWebView interno | | Interstitial fullscreen | MetrikeInterstitial | /serve | UIViewController fullscreen próprio | | Vídeo fullscreen (pre/mid/post-roll manual) | MetrikeVastVideo | /vast/{token} | AVPlayer em view controller do SDK | | Vídeo outstream inline | MetrikeVideoAdView | /vast/{token} | AVPlayer inline | | Instream automático (estilo IMA) | MetrikeAdsLoader | /vmap/{token} ou /vast/{token} | scheduler próprio sobre o AVPlayer do publisher | | Player próprio do publisher | Metrike.buildVastUrl(token) | /vast/{token} | — |

Contrato do delegate — weak var delegate, sempre retenha. Igual ao WeakReference que o Android usa pro mesmo fim, cada delegate deste SDK é weak do lado do SDK (evita um ciclo forte SDK↔host). Se nada além dessa propriedade weak segurar o objeto delegate, o ARC libera ele assim que a função que o criou retorna — e nenhum callback chega a disparar, porque a chamada de rede que dispararia onReady/onLoad/etc. ainda está em voo. Os exemplos abaixo usam dois padrões, ambos corretos:

  • Banner e interstitial (um formato só por tela, protocolo próprio): o UIViewController conforma ao protocolo direto e passa self — o VC já é mantido vivo pela apresentação da tela, nada extra a reter.
  • Os três formatos de vídeo (MetrikeVastVideo/MetrikeVideoAdView/ MetrikeAdsLoader) compartilham o mesmo protocolo MetrikeVideoDelegate — uma tela real costuma ter mais de uma instância ao mesmo tempo (vários outstreams num feed, por exemplo), e uma única conformance no VC não distinguiria qual anúncio disparou qual callback. Para esses três, use um objeto delegate dedicado por instância, guardado como propriedade (nunca uma let/var local dentro da função) — o mesmo padrão que o app de exemplo (Example/) usa de verdade.

3.1 Banner (MetrikeBannerView)

// Swift — o VC conforma ao delegate direto e passa self (formato único por
// tela — ver nota acima).
final class ArticleViewController: UIViewController, MetrikeBannerViewDelegate {
    private var banner: MetrikeBannerView? // referência forte -- também facilita destroy() depois

    override func viewDidLoad() {
        super.viewDidLoad()
        let banner = MetrikeBannerView(frame: CGRect(x: 0, y: 0, width: 320, height: 50))
        banner.zoneId = 12345
        banner.contentUrl = "https://meusite.com.br/noticia/123" // opcional — ver §4
        banner.delegate = self
        view.addSubview(banner)
        self.banner = banner
        banner.load()
    }

    func onLoad() { /* renderizado */ }
    func onNoFill() { /* a view já colapsa sozinha */ }
    func onClick() { /* SFSafariViewController já foi aberto */ }
    func onClosed() { }
    func onError(_ code: Int, _ message: String) { /* log/telemetria */ }
    func onViewable(_ timeToViewableMs: Int64) { /* beacon nativo já disparado */ }
}
// Objective-C — mesma ideia: o próprio ViewController conforma ao protocolo.
@interface ArticleViewController () <MetrikeBannerViewDelegate>
@property (nonatomic, strong) MetrikeBannerView *banner;
@end

@implementation ArticleViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    MetrikeBannerView *banner = [[MetrikeBannerView alloc] initWithFrame:CGRectMake(0, 0, 320, 50)];
    banner.zoneId = 12345;
    banner.contentUrl = @"https://meusite.com.br/noticia/123";
    banner.delegate = self;
    [self.view addSubview:banner];
    self.banner = banner;
    [banner load];
}

- (void)onLoad { }
- (void)onNoFill { }
- (void)onClick { }
- (void)onClosed { }
- (void)onError:(NSInteger)code message:(NSString *)message { }
- (void)onViewable:(int64_t)timeToViewableMs { }

@end

Só onError é obrigatório no delegate — os demais métodos são @objc optional, então implemente só o que interessa (diferente do Android, onde os listeners Kotlin exigem todos os métodos em Java por causa de como o compilador Kotlin gera bytecode — ver o aviso equivalente no doc Android; essa armadilha específica não existe aqui).

O creative é desenhado num WKWebView interno (image vira <img>, htmlzip/ext_tag carregam o HTML), mas medição e beacons são 100% nativos — viewability, impressão e clique não dependem de nenhum IntersectionObserver rodando dentro do WebView. Remover a view da hierarquia (removeFromSuperview) derruba o creative atual por completo; para reaparecer, chame load() de novo. Cobrir a tela temporariamente com outra tela (um MetrikeInterstitial/MetrikeVastVideo do próprio SDK, ou navegação comum do host) não derruba nada — a view continua intacta e volta a aparecer sozinha.

MRAID: o mesmo metrike-mraid.js do Android é reaproveitado (é JS puro) — só o lado nativo da bridge (WKScriptMessageHandler/WKUserScript) é próprio do iOS. Mesmo perfil core, mesmas exclusões (mraid.getLocation fora — §10). Nenhuma API pública adicional: se a zona tiver um creative MRAID configurado, o SDK já injeta e faz o host da bridge sozinho dentro do WKWebView interno.

ext_tag: o servidor já pré-substitui qualquer macro de clique no HTML antes de enviá-lo — o SDK apenas injeta o HTML no WKWebView tal como veio.

3.2 Interstitial (MetrikeInterstitial)

// Swift — mesma ideia do banner: o VC conforma direto (protocolo próprio,
// não compartilhado com outro formato).
final class ArticleViewController: UIViewController, MetrikeInterstitialDelegate {
    private var interstitial: MetrikeInterstitial? // referência forte -- preload() é assíncrono

    func loadInterstitial() {
        let ad = MetrikeInterstitial(zoneId: 12345)
        ad.closeDelaySeconds = 5 // default
        ad.delegate = self
        interstitial = ad
        ad.preload()
    }

    func onReady() {
        interstitial?.show(from: self) // no-op se já não estiver mais pronto
    }
    func onClosed() { /* usuário fechou ou voltou */ }
    func onNoFill() { }
    func onError(_ code: Int, _ message: String) { }
    func onClick() { }
}
// Objective-C
@interface ArticleViewController () <MetrikeInterstitialDelegate>
@property (nonatomic, strong) MetrikeInterstitial *interstitial;
@end

@implementation ArticleViewController

- (void)loadInterstitial {
    MetrikeInterstitial *ad = [[MetrikeInterstitial alloc] initWithZoneId:12345];
    ad.closeDelaySeconds = 5;
    ad.delegate = self;
    self.interstitial = ad; // referência forte -- preload() é assíncrono
    [ad preload];
}

- (void)onReady { [self.interstitial showFrom:self]; }
- (void)onClosed { }
- (void)onNoFill { }
- (void)onError:(NSInteger)code message:(NSString *)message { }
- (void)onClick { }

@end

preload()/show(from:) seguem o mesmo modelo pre-fetch do AdButler: chamar show(from:) antes de onReady (ou depois de já ter sido mostrado) é um no-op silencioso reportado via onError — não há um Bool de retorno como o isReady() do Android, onError é o único canal de feedback para essa borda. Chame preload() de novo antes de tentar mostrar outra vez. O botão de fechar (X) fica oculto até o delay configurado se esgotar (closeDelaySeconds, default 5) — nunca apenas desabilitado, sempre removido do hit-testing até então.

Nota: ao contrário do banner, MetrikeInterstitial não aceita contentUrl nesta versão — a requisição /serve do interstitial não leva esse parâmetro. Use um banner (ou o classificador app-level do §4) se precisar de classificação por conteúdo em telas cujo único formato é interstitial.

3.3 Vídeo fullscreen manual — pre/mid/post-roll (MetrikeVastVideo)

Modelo AdButler: preload() → onReady() → display(from:). O SDK não decide quando mostrar o anúncio — quem decide o timing (pré-roll, meio do conteúdo, pós-roll) é o publisher, chamando display(from:) no momento certo.

// Swift — mid-roll pausando o próprio player de conteúdo do publisher.
// MetrikeVideoDelegate é compartilhado pelos 3 formatos de vídeo (§3.3-3.5)
// -- um objeto delegate dedicado por instância, guardado como PROPRIEDADE
// (nunca local), é o padrão usado aqui e no app de exemplo (ver nota no
// início de §3).
final class VastDelegate: NSObject, MetrikeVideoDelegate {
    weak var presenter: UIViewController?
    let vast: MetrikeVastVideo
    let contentPlayer: AVPlayer
    init(vast: MetrikeVastVideo, contentPlayer: AVPlayer, presenter: UIViewController) {
        self.vast = vast
        self.contentPlayer = contentPlayer
        self.presenter = presenter
    }
    func onReady() {
        contentPlayer.pause() // pausa o conteúdo ANTES de mostrar o ad
        guard let presenter else { return }
        vast.display(from: presenter)
    }
    func onComplete() { contentPlayer.play() } // retoma
    func onClose() { contentPlayer.play() } // fechou antes do fim — retoma também
    func onError(_ code: Int, _ message: String) { contentPlayer.play() }
    func onStart() { }
    func onQuartile(_ name: String) { } // "firstQuartile" / "midpoint" / "thirdQuartile"
    func onSkip() { }
    func onClick() { }
}

final class ArticleViewController: UIViewController {
    private var vastVideo: MetrikeVastVideo?
    private var vastDelegate: VastDelegate? // propriedade -- vast.delegate é weak

    func loadMidRollAd() {
        let vast = MetrikeVastVideo(vastToken: "abc123")
        let delegate = VastDelegate(vast: vast, contentPlayer: myContentPlayer, presenter: self)
        vast.delegate = delegate
        vastVideo = vast
        vastDelegate = delegate
        // Chame preload() com antecedência (ex.: ao chegar perto do ponto de meio de conteúdo)
        vast.preload()
    }
}
// Objective-C
@interface VastDelegate : NSObject <MetrikeVideoDelegate>
@property (nonatomic, weak) UIViewController *presenter;
@property (nonatomic, strong) MetrikeVastVideo *vast;
@property (nonatomic, strong) AVPlayer *contentPlayer;
@end

@implementation VastDelegate
- (void)onReady {
    [self.contentPlayer pause];
    [self.vast displayFrom:self.presenter];
}
- (void)onComplete { [self.contentPlayer play]; }
- (void)onClose { [self.contentPlayer play]; }
- (void)onError:(NSInteger)code message:(NSString *)message { [self.contentPlayer play]; }
- (void)onStart { }
- (void)onQuartile:(NSString *)name { }
- (void)onSkip { }
- (void)onClick { }
@end

@interface ArticleViewController ()
@property (nonatomic, strong) MetrikeVastVideo *vastVideo;
@property (nonatomic, strong) VastDelegate *vastDelegate; // propriedade -- vast.delegate é weak
@end

@implementation ArticleViewController

- (void)loadMidRollAd {
    MetrikeVastVideo *vast = [[MetrikeVastVideo alloc] initWithVastToken:@"abc123"];
    VastDelegate *delegate = [VastDelegate new];
    delegate.vast = vast;
    delegate.contentPlayer = self.myContentPlayer;
    delegate.presenter = self;
    vast.delegate = delegate;
    self.vastVideo = vast;
    self.vastDelegate = delegate;
    [vast preload];
}

@end

Pre-roll: chame preload()/display(from:) antes de iniciar o conteúdo. Post-roll: chame no evento de fim de conteúdo, e não retome nada em onComplete()/onClose().

Comportamentos do player fullscreen que você não precisa (nem consegue) configurar: um countdown "Anúncio · m:ss" e uma barra de progresso ficam sempre visíveis; um clique no "Saiba mais" abre o destino num SFSafariViewController pausando o anúncio, que retoma sozinho quando o usuário volta (equivalente exato do Custom Tabs do Android — sem risco de ad "congelado").

3.4 Vídeo outstream inline (MetrikeVideoAdView)

Sem view controller próprio — uma UIView que você coloca direto no seu layout (ex.: uma linha de feed). Autoplay mudo quando ≥50% visível continuamente, pausa ao sair de 50%; som ativa no toque do próprio usuário no ícone. Ao terminar, a view colapsa sozinha — não há botão de fechar nem skip (a saída padrão de mercado do outstream é o próprio scroll levando o anúncio pra fora de tela).

// Swift — mesmo padrão de §3.3: delegate dedicado, guardado como
// propriedade. Um feed tipicamente tem VÁRIOS outstreams simultâneos, então
// o VC não pode conformar direto a MetrikeVideoDelegate (uma única
// conformance não distingue qual instância disparou qual callback).
final class OutstreamDelegate: NSObject, MetrikeVideoDelegate {
    func onReady() { }
    func onError(_ code: Int, _ message: String) { }
    // demais callbacks (onStart, onQuartile, onComplete, onClick...) — mesmo
    // protocol MetrikeVideoDelegate do fullscreen; todos @objc optional
    // menos onError
}

final class FeedCell: UICollectionViewCell {
    private var outstreamAd: MetrikeVideoAdView?
    private var outstreamDelegate: OutstreamDelegate? // propriedade -- delegate é weak

    func loadOutstream(vastToken: String) {
        let outstream = MetrikeVideoAdView(frame: contentView.bounds)
        outstream.vastToken = vastToken
        let delegate = OutstreamDelegate()
        outstream.delegate = delegate
        contentView.addSubview(outstream)
        outstreamAd = outstream
        outstreamDelegate = delegate
        outstream.load()
    }
}
// Objective-C
@interface OutstreamDelegate : NSObject <MetrikeVideoDelegate>
@end

@implementation OutstreamDelegate
- (void)onReady { }
- (void)onError:(NSInteger)code message:(NSString *)message { }
@end

@interface FeedCell : UICollectionViewCell
@property (nonatomic, strong) MetrikeVideoAdView *outstreamAd;
@property (nonatomic, strong) OutstreamDelegate *outstreamDelegate; // propriedade -- delegate é weak
@end

@implementation FeedCell

- (void)loadOutstream:(NSString *)vastToken {
    MetrikeVideoAdView *outstream = [[MetrikeVideoAdView alloc] initWithFrame:self.contentView.bounds];
    outstream.vastToken = vastToken;
    OutstreamDelegate *delegate = [OutstreamDelegate new];
    outstream.delegate = delegate;
    [self.contentView addSubview:outstream];
    self.outstreamAd = outstream;
    self.outstreamDelegate = delegate;
    [outstream load];
}

@end

Chame destroy() (ou deixe a view sair da hierarquia via removeFromSuperview) para parar o player e a medição de viewability — a reciclagem de uma UICollectionView/UITableView já dispara isso sozinha. Cobrir a tela temporariamente com outra tela (interstitial, VAST fullscreen do próprio SDK, ou navegação do host) não derruba a view.

3.5 Instream automático estilo IMA (MetrikeAdsLoader)

Requisito obrigatório: o player de conteúdo do publisher precisa ser AVPlayer — é o player de facto do iOS, então na prática isso cobre quase todo mundo (análogo ao "Media3-only" do Android). Não há no AVFoundation um plug-point equivalente ao AdsLoader do Media3 que insira anúncios diretamente na timeline do player — este formato é um scheduler próprio, mesmo modelo IMA-like: pausa o conteúdo no break, mostra o ad num AVPlayer separado sobre um container que você fornece, e retoma o conteúdo ao fim — inclusive se o ad falhar (resiliência é o objetivo central desta classe).

// Swift — mesmo padrão de §3.3/3.4: delegate dedicado, guardado como
// propriedade (loader.delegate é weak).
final class InstreamDelegate: NSObject, MetrikeVideoDelegate {
    func onStart() { /* um break começou -- torne o overlay interativo se precisar */ }
    func onComplete() { }
    func onSkip() { }
    func onClick() { }
    func onError(_ code: Int, _ message: String) { /* break abandonado -- conteúdo já retomou sozinho */ }
}

final class InstreamViewController: UIViewController {
    private let contentPlayer = AVPlayer(url: contentVideoUrl)
    private var adsLoader: MetrikeAdsLoader?
    private var instreamDelegate: InstreamDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()
        let playerLayer = AVPlayerLayer(player: contentPlayer)
        playerLayer.frame = contentContainerView.bounds
        contentContainerView.layer.addSublayer(playerLayer)

        let loader = MetrikeAdsLoader(vmapToken: "abc123")
        let delegate = InstreamDelegate()
        loader.delegate = delegate

        // overlayContainerView é onde CADA break embute seu próprio AVPlayer +
        // chrome (countdown, progresso, skip/clique) só durante aquele break.
        loader.attach(to: contentPlayer, overlayContainer: overlayContainerView, from: self)
        adsLoader = loader
        instreamDelegate = delegate
        contentPlayer.play()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        adsLoader?.detach()
        contentPlayer.pause()
    }
}
// Objective-C
@interface InstreamDelegate : NSObject <MetrikeVideoDelegate>
@end

@implementation InstreamDelegate
- (void)onStart { }
- (void)onComplete { }
- (void)onSkip { }
- (void)onClick { }
- (void)onError:(NSInteger)code message:(NSString *)message { }
@end

@interface InstreamViewController ()
@property (nonatomic, strong) AVPlayer *contentPlayer;
@property (nonatomic, strong) MetrikeAdsLoader *adsLoader;
@property (nonatomic, strong) InstreamDelegate *instreamDelegate; // propriedade -- loader.delegate é weak
@end

@implementation InstreamViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.contentPlayer = [AVPlayer playerWithURL:contentVideoUrl];
    AVPlayerLayer *playerLayer = [AVPlayerLayer playerLayerWithPlayer:self.contentPlayer];
    playerLayer.frame = self.contentContainerView.bounds;
    [self.contentContainerView.layer addSublayer:playerLayer];

    MetrikeAdsLoader *loader = [[MetrikeAdsLoader alloc] initWithVmapToken:@"abc123"];
    InstreamDelegate *delegate = [InstreamDelegate new];
    loader.delegate = delegate;
    [loader attachTo:self.contentPlayer overlayContainer:self.overlayContainerView from:self];
    self.adsLoader = loader;
    self.instreamDelegate = delegate;
    [self.contentPlayer play];
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    [self.adsLoader detach];
    [self.contentPlayer pause];
}

@end

vmapToken aponta pro /vmap/{token} — o servidor pode responder um <vmap:VMAP> com múltiplos breaks OU, para uma zona sem breaks configurados, um <VAST> único (nesse caso o loader trata como um pre-roll síncrono, sem fazer um segundo fetch). Pós-roll dispara no fim de conteúdo (fim de item do AVPlayer), não por posição. Um break individual que falhar (rede, VAST malformado, sem preenchimento) nunca deixa o conteúdo travado pausado — o conteúdo é resumido automaticamente; se nada em tela avançar por 15s seguidos durante um break (rede caiu no meio, etc.), um watchdog interno força o fim do break e resume o conteúdo de qualquer forma.

Player diferente de AVPlayer (ex.: um player de terceiro embutido via outra lib) não é compatível com este formato — use o modelo fullscreen manual (§3.3) ou buildVastUrl() (§3.6) nesse caso.

3.6 Player próprio (Metrike.buildVastUrl)

Para publishers com player de vídeo próprio que não usam nenhum dos formatos acima:

// Swift
if let vastUrl = Metrike.buildVastUrl("abc123") {
    // vastUrl já vem com app_bundle/app_name/app_version/sdk_version anexados —
    // alimente-o no seu player como faria com qualquer VAST tag.
}
// Objective-C
NSString *vastUrl = [Metrike buildVastUrl:@"abc123"];
if (vastUrl != nil) {
    // ...
}

Trade-off explícito: o SDK não mede nada nesse caminho (sem beacons nativos de viewability/audibilidade) — o ad request deixa de ser anônimo (identidade do app anexada), mas a medição vira responsabilidade do player externo, exatamente como um VAST tag puro contratado de qualquer fornecedor. buildVastUrl retorna nil se chamado antes de Metrike.initialize() ou com um token vazio.


4. contentUrl — classificação por conteúdo

MetrikeBannerView.contentUrl é a URL web equivalente do conteúdo da tela atual (apps de notícias tipicamente têm uma). O servidor trata como page_url — o mesmo classificador de conteúdo que já roda na tag web funciona sem nenhuma mudança, por matéria, igual à experiência web.

Sem contentUrl (ou em telas que só usam interstitial/vídeo, que não carregam esse parâmetro nesta versão), o servidor cai num caminho app-level: resolve a categoria pela iTunes Lookup API pública (itunes.apple.com/lookup?bundleId=..., que devolve primaryGenreName) para o app_bundle do seu app — equivalente iOS do scrape da página pública da Play Store que o Android usa, cacheado por app. Preencha contentUrl sempre que a tela tiver um equivalente web identificável — é estritamente melhor que o fallback app-level.


Consent (IABTCF): o SDK lê IABTCF_gdprApplies e IABTCF_TCString do UserDefaults.standard — é onde todo CMP in-app certificado IAB escreve — e repassa como gdpr/gdpr_consent no /serve, automaticamente, sem nenhuma chamada extra do publisher. Sem CMP presente, o SDK se comporta como a tag web sem TCF (nenhum dos dois parâmetros é enviado).

IDFA: não é tocado. Sem leitura do identificador de publicidade, sem prompt de App Tracking Transparency por causa deste SDK (§8). visitor_id é um UUID próprio persistido em UserDefaults, não um device ID.

Recusa do usuário (opt-out) e o identificador do SDK: o visitor_id é um UUID gerado pelo SDK e guardado só no UserDefaults do seu app — dois apps no mesmo aparelho têm IDs diferentes. Para ligar a escolha de privacidade do seu app ao SDK:

// Swift — pode ser chamado antes ou depois do initialize()
Metrike.setTrackingEnabled(false)   // usuário recusou
Metrike.isTrackingEnabled()         // estado atual
Metrike.visitorId()                 // ID atual, ou nil
// Objective-C
[Metrike setTrackingEnabled:NO];
NSString *visitorId = [Metrike visitorId]; // pode ser nil

Com setTrackingEnabled(false):

A escolha fica gravada e vale nas próximas aberturas do app. Chamada antes do initialize(), ela é aplicada antes de qualquer ID existir: um app que já começa com a recusa nunca gera o UUID. setTrackingEnabled(true) volta a permitir, com um ID novo, sem ligação com o anterior.

O mesmo vale por zona: quando o /serve responde tracking: false (zona que exige consentimento e ele não veio), o SDK não manda o ID nos beacons e no clique daquele anúncio, nem no próximo pedido daquela zona.

As requisições do SDK ao servidor da Metrike não usam o cookie jar compartilhado do URLSession: o ID só viaja quando o SDK o envia explicitamente.

Metrike.visitorId() devolve o ID que o SDK envia, para o app mostrar ao usuário numa tela de privacidade ("meus dados"). É esse ID que o titular informa ao pedir acesso ou exclusão dos dados ligados a ele. Devolve nil antes do initialize() e com o tracking desligado.

App dirigido a crianças ou adolescentes: se o seu app é para esse público (por exemplo, na Kids Category da App Store), declare na inicialização:

// Swift
let config = MetrikeConfig()
config.childDirected = true
Metrike.initialize(config: config)
// Objective-C
MetrikeConfig *config = [MetrikeConfig new];
config.childDirected = YES;
[Metrike initializeConfig:config];

Com childDirected = true, o SDK não gera nem envia o UUID de instalação (mesmo que setTrackingEnabled(true) seja chamado), não lê localização (mesmo com collectLocation = true) e manda child_directed=1 em toda requisição ao servidor da Metrike, que aplica a mesma regra do lado dele. Os anúncios continuam sendo exibidos e contados. Avise também a Metrike para marcar o veículo ou a zona como infantil no painel.

GPS — opt-in, default OFF:

// Swift
let config = MetrikeConfig()
config.collectLocation = true
Metrike.initialize(config: config)
// Objective-C
MetrikeConfig *config = [MetrikeConfig new];
config.collectLocation = YES;
[Metrike initializeConfig:config];

Com collectLocation = true, o SDK lê a última localização conhecida em cache de um CLLocationManager compartilhado — nunca chama requestWhenInUseAuthorization(), nunca chama startUpdatingLocation() — e somente se o app hospedeiro já detém autorização authorizedWhenInUse/authorizedAlways. Sem uma das duas, collectLocation = true é um no-op silencioso.

Finalidade e precisão da localização: a localização serve só para verificar tráfego inválido — comparar a posição do aparelho com a geolocalização pelo IP ajuda a achar emulador, proxy e fraude de origem. Os relatórios de praça continuam vindo do IP. Antes de sair do aparelho, a coordenada é arredondada para 2 casas decimais (cerca de 1,1 km, nível de bairro) e a precisão informada nunca é menor que 1.000 m. A localização só vai em beacon rastreado: com setTrackingEnabled(false), numa zona que respondeu tracking: false ou em app infantil (childDirected), ela não é enviada.

Aviso de App Privacy (App Store Connect): se o publisher ativar collectLocation, a seção "App Privacy" do app na App Store Connect precisa refletir a coleta de localização aproximada (Coarse Location), com finalidade App Functionality (prevenção de fraude). O manifesto do SDK já declara esse tipo (§8); o formulário da App Store Connect continua sendo responsabilidade do publisher.


6. Conversão in-app

Lado do publisher (onde o anúncio é exibido)

Nada a fazer. O clique já abre a URL de /click via SFSafariViewController; o redirect 302 do servidor já anexa mk_cid na própria URL de destino. Não há API adicional do lado de quem exibe o anúncio.

Lado do advertiser (app anunciado no clique)

O app do advertiser também integra MetrikeAds e usa dois pontos de entrada:

1. Capturar o mk_cid — deep link (universal link ou custom scheme):

// Swift — AppDelegate
func application(_ app: UIApplication, open url: URL,
                  options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    return Metrike.handleDeepLink(url) // lê mk_cid da URL, se houver
}

// Universal links chegam via NSUserActivity, não open url: —
// exige Associated Domains (applinks:) + apple-app-site-association
// servido no domínio de destino.
func application(_ application: UIApplication,
                  continue userActivity: NSUserActivity,
                  restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else { return false }
    return Metrike.handleDeepLink(url)
}
// Objective-C
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url
            options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
    return [Metrike handleDeepLink:url];
}

- (BOOL)application:(UIApplication *)application
continueUserActivity:(NSUserActivity *)userActivity
 restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> *))restorationHandler {
    if (![userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]
        || userActivity.webpageURL == nil) {
        return NO;
    }
    return [Metrike handleDeepLink:userActivity.webpageURL];
}

Chame no AppDelegate do app do advertiser. Retorna true se um mk_cid foi encontrado e persistido; um link sem esse parâmetro não apaga um mk_cid já armazenado por outro caminho.

2. Registrar o evento de conversão:

// Swift
Metrike.trackConversion("goal_public_id", valueCents: 4990, orderId: "order-123")
// Objective-C
[Metrike trackConversion:@"goal_public_id" valueCents:@4990 orderId:@"order-123"];

Atribuição de instalação no iOS

Honestidade primeiro: não existe atribuição individual de clique→instalação no iOS, por decisão de plataforma da Apple, não por limitação deste SDK.

O Android tem o Play Install Referrer — um mecanismo do sistema que carrega o mk_cid do clique através da instalação e da primeira abertura do app. A App Store não tem equivalente: nenhum "carteiro" do outro lado. Ao contrário do Google, a Apple deliberadamente não expõe um jeito de ligar "este usuário clicou neste anúncio" a "este usuário instalou este app" — essa é uma política de privacidade da plataforma, não uma lacuna técnica. Como consequência, este SDK não implementa nenhum substituto: sem pasteboard, sem fingerprinting probabilístico, sem SKAdNetwork no V1 — esses caminhos existem no mercado justamente para contornar essa ausência, e foram descartados de propósito (política de privacidade da Apple em torno de fingerprinting, e SKAdNetwork/AdAttributionKit são agregados por design, não individuais — não valeria a complexidade para o V1).

O que funciona integralmente: conversão de usuários que já têm o app instalado — deep link (universal link ou custom scheme) + Metrike.trackConversion(...) cobre esse caminho por completo, exatamente como no Android. A lacuna é especificamente "clique levou a uma instalação nova" — não "clique levou a uma conversão".

Instalação não é evento contável — não existe (e não está planejado) um goal type install, mesmo caminho do Android.

Caminho futuro (V1.1, se algum dia importar): SKAdNetwork/ AdAttributionKit dão atribuição agregada (contagens por campanha, sem identificar o usuário individual) — diferente do que o Install Referrer Android oferece, mas é o único caminho que a Apple permite. Não está no escopo do V1.


7. Métricas

Paridade total com a tag web e com o SDK Android — mesmo pipeline de ingestão, mesmos dashboards, mesmo verification report:

| Evento | Disparado por | |---|---| | request | load() (banner) / preload() (interstitial) — cada fill bem-sucedido de GET /vast/{token} já grava sua própria linha request no servidor, então zonas de vídeo contam requests + uniques igual às demais | | impression | banner/interstitial renderizados; vídeo ao iniciar (pixel <Impression> do VAST) | | viewable / notViewable / viewUndetermined | medição nativa de viewability (abaixo) | | click | toque no creative (abre /click via SFSafariViewController) | | Quartis VAST (start, firstQuartile, midpoint, thirdQuartile, complete) | polling de posição do player, todos os formatos de vídeo | | mute / unmute / skip | interação do usuário no player de vídeo | | Conversão | Metrike.trackConversion (lado advertiser) |

Exclusivas do SDK (não existem no caminho web-tag/in-app-sem-SDK): identidade do app (app_bundle/app_name/app_version), sdk_version (prova de ambiente in_app_mechanism='sdk' — topo da escada de detecção in-app, acima de mraid-top/mraid-window/webview-ua), viewability com oclusão/tela/foreground reais (não estimada por UA), audibilidade real, device_type declarado (não inferido por UA).

Viewability (display — banner/interstitial): regra MRC, ≥50% da área visível por 1s contínuo, medida nativamente por amostragem CADisplayLink (throttled ~250ms): frame convertido pra janela × bounds da tela, isHidden/alpha/hierarquia, janela ativa. viewable_mechanism = 'native-sdk'. Timeout de 30min sem atingir o critério: comportamento silencioso — igual à tag web e ao Android, nenhum beacon é disparado nesse desfecho.

Viewability + audibilidade (vídeo): ≥50% visível por 2s contínuos (MRC vídeo). Audibilidade combina AVPlayer.volume/isMuted × volume do device (AVAudioSession.outputVolume). Quando visível≥50% e audível somam, juntos, pelo menos metade da duração do vídeo, dispara fully_viewable_audible_half_duration_impression.

Duração de referência (quartis + audibilidade): o SDK usa a duração real reportada pelo player assim que conhecida — a <Duration> do XML é só fallback inicial. Igual ao Google IMA e ao Android, imune a metadado errado no ad item.

Mecanismos: todo beacon do SDK carrega in_app_mechanism='sdk' (display/interstitial) — prova positiva de identidade, diferente de mraid-top/mraid-window/webview-ua (heurísticas de UA sem identidade).

Identidade no pipeline de vídeo: os pixels de tracking/impressão/click que voltam dentro do próprio XML VAST (<Impression>, <Tracking event="...">, <ClickTracking>) são GETs simples, sem corpo JSON — o SDK anexa a identidade a cada um deles imediatamente antes de disparar, mas só quando a URL aponta pro nosso próprio servidor (mesmo host do baseUrl configurado). Um break VMAP resolvido pra um ad server de terceiro nunca recebe essa identidade — só os pixels que o /vast//serve da própria Metrike gerou.


8. Privacidade e o PrivacyInfo.xcprivacy

O framework embute um manifesto de privacidade (PrivacyInfo.xcprivacy) declarando o que o SDK coleta — a revisão da App Store rejeita apps cujos SDKs não o declaram, então isso é parte do artefato, não da documentação. Um app hospedeiro que integra MetrikeAds herda essa declaração automaticamente; nada a configurar do lado do publisher. O manifesto declara:

Sem IDFA → sem prompt de ATT. Como o SDK nunca toca o identificador de publicidade, o prompt de App Tracking Transparency nunca aparece por causa deste SDK — uma vantagem competitiva documentável para o publisher.


9. Requisitos


10. Limitações declaradas do V1


11. App de exemplo

Diferente do Android (que distribui um APK assinado), o deliverable de exemplo aqui é o código-fonte — Example/ dentro do repositório público github.com/metrikehub/metrike-ads-ios. Quem integra o SDK iOS é dev iOS com Xcode; "clone e rode" (inclusive no Simulator) é a história do V1. TestFlight (exige conta Apple Developer paga) fica em V1.1.

O que dá pra testar:

Zonas de teste dedicadas (org de teste id 2) — nunca uma zona real: display 13778, vídeo 13779.