Kotlin MVI vs MVVM cho ứng dụng Android nhiều biểu mẫu: Trạng thái UI
Kotlin MVI vs MVVM cho ứng dụng Android nhiều biểu mẫu, giải thích các cách thực tế để mô hình hóa xác thực, UI lạc quan, trạng thái lỗi và bản nháp ngoại tuyến.

Vì sao ứng dụng Android nhiều biểu mẫu nhanh chóng trở nên rối\n\nỨng dụng nhiều biểu mẫu thường cảm thấy chậm hoặc mong manh vì người dùng liên tục chờ đợi những quyết định nhỏ mà mã của bạn phải thực hiện: trường này hợp lệ không, việc lưu có thành công không, có nên hiển thị lỗi không, và nếu mạng rớt thì sao.\n\nBiểu mẫu cũng là nơi lộ ra lỗi trạng thái đầu tiên vì chúng trộn nhiều loại trạng thái cùng lúc: trạng thái UI (cái gì đang hiển thị), trạng thái nhập (người dùng gõ gì), trạng thái server (đã lưu gì), và trạng thái tạm thời (đang tiến hành). Khi những thứ đó lệch nhau, ứng dụng bắt đầu cảm thấy “ngẫu nhiên”: nút bị vô hiệu hóa sai lúc, lỗi cũ vẫn hiện, hoặc màn hình bị đặt lại sau khi xoay.\n\nHầu hết vấn đề tập trung vào bốn lĩnh vực: xác thực (đặc biệt quy tắc giữa các trường), UI lạc quan (phản hồi nhanh khi công việc còn đang chạy), xử lý lỗi (thất bại rõ ràng, có thể phục hồi), và bản nháp ngoại tuyến (không mất công việc chưa hoàn thành).\n\nTrải nghiệm biểu mẫu tốt tuân theo vài quy tắc đơn giản:\n\n- Xác thực nên hữu ích và gần trường. Đừng chặn việc gõ. Nghiêm ngặt khi cần, thường là lúc submit.\n- UI lạc quan nên phản ánh hành động của người dùng ngay lập tức, nhưng cũng cần cơ chế rollback rõ ràng nếu server từ chối.\n- Lỗi nên cụ thể, có thể hành động, và không bao giờ xóa input của người dùng.\n- Bản nháp nên tồn tại qua khởi động lại, gián đoạn, và kết nối xấu.\n\nĐó là lý do tranh luận kiến trúc nóng lên với biểu mẫu. Mẫu bạn chọn quyết định các trạng thái đó cảm thấy có thể dự đoán như thế nào khi chịu áp lực.\n\n## Nhắc nhanh: MVVM và MVI theo cách đơn giản\n\nSự khác biệt thực sự giữa MVVM và MVI là cách thay đổi chảy qua một màn hình.\n\nMVVM (Model View ViewModel) thường trông như sau: ViewModel giữ dữ liệu màn hình, phơi bày chúng cho UI (thường qua StateFlow hoặc LiveData), và cung cấp các phương thức như save, validate, hoặc load. UI gọi phương thức ViewModel khi người dùng tương tác.\n\nMVI (Model View Intent) thường trông như: UI gửi sự kiện (intent), một reducer xử lý chúng, và màn hình render từ một đối tượng trạng thái duy nhất biểu diễn mọi thứ UI cần ngay bây giờ. Các side effect (mạng, database) được kích hoạt theo cách có kiểm soát và báo lại kết quả dưới dạng sự kiện.\n\nCách nhớ nhanh tư duy: \n\n- MVVM hỏi: “ViewModel nên phơi bày dữ liệu gì, và nên cung cấp phương thức nào?”\n- MVI hỏi: “Những sự kiện nào có thể xảy ra, và chúng biến đổi trạng thái hiện tại như thế nào?”\n\nCả hai đều ổn cho màn hình đơn giản. Khi thêm xác thực giữa các trường, autosave, retry, và bản nháp ngoại tuyến, bạn cần quy tắc nghiêm ngặt hơn về ai có thể thay đổi trạng thái và khi nào. MVI ép các quy tắc đó theo mặc định. MVVM vẫn có thể hoạt động tốt, nhưng cần kỷ luật: đường dẫn cập nhật nhất quán và xử lý cẩn thận các sự kiện một lần (snackbar, điều hướng).\n\n## Cách mô hình hóa trạng thái biểu mẫu để không bất ngờ\n\nCách nhanh nhất để mất kiểm soát là để dữ liệu biểu mẫu sống ở quá nhiều nơi: view bindings, nhiều flow khác nhau, và “thêm một boolean nữa”. Màn hình nhiều biểu mẫu chỉ có thể dự đoán được khi có một nguồn chân lý duy nhất.\n\n### Một dạng FormState thực tế\n\nHướng tới một FormState duy nhất chứa input thô cộng với vài cờ dẫn xuất đáng tin cậy. Giữ nó nhàm và đầy đủ, dù trông có hơi lớn.\n\nkotlin\ndata class FormState(\n val fields: Fields,\n val fieldErrors: Map\u003cFieldId, String\u003e = emptyMap(),\n val formError: String? = null,\n val isDirty: Boolean = false,\n val isValid: Boolean = false,\n val submitStatus: SubmitStatus = SubmitStatus.Idle,\n val draftStatus: DraftStatus = DraftStatus.NotSaved\n)\n\nsealed class SubmitStatus { object Idle; object Saving; object Saved; data class Failed(val msg: String) }\nsealed class DraftStatus { object NotSaved; object Saving; object Saved }\n\n\nĐiều này giữ xác thực theo trường (từng input) tách rời khỏi các vấn đề toàn form (như “tổng phải > 0”). Các cờ dẫn xuất như isDirty và isValid nên được tính ở một nơi duy nhất, không tái hiện trong UI.\n\nMột mô hình tinh thần rõ ràng là: fields (người dùng gõ gì), validation (cái gì sai), status (ứng dụng đang làm gì), dirtiness (cái gì đã thay đổi kể từ lần lưu cuối), và drafts (có bản sao ngoại tuyến hay không).\n\n### Các hiệu ứng một lần thuộc về đâu\n\nBiểu mẫu cũng phát sinh các sự kiện một lần: snackbars, điều hướng, banner “đã lưu”. Đừng đặt những thứ này trong FormState, nếu không chúng sẽ bật lại sau khi xoay màn hình hoặc UI re-subscribe.\n\nTrong MVVM, phát hiệu ứng qua một channel riêng (ví dụ SharedFlow). Trong MVI, mô hình hóa chúng như Effects (hoặc Events) mà UI tiêu thụ một lần. Sự tách bạch này ngăn “lỗi ma” và thông báo thành công trùng lặp.\n\n## Luồng xác thực trong MVVM vs MVI\n\nXác thực là nơi màn hình biểu mẫu bắt đầu thấy mong manh. Lựa chọn chính là quy tắc sống ở đâu và kết quả trả về UI như thế nào.\n\nCác quy tắc đơn, đồng bộ (bắt buộc, độ dài tối thiểu, phạm vi số) nên chạy ở ViewModel hoặc tầng domain, chứ không phải UI. Điều đó giữ quy tắc có thể kiểm thử và nhất quán.\n\nCác quy tắc bất đồng bộ (như “email này đã có người dùng chưa?”) phức tạp hơn. Bạn cần xử lý loading, kết quả cũ, và trường hợp “người dùng gõ tiếp”.\n\nTrong MVVM, xác thực thường trở thành hỗn hợp giữa trạng thái và phương thức trợ giúp: UI gửi thay đổi (cập nhật text, mất focus, click submit) tới ViewModel; ViewModel cập nhật StateFlow/LiveData và phơi bày lỗi theo trường cùng một cờ canSubmit dẫn xuất. Kiểm tra bất đồng bộ thường khởi động một job, sau đó cập nhật cờ loading và lỗi khi hoàn tất.\n\nTrong MVI, xác thực thường rõ ràng hơn. Một phân chia thực tế như sau:\n\n- Reducer chạy xác thực đồng bộ và cập nhật lỗi trường ngay lập tức.\n- Một effect chạy xác thực bất đồng bộ và dispatch kết quả như một intent.\n- Reducer áp dụng kết quả đó chỉ nếu nó vẫn khớp với input mới nhất.\n\nBước cuối cùng rất quan trọng. Nếu người dùng gõ email mới trong khi check “unique email” đang chạy, kết quả cũ không nên ghi đè lên input hiện tại. MVI thường dễ mã hóa điều đó vì bạn có thể lưu giá trị đã kiểm tra cuối cùng trong state và bỏ qua phản hồi lỗi thời.\n\n## UI lạc quan và lưu bất đồng bộ\n\nUI lạc quan có nghĩa màn hình hành xử như thể việc lưu đã thành công trước khi có phản hồi mạng. Trong biểu mẫu, thường là nút Save chuyển sang “Saving...”, một chỉ báo nhỏ “Saved” xuất hiện khi xong, và input vẫn có thể dùng (hoặc bị khóa có chủ ý) khi request đang chạy.\n\nTrong MVVM, việc này thường triển khai bằng cách chuyển các cờ như isSaving, lastSavedAt, và saveError. Rủi ro là drift: các lưu chồng lên nhau có thể làm các cờ đó không nhất quán. Trong MVI, reducer cập nhật một đối tượng state duy nhất, nên “Saving” và “Disabled” ít có khả năng mâu thuẫn.\n\nĐể tránh double submit và race condition, coi mỗi lần lưu là một sự kiện có định danh. Nếu người dùng bấm Save hai lần hoặc chỉnh sửa trong khi đang lưu, bạn cần quy tắc cho phản hồi nào thắng. Một vài biện pháp an toàn áp dụng cho cả hai pattern: vô hiệu hóa Save khi đang lưu (hoặc debounce), gắn requestId (hoặc version) cho mỗi save và bỏ qua phản hồi lỗi thời, hủy công việc đang chạy khi người dùng rời màn hình, và định nghĩa chỉnh sửa trong khi lưu có nghĩa gì (xếp hàng lưu khác, hay đánh dấu form lại là dirty).\n\nThành công một phần cũng phổ biến: server chấp nhận vài trường nhưng từ chối trường khác. Mô hình hóa điều đó rõ ràng. Giữ lỗi theo trường (và nếu cần, trạng thái sync theo trường) để bạn có thể hiển thị “Saved” tổng thể trong khi vẫn đánh dấu trường cần sửa.\n\n## Trạng thái lỗi mà người dùng có thể khôi phục\n\nMàn hình biểu mẫu thất bại theo nhiều cách hơn là “có lỗi xảy ra”. Nếu mọi thất bại chỉ thành một toast chung chung, người dùng sẽ gõ lại dữ liệu, mất niềm tin, và bỏ cuộc. Mục tiêu luôn là: giữ input an toàn, cho thấy cách sửa rõ ràng, và làm cho retry trở nên bình thường.\n\nSẽ hữu ích khi tách lỗi theo nơi chúng thuộc về. Email sai định dạng không giống như server sập.\n\nLỗi theo trường nên inline và gắn với một input. Lỗi toàn form nên nằm gần hành động submit và giải thích điều gì đang chặn submit. Lỗi mạng nên đề xuất retry và giữ form có thể chỉnh sửa. Lỗi quyền hoặc xác thực nên hướng người dùng tới đăng nhập lại trong khi giữ bản nháp.\n\nMột quy tắc phục hồi cốt lõi: không bao giờ xóa input của người dùng khi thất bại. Nếu lưu thất bại, giữ nguyên giá trị hiện tại trong bộ nhớ và trên đĩa. Retry nên gửi lại cùng payload trừ khi người dùng sửa.\n\nNơi hai pattern khác nhau là cách ánh xạ lỗi server vào trạng thái UI. Trong MVVM, dễ cập nhật nhiều flow hoặc trường và vô tình tạo bất nhất. Trong MVI, bạn thường áp dụng phản hồi server trong một bước reducer duy nhất cập nhật fieldErrors và formError cùng lúc.\n\nNgoài ra, quyết định cái gì là state so với hiệu ứng một lần. Lỗi inline và “submission failed” thuộc về state (phải tồn tại qua xoay màn hình). Hành động một lần như snackbar, rung, hay điều hướng nên là effects.\n\n## Bản nháp ngoại tuyến và khôi phục biểu mẫu đang làm dở\n\nỨng dụng nhiều biểu mẫu cảm thấy “ngoại tuyến” ngay cả khi mạng ổn. Người dùng chuyển app, OS kill process của bạn, hoặc họ mất tín hiệu giữa chừng. Bản nháp giữ họ khỏi phải bắt đầu lại.\n\nĐầu tiên, định nghĩa bản nháp nghĩa là gì. Chỉ lưu mô hình “sạch” thường không đủ. Thường bạn muốn khôi phục màn hình đúng như trước, bao gồm cả các trường gõ dở.\n\nNhững gì đáng lưu hầu hết là input thô (chuỗi gõ, ID chọn, URI tệp đính kèm), cộng metadata đủ để gộp an toàn sau này: snapshot server cuối cùng biết và marker phiên bản (updatedAt, ETag, hoặc số tăng đơn giản). Xác thực có thể tính lại khi khôi phục.\n\nChọn nơi lưu phụ thuộc vào độ nhạy và kích thước. Bản nháp nhỏ có thể cho vào preferences, nhưng form nhiều bước và tệp đính kèm an toàn hơn trong database địa phương. Nếu bản nháp chứa dữ liệu cá nhân, dùng lưu mã hóa.\n\nCâu hỏi kiến trúc lớn nhất là nguồn chân lý nằm ở đâu. Trong MVVM, các team thường persist từ ViewModel mỗi khi trường thay đổi. Trong MVI, lưu sau mỗi cập nhật reducer có thể đơn giản hơn vì bạn lưu một state nhất quán (hoặc một đối tượng Draft dẫn xuất).\n\nThời điểm autosave quan trọng. Lưu mỗi keystroke là ồn; debounce ngắn (ví dụ 300–800 ms) cộng lưu khi chuyển bước hoạt động tốt.\n\nKhi người dùng trở lại trực tuyến, bạn cần quy tắc gộp. Cách thực tế: nếu phiên bản server không đổi, áp dụng bản nháp và submit. Nếu có thay đổi, hiển thị lựa chọn rõ ràng: giữ bản nháp của tôi hay tải lại dữ liệu server.\n\n## Từng bước: triển khai một biểu mẫu đáng tin cậy với bất cứ pattern nào\n\nBiểu mẫu đáng tin cậy bắt đầu bằng quy tắc rõ ràng, không phải mã UI. Mỗi hành động người dùng nên dẫn tới trạng thái có thể dự đoán, và mỗi kết quả bất đồng bộ nên có một chỗ rõ ràng để đổ về.\n\nGhi ra các hành động màn hình phải phản ứng: gõ, mất focus, submit, retry, và chuyển bước. Trong MVVM chúng trở thành phương thức ViewModel và cập nhật state. Trong MVI chúng là intents rõ ràng.\n\nRồi xây theo các bước nhỏ:\n\n1. Định nghĩa events cho vòng đời đầy đủ: edit, blur, submit, save success/failure, retry, restore draft.\n2. Thiết kế một đối tượng state: giá trị trường, lỗi theo trường, trạng thái form tổng thể, và “có thay đổi chưa”.\n3. Thêm xác thực: kiểm tra nhẹ khi gõ, kiểm tra nặng khi submit.\n4. Thêm quy tắc lưu lạc quan: thay đổi gì xảy ra ngay, và điều gì kích hoạt rollback.\n5. Thêm bản nháp: autosave với debounce, khôi phục khi mở, và hiển thị một chỉ báo nhỏ “bản nháp đã khôi phục” để người dùng tin tưởng điều họ thấy.\n\nXem lỗi như một phần trải nghiệm. Giữ input, chỉ nổi bật chỗ cần sửa, và đưa ra một hành động tiếp theo rõ ràng (chỉnh sửa, retry, hay giữ bản nháp).\n\nNếu muốn prototype trạng thái biểu mẫu phức tạp trước khi viết UI Android, một nền tảng không-code như AppMaster có thể hữu ích để xác thực luồng trước. Sau đó bạn có thể hiện thực cùng quy tắc trong MVVM hoặc MVI với ít bất ngờ hơn.\n\n## Ví dụ kịch bản: biểu mẫu báo cáo chi tiêu nhiều bước\n\nGiả sử có biểu mẫu báo cáo chi tiêu 4 bước: chi tiết (ngày, loại, số tiền), upload biên lai, ghi chú, rồi review và submit. Sau submit, nó hiển thị trạng thái duyệt như Draft, Submitted, Rejected, Approved. Những phần khó là xác thực, lưu có thể thất bại, và giữ bản nháp khi máy offline.\n\nTrong MVVM, bạn thường giữ một FormUiState trong ViewModel (thường là một StateFlow). Mỗi thay đổi trường gọi hàm ViewModel như onAmountChanged() hoặc onReceiptSelected(). Xác thực chạy khi thay đổi, khi chuyển bước, hoặc khi submit. Cấu trúc phổ biến là input thô cộng lỗi theo trường, với các cờ dẫn xuất điều khiển Next/Submit.\n\nTrong MVI, cùng luồng trở thành rõ ràng: UI gửi intent như AmountChanged, NextClicked, SubmitClicked, và RetrySave. Reducer trả về state mới. Side effects (upload receipt, gọi API, show snackbar) chạy ngoài reducer và phản hồi lại dưới dạng sự kiện.\n\nThực tế, MVVM làm cho việc thêm hàm và cập nhật flow nhanh chóng dễ dàng. MVI khiến bạn khó vô tình bỏ qua một chuyển trạng thái vì mọi thay đổi đều được funnel qua reducer.\n\n## Lỗi thường gặp và bẫy\n\nHầu hết lỗi biểu mẫu xuất phát từ quy tắc không rõ ràng về ai sở hữu chân lý, khi nào chạy xác thực, và chuyện gì xảy ra khi kết quả bất đồng bộ tới muộn.\n\nSai lầm phổ biến nhất là trộn nguồn chân lý. Nếu một text field đôi khi đọc từ widget, đôi khi từ ViewModel state, và đôi khi từ bản nháp khôi phục, bạn sẽ gặp reset ngẫu nhiên và báo cáo “input của tôi biến mất”. Chọn một state chính thống cho màn hình và dẫn xuất mọi thứ từ nó (domain model, cache rows, payload API).\n\nBẫy dễ gặp nữa là nhầm lẫn state với events. Snackbar, điều hướng, hay banner “Saved!” là một lần. Tin nhắn lỗi phải tồn tại cho đến khi người dùng sửa thì là state. Trộn lẫn sẽ gây hiệu ứng trùng lặp khi xoay màn hình hoặc feedback bị mất.\n\nHai vấn đề correctness hay xuất hiện:\n\n- Over-validate mỗi keystroke, đặc biệt với kiểm tra tốn kém. Dùng debounce, xác thực on blur, hoặc chỉ validate những trường đã touch.\n- Bỏ qua kết quả bất đồng bộ về thứ tự. Nếu người dùng lưu hai lần hoặc sửa sau khi lưu, phản hồi cũ có thể ghi đè phản hồi mới trừ khi bạn dùng request ID (hoặc logic “chỉ lấy mới nhất”).\n\nCuối cùng, bản nháp không phải “chỉ lưu JSON”. Không có versioning, cập nhật app có thể phá restore. Thêm schema version đơn giản và kế hoạch migration, dù là “bỏ và bắt đầu mới” cho bản nháp rất cũ.\n\n## Checklist nhanh trước khi ra mắt\n\nTrước khi tranh luận MVVM vs MVI, đảm bảo biểu mẫu có một nguồn chân lý rõ ràng. Nếu một giá trị có thể thay đổi trên màn hình, nó thuộc về state, không phải widget view hay một cờ ẩn.\n\nKiểm tra thực tế trước khi phát hành:\n\n- State bao gồm inputs, lỗi theo trường, trạng thái lưu (idle/saving/saved/failed), và trạng thái bản nháp/hàng đợi để UI không phải đoán.\n- Quy tắc xác thực thuần và có thể kiểm thử ngoài UI.\n- UI lạc quan có đường lui nếu server từ chối.\n- Lỗi không bao giờ xóa input người dùng.\n- Khôi phục bản nháp rõ ràng: banner tự động khôi phục hoặc hành động “Khôi phục bản nháp” rõ ràng.\n\nMột bài kiểm tra bắt lỗi thật: bật chế độ máy bay giữa lúc đang lưu, tắt nó, rồi retry hai lần. Lần retry thứ hai không nên tạo bản ghi trùng lặp. Dùng request ID, idempotency key, hoặc marker “pending save” cục bộ để retry an toàn.\n\nNếu câu trả lời còn mơ hồ, thắt chặt mô hình state trước, rồi chọn pattern giúp bạn thực thi các quy tắc đó dễ nhất.\n\n## Bước tiếp theo: chọn hướng và xây nhanh hơn\n\nBắt đầu bằng một câu hỏi: hậu quả thế nào nếu biểu mẫu rơi vào trạng thái nửa cập nhật? Nếu hậu quả thấp, giữ đơn giản.\n\nMVVM phù hợp khi màn hình đơn giản, state chủ yếu là “fields + errors”, và đội ngũ đã quen triển khai ViewModel + LiveData/StateFlow.\n\nMVI phù hợp hơn khi bạn cần chuyển trạng thái chặt chẽ, nhiều sự kiện bất đồng bộ (autosave, retry, sync), hoặc khi lỗi rất tốn kém (thanh toán, compliance, luồng công việc quan trọng).\n\nDù chọn đường nào, các bài kiểm thử mang lại lợi ích lớn nhất cho biểu mẫu thường không cần chạm UI: các cạnh xác thực, chuyển trạng thái (edit, submit, success, failure, retry), rollback lưu lạc quan, và khôi phục bản nháp cùng hành vi xung đột.\n\nNếu bạn cũng cần backend, trang admin và API song song với mobile, AppMaster (appmaster.io) có thể sinh backend, web và native mobile từ một mô hình duy nhất, giúp các quy tắc xác thực và workflow nhất quán trên nhiều bề mặt.
Câu hỏi thường gặp
Chọn MVVM khi luồng biểu mẫu của bạn khá tuyến tính và đội ngũ đã có quy ước vững về StateFlow/LiveData, các sự kiện một lần và hủy tác vụ. Chọn MVI khi bạn dự đoán nhiều công việc bất đồng bộ chồng chéo (autosave, retry, upload) và muốn quy tắc chặt chẽ hơn để tránh trạng thái bị thay đổi từ nhiều nơi.
Bắt đầu bằng một đối tượng trạng thái màn hình duy nhất (ví dụ FormState) chứa giá trị thô của trường, lỗi theo trường, lỗi toàn form và các trạng thái rõ ràng như Saving hoặc Failed. Đảm bảo các cờ dẫn xuất như isValid hay canSubmit chỉ được tính ở một nơi để UI chỉ chịu trách nhiệm hiển thị.
Chạy các kiểm tra nhẹ, rẻ khi người dùng nhập (bắt buộc, phạm vi, định dạng cơ bản), và chạy kiểm tra nghiêm ngặt khi submit. Đặt logic xác thực ngoài UI để dễ kiểm thử, và lưu lỗi trong trạng thái để chúng tồn tại qua xoay màn hình hoặc quá trình bị kill.
Đối xử với xác thực bất đồng bộ như “mới nhất thắng”. Lưu giá trị đã kiểm tra (hoặc request/version id) và bỏ qua kết quả không khớp với trạng thái hiện tại. Cách này ngăn các phản hồi cũ ghi đè lên gõ mới, nguồn gây ra nhiều thông báo lỗi “ngẫu nhiên”.
Cập nhật giao diện ngay để phản ánh hành động (ví dụ hiển thị Saving… và giữ input nhìn thấy được), nhưng luôn có đường lui nếu server từ chối. Dùng request id/version, vô hiệu hóa hoặc debounce nút Save, và định nghĩa rõ chỉnh sửa trong khi đang lưu có nghĩa là gì (khóa trường, xếp hàng lưu khác, hay đánh dấu lại là dirty).
Không bao giờ xóa input của người dùng khi thất bại. Hiển thị vấn đề theo trường ngay trên trường tương ứng, giữ lỗi toàn form gần hành động submit, và làm cho lỗi mạng có thể phục hồi bằng retry gửi lại cùng payload trừ khi người dùng thay đổi.
Để các hiệu ứng một lần ra ngoài trạng thái bền. Trong MVVM, gửi chúng qua một luồng riêng (ví dụ SharedFlow), và trong MVI, mô hình hóa chúng như Effects mà UI tiêu thụ một lần. Điều này tránh snackbar lặp lại hoặc điều hướng hai lần sau khi xoay màn hình.
Lưu chủ yếu input thô của người dùng (như gõ chữ, ID được chọn, URI file đính kèm), cùng metadata tối thiểu để khôi phục và gộp sau này an toàn, như marker phiên bản server. Tính toán lại xác thực khi khôi phục thay vì lưu nó, và thêm phiên bản schema đơn giản để xử lý cập nhật app.
Dùng debounce ngắn (khoảng vài trăm ms) và lưu khi chuyển bước hoặc khi app xuống background. Lưu liên tục theo mỗi keystroke gây ồn và có thể làm tăng tranh chấp; lưu chỉ khi thoát thì dễ mất dữ liệu khi process bị kill.
Lưu một marker phiên bản (ví dụ updatedAt, ETag, hoặc số tăng) cho cả snapshot server và bản nháp. Nếu phiên bản server không đổi thì áp dụng bản nháp và submit; nếu đã thay đổi, hiển thị lựa chọn rõ ràng để giữ bản nháp hay tải lại dữ liệu server, thay vì ghi đè im lặng.


