答案之书 序列1启动白屏先别改页面把题库播种放进 WindowStage 之前这类问题在轻量工具类应用里很常见页面看起来只有几个按钮但一旦把离线题库、动画、收藏、导入和发布状态串起来真正容易出错的不是某个组件而是数据从哪里来、什么时候可读、谁负责写入、页面靠什么刷新。The_Book_of_Answers这个项目的价值在于它没有把答案之书写成一次性的页面 Demo而是把主入口、HAR 公共层、HSP 功能层、Preferences 持久化、Navigation 路由和资源归属拆开。下面的写法都来自这个项目的真实工程链路读者可以把它当成一个小型 HarmonyOS 离线应用的拆解样本。这篇文章解决四件事把项目里的真实代码链路拆成可复用的工程方法。说明页面、服务、仓库、资源或发布配置之间的责任边界。给出可以迁移到其他 HarmonyOS 项目的 ArkTS / JSON5 写法。留下验证路径和排障表避免只看源码不看运行结果。1. 问题从哪里冒出来首页依赖当前题库题库选择器也要在页面出现时读取列表。如果loadContent先执行页面可能先拿到空索引后续即使种子数据写进 Preferences也不一定能触发所有页面重算。这个项目把首屏稳定性放到EntryAbility.onWindowStageCreate里处理先让仓库和默认题库就绪再挂载pages/Index。asynconWindowStageCreate(windowStage:window.WindowStage):Promisevoid{constctxthis.context;try{awaitPreferencesStore.init(ctx);awaitSeedLoader.run(ctx);}catch(err){hilog.error(DOMAIN,testTag,bootstrap failed: %{public}s,(errasError).message);}windowStage.loadContent(pages/Index,(err){if(err.code){hilog.error(DOMAIN,testTag,Failed to load the content. Cause: %{public}s,JSON.stringify(err));return;}});}这段代码背后的工程意图是把职责停在正确层级页面只处理交互和展示服务层处理业务规则仓库层处理持久化细节配置文件说明运行时归属。只要这个边界不乱后续增加页面、换资源或做发布自查时就不会牵一发动全身。2. 仓库初始化要硬失败项目没有让未初始化的 Preferences 静默返回空值。静默回退最危险因为上层会误以为“用户没有题库”再触发错误的空状态或重复播种。更稳的做法是启动期必须打开 store读取期如果仓库没准备好就直接抛错让问题停在启动链路。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;}这段代码背后的工程意图是把职责停在正确层级页面只处理交互和展示服务层处理业务规则仓库层处理持久化细节配置文件说明运行时归属。只要这个边界不乱后续增加页面、换资源或做发布自查时就不会牵一发动全身。3. 默认题库不是只在首启写一次离线应用经常会升级内置内容。如果只判断deck_index是否为空老用户永远拿不到新的默认答案如果每次都覆盖又会破坏用户修改过的题库信息。项目用LastSeededVersion、默认题库缺失、默认题库损坏三个条件做分支既能升级种子又能保留用户对默认题库的名称和颜色调整。constids:string[]awaitDeckRepository.loadIndex();constdefaultDeck:Deck|nullawaitDeckRepository.loadDeck(DEFAULT_DECK_ID);constseededVersion:numberawaitPreferencesStore.getNumber(PrefStoreName.App,AppPrefKey.LastSeededVersion,0);constneedsSeed:booleanids.length0||defaultDecknull||(defaultDeck.answers.lengthSEED_RESTORE_THRESHOLD)||seededVersionDEFAULT_DECK_SEED_VERSION;这段代码背后的工程意图是把职责停在正确层级页面只处理交互和展示服务层处理业务规则仓库层处理持久化细节配置文件说明运行时归属。只要这个边界不乱后续增加页面、换资源或做发布自查时就不会牵一发动全身。4. rawfile 到对象要先清洗默认题库来自entry/src/main/resources/rawfile/seed/default_deck.json。读取后不要直接保存至少要做 trim、去空、补 id 和时间戳。这样后续随机抽取、收藏来源和编辑页 key 都能依赖稳定字段而不是靠文本本身充当身份。constraw:Uint8Arrayawaitcontext.resourceManager.getRawFileContent(SEED_PATH);constdecoder:util.TextDecoderutil.TextDecoder.create(utf-8);constseed:SeedDeckJsonJSON.parse(decoder.decodeToString(raw))asSeedDeckJson;constnow:numberDate.now();constanswers:Answer[](seed.answers??[]).map((t:string):stringt.trim()).filter((t:string):booleant.length0).map((t:string):Answer({id:newId(a),text:t,createdAt:now}));这段代码背后的工程意图是把职责停在正确层级页面只处理交互和展示服务层处理业务规则仓库层处理持久化细节配置文件说明运行时归属。只要这个边界不乱后续增加页面、换资源或做发布自查时就不会牵一发动全身。5. 写入后必须通知页面Preferences 负责持久化AppStorage负责运行期联动。播种成功后只写 Preferences首页的StorageLink(currentDeckId)不会自动知道当前题库只写currentDeckId题库列表页也不一定刷新。因此项目同时写当前题库和更新时间戳。awaitPreferencesStore.setString(PrefStoreName.App,AppPrefKey.CurrentDeckId,deck.id);AppStorage.setOrCreate(AppStorageKey.CurrentDeckId,deck.id);AppStorage.setOrCreate(AppStorageKey.LastDeckUpdateAt,deck.updatedAt);这段代码背后的工程意图是把职责停在正确层级页面只处理交互和展示服务层处理业务规则仓库层处理持久化细节配置文件说明运行时归属。只要这个边界不乱后续增加页面、换资源或做发布自查时就不会牵一发动全身。6. 首页只相信运行期状态首页没有自己去读 Preferences它只订阅当前题库 id。这样页面职责非常轻只管用户输入、跳转和展示持久化是否可用、默认题库是否存在都由启动链路兜住。这个边界让首页代码更容易排查。StorageLink(currentDeckId)currentDeckId:stringDEFAULT_DECK_ID;privateonSubmit():void{if(!this.currentDeckId){promptAction.showToast({message:题库尚未就绪请稍候});return;}this.pathStack.pushPath({name:RouteName.Drawing,param:{deckId:this.currentDeckId,question:this.question.trim()||undefined}});}这段代码背后的工程意图是把职责停在正确层级页面只处理交互和展示服务层处理业务规则仓库层处理持久化细节配置文件说明运行时归属。只要这个边界不乱后续增加页面、换资源或做发布自查时就不会牵一发动全身。7. 日志要能说明启动分支启动问题最怕只看到“加载失败”。建议把跳过、首启、缺失、自愈、版本升级分支都打出来。这个项目已经在SeedLoader中区分了 skip、seeded、rewrote 三类日志真机排查时可以直接看是哪条分支在工作。SeedLoader: skip: decks3 defaultAnswers226 seedV2 SeedLoader: seeded default deck count226 v2 SeedLoader: rewrote default deck (version-upgrade) count226 oldV1 newV2这段代码背后的工程意图是把职责停在正确层级页面只处理交互和展示服务层处理业务规则仓库层处理持久化细节配置文件说明运行时归属。只要这个边界不乱后续增加页面、换资源或做发布自查时就不会牵一发动全身。8. 验证路径启动链路的验证不是只看首页是否出现而是按数据、状态、页面三个层级走一遍。先清应用数据启动确认默认题库写入再保留用户自建题库升级种子版本确认只改默认题库最后进入题库列表和抽取页确认StorageLink驱动的页面都刷新。Write-HostDebug 构建D:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.batassembleHap--no-daemonWrite-Host观察启动日志时重点过滤hdc shell hilog|Select-StringSeedLoader|PreferencesStore|EntryAbility这段代码背后的工程意图是把职责停在正确层级页面只处理交互和展示服务层处理业务规则仓库层处理持久化细节配置文件说明运行时归属。只要这个边界不乱后续增加页面、换资源或做发布自查时就不会牵一发动全身。9. 常见问题与处理现象先看哪里处理首页提示题库尚未就绪PreferencesStore.init是否 await把loadContent放到初始化之后默认题库被重复覆盖LastSeededVersion是否写入只在版本变化、缺失或损坏时重写题库列表不刷新LastDeckUpdateAt是否变化写入后同步更新 AppStorage老用户题库丢失是否覆盖了所有 index播种只处理默认题库不清空用户题库privateasyncensureCurrentDeck():Promisevoid{constcur:stringawaitPreferencesStore.getString(PrefStoreName.App,AppPrefKey.CurrentDeckId,);constfallback:stringcur||(awaitDeckRepository.loadIndex())[0]||DEFAULT_DECK_ID;awaitPreferencesStore.setString(PrefStoreName.App,AppPrefKey.CurrentDeckId,fallback);AppStorage.setOrCreate(AppStorageKey.CurrentDeckId,fallback);AppStorage.setOrCreate(AppStorageKey.LastDeckUpdateAt,Date.now());}这段代码背后的工程意图是把职责停在正确层级页面只处理交互和展示服务层处理业务规则仓库层处理持久化细节配置文件说明运行时归属。只要这个边界不乱后续增加页面、换资源或做发布自查时就不会牵一发动全身。验证清单清应用数据后从冷启动走一遍确认默认数据、页面状态和日志分支符合预期。对每个写路径准备正常、空值、重复、越界四类输入确认错误停在 service 层。页面返回、重新进入、切换题库、收藏或删除后确认列表通过更新时间戳重新加载。修改资源或模块归属后重新构建确认 HAP、HAR、HSP 的依赖方向没有反转。涉及发布的内容必须用真机或构建产物确认不能只凭源码判断。小结The_Book_of_Answers的序列 1 不是在讲一个孤立 API而是在讲一个小型离线 HarmonyOS 应用怎样把具体问题做成可维护链路。把入口、状态、持久化、页面和发布清单分开后项目的复杂度不会消失但每个问题都有了明确入口。AR、HSP 的依赖方向没有反转。涉及发布的内容必须用真机或构建产物确认不能只凭源码判断。小结The_Book_of_Answers的序列 1 不是在讲一个孤立 API而是在讲一个小型离线 HarmonyOS 应用怎样把具体问题做成可维护链路。把入口、状态、持久化、页面和发布清单分开后项目的复杂度不会消失但每个问题都有了明确入口。