Bỏ qua, tới nội dung
Kiếm tiền
6 phút đọc

AdMob trong app React Native: vị trí đặt ad, điểm dừng tự nhiên và consent

Cách mình chạy AdMob trong hai app React Native: mỗi vị trí một ad unit, interstitial ở điểm dừng tự nhiên, rewarded, UMP và app-ads.txt.

Dao Van Thuong
Mobile & Fullstack Engineer
Read in English
Ảnh bìa: vị trí đặt AdMob, interstitial ở điểm dừng tự nhiên và luồng consent trong app React Native
Trong bài này
  1. Một port, và chỉ một file được import SDK
  2. Mỗi vị trí một ad unit
  3. Interstitial chỉ ở điểm dừng tự nhiên
  4. Rewarded do người dùng tự chọn
  5. Consent trước SDK, và tôn trọng câu trả lời
  6. app-ads.txt, Marketing URL và giấy tờ
  7. Rút ra

Trong bảy app của mình, có hai app hiện quảng cáo. Lockboxy, kho riêng tư có mã hoá, cho người dùng bản free thấy interstitial và rewarded. Linkeeper, app đọc sau, chỉ có đúng một loại: rewarded ad tự nguyện trên paywall khi chạm giới hạn free. Năm app còn lại không có quảng cáo nào.

Cả hai dùng react-native-google-mobile-ads. Phần gắn SDK làm nhanh. Phần tốn công là luật chơi: ad được hiện ở đâu, bao lâu một lần, cần làm gì trước request đầu tiên, và AdMob kiểm tra gì trước khi chịu phân phát đầy đủ. Bài này là cách setup hiện tại ở hai repo, kèm hai bug mà test không bắt được.

Một port, và chỉ một file được import SDK

Code tính năng không bao giờ import SDK quảng cáo. Nó gọi qua interface AdsPort; adMobAdsAdapter.ts là file duy nhất import react-native-google-mobile-ads. Nhờ vậy test chạy được với fake port, và luật vị trí đặt ad có thể ép bằng code:

ts
export interface AdsPort {
  requestConsent(): Promise<void>;
  loadInterstitial(placement: InterstitialPlacement): Promise<void>;
  showInterstitial(placement: InterstitialPlacement): Promise<boolean>; // false, không throw
  loadRewarded(placement: RewardedPlacement): Promise<void>;
  showRewarded(placement: RewardedPlacement): Promise<boolean>; // true chỉ khi đã nhận thưởng
}

Mỗi app có một guard test duyệt cả src/ và fail nếu file nào không được phép mà lại import module ads. Ở Lockboxy, danh sách cấm gồm màn hình mở đầu tuỳ chọn, màn hình khoá, onboarding và splash: chưa vào trong kho thì không có ad nào. Ở Linkeeper, chỉ hook useRewardedAd của paywall được đụng tới ads.

Mỗi vị trí một ad unit

Ban đầu Lockboxy có hai ad unit, một interstitial và một rewarded, dùng chung cho mọi chỗ. Báo cáo AdMob tính theo ad unit, nên mình không biết chỗ nào thực sự ra tiền. Từ bản 1.2.12, mỗi vị trí (placement) có ad unit iOS riêng, và mọi lời gọi AdsPort phải nêu tên vị trí:

ts
export const INTERSTITIAL_PLACEMENTS =
  ['lockNowHome', 'lockNowSettings', 'afterImport', 'afterSlideshow'] as const;
export const REWARDED_PLACEMENTS = ['unlockSlots', 'premiumTrial'] as const;

export function adUnitFor(kind, placement, platform = Platform.OS, dev = __DEV__) {
  if (dev) return TEST_UNITS[platform][kind];          // test unit công khai của Google
  if (platform === 'android') return ORIGINAL_UNITS.android[kind];
  const unit = IOS_PROD_UNITS[kind][placement];
  return unit && unit !== IOS_UNIT_TODO ? unit : ORIGINAL_UNITS.ios[kind];
}

Ba chi tiết giúp việc này an toàn:

  • Build debug luôn dùng test unit của Google. Tự bấm ad thật lúc dev có thể khiến tài khoản AdMob bị ban, nên công tắc dev/test nằm ngay trong hàm mà mọi request đều phải đi qua.
  • Placeholder không thể lên store. Vị trí chưa tạo unit sẽ mang IOS_UNIT_TODO. Lúc chạy nó rơi về unit gốc, nên không bao giờ request một ID rác. Còn config.test.ts sẽ fail chừng nào còn TODO, nên không release được.
  • Adapter giữ một ad đã tải cho mỗi unit, không phải mỗi vị trí. Các vị trí dùng chung unit (hiện là mọi vị trí trên Android) dùng chung slot, nên một unit không bị tải hai lần.

Linkeeper làm vậy ngay từ đầu: ba rewarded unit, cho thêm lượt import, thêm chỗ trong kho ẩn và thêm một collection.

Interstitial chỉ ở điểm dừng tự nhiên

Kho chính, interstitial test sau khi bấm Khoá ngay, rồi màn hình khoá
Khoá ngay trong kho chính: một interstitial test, rồi về màn hình khoá

Interstitial đầu tiên của Lockboxy nằm ở nút Khoá ngay do người dùng tự bấm trong kho chính. Lúc đó người dùng đã xong việc và đang rời đi, nên ad không cắt ngang việc gì. Bản 1.2.12 thêm hai điểm dừng tự nhiên: quay lại lưới ảnh sau khi nhập từ 5 mục trở lên, và khi một slideshow dài từ 30 giây kết thúc.

Thêm vị trí dễ thành thêm ad, nên tất cả đi qua một policy thuần (pure) và dùng chung một lastShownAt:

Bốn vị trí interstitial đi vào một policy chung, hoặc hiện ad từ unit riêng của vị trí đó, hoặc bỏ qua
Thêm vị trí là thêm cơ hội lấp một slot, không phải thêm ad mỗi phút

Policy chỉ cho hiện ad khi tất cả điều kiện đều đúng: người dùng không phải Premium, đang ở kho chính (không bao giờ ở kho phụ), không phải phiên đầu tiên, cài app đã ít nhất hai ngày, cách interstitial gần nhất ít nhất bốn phút, và ad đã tải sẵn. Riêng Khoá ngay còn phải do người dùng tự bấm (không tính auto-lock, panic-lock hay đưa app xuống nền), và lượt vào kho phải dài ít nhất 20 giây. Chỉ cần một điều kiện trượt là không chờ gì cả: kho khoá ngay, và event ad_interstitial_skipped {reason, placement} ghi lại lý do.

Ad sau khi nhập còn phải nhường màn hình: nó hiện khoảng 0,9 giây sau khi lưới ảnh quay lại, không bao giờ đè lên bảng tóm tắt, câu hỏi "xoá bản gốc?" hay một confirm sheet đang mở.

Bản đầu của interstitial này chưa từng hiện lần nào. Không chỗ nào gọi loadInterstitial(), trong khi showInterstitial() chỉ hiện ad đã tải sẵn. Nó được gắn từ tháng 7 và tới cuối tháng 9 mới được sửa; trong suốt thời gian đó AdMob console ghi nhận 0 request. Test vẫn xanh, vì policy đúng và adapter cũng đúng, chỉ là bước tải sẵn chưa bao giờ chạy. Giờ Home tải sẵn ad mỗi lần mở khoá vào kho chính, còn Cài đặt tải sẵn vị trí của nó khi mở ra.

Rewarded do người dùng tự chọn

Rewarded luôn là tự nguyện, và phần thưởng phải có tác dụng thật:

  • Lockboxy, +5 slot. Hiện trên các sheet chạm giới hạn free. Sheet tải sẵn ad khi mở, nên bấm là chạy luôn.
  • Lockboxy, dùng thử 24 giờ. Trên paywall của Tìm kiếm trong két, Web shortcuts hoặc Chụp ảnh kẻ đột nhập, dòng "Xem quảng cáo để dùng thử 24 giờ" nằm dưới nút mua. Mỗi lúc chỉ chạy một trial, và mỗi tính năng phải chờ 7 ngày kể từ lần thử trước mới thử lại được. Mình chỉ cho thử những tính năng không để lại gì dang dở khi hết ngày. Kho phụ hay recovery key mà bị khoá lại sau 24 giờ thì dữ liệu nằm kẹt sau cổng, nên chúng không có trong danh sách.
  • Linkeeper, nới giới hạn free. +200 lượt import, +5 chỗ trong kho ẩn (tối đa +50 từ ad) hoặc +1 collection (tối đa +5). Người đã mua Unlimited không bao giờ thấy, và paywall của sync hay settings cũng không có.

Kiểm tra phần thưởng quan trọng hơn ta tưởng. showRewarded() chỉ resolve true khi EARNED_REWARD bắn trước CLOSED. Bỏ ngang, đóng sớm, không có ad hay lỗi tải đều ra false, nên không ai nhận thưởng cho một ad chưa xem hết.

Cả hai app đều không hiện prompt ATT. Request là non-personalized, còn thông báo consent cho EEA/UK do UMP SDK của Google lo. Lần submit đầu của Lockboxy, key NSUserTrackingUsageDescription nằm trong Info.plist mà không có lời gọi ATT nào đi kèm đã làm kẹt review, nên mình bỏ key đó thay vì khai một kiểu tracking không hề xảy ra.

Thứ tự là: consent trước, rồi mobileAds().initialize(), rồi mới tới request đầu tiên. Cả hai adapter ép thứ tự này trong một hàm ensureInitialized() được memoize, nên nơi gọi không phải tự lo:

Cập nhật thông tin UMP, form nếu cần, rồi canRequestAds quyết định khởi tạo SDK hay không request gì
Không consent thì không request, và hỏi lại lần sau chứ không bỏ luôn
ts
async function ensureConsent(): Promise<boolean> {
  try {
    await AdsConsent.requestInfoUpdate();
    await AdsConsent.loadAndShowConsentFormIfRequired();
  } catch { /* offline, bị đóng */ }
  const ok = (await AdsConsent.getConsentInfo()).canRequestAds;
  if (!ok) consentPromise = null; // lần gọi ad sau chạy lại flow
  return ok;
}

Bug thứ hai nằm ở đây, có ở cả hai app, và mình sửa cả hai trong cùng một ngày. Flow consent có chạy, nhưng không ai đọc kết quả của nó. Form lỗi hoặc bị đóng (ví dụ lúc offline) vẫn đi tiếp tới initialize() rồi request ad. Tệ hơn, promise lỗi bị cache lại, nên form không bao giờ hiện lại nữa. Giờ không có gì được tải nếu canRequestAds chưa là true. Flow lỗi thì dùng consent đã lưu từ phiên trước, còn câu trả lời "không" sẽ được hỏi lại ở lần gọi ad kế tiếp.

app-ads.txt, Marketing URL và giấy tờ

Khi AdMob chưa xác minh app, việc phân phát bị giới hạn và console hiện "Requires review". Phần xác minh xoay quanh một dòng text đặt trên domain của chính app:

txt
google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0

Cả hai landing đều phục vụ file này từ public/app-ads.txt. Cái bẫy là AdMob đi tìm ở domain nào. Không phải domain tuỳ bạn chọn, mà là domain của Marketing URL trên trang App Store. File của Linkeeper đã lên mạng từ trước khi trường đó trỏ về https://linkeeper.io.vn, nên cách sửa là đặt Marketing URL ở phiên bản kế tiếp (1.3.0) rồi bấm "Check for updates" trong AdMob. Với Lockboxy, app trên AdMob còn phải được link vào trang store, vì nó được tạo lúc bản iOS vẫn đang chờ review.

Phần còn lại của checklist:

  • Danh sách SKAdNetwork ID của Google trong Info.plist, ngay cạnh App ID.
  • Cập nhật App Privacy trên App Store Connect cho AdMob: Device ID, Product Interaction, Advertising Data và Other Diagnostic Data cho mục đích quảng cáo bên thứ ba, không liên kết với danh tính và không dùng để tracking.

Rút ra

  • Đặt SDK sau một port, và dùng test để giữ ad tránh xa những màn hình không thuộc về nó.
  • Mỗi vị trí một ad unit. Công tắc dev/test và việc chặn placeholder phải nằm trong code, không phải một dòng trong checklist release.
  • Mọi interstitial đi qua một policy với một mốc thời gian chung. Không bao giờ bắt người dùng chờ ad.
  • Đọc canRequestAds. Chạy flow consent mà bỏ qua câu trả lời thì coi như chưa làm.
  • Đặt app-ads.txt trên domain của Marketing URL, rồi nhờ AdMob kiểm tra lại.

Nửa còn lại của bản free, tức là paywall, nằm ở bài Các pattern paywall với RevenueCat. Cả bảy app đều có trên apps.vanthuongdao.id.vn.

  • #AdMob
  • #React Native
  • #Lockboxy
  • #Linkeeper
  • #Consent
Chia sẻXLinkedInFacebook
Dao Van Thuong

Kỹ sư mobile và fullstack ở TP. Hồ Chí Minh. Mình tự xây và phát hành các app iOS indie — Lockboxy, Linkeeper, Minivid, Ringsy, Talkzy, Baton và Stampzy.