发布时间:2026/7/19 11:41:54
答案之书-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页面才不会在功能增加后变成隐形的数据层。

相关新闻

2026/7/19 11:41:54

如何构建稳定可靠的Unity插件框架:BepInEx 6.0架构深度解析

如何构建稳定可靠的Unity插件框架:BepInEx 6.0架构深度解析 【免费下载链接】BepInEx Unity / XNA game patcher and plugin framework 项目地址: https://gitcode.com/GitHub_Trending/be/BepInEx 在Unity游戏模组开发领域,BepInEx作为一款功能强…

2026/7/19 11:41:54

答案之书-01-启动白屏先别改页面:把题库播种放进 WindowStage 之前

答案之书 序列1:启动白屏先别改页面:把题库播种放进 WindowStage 之前 这类问题在轻量工具类应用里很常见:页面看起来只有几个按钮,但一旦把离线题库、动画、收藏、导入和发布状态串起来,真正容易出错的不是某个组件&a…

2026/7/19 11:36:54

宝马发动机烧机油问题分级维修方案详解

1. 项目概述:宝马烧机油问题的行业现状 在高端汽车维修领域,宝马发动机烧机油问题一直是困扰车主和维修技师的"老大难"。特别是2010-2018年间生产的N20、N55等系列发动机,普遍存在每1000公里消耗0.5-1升机油的情况。这个问题在温州…

2026/7/19 18:12:29

pgagroal:高性能PostgreSQL连接池的终极指南

pgagroal:高性能PostgreSQL连接池的终极指南 【免费下载链接】pgagroal High-performance connection pool for PostgreSQL 项目地址: https://gitcode.com/gh_mirrors/pg/pgagroal PostgreSQL作为世界上最先进的开源关系数据库,在高并发场景下需…

2026/7/19 18:12:29

H3C交换机SNMP网管识别配置实例

这是“H3C交换机中小企业运维实战”专栏的第 8 篇。交换机完成 SSH 远程管理后,下一步通常是接入公司的网管系统,让平台读取设备名称、型号、运行时间和端口状态。 本文以 H3C S5570S 和 Comware V7 为例,配置只读 SNMPv3 用户、认证与加密、…

2026/7/19 18:12:29

AI办公提效专栏目录

目录专栏定位内容板块说明WorkBuddy —— 让 AI 触达你的数据源设计向 —— 用 AI 设计图片技能向 —— 文档转换与视频制作Word 提效案例 —— 一句话搞定格式与排版PDF 提效案例 —— 批量处理 PDF 文件Excel 提效案例(已更新 99 篇)适用人群专栏特色一…

2026/7/19 18:12:29

DeepSeek LeetCode 3605. 数组的最小稳定性因子 Rust实现

这个问题的核心是二分答案 贪心验证,其中预处理所有长度为 mid1 的稳定子数组是关键。这里提供一个结合了线段树进行区间 GCD 查询和贪心区间覆盖的 Rust 实现,思路清晰且易于理解。rust use std::cmp::max;impl Solution {pub fn min_stable(nums: Vec…

2026/7/19 0:00:15

Unity与Python本地通信:基于Flask的跨语言数据交换实战

1. 项目概述:为什么我们需要一个本地通信服务器?在游戏开发、数字孪生、仿真训练等众多领域,Unity作为强大的实时3D内容创作平台,其核心逻辑通常由C#驱动。然而,当我们需要进行复杂的数据分析、机器学习推理、科学计算…

2026/7/19 0:00:15

Unity与Python本地通信:基于Flask的跨语言数据交换实战

1. 项目概述:为什么我们需要一个本地通信服务器?在游戏开发、数字孪生、仿真训练等众多领域,Unity作为强大的实时3D内容创作平台,其核心逻辑通常由C#驱动。然而,当我们需要进行复杂的数据分析、机器学习推理、科学计算…

2026/7/19 16:59:11

3个高效策略:快速掌握Axure中文界面配置

3个高效策略:快速掌握Axure中文界面配置 【免费下载链接】axure-cn Chinese language file for Axure RP. Axure RP 简体中文语言包。支持 Axure 11、10、9。不定期更新。 项目地址: https://gitcode.com/gh_mirrors/ax/axure-cn 还在为Axure RP的英文界面感…