答案之书-02-离线应用别让页面直接读 Preferences:封一层仓库再写业务
答案之书 序列2离线应用别让页面直接读 Preferences封一层仓库再写业务在答案之书这种离线小工具里最容易被低估的不是页面布局而是本地数据边界。页面直接读 Preferences 的时候第一版确实写得快点按钮、拿字符串、JSON.parse、再刷新列表。但题库、收藏、历史、当前题库和默认种子版本都进来以后页面会同时背上 key 命名、序列化、异常兜底和刷新通知。最后问题会变成某个页面能跑整个应用的数据链路不可信。这篇文章会把问题拆成四个可落地的点把 Preferences 收进统一 Store页面不直接碰持久化细节。用 Repository 管 key 和序列化用 Service 管业务规则。写清楚 JSON 损坏、索引悬挂、删除当前题库这类真实故障的处理边界。给出能迁移到本地离线 HarmonyOS 应用的数据分层方法。1. 页面直接读写的坏处不是代码长实际项目里页面直接调用 Preferences 最先暴露的问题通常不是崩溃而是每个页面都以为自己知道数据格式。首页保存当前题库题库页保存列表收藏页保存来源历史页又单独维护时间线一旦 key 改名或 JSON 结构升级排查点会散在多个 ETS 文件里。答案之书把底层入口收进PreferencesStore让 UI 只面对领域方法。classPreferencesStoreImpl{privatereadonlystores:Mapstring,preferences.PreferencesnewMap();privatecontext:common.UIAbilityContext|nullnull;privateinited:booleanfalse;asyncinit(context:common.UIAbilityContext):Promisevoid{if(this.inited){return;}this.contextcontext;awaitthis.openStore(PrefStoreName.App);awaitthis.openStore(PrefStoreName.Deck);awaitthis.openStore(PrefStoreName.Favorite);this.initedtrue;}}这段代码只负责一件事把持久化 store 的生命周期集中起来。页面不需要知道preferences.getPreferences什么时候打开也不需要在每个入口重复判断是否初始化。2. 先按数据域拆 storeName答案之书没有把所有内容塞进一个 Preferences 文件。当前题库、种子版本属于应用域题库索引和题库实体属于题库域收藏列表属于收藏域。这样拆的价值在发布后才明显用户反馈收藏丢失时不需要怀疑题库实体要做备份或清理时也能按域给出明确策略。exportclassPrefStoreName{staticreadonlyApp:stringpref_app;staticreadonlyDeck:stringpref_deck;staticreadonlyFavorite:stringpref_favorite;}exportclassAppPrefKey{staticreadonlyCurrentDeckId:stringcurrent_deck_id;staticreadonlyLastSeededVersion:stringlast_seeded_version;staticreadonlySchemaVersion:stringschema_version;}这里的拆分不是为了显得分层而是为了让数据归属可以被追踪。一个 key 属于哪个域后续的迁移、清理和排障就从哪个域开始。3. Repository 管 key不替业务做决定Repository 的责任是稳定读写不是判断业务能不能发生。比如题库仓库知道deck_index和deck:id的关系但它不应该决定默认题库能不能删除也不应该决定答案最少几条。这些规则放到 Service仓库保持薄而可靠。classDeckPrefKey{staticreadonlyIndex:stringdeck_index;staticdeckEntry(id:string):string{returndeck:${id};}}asyncsaveDeck(deck:Deck):Promisevoid{awaitPreferencesStore.setJsonDeck(PrefStoreName.Deck,DeckPrefKey.deckEntry(deck.id),deck);constids:string[]awaitthis.loadIndex();if(!ids.includes(deck.id)){awaitthis.saveIndex(ids.concat(deck.id));}}这段写法保证了实体和索引的关系不散落到页面。页面只触发保存Service 决定能不能保存Repository 保证保存后索引仍能找到实体。4. JSON 解析失败要回退也要留下线索本地 Preferences 最常见的脏数据来自开发期结构变更、导入覆盖和手工调试。解析失败时直接崩溃会伤用户但静默返回空值也危险因为页面会误判成“用户没有数据”。更稳的做法是返回 fallback同时把 store 和 key 打进日志。asyncgetJsonT(storeName:string,key:string,fallback:T):PromiseT{constraw:stringawaitthis.getString(storeName,key,);if(!raw){returnfallback;}try{returnJSON.parse(raw)asT;}catch(err){hilog.warn(DOMAIN,TAG,parse json failed, store%{public}s key%{public}s,storeName,key);returnfallback;}}fallback 是给运行期兜底日志是给排查留证。两者缺一都会把问题推到页面层。5. Service 才能决定什么能写进去答案之书的题库保存不是简单落盘。题库名要 trim空答案要过滤答案长度要限制默认颜色要补齐已有题库的创建时间和内置标记要保留。这些规则如果写在页面里导入页、新建页、编辑页迟早会不一致。asyncsave(payload:SaveDeckPayload):PromiseDeck{consttrimmedName:stringpayload.name.trim();if(!trimmedName){thrownewError(题库名称不能为空);}constcleaned:string[]payload.answers.map((item:string):stringitem.trim()).filter((item:string):booleanitem.length0item.lengthMAX_ANSWER_LEN);if(cleaned.lengthMIN_ANSWERS_PER_DECK){thrownewError(题库至少需要${MIN_ANSWERS_PER_DECK}条答案);}returnthis.persistDeck(payload.id,trimmedName,cleaned);}Service 接收页面 payload但不信任 payload。它统一做清洗和校验Repository 只保存已经成形的领域对象。6. 运行期刷新不要指望 Preferences 自动通知Preferences 是持久层不是页面状态总线。题库保存成功后列表页和首页需要知道“题库数据发生了变化”。答案之书用AppStorageKey.LastDeckUpdateAt做轻量刷新信号页面通过StorageLink订阅再重新从 Service 读取最新数据。awaitDeckRepository.saveDeck(deck);AppStorage.setOrCreate(AppStorageKey.LastDeckUpdateAt,deck.updatedAt);StorageLink(lastDeckUpdateAt)Watch(onDeckUpdate)lastDeckUpdateAt:number0;privateonDeckUpdate():void{this.reloadDecks().catch((err:Error){hilog.warn(DOMAIN,TAG,reload deck list failed: %{public}s,err.message);});}这里的关键点是 AppStorage 只放刷新信号不放完整题库。完整数据仍由 Service 从持久层读取避免页面之间复制多个版本。7. 删除当前题库时要同时处理回退很多离线应用的数据问题发生在删除路径实体删了索引没删索引删了当前选中 id 还指向旧实体页面刷新后还拿旧 id 去抽答案。答案之书把删除题库后的回退放在 Service确保当前题库始终可用。asyncremoveDeck(id:string):Promisevoid{constdeck:Deck|nullawaitDeckRepository.loadDeck(id);if(deck?.builtIn){thrownewError(默认题库不能删除);}awaitDeckRepository.removeDeck(id);constcurrentId:stringawaitAppRepository.loadCurrentDeckId();if(currentIdid){constids:string[]awaitDeckRepository.loadIndex();constfallback:stringids[0]??DEFAULT_DECK_ID;awaitAppRepository.saveCurrentDeckId(fallback);AppStorage.setOrCreate(AppStorageKey.CurrentDeckId,fallback);}AppStorage.setOrCreate(AppStorageKey.LastDeckUpdateAt,Date.now());}删除不是单点操作而是实体、索引、当前选择和刷新信号的组合事务。即使底层不是数据库也要按事务思路收口。8. 迁移策略放启动期不放页面里当题库结构升级时不要让每个页面自己判断版本。更稳的做法是在启动链路读取 schemaVersion完成迁移后再挂载页面。页面永远消费当前结构旧数据兼容问题停在启动和仓储层。asyncfunctionmigrateIfNeeded():Promisevoid{constversion:numberawaitPreferencesStore.getNumber(PrefStoreName.App,AppPrefKey.SchemaVersion,0);if(version2){awaitDeckMigration.fillMissingAnswerIds();}awaitPreferencesStore.setNumber(PrefStoreName.App,AppPrefKey.SchemaVersion,2);}迁移逻辑越靠近页面越难保证所有入口一致。启动期迁移能让错误更早暴露也让页面代码保持稳定。9. 验证与排障这层的验证要刻意制造坏输入而不是只走正常新建流程。建议准备空题库名、过长答案、破坏 JSON、删除当前题库、重复保存五类场景。目标是确认错误停在 Service 或 Repository页面拿到的是可展示的结果。用例顺序 1. 清应用数据确认默认题库写入 pref_deck 2. 新建题库后重启确认 deck_index 和 deck:id 都存在 3. 手工破坏 deck:id JSON列表不崩溃日志出现 parse json failed 4. 删除当前题库currentDeckId 回退到可用题库 5. 保存后返回首页抽取页使用最新题库不要只看页面 Toast。真正要看的是写入路径、持久化结果、刷新信号和页面重读是否在同一个闭环里。验证清单清应用数据后从冷启动进入确认默认数据、页面状态和日志分支符合预期。对本文涉及的写路径准备正常、空值、重复、越界四类输入确认错误停在 Service 或 Repository。页面返回、重新进入、切换题库、收藏、历史或删除后确认对应刷新信号触发重新读取。修改资源或模块归属后重新构建确认 HAP、HAR、HSP 的依赖方向没有反转。涉及真机体验、备份恢复、发布素材的内容单独记录是否已经在设备或平台侧验证。常见问题与处理现象先看哪里处理方式保存后重启数据没了PreferencesStore.flush或封装写入是否 await统一通过 Store 写入页面不直接写 Preferences列表显示旧题库名LastDeckUpdateAt是否变化保存成功后同步刷新信号删除后抽取页报空currentDeckId是否仍指向被删 id删除当前题库后回退到默认或第一条可用题库偶发 JSON 解析错误日志里的 store/key保留 fallback同时补迁移或清理脏数据小结离线应用可以没有后端但不能没有数据边界。把 Preferences 收进 Store把 key 交给 Repository把规则交给 Service页面才不会在功能增加后变成隐形的数据层。