Chương 5: Chi tiết từng Service trong Template
Mỗi service trong chương này được trình bày theo cấu trúc chuẩn mực: Chức năng → Sơ đồ luồng (Internal Flow) → Bảng API công khai → Cấu hình liên quan → Code mẫu sử dụng → Sai lầm thường gặp → Cách test trong Editor.
1. Dịch vụ Quảng cáo: IAdsService (MaxAdsService & AdsPolicy)
Nhiệm vụ: Quản lý toàn bộ vòng đời hiển thị quảng cáo qua AppLovin MAX: Banner, Interstitial, Rewarded, App Open. Tự động thử lại khi tải thất bại với cơ chế Exponential Backoff (2s, 4s, 8s... tối đa 64s). Tự động tắt tiếng game khi xem quảng cáo toàn màn hình.
Sơ đồ luồng hoạt động của Ads & AdsPolicy:
flowchart TD
Start["Yêu cầu hiện Interstitial (TryShowInterstitial)"] --> PolicyCheck{"Kiểm tra AdsPolicy"}
PolicyCheck -->|"User đã mua Remove Ads"| SkipNoAd["Bỏ qua: Trả về false"]
PolicyCheck -->|"Chưa qua delay đầu phiên (60s)"| SkipNoAd
PolicyCheck -->|"Chưa đủ khoảng cách 2 ads (30s)"| SkipNoAd
PolicyCheck -->|"Thỏa mãn policy"| ReadyCheck{"MaxSdk.IsInterstitialReady?"}
ReadyCheck -->|"Chưa sẵn sàng"| SkipNoAd
ReadyCheck -->|"Đã sẵn sàng"| ShowAd["1. Mute AudioListener
2. MaxSdk.ShowInterstitial"]
ShowAd --> UserClose["Người chơi đóng quảng cáo"]
UserClose --> ResetPolicy["1. Đặt lại mốc thời gian AdsPolicy
2. Bật lại AudioListener
3. Tải trước quảng cáo tiếp theo"]
ResetPolicy --> FireCallback["Gọi onClosed()"]
SkipNoAd --> FireCallback
style PolicyCheck fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#fff
style FireCallback fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#fff
Bảng API công khai (IAdsService):
| Thành viên | Kiểu dữ liệu | Mô tả |
|---|---|---|
IsInitialized |
bool { get; } |
MAX SDK đã hoàn tất khởi tạo chưa. |
IsInterstitialReady |
bool { get; } |
Quảng cáo Interstitial đã sẵn sàng để chiếu chưa. |
IsRewardedReady |
bool { get; } |
Quảng cáo Rewarded đã sẵn sàng để chiếu chưa. |
ShowBanner() |
void |
Tạo và hiển thị Banner ở đáy màn hình. |
HideBanner() |
void |
Ẩn Banner hiện tại. |
TryShowInterstitial(placement, onClosed) |
bool |
Thử chiếu Interstitial. Hàm onClosed LUÔN ĐƯỢC GỌI đúng 1 lần kể cả khi bị chặn hoặc lỗi! |
ShowRewarded(placement, onResult) |
void |
Chiếu quảng cáo nhận thưởng. onResult trả về enum RewardedResult. |
TryShowAppOpen() |
bool |
Chiếu quảng cáo App Open khi user quay lại ứng dụng. |
ShowMediationDebugger() |
void |
Mở giao diện Mediation Debugger của MAX trên thiết bị thật. |
FullscreenOpened / FullscreenClosed |
event Action<AdFormat> |
Sự kiện phát ra khi bất kỳ quảng cáo toàn màn hình nào mở hoặc đóng. |
Cấu hình liên quan: CoreConfig.maxSdkKey, bannerAdUnit, interstitialAdUnit, rewardedAdUnit, appOpenAdUnit, autoShowBanner. Remote Config keys: inter_interval_sec (mặc định 30s), inter_first_delay_sec (60s), app_open_enabled (false).
Code mẫu sử dụng:
// 1. Hiện quảng cáo nhận thưởng: CHỈ THƯỞNG KHI KẾT QUẢ LÀ Rewarded
App.Ads.ShowRewarded("double_coins", result =>
{
if (result == RewardedResult.Rewarded)
{
GameApp.Wallet.Add(100);
App.Overlay.Toast("+100 Coins!");
}
});
// 2. Hiện Interstitial khi qua màn: onClosed luôn chạy, đảm bảo chuyển màn không bao giờ kẹt
App.Ads.TryShowInterstitial("level_end", () =>
{
App.Scenes.Load(SceneId.Home);
});
1. Cấp thưởng bừa bãi: Thưởng coin cho người chơi ngay khi gọi hàm mà không kiểm tra result == RewardedResult.Rewarded. Nếu user bấm đóng sớm (Skipped) hoặc không có mạng (NotReady), họ vẫn nhận quà → gian lận!
2. Điều hướng game ngoài callback: Gọi TryShowInterstitial() rồi ngay dòng tiếp theo gọi chuyển scene, khiến quảng cáo vừa mở lên thì scene bị hủy → Crash SDK MAX.
Cách test trong Editor: Bấm F1 mở Debug panel → Bấm nút "Show Interstitial" hoặc "Show Rewarded". (Lưu ý: Trong Editor khi chưa điền MAX key thật, callback sẽ trả về NotReady là hoàn toàn bình thường).
2. Dịch vụ Mua hàng trong ứng dụng: IIapService (IapService & PendingOrderStore)
Nhiệm vụ: Xử lý thanh toán số an toàn tuyệt đối qua Unity IAP 5.3.1 (StoreController). Tự động xác thực biên lai (Receipt Validation), hỗ trợ giao dịch chờ (Pending/Deferred), tự động mở khóa Remove Ads, và lưu trữ đơn hàng chưa hoàn tất bằng PendingOrderStore để chống mất đồ khi game bị crash.
Sơ đồ luồng mua hàng Crash-Safe:
sequenceDiagram
autonumber
actor Player as Người chơi
participant IAP as IapService
participant Store as Google Play / App Store
participant Disk as PendingOrderStore
participant Game as GameWallet (GrantHandler)
Player->>IAP: Purchase("com.mygame.coins_100")
IAP->>Store: Yêu cầu mua
Store-->>IAP: OnPurchasePending(Order)
IAP->>IAP: Xác thực biên lai (Receipt Validation)
IAP->>Disk: 1. AddPending (ghi đơn vào PendingOrderStore)
IAP->>Game: 2. Gọi GrantHandler(productId)
Note over Game: Cộng coin vào ví & Lưu LocalStorage ngay lập tức
Game-->>IAP: Trả về true (Đã cấp quà an toàn)
IAP->>Disk: 3. MarkGranted (đánh dấu đã cấp trong PendingOrderStore)
IAP->>Store: 4. ConfirmPurchase (Xác nhận với Store)
IAP-->>Player: Callback UI Success
Hàm PurchaseGrantHandler của game CHỈ ĐƯỢC PHÉP TRẢ VỀ TRUE sau khi dữ liệu đã được cộng và lưu thành công xuống đĩa (LocalStorage.Save()). Nếu trả về false hoặc app bị crash giữa chừng, đơn hàng sẽ tiếp tục nằm lại trong PendingOrderStore và Core sẽ tự động gọi lại hàm grant ngay khi mở app lần sau!
Bảng API công khai (IIapService):
| Thành viên | Kiểu dữ liệu | Mô tả |
|---|---|---|
IsReady |
bool { get; } |
StoreController đã kết nối thành công và tải xong danh sách sản phẩm chưa. |
Ready |
event Action |
Sự kiện kích hoạt khi Store IAP sẵn sàng. |
RemoveAdsGranted |
event Action |
Sự kiện kích hoạt khi gói xóa quảng cáo được kích hoạt. |
SetGrantHandler(handler) |
void |
Đăng ký hàm xử lý cấp quà của game (được gọi duy nhất 1 lần trong GameEntry). |
GetLocalizedPrice(productId) |
string |
Lấy chuỗi giá tiền bản địa hóa từ Store (ví dụ: "23.000 ₫" hoặc "$0.99"). |
IsOwned(productId) |
bool |
Kiểm tra xem user đã sở hữu sản phẩm non-consumable này chưa. |
Purchase(productId, onDone) |
void |
Kích hoạt popup mua hàng của OS. onDone chỉ dùng để phản hồi UI (tắt loading, đóng shop). |
Restore(onDone) |
void |
Khôi phục các gói non-consumable đã mua trước đó (Bắt buộc trên iOS). |
Cấu hình liên quan: CoreConfig.removeAdsProductId (mặc định: com.tapbrain.template.removeads), file danh mục Assets/Resources/IAPProductCatalog.json.
Code mẫu sử dụng:
// 1. Đăng ký Grant Handler trong GameEntry.cs:
App.Iap.SetGrantHandler(productId =>
{
if (productId == GameKeys.ProductCoinsSmall)
{
GameApp.Wallet.Add(100); // Add() tự động gọi LocalStorage.Save()
return true; // Xác nhận đã cấp quà an toàn!
}
return false;
});
// 2. Mua hàng từ giao diện Shop:
App.Iap.Purchase(GameKeys.ProductCoinsSmall, result =>
{
// TUYỆT ĐỐI KHÔNG CẤP COIN Ở ĐÂY!
// Đây chỉ là callback phản hồi giao diện
if (result == PurchaseResult.Success) App.Overlay.Toast("Mua thành công!");
else if (result == PurchaseResult.Cancelled) App.Overlay.Toast("Đã hủy giao dịch");
});
Cách test trong Editor: Unity IAP tích hợp sẵn Fake Store. Bấm F1 → Bấm nút "Buy com.tapbrain.template.coins_small" → Chọn "Buy" trên popup giả lập → Coin sẽ tăng lên 100 ngay lập tức!
3. Dịch vụ Phân tích dữ liệu: AnalyticsService (Hub & Sinks)
Nhiệm vụ: Đóng vai trò là trạm trung chuyển (Fan-out Hub). Game chỉ cần gọi một dòng duy nhất, sự kiện sẽ được tự động định dạng và gửi tới mọi backend đang được bật:
- FirebaseAnalyticsSink: Gửi sự kiện hành vi và user property theo chuẩn Google Analytics 4 (GA4). Tự động ghi nhận doanh thu IAP (
purchase) và doanh thu quảng cáo (ad_impression). - GameAnalyticsSink: Gửi Design event (chỉ lấy tham số
"value") và Business event. Doanh thu quảng cáo đi qua hệ thống ILRD của GameAnalytics. - AppMetricaSink: Gửi sự kiện kèm JSON tham số chi tiết và báo cáo doanh thu ad/IAP.
Bảng API công khai (AnalyticsService):
| Phương thức | Mô tả |
|---|---|
LogEvent(name, parameters) |
Gửi sự kiện kèm từ điển tham số (chấp nhận: int, long, float, double, bool [gửi dạng 0/1], string). |
SetUserProperty(name, value) |
Gắn thuộc tính định danh cho người dùng (ví dụ: "ab_group", "vip_level"). |
LogAdRevenue(in AdRevenueInfo info) |
Báo cáo doanh thu của một lượt xem quảng cáo (thường do MaxAdsService tự gọi). |
LogPurchase(in PurchaseInfo info) |
Báo cáo giao dịch mua IAP thành công (thường do IapService tự gọi). |
Code mẫu sử dụng:
App.Analytics.LogEvent("booster_used", new Dictionary
{
{ "type", "hammer" },
{ "level", 12 },
{ "coins_left", 350 }
});
Tên sự kiện phải viết theo chuẩn snake_case, chỉ chứa chữ thường, số và dấu gạch dưới, tối đa 40 ký tự (giới hạn nghiêm ngặt của Firebase). Không dùng khoảng trắng hoặc ký tự có dấu!
4. Dịch vụ Cấu hình từ xa: RemoteConfigService
Nhiệm vụ: Lấy giá trị tham số cấu hình từ Firebase Remote Config. Hỗ trợ đầy đủ bộ đệm giá trị mặc định local (offline defaults) trong CoreConfig và GameEntry, đảm bảo game luôn đọc được giá trị hợp lệ ngay cả khi mất mạng hoặc chưa fetch xong.
Bảng API công khai (RemoteConfigService):
| Thành viên | Mô tả |
|---|---|
IsFetched |
Đã fetch thành công từ server Firebase ít nhất một lần chưa. |
Updated |
Event kích hoạt khi một lượt fetch hoàn thành và kích hoạt giá trị mới. |
AddDefault(key, value) |
Đăng ký giá trị mặc định lúc offline (thường gọi trong GameEntry). |
GetLong(key, fallback) |
Đọc số nguyên 64-bit (chuyển sang int an toàn). |
GetDouble(key, fallback) |
Đọc số thực độ chính xác kép. |
GetBool(key, fallback) |
Đọc giá trị boolean (hỗ trợ cả dạng "true"/"false" và "1"/"0"). |
GetString(key) |
Đọc chuỗi ký tự thô. |
Fetch() |
Coroutine kích hoạt fetch và activate thủ công. |
Code mẫu sử dụng:
// Đọc số coin thưởng khi thắng màn chơi (mặc định là 50 nếu offline)
int winCoins = (int)App.RemoteConfig.GetLong(GameKeys.RcWinCoins, 50);
// Lắng nghe giá trị thay đổi từ server:
App.RemoteConfig.Updated += () =>
{
Debug.Log("Remote Config đã nhận giá trị mới từ Firebase!");
};
5. Realtime Database & BI Telemetry: RealtimeDbService & BIService
Nhiệm vụ:
RealtimeDbService: Cung cấp quyền truy cập Firebase Realtime Database được phân vùng an toàn theo cấu trúc/<realtimeDbRoot>/<dataset>/. Bản build Debug tự động ghi vào thư mục con/debug/, bản build Release ghi vào/release/, tránh việc dữ liệu test làm ô nhiễm cơ sở dữ liệu thật.BIService: Tự động tổng hợp và đẩy hồ sơ người dùng lên đường dẫnuser_profile/<userId>/coretại mỗi lần khởi động app (bao gồm: platform, app version, RAM, ngày cài, session count, quốc gia, remove ads, doanh thu ads...). Game có thể mở rộng ghi thêm vàouser_profile/<userId>/game.
Code mẫu sử dụng:
var db = ServiceLocator.Get();
// Ghi dữ liệu JSON
db.SetJson($"scores/{user.UserId}", JsonUtility.ToJson(highScore), success => { });
// Đọc dữ liệu JSON
db.GetJson("events/summer_event", json =>
{
if (json != null) { /* Parse dữ liệu */ }
});
6. Các dịch vụ nền tảng: ATT, Search Ads, Notifications, Update & Review, Haptic
6.1. AttService (iOS App Tracking Transparency)
Xin quyền truy cập IDFA trên iOS 14.5+. Cung cấp property Status, IsAuthorized và hàm OpenAppSettings() để đưa người chơi vào cài đặt iOS bật lại quyền nếu muốn.
6.2. SearchAdsService (Apple Search Ads Attribution)
Lấy attribution token từ Apple AdServices native, gọi API Apple 1 lần duy nhất cho mỗi lượt cài, lưu cache và bắn sự kiện asa_attribution (chứa campaignId, adGroupId, keywordId) sang analytics.
6.3. NotificationService (Thông báo cục bộ - Local Notifications)
Chỉ phục vụ nhắc nhở offline (không cần server push FCM). Tự động dọn sạch thông báo khi user mở app, và phát event ScheduleRequested khi app pause để game lên lịch:
var notif = ServiceLocator.Get();
notif.ScheduleRequested += () =>
{
notif.Schedule("TapBrain!", "Năng lượng của bạn đã hồi phục, vào chơi ngay!", DateTime.Now.AddHours(4));
};
6.4. AppUpdateService & ReviewService
AppUpdateService: Kiểm tra bản cập nhật mới trên Google Play (Play Core In-App Update) hoặc Apple App Store (iTunes Lookup). Trả kết quả qua propertyUpdateAvailablevà eventChecked(bool). Game toàn quyền quyết định khi nào hiện popup nhắc update. HàmOpenStorePage(config)mở thẳng trang store của game.ReviewService: GọiApp.Review.RequestReview()để hiện popup đánh giá sao gốc của hệ điều hành. Tuyệt đối không gắn phần thưởng quà tặng vào việc đánh giá (vi phạm chính sách Store).
6.5. HapticService (Rung phản hồi)
Tạo cảm giác xúc giác chân thực khi bấm nút, thắng hoặc thua. iOS dùng UIFeedbackGenerator, Android dùng Vibrator service.
App.Haptic.Play(HapticType.Success); // Rung khi thắng
App.Haptic.Play(HapticType.Light); // Rung nhẹ khi bấm nút
App.Haptic.Enabled = false; // Tắt rung (lưu trong Settings)
7. Hạ tầng giao diện và tiện ích: SceneLoader, DialogService, UIOverlay, Audio, LocalStorage
7.1. SceneLoader (Chuyển Scene an toàn)
Đồng bộ tuyệt đối với enum SceneId (Loading = 0, Home = 1, Main = 2). Tích hợp màn hình đen làm mờ (fade out → nạp bất đồng bộ → fade in).
// Luôn dùng enum SceneId, không bao giờ dùng chuỗi ký tự hay số index
App.Scenes.Load(SceneId.Home);
7.2. DialogService & DialogBase (Hệ thống Popup Stack)
Quản lý toàn bộ popup trên một Canvas độc lập mang DontDestroyOnLoad. Cơ chế hoạt động:
- Mọi popup đều kế thừa từ
DialogBasevà đặt prefab tạiAssets/_Project/Resources/Dialogs/<TênClass>.prefab. - Khi mở bằng
App.Dialogs.Show<T>(), service sẽ tải prefab từ Resources (có lưu cache), sinh ra trên canvas và đưa vào ngăn xếp (stack). - Phím Back trên điện thoại Android tự động đóng popup trên cùng của ngăn xếp (nếu
closeOnBack = true).
7.3. UIOverlay (Canvas trên cùng)
Nằm ở sorting order cao nhất (30.000). Cung cấp 3 tính năng quan trọng:
ShowBlocker()/HideBlocker(): Màn chắn trong suốt chặn người chơi bấm lung tung khi đang xử lý giao dịch hoặc nạp dữ liệu. Có cơ chế Ref-counted (đếm lồng nhau) an toàn.Toast(msg, seconds): Hiện thông báo nổi ở đáy màn hình.FadeOut()/FadeIn(): Làm tối/sáng màn hình bằng coroutine.
7.4. AudioService (Âm thanh chuẩn mực)
Quản lý 2 kênh âm thanh độc lập trên GameObject Core: 1 kênh looping cho nhạc nền (BGM) và 1 kênh one-shot cho hiệu ứng (SFX). Tự động lưu trạng thái bật/tắt vào LocalStorage. Tự động gọi AudioListener.pause = true khi người chơi đang xem video quảng cáo.
App.Audio.PlaySfx(clickClip);
App.Audio.PlayMusic(bgmClip);
App.Audio.SoundOn = false; // Tắt SFX
7.5. LocalStorage (Lưu trữ an toàn)
Wrapper toàn diện trên PlayerPrefs. Hỗ trợ đầy đủ các kiểu dữ liệu và chia tách rõ ràng tiền tố: Core dùng core.* (danh sách trong CoreKeys), Game dùng game.* (danh sách trong GameKeys). Dữ liệu được gom lại và lưu xuống đĩa khi gọi LocalStorage.Save().
7.6. UserIdentity (Định danh người dùng)
Sinh một chuỗi ID duy nhất cho mỗi lượt cài đặt (kết hợp Unix timestamp và 4 chữ số ngẫu nhiên). ID này được chia sẻ đồng nhất cho Firebase, Crashlytics, MAX, GameAnalytics, AppMetrica và BI.
Mở scene Main.unity, vào hàm Start() của GameplayController.cs và thêm dòng lệnh sau:
App.Overlay.Toast("Chào mừng đến với TapBrain!");
Bấm Play để xem toast hiển thị mượt mà trên canvas UIOverlay!