私が iOS 14.5 のアップデート後に最初に痛感したのは、「広告を打っても、どのキャンペーンが効いているのか以前のように見えない」という事実でした。前まではざっくり眺めていれば成果が分かっていたのに、ある日を境に管理画面のインストール数が半分になり、CPA が二倍に膨らんだように見えました。実際にはユーザーが減ったわけではなく、IDFA を取得できないことで計測がズレているだけだったのですが、原因に辿り着くまでに数週間を消費しました。
Rork で作ったアプリでも事情はまったく同じで、SDK 統合や SKAdNetwork 4.0 の Conversion Value 設計を疎かにすると、広告予算をいくら積んでも判断材料が手に入りません。ここでは私が個人開発のアプリ複数本で運用してきた中で「これだけはやっておくべき」と感じた構成を、コードと判断軸まで含めてまとめます。読み終えるころには、AppsFlyer / Adjust のどちらを選ぶべきかの基準と、Rork (Expo + React Native) プロジェクトで本番運用に耐える実装が手元に揃っているはずです。
なぜ iOS 14.5 以降のアトリビューションは難しくなったのか
iOS 14.5 で App Tracking Transparency(以下 ATT)が必須化されてから、IDFA を使ったクロスアプリ計測は事実上不可能になりました。代わりに登場したのが Apple 純正の SKAdNetwork(SKAN)で、媒体(広告ネットワーク)が広告を表示し、アプリ側がインストール後の特定イベントを発火すると、Apple のサーバを経由して匿名化された Conversion Value だけが媒体に返ります。
SKAN 4.0 ではこれが大幅に拡張され、fine(0〜63 の数値)と coarse(low / medium / high)の二段階で値を返せるようになり、ポストバックも最大3回(postback windows 0〜2)届くようになりました。LTV の長期的な変化を計測できるようになった一方、設計の複雑さは数倍に増えています。
ここで多くの開発者が陥るのが、「AppsFlyer / Adjust を入れれば自動的に計測できる」という誤解です。実際には Conversion Value のスキーマは自分のアプリに合わせて定義する必要があり、ATT の許諾フローもアプリ側で実装しなければ何も計測されません。SDK は箱を開けただけで動くものではなく、設計と運用が伴って初めて意味を持ちます。
AppsFlyer / Adjust のどちらを選ぶべきか — 実体験に基づく判断軸
私はこれまでに同じアプリを AppsFlyer と Adjust の両方で運用したことがあり、両者には明確な向き不向きがあると感じています。先に結論を書くと、初めて MMP(Mobile Measurement Partner)を導入する個人開発者には AppsFlyer を、エージェンシーや広告代理店経由で大規模出稿する場合は Adjust を推す、というのが私の今の判断です。
AppsFlyer は管理画面のグラフが直感的で、SKAN ポストバックの可視化も分かりやすく、無料枠で始められる点が明確に優しいです。OneLink(同社のディープリンクサービス)との統合もスムーズで、TikTok や Meta の管理画面とも連携が手早く済みます。一方で SDK サイズはやや重く、Hermes 環境で稀にビルド時間が伸びる印象があります。
Adjust はエンタープライズ寄りで、データの精度と監査ログの品質が高いです。広告詐欺(広告クリック詐称)対策が標準で組み込まれており、月間広告予算が数千万円を超える運用には強い味方です。ただし無料プランはなく、料金は従量課金で、月間トラッキングイベント数が増えるとコストが急に膨らむ点は覚悟が必要です。
迷ったら AppsFlyer から始めて、規模に応じて Adjust への移行を検討するのが現実的です。両者の SDK は仕様こそ違いますが、抽象化レイヤーを自分で1枚かませておけば乗り換えはそれほど大変ではありません。ここでは AppsFlyer を主軸に、Adjust 固有の差分も都度補足する形で進めます。
AppsFlyer SDK を Rork(Expo + React Native)に正しく組み込む
Rork は内部的に Expo の Managed Workflow をベースにしているので、ネイティブモジュールを追加する場合は Config Plugin を使います。AppsFlyer は公式に react-native-appsflyer を提供しており、Expo SDK 51 以降であれば Prebuild 経由でほぼコードを書かずに導入できます。
# Step 1: 依存パッケージの追加
npx expo install react-native-appsflyer
# Step 2: app.json に Config Plugin を登録(必須)
# プラグインが Info.plist に SKAdNetworkItems を自動注入する
app.json の plugins には次の形で記述します。isDebug を true にすると AppsFlyer のデバッグログが Xcode コンソールに流れるので、開発中は有効化しておくと配線確認が楽です。
{
"expo" : {
"plugins" : [
[
"react-native-appsflyer" ,
{
"shouldUseStrictMode" : true
}
]
],
"ios" : {
"infoPlist" : {
"NSUserTrackingUsageDescription" : "あなたに合った広告とコンテンツをお届けするために、限定的な情報を計測させてください。設定からいつでも変更できます。"
}
}
}
}
SDK の初期化は、ATT のステータスが確定してから呼び出すのが鉄則です。アプリ起動と同時に初期化してしまうと、IDFA がまだ解放されていない状態で計測が始まり、後から ATT を許可してもデータの先頭部分が欠損したまま固定されてしまいます。
// src/lib/attribution.ts
import appsFlyer from 'react-native-appsflyer' ;
import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, PermissionStatus } from 'expo-tracking-transparency' ;
import { Platform } from 'react-native' ;
const APPSFLYER_DEV_KEY = process.env. EXPO_PUBLIC_APPSFLYER_DEV_KEY ! ;
const APPSFLYER_APP_ID = process.env. EXPO_PUBLIC_APPSFLYER_APP_ID ! ; // App Store の数値ID
export async function initAttribution () : Promise < void > {
// 1. ATT のステータスを最終確定させる(許諾済み/拒否済みなら何もしない)
if (Platform. OS === 'ios' ) {
const current = await getTrackingPermissionsAsync ();
if (current.status === PermissionStatus. UNDETERMINED ) {
// 必要に応じて事前説明 UI を出してから次の関数を呼ぶ
await requestTrackingPermissionsAsync ();
}
}
// 2. ATT 確定後に SDK を初期化(順序が逆だと IDFA を取りこぼす)
return new Promise (( resolve , reject ) => {
appsFlyer. initSdk (
{
devKey: APPSFLYER_DEV_KEY ,
isDebug: __DEV__,
appId: APPSFLYER_APP_ID ,
onInstallConversionDataListener: true , // 初回インストール属性を取得
onDeepLinkListener: true , // OneLink ディープリンクをハンドリング
timeToWaitForATTUserAuthorization: 60 , // ATT 許諾待ち最大秒数
},
( result ) => {
console. log ( '[Attribution] init success' , result);
resolve ();
},
( error ) => {
console. warn ( '[Attribution] init failed' , error);
reject (error);
},
);
});
}
timeToWaitForATTUserAuthorization を 60 秒に設定しているのが重要なポイントです。ATT のシステムダイアログを出してから許諾結果を返すまでに時間がかかる場合があり、SDK 初期化を急ぎすぎると IDFA を空のまま送ってしまいます。十分な余裕を持たせ、許諾後に SDK 側で改めて IDFA を読み直してくれる挙動を活かしましょう。
期待する動作: 初回起動時に ATT ダイアログが表示され、ユーザーが許可した瞬間に AppsFlyer の appsflyer_id と idfa が紐づいた状態で初回インストールイベントが送信されます。Xcode のコンソールに [Attribution] init success が出力されれば配線は OK です。
SKAdNetwork 4.0 の Conversion Value 設計
SKAN 4.0 で最も時間をかけて設計すべきなのが Conversion Value のスキーマです。fine 値は 0〜63、coarse 値は low / medium / high の三段階。一度配信が始まったあとに変えるとデータが分断されるので、最初に「どのイベントを何点に重み付けするか」を綿密に決めましょう。
私は次のような重み付けを基本形として使っています。アプリの種類によって変える必要はありますが、考え方の出発点として参考にしてください。
起動完了: 1 点
オンボーディング完了: 4 点
初回コンテンツ閲覧: 8 点
アカウント作成: 16 点
課金トライアル開始: 32 点
これを fine の 0〜63 にマッピングし、coarse は LTV の上位 30% を high、上位 70% を medium、それ以外を low に振り分けます。coarse は postback window 1(インストール後 3〜7 日)で最も効果を発揮するので、サブスクリプション型のアプリでは特に重要です。
ここで、私が最初に書いて長らく気づけなかった実装を先にお見せします。読み飛ばしたくなるくらい素直なコードですが、この形には二つの穴が空いています。
// ❌ 私が最初に書いた版。動くように見えて、二か所で計測を失います
let cumulativeScore = 0 ;
export async function reportEvent ( event : Event ) : Promise < void > {
const next = Math. min ( 63 , cumulativeScore + SCORE_MAP [event]);
if (next > cumulativeScore) {
cumulativeScore = next;
appsFlyer. updateConversionValueWithErrorCallback (next, ( e ) => console. warn (e));
}
}
一つ目は、cumulativeScore がプロセス内の変数だという点です。アプリを一度落として翌日に開き直すと 0 に戻りますので、そこから積み上げ直した値は前回の到達点より低くなります。SKAN は単調増加でしか値を受け取らないため、この低い値は黙って捨てられます。エラーも警告も出ません。オンボーディング完了までは正しく、課金トライアルの 32 点だけが永遠に届かない——という歪み方をします。
二つ目は、fine しか更新していない点です。SKAN 4.0 では fine を運べるのは最初のポストバック(インストール後 2 日まで)だけで、2 回目と 3 回目は coarse しか運べません。coarse を一度も設定していなければ、後続のポストバックには coarse: none が入って返ってきます。ダッシュボードで「window 1 以降が空っぽ」に見える原因の多くはここです。coarse の設計を熱心に語っておきながら、コードが SKAN 3.0 のままだった、というのが当時の私でした。
設計は 4.0、実装は 3.0——このズレは、レポートが空になるまで誰も教えてくれません。
両方を塞いだ形が次です。
// src/lib/conversion-value.ts
import AsyncStorage from '@react-native-async-storage/async-storage' ;
import appsFlyer from 'react-native-appsflyer' ;
import { updateSkanConversionValue, type CoarseValue } from './skan' ;
type Event =
| 'app_open'
| 'onboarding_completed'
| 'first_content_view'
| 'account_created'
| 'trial_started' ;
const SCORE_MAP : Record < Event , number > = {
app_open: 1 ,
onboarding_completed: 4 ,
first_content_view: 8 ,
account_created: 16 ,
trial_started: 32 ,
};
const STORE_KEY = 'skan:cumulative-fine' ;
// fine の到達点から coarse を決めます。しきい値は自分の LTV 分布に合わせて調整してください
function toCoarse ( fine : number ) : CoarseValue {
if (fine >= 32 ) return 'high' ; // 課金トライアル到達
if (fine >= 8 ) return 'medium' ; // 初回コンテンツ閲覧以降
return 'low' ;
}
async function readScore () : Promise < number > {
const raw = await AsyncStorage. getItem ( STORE_KEY );
const parsed = Number (raw);
return Number. isFinite (parsed) ? parsed : 0 ;
}
export async function reportEvent ( event : Event , payload ?: Record < string , unknown >) : Promise < void > {
// 1. AppsFlyer に通常イベントを送信(ダッシュボードで分析用)
appsFlyer. logEvent (event, payload ?? {}, () => {}, ( err ) => {
console. warn ( '[Attribution] logEvent error' , err);
});
// 2. 累積スコアはストレージから読み直す(プロセス内変数だと再起動で 0 に戻ります)
const current = await readScore ();
const next = Math. min ( 63 , current + SCORE_MAP [event]);
if (next <= current) return ;
await AsyncStorage. setItem ( STORE_KEY , String (next));
// 3. fine と coarse を同時に更新する。トライアル到達時は window を閉じて即座に送出させる
await updateSkanConversionValue ({
fine: next,
coarse: toCoarse (next),
lockWindow: event === 'trial_started' ,
});
}
updateSkanConversionValue の中身は、iOS 16.1 以降の SKAdNetwork.updatePostbackConversionValue(_:coarseValue:lockWindow:) を呼ぶだけの薄いブリッジです。Expo Modules API で 30 行ほど書けば済みますので、MMP の SDK が対応するのを待つ必要はありません。
// modules/skan/ios/SkanModule.swift — Expo Modules API の薄いブリッジ
import ExpoModulesCore
import StoreKit
public class SkanModule : Module {
public func definition () -> ModuleDefinition {
Name ( "Skan" )
AsyncFunction ( "update" ) { ( fine : Int , coarse : String , lockWindow : Bool , promise : Promise) in
// iOS 16.1 未満は coarse の概念そのものが無いため fine だけを送ります
guard #available ( iOS 16.1 , * ) else {
if #available ( iOS 15.4 , * ) {
SKAdNetwork. updatePostbackConversionValue (fine) { error in
if let error { promise. reject ( "skan" , error.localizedDescription) } else { promise. resolve ( nil ) }
}
} else {
promise. resolve ( nil )
}
return
}
let coarseValue: SKAdNetwork.CoarseConversionValue = {
switch coarse {
case "high" : return .high
case "medium" : return .medium
default: return .low
}
}()
SKAdNetwork. updatePostbackConversionValue (fine, coarseValue : coarseValue, lockWindow : lockWindow) { error in
if let error { promise. reject ( "skan" , error.localizedDescription) } else { promise. resolve ( nil ) }
}
}
}
}
一点だけ線引きが必要です。AppsFlyer の SKAN Conversion Studio のようにサーバ側で Conversion Value を決める仕組みを有効にしている場合、アプリ側からの自前更新と二重に走らせてはいけません。どちらが最後に書いたかで値が決まってしまい、原因の切り分けが難しくなります。Conversion Value を書く主体は、アプリかサーバのどちらか一方に必ず寄せます。 私は自前で持つ側を選んでいますが、媒体を増やすほどサーバ側に寄せる利点が効いてきますので、規模が変わったら見直すつもりでおります。
lockWindow を true にすると、その window の残り時間を待たずにポストバックが送出されます。トライアル開始のように「これ以上スコアが動かない」と分かる地点でだけ立てるのが要点で、闇雲に立てると後続イベントの計測機会を捨てることになります。
期待する出力: AppsFlyer のダッシュボードで Cohort Report → SKAN Postbacks を開くと、postback window 0 に fine の分布が、window 1 と 2 に coarse の low / medium / high の分布が現れます。window 1 以降が none で埋まっているうちは、まだ coarse が届いていない証拠です。
ATT 同意 UI を設計し、opt-in 率を最大化する
ATT の opt-in 率はアプリの収益を直接左右します。私の感覚では、デフォルトの ATT ダイアログだけを出した場合の opt-in 率は 25〜35% 程度ですが、事前説明 UI(pre-prompt)をきちんと作り込むと 55〜65% まで上がります。倍近い違いです。
ここでの設計の肝は「許可した方がユーザーにとってもメリットがある」と感じてもらえる文言にすることです。「広告のために必要です」と書くと拒否率が跳ね上がり、「あなたに合ったコンテンツを届けるために」と書くと許諾率が上がる、という現象は何度も観測しました。
// src/components/PrePromptATT.tsx
import { useEffect, useState } from 'react' ;
import { Modal, Text, View, Pressable, StyleSheet } from 'react-native' ;
import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync, PermissionStatus } from 'expo-tracking-transparency' ;
import { initAttribution } from '../lib/attribution' ;
export function PrePromptATT () {
const [ visible , setVisible ] = useState ( false );
useEffect (() => {
( async () => {
const { status } = await getTrackingPermissionsAsync ();
if (status === PermissionStatus. UNDETERMINED ) setVisible ( true );
})();
}, []);
const onAllow = async () => {
setVisible ( false );
await requestTrackingPermissionsAsync (); // ここで OS のダイアログが出る
await initAttribution (); // 許諾結果が確定してから SDK を立ち上げる
};
if ( ! visible) return null ;
return (
< Modal animationType = "fade" transparent >
< View style = { styles.backdrop } >
< View style = { styles.card } >
< Text style = { styles.title } >あなたに合ったおすすめのために</ Text >
< Text style = { styles.body } >
次の画面で「許可」を選んでいただくと、興味に近いコンテンツや
お得な情報を表示できるようになります。設定からいつでも変更できます。
</ Text >
< Pressable onPress = { onAllow } style = { styles.cta } >
< Text style = { styles.ctaText } >つづける</ Text >
</ Pressable >
</ View >
</ View >
</ Modal >
);
}
const styles = StyleSheet. create ({
backdrop: { flex: 1 , backgroundColor: 'rgba(0,0,0,0.5)' , justifyContent: 'center' , padding: 24 },
card: { backgroundColor: '#fff' , borderRadius: 16 , padding: 24 },
title: { fontSize: 18 , fontWeight: '700' , marginBottom: 8 },
body: { fontSize: 14 , lineHeight: 22 , color: '#444' , marginBottom: 16 },
cta: { backgroundColor: '#000' , paddingVertical: 14 , borderRadius: 12 , alignItems: 'center' },
ctaText: { color: '#fff' , fontSize: 15 , fontWeight: '600' },
});
この pre-prompt は、オンボーディングの最後(アプリの価値をある程度体験してもらった後)に出すのがおすすめです。起動直後に出すと「まだ何もしていないのに広告のために確認された」という印象になりやすく、拒否率が上がります。
ATT 同意管理を CMP(Consent Management Platform)と組み合わせて GDPR と統合的に処理したい場合は、別記事のRork × UMP・ATT で広告同意を本番運用する も合わせて読んでみてください。
ディープリンク(Universal Links / OneLink / Adjust Link)統合
広告流入を計測するうえで欠かせないのがディープリンクです。Universal Links だけだと「広告クリックからインストールまで」が紐づかないため、AppsFlyer の OneLink もしくは Adjust の Tracker Link を併用して、Deferred Deep Link(インストール後に意図したページへ遷移する仕組み)を実装する必要があります。
// src/lib/deep-link.ts
import appsFlyer from 'react-native-appsflyer' ;
import { router } from 'expo-router' ;
let unsubscribeOnDeepLink : (() => void ) | null = null ;
export function setupDeepLinking () {
// OneLink からのリンクを受け取り、適切な画面へ遷移
unsubscribeOnDeepLink = appsFlyer. onDeepLink (( res ) => {
if (res.deepLinkStatus !== 'FOUND' ) return ;
const data = res.data as { deep_link_value ?: string ; sub1 ?: string };
// deep_link_value にスキーマを定義(例: "campaign:summer-sale")
if (data.deep_link_value?. startsWith ( 'campaign:' )) {
const campaignId = data.deep_link_value. split ( ':' )[ 1 ];
router. push ( `/campaign/${ campaignId }` as never );
return ;
}
if (data.deep_link_value?. startsWith ( 'content:' )) {
const contentId = data.deep_link_value. split ( ':' )[ 1 ];
router. push ( `/content/${ contentId }` as never );
}
});
// 念のため初回インストール時の Conversion Data も拾う
appsFlyer. onInstallConversionData (( res ) => {
if (res.data?.is_first_launch === 'true' && res.data?.deep_link_value) {
const value = String (res.data.deep_link_value);
console. log ( '[DeepLink] first launch with' , value);
}
});
}
export function teardownDeepLinking () {
unsubscribeOnDeepLink ?.();
unsubscribeOnDeepLink = null ;
}
deep_link_value という汎用キーに「ドメイン:ID」形式で値を入れる設計にすると、media partner が変わってもアプリ側のロジックを書き換えずに済みます。私は campaign:, content:, referral: の三種類を最初から決めておき、新しい広告先が増えてもこの命名規則で吸収できる形にしています。
ディープリンクの基礎についてはRork Max でのディープリンク・ユニバーサルリンク実装 で図解しているので、Universal Links の apple-app-site-association ファイル設置でつまずいている方はそちらも参照してください。
広告 SDK を本番リリース前にデバッグする実践テクニック
ここまでの実装が一通り終わったら、必ず本番リリース前に「テスト計測」を1サイクル回してください。本番でズレが発覚すると、広告予算を消費しながらの修正となり傷が大きくなります。
私は次の手順をリリース前のチェックリストにしています。
AppsFlyer 管理画面の「Test Devices」に自分の端末を登録(IDFV か Custom ID で識別)
アプリをデバッグビルドで起動し、logEvent 呼び出しが Live Activity に流れるか確認
SKAN ポストバックは In-app Events → SKAN のセクションで擬似ポストバックを発生させ、コンバージョン値が想定通りに更新されるかを目視
App Store の TestFlight ビルドで、別のデバイスから OneLink を踏み、インストール後に Deferred Deep Link が正しく発火するかを実機検証
1〜4 が確認できたら本番ストアへの提出
// src/lib/attribution-debug.ts
import appsFlyer from 'react-native-appsflyer' ;
/**
* 本番リリース直前にだけ呼び出すデバッグ用ヘルパー。
* Test Devices 登録後にこの関数を1度実行し、
* AppsFlyer 管理画面の Live Events に表示されるか確認する。
*/
export function fireDebugEvents () {
if ( ! __DEV__) return ; // 本番ビルドでは絶対に実行しない
appsFlyer. logEvent ( 'debug_app_open' , { source: 'self_test' }, () => {}, () => {});
appsFlyer. logEvent ( 'debug_onboarding_completed' , { variant: 'A' }, () => {}, () => {});
appsFlyer. logEvent ( 'debug_account_created' , { provider: 'apple' }, () => {}, () => {});
}
期待する出力: AppsFlyer の Live Events 画面に 3 つのイベントが 1〜2 分以内に表示されれば、ATT・SDK 初期化・ネットワーク到達がすべて正常に機能している証拠です。
よくある間違い・落とし穴
ここまで実装してもまだ計測がズレるとしたら、十中八九は次のいずれかに当てはまっています。私自身が一度はやらかしたものばかりなので、リリース前のチェックリストとして使ってください。
ATT 許諾を求めるタイミングが早すぎる : 起動と同時に ATT ダイアログを出すと拒否率が 70% を超えがちです。ユーザーがアプリの価値を体験した後に求めるのが鉄則で、私はオンボーディング最終ステップに配置しています。
SKAdNetwork ID リストが古い : 広告ネットワークごとに SKAdNetwork ID が違い、Info.plist の SKAdNetworkItems に追加されていないとそのネットワーク経由のインストールが計測されません。react-native-appsflyer は最新の ID をプラグイン経由で注入してくれますが、自前で書き加えた他媒体は手動更新が必要です。半年に一度はチェックしましょう。
Conversion Schema を後から大幅変更する : SKAN は単調増加でしか値を更新できません。途中で重み付けを反転させると過去データと整合しなくなり、レポートが事実上使い物になりません。スキーマは仮置きでもよいので、配信前に必ず固定してから走らせましょう。
Hermes / Expo Modules のバージョン不整合で SDK 初期化失敗 : Expo SDK のメジャーアップデート後に react-native-appsflyer のメジャーバージョンを上げ忘れると、ネイティブモジュールのリンクが解けて起動時にクラッシュします。アップデートは Expo SDK と AppsFlyer SDK をセットで揃えるのが安全です。
デバッグビルドで本番イベントが流れる : __DEV__ ガードを忘れると、開発中の logEvent 呼び出しが本番計測に混入し、Conversion Rate を歪めます。ヘルパー関数で必ず if (__DEV__) を挟むか、AppsFlyer の setIsSandbox(true) をデバッグ時に呼んでサンドボックス環境に隔離しましょう。
App Store Connect の Bundle ID と AppsFlyer の App ID が一致していない : 私はリリースの直前にこれで一度詰まりました。Bundle ID をリブランドした際に AppsFlyer 側の設定を更新し忘れ、初日の計測がすべて欠損しました。リネーム時は両側を必ず確認してください。
累積スコアをメモリだけで持っている : 前述のとおり、アプリを再起動すると 0 に戻り、以降の更新が単調増加の制約で捨てられます。手元で再現するなら、オンボーディング完了まで進めてからアプリを完全終了し、再起動して課金トライアルまで進めてください。ポストバックの fine が 4 のまま止まっていれば、この穴に落ちています。
TikTok / Meta / Apple Search Ads と組み合わせた最適化ループ
ここからは、計測した数値をどう運用に活かすかという視点で締めくくります。アトリビューションは「導入して終わり」ではなく、媒体ごとに Conversion Value 設計を微調整して LTV を最大化するループに入ってこそ意味を持ちます。
媒体ごとの特性を私は次のように捉えています。
TikTok Ads : 認知獲得に強く、fine の低スコア帯(起動・オンボーディング完了)でターゲティング最適化が効きやすい媒体です。クリエイティブの賞味期限が短く 2〜3 週間で枯れますので、coarse まで見て高 LTV のクリエイティブだけを残す運用が向いています。
Meta Ads : 高 LTV ユーザーの獲得に強く、coarse の high レンジで配信最適化を回すと CPI は上がるものの ROAS は伸びる傾向があります。Advantage+ App Campaigns と SKAN 4.0 の組み合わせは相性が良いと感じています。
Apple Search Ads : 検索意図が明確なユーザーが入ってきますので、課金率と継続率がもっとも高くなります。Apple Search Ads は SKAN ポストバックを直接受け取りますので、AppsFlyer 経由でも独自の Search Ads Attribution API を併用すると精度が上がります。
Apple Search Ads の運用と組み合わせる場合は、Apple Search Ads の高度な運用戦略 で書いたキーワード階層化と組み合わせると効果が読みやすくなります。
媒体ごとに Conversion Value を分けたい場合は、SKAN 4.0 の lockWindow を使って postback window 1 で値を固定し、media partner ごとに別々のコホートとして分析するのが定番です。AppsFlyer のダッシュボードでは SKAN Cohort Builder から数クリックで設定できます。
Adjust 固有の差分と乗り換え時のコツ
AppsFlyer から Adjust に移行する場合、もしくは最初から Adjust で構築する場合、SDK の API そのものは似ていますが、Conversion Value の更新方法と Callback Parameter の概念が少し異なります。Adjust は addCallbackParameter で任意のキー値を後から追加できる柔軟性があり、特定の媒体(例: TikTok)からの流入だけ別の Conversion Schema を当てたい場合に便利です。
// src/lib/attribution-adjust.ts — react-native-adjust v5 系
import { Adjust, AdjustConfig, AdjustEvent } from 'react-native-adjust' ;
export function initAdjust ( appToken : string ) {
const config = new AdjustConfig (
appToken,
__DEV__ ? AdjustConfig.EnvironmentSandbox : AdjustConfig.EnvironmentProduction,
);
config. setLogLevel (__DEV__ ? AdjustConfig.LogLevelDebug : AdjustConfig.LogLevelError);
config. setAttributionCallbackListener (( attribution ) => {
console. log ( '[Adjust] attribution' , attribution);
});
// v4 では Adjust.create(config) でした。v5 で initSdk に変わっています
Adjust. initSdk (config);
}
export function trackPurchase ( eventToken : string , amount : number , currency : string ) {
const event = new AdjustEvent (eventToken);
event. setRevenue (amount, currency);
// Conversion Value の更新は OS 側に任せ、媒体別の解析はコールバックパラメータで管理する
event. addCallbackParameter ( 'plan_id' , 'pro_monthly' );
Adjust. trackEvent (event);
}
ここで一度つまずいたのが SDK のメジャーバージョンでした。react-native-adjust は v5 で初期化のエントリポイントが変わり、v4 までの Adjust.create(config) は Adjust.initSdk(config) になっています。古い記事やサンプルをそのまま写すと、Adjust.create is not a function で起動直後に落ちますので、まず package.json の系列を確かめてください。あわせて、セッション単位のコールバックパラメータも addSessionCallbackParameter から addGlobalCallbackParameter へ改名されています(イベント単位の addCallbackParameter はそのままです)。移行の詳細は Adjust 公式の SDK v5 移行ガイド に一覧があります。
setAttributionCallbackListener を置く位置と、値の受け取り方
上のコードで console.log だけにしていたコールバックは、実運用では最初に手を入れる箇所になります。私が最初に書いた版は、ログを出すだけで満足して、受け取った値をどこにも残しておりませんでした。数日後、「このユーザーはどの広告から来たのか」を画面側で使いたくなったとき、取りに行く先がなかったのです。
押さえておく点は三つです。
一つ目は、リスナーを Adjust.initSdk(config) より前に設定することです。初期化後に足しても受け取れません。上のコードで setAttributionCallbackListener が initSdk の手前に並んでいるのは、見た目の整理ではなく順序の制約です。
二つ目は、このコールバックが毎回の起動で呼ばれるわけではない、という前提で設計することです。呼ばれるのは、アトリビューションが確定したとき、あるいは内容が変わったときです。そのため「起動のたびに届く値」として画面に直結させると、二回目以降の起動で空になります。受け取った時点でストレージに保存し、以後はそこから読む形にしておくと安定します。
三つ目は、値が空のことがある前提で書くことです。オーガニック流入や、媒体側が情報を返さないケースでは、campaign や adgroup などが埋まらない場合があります。空文字や未定義を「エラー」として扱わず、「不明」として保存します。
// src/lib/attribution-store.ts — Adjust のアトリビューションを一度だけ保存して使い回す
import AsyncStorage from '@react-native-async-storage/async-storage' ;
import { Adjust, AdjustConfig } from 'react-native-adjust' ;
const KEY = 'adjust:attribution' ;
type StoredAttribution = {
network : string ;
campaign : string ;
adgroup : string ;
creative : string ;
savedAt : number ;
};
export function attachAttributionStore ( config : AdjustConfig ) : void {
// initSdk より前に呼ぶこと。後からでは受け取れません
config. setAttributionCallbackListener ( async ( attribution ) => {
const next : StoredAttribution = {
network: attribution.network || 'unknown' ,
campaign: attribution.campaign || 'unknown' ,
adgroup: attribution.adgroup || 'unknown' ,
creative: attribution.creative || 'unknown' ,
savedAt: Date. now (),
};
// 内容が変わったときにも呼ばれるため、上書き保存で最新を保ちます
await AsyncStorage. setItem ( KEY , JSON . stringify (next));
});
}
export async function readStoredAttribution () : Promise < StoredAttribution | null > {
const raw = await AsyncStorage. getItem ( KEY );
return raw ? ( JSON . parse (raw) as StoredAttribution ) : null ;
}
呼び出し側は、initAdjust の中で config を作ったあと attachAttributionStore(config) を一行足してから Adjust.initSdk(config) へ進むだけです。
動作の確認は、Sandbox 環境で行います。初回起動後にしばらく待ってから readStoredAttribution() を呼び、null でなければ配線は通っています。すぐに null が返ってきても慌てず、少し時間を置いてもう一度試してください。アトリビューションの確定は、起動直後ではなく、通信を経てから届くためです。なお、各フィールドの有無や名称は SDK のバージョンで変わる可能性があります。実装の前に、お使いの系列の公式ドキュメントで一覧をご確認ください。
受け取った値は、その場で使うものではなく、残しておくものです。 画面に出す・Conversion Value の重み付けに使う・サポート問い合わせの手がかりにする——使い道が決まる前に保存だけ済ませておくと、後の設計が楽になります。
私の経験上、AppsFlyer から Adjust への移行時に最も詰まりやすいのは「ATT 同意フローを再実装する手間」です。両 SDK ともネイティブの ATT API をラップしてはいますが、初期化順序の最適解が微妙に違います。Adjust の場合は Adjust.initSdk を ATT 確定前に呼んでも内部で待機してくれるので、起動時の体感速度が AppsFlyer より一段速く感じられます。
RevenueCat / StoreKit のサブスクリプションイベントを SKAN に紐付ける
サブスクリプション型のアプリでは、トライアル開始から本課金、そして解約までの一連の遷移を SKAN の Conversion Value に反映させると、媒体別の真の ROAS を測れるようになります。RevenueCat を使っている場合、Purchases.addCustomerInfoUpdateListener のフックから直接 Conversion Value を更新するのが最も整理しやすい配線です。
// src/lib/revenuecat-skan-bridge.ts
import AsyncStorage from '@react-native-async-storage/async-storage' ;
import Purchases, { CustomerInfo } from 'react-native-purchases' ;
import { reportEvent } from './conversion-value' ;
const SENT_KEY_PREFIX = 'skan:sent:' ;
// リスナーはリニューアルでも発火します。同じイベントを二度送らないための門番です
async function sendOnce ( event : 'trial_started' | 'paid_started' , payload : Record < string , unknown >) {
const key = `${ SENT_KEY_PREFIX }${ event }` ;
if ( await AsyncStorage. getItem (key)) return ;
await AsyncStorage. setItem (key, '1' );
await reportEvent (event, payload);
}
export function bindRevenueCatToSKAN () {
Purchases. addCustomerInfoUpdateListener (( info : CustomerInfo ) => {
const active = info.entitlements.active[ 'pro' ];
if ( ! active) return ;
const payload = { product: active.productIdentifier };
// periodType は 'TRIAL' / 'NORMAL' / 'INTRO' を返します。大文字小文字の取り違えに注意
if ( String (active.periodType). toUpperCase () === 'TRIAL' ) {
void sendOnce ( 'trial_started' , payload);
} else {
void sendOnce ( 'paid_started' , payload);
}
});
}
先ほどの版では、トライアルと本課金の分岐が両方とも trial_started を送っていました。コメントには「本課金は最大値にクランプする」と書きながら、実際には同じイベントが飛んでいたわけです。SKAN の単調増加の制約に守られて例外もエラーも出ないため、レポートの high がいつまでも増えないという形でしか現れません。分岐を書いたら、両方の枝を声に出して読み上げる——これは私が自分に課している確認です。
もう一点、customerInfo は購読の更新(リニューアル)でも発火します。毎回送っても単調増加の制約で無視されるだけなので実害は小さいのですが、ダッシュボードのイベント数だけが膨らみ、本当の初回課金がどこだったのか読めなくなります。上のように送信済みフラグを永続化しておくと、後からログを追うときに助かります。paid_started を SCORE_MAP に追加するのを忘れないでください。
サーバ to サーバ ポストバック検証のセットアップ
SKAN ポストバックは Apple のサーバから直接広告ネットワークへ送られますが、自社サーバでもコピーを受け取る S2S(Server-to-Server)構成にしておくと、データが第三者にロックインされず、後から自由に再分析できます。AppsFlyer / Adjust 双方が S2S ポストバックの転送機能を提供しているので、Cloudflare Workers など軽量なエンドポイントを 1 本立てておくのが手堅い構成です。
// Cloudflare Workers — SKAN postback receiver
export default {
async fetch ( request : Request , env : { SKAN_KV : KVNamespace }) : Promise < Response > {
if (request.method !== 'POST' ) return new Response ( 'OK' , { status: 200 });
const payload = await request. json < Record < string , unknown >>();
// 必要最小限のスキーマで保存(個人情報は含めない)
const key = `${ payload [ 'source-app-id' ] ?? 'unknown'}:${ payload [ 'transaction-id' ] }` ;
await env. SKAN_KV . put (key, JSON . stringify (payload), {
expirationTtl: 60 * 60 * 24 * 90 , // 90 日保存
});
return new Response ( 'stored' , { status: 200 });
} ,
} ;
このように KV や D1 に蓄積しておくと、媒体管理画面のレポートが分断されている場合でも、自前で横串の集計が可能になります。期待する動作: SKAN のポストバックが到達するたびに source-app-id:transaction-id をキーとした JSON が KV に書き込まれ、wrangler kv:key list で確認できます。
ダッシュボードの数値をどう読み解くか — 私の毎週の運用ルーティン
最後に、計測データを実際にどう運用に活かしているかをお伝えします。私は毎週月曜の朝に 30 分だけダッシュボードを開く時間を取り、「先週の Conversion Value 分布が直近 4 週の平均からズレていないか」「coarse の high の割合が想定通りか」「特定の媒体だけ fine の最大値が頭打ちになっていないか」の 3 点だけを確認します。
これら 3 点だけに絞っているのは、過剰な細かいレポートに溺れると判断が遅くなるためです。逆に「いつもより low の割合が増えている」と分かれば、クリエイティブの賞味期限が来た合図と捉え、即日広告セットを差し替えます。逆に high が増えていれば、広告予算の上限を 20% 増やして翌週まで様子を見ます。判断基準を少なく保つことが、個人開発で続けるコツでもあります。
全体を振り返って
アトリビューションは導入のハードルが高い割に、一度配線が整えば広告予算の意思決定の速度を一変させてくれます。今日この記事を閉じる前に、まず手元の Rork プロジェクトに react-native-appsflyer を入れて、initAttribution() を 1 回だけ呼ぶところまで進めてみてください。Conversion Value 設計や ATT pre-prompt は、SDK が動くと確認できてから腰を据えて取り組むほうが、結果的に短い時間で完成します。
媒体側の最適化は計測が正しく取れていることを前提にした世界です。先に SDK と Conversion Value 設計を固め、それから広告予算を増やしていく順序を守ることで、個人開発でも代理店並みの精度で運用できる土台が手に入ります。私自身もまだ毎月のように設計を見直していますが、一度この型を持っておくと媒体が変わっても応用が効きます。一緒に磨いていきましょう。