用實(shí)戰(zhàn)-22-頁(yè)面首次加載為何要先初始化 Preferences 再刷新統(tǒng)計(jì))
首頁(yè)上的“練習(xí)、合格、錯(cuò)題”看起來(lái)只是三個(gè)數(shù)字實(shí)際上它們是持久化初始化時(shí)序的最終投影。順序?qū)懛磿r(shí)應(yīng)用不會(huì)一定崩潰反而更容易出現(xiàn)迷惑現(xiàn)象歷史頁(yè)明明有舊記錄冷啟動(dòng)首頁(yè)卻顯示三個(gè) 0等用戶完成一次新練習(xí)后統(tǒng)計(jì)又突然恢復(fù)。燈光模擬應(yīng)用用一條很短的 Promise 鏈規(guī)避了這個(gè)問(wèn)題先取得Preferences實(shí)例再讀取歷史記錄最后用State records驅(qū)動(dòng)統(tǒng)計(jì)重算。本文不把“先后順序”停留在口號(hào)上而是逐段解釋首次構(gòu)建、異步回調(diào)、兜底空數(shù)組和派生統(tǒng)計(jì)之間的真實(shí)關(guān)系。三個(gè) 0 可能是正常首幀也可能是一次被掩蓋的讀取失敗Index頁(yè)給記錄數(shù)組的初始值是空數(shù)組Staterecords:PracticeRecord[][];privatestore:PracticeStorenewPracticeStore();因此頁(yè)面第一次執(zhí)行build()時(shí)統(tǒng)計(jì)區(qū)允許暫時(shí)顯示 0。這本身不是錯(cuò)誤。真正的驗(yàn)收點(diǎn)是Preferences準(zhǔn)備完成后持久化記錄能否被讀出并寫(xiě)回records從而觸發(fā)二次渲染。要區(qū)分兩種狀態(tài)看到 0 的時(shí)刻是否合理下一步應(yīng)發(fā)生什么冷啟動(dòng)剛出現(xiàn)的首幀可以接受初始化完成后刷新成真實(shí)數(shù)字初始化與讀取已經(jīng)結(jié)束有歷史時(shí)不合理檢查是否提前讀取、解析失敗或存儲(chǔ)未初始化存儲(chǔ)確實(shí)沒(méi)有歷史合理繼續(xù)保持 0并顯示對(duì)應(yīng)空態(tài)存儲(chǔ)初始化異常但被兜底不能冒充“沒(méi)有數(shù)據(jù)”正式產(chǎn)品應(yīng)增加可觀測(cè)狀態(tài)或日志當(dāng)前源碼選擇“可用優(yōu)先”讀取失敗時(shí)回退空數(shù)據(jù)不讓首頁(yè)崩潰。文章后面會(huì)說(shuō)明如何在不改變這一原則的前提下增加錯(cuò)誤可見(jiàn)性。aboutToAppear 建立正確的因果順序下面是Index.ets的真實(shí)啟動(dòng)代碼aboutToAppear():void{constcontextthis.getUIContext().getHostContext()ascommon.UIAbilityContext;this.store.init(context).then((){this.refreshData();});}它表達(dá)的是“init完成后才調(diào)用refreshData”而不是“頁(yè)面必須等數(shù)據(jù)讀完才顯示”。根據(jù) ArkUI 自定義組件生命周期aboutToAppear在組件實(shí)例創(chuàng)建后、build()之前觸發(fā)但這里啟動(dòng)的是異步任務(wù)then不會(huì)阻塞首幀構(gòu)建。所以準(zhǔn)確時(shí)序是Index創(chuàng)建records采用空數(shù)組默認(rèn)值aboutToAppear()獲取UIAbilityContext并啟動(dòng)初始化頁(yè)面可以先用默認(rèn)狀態(tài)完成構(gòu)建preferences.getPreferences()完成Promise 回調(diào)執(zhí)行refreshData()records被替換依賴它的統(tǒng)計(jì)組件更新。這里最關(guān)鍵的是第 4、5 步不能倒置。首幀是否顯示加載態(tài)屬于產(chǎn)品選擇但真實(shí)讀取必須發(fā)生在存儲(chǔ)句柄可用之后。PracticeStore.init 只負(fù)責(zé)拿到持久化入口PracticeStore將Preferences聲明為可選成員并在初始化失敗時(shí)保持undefinedconstSTORE_NAME:stringkemusan_exam_store;exportclassPracticeStore{privatestore?:preferences.Preferences;asyncinit(context:common.UIAbilityContext):Promisevoid{if(this.store){return;}try{this.storeawaitpreferences.getPreferences(context,STORE_NAME);}catch(err){this.storeundefined;}}}這個(gè)方法擁有三個(gè)清晰邊界頁(yè)面提供有效的UIAbilityContext服務(wù)不在內(nèi)部猜測(cè)上下文已經(jīng)取得實(shí)例后再次調(diào)用會(huì)立即返回初始化異常不會(huì)直接把頁(yè)面 Promise 變成未處理拒絕。與此同時(shí)當(dāng)前實(shí)現(xiàn)沒(méi)有向調(diào)用者返回“成功/失敗”狀態(tài)也沒(méi)有記錄錯(cuò)誤詳情。這意味著then被調(diào)用只代表init()已結(jié)束不等價(jià)于一定拿到了存儲(chǔ)實(shí)例。后續(xù)讀方法的兜底會(huì)繼續(xù)保證頁(yè)面可用但無(wú)法區(qū)分“真的沒(méi)有歷史”和“初始化失敗”。這是當(dāng)前源碼的容錯(cuò)邊界不應(yīng)被包裝成完整的錯(cuò)誤提示體系。為什么不能在 init 之前直接 listRecordslistRecords()最終會(huì)調(diào)用一個(gè)私有讀取方法。當(dāng)前真實(shí)邏輯可概括為privateasyncgetString(key:string,fallback:string):Promisestring{if(!this.store){returnfallback;}try{constvalueawaitthis.store.get(key,fallback);returntypeofvaluestring?value:fallback;}catch(err){returnfallback;}}如果頁(yè)面搶在init()之前調(diào)用listRecords()this.store還是undefined于是getString()會(huì)立刻返回[]。JSON 解析成功、頁(yè)面不報(bào)錯(cuò)、統(tǒng)計(jì)也能計(jì)算——只是計(jì)算的是一個(gè)“看起來(lái)合法”的空數(shù)組。最危險(xiǎn)的反例不是崩潰而是下面這種沒(méi)有第二次刷新的代碼// 反例兩個(gè)異步操作并行啟動(dòng)讀取可能先拿到兜底空數(shù)組aboutToAppear():void{constcontextthis.getUIContext().getHostContext()ascommon.UIAbilityContext;this.store.init(context);this.refreshData();}一旦refreshData()先走到getString()頁(yè)面就把空數(shù)組寫(xiě)入records隨后init()雖然成功也沒(méi)有任何動(dòng)作重新讀取歷史。這個(gè)故障很難從異常堆棧發(fā)現(xiàn)因?yàn)槊恳徊蕉肌俺晒Ψ祷亍绷?。listRecords 是原始記錄到頁(yè)面狀態(tài)的唯一讀路徑初始化完成后refreshData()不自行讀取鍵值或解析 JSON而是調(diào)用服務(wù)層privaterefreshData():void{this.store.listRecords().then((records:PracticeRecord[]){this.recordsrecords;});}PracticeStore.listRecords()內(nèi)部讀取exam_history把舊結(jié)構(gòu)規(guī)范化為PracticeRecord再按createdAt從新到舊排序。頁(yè)面得到的不是一段原始 JSON而是一組已經(jīng)過(guò)兼容處理的領(lǐng)域記錄。這種分層有實(shí)際收益JSON 損壞時(shí)由服務(wù)統(tǒng)一回退空數(shù)組舊記錄缺少subject、mode等字段時(shí)由normalizeRecord()補(bǔ)默認(rèn)值首頁(yè)、歷史頁(yè)和篩選邏輯共同消費(fèi)同一份records不會(huì)各自解析出不同結(jié)果。圖中records才是頁(yè)面會(huì)話內(nèi)的真值統(tǒng)計(jì)數(shù)字是從它派生出來(lái)的展示結(jié)果不應(yīng)被單獨(dú)持久化后再嘗試雙向同步。getStats 從同一批記錄重算不手工補(bǔ)計(jì)數(shù)PracticeStore.getStats()遍歷記錄根據(jù)passed計(jì)算通過(guò)和錯(cuò)題數(shù)量getStats(records:PracticeRecord[]):PracticeStats{letpassed0;letwrong0;for(letindex0;indexrecords.length;index){if(records[index].passed){passed;}else{wrong;}}return{total:records.length,passed,wrong};}頁(yè)面中的getStats()只是把當(dāng)前records交給服務(wù)計(jì)算。這樣做比“答對(duì)就把合格數(shù)加一、答錯(cuò)就把錯(cuò)題數(shù)加一”更可靠因?yàn)橐韵聢?chǎng)景都會(huì)改變統(tǒng)計(jì)基礎(chǔ)冷啟動(dòng)重新讀取歷史清空全部記錄舊數(shù)據(jù)遷移或兼容一次寫(xiě)入后服務(wù)返回截?cái)嗟?100 條的最新記錄某條記錄未來(lái)增加修改或刪除能力。當(dāng)前統(tǒng)計(jì)面板會(huì)分別調(diào)用this.getStats()讀取三個(gè)字段。由于歷史上限是 100這個(gè)開(kāi)銷可控若以后數(shù)據(jù)量增大可在普通方法中一次計(jì)算后映射到頁(yè)面狀態(tài)但不要在Builder內(nèi)聲明臨時(shí)變量破壞 ArkTS 聲明式語(yǔ)法。寫(xiě)入后的刷新鏈路同樣不能只改數(shù)字應(yīng)用完成練習(xí)后addSimpleRecord()調(diào)用store.addRecord()。服務(wù)先讀取舊記錄、把新記錄放到數(shù)組頭部、保留最多 100 條再寫(xiě)入Preferences并返回最新數(shù)組頁(yè)面隨后替換records。this.store.addRecord(record).then((records:PracticeRecord[]){this.recordsrecords;});這條寫(xiě)路徑和首次讀路徑遵循同一個(gè)原則頁(yè)面狀態(tài)來(lái)自服務(wù)返回的完整記錄快照而不是頁(yè)面猜測(cè)“總數(shù)應(yīng)該加一”。因此持久化、容量上限和統(tǒng)計(jì)三者不會(huì)各維護(hù)一套計(jì)數(shù)。需要誠(chéng)實(shí)說(shuō)明當(dāng)前實(shí)現(xiàn)的另一個(gè)邊界putString()捕獲了寫(xiě)入或flush()異常但addRecord()仍可能把內(nèi)存數(shù)組返回給頁(yè)面。也就是說(shuō)當(dāng)前會(huì)話看起來(lái)新增成功不代表進(jìn)程重啟后一定能讀到。若要做發(fā)布級(jí)可靠性應(yīng)讓寫(xiě)入結(jié)果顯式攜帶成功狀態(tài)并用重啟回讀驗(yàn)證而不是只看當(dāng)前界面數(shù)字。從當(dāng)前實(shí)現(xiàn)升級(jí)到可觀測(cè)加載狀態(tài)如果產(chǎn)品不希望首幀短暫顯示 0可以加一個(gè)非常小的加載狀態(tài)。以下代碼是建議方案不是當(dāng)前The_kemusan已有實(shí)現(xiàn)頁(yè)面與后面的Promiseboolean初始化接口需要一起遷移Staterecords:PracticeRecord[][];StatehistoryLoadState:stringloading;asyncaboutToAppear():Promisevoid{constcontextthis.getUIContext().getHostContext()ascommon.UIAbilityContext;this.historyLoadStateloading;try{constinitializedawaitthis.store.init(context);if(!initialized){this.historyLoadStatefailed;return;}this.recordsawaitthis.store.listRecords();this.historyLoadStateready;}catch(err){this.historyLoadStatefailed;}}對(duì)應(yīng)的PracticeStore.init()顯式返回boolean頁(yè)面必須檢查false分支不能只依靠catch。否則初始化內(nèi)部捕獲異常后頁(yè)面仍會(huì)把失敗誤記為readyasyncinit(context:common.UIAbilityContext):Promiseboolean{if(this.store){returntrue;}try{this.storeawaitpreferences.getPreferences(context,STORE_NAME);returntrue;}catch(err){this.storeundefined;returnfalse;}}這套布爾方案能把“初始化失敗可重試”與加載中、初始化成功后的空記錄區(qū)分開(kāi)但還不是所有讀取錯(cuò)誤的完整可觀測(cè)方案。當(dāng)前l(fā)istRecords()和getString()仍會(huì)把讀取或解析異?;赝藶榭諗?shù)組若要區(qū)分“確實(shí)無(wú)歷史”和“讀取損壞”還應(yīng)讓讀取接口返回明確的結(jié)果狀態(tài)而不是把空數(shù)組直接視為正常讀取。當(dāng)前源碼只實(shí)現(xiàn)可用性兜底上面的建議僅補(bǔ)齊初始化失敗的可見(jiàn)性。遷移檢查不要漏掉上下文、異步順序和回讀把這一模式遷移到其他 HarmonyOS 項(xiàng)目時(shí)可按以下順序執(zhí)行在服務(wù)中統(tǒng)一保存Preferences實(shí)例頁(yè)面不直接散落鍵名。從UIContext獲取宿主UIAbilityContext傳入服務(wù)初始化。await init()或在.then()內(nèi)開(kāi)始讀取禁止并行搶跑。服務(wù)返回規(guī)范化記錄頁(yè)面一次性替換State數(shù)組。統(tǒng)計(jì)從記錄重算不維護(hù)獨(dú)立持久化計(jì)數(shù)。寫(xiě)入后關(guān)閉并重開(kāi)頁(yè)面必要時(shí)重啟應(yīng)用驗(yàn)證flush()后的數(shù)據(jù)確實(shí)存在。如果一個(gè)頁(yè)面會(huì)在導(dǎo)航返回或應(yīng)用回前臺(tái)時(shí)繼續(xù)復(fù)用還要根據(jù)頁(yè)面架構(gòu)評(píng)估onPageShow或NavDestination生命周期刷新。aboutToAppear適合組件實(shí)例創(chuàng)建前后的初始化但不會(huì)因?yàn)閼?yīng)用每次從后臺(tái)回前臺(tái)就必然重新創(chuàng)建組件。不要把“首次初始化”和“每次重新可見(jiàn)”混為一談。驗(yàn)證矩陣用時(shí)序而不是肉眼猜測(cè)用例準(zhǔn)備數(shù)據(jù)操作預(yù)期首次安裝無(wú)歷史啟動(dòng)應(yīng)用初始化結(jié)束后總數(shù)仍為 0無(wú)異常冷啟動(dòng)回讀預(yù)置 3 條通過(guò)、2 條失敗結(jié)束進(jìn)程再啟動(dòng)總數(shù) 5、合格 3、錯(cuò)題 2慢初始化在調(diào)試環(huán)境記錄時(shí)間戳啟動(dòng)并觀察首幀與回調(diào)可先顯示默認(rèn)態(tài)隨后只刷新到真實(shí)快照提前讀取反例臨時(shí)把刷新移到init外冷啟動(dòng)可復(fù)現(xiàn)先讀到空數(shù)組用于證明順序問(wèn)題驗(yàn)證后還原JSON 異常調(diào)試構(gòu)造非法歷史字符串啟動(dòng)頁(yè)面不崩潰記錄回退空數(shù)組舊記錄兼容缺少新增字段的歷史啟動(dòng)normalizeRecord()補(bǔ)默認(rèn)字段統(tǒng)計(jì)仍正確寫(xiě)入持久化完成一次練習(xí)重啟應(yīng)用新紀(jì)錄仍存在統(tǒng)計(jì)一致清空歷史先有多條記錄清空后重啟三個(gè)數(shù)字歸零不回彈舊數(shù)據(jù)建議給init 開(kāi)始/結(jié)束、listRecords 開(kāi)始/結(jié)束、records 賦值臨時(shí)打印單調(diào)序號(hào)而不是只打印“成功”。正確日志順序應(yīng)始終是初始化結(jié)束在讀取開(kāi)始之前。驗(yàn)證后移除日志避免把本地存儲(chǔ)內(nèi)容寫(xiě)進(jìn)正式日志。故障表從哪個(gè)邊界開(kāi)始排查癥狀更可能的根因檢查點(diǎn)冷啟動(dòng)永遠(yuǎn)是 0做題后正常讀取早于初始化且沒(méi)有二次刷新aboutToAppear的 Promise 順序當(dāng)前會(huì)話有記錄重啟后消失寫(xiě)入或flush()失敗putString()的結(jié)果與重啟回讀有記錄但統(tǒng)計(jì)分類錯(cuò)誤舊字段規(guī)范化或passed值異常normalizeRecord()和原始數(shù)據(jù)切回首頁(yè)仍是舊統(tǒng)計(jì)頁(yè)面復(fù)用后沒(méi)有刷新路由返回對(duì)應(yīng)的生命周期入口初始化失敗卻顯示“暫無(wú)記錄”失敗與空數(shù)據(jù)共用同一兜底增加顯式加載/失敗狀態(tài)偶發(fā)重復(fù)初始化多次觸發(fā)生命周期且首個(gè)初始化未完成增加進(jìn)行中的 Promise 復(fù)用或頁(yè)面級(jí)門(mén)閂生命周期時(shí)機(jī)可參考華為官方的 頁(yè)面和自定義組件生命周期。官方說(shuō)明用于確認(rèn)aboutToAppear與build()的前后關(guān)系本文中的存儲(chǔ)名、記錄結(jié)構(gòu)和刷新鏈路均來(lái)自當(dāng)前項(xiàng)目源碼。最終結(jié)論先建立數(shù)據(jù)入口再讀取再派生“先初始化 Preferences 再刷新統(tǒng)計(jì)”真正解決的不是一個(gè) API 調(diào)用順序而是數(shù)據(jù)可信度問(wèn)題。存儲(chǔ)實(shí)例未準(zhǔn)備好時(shí)兜底空數(shù)組只能代表“暫時(shí)讀不到”不能代表“用戶沒(méi)有歷史”。當(dāng)前應(yīng)用通過(guò)init().then(refreshData)保住了因果順序再用服務(wù)規(guī)范化記錄、用State records驅(qū)動(dòng)渲染、用getStats()從同一快照派生數(shù)字。繼續(xù)加固時(shí)應(yīng)優(yōu)先補(bǔ)加載與失敗可觀測(cè)性仍然不要讓頁(yè)面自己維護(hù)一套與持久化脫節(jié)的計(jì)數(shù)。