Bối cảnh Nhiều bạn dùng EAS Update và giật mình khi hoá đơn tăng theo số người dùng. Cách Expo tính: 1 máy tải ít nhất 1 bản update trong kỳ = 1 MAU. Gói Starter ($19) có 3.000 MAU, vượt thì ~$0,005/MAU. Ví dụ 10.800 MAU ⇒ riêng khoản update ~$39/tháng, app càng đông càng tốn.
Lưu ý hay hiểu nhầm: không phải "người cũ không tính, người mới mới tính". Máy nào tải update trong kỳ đều tính 1 lần (đẩy 1 hay 10 bản trong kỳ vẫn chỉ 1). Máy không tải gì thì không tính.
Giải pháp mình đang chạy
- Build: vẫn dùng EAS Build như cũ (phí cố định theo gói, không theo người dùng).
- OTA: hot-updater (github.com/gronxb/hot-updater) + Cloudflare R2 (lưu bundle) + D1 (metadata) + Worker (endpoint cho app hỏi update). R2 không tính phí egress, free tier dư cho vài chục nghìn user ⇒ gần như $0.
- Có sẵn: channel (preview/production), force update, rollback, console web, tự quay về bundle cũ nếu bundle mới crash lúc khởi động.
CÁC BƯỚC
1) Gỡ expo-updates (bắt buộc — 2 thư viện cùng giành quyền update sẽ crash)
Xoá trong app.config: khối "updates", "runtimeVersion", plugin "expo-updates". Xoá "channel" trong eas.json (của EAS Update).
2) Chuẩn bị Cloudflare
- Bật R2, tạo bucket (vd: myapp-ota), để private.
- Tạo R2 API token quyền Object Read & Write, CHỈ cho bucket đó (lấy Access Key + Secret).
- Tạo API token account quyền D1 Edit + Workers Scripts Edit (+ Workers R2 Storage Edit).
- Nếu account chưa từng dùng Workers: mở trang Workers & Pages 1 lần để Cloudflare tạo subdomain *.workers.dev. Đổi subdomain thành tên trung tính (tên công ty) NGAY LÚC NÀY — URL này sẽ gắn cứng vào app, phát hành rồi là không đổi được.
3) Init
Cài CLI cục bộ trước (chạy npx trực tiếp dễ lỗi "Cannot find package @hot-updater/cloudflare"):
Ô "D1 API Token" điền token account (bước 2), KHÔNG phải token R2. Init sẽ tạo D1, deploy Worker, sinh hot-updater.config.ts và .env.hotupdater (nhớ gitignore).
4) Gắn vào app
app.config.js:
eas.json: mỗi profile thêm env HOT_UPDATER_CHANNEL (development / preview / production).
App.tsx:
Build lại native (prebuild + pod install / eas build). Bản Debug (__DEV__) không hỏi update — test OTA phải dùng bản Release/TestFlight.
5) Đẩy update
Thêm -f để ép cập nhật (tải xong reload ngay). Rollback: npx hot-updater rollback production.
Mẹo: hiển thị HotUpdater.getBundleId() ở màn Cài đặt ⇒ nhìn là biết máy đang chạy bản OTA nào.
CHỌN appVersion HAY fingerprint?
- fingerprint: an toàn tuyệt đối (bundle chỉ tới máy có native khớp) nhưng mã hash khó nhớ, và hash lúc build trên EAS phải khớp hash lúc deploy — dễ lệch.
- appVersion: nhắm theo version "1.1.0", dễ nhớ. Quy tắc bắt buộc: ĐỔI NATIVE THÌ TĂNG VERSION. Quên là OTA tới máy cũ thiếu module ⇒ crash kiểu "TurboModuleRegistry.getEnforcing...".
Mình chọn appVersion + tự viết script guard: lúc build store thì ghi fingerprint native của version đó vào 1 file JSON; lúc deploy OTA thì tính lại, lệch là chặn. Người nhớ version, máy nhớ hash.
NHỮNG BẪY MÌNH ĐÃ DẪM
1. Biến EXPO_PUBLIC_* được Babel "nhúng cứng" LÚC BUNDLE OTA, không phải lúc build store. Deploy trên CI không có .env ⇒ bundle thiếu biến (vd Google Sign-In client id rỗng ⇒ hỏng đăng nhập của mọi máy nhận OTA). Deploy từ máy dev thì .env đang trỏ API dev cũng bị nhúng vào bản production. Cách chữa: script deploy export đúng env của eas.json (build.base.env) trước khi bundle.
2. Tự tính fingerprint bằng @expo/fingerprint: thư mục ios/ android/ (sinh ra từ prebuild, bị gitignore) làm hash máy dev khác CI ⇒ phải loại nguồn "bareNativeDir". App config đọc env ⇒ cũng phải ép env cố định khi tính.
3. "hot-updater app-version" đọc version từ thư mục native ⇒ trên CI (không có ios/android) ra null. Đọc version bằng "npx expo config --json" cho chắc.
4. "hot-updater fingerprint create" không chạy khi updateStrategy = appVersion.
5. Tự rollback chỉ bắt được crash ngay lúc khởi động. Lỗi xảy ra sau màn đầu tiên thì phải rollback tay ⇒ LUÔN thử channel preview trước production.
6. Khi gửi app cho Apple review, reviewer cũng nhận OTA channel production ⇒ đừng đẩy bản thử nghiệm lên production lúc đó.
7. Apple ID bị khoá (lỗi -20209) vì EAS tự đăng nhập bằng mật khẩu lưu Keychain. Build iOS nên thêm --non-interactive (dùng credentials đã lưu trên EAS, không đăng nhập Apple), submit bằng App Store Connect API key.
CI (tuỳ chọn)
GitHub Actions workflow_dispatch (bấm tay), chạy ubuntu là đủ (OTA chỉ bundle JS, không cần Mac). Đưa key Cloudflare vào Secrets, chỉ cấp cho đúng step deploy; chặn channel production nếu không chạy từ nhánh main; truyền input qua env (không nhét ${{ }} thẳng vào lệnh shell).
Kết quả
Đã test trên máy thật: update thường, ép cập nhật, rollback tay, bundle lỗi tự quay về bản cũ, mất mạng vẫn mở app bình thường, lệch version không nhận nhầm bundle. Chi phí OTA ~0đ, hoá đơn Expo cố định theo gói build.
Ai cần script guard fingerprint thì cmt mình gửi.
Lưu ý hay hiểu nhầm: không phải "người cũ không tính, người mới mới tính". Máy nào tải update trong kỳ đều tính 1 lần (đẩy 1 hay 10 bản trong kỳ vẫn chỉ 1). Máy không tải gì thì không tính.
Giải pháp mình đang chạy
- Build: vẫn dùng EAS Build như cũ (phí cố định theo gói, không theo người dùng).
- OTA: hot-updater (github.com/gronxb/hot-updater) + Cloudflare R2 (lưu bundle) + D1 (metadata) + Worker (endpoint cho app hỏi update). R2 không tính phí egress, free tier dư cho vài chục nghìn user ⇒ gần như $0.
- Có sẵn: channel (preview/production), force update, rollback, console web, tự quay về bundle cũ nếu bundle mới crash lúc khởi động.
CÁC BƯỚC
1) Gỡ expo-updates (bắt buộc — 2 thư viện cùng giành quyền update sẽ crash)
npm uninstall expo-updatesXoá trong app.config: khối "updates", "runtimeVersion", plugin "expo-updates". Xoá "channel" trong eas.json (của EAS Update).
2) Chuẩn bị Cloudflare
- Bật R2, tạo bucket (vd: myapp-ota), để private.
- Tạo R2 API token quyền Object Read & Write, CHỈ cho bucket đó (lấy Access Key + Secret).
- Tạo API token account quyền D1 Edit + Workers Scripts Edit (+ Workers R2 Storage Edit).
- Nếu account chưa từng dùng Workers: mở trang Workers & Pages 1 lần để Cloudflare tạo subdomain *.workers.dev. Đổi subdomain thành tên trung tính (tên công ty) NGAY LÚC NÀY — URL này sẽ gắn cứng vào app, phát hành rồi là không đổi được.
3) Init
Cài CLI cục bộ trước (chạy npx trực tiếp dễ lỗi "Cannot find package @hot-updater/cloudflare"):
npm i -D hot-updaternpx hot-updater init → chọn Expo → CloudflareÔ "D1 API Token" điền token account (bước 2), KHÔNG phải token R2. Init sẽ tạo D1, deploy Worker, sinh hot-updater.config.ts và .env.hotupdater (nhớ gitignore).
4) Gắn vào app
app.config.js:
plugins: [["@hot-updater/react-native", { channel: process.env.HOT_UPDATER_CHANNEL || "production" }]]eas.json: mỗi profile thêm env HOT_UPDATER_CHANNEL (development / preview / production).
App.tsx:
Code:
export default HotUpdater.wrap({ baseURL: "https://<worker>.<subdomain>.workers.dev/api/check-update", updateStrategy: "appVersion", onError: (e) => console.warn("[hot-updater]", e), })(App);
5) Đẩy update
npx hot-updater deploy -p ios -c production -t 1.1.0Thêm -f để ép cập nhật (tải xong reload ngay). Rollback: npx hot-updater rollback production.
Mẹo: hiển thị HotUpdater.getBundleId() ở màn Cài đặt ⇒ nhìn là biết máy đang chạy bản OTA nào.
CHỌN appVersion HAY fingerprint?
- fingerprint: an toàn tuyệt đối (bundle chỉ tới máy có native khớp) nhưng mã hash khó nhớ, và hash lúc build trên EAS phải khớp hash lúc deploy — dễ lệch.
- appVersion: nhắm theo version "1.1.0", dễ nhớ. Quy tắc bắt buộc: ĐỔI NATIVE THÌ TĂNG VERSION. Quên là OTA tới máy cũ thiếu module ⇒ crash kiểu "TurboModuleRegistry.getEnforcing...".
Mình chọn appVersion + tự viết script guard: lúc build store thì ghi fingerprint native của version đó vào 1 file JSON; lúc deploy OTA thì tính lại, lệch là chặn. Người nhớ version, máy nhớ hash.
NHỮNG BẪY MÌNH ĐÃ DẪM
1. Biến EXPO_PUBLIC_* được Babel "nhúng cứng" LÚC BUNDLE OTA, không phải lúc build store. Deploy trên CI không có .env ⇒ bundle thiếu biến (vd Google Sign-In client id rỗng ⇒ hỏng đăng nhập của mọi máy nhận OTA). Deploy từ máy dev thì .env đang trỏ API dev cũng bị nhúng vào bản production. Cách chữa: script deploy export đúng env của eas.json (build.base.env) trước khi bundle.
2. Tự tính fingerprint bằng @expo/fingerprint: thư mục ios/ android/ (sinh ra từ prebuild, bị gitignore) làm hash máy dev khác CI ⇒ phải loại nguồn "bareNativeDir". App config đọc env ⇒ cũng phải ép env cố định khi tính.
3. "hot-updater app-version" đọc version từ thư mục native ⇒ trên CI (không có ios/android) ra null. Đọc version bằng "npx expo config --json" cho chắc.
4. "hot-updater fingerprint create" không chạy khi updateStrategy = appVersion.
5. Tự rollback chỉ bắt được crash ngay lúc khởi động. Lỗi xảy ra sau màn đầu tiên thì phải rollback tay ⇒ LUÔN thử channel preview trước production.
6. Khi gửi app cho Apple review, reviewer cũng nhận OTA channel production ⇒ đừng đẩy bản thử nghiệm lên production lúc đó.
7. Apple ID bị khoá (lỗi -20209) vì EAS tự đăng nhập bằng mật khẩu lưu Keychain. Build iOS nên thêm --non-interactive (dùng credentials đã lưu trên EAS, không đăng nhập Apple), submit bằng App Store Connect API key.
CI (tuỳ chọn)
GitHub Actions workflow_dispatch (bấm tay), chạy ubuntu là đủ (OTA chỉ bundle JS, không cần Mac). Đưa key Cloudflare vào Secrets, chỉ cấp cho đúng step deploy; chặn channel production nếu không chạy từ nhánh main; truyền input qua env (không nhét ${{ }} thẳng vào lệnh shell).
Kết quả
Đã test trên máy thật: update thường, ép cập nhật, rollback tay, bundle lỗi tự quay về bản cũ, mất mạng vẫn mở app bình thường, lệch version không nhận nhầm bundle. Chi phí OTA ~0đ, hoá đơn Expo cố định theo gói build.
Ai cần script guard fingerprint thì cmt mình gửi.