Ghép amount + " đ" có vẻ đủ cho màn hình chỉ dùng tiếng Việt, nhưng nhanh chóng gây rối khi sản phẩm có nhiều ngôn ngữ hoặc mã tiền tệ. Locale quyết định cách trình bày số; currency cho biết đơn vị; amount là dữ liệu cần trình bày. Ba trường này không thay thế nhau.
Bài đối chiếu Android API ngày 29/10/2026, dùng java.text.NumberFormat trong Kotlin và Locale.forLanguageTag, nên ví dụ yêu cầu Android API 21 trở lên. Phần Java tương đương được chạy trên OpenJDK 25.0.4 để kiểm tra quy tắc cấu hình; chưa build Kotlin/Android hay kiểm tra thiết bị. Ký hiệu và khoảng trắng hiển thị có thể phụ thuộc dữ liệu locale của runtime.
Giữ amount và currency ngoài chuỗi giao diện
Giả sử dữ liệu thử có amount là chuỗi thập phân 1250.00, currency là VND hoặc USD. Đây là hai giá trị minh họa riêng, không phải cùng một khoản được quy đổi. Dùng BigDecimal từ chuỗi để không đưa thêm sai số nhị phân ngay khi tạo dữ liệu thử.
import java.math.BigDecimal
val amount = BigDecimal("1250.00")
BigDecimal API mô tả biểu diễn số thập phân. Chọn kiểu này không tự giải quyết quy tắc làm tròn hoặc đơn vị nhỏ nhất của backend. Trước hết, hợp đồng API phải nói amount là đơn vị chính hay đơn vị nhỏ; không đoán bằng số chữ số hoặc tên biến.
Chuỗi đã định dạng dành cho hiển thị. Không gửi ngược chuỗi đó làm số liệu nghiệp vụ, vì nó có thể chứa dấu nhóm, dấu thập phân và khoảng trắng không phải ký tự space thông thường.
Cấu hình cả mã tiền tệ lẫn phần lẻ
Theo NumberFormat API, gọi setCurrency không tự cập nhật minimum/maximum fraction digits. Nếu formatter được tạo với locale có mặc định khác, chỉ đổi mã tiền tệ có thể để lại cấu hình phần lẻ cũ.
Hàm dưới đây chọn số chữ số phần lẻ mặc định từ currency. Nó cố tình từ chối làm tròn ngầm: nếu giá trị cần làm tròn mới vừa cấu hình hiển thị, caller phải xử lý theo hợp đồng sản phẩm.
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
fun formatMoney(
amount: BigDecimal,
currencyCode: String,
locale: Locale
): String {
val unit = Currency.getInstance(currencyCode)
val digits = unit.defaultFractionDigits
require(digits >= 0) { "Currency cần chính sách phần lẻ riêng" }
val formatter = NumberFormat.getCurrencyInstance(locale)
formatter.currency = unit
formatter.minimumFractionDigits = digits
formatter.maximumFractionDigits = digits
formatter.roundingMode = RoundingMode.UNNECESSARY
return formatter.format(amount)
}
val vi = Locale.forLanguageTag("vi-VN")
val en = Locale.forLanguageTag("en-US")
val sample = BigDecimal("1250.00")
val vndVi = formatMoney(sample, "VND", vi)
val vndEn = formatMoney(sample, "VND", en)
val usdVi = formatMoney(sample, "USD", vi)
Currency.getInstance có thể ném lỗi nếu mã không hợp lệ. getDefaultFractionDigits còn có thể trả -1 cho một số pseudo-currency, nên hàm không âm thầm biến mọi trường hợp thành hai chữ số. Đây là chi tiết được ghi trong Currency API.
Locale không thực hiện quy đổi
Đổi vi sang en chỉ đổi cách hiển thị cùng amount và currency. Đổi VND sang USD trong lời gọi cũng không nhân hay chia amount với tỷ giá. Nếu sản phẩm có chức năng quy đổi, kết quả quy đổi phải được tính và định nghĩa ở lớp riêng trước khi đưa vào formatter.
Trong ví dụ, VND dùng cấu hình phần lẻ mặc định 0, USD dùng 2. 1250.00 có thể biểu diễn ở cả hai độ chính xác ấy mà không cần làm tròn giá trị. Ngược lại, gọi hàm với BigDecimal("1250.50") và VND sẽ bị từ chối bởi RoundingMode.UNNECESSARY. Đừng bắt lỗi rồi tùy tiện bỏ phần lẻ chỉ để UI có chuỗi.
Locale cố định giúp test tái lập. Khi đưa vào app, chọn locale theo thiết kế ngôn ngữ của ứng dụng và cập nhật formatter khi lựa chọn đó đổi. Locale API xác nhận forLanguageTag có từ API 21; ví dụ không yêu cầu đổi locale global của process.
Checklist kiểm tra giao diện
Thử cùng currency với ít nhất hai locale, sau đó thử hai currency với cùng locale. Kiểm tra số 0, số âm nếu màn hình cho phép, giá trị lớn hơn chiều rộng ô và phần lẻ không thể hiển thị chính xác. Quy tắc cho phép số âm thuộc nghiệp vụ, không thuộc formatter.
Nếu snapshot text lệch chỉ vì khoảng trắng, kiểm tra code point trước khi thay toàn bộ dấu cách. Dữ liệu locale có thể dùng khoảng trắng không ngắt dòng để giữ ký hiệu và số đi cùng nhau. So sánh một output từ desktop JVM không đủ để kết luận Android phải dùng đúng cùng byte.
Đừng để UI chỉ dựa vào ký hiệu mơ hồ khi nhiều đơn vị có thể cùng dùng một ký hiệu. Khi cần, hiển thị thêm mã currency từ dữ liệu ở vị trí phù hợp. Đồng thời thử chữ lớn, phần đọc màn hình và vùng bảng hẹp để tránh mất đơn vị vì cắt text.
Lỗi thường gặp và giới hạn
- Lấy locale làm nguồn xác định currency của một giao dịch đã có mã riêng.
- Chỉ gọi setCurrency rồi quên cấu hình fraction digits.
- Lưu chuỗi giao diện thay cho amount và currency gốc.
- Dùng chung một NumberFormat mutable giữa nhiều thread mà không quản lý truy cập. Ví dụ tạo formatter trong mỗi lời gọi để giữ phạm vi đơn giản.
Hàm trên tập trung vào trình bày có kiểm soát, không thiết kế kế toán, tỷ giá hay quy tắc thanh toán. Giữ dữ liệu gốc có cấu trúc, cấu hình locale/currency rõ ràng và kiểm tra trên Android đích trước khi chốt giao diện.

