:保持詳情頁與收藏列表同步)
收藏功能最容易出現(xiàn)一種“看起來已經(jīng)成功”的錯誤用戶在題目頁點亮收藏圖標切到收藏列表也能看到記錄但應(yīng)用重啟后收藏消失或者兩個題庫恰好使用同一個題目 ID收藏其中一題卻把另一題判定為已收藏。界面、內(nèi)存和磁盤只要有一處采用了不同的身份規(guī)則所謂同步就只是暫時一致。本文基于知律項目D:\huawei\one19-11、包名com.jiaweikang.one19的真實源碼沿著 brief 指向的FavoritePage.ets繼續(xù)追蹤PracticePage.ets、UserDataManager.ets、EntryAbility.ets與Index.ets。當前版本沒有獨立“法條詳情頁”收藏入口位于練習頁收藏列表點擊后進入同題庫的隨機練習也不會精準跳回被收藏題目。文章標題里的“詳情頁”因此按收藏發(fā)起頁來理解并明確區(qū)分現(xiàn)狀與改造方案。一、先畫出當前真實同步鏈路應(yīng)用啟動時EntryAbility.onCreate()調(diào)用UserDataManager.init(this.context)管理器從 Preferences 讀取 JSON 字符串再把收藏數(shù)組放進AppStorageconst favStr UserDataManager.prefs.getSync( UserDataManager.K_FAV, [] ) as string AppStorage.setOrCreateFavoriteRecord[]( favoriteRecords, JSON.parse(favStr) as FavoriteRecord[] )練習頁和收藏頁都通過相同鍵建立雙向鏈接StorageLink(favoriteRecords) favRecords: FavoriteRecord[] []所以當前會話里的鏈路是練習頁產(chǎn)生新數(shù)組AppStorage保存新引用收藏頁訂閱同一鍵并重新渲染進程重啟后的鏈路則由 Preferences 恢復(fù)。兩條鏈路都成立收藏才算真正同步。二、FavoriteRecord 的三個字段分別承擔什么真實模型很小export interface FavoriteRecord { questionId: string bankId: string createdAt: string }questionId指向題目bankId指向題庫createdAt用于列表展示收藏時間。這里已經(jīng)保存了題庫與題目兩個維度說明數(shù)據(jù)模型認識到“題目屬于某個題庫”。但模型擁有字段不代表所有算法都使用了這些字段。后續(xù)判重、刪除和ForEach鍵只看questionId身份契約在不同層發(fā)生了分裂。三、練習頁如何切換收藏PracticePage底部工具欄讀取當前題.onClick(() { const q this.currentQ() if (q) { this.favRecords UserDataManager.toggleFavorite( this.favRecords, q.id, q.bankId ) } })這里有一個正確細節(jié)沒有原地push或splice頁面持有的數(shù)組而是接收管理器返回的新數(shù)組并重新賦值。對于 ArkUI 狀態(tài)更新新引用比“原數(shù)組內(nèi)部悄悄變化”更容易觸發(fā)依賴組件刷新。收藏按鈕的顏色也從同一份狀態(tài)推導(dǎo)private isCurFav(): boolean { const q this.currentQ() if (!q) return false return UserDataManager.isFavorite(this.favRecords, q.id) }因此按鈕高亮和收藏列表數(shù)量在正常單題庫 ID 唯一的情況下會保持一致沒有另外維護一個容易漂移的isFavorite布爾值。四、toggleFavorite 做了哪些事現(xiàn)有實現(xiàn)先查找記錄再生成新數(shù)組static toggleFavorite( records: FavoriteRecord[], questionId: string, bankId: string ): FavoriteRecord[] { const idx records.findIndex(r r.questionId questionId) let result: FavoriteRecord[] if (idx 0) { const next [...records] next.splice(idx, 1) result next } else { result [{ questionId, bankId, createdAt: nowStr() }, ...records] } UserDataManager.persist(UserDataManager.K_FAV, result) return result }新增記錄放在頭部列表天然按最近收藏排序取消收藏復(fù)制后刪除避免直接修改輸入數(shù)組最后同步寫入 Preferences。這個實現(xiàn)短小但它把領(lǐng)域規(guī)則、持久化和結(jié)果返回放在一個同步方法中錯誤無法傳回 UI。五、當前最大的隱患是身份只看 questionIdFavoriteRecord同時保存bankId和questionId可isFavorite()與toggleFavorite()都只比較questionIdrecords.some(r r.questionId questionId) records.findIndex(r r.questionId questionId)如果題庫 A 和題庫 B 都有q_001用戶收藏 A 的q_001后B 的按鈕也會顯示已收藏再點擊 B刪除的可能是 A 的記錄。這不是渲染延遲而是主鍵定義錯誤。更穩(wěn)的身份應(yīng)是function favoriteKey(bankId: string, questionId: string): string { return ${bankId}:${questionId} }所有查詢、切換、列表鍵、路由參數(shù)和測試都必須使用同一復(fù)合身份不能一層看兩個字段、另一層只看一個字段。六、ForEach 的 key 也存在相同風險收藏列表當前寫法是ForEach(this.favRecords, (record: FavoriteRecord) { ListItem() { this.QuestionCard( record.questionId, record.bankId, record.createdAt, ) } }, (record: FavoriteRecord) record.questionId)ArkUI 用 key 識別重復(fù)項。如果兩個題庫出現(xiàn)相同questionId即使數(shù)據(jù)層允許兩條記錄列表層仍會收到重復(fù) key。復(fù)用節(jié)點時可能出現(xiàn)文本、按壓狀態(tài)或順序不符合預(yù)期。修正應(yīng)與領(lǐng)域主鍵完全一致}, (record: FavoriteRecord) ${record.bankId}:${record.questionId})主鍵不是數(shù)據(jù)庫層的私事。它會一路影響狀態(tài)查詢、組件復(fù)用、刪除目標和頁面導(dǎo)航。七、FavoritePage 如何把記錄還原成可讀卡片收藏記錄沒有冗余保存題干而是保存引用再從題庫源查詢private findQuestion( questionId: string, bankId: string ): Question | undefined { return getQuestions(bankId) .find(q q.id questionId) }題干展示使用private getStem(questionId: string, bankId: string): string { const q this.findQuestion(questionId, bankId) return q ? q.stem : 題目 ${questionId} }優(yōu)點是題干修訂后收藏列表能展示最新內(nèi)容收藏記錄也很輕。代價是題庫刪除、題目改 ID 或數(shù)據(jù)遷移失敗后只剩兜底文字。產(chǎn)品需要決定收藏保存“實時引用”還是同時保存一份只讀快照用于歷史可讀性。八、列表點擊不是精準回到收藏題當前QuestionCard的點擊行為是router.pushUrl({ url: pages/PracticePage, params: { bankId: bankId, mode: random } })參數(shù)沒有questionId。練習頁收到random后加載整個題庫并不會定位收藏題。因此用戶點的是某條收藏記錄打開的卻可能是該題庫另一道題。所以當前源碼只能證明“收藏列表可以進入所屬題庫練習”不能宣稱已經(jīng)實現(xiàn)詳情頁與列表精準跳轉(zhuǎn)。要兌現(xiàn)這個體驗需要給路由增加穩(wěn)定身份或者提供獨立題目詳情頁。九、為精準回跳定義顯式路由契約可以擴展練習參數(shù)interface PracticeParams { bankId: string mode: string focusQuestionId?: string }收藏列表傳遞兩個身份字段router.pushUrl({ url: pages/PracticePage, params: { bankId: record.bankId, mode: favorite, focusQuestionId: record.questionId } })練習頁加載題庫后再定位private locateFocusedQuestion( questions: Question[], questionId?: string ): number { if (!questionId) return 0 const index questions.findIndex(q q.id questionId) return index 0 ? index : 0 }重點不是“多傳一個參數(shù)”而是接收方要驗證該題確實屬于傳入題庫。找不到時回到首題并給出提示不能讓索引變成-1后繼續(xù)訪問數(shù)組。十、AppStorage 解決的是進程內(nèi)共享PracticePage、FavoritePage、HomePage、MinePage和統(tǒng)計頁都鏈接favoriteRecords。用戶在練習頁賦值后其他頁面讀取的是同一個應(yīng)用級狀態(tài)鍵因此數(shù)量、高亮和列表能在會話內(nèi)聯(lián)動。這層適合保存當前 UI 所需的共享狀態(tài)但不應(yīng)承擔數(shù)據(jù)校驗和磁盤錯誤處理??梢园阉斫獬伞鞍l(fā)布給界面的最新快照”不是最終數(shù)據(jù)源。十一、Preferences 才負責重啟后的恢復(fù)管理器使用preferences.getPreferencesSync(context, { name: dialect_quiz })收藏數(shù)組序列化到favoriteRecords鍵。每次切換后執(zhí)行prefs.putSync(key, JSON.stringify(value)) prefs.flushSync()這保證代碼按順序發(fā)起寫入和刷盤但它發(fā)生在點擊路徑上。收藏數(shù)據(jù)通常很小短期可用如果記錄量增長、字段擴展或一次清空多類數(shù)據(jù)最好評估同步 I/O 對交互線程的影響并把寫入結(jié)果設(shè)計成可感知狀態(tài)。十二、當前 persist 會吞掉所有失敗真實代碼是private static persist( key: string, value: Object | string | number | boolean ): void { if (UserDataManager.prefs null) return try { UserDataManager.prefs.putSync(key, JSON.stringify(value)) UserDataManager.prefs.flushSync() } catch (_) {} }如果prefs尚未初始化方法直接返回如果序列化或?qū)懭胧‘惓R脖缓雎?。toggleFavorite()仍返回新數(shù)組頁面于是顯示收藏成功但磁盤可能沒有變化。下一次啟動恢復(fù)舊數(shù)據(jù)就形成“重啟后丟收藏”。錯誤可以被轉(zhuǎn)成業(yè)務(wù)結(jié)果interface SaveResultT { ok: boolean value: T message?: string }UI 只有在ok為真時發(fā)布新狀態(tài)失敗時保留舊數(shù)組并顯示可重試提示。十三、提交順序決定用戶看到的真假當前順序?qū)嶋H上是計算result嘗試持久化無論成功失敗都返回result頁面更新AppStorage。推薦改為“持久化成功后發(fā)布”async toggleFavorite( records: FavoriteRecord[], target: FavoriteIdentity ): PromiseSaveResultFavoriteRecord[] { const next this.reducer.toggle(records, target) const saved await this.repository.save(next) return saved ? { ok: true, value: next } : { ok: false, value: records, message: 收藏保存失敗 } }這樣內(nèi)存狀態(tài)不會跑在磁盤前面。若產(chǎn)品更強調(diào)即時反饋也可以先樂觀更新但必須保留舊快照寫入失敗時回滾并提示。兩種策略都可以不能既樂觀更新又隱藏失敗。十四、用純函數(shù)固定收藏切換規(guī)則先把不涉及平臺 API 的部分抽成純函數(shù)interface FavoriteIdentity { bankId: string questionId: string } function sameFavorite( record: FavoriteRecord, target: FavoriteIdentity ): boolean { return record.bankId target.bankId record.questionId target.questionId } function reduceFavorite( records: FavoriteRecord[], target: FavoriteIdentity, createdAt: string ): FavoriteRecord[] { const exists records.some(r sameFavorite(r, target)) if (exists) { return records.filter(r !sameFavorite(r, target)) } return [{ ...target, createdAt }, ...records] }純函數(shù)不依賴Context、Preferences 或頁面可以覆蓋重復(fù)點擊、跨題庫同 ID、取消收藏和排序等測試。平臺層只負責保存結(jié)果。十五、Repository 只負責存取與遷移更清晰的職責是interface FavoriteRepository { load(): FavoriteRecord[] save(records: FavoriteRecord[]): boolean }Preferences 實現(xiàn)應(yīng)集中處理鍵名、JSON 解析、版本遷移和寫入錯誤。頁面不應(yīng)知道dialect_quiz或favoriteRecords這些存儲細節(jié)領(lǐng)域服務(wù)也不應(yīng)直接操作 ArkUI 狀態(tài)。這一步不是為了增加文件數(shù)量而是為了讓三類錯誤有各自位置身份錯誤在領(lǐng)域?qū)有薮疟P錯誤在倉儲層處理交互反饋在頁面層展示。十六、啟動解析失敗不能只回退空數(shù)組UserDataManager.init()把多類數(shù)據(jù)放在同一個try中。任何一個 JSON 解析失敗都會進入catch隨后把收藏、筆記、錯題、進度、歷史全部設(shè)置為空數(shù)組。這會產(chǎn)生兩個問題一類數(shù)據(jù)損壞可能讓其他正常數(shù)據(jù)在本次會話不可見損壞字符串沒有被修復(fù)下一次啟動可能再次失敗。更穩(wěn)的做法是逐鍵解析function parseRecordsT(raw: string, fallback: T): T { try { return JSON.parse(raw) as T } catch (_) { return fallback } }每個鍵單獨恢復(fù)發(fā)現(xiàn)損壞時寫回合法默認值并記錄不含用戶內(nèi)容的診斷信息。這樣收藏損壞不會連帶清空考試歷史。十七、舊數(shù)據(jù)遷移要先補齊復(fù)合身份當前記錄已經(jīng)有bankId遷移成本較低。若歷史版本存在缺失bankId的數(shù)據(jù)不能憑questionId隨意猜題庫。遷移策略可以分為輸入情況處理方式bankId、questionId均合法保留并去重缺少bankId題目只在一個題庫出現(xiàn)可安全補齊缺少bankId多個題庫同 ID標記不可解析并跳過題庫或題目已刪除保留快照或顯示失效記錄遷移后按${bankId}:${questionId}去重避免舊重復(fù)記錄在新列表產(chǎn)生重復(fù) key。十八、收藏列表需要單條取消能力當前FavoritePage的收藏卡沒有取消按鈕。用戶只能回到練習頁找到目標題再切換或者在設(shè)置頁清空所有收藏。這對精準管理不夠友好。列表可以提供明確操作private removeFavorite(record: FavoriteRecord): void { const next this.favRecords.filter(item !(item.bankId record.bankId item.questionId record.questionId) ) // 交給 Service 持久化成功后再發(fā)布 }單條刪除的觸摸區(qū)域、無障礙文本和失敗反饋要完整“清空全部”屬于破壞性操作應(yīng)增加確認而不是與普通跳轉(zhuǎn)并列為一次點擊。十九、不要把收藏數(shù)量手工同步到多個字段FavoritePage的數(shù)量直接來自private countFor(index: number): number { if (index 0) return this.favRecords.length if (index 1) return this.noteRecords.length return this.wrongRecords.length }這是正確方向數(shù)量是數(shù)組的派生值不需要額外維護favoriteCount。只要多個頁面都從favoriteRecords.length計算便不會出現(xiàn)列表有 5 條、首頁寫 4 條的人工同步問題。設(shè)置頁為了展示和交互持有displayFavCount并通過syncDataCounts()手動刷新。此類顯示緩存需要明確刷新時機或者繼續(xù)改為從共享數(shù)組派生減少狀態(tài)副本。二十、連續(xù)點擊需要串行化寫入當前同步方法執(zhí)行很快但未來若切換為異步存儲用戶連續(xù)點擊可能產(chǎn)生第一次請求計算“新增”第二次請求仍基于舊數(shù)組計算“新增”兩次寫入亂序完成最終狀態(tài)與最后一次點擊不一致??梢栽诜?wù)層維護單一提交隊列或者在保存期間禁用當前收藏按鈕State favoriteSaving: boolean false private async onFavoriteClick(): Promisevoid { if (this.favoriteSaving) return this.favoriteSaving true try { await this.toggleCurrentFavorite() } finally { this.favoriteSaving false } }禁用態(tài)要有視覺反饋但不能改變按鈕尺寸避免工具欄跳動。二十一、多頁面同步要驗證四個觀察點測試時不要只看收藏列表。至少觀察觀察點預(yù)期練習頁按鈕收藏后立即高亮取消后恢復(fù)收藏 Tab 數(shù)量與數(shù)組長度一致收藏卡片題庫、題干、時間對應(yīng)正確應(yīng)用重啟狀態(tài)與退出前一致再加入跨題庫同questionId的構(gòu)造數(shù)據(jù)。如果 A、B 兩個題庫都含q_001收藏 A 不應(yīng)點亮 B兩條記錄同時存在時列表 key 也必須不同。二十二、精準回跳的驗收不能只看頁面打開打開PracticePage不代表跳轉(zhuǎn)正確。應(yīng)驗證路由同時攜帶bankId和questionId目標題庫加載完成后再定位當前題 ID 與收藏記錄一致找不到目標題時給出兜底提示返回收藏頁后原 Tab 與滾動上下文合理在手機、平板和 2in1 布局中目標操作可達。當前源碼只攜帶bankId random所以這組精準回跳驗收在改造前應(yīng)判為未實現(xiàn)。二十三、清空收藏必須保持內(nèi)存與磁盤一致設(shè)置頁調(diào)用this.favRecords UserDataManager.clearFavorites()管理器把空數(shù)組寫入 Preferences 后返回。它與切換收藏存在同一個問題寫入失敗被吞掉UI 仍會顯示清空成功。清空還應(yīng)經(jīng)過二次確認并在失敗時保持舊列表。若一次操作清空收藏、筆記、錯題、進度和考試歷史最好定義批處理結(jié)果避免前幾項寫成功、后幾項失敗后卻統(tǒng)一顯示成功。二十四、時間字段適合展示不適合作為身份nowStr()只精確到分鐘return ${year}-${month}-${day} ${hour}:${minute}同一分鐘收藏多條記錄會擁有相同時間。這不會影響當前排序因為新增記錄直接放到數(shù)組頭部但createdAt不能承擔唯一鍵也無法表達秒級操作先后。如果后續(xù)需要穩(wěn)定排序或跨設(shè)備合并可保存毫秒時間戳并單獨格式化展示。身份仍然是bankId questionId不要用時間戳替代業(yè)務(wù)主鍵。二十五、推薦的落地順序第一步保持當前 UI 不變把所有收藏判重和列表 key 改為復(fù)合主鍵。第二步把reduceFavorite()抽成純函數(shù)并覆蓋跨題庫同 ID、重復(fù)點擊、取消和排序測試。第三步讓持久化返回明確結(jié)果只有保存成功才發(fā)布到AppStorage失敗顯示提示。第四步給收藏卡增加精準路由參數(shù)練習頁按目標題定位找不到時提供可解釋兜底。第五步拆分啟動解析與遷移單個鍵損壞不影響其他學習數(shù)據(jù)。這個順序先修數(shù)據(jù)正確性再補交互不需要一次重寫所有頁面。二十六、常見故障排查表現(xiàn)象優(yōu)先檢查真實源碼中的關(guān)聯(lián)點收藏 A 卻點亮 B是否只比較questionIdisFavorite()取消 B 刪除了 A刪除條件是否缺少bankIdtoggleFavorite()列表卡片復(fù)用錯亂ForEachkey 是否重復(fù)FavoritePage.FavList()點擊收藏卡不是原題路由是否傳questionIdQuestionCard()重啟后收藏消失Preferences 寫入是否失敗persist()吞異常一條數(shù)據(jù)損壞后多類數(shù)據(jù)為空是否用一個總try解析全部鍵init()清空后重啟又回來清空寫入是否真正成功clearFavorites()題干顯示“題目 xxx”題庫或題目引用是否失效getStem()排查順序應(yīng)是身份契約、狀態(tài)發(fā)布、持久化結(jié)果、路由定位不能先靠延時刷新掩蓋問題。二十七、本地驗證清單準備兩個題庫并人為加入相同questionId然后執(zhí)行收藏 A 題A 高亮、B 不高亮收藏 B 題列表出現(xiàn)兩條且 key 不重復(fù)取消 AB 保留從列表點擊 B進入 B 的目標題返回后收藏 Tab 仍展示 B殺進程并重啟B 仍存在模擬存儲失敗界面不假報成功寫入損壞的筆記 JSON收藏仍能獨立恢復(fù)清空收藏時顯示確認取消確認不改數(shù)據(jù)手機、小窗、平板和 2in1 下卡片、按鈕與底部安全區(qū)正常。這套驗證同時覆蓋 ArkUI 響應(yīng)式狀態(tài)、業(yè)務(wù)主鍵、Preferences 持久化和路由契約。二十八、結(jié)語知律當前已經(jīng)具備一條可復(fù)核的收藏基礎(chǔ)鏈路EntryAbility從 Preferences 恢復(fù)數(shù)據(jù)練習頁與收藏頁通過同一個AppStorage鍵共享數(shù)組toggleFavorite()返回不可變新數(shù)組收藏列表再按題庫與題目引用還原卡片。對于題目 ID 全局唯一且存儲正常的場景會話內(nèi)聯(lián)動與重啟恢復(fù)都具備實現(xiàn)基礎(chǔ)。真正需要補強的是一致性邊界判重、刪除和列表 key 只使用questionId持久化失敗不會反饋啟動時一處解析異常可能讓多類數(shù)據(jù)統(tǒng)一回退收藏卡點擊只進入隨機練習不能精準定位原題。把復(fù)合主鍵、可驗證提交、逐鍵恢復(fù)和顯式路由契約統(tǒng)一起來收藏就不再是一個局部按鈕狀態(tài)而是一條可以測試、遷移和解釋的數(shù)據(jù)鏈路。---本文部分內(nèi)容由 AI 輔助整理。所有現(xiàn)狀判斷均基于D:\huawei\one19-11中com.jiaweikang.one19的本地源碼復(fù)核示例改造代碼用于說明工程方案不代表當前版本已經(jīng)實現(xiàn)獨立法條詳情頁、復(fù)合主鍵遷移、持久化失敗回滾或收藏題精準定位。