ユーザー獲得広告を初めて回したとき、私が一番面食らったのは「インストールは増えているのに、どの出稿が黒字なのか一切分からない」という状態でした。iOS では ATT(App Tracking Transparency)の許可を取らない限り、広告クリックと個別ユーザーを結びつける IDFA が使えません。Google や AdMob のレポートには「インストール数」は出ても、「そのインストールが課金につながったか」が出てこないのです。
この穴を埋めるのが SKAdNetwork(SKAN)です。ただし SKAN が返してくるのは、ユーザー単位ではなく「キャンペーン × コンバージョン値」という匿名で集約された数字だけです。そして、そのコンバージョン値(0〜63 の整数)に何を意味させるかは、開発者である私たち自身が設計しなければなりません。ここを空欄のまま広告を回している個人開発者が、本当に多いと感じています。
私は壁紙・癒し系のアプリを App Store と Google Play で長く運用してきましたが、SKAN のコンバージョン値設計を入れる前と後では、広告予算の振り分け精度がまったく変わりました。ここではその設計を、Rork(Expo / React Native)で作ったアプリにそのまま組み込める形で残しておきます。
コンバージョン値は「6ビットの予算」だと考える
SKAN のコンバージョン値は 0 から 63、つまり 6 ビットしかありません。この狭い帯域に「アプリ内で起きた価値あるイベント」を詰め込む必要があります。よくある失敗は、思いつくイベントを全部別の値に割り当てて、64 種類すぐに使い切ってしまうことです。
私が採用しているのは、6 ビットを役割ごとに区切る方法です。
- 上位2ビット(値 ×16): 収益ステージ(無課金 / 試用 / 初回課金 / 継続課金)
- 中位2ビット(値 ×4): エンゲージメント深度(起動のみ / 主要機能到達 / 2日目復帰 / 通知許可)
- 下位2ビット: 流入の質を測る補助フラグ
この区切りにすると、SKAN ダッシュボードに並ぶ数字を見ただけで「この出稿は試用までは来るが課金しない層を連れてくる」といった解釈ができるようになります。値そのものに意味の階層が埋め込まれているからです。
具体的なエンコード表は、運用するアプリの収益モデルに合わせます。サブスク型のアプリでは、私は次のような割り当てにしています。
// conversionValue.ts — 収益ステージを6ビットにエンコードする
export type RevenueStage =
| "install" // インストール直後
| "activated" // 主要機能に到達
| "trial_started" // 無料トライアル開始
| "subscribed" // 有料課金が確定
| "retained_d2"; // 2日目に復帰
// 上位ビット = 収益ステージ(最重要シグナル)
const STAGE_BITS: Record<RevenueStage, number> = {
install: 0b000000, // 0
activated: 0b000100, // 4
trial_started: 0b010000, // 16
subscribed: 0b110000, // 48
retained_d2: 0b001000, // 8(activated と OR して使う)
};
// 課金額の概算レンジを下位ビットに添える(任意)
function revenueBucket(yen: number): number {
if (yen >= 3000) return 0b11;
if (yen >= 1000) return 0b10;
if (yen > 0) return 0b01;
return 0b00;
}
export function buildConversionValue(
stage: RevenueStage,
yen = 0,
): number {
const cv = STAGE_BITS[stage] | revenueBucket(yen);
// 0〜63 にクランプ(万一のビット溢れ対策)
return Math.min(63, Math.max(0, cv));
}ここで大切なのは、コンバージョン値は「上書きできるが、原則として上げる方向にしか更新できない」というルールがある点です。一度 48(課金)を送った後に 4(起動のみ)へ下げることはできません。ですから、最も収益価値の高いステージほど大きい数字を割り当てるのが鉄則になります。私は最初これを逆に設計してしまい、課金したユーザーの値が初日の起動イベントで上書きされて消える、という痛い目を見ました。
Expo アプリからネイティブの postback を呼ぶ
React Native / Expo には SKAN を直接叩く標準 API がありません。updatePostbackConversionValue(iOS 16.1 以降の SKAN 4.0 API)は Objective-C / Swift 側にあります。Rork が生成するのは JS 層のコードなので、ここはネイティブモジュールの薄い橋渡しを自分で足す必要があります。
Expo の Config Plugin と小さな Swift ファイルで実装できます。
// SkanModule.swift — SKAN 4.0 のコンバージョン値を更新する最小モジュール
import StoreKit
import ExpoModulesCore
public class SkanModule: Module {
public func definition() -> ModuleDefinition {
Name("Skan")
AsyncFunction("updateConversionValue") {
(value: Int, coarse: String, lockWindow: Bool, promise: Promise) in
if #available(iOS 16.1, *) {
let coarseValue: SKAdNetwork.CoarseConversionValue =
coarse == "high" ? .high : coarse == "medium" ? .medium : .low
SKAdNetwork.updatePostbackConversionValue(
value,
coarseValue: coarseValue,
lockWindow: lockWindow
) { error in
if let error = error {
promise.reject("SKAN_ERR", error.localizedDescription)
} else {
promise.resolve(nil)
}
}
} else {
// iOS 16.0 以前は fine value のみの旧 API
SKAdNetwork.updateConversionValue(value)
promise.resolve(nil)
}
}
}
}JS 側からはこう呼びます。課金確定のタイミングで lockWindow: true を渡すと、計測ウィンドウを早めに確定させてポストバックを早く受け取れます。
import { requireNativeModule } from "expo-modules-core";
import { buildConversionValue } from "./conversionValue";
const Skan = requireNativeModule("Skan");
export async function reportSubscribed(yen: number) {
const cv = buildConversionValue("subscribed", yen);
// 課金は最終ステージなのでウィンドウをロックして即時集計に回す
await Skan.updateConversionValue(cv, "high", true);
}
export async function reportActivated() {
const cv = buildConversionValue("activated");
await Skan.updateConversionValue(cv, "medium", false);
}coarseValue(low / medium / high)は、インストール数が少なくて Apple のプライバシー閾値を超えられないキャンペーンでも返ってくる粗い指標です。個人開発の小規模な出稿では fine value(0〜63)がプライバシー保護で丸められて NULL になることが頻繁にあるので、coarse 側にも必ず意味を持たせておくのが実用上の保険になります。