Skip to content
Brandon Ta logo Brandon Ta
en
Progress0% read
Quay lại Blog

StreakFire tuần 1–4: 30 ngày build dạy tôi điều gì

12 TestFlight user, day-3 retention 58%, một lần scrapped Core Data, và 5 bài học tôi không đọc được ở đâu trước khi tự trải qua.

Brandon Ta
Brandon Ta
24 thg 4, 2026 · 8 phút đọc
ios build-in-public streakfire swiftui cloudkit

TestFlight build đầu tiên của StreakFire tôi ship vào tuần 3. App chạy được. Streak math đúng. Widget refresh đúng. Bốn tester mở app ngay trong ngày tôi gửi invite link.

Cảm giác đó kéo dài khoảng 48 tiếng. Rồi data retention tuần 4 về và tôi nhận ra khoảng cách giữa “chạy được” và “chạy tốt đến mức người dạ lạ quay lại mà không cần ai nhắc” — đó mới là chỗ cần làm thật sự.

Ba mươi ngày build một iOS habit tracker theo kiểu public dạy tôi 5 điều tôi không nghĩ ra trước khi làm. Một vài cái mâu thuẫn với những thứ tôi từng đọc và tin. Một cái tôi vẫn chưa chắc mình đã giải quyết xong. Full metrics và quyết định kiến trúc có ở case study StreakFire tuần 1–4. Post này là phần lessons nằm dưới những quyết định đó.


Bài học 1: Quyết định database sớm, dù mất công

Ngày 3 tuần 1, tôi đã làm được 6 tiếng với Core Data. Schema xong, persistent container đã init, đang wire fetch request đầu tiên. Rồi tôi dừng lại và tự hỏi một câu đáng lẽ phải hỏi từ ngày đầu: chuyện gì xảy ra khi user đổi iPhone?

Với Core Data không có iCloud sync, câu trả lời là: mất hết. Tôi biết mình sẽ cần sync, nên câu hỏi thật ra là: tự build sync layer hay nhờ Apple làm. Tôi scrapped 6 tiếng Core Data đó và chuyển sang CloudKit private database.

Sáu tiếng mất, nhưng cách kia tệ hơn — rebuild data layer vào tuần 3 hoặc 4, khi widget và streak engine đã couple vào Core Data fetch pattern. CloudKit cần học một mental model khác (CKRecord thay vì NSManagedObject, async save thay vì sync save vào context), nhưng iCloud sync đến miễn phí, conflict resolution cũng vậy.

Bài học không phải là “luôn dùng CloudKit”. Bài học là: chốt persistence và sync story trước khi viết bất kỳ feature code nào. Thay đổi sau này không phải refactor — đó là rewrite.

Bài học 2: Build widget vào tuần 2, không phải tuần 5

WidgetKit có một constraint dễ bị đánh giá thấp cho đến khi bị dính: widget extension là một binary riêng, memory budget riêng, timeline riêng, và không có live access vào in-memory state của app chính. Nếu data model không được thiết kế cho điều đó từ đầu, thêm widget sau này nghĩa là redesign data model — tức là rewrite tất cả những gì liên quan.

Tôi build WidgetKit timeline entry đầu tiên vào đầu tuần 2, trước khi streak calculation engine hoàn thiện. Cảm giác làm sớm quá. Nhưng không phải. Thiết kế cho stateless read model của widget buộc tôi giữ HabitRecord serialisation sạch và nghĩ về minimum readable data cho một widget là gì. Những constraint đó cải thiện architecture của app chính.

Một chỗ tôi đã sai ở đây: tôi nghĩ iOS 16, 17, và 18 xử lý widget refresh budget như nhau. Không phải. Tôi mất gần cả ngày với widget refresh failure trên device iOS 16 trước khi tìm ra documentation theo từng phiên bản OS. Fix thì nhỏ — một explicit relevance score trên timeline entry — nhưng tìm ra nó tốn thời gian tôi không có trong kế hoạch.

Lần sau, tôi sẽ viết WidgetKit layer như một standalone test harness và chạy trên OS version cũ nhất được support trước khi kết nối vào app chính.

Bài học 3: 14 unit test cho streak math là đủ, không phải dư

Trước tuần 3, tôi sẽ gọi 14 unit test cho một hàm tính streak là over-engineered với MVP. Tôi đã sai.

Streak math có ít nhất 4 edge case thật sự phức tạp: timezone crossings (user check in “hôm nay” theo timezone của họ hay UTC?), DST transition (những ngày dài 23 hoặc 25 tiếng), grace-day logic (một ngày bỏ lỡ không nên reset streak, nhưng hai ngày thì có), và interaction giữa cả ba cùng lúc. Mỗi cái là một chỗ mà off-by-one error cho ra wrong answer im lặng — streak của user biến mất hoặc trụ lại khi không nên, và họ không biết tại sao.

Tôi viết 14 test trước khi submit TestFlight ở tuần 3. Tất cả pass trên build TestFlight đầu tiên. Không phải vì tôi đặc biệt cẩn thận mà vì những test đó tìm ra 3 bug trong quá trình development mà không có chúng tôi đã ship. Crash-free rate qua 847 session là 99.8%. Tôi không nghĩ đó là ngẫu nhiên.

Bài học: với bất kỳ tính toán nào liên quan đến time và user state, test coverage không phải tuỳ chọn trong v1. Chi phí của một wrong answer im lặng trong habit tracker là người dùng mất 30-day streak vì timezone crossing đêm Giao Thừa và không bao giờ mở app lại.

Bài học 4: Onboarding flow tôi hài lòng là wrong design

Tôi build 3-screen onboarding flow vào tuần 4. Hài lòng với nó. Nó giải thích grace-day mechanic, hỏi notification permission với một rationale screen trước, và kết thúc với prompt “bắt đầu habit đầu tiên”. Tôi nghĩ nó rõ ràng.

Ba tester đầu tiên đi qua nó trong tuần 4 đều skip rationale screen. Họ tap qua nhanh nhất có thể để vào app. Một người nói với tôi rationale screen trông như cảnh báo trước quảng cáo — đúng ngược lại với ý định của tôi.

Tôi rewrite notification permission flow hai lần trước khi nó cảm giác đúng. Version hoạt động ngắn hơn: một câu, ngôn ngữ thông thường, không nhấn mạnh design vào permission request, chỉ hiện khi user đặt reminder đầu tiên. Đó là contextual permission. Cái in-context convert tốt hơn rationale screen upfront, và ít friction hơn lúc bắt đầu.

Tôi vẫn chưa chắc onboarding hiện tại đã đúng. Day-3 retention của cohort tuần 4 là 58% — tức là 4 trong 7 tester quay lại vào ngày 3. Tín hiệu tốt, nhưng 7 tester không phải sample size để kết luận gì. Ưu tiên tuần 5 là notification personalisation, cái đó nên di chuyển con số này.

Bài học 5: Ship lên TestFlight ở tuần 3 là quyết định duy nhất quan trọng

Tôi có thể dành tuần 3 để polish flame animation. Visual design của streak indicator — ngọn lửa sống sót qua một ngày bỏ lỡ — là thứ tôi dành nhiều thời gian nhất để nghĩ về mặt conceptual. Tôi có 6 Figma frame cho nó.

Thay vào đó tôi ship build 1 lên TestFlight đầu tuần 3 với placeholder visual và onboard 4 tester từ network của mình. Feedback nhận được trong 48 tiếng đầu đã thay đổi onboarding design và tìm ra một edge-case bug thật trong notification scheduling logic mà tôi chưa gặp trên simulator.

Sáu Figma frame cho flame visual vẫn hầu hết chưa dùng. Feedback từ 4 người dùng thật trên device thật đáng giá hơn bất kỳ lần iteration solo nào trên visual. Tôi đã biết điều này về mặt lý thuyết. Trải nghiệm lại nó trong thực tế — nhìn tester tap thẳng qua thứ tôi đang obsess và hỏi về thứ tôi hầu như chưa nghĩ tới — mới là thứ thay đổi hành vi của tôi.

Bài học này rõ ràng đến ngượng ngùng khi nhìn lại: thứ bạn gắn bó nhất thường không phải thứ người dùng quan tâm nhất. Ship sớm làm khoảng cách đó hiện ra ở thời điểm bạn vẫn còn làm được gì đó.


Nhìn lại

Ba mươi ngày: 12 TestFlight user, 99.8% crash-free qua 847 session, day-3 retention 58% cho cohort tuần 4. Những con số này nhỏ và tôi không giả vờ khác. Tất cả user đều là người biết về project — không phải cold App Store discovery.

Những gì chúng nói được: core mechanic hoạt động. Người dùng đang mở app và log habit mà không cần tôi nudge. Thesis về grace-day — rằng streak nên sống sót qua một lỗi của con người — đang giữ vững trong thực tế sử dụng.

Flame visual vẫn đang được iterate. Tôi đã yêu brand trước khi chứng minh core mechanic, và đó là lỗi founder kinh điển nhất có thể có. Mechanic hoạt động. Brand có thể chờ.

Tuần 5 là notification personalisation và lần submit App Store Connect đầu tiên. Mục tiêu là có listing public trước cuối tháng 4. Dù đúng hẹn hay trễ, build-in-public record sẽ ghi lại thật sự.


Nếu những bài học này có ích, diary entries hàng tuần đi sâu hơn vào những quyết định ngày qua ngày. Newsletter cũng sẽ thông báo khi có post mới.

Brandon Ta

Viết bởi Brandon Ta

Indie Hacker & AI Builder. Xây dựng công cụ AI và chia sẻ hành trình công khai.

Bình luận

Comments

Join the conversation! Share your thoughts, ask questions, or connect with other readers below.