AdMobのUMP(同意管理)をExpo・React Nativeアプリに組み込む ─ 同意を確かめるまで広告SDKを起動しない設計にした話

当ページのリンクには広告が含まれています。
この記事の結論
英国・アイルランドへアプリを配信する前提として、Googleの同意管理(UMP)を組み込みました。方針は「同意を確かめられるまで、広告SDKの起動も広告のリクエストもしない」です。通信に失敗した場合も含めて、迷ったら広告を出さない側に倒しています。ただし、実機で確かめると、利用者が同意を拒否しても canRequestAds は true のままでした。この値は「全部に同意した」という意味ではないので、アプリ側でそう読み替えないことが大事だと考えています。

お疲れ様です!IT業界で働くアライグマです!

個人開発している技術記事キュレーションサービス「DevPick」のAndroidアプリは、日本に加えて、カナダ・オーストラリア・ニュージーランドでも配信を始めました。同じタイミングで英国とアイルランドも候補に挙がりましたが、こちらは広告の同意管理が無かったので見送っています。この記事では、その同意管理をアプリへ組み込んだときの設計と、実機で確かめて分かったことを書きます。

目次

英国・アイルランドへの配信を見送った理由

DevPickのアプリは、課金を入れずに完全無料で配信し、広告(Google AdMob)を表示しています。課金をやめて広告だけで出した経緯は、個人開発アプリで課金をやめ、広告のみで公開した判断の記事に書きました。広告で成り立っているアプリなので、配信する国を増やすときは、その国で広告を出すためのルールも確かめる必要があります。

配信国は段階を分けて広げることにしました。最初の段階で追加したのは、カナダ・オーストラリア・ニュージーランドです。英国とアイルランドもこの段階の候補でしたが、次の理由で後の段階に回しました。

  • Googleの「EU ユーザーの同意ポリシー」は、EEA(EU加盟国などの欧州経済領域)と英国の利用者に広告を出すアプリに、Googleが認定した同意管理の仕組み(CMP)で同意を取ることを求めています。2024年1月16日以降は、認定CMPを使っていないと広告の配信が制限されます。
  • 英国のPECR、EUのePrivacy指令も、端末の情報を読み書きする前に利用者の同意を求めています。アイルランドはEU加盟国、英国はEU外ですが、どちらも対象です。
  • 当時のアプリは、起動するとすぐに広告SDKを起動していて、同意を確かめる仕組みがありませんでした。

ここで勘違いしやすいのが、「非パーソナライズ広告にしているから大丈夫」という考え方です。DevPickのバナーは、閲覧履歴などに基づいて出し分けない非パーソナライズ広告だけをリクエストする設定にしていました。それでも、非パーソナライズ広告は端末の識別子やストレージを使います。出し分けをしないことと、端末の情報に触れないことは別の話なので、同意は必要だと判断しました。

Googleの同意管理の仕組みが UMP(User Messaging Platform)です。AdMobの管理画面で同意メッセージを作り、アプリにはUMPのSDKを組み込みます。アプリを起動すると、UMPが利用者の地域を判定し、同意が必要な地域であれば同意フォームを表示します。今回は、この組み込みをアプリ側で行いました。

IT女子 アラ美
出し分けしない広告なら同意いらないって、私も思い込んでたわ。

ITアライグマ
見落としやすい点です。出し分けではなく、端末の情報に触れるかどうかで考えると整理できます。

変更前は同意を確かめずに広告SDKを起動していた

アプリはExpo(React Native)で作っていて、広告には react-native-google-mobile-ads というライブラリを使っています。変更前は、アプリの起動処理の中で、次のように広告SDKを起動していました。

// 変更前(実装から抜粋)
export async function initializeAdMob(): Promise<void> {
  if (isAdMobInitialized || !isAdMobSupported()) return;

  try {
    const mobileAds = require("react-native-google-mobile-ads").default;
    await mobileAds().initialize();
    isAdMobInitialized = true;
  } catch (error) {
    console.warn("Failed to initialize AdMob SDK:", error);
  }
}

起動処理からは、この関数を呼ぶだけです。利用者がどの地域にいるかも、同意しているかも確かめていません。バナーの部品も、表示を止める条件は「有料会員かどうか」だけでした。画面にバナーが置かれた時点で、広告のリクエストが飛びます。

日本の利用者だけを相手にしているうちは、これで困りませんでした。日本は、UMPが同意フォームを求めない地域として扱われるからです。しかし、英国やアイルランドの利用者に同じアプリを配ると、同意を聞く前に広告SDKが動き出し、広告をリクエストしてしまいます。

そこで、広告にまつわる処理を「同意を確かめた後」にそろえることにしました。手を入れる場所は、次の4つです。

  • 広告SDKの起動(アプリの起動時)
  • バナー広告と全画面広告のリクエスト
  • 広告SDKに付随するアクセス計測の開始
  • 利用者が同意を変更するための入口(マイページ)

この4つは、まとめて1回の変更で入れています。1つでも抜けていると、その経路から同意前に広告や計測が動いてしまうので、同じ変更の中でそろえておく方が確かめやすくなります。

IT女子 アラ美
起動したらとりあえずSDK初期化、ってサンプルどおりに書いちゃうやつね。

ITアライグマ
はい。同意を求めない国だけなら表に出ないので、配信国を広げる段階で見えてきました。

canRequestAdsが真のときだけ起動する(失敗時は出さない側へ)

react-native-google-mobile-ads には、UMPを呼び出すための AdsConsent が同梱されています。中心になるのは AdsConsent.gatherConsent() です。この関数は、最新の同意情報をGoogleに問い合わせ、同意が必要な地域でまだ答えていなければ同意フォームを表示し、その結果を返します。返ってきた情報のうち、広告を出すかどうかの判断に使うのが canRequestAds です。

新しい起動処理では、この canRequestAds が true のときだけ広告SDKを起動します。判断の部分は次のとおりです。

// 変更後(実装から抜粋。コメントは記事用に補足)
export async function resolveAdsConsent(deps: ConsentFlowDeps): Promise<AdsConsentState> {
  let info: ConsentInfoLike | null;
  try {
    // 最新の同意情報を取得し、必要なら同意フォームを出す
    info = await deps.gatherConsent();
  } catch (error) {
    console.warn("Failed to gather ads consent:", error);
    try {
      // 取得に失敗したら、前回のセッションで得た同意情報で判断する
      info = await deps.getConsentInfo();
    } catch {
      // それも読めなければ「情報なし」
      info = null;
    }
  }
  return applyConsentInfo(info, deps.initialize);
}

async function applyConsentInfo(
  info: ConsentInfoLike | null,
  initialize: () => Promise<unknown>,
): Promise<AdsConsentState> {
  const privacyOptionsRequired = info?.privacyOptionsRequirementStatus === "REQUIRED";
  // true と確認できないかぎり広告を出さない(欠けている場合も含む)
  if (info?.canRequestAds !== true) return { canRequestAds: false, privacyOptionsRequired };
  if (!isAdMobInitialized) {
    try {
      await initialize();
      isAdMobInitialized = true;
    } catch (error) {
      console.warn("Failed to initialize AdMob SDK:", error);
      return { canRequestAds: false, privacyOptionsRequired };
    }
  }
  return { canRequestAds: true, privacyOptionsRequired };
}

この判断で意識したのは、「広告を出してよい」と確かめられなかった場合を、すべて「出さない」に倒すことです。このような、うまくいかなかったときに安全な側(今回は広告を出さない側)へ倒す設計を fail-closed と呼びます。具体的には、次のどのケースでも広告を出しません。

  • 同意情報の取得に失敗し、前回の同意情報も読めなかった
  • 返ってきた情報に canRequestAds が無かった
  • canRequestAds が false だった(同意が必要な地域で、まだ同意を得ていない)
  • 同意は確認できたが、広告SDKの起動自体に失敗した

同意情報の取得に失敗したときに前回の情報を使うのは、Googleが推奨している手順に合わせたものです。通信が一時的に切れただけで、前回までに同意してくれていた利用者の広告まで止める必要はありません。一方で、前回の情報も無いなら、判断の材料が無いので出さない側に倒します。

日本の利用者の場合は、UMPが「同意は不要」と返し、canRequestAds が true になります。なので、日本ではこれまでどおり広告が出ます。逆に、AdMobの管理画面で同意メッセージを公開していない状態だと、同意が必要な地域では同意フォームが出せず、canRequestAds は true になりません。この場合も、設計どおり広告は出ません。

もう1つ、起動時の確認が通信の失敗などで終わらなかった場合の回復も入れました。アプリが前面に戻るたびに、まだ広告を出せる状態になっていなければ、同意の確認をやり直します。すでに広告を出せる状態なら何もしません。

// 同意の確認とプライバシー設定フォームは同時に走らせず、1本の列で順に処理する
let consentQueue: Promise<void> = Promise.resolve();

function enqueue<T>(task: () => Promise<T>): Promise<T> {
  const run = consentQueue.then(task);
  consentQueue = run.then(
    () => undefined,
    () => undefined,
  );
  return run;
}

前面に戻ったときの再確認と、後で書くマイページからの同意の変更は、タイミングによっては同時に走ります。2つが並行すると、先に始まった確認の古い結果が、後から変更した新しい同意状態を上書きするおそれがあります。そこで、同意にかかわる処理はすべてこの列に並べ、1つずつ順番に処理するようにしました。

これらのケースは、それぞれテストで固定しています。同意が不要な場合、同意が無い場合、取得失敗時に前回の情報を使う場合、両方とも失敗した場合、canRequestAds が欠けている場合、SDKの起動に失敗した場合、前面に戻ったときの再試行、並行して呼ばれた場合です。広告SDKそのものは差し替えて、判断の部分だけを確かめる形にしています。

IT女子 アラ美
わからないときは出さない、って広告で食べてるアプリにしては潔いわね。

ITアライグマ
同意前に出してしまう方が取り返しがつかないので、出さない側を選びました。

バナーは同意が取れるまで置かず、計測も遅らせる

広告SDKの起動を遅らせても、バナーの部品が画面に置かれた時点でリクエストが飛ぶなら意味がありません。そこで、同意の確認結果をアプリ全体で共有する小さな状態ストアを作り、バナーはその状態を見てから描画するようにしました。

// 同意状態のストア(実装から抜粋)
export const NO_ADS_CONSENT: AdsConsentState = {
  canRequestAds: false,
  privacyOptionsRequired: false,
};

let current: AdsConsentState = NO_ADS_CONSENT;

/** 同意状態を購読する。値が変わると再描画される */
export function useAdsConsent(): AdsConsentState {
  return useSyncExternalStore(subscribeAdsConsent, getAdsConsentState, getAdsConsentState);
}

ポイントは初期値です。アプリが起動した直後は、まだ同意の確認が終わっていません。この間の状態を「広告を出さない・同意変更の入口も出さない」から始めています。確認が終わって canRequestAds が true になったときだけ、状態が「出せる」に変わります。ここでも、確認が終わるまでは出さない側に倒しています。

バナーの部品は、この状態が「出せる」になるまで、広告SDKのバナー部品そのものを置きません。

// バナー広告の部品(実装から抜粋)
export function AdBanner({ testID = "ad-banner", className = "" }: AdBannerProps) {
  const { isPro } = useProStatus();
  const { canRequestAds } = useAdsConsent();
  // 有料会員と、同意を確認できるまでの間は BannerAd 自体を置かない
  if (isPro || !canRequestAds) return null;
  // ...(ここから BannerAd を描画し、広告をリクエストする)
}

非表示のスタイルを当てて隠すのではなく、部品を置かないのがポイントです。バナー部品は画面に置かれた時点で広告をリクエストするので、見えなくしただけでは通信が発生してしまいます。また、利用者が後から同意を取り消して状態が「出さない」に戻った場合も、この条件でバナーが画面から外れます。

全画面広告を出す関数にも、同じ条件を入れました。全画面広告は今の画面では使っていませんが、将来使うときのために用意している関数です。同意を確認できていない間は、何もリクエストせずに「表示しなかった」を返します。

もう1つ、広告SDKに付随するアクセス計測の開始も遅らせました。react-native-google-mobile-ads のExpo用の設定には delayAppMeasurementInit という項目があり、これを有効にすると、アプリの起動と同時に計測が始まらなくなります。

[
  "react-native-google-mobile-ads",
  {
    "androidAppId": "ca-app-pub-xxxxxxxxxxxxxxxx~xxxxxxxxxx",
    "delayAppMeasurementInit": true
  }
]

この設定はAndroidのネイティブ側の設定に書き込まれるため、JavaScriptの更新だけでは反映されません。次にアプリをビルドし直したときから効きます。設定が入っていることと、検証用の設定が本番用のビルド設定に紛れ込んでいないことは、ビルド設定を読むテストで確かめています。

IT女子 アラ美
display:noneで隠して済ませたくなるけど、それだと通信は飛んじゃうのね。

ITアライグマ
はい。見た目ではなく、リクエストが出るかどうかで判断するようにしました。

プライバシー設定の入口は必要な地域でだけ出す

同意は、一度選んだら終わりではありません。利用者が後から考えを変えられるように、同意を変更する入口をアプリ内に用意する必要があります。DevPickでは、マイページに「広告のプライバシー設定」という項目を置きました。

ただし、この入口を全員に見せるわけではありません。UMPは同意情報と一緒に privacyOptionsRequirementStatus という値を返します。これが REQUIRED のとき、つまりその利用者に同意変更の入口を見せる必要があるとUMPが判断したときだけ、項目を表示します。日本のように同意を求めない地域では NOT_REQUIRED が返るので、項目そのものが出ません。

// マイページの入口(実装から抜粋)
export function AdPrivacySettings() {
  const { privacyOptionsRequired } = useAdsConsent();
  const [opening, setOpening] = useState(false);
  const [failed, setFailed] = useState(false);
  // フォームを開いている間の連打で二重に開かない(stateの反映前の2回目の押下も止める)
  const openingRef = useRef(false);

  if (!privacyOptionsRequired) return null;

  async function onOpen() {
    if (openingRef.current) return;
    openingRef.current = true;
    setOpening(true);
    setFailed(false);
    try {
      setFailed(!(await showAdsPrivacyOptions()));
    } finally {
      openingRef.current = false;
      setOpening(false);
    }
  }
  // ...(タイトル・説明文・ボタン・失敗時のメッセージを描画)
}

ボタンを押すと、UMPの AdsConsent.showPrivacyOptionsForm() で同意の変更フォームを開きます。フォームを閉じると、変更後の同意情報が返ってくるので、起動時と同じ判断にかけ直して、広告を出すかどうかを決め直します。同意を取り消して「出さない」になれば、前の章で書いた条件でバナーも画面から外れます。フォームを開けなかったときは失敗のメッセージを出し、同意の状態は変えません。

連打の対策には、useState ではなく useRef を使っています。useState の値は次の描画まで反映されないので、素早く2回押すと、2回目の押下の時点ではまだ「開いていない」と見えてしまいます。useRef なら書き換えた瞬間に次の押下から見えるので、二重に開くのを止められます。同じ考え方は、Expo Routerの言語切り替えで入力が消えた問題の記事でも、保存中の連打を止めるのに使いました。

もう1つ決めたのは、有料会員にもこの項目を出すことです。今は課金を止めていますが、有料会員の仕組み自体はコードに残っていて、有料会員には広告が出ません。そのため一見すると不要に思えますが、有料会員でも同意を変更できるように、会員の状態では隠さず、UMPが求めるかどうかだけで出し分けています。

表示する条件、フォームを開けなかったときの表示、連打の防止は、部品のテストで確かめています。

IT女子 アラ美
有料会員には関係ないって消しちゃいそうなところ、ちゃんと残したのね。

ITアライグマ
広告が出るかどうかではなく、UMPが求めるかどうかだけで判断するようにしました。

実機で確かめたこと:拒否してもcanRequestAdsはtrueだった

ここまでの変更は、マージした時点ではテストでしか確かめていませんでした。同意フォームを実際に出すには、ネイティブのビルドと、AdMob側での同意メッセージの公開が必要だからです。Expoのブラウザ版では、広告もマイページの入口も出ません。そこで、マージした後に、Androidの実機で次の流れを確かめました。

確かめるにあたっては、いくつか準備をしています。

  • 製品版のデータを残しておくため、製品版とは別のパッケージ名の検証用アプリを作り、手元の端末に入れました。広告はGoogleのテスト用の広告ユニットを使っています。
  • この月は、Expoのクラウドビルド(EAS Build)の無料枠を使い切っていたので、検証用のアプリは手元のPCでビルドしました。
  • 日本にいながら同意フォームを出すために、UMPの検証用の設定で、地域を「EEA」に見せかけました。この設定は、UMPにテスト端末として登録した端末でしか効きません。本番用のビルド設定に入っていないことは、前の章で書いたテストで確かめています。

まず、地域をEEAに見せかけた状態で起動しました。同意フォームが表示され、利用者が選ぶのを待っている間は、広告SDKの起動もバナーの生成もログに出ませんでした。同意を選んだ後のログは、次の順番でした。

時刻 出来事
23:37:57.110 同意の確認を開始(同意フォームを表示)
(選択待ちの間) 広告SDKの起動・バナー生成のログは無し
23:38:52.495 同意を選択。status=OBTAINED、privacyOptionsRequirementStatus=REQUIRED、canRequestAds=true
23:38:52.495 広告SDKを起動
23:38:52.594 バナーの生成を開始

同意を得てから、広告SDKの起動、バナーの生成という順番で進んでいることが確認できました。マイページには「広告のプライバシー設定」の項目が表示されていました。

次に、マイページから同意の変更フォームを開き、今度は拒否を選んで保存しました。UMPは、利用者がどの目的に同意したかを端末に保存していて、SDKのログからその値が読めます。同意したときはすべての目的が許可を表す 11111111111 でしたが、拒否して保存した後はすべて不許可の 00000000000 に変わりました。アプリを再起動しても、保存した同意情報が使われ、同意フォームは再び出ませんでした。

注意が必要なのはここからです。拒否を保存した後も、UMPがアプリに返す値は canRequestAds=true のままでした。

canRequestAds は「この利用者は、すべての目的に同意した」という意味ではありません。「同意の確認は済んでいて、SDKに広告をリクエストさせてよい状態か」を表す値です。拒否した利用者にどのような広告を出すか、あるいは出さないかは、保存された同意の内容をもとに広告SDKとGoogleの側で決まります。アプリ側で canRequestAds を「同意してくれた」と読み替えて、たとえば同意を前提にした独自の計測を始めるような使い方をすると、拒否した利用者の選択を無視することになります。

なお、今回確かめたのは、拒否が保存されたことと、SDKがアプリに返した判断までです。拒否した後に実際にどの種類の広告が配信されたかまでは調べていないので、そこは特定できていません。

最後に、地域の見せかけを外した検証用アプリに戻して、日本の利用者と同じ状態を確かめました。UMPは status=NOT_REQUIRED、privacyOptionsRequirementStatus=NOT_REQUIRED、canRequestAds=true を返し、同意フォームもマイページの項目も出ませんでした。ニュースの一覧をスクロールすると、AdMobのテスト広告がこれまでどおり表示されました。

このときの同意フォームは日本語で表示されていました。英語の利用者に向けた表示の設定は、AdMobの管理画面で別に確かめる作業として残しています。また、同意管理を含むアプリの版は製品版の審査に提出したところで、英国とアイルランドはまだ配信国に追加していません。審査を通って公開されたことを確かめてから追加する予定です。

IT女子 アラ美
拒否したのにtrueって、ログ見なかったら絶対に勘違いしてたわ。

ITアライグマ
値の名前だけで判断せず、ログで実際の挙動を見たから気づけた点です。

まとめ

英国・アイルランドへの配信に向けて、AdMobの同意管理(UMP)をExpoのアプリに組み込みました。やったことは、広告にまつわる処理をすべて「同意を確かめた後」にそろえることです。

  • 広告SDKは、UMPが canRequestAds=true を返したときだけ起動する。取得に失敗したら前回の同意情報で判断し、それも無ければ出さない
  • 同意の状態は「出さない」から始め、バナーは「出せる」になるまで部品そのものを置かない
  • 広告SDKに付随する計測も、起動と同時には始めない
  • 同意を変更する入口は、UMPが求める地域でだけマイページに出す

実機で確かめて分かったのは、canRequestAds が「同意した」という意味ではないことです。拒否を保存しても true のままでした。この値は「SDKに広告をリクエストさせてよいか」の判断であって、利用者がどの目的に同意したかはまた別の情報です。同意管理を組み込むときは、値の名前から意味を決めつけず、実機のログで確かめるのが確実だと今は考えています。

英国とアイルランドを配信国に追加するのは、同意管理を含む版が製品版として公開されたことを確かめてからです。

IT女子 アラ美
広告を出さないほうに倒すって、地味だけど一番大事な判断だったわね。

ITアライグマ
はい。配信国を増やすほど効いてくるので、最初に土台を固めておいてよかったです。

運営サービス・姉妹メディア

この記事をシェアする
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

ITアライグマのアバター ITアライグマ ITエンジニア / PM

都内で働くPM兼Webエンジニア(既婚・子持ち)です。
AIで作業時間を削って実務をラクにしつつ、市場価値を高めて「高年収・自由な働き方」を手に入れるキャリア戦略を発信しています。

目次