字、日期與文件大小本地化解析)
做跨端適配最怕遇到的就是那種“包很小、功能看著很直接真跑起來全是隱性差異”的組件。最近在把一套 Flutter 工程往鴻蒙HarmonyOS端遷移時我花了兩天時間把 humanize 這個人性化數(shù)據(jù)格式組件徹底理順了。簡單說humanize 負責(zé)把原始數(shù)字、文件大小、日期差值這些冷冰冰的數(shù)據(jù)翻譯成人話1234567 顯示成 1.23 million3845 字節(jié)顯示成 3.8 KB三天前的時間顯示成“3 days ago”。這類能力在普通 Flutter 環(huán)境里用得很順但到了鴻蒙端最先暴露出來的不是功能缺失而是語言、單位、刷新時機這些“感知層”問題。這篇內(nèi)容適合正在做 Flutter 組件向鴻蒙遷移的開發(fā)者也適合想搞清楚這類格式化組件在非標(biāo)準(zhǔn)環(huán)境下如何本地化的朋友。1. 為什么是 humanize人性化格式的語義范圍比你想的更寬1.1 數(shù)字、日期、文件大小之外的“隱藏語義”很多人第一次接觸 humanize以為它只是“把數(shù)字變短”。實際用下來會發(fā)現(xiàn)它是一整套“語言規(guī)則”的集合底層依賴的是 locale、復(fù)數(shù)規(guī)則、單位體系這些東西??绲进櫭啥酥攸c往往不在包本身而在于它對語言的假設(shè)。我在這次適配中把需要關(guān)注的語義能力分成了四類貼一張表方便對照場景原始數(shù)據(jù)英文輸出中文場景期望緊湊數(shù)字12345671.23 million123.5萬相對日期now - 3 小時3 hours ago3小時前序數(shù)詞33rd第3文件大小2048 bytes2 KB2 KB前兩類幾乎每個 App 都會用到。緊湊數(shù)字出現(xiàn)在指標(biāo)卡、統(tǒng)計報表、支付金額備選展示里相對日期出現(xiàn)在點贊記錄、消息列表、動態(tài)時間戳里。文件大小次之主要出現(xiàn)在下載頁和存儲詳情頁。序數(shù)詞在中文環(huán)境里使用頻率低但英文環(huán)境下“1st、2nd、3rd”又是剛需。還有一個容易忽略的“隱藏語義”自然語言連接。英文會把列表拼成 “A, B and C”中文則習(xí)慣“A、B 和 C”。這種低頻能力我建議適配時先凍結(jié)不要強行統(tǒng)一等核心場景穩(wěn)定后再考慮。盲目擴大適配范圍只會讓回歸測試量翻倍收益卻很小。1.2 適配鴻蒙前先做能力盤點哪些必改、哪些暫緩在動手寫代碼之前我把 humanize 的現(xiàn)有能力從頭到尾過了一遍按“改動成本”和“用戶感知頻率”做了個優(yōu)先級排序高優(yōu)先級緊湊數(shù)字、相對日期。這兩個幾乎每個界面都會出現(xiàn)而且中文和英文的表述習(xí)慣差異巨大。中優(yōu)先級文件大小。高頻但規(guī)則相對固定主要糾結(jié)在二進制和十進制的選擇上。低優(yōu)先級序數(shù)詞、復(fù)數(shù)、自然語言連接。中文環(huán)境基本感知不到英文環(huán)境保留默認行為即可。這個優(yōu)先級直接影響后面兩個決策改哪些代碼、先跑哪些測試用例。做能力盤點還有一個作用避免把“通用包直接引入”當(dāng)成適配完成。我知道不少團隊在遷移時直接把 humanize 加進 pubspec運行了一下中文場景發(fā)現(xiàn)輸出還是英文就開始懷疑鴻蒙 SDK 有問題。其實問題很簡單——組件自帶的是英文本地化規(guī)則它需要你主動告訴它當(dāng)前是什么語言。所以適配的第一步永遠是先把 Locale 鏈路打通。2. 鴻蒙端跑通用 humanize 的第一道坎Locale 與中文本地化2.1 默認英文文案的來源與“半本地化”現(xiàn)象在普通 Flutter 環(huán)境里很多組件會自動讀取系統(tǒng)語言比如 Material 組件庫里的日期選擇器、文本方向判斷。但 humanize 這類純 Dart 格式化組件不會去監(jiān)聽系統(tǒng)狀態(tài)它只知道你傳進來的 locale 參數(shù)。鴻蒙端接入后我遇到的第一個怪現(xiàn)象是系統(tǒng)語言已經(jīng)切成中文界面上的按鈕、Label 都變成了中文但 humanize 輸出的還是 “1.2K”“3 days ago”。你可以理解成一種“半本地化”狀態(tài)——UI 層被系統(tǒng)處理了但數(shù)據(jù)格式化層沒被通知到。排查后發(fā)現(xiàn)鴻蒙端 Flutter 拿到的系統(tǒng)語言標(biāo)識偶爾會是 BCP47 格式比如zh-CN而 Dart 側(cè)很多組件習(xí)慣用下劃線格式zh_CN。如果直接把zh-CN當(dāng)成Locale對象的輸出傳入底層匹配不上語言表就會回退到默認英文。這個小細節(jié)在排錯時很隱蔽很多同事一開始都以為是緩存問題其實只是字符串分隔符不同。所以我在項目里加了一個normalizeLocale函數(shù)專門把-轉(zhuǎn)成_并且把zh_Hans、zh_Hant這種帶腳本標(biāo)識的 Locale 也做歸一化處理。2.2 中文字段表的調(diào)整數(shù)字單位、復(fù)數(shù)規(guī)則、日期相對詞打通 Locale 之后下一步就是語言表本身。英文數(shù)字是三位一個單位thousand、million、billion中文是四位一個單位萬、億。這意味著人類感知的“刻度”完全不同不能只把英文詞換成中文詞連分組邏輯都要改。相對日期也一樣。英文有完整的單復(fù)數(shù)變化“1 hour ago”和 “2 hours ago”中文沒有復(fù)數(shù)后綴直接是“1小時前”“2小時前”。方向詞也不一樣英文用 ago / in中文用“前 / 后”還有“剛剛”“馬上”這種口語化表達。我的做法是維護一個簡單的多語言數(shù)據(jù)表class HumanizeMessages { final String justNow; final String minutesAgo; final String hoursAgo; final String daysAgo; final String inMinutes; final String compactTenThousand; final String compactHundredMillion; }每個語言一份中文、英文各一份。啟動時根據(jù)當(dāng)前 Locale 選取并注入到統(tǒng)一的格式化入口。這里有一個關(guān)鍵點不要在每個頁面里各自創(chuàng)建一份表否則切語言時會看到一部分頁面是新的文案、一部分還是舊的非常難排查。2.3 一個容易忽略的細節(jié)語言切換后的緩存失效真正讓我多花了半天時間的是緩存問題。格式化結(jié)果本質(zhì)上也是本地化產(chǎn)物是會隨語言變化的。但團隊里有些同學(xué)習(xí)慣把格式化后的字符串直接緩存到內(nèi)存里理由也很正當(dāng)“一天之內(nèi)語言不會變”。實際在鴻蒙設(shè)備上系統(tǒng)語言設(shè)置是可以實時切換的多語言應(yīng)用也常常在應(yīng)用內(nèi)切換語言這時候緩存就成了罪魁禍?zhǔn)?。我的建議是格式化后的字符串只做短期緩存比如單次頁面構(gòu)建會話內(nèi)有效不做全局靜態(tài)緩存。更嚴(yán)格的做法是格式化結(jié)果根本不緩存每次視圖重建時重新計算。后面會講到這套機制在真機上踩坑的過程。3. 核心實操數(shù)字壓縮、文件大小與日期語義的鴻蒙端適配3.1 數(shù)字壓縮中英文單位映射與邊界精度數(shù)字壓縮是 humanize 最常見的用法也是最容易踩精度坑的地方。英文場景里1234567 通常期望顯示成 1.23M 或者 1.23 million中文場景則習(xí)慣直接顯示成 123.5萬四位一檔。一開始我嘗試直接用NumberFormat.compact它在普通 Flutter 環(huán)境里的表現(xiàn)確實不錯。但在鴻蒙端 Flutter 嵌入環(huán)境下我發(fā)現(xiàn) intl 的版本行為不完全一致部分版本對中文 compact 的處理會保留冗長的零比如輸出 “1200000”而不是“120萬”。為了保持回歸可控我干脆自己寫了一個精簡版String compactNumber(num value, {String locale zh}) { if (locale.startsWith(zh)) { if (value 100000000) { return _trimZero((value / 100000000).toStringAsFixed(1)) 億; } if (value 10000) { return _trimZero((value / 10000).toStringAsFixed(1)) 萬; } return value.toStringAsFixed(0); } // 英文場景使用原有 humanize 邏輯 }_trimZero做的事情是把 123.0 修正成 123把 1.50 修正成 1.5避免界面出現(xiàn) “123.0萬”這種別扭的文案。這里要注意一個邊界如果值為 99995000用萬做單位會看到 “9999.5萬”用億做單位會得到 “1.0億”。從人體感知角度1.0億顯然更簡潔但判斷邏輯不能只做兩次 if需要在“位數(shù)縮短”和“精度損失”之間取一個平衡。我的經(jīng)驗是當(dāng)值在億和千萬之間時優(yōu)先取最接近的整位單位而不是機械地按數(shù)值大小分段。3.2 文件大小二進制 vs 十進制、單位詞是否翻譯文件大小格式化看起來簡單實際會引出兩個爭論進制和單位詞。進制的選擇。鴻蒙操作系統(tǒng)的文件管理器習(xí)慣用二進制進制也就是 1KB 1024B1GB 1024MB。但也有部分第三方應(yīng)用習(xí)慣用十進制1kB 1000B。工程上我建議全端統(tǒng)一用二進制因為與系統(tǒng)顯示一致用戶對比存儲空間時不會算出小數(shù)點差異。代碼邏輯就是 1024 遞歸取模單位從 B 一路走到 TB。單位詞要不要翻譯成中文是另一個容易沖動處理的地方。有人會想把 KB 顯示成“千字節(jié)”把 MB 顯示成“兆字節(jié)”。我實測下來的結(jié)論是不要翻譯。一是這些單位已經(jīng)是國際通用符號用戶概念里自帶語義二是“千字節(jié)”這種中文詞在窄卡上容易折行還會擠掉數(shù)字的可讀性。通用做法是保留 KB、MB、GB 英文縮寫只在數(shù)字小數(shù)位規(guī)則上做本地化。String fileSize(int bytes, {bool useBinary true}) { const unit [B, KB, MB, GB, TB]; var size bytes.toDouble(); var step 0; final base useBinary ? 1024 : 1000; while (size base step unit.length - 1) { size / base; step; } return step 0 ? ${bytes} B : ${size.toStringAsFixed(2)} ${unit[step]}; }3.3 相對時間方向詞與“剛剛”的適配相對時間格式化比數(shù)字復(fù)雜一點因為它需要處理“過去”和“未來”兩個方向還要考慮顯示粒度。String relativeTime(DateTime time, {DateTime? now}) { final current now ?? DateTime.now(); final diff current.difference(time); if (diff.inSeconds.abs() 30) return 剛剛; if (diff.isNegative) { // 未來時間 if (diff.inMinutes.abs() 60) return ${diff.inMinutes.abs()}分鐘后; if (diff.inHours.abs() 24) return ${diff.inHours.abs()}小時后; return ${diff.inDays.abs()}天后; } if (diff.inMinutes 60) return ${diff.inMinutes}分鐘前; if (diff.inHours 24) return ${diff.inHours}小時前; if (diff.inDays 7) return ${diff.inDays}天前; // 超過 7 天直接走日期格式 return DateFormat(yyyy-MM-dd).format(time); }這個實現(xiàn)里有幾個容易出問題的細節(jié)。第一個是now.difference(time)的方向性如果time是過去的某個時刻diff為正數(shù)但如果time在未來inMinutes會是負數(shù)。很多新手直接拿負數(shù)去做拼接結(jié)果界面出現(xiàn)“-3分鐘前”。第二個是中文不需要復(fù)數(shù)但英文需要所以相對時間的英文分支要額外處理 single 和 plural。第三個是“剛剛”的閾值30秒以內(nèi)是體驗最好的超過 1 分鐘還顯示“剛剛”會讓人覺得時間沒刷新。4. 真機驗證與排錯記錄切語言不刷新、字體缺字、精度誤差4.1 切語言后文案不刷新完整排查鏈路這個排錯過程我完整記錄過非常典型值得按鏈路走一遍?,F(xiàn)象是這樣的真機上把系統(tǒng)語言從英文切到中文界面大部分文字立刻變中文但某個列表頁里 humanize 格式化出來的數(shù)字還是 1.2K日期還是 3 days ago。等了十幾秒也不刷新。第一步我檢查 Flutter 側(cè)是否收到了系統(tǒng)語言變化。在MaterialApp的localeResolutionCallback里打了日志確認回調(diào)觸發(fā)了頁面也重建了。第二步懷疑是列表項渲染太快格式化發(fā)生在頁面構(gòu)建之前。查看代碼后發(fā)現(xiàn)列表數(shù)據(jù)在進入頁面時統(tǒng)一格式化后緩存進了一個列表項模型里。也就是說頁面雖然重建了但列表數(shù)據(jù)的格式化內(nèi)容是事前算好的不會跟著語言變化重新計算。這是第一個根因。第三步修復(fù)方式是把緩存去掉在 item 的build方法里重新調(diào)用格式化函數(shù)。改完再測大部分文案正常了但還有一處數(shù)字依舊是英文。第四步這處數(shù)字來自另一個模塊的靜態(tài)工具類。這個工具類直接引用了Intl.defaultLocale而它被某個三方日志組件在初始化時設(shè)置成了en_US。這屬于全局變量污染。修復(fù)方案很粗暴在應(yīng)用啟動階段統(tǒng)一用一個AppLocaleController維護當(dāng)前的 locale并在系統(tǒng)語言變化回調(diào)里同時更新Intl.defaultLocale。排查來回花了兩小時但收獲很直接格式化入口必須收斂到統(tǒng)一的地方任何散落的分支都可能在某個角落繞過語言更新邏輯。4.2 精簡字體下“萬”“億”的渲染異常第二個問題來自一臺系統(tǒng)字體被精簡過的設(shè)備界面里“1.2萬”的“萬”字渲染成了豆腐塊。最初我以為是 humanize 輸出亂碼后來發(fā)現(xiàn)是字體回退鏈路的問題數(shù)字格式化后生成了中文字符而這個界面為了視覺統(tǒng)一配置了某款拉丁字體族中文字符沒有對應(yīng)的 fallback。這個坑在鴻蒙端比其他端更容易遇到因為不少鴻蒙設(shè)備鏡像為了減小體積會裁剪自帶中文字體集。解決辦法是在數(shù)字指標(biāo)這類組件的TextStyle里顯式指定一個包含 CJK 字符的字體族比如TextStyle( fontSize: 14, fontFamily: HarmonyOS Sans, fontFamilyFallback: [HarmonyOS Sans SC], )實在無法確定字體集時也可以臨時把中文單位替換成國際縮寫比如“1.2e8”這種形式。雖然犧牲了美觀但至少不會出現(xiàn)亂碼。4.3 精度誤差與展示截斷數(shù)字壓縮還有一個隱藏的精度問題顯示值和真實值之間存在舍入誤差。例如 22994999 按萬顯示是 2299.5萬按億顯示是 0.2億兩種方案差異很大。產(chǎn)品經(jīng)理經(jīng)常要求“保留一位小數(shù)”但當(dāng)數(shù)值跨越單位閾值時保留一位小數(shù)的行為會讓數(shù)字變大或變小。比如 99999999最貼近的表述是 9999.9999萬按一位小數(shù)會約成 10000.0萬而用戶的直覺期望是顯示成更高的“1.0億”。這類問題沒法完全靠語言表解決必須在格式化函數(shù)里做“就近取整單位”的邏輯比較兩個相鄰單位的結(jié)果哪種更短、更符合直覺。這個判斷規(guī)則我放在第三個段落里提到的compactNumber的邊界分支中并且加了注釋當(dāng)值的首位接近 9 時優(yōu)先進位到高一級單位而不是強行保留當(dāng)前單位的一位小數(shù)。5. 性能與工程落地在鴻蒙端引入 humanize 的正確姿勢5.1 調(diào)用頻率與執(zhí)行耗時別在 build 里重復(fù)構(gòu)造格式化函數(shù)看起來輕但底層如果每次調(diào)用都去創(chuàng)建NumberFormat或DateFormat實例成本并不低。我在低端測試機上開了日志測過一次列表滾動時每個 item 同時做數(shù)字壓縮和相對日期格式化如果每幀都重新 new 格式化器幀率會出現(xiàn)肉眼可見的抖動。正確的做法是把格式化器做成單例或者在狀態(tài)管理層的入口里預(yù)創(chuàng)建實例后續(xù)只調(diào)用format方法。尤其是DateFormat構(gòu)造時需要解析模式字符串比NumberFormat更重盡量做到整個應(yīng)用只創(chuàng)建一次。5.2 統(tǒng)一封裝與禁用范圍不要讓每處 UI 自己格式化這次適配讓我最大的教訓(xùn)是humanize 不應(yīng)該出現(xiàn)在任意的 UI 文件里。它太容易被順手調(diào)用一旦散落語言切換、精度策略、單位統(tǒng)一都會失控。我最終的做法是封裝了一個統(tǒng)一的語義格式化入口公開方法只有四個SemanticText.compactNumber(int value)SemanticText.fileSize(int bytes)SemanticText.relativeTime(DateTime time)SemanticText.ordinal(int number)UI 層只面向這個入口調(diào)用具體是用 humanize 原邏輯還是自己實現(xiàn)對調(diào)用方透明。這樣后續(xù)如果要擴展語言只需改語言表如果要調(diào)整單位策略只需改動入口內(nèi)部實現(xiàn)。5.3 回歸清單這樣測才能放心合入鴻蒙分支最后列一份我用來合入的回歸清單覆蓋這次踩過的所有問題檢查項用例預(yù)期結(jié)果主要語言輸出系統(tǒng)語言切中文數(shù)字顯示“1.2萬”、日期顯示“3小時前”次語言輸出系統(tǒng)語言切英文數(shù)字顯示“1.2M”、日期顯示“3 hours ago”Locale 格式兼容系統(tǒng)返回 zh-CN / zh_CN統(tǒng)一按中文規(guī)則輸出數(shù)字邊界10000 / 99999999 / 100000000萬、億單位切換正確無 10000.0萬 類文案相對時間未來now 5 分鐘顯示“5分鐘后”文件大小進制1536 bytes顯示“1.50 KB”而不是“2 KB”或“1.536 KB”字體回退精簡字體設(shè)備“萬”“億”不出現(xiàn)豆腐塊語言切換熱切換后立即返回列表頁所有格式化文案同步更新性能千條列表滾動幀率無掉幀無格式化器重復(fù)構(gòu)建告警自動化用例覆蓋前六項后三項必須在真機上人工過一遍。我把自動化用例直接放到了項目的格式化單元測試目錄里每次修改語言表都會先跑這組用例避免只改英文導(dǎo)致中文回歸。最后再分享一個小技巧適配這類通用包別迷信“開箱即用”。把語言當(dāng)作配置項而不是環(huán)境變量所有格式化結(jié)果都通過統(tǒng)一入口產(chǎn)出后續(xù)維護會輕松很多。