Prompt Có phiên bản mới cho bảy app, không cần backend
Bảy app iOS của tôi phát hiện bản mới trên App Store bằng lookup công khai của Apple, mỗi ngày một lần, không làm phiền, mười ngôn ngữ, không server riêng.

Trong bài này
Không phải ai cũng bật tự động cập nhật. Tôi ship một bản sửa lỗi, vẫn có người chạy build cũ cả mấy tuần, rồi gặp đúng cái bug tôi đã sửa. Phần lớn app của tôi không có server riêng, nên cũng chẳng có remote config nào để nói "cập nhật đi". Thứ tôi cần là một cái card nhỏ: "Đã có phiên bản 1.6. Cập nhật để có tính năng và bản sửa lỗi mới nhất", kèm hai nút Cập nhật và Để sau.
Tôi làm nó trước ở Minivid, rồi đưa cùng feature đó sang sáu app còn lại. Tài liệu feature của Linkeeper ghi rõ là "ported from Minivid's implementation (same pure module and tests)", còn tài liệu của Baton và Stampzy kết bằng câu "keep them alike". Bài này kể nó chạy thế nào, và bảy phiên bản cố ý khác nhau ở đâu.

Apple đã chạy sẵn backend
App Store có một endpoint lookup công khai. Hỏi về app của bạn, nó trả JSON có version đang bán trên store và trackId. Toàn bộ "server" chỉ có vậy:
GET https://itunes.apple.com/lookup?id=<store id>&country=<region>&t=<số ngày>Minivid, Linkeeper và Lockboxy hỏi bằng store id; Ringsy, Talkzy, Baton và Stampzy hỏi bằng bundle id (bundleId=). Cách nào thì cũng có ba chi tiết quan trọng:
countrylà vùng của điện thoại, để câu trả lời đến từ đúng storefront người dùng đang mua. Nếu storefront đó không có kết quả, code thử lại một lần không kèmcountryđể lấy store mặc định.tlà số thứ tự ngày (Math.floor(now / DAY_MS)). CDN của Apple có thể vẫn trả version cũ vài tiếng sau khi bản mới lên; một tham số đổi theo ngày giúp bản cache cũ không che mất bản release lâu được.- Không gì khác. Chỉ store id hoặc bundle id và vùng đi tới Apple. Không định danh nào, không gì về video, kịch bản hay nội dung trong kho.
Phần parse làm kỹ. parseLookup lấy kết quả đầu tiên và trả null trừ khi version là chuỗi, trackId là số, và version là dạng số chấm thuần. App chưa có trên store, mảng results rỗng hay JSON lạ đều nghĩa là "không có bản mới".
Bao lâu kiểm tra một lần
Card kiểm tra khi màn hình của nó mount và mỗi lần app quay lại foreground (AppState chuyển sang active). Store chặn lại còn tối đa một lần lookup mỗi 24 giờ (CHECK_INTERVAL_MS). Comment trong code giải thích vì sao: "Apple's CDN caches the answer for about that long anyway."
Mỗi lần lookup là fire-and-forget với timeout 5 giây qua AbortController. Comment đầu module của Minivid đặt ra tiêu chuẩn: lỗi nào cũng là im lặng, tính năng này không bao giờ được làm chậm lúc mở app hay hiện thông báo lỗi. Mất mạng, timeout, lỗi 500 hay JSON hỏng: không card, không toast, không chắn đường ai.

So sánh version mà không nói dối
Bug dễ mắc nhất ở đây là so sánh version như chuỗi. Theo thứ tự chuỗi, "1.10" đứng trước "1.9", nên người đang ở 1.9 sẽ không bao giờ được báo có 1.10. App nào cũng dùng chung một phép so sánh bằng số:
export function compareVersions(a: string, b: string): number | null {
const parse = (v: string) =>
/^\d+(\.\d+)*$/.test(v.trim()) ? v.trim().split('.').map(Number) : null;
const left = parse(a);
const right = parse(b);
if (!left || !right) return null; // không phải version dạng số: không card
for (let i = 0; i < Math.max(left.length, right.length); i++) {
const diff = (left[i] ?? 0) - (right[i] ?? 0);
if (diff !== 0) return diff > 0 ? 1 : -1;
}
return 0;
}Đoạn thiếu tính là 0, nên 1.5 bằng 1.5.0. Bất cứ thứ gì không phải số và dấu chấm đều trả null, và nơi gọi hiểu là "không có bản mới". Card chỉ hiện khi bản trên store mới hơn hẳn. Điều này cũng lo luôn cho build mà App Review hay tester TestFlight đang chạy: nó mới hơn bản đang bán, nên phép so sánh trả lời "không".

Nửa còn lại của phép so sánh là version đang cài, và đây là chỗ một bug âm thầm có thể nấp. Nếu hằng số trong JavaScript lệch khỏi MARKETING_VERSION của Xcode, người đang ở bản mới nhất lại bị bảo đi cập nhật. Có hai cách chặn, tuỳ app:
- Minivid, Linkeeper, Ringsy và Talkzy giữ hằng
APP_VERSION, và một unit test fail nếu nó không bằng mọiMARKETING_VERSIONtrong project Xcode. Lockboxy làm y vậy với version trongpackage.json. - Baton và Stampzy bỏ hằng số, đọc thẳng
CFBundleShortVersionStringtừ module native, kèm ghi chú trong tài liệu: không bao giờ dùngAPP_VERSIONtrongsrc/constants.tsvì nó có thể lệch.
Không làm phiền
Một prompt hiện quá thường xuyên sẽ bị lờ đi, tệ hơn là khiến người ta xoá app. Các luật giữ cho nó nhẹ nhàng giống nhau ở cả bảy app:
- Không bao giờ ở phiên đầu tiên sau khi cài. Người ta vừa từ store về, chẳng có gì để nhắc. Minivid, Linkeeper và Lockboxy ghi
firstSeenAtở lần kiểm tra đầu và so với thời điểm JS runtime này khởi động. Ringsy và Talkzy giữ một cờlaunchedBefore. Baton và Stampzy dùng điều kiện "chưa onboard xong khi phiên này bắt đầu". - Để sau nghĩa là ba ngày, cho đúng version đó.
SNOOZE_MSlà ba ngày và gắn với version trên store đã bị snooze. Nếu có bản còn mới hơn nữa, card hiện lại ngay. - Là card, không bao giờ là sheet hay màn chặn. Nó chỉ sống trên một màn hình, nên tự nhiên tránh được onboarding, paywall và mọi việc đang làm dở.
- Biết chờ tới lượt. Ringsy ẩn nó khi coach mark lần đầu hay What's New đang hiện; Talkzy còn ẩn khi đang hiện một take vừa được cứu; Linkeeper ẩn khi đang tìm kiếm hoặc chọn nhiều; Baton ẩn trong chế độ đang mang thai, khi màn đếm cơn gò thay cho Today.
Card nào cũng theo luật thiết kế của app đó. Trên Home của Stampzy, màu vàng thuộc về nút "Open camera", nên Cập nhật là nút phụ và Để sau là nút ghost; một commit sau đó của Talkzy cũng chuyển Cập nhật thành nút phụ với cùng lý do: mỗi màn hình chỉ một nút chính.
Bảy app khác nhau ở đâu
Module thuần thì dùng chung; phần rìa thì không.
| App | Card nằm ở | Version đang cài | Ghi chú |
|---|---|---|---|
| Minivid | Home | APP_VERSION + test | Cập nhật mở sheet App Store ngay trong app trước |
| Linkeeper | đầu Library | APP_VERSION + test | ẩn khi đang tìm kiếm hoặc chọn |
| Lockboxy | Home của kho chính, sau khi mở khoá | package.json + test | lookup chỉ chạy sau khi mở khoá |
| Ringsy | Home | APP_VERSION + test | các lần kiểm tra trùng nhau dùng chung một request |
| Talkzy | thư viện kịch bản | APP_VERSION + test | chờ coach mark và What's New |
| Baton | Today | CFBundleShortVersionString native | không có analytics nào |
| Stampzy | Home, dưới Open camera | CFBundleShortVersionString native | nút Cập nhật dạng phụ |
Lockboxy có luật chặt nhất. Card chỉ được phép ở đúng một chỗ, Home của kho chính sau khi mở khoá, và một test sẽ fail nếu file nào khác import nó. Nó không bao giờ xuất hiện trên màn khoá hay màn hình mở đầu tuỳ chọn, lookup chỉ chạy khi kho đã mở, và trong lúc card đang hiện thì các mẹo khác trên Home phải chờ, để hai card không tranh nhau cùng một cú chạm.
Một khác biệt đáng biết: chuyện gì xảy ra khi lookup trả về rỗng. Minivid, Linkeeper và Lockboxy giữ câu trả lời tốt gần nhất, nên mạng chập chờn không bao giờ giấu mất một bản cập nhật thật. Ringsy và Talkzy xoá release đã cache khi lookup không trả gì, tức là nghiêng về phía ẩn card. Hai cách này có lẽ nên được thống nhất.
Cách mở store cũng khác. Đa số app thử itms-apps://apps.apple.com/app/id<trackId> rồi lùi về URL https. Minivid thử trước sheet App Store ngay trong app, nơi nút bấm vốn đã ghi Update. Minivid, Linkeeper và Lockboxy tắt hẳn card trên Android: lookup là của Apple, và không có listing App Store nào để so.
Mười ngôn ngữ, ba event, và cách tôi test
Phần chữ chỉ có bốn key (title, body, update, later) trong cả mười catalog, version nằm trong placeholder {{version}}, nên test parity bắt được bản dịch nào làm rơi nó. Tiếng Việt đọc là "Đã có phiên bản {{version}}", với "Cập nhật" và "Để sau".
Analytics là ba event không có tham số nào: update_prompt_shown (mỗi version trên store một lần mỗi phiên), update_prompt_tapped, update_prompt_dismissed. Không version, không vùng. Baton không gửi gì cả, vì luật của nó cấm hoàn toàn analytics.
Test một tính năng phụ thuộc vào bản release tương lai thì cần một "khe cắm". App nào cũng đọc một launch argument chỉ có ở bản debug: -MinividUpdateInstalledVersion 0.0.1 của Minivid giả vờ đang chạy build cũ và so với listing thật, còn -…UpdatePreview 9.9 của Ringsy và Talkzy giả vờ store đang có 9.9 mà không cần mạng. Khe cắm này cũng bỏ qua giới hạn mỗi ngày một lần và luật phiên đầu tiên. Các flow Maestro tiếng Anh và tiếng Việt dùng nó để chụp những ảnh ở trên. Bản release không bao giờ đọc nó.
Rút ra
- Có thể bạn không cần backend cho việc này. Lookup của Apple, giới hạn mỗi ngày một lần và một tham số phá cache là đủ.
- So version bằng số, và test cả nguồn của version đang cài. Test thứ hai mới là cái ngăn bạn làm phiền người đã ở bản mới nhất.
- Nhẹ nhàng hơn ồn ào. Không ở phiên đầu, snooze theo từng version, một màn hình, im lặng khi lỗi.
- Port module thuần, còn phần rìa thì quyết theo từng app. Vị trí, style và luật riêng tư thuộc về từng app; phép tính thì không.
Cũng chính lookup này cấp dữ liệu cho trang showcase app của tôi. Phần còn lại của bộ khung mà bảy app dùng chung nằm ở bài một mình ship bảy app iOS.
Bài liên quan


