Bỏ qua, tới nội dung
Ứng dụng indie
6 phút đọc

Một mình ship bảy app iOS: stack và quy trình làm việc

Cách tôi một mình làm bảy app iOS: chung một stack React Native, engine Swift native, Maestro, checklist máy thật và một landing Next.js cho mỗi app.

Dao Van Thuong
Mobile & Fullstack Engineer
Read in English
Ảnh bìa: Một mình ship bảy app iOS, stack và quy trình làm việc
Trong bài này
  1. Stack dùng chung
  2. SDK bên thứ ba nằm sau port
  3. Mỗi repo trông như thế nào
  4. Test: Jest, Maestro, rồi iPhone thật
  5. Mười ngôn ngữ ngay từ đầu
  6. Release, landing và showcase
  7. Những gì tôi sẽ giữ lại

Tôi đang tự làm bảy app iOS: Lockboxy (kho riêng tư có mã hoá), Linkeeper (lưu link đọc sau), Minivid (nén video), Ringsy (làm nhạc chuông), Talkzy (teleprompter), Baton (theo dõi em bé) và Stampzy (camera đóng dấu giờ và GPS). Cả bảy nằm cạnh nhau trên trang showcase.

Một người chỉ kham nổi bảy app khi chúng thôi là bảy dự án khác nhau. App mới luôn chép lại khuôn của app trước. File hướng dẫn cho agent của Baton ghi thẳng: "Built the way Subzy is built; when unsure, copy what Subzy does." Stampzy cũng ghi y vậy về Baton và Talkzy. Bài này kể về cái khuôn đó: stack, cách tổ chức repo, và vòng lặp mà mỗi bản release phải đi qua.

Ảnh App Store của Baton, Ringsy và Talkzy đặt cạnh nhau
Ba trong bảy app: Baton, Ringsy và Talkzy

Stack dùng chung

App nào cũng là React Native CLI bản bare, viết TypeScript, không dùng Expo. Năm app mới nhất chạy React Native 0.87 với New Architecture và Hermes; Lockboxy và Linkeeper, hai app lâu đời nhất, ở 0.86. Phần JavaScript còn lại giống hệt nhau:

ViệcDùng gì
StateZustand, lưu xuống MMKV
Điều hướngReact Navigation 7
Chuỗi hiển thịi18next + react-i18next, mười catalog
Mua hàngRevenueCat (react-native-purchases) ở cả bảy app
AnalyticsGoogle Analytics for Firebase ở sáu app; Baton không có, theo luật riêng
Quảng cáoAdMob, chỉ ở Lockboxy và Linkeeper
TestJest, cộng swift test cho phần native

Lý do tôi ở lại với bản bare là nửa còn lại của mỗi app: một engine Swift native. JavaScript không bao giờ làm phần nặng. Minivid có các module video-engine, image-engine và vault; Ringsy có ringtone-engine; Talkzy có prompter-engine; Stampzy có camera-engine; Baton có baton-core, một kho SQLite mà widget và nút trên Live Activity ghi vào được ngay cả khi JS không chạy. Luật cứng đầu tiên của Stampzy là "JavaScript never touches pixels": camera, vẽ overlay, vị trí và đồng hồ đều là native, JS chỉ gửi một spec và nhận event về tối đa mỗi giây một lần.

Mỗi engine có một file engine-contract.md. Một method, prop hay event chỉ được đổi đồng thời ở cả ba chỗ: tài liệu contract, wrapper TypeScript và bridge Swift. Đa số engine còn có một harness Swift nhỏ (tools/*-harness/run.sh) để tôi test phép tính layout, đoạn cắt audio hay việc tra địa chỉ hành chính Việt Nam mà không phải build cả app.

SDK bên thứ ba nằm sau port

Luật tôi chép vào mọi repo: không gọi SDK của vendor trong code feature. RevenueCat nằm sau BillingPort, Firebase sau AnalyticsPort, AdMob sau AdsPort. Code feature gọi track() với tên event lấy từ allowlist trong events.ts, và allowlist đó ghi rõ những gì không bao giờ được gửi đi: Stampzy cấm địa chỉ, toạ độ, ghi chú hay mã ảnh; Minivid cấm tên file, đường dẫn hay dung lượng.

Một luật mà lời ba chỗ. Jest chạy với billing port giả, không cần store. Đổi hay gỡ một SDK chỉ là sửa một adapter. Và chính sách quyền riêng tư không nói sai, vì chỉ cần đọc đúng một file là biết cái gì rời khỏi điện thoại.

Luật về kiếm tiền cũng được viết thành test chứ không chỉ là ý định. AGENTS.md của Stampzy có câu "the free-tier pledge is a test (entitlements.test.ts). Fix the change, not the test", và paywall chỉ hiện trial khi store báo có intro offer miễn phí và RevenueCat nói người dùng đủ điều kiện.

Sơ đồ stack dùng chung và vòng release
Cái khuôn mà app nào cũng chép

Mỗi repo trông như thế nào

Mở bất kỳ repo nào trong bảy cái cũng thấy cùng một bộ khung:

txt
AGENTS.md            hợp đồng: app là gì, luật cứng, bảng test
CLAUDE.md            một dòng trỏ sang AGENTS.md
.claude/config.md    stack, lệnh, port, những gì còn phải đăng ký
src/                 features/, services/ (port), store/, i18n
modules/<engine>/    module Swift native
tools/               harness, script i18n, release/upload.sh
.maestro/            các flow UI
docs/                PRODUCT.md, architecture/, features/, changelogs/,
                     app-store/, device-checklist.md, audits/

AGENTS.md viết cho bất kỳ coding agent nào, và cho chính tôi sau một tháng không đụng tới repo. Trong đó có luật cứng (Baton: "No analytics or crash SDKs, ever"), một bảng nói lớp nào được test bằng công cụ nào, và quy ước commit (type(scope): subject). Mỗi thay đổi mang dáng một feature đều ghi thêm vào changelog trong docs/changelogs/, nên câu hỏi "sao lại làm thế này" có câu trả lời nằm trong repo chứ không nằm trong đầu tôi.

Cả bảy app build trên cùng một máy Mac, nên cần thêm luật riêng. Mỗi app có simulator riêng, port Metro riêng (Stampzy dùng 8086, không bao giờ 8081), DerivedData trong thư mục tạm riêng, và mọi lệnh xcodebuild hay pod install phải chờ một lock dùng chung để hai bản build native không giành CPU và ổ đĩa của nhau.

Test: Jest, Maestro, rồi iPhone thật

Test có ba lớp, và bảng test trong repo nào cũng gọi tên đủ ba:

  1. Jest cho TypeScript thuần: entitlement, timer, so sánh version, migrate settings, parity i18n, app khởi động được.
  2. Maestro trên simulator cho màn hình và flow. Tính cả subflow thì bảy repo có khoảng 170 file YAML của Maestro. Các launch argument chỉ có ở bản debug đóng vai "khe cắm": -RingsyUpdatePreview 9.9 của Ringsy giả vờ store đang có bản mới để flow chụp được card cập nhật mà không cần mạng.
  3. Checklist trên máy thật (docs/device-checklist.md) cho những gì simulator không chứng minh được. Của Talkzy mở đầu bằng việc phải chạy trên iPhone thật trước khi release và ghi kết quả cùng máy/iOS vào changelog của bản đó, rồi liệt kê những mục như Picture in Picture nổi trên TikTok Live suốt mười phút, hay voice-follow với mic Bluetooth.
bash
yarn typecheck && yarn lint && yarn test
./tools/kit-harness/run.sh                  # swift test cho lõi native
maestro test --device <udid> .maestro/

Flow Maestro cũng tự chụp ảnh vào docs/screenshots/, nhờ vậy tôi review một feature bằng cả tiếng Việt lẫn tiếng Anh mà không phải tự bấm qua từng màn. AGENTS.md của Stampzy kết thúc bằng mục "Honest reporting": tách bạch cái gì đã chạy và kiểm chứng với cái gì mới chỉ viết ra. Simulator không có camera, nên "chạy được trên simulator" không bao giờ đồng nghĩa với "chạy được".

Ngoài ra mỗi repo có audit định kỳ. Đợt tháng 10/2026 chấm lỗi từ P0 đến P3 và ghi lại kết quả các cổng kiểm tra trước và sau. Audit của Baton tìm ra một lỗi: khi ghi SQLite thất bại thì promise bị reject mà không ai bắt, nút Save trông như không làm gì cả. Lỗi được sửa, và bộ test tăng từ 574 lên 580.

Mười ngôn ngữ ngay từ đầu

App nào cũng có tiếng Anh, Việt, Nhật, Hàn, Trung, Đức, Pháp, Tây Ban Nha, Bồ Đào Nha và Ý. Ba thứ giúp chuyện này không vỡ trận:

  • Test parity. Fail khi thiếu file, thiếu key, mất một {{placeholder}}, hoặc (bản của Linkeeper) thiếu dạng số nhiều mà ngôn ngữ đó cần.
  • Chuỗi native chung một nguồn. Câu xin quyền và cụm từ Siri nằm trong docs/app-store/locales/*.json, rồi tools/i18n/apply_native.py ghi chúng vào các thư mục .lproj.
  • Listing nằm trong repo. Tên, subtitle và keyword trên App Store của từng locale nằm cạnh code, và Stampzy có script kiểm tra chúng với giới hạn độ dài của App Store.

Linkeeper còn bỏ luôn bộ chọn ngôn ngữ trong app từ 1.3.0: hệ thống quyết định, theo cài đặt ngôn ngữ riêng cho từng app của iOS, ngôn ngữ lạ thì về tiếng Anh.

Release, landing và showcase

Không có EAS hay dịch vụ CI nào. Năm app mới nhất có tools/release/upload.sh: script từ chối chạy nếu cờ test billing còn bật hoặc key RevenueCat vẫn là placeholder, kiểm tra có chứng chỉ Apple Distribution, lấy lock build, rồi archive, export và upload lên App Store Connect bằng xcodebuild. Lockboxy và Linkeeper lâu đời hơn, chỉ có Makefile để build và chạy; script này ra đời sau.

Mỗi app có một landing: Next.js 16 với App Router, Tailwind 4, TypeScript, chạy trên Vercel, có tiếng Việt và tiếng Anh. Từ điển được khai báo kiểu nên thiếu một bản dịch là build lỗi ngay. Luật cho landing ngắn và gắt: chính sách quyền riêng tư phải khớp với những gì app thật sự gửi đi, chưa lên store thì không có link App Store (thay bằng nhãn "Sắp ra mắt"), và không có review, rating hay số người dùng giả.

Showcase là thêm một site Next.js nữa. Một script gọi iTunes lookup công khai của Apple cho từng app và chép icon cùng ảnh App Store thật từ repo của app, nên trang không bao giờ lệch so với store. Cũng chính lookup đó chạy card "có phiên bản mới" trong app, tôi có viết riêng ở bài prompt cập nhật không cần backend.

Những gì tôi sẽ giữ lại

  • Chép cái khuôn, không chỉ chép code. App mới bắt đầu từ AGENTS.md, port và cấu trúc thư mục của app anh em thì làm được việc ngay từ ngày đầu.
  • Đẩy phần khó sang Swift kèm một contract viết ra giấy. JS gọn hơn, và phần native test được độc lập.
  • Viết luật thành test. Lời hứa về gói free, parity i18n, "hằng số version khớp với Xcode" đều là test, nên sống sót qua cả những lần release vội.
  • Giữ simulator trung thực. Maestro cho flow, checklist máy thật cho phần còn lại, và changelog ghi rõ cái nào là cái nào.
  • #React Native
  • #iOS
  • #Maestro
  • #Indie Apps
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.