R2CDN — Hướng dẫn cho team

Cloudflare R2 làm primary, Unity CCD làm fallback. Phân phối nội dung Addressables cho iOS + Android, có gate kiểm tra offline trước khi vào màn. Hướng dẫn cho cả senior và junior — đọc xong dùng được ngay.

Quick-start: Section 1 (tổng quan) → Section 3 (workflow) → Section 4 (Manager). Là đủ làm việc. Section 5+ cho người cần đào sâu.
Markdown docs đi kèm — click để mở trong viewer (render đẹp), hoặc mở trong IDE:
README ARCHITECTURE BUILD-PROCESS INTEGRATION PORTING-GUIDE TROUBLESHOOTING CHANGELOG

1. Tổng quan

R2CDN là gì? Là package Unity (nằm trong Assets/R2CDN/) thay thế Cloud Content Delivery (CCD) mặc định bằng Cloudflare R2 (rẻ hơn nhiều). Khi R2 lỗi thì tự fallback sang CCD nên người chơi không bị gián đoạn.

Tính năngLàm gì
R2 primary, CCD fallbackMỗi bundle tải từ R2 trước. Lỗi → tự thử CCD.
Pre-build Verify tab15+ check trước khi build APK/IPA: hash khớp, file đủ, link.xml có…
Level-entry gateTrước khi vào màn, đảm bảo bundle đã tải xong. Mất mạng → popup → quay home.
Background preloadMỗi lần vào màn, tải sẵn bundle kế tiếp ngầm. Chi tiết Section 6.
Event content APIEventContent SO + helper. Plug-and-play cho event.
EDITOR — máy dev CLOUD DEVICE — người chơi Unity Editor R2CDN Manager Build Player APK / IPA build addressables Cloudflare R2 primary egress = $0 Unity CCD fallback khi R2 lỗi PUT bundles App trên điện thoại R2FallbackBundleProvider Người chơi vào màn Gate đảm bảo content sẵn sàng GET bundles fallback nếu R2 fail install APK
Hình 1 — Bức tranh tổng thể: Editor build/upload nội dung lên R2; APK trên điện thoại tải bundle từ R2 (chính), CCD (dự phòng).
Đọc thêm: README.md · ARCHITECTURE.md

2. Cài đặt (làm 1 lần / máy)

Điều kiện trước

  1. File .r2-credentials.jsonthư mục gốc repo (không phải trong Assets/). Đã gitignore:
    {
      "accessKeyId":     "…",
      "secretAccessKey": "…",
      "endpoint":        "https://<acct>.r2.cloudflarestorage.com",
      "buckets":    { "dev": "matchbuddies-dev",  "prod": "matchbuddies-prod" },
      "publicUrls": { "dev": "https://pub-…r2.dev", "prod": "https://pub-…r2.dev" }
    }
    Lấy từ team lead — không bao giờ commit.
  2. Cloudflare Dashboard → R2 → bucket → Settings → bật Allow R2.dev subdomain. Không bật → mọi request trả 403.
  3. Vào Window → R2CDN → Manager → Settings → kiểm tra credentials = ✅ Loaded.
  4. File Assets/R2CDN/Runtime/link.xml phải tồn tại. Thiếu → IL2CPP strip code R2 → app không tải được.

Auto-Fix Addressables (mỗi platform)

Mở Manager → Build Setup → Auto-Fix All (Re-apply). Tool tự enforce setting:

SettingGiá trịVì sao
Build with PlayerOffBuild Addressables riêng, không trộn player build
Remote BuildPathServerData/[BuildTarget]Output bundle ra ngoài Library/
Remote LoadPath{R2 base}/[BuildTarget]Player tải về từ URL R2
Bundle namingAppend Hash to FilenameĐổi nội dung → đổi filename → không cache cũ
Provider trên Remote groupsR2FallbackBundleProviderCó R2 → CCD fallback
Phải re-run Auto-Fix khi: switch BuildTarget (iOS ↔ Android), pull branch sửa Addressables groups, thêm group RemoteTexture_* mới.

3. Quy trình hằng ngày

1 Sửa asset level data, sprite 2 Build + Upload Manager → Upload tab 3 Verify Run All Checks 4 Build Player APK / IPA 5 Test device Bước 3 (Verify) BẮT BUỘC trước khi build APK
Hình 2 — Quy trình 5 bước. Mỗi lần content đổi: làm hết 5 bước. Chỉ sửa code C# (không động asset): chỉ làm bước 4-5.
Tuyệt đối không bỏ qua bước 3 (Verify). Build player với catalog hash cũ = sprite trắng trên thiết bị.
Đọc thêm: BUILD-PROCESS.md — chi tiết từng bước

4. Manager Tabs

Mở qua Window → R2CDN → Manager. Top bar: tab strip + platform badge + toggle Dev/Prod + reload + nút ? (mở guide này).

TabDùng đểKhi nào dùng
SettingsEdit credentials, R2 URLs, env mode (Auto/Dev/Production), timeout. Test connection.Lần đầu setup, hoặc đổi env build.
AddressablesXem read-only các Addressables groups.Debug — kiểm tra group nào remote.
Build SetupAuto-Fix All — enforce mọi setting.Sau khi switch platform / pull branch.
UploadBuild + upload Addressables lên R2.Mỗi lần asset đổi.
Verify15+ check trước khi build player.Bắt buộc trước Bước 4.
BrowseLiệt kê file trong R2 bucket.Debug — kiểm tra bundle có thực sự trên server.
Env mode trong Settings:
  • Auto = theo Debug.isDebugBuild (Development Build = ✓ → dev, không tick → prod).
  • Dev = ép dùng dev URL (cho QA build).
  • Production = ép dùng prod URL (cho store build).

5. Kiến trúc & Flow

Build & Upload — chuyện gì xảy ra

Assets/ .png · .asset · prefab BuildPlayerContent() Manager → Upload tab → Build + Upload Library/aa/<Platform>/ catalog.bin catalog.hash builtin bundles ServerData/<Platform>/ RemoteTexture_*.bundle RemoteEvent_*.bundle R2HttpClient.Put() AWS Sig V4 + ETag verify + manifest cache
Hình 3 — Build & Upload flow. BuildPlayerContent() tạo 2 thư mục output. Uploader walk cả 2, diff với manifest (MD5), PUT cái nào đổi lên R2.

Lúc người chơi vào màn — chuyện gì xảy ra

User tap "Play L51" MIMGameController.LoadLevel Bundle 51-60 đã cache? Vào màn instant CHƯA Có internet? Download silent watchdog 60s · stuck 15s xong KHÔNG Popup tiếng Anh "Connection Required" → ScreenHome
Hình 4 — Runtime gate flow. 3 nhánh: cached → instant; online + chưa cache → tải im lặng; offline + chưa cache → popup → home.

Toàn bộ logic này nằm trong R2Gate.EnsureLevelReadyCoroutine. Watchdog: không có progress trong 15s → abort với mã "download_stuck"; tổng 60s → "download_timeout".

R2 → CCD fallback (mỗi bundle)

Request bundle Addressables runtime GET R2 URL timeout = 10s 200 OK Use bundle load asset thành công 5xx / timeout GET CCD URL fallback 200 OK cả 2 fail HARD FAIL gate báo → popup
Hình 5 — R2 → CCD fallback chain. R2 timeout/lỗi → tự động thử CCD. Cả 2 fail → gate báo lỗi → popup.
Đọc thêm: ARCHITECTURE.md — chi tiết design decisions

6. Background Preload — tải ngầm bundle kế tiếp

Đây là cơ chế quan trọng giúp người chơi vào màn instant không phải đợi tải. Chạy ngầm, không hiện UI gì.

Ý tưởng

Khi người chơi đang ở bundle hiện tại (vd L41-L50 builtin trong APK), tự động tải bundle kế tiếp (L51-L60 remote) trong background. Tới lúc họ vượt qua L50 và bấm Play L51, bundle đã có sẵn → vào màn instant.

L41 L41-L49 L51 entry L52, L53... Mỗi level entry PreloadUpcomingLevels(currentLevel) → tải bundle 51-60 ngầm Bundle đã sẵn Gate: needsDownload=0 → Vào màn instant Bundle 51-60 download (silent) Player progress timeline
Hình 6 — Background preload. Tại mỗi level entry trong bundle hiện tại (L41-L49), kick preload bundle kế tiếp. Đến L51 → đã cache → instant.

Cơ chế

Khi nào triggerLàm gì
Mỗi lần MIMGameController.LoadLevel được gọiAddressablesPreloader.PreloadUpcomingLevels(currentLevel) chạy ngầm
Đang preload bundle target XTrigger lại với cùng X → SKIP (không restart)
Đang preload target X, trigger với target Y khácStart coroutine MỚI cho Y, song song với X
Bundle đã preload xongMarker giữ lại, gọi lại với same target → SKIP
Vì sao không cancel khi trigger lại? Cancel-and-restart làm reset progress download. Lần trước đó tải được 80% rồi → cancel → mất, phải tải lại từ 0%. Logic mới: nếu đang tải target đúng → để yên, đừng đụng vào.

Đọc log preload

# Vào L41 lần đầu
[AddressablesPreloader] Preload START currentLevel=41 nextBundleStart=50
# tải bundle 51-60 ngầm...

# Vào L42 (cùng bundle, cùng target)
[AddressablesPreloader] Preload skip — already preloading bundle starting at 50

# ... tiếp tục L43..L49 → đều skip

# Vào L51 (bundle đã cache)
[R2Gate] EnsureLevelReady BEGIN displayLevel=51
[R2Gate] STEP 2 size=0 bytes
[R2Gate] All atlases cached — PASS    ← instant entry!
Edge case fresh install ở L51: nếu user fresh install và save restore thẳng vào L51, preload chưa kịp chạy ở L41-L49 → bundle 51-60 chưa có cache. Lúc này gate sẽ tự download reactively (vẫn silent, dùng existing loading screen cover).

7. Kết nối với game

Public API surface

Toàn bộ "mặt tiền" mà game code chạm vào R2CDN. Không có gì khác.

namespace R2CDN.Runtime
{
    public interface IRemoteLevelSource
    {
        // Game implement: với 1 level number, trả về list AssetReference cần load
        IEnumerator GetLevelDependenciesAsync(int displayLevel,
            Action<List<AssetReference>> onResult);
    }

    public static class R2Gate
    {
        public static void Initialize(IRemoteLevelSource source);

        // Gate cho normal level (qua IRemoteLevelSource)
        public static IEnumerator EnsureLevelReadyCoroutine(int displayLevel,
            Action<bool, string> onComplete, Action<float> onProgress = null);

        // Gate generic (event, store, daily reward, ...)
        public static IEnumerator EnsureRefsReadyCoroutine(List<AssetReference> deps,
            Action<bool, string> onComplete, Action<float> onProgress = null);
    }

    public sealed class RemoteContentOverlay : MonoBehaviour
    {
        // Popup runtime, không cần prefab
        public static void ShowError(string title, string body, string btn, Action onDismiss);
    }
}

3 file game-side đụng vào R2CDN

FileĐụng gìSố dòng R2
MatchSquadLevelSource.csAdapter — implement IRemoteLevelSourceCả file (~110 dòng)
AddressablesPreloader.csR2Gate.Initialize(...) ở Awake1 dòng
MIMGameController.csGateThenLoadLevel coroutine trong LoadLevel~25 dòng
[R2Gate] debug log do R2 package phát ra nội bộ. Game code không chứa log nào của R2 — sạch sẽ.
Đọc thêm: INTEGRATION.md — chi tiết từng touchpoint + audit checklist · PORTING-GUIDE.md — port qua game khác (adapter examples, preload, architecture)

8. Hệ thống Events (Match Race, Hat Race, …)

Mọi event remote dùng chung 1 pattern: ScriptableObject manifest + helper preloader. Không cần đụng R2Gate per event.

L19 L20 unlock L21, L22, L23... tap event Event button SHOW PreloadIfNeeded(content, this) → tải bundle ngầm (fire-and-forget) Background download (silent) User tap "Match Race" EnsureReady(content, callback) → cached → instant entry → ScreenMatchRace Player progress timeline
Hình 7 — Event preload timeline. L20 unlock → button show → preload chạy ngầm. Player chơi tiếp L21, L22... → đến lúc tap event thì content đã sẵn.

Cách wire-up 1 event mới (3 bước)

Bước 1: tạo EventContent asset trong Project:

Assets → Create → MIM → Events → Event Content

  • Set eventId: lowercase string ổn định, vd "match_race".
  • Drag mọi addressable asset event cần vào dependencies: prefab, sprite atlas, texture, ScriptableObject, audio... Mix kiểu nào cũng được.

Bước 2: trong ScreenHome, khi event button show ra (do remote config + level unlock), gọi preload:

[SerializeField] private EventContent matchRaceContent;

private void RefreshMatchRaceButton()
{
    bool unlocked = MatchRaceManager.Instance != null && IsRemoteConfigEnabled("match_race");
    if (unlocked) matchRaceButton.Show();
    else          matchRaceButton.Hide(true);

    // Fire-and-forget — tự dedupe, gọi nhiều lần OK.
    if (unlocked && matchRaceContent != null)
        EventContentPreloader.PreloadIfNeeded(matchRaceContent, this);
}

Bước 3: khi player tap event button, gate trước khi navigate:

private IEnumerator EnterMatchRace()
{
    bool ok = false; string err = null;
    yield return EventContentPreloader.EnsureReady(matchRaceContent,
        (s, e) => { ok = s; err = e; });

    if (ok) { UIController.ShowPage<ScreenMatchRace>(); yield break; }

    R2CDN.Runtime.RemoteContentOverlay.ShowError(
        "Connection Required",
        "Match Race needs internet to download content. Please connect and try again.",
        "OK", onDismiss: null);
}
Cuối cùng tạo Addressables group RemoteEvent_<EventName>, chạy Auto-Fix, build + upload. Verify tab tự bắt cross-bundle leak.

9. Khắc phục lỗi thường gặp

Sprite trắng ở level N (N ≥ 51) trên thiết bị

  1. Log adb: tìm [R2Gate]. Không thấy log init = IL2CPP đã strip → check Assets/R2CDN/Runtime/link.xml tồn tại.
  2. Init log thấy env=production mà bạn upload lên dev → mismatch. Settings tab → ép Dev, hoặc upload prod.
  3. [R2Gate] STEP 1 FAIL → catalog reference đến bundle không có trên R2. Re-upload + Verify.

Download stuck (STEP 4 STUCK)

  1. Tìm URL trong log WebRequest → GET …. curl -I URL đó. Mong 200.
  2. 403 → bucket Public Access chưa bật.
  3. 404 → bundle không có trên R2. Manager → Upload, rồi Verify.
  4. 200 từ máy bạn nhưng device hang → vấn đề mạng device (proxy, IPv6, captive portal).

Verify báo Local hash != R2 hash

Đã build Addressables nhưng chưa upload, hoặc upload sai env. Manager → Upload đúng env, rồi Verify lại.

Bundle download thành công nhưng tile trắng

Cross-bundle dependency. Window → Asset Management → Addressables → Reports → Build Layout. Tìm bundle thiếu hoặc bị split. Move asset về đúng group, rebuild.

Đọc thêm: TROUBLESHOOTING.md — bảng symptom → fix đầy đủ

10. Tham khảo nhanh

Đường dẫn quan trọng

Cái gìĐâu
Credentials (gitignored)<repo>/.r2-credentials.json
Runtime configAssets/R2CDN/Runtime/Resources/R2FallbackConfig.asset
IL2CPP link xmlAssets/R2CDN/Runtime/link.xml
Built catalogLibrary/com.unity.addressables/aa/<Platform>/
Built remote bundlesServerData/<Platform>/
Upload manifest (gitignored)<repo>/.r2-upload-manifest-<env>.json
Manager windowWindow → R2CDN → Manager
Guide nàyWindow → R2CDN → Open Documentation

Log prefixes — filter logcat

PrefixNguồnLệnh filter
[R2Gate]R2 gate, providers, initadb logcat -s Unity | grep R2Gate
[R2Fallback]R2 → CCD fallback events… | grep R2Fallback
[AddressablesPreloader]Game preload (next bundle)… | grep AddressablesPreloader
[EventPreload]Event content download… | grep EventPreload
[R2]Editor uploader(Manager Upload tab log)

Markdown docs đầy đủ — click mở viewer (render đẹp), hoặc mở trong IDE:
README.md ARCHITECTURE.md BUILD-PROCESS.md INTEGRATION.md PORTING-GUIDE.md TROUBLESHOOTING.md CHANGELOG.md

Tài liệu này dùng tiếng Việt cho phần giải thích, giữ nguyên tiếng Anh cho thuật ngữ chuyên ngành (Addressables, bundle, catalog, gate, AssetReference, …) để khi search code/docs Unity vẫn khớp.