HarmonyOS应用实战-启示散页-50-发布截图别暴露用户题库:准备一套可复现的演示数据
HarmonyOS 应用实战 50:发布截图别暴露用户题库,把截图环境做成可回滚数据沙箱
发布截图最怕一种“看起来很干净”的做法:开发者先把自己的题库改名,删掉几条提问历史,再对结果页做几次手工打码,然后拿这些图去交材料。单张图里可能只露出一个题库名,连着首页、抽取页、结果页、收藏页一起看,就能还原出用户最近问过什么、收藏过哪条答案、从哪个题库抽到过结果。
答案之书是离线应用,没有账号体系,也没有服务端同步,但这并不代表发布材料没有隐私边界。它的题库、收藏、提问历史都在本地 Preferences,页面展示时又会把答案正文、题库来源和最近问题带出来。第 50 篇要解决的不是“怎么打码”,而是把发布截图前的本地数据切换做成可回滚的数据沙箱:进入前备份真实数据,截图时只展示演示数据,退出后恢复原状,并留下可复查的截图清单。

这篇文章解决四件事:
- 从现有源码里找出发布截图会暴露哪些用户内容。
- 说明为什么不能把截图准备逻辑塞进
SeedLoader或页面回调。 - 用现有 Repository 思路设计进入、安装、恢复三段流程。
- 给出发布前复核表,确认截图、日志和恢复路径都能解释清楚。


先把泄露链路从源码里找出来
第一个判断标准很简单:页面上能出现的文字,如果来自真实用户输入或真实本地题库,就不能直接进入发布截图。答案之书当前已经有清晰的数据边界,问题不在“找不到 owner”,而在发布前没有把这些 owner 组合成一条临时数据沙箱链路。
| 画面位置 | 真实来源 | 风险 |
|---|---|---|
| 首页题库卡片 | DeckService.list() 返回 DeckSummary.name、count |
真实题库名和规模暴露 |
| 抽取页参数 | 路由里的 question、deckId |
最近提问和当前题库暴露 |
| 结果页答案 | AnswerPage.param.text |
答案正文直接入图 |
| 结果页来源 | DeckService.get(this.param.deckId) |
题库来源暴露 |
| 收藏页 | FavoriteService.list() |
收藏答案和来源题库暴露 |
| 历史弹层 | QuestionHistoryService.list() |
用户问题原文暴露 |
这张表比“发布前清空数据”更重要。清空只解决当次截图,源码 owner 表能告诉我们每个页面为什么会出现真实内容,以及应该从哪一层替换为演示数据。
结果页已经把答案和题库名连在一起
结果页是最典型的泄露点。它不仅展示答案正文,还会异步读取题库名;用户点击收藏时,又把答案和来源题库一起写进收藏。
@Component
export struct AnswerPage {
@State private favorited: boolean = false;
@State private deckName: string = '';
private param: AnswerParams = { deckId: '', answerId: '', text: '' };
private async loadDeckName(): Promise<void> {
const deck = await DeckService.get(this.param.deckId);
this.deckName = deck?.name ?? '';
}
private onToggleFavorite(): void {
FavoriteService.add({
text: this.param.text,
fromDeckId: this.param.deckId,
fromDeckName: this.deckName || undefined
});
}
}
这段代码本身没有错,它是正常业务需要:结果页应该知道答案来自哪个题库,收藏也应该保留来源。但发布截图不能直接复用真实运行态。只要 this.param.text 来自真实抽取结果,this.deckName 来自真实题库名,截图就不是演示材料,而是用户内容的再展示。
更稳的处理方式不是在 AnswerPage 里加一堆发布分支,而是在进入截图环境前,把页面会读取到的数据整体替换成受控演示数据。页面继续走原来的业务链路,截图环境负责保证这条链路读到的是演示内容。
历史和收藏必须一起处理
很多发布图只检查首页和结果页,忽略了底部弹层或二级页面。答案之书的提问历史和收藏都是真实 store,一旦只替换题库,收藏页和历史弹层仍会露出用户内容。
class QuestionHistoryServiceImpl {
async add(text: string): Promise<void> {
const trimmed: string = text.trim();
if (!trimmed) {
return;
}
const all: string[] = await QuestionHistoryRepository.loadAll();
const filtered: string[] = all.filter((q: string): boolean => q !== trimmed);
filtered.unshift(trimmed);
const next = filtered.length > MAX_QUESTION_HISTORY
? filtered.slice(0, MAX_QUESTION_HISTORY)
: filtered;
await QuestionHistoryRepository.saveAll(next);
AppStorage.setOrCreate(AppStorageKey.LastQuestionHistoryUpdateAt, Date.now());
}
}
class FavoriteServiceImpl {
async add(payload: AddFavoritePayload): Promise<Favorite> {
const all: Favorite[] = await FavoriteRepository.loadAll();
const fav: Favorite = {
id: newId('fav'),
text: payload.text.trim(),
fromDeckId: payload.fromDeckId,
fromDeckName: payload.fromDeckName,
createdAt: Date.now()
};
all.push(fav);
await FavoriteRepository.saveAll(all);
AppStorage.setOrCreate(AppStorageKey.LastFavoriteUpdateAt, fav.createdAt);
return fav;
}
}
这两段源码说明了一个交付边界:发布截图数据沙箱必须同时覆盖题库、收藏、提问历史和当前题库 id。只处理其中一项,会制造“首页是演示数据,收藏页是真实数据”的半干净状态。
SeedLoader 不是发布截图入口
当前项目已经有 SeedLoader,但它的目标是首次启动、默认题库缺失、默认题库损坏或种子版本升级时恢复默认题库。它不应该被改成发布截图入口。
class SeedLoaderImpl {
async run(context: common.UIAbilityContext): Promise<void> {
const ids: string[] = await DeckRepository.loadIndex();
const defaultDeck: Deck | null = await DeckRepository.loadDeck(DEFAULT_DECK_ID);
const seededVersion: number = await PreferencesStore.getNumber(
PrefStoreName.App,
AppPrefKey.LastSeededVersion,
0
);
const needsSeed: boolean = ids.length === 0
|| defaultDeck === null
|| defaultDeck.answers.length < SEED_RESTORE_THRESHOLD
|| seededVersion < DEFAULT_DECK_SEED_VERSION;
if (!needsSeed) {
await this.ensureCurrentDeck();
return;
}
const deck: Deck = await this.buildDefaultDeck(context);
await DeckService.putRaw(deck);
AppStorage.setOrCreate(AppStorageKey.LastDeckUpdateAt, deck.updatedAt);
}
}
这里的 owner 是“默认题库自愈”。发布截图需要的是“真实数据备份、演示数据安装、截图完成恢复、证据留痕”。如果把两者混在一起,冷启动时就很难解释当前题库为什么被替换,也很难保证退出截图环境后真实收藏和历史已经恢复。
演示场景要写成稳定合同
演示数据不应该散落在页面里。它应该像一份发布素材合同:本次截图使用哪个题库、哪个问题、哪些收藏、覆盖哪些页面。这样补图时才能复用同一套输入。
interface ReleaseScreenshotScenario {
scenarioId: string;
seedVersion: number;
currentDeckId: string;
decks: Deck[];
favorites: Favorite[];
questionHistory: string[];
screenshotPages: Array<'home' | 'drawing' | 'answer' | 'favorites' | 'settings'>;
}
const releaseScenarioV1: ReleaseScreenshotScenario = {
scenarioId: 'release-screenshot-v1',
seedVersion: 1,
currentDeckId: 'release-deck-daily',
decks: [{
id: 'release-deck-daily',
name: '发布演示题库',
builtIn: false,
colorKey: 'blue',
answers: [
{ id: 'release-answer-1', text: '先把问题写清楚,再决定下一步。', createdAt: 1760000000000 },
{ id: 'release-answer-2', text: '今天适合做一次小范围验证。', createdAt: 1760000000000 }
],
createdAt: 1760000000000,
updatedAt: 1760000000000
}],
favorites: [],
questionHistory: ['今天要不要继续推进?'],
screenshotPages: ['home', 'drawing', 'answer', 'favorites', 'settings']
};
这里刻意使用固定 id 和固定时间。发布截图追求的是可复现,不是模拟真实随机行为。随机答案、随机颜色、随机时间都会让补图和复查变得困难。
备份用现有 Repository 粒度,不要假装有整库导出
当前 PreferencesStore 提供的是 getString、setString、getJson、setJson、remove 这些基础能力,并没有一键导出或导入整个 store 的接口。因此文章里的落地方案不应该依赖当前项目不存在的整库能力。
更贴近现有工程的做法,是按业务 Repository 粒度备份:题库全量、收藏全量、提问历史全量、当前题库 id。
interface ReleaseUserSnapshot {
decks: Deck[];
favorites: Favorite[];
questionHistory: string[];
currentDeckId: string;
createdAt: number;
}
class ReleaseSnapshotStore {
async createSnapshot(): Promise<ReleaseUserSnapshot> {
return {
decks: await DeckRepository.loadAll(),
favorites: await FavoriteRepository.loadAll(),
questionHistory: await QuestionHistoryRepository.loadAll(),
currentDeckId: await DeckService.getCurrentId(),
createdAt: Date.now()
};
}
}
这个粒度更利于排障。恢复失败时,可以分别判断是题库没有恢复、收藏没有恢复、历史没有恢复,还是 currentDeckId 没有写回。整库黑盒导出看似简单,出了问题反而难定位。
题库恢复还要多一步,因为当前 DeckRepository 只有 loadAll()、saveDeck()、removeDeck() 和 saveIndex(),没有一键替换全量题库的方法。发布截图能力可以在自己的边界里补一个很薄的批量写入器,内部仍然走现有 Repository。
class ReleaseDeckSnapshotWriter {
async replaceAll(decks: Deck[]): Promise<void> {
const oldIds: string[] = await DeckRepository.loadIndex();
for (const id of oldIds) {
await DeckRepository.removeDeck(id);
}
for (const deck of decks) {
await DeckRepository.saveDeck(deck);
}
await DeckRepository.saveIndex(decks.map((deck: Deck): string => deck.id));
}
}
这段代码不是为了绕过 Repository,而是把“发布截图要临时替换题库全集”这个特殊动作收在发布准备层。页面和普通业务 Service 不需要知道它存在。
安装演示数据时要刷新所有相关 live state
演示数据安装不是把 JSON 写进去就结束。现有页面依赖 AppStorageKey.LastDeckUpdateAt、LastFavoriteUpdateAt、LastQuestionHistoryUpdateAt 这类时间戳触发刷新;如果只写 Repository,不发刷新信号,页面可能仍显示旧数据。
class ReleaseScreenshotInstaller {
async installScenario(scenario: ReleaseScreenshotScenario): Promise<void> {
await new ReleaseDeckSnapshotWriter().replaceAll(scenario.decks);
await FavoriteRepository.saveAll(scenario.favorites);
await QuestionHistoryRepository.saveAll(scenario.questionHistory);
await PreferencesStore.setString(
PrefStoreName.App,
AppPrefKey.CurrentDeckId,
scenario.currentDeckId
);
const now = Date.now();
AppStorage.setOrCreate(AppStorageKey.CurrentDeckId, scenario.currentDeckId);
AppStorage.setOrCreate(AppStorageKey.LastDeckUpdateAt, now);
AppStorage.setOrCreate(AppStorageKey.LastFavoriteUpdateAt, now);
AppStorage.setOrCreate(AppStorageKey.LastQuestionHistoryUpdateAt, now);
}
}
这一步要特别注意 deck_index 和 deck:<id> 的一致性。只写单个题库 JSON、不更新索引,首页列表会读不到;只更新索引、不清旧题库,退出截图环境后又可能残留演示数据。
退出时先恢复,再宣布截图环境结束
恢复顺序也要明确。先恢复真实题库、收藏和历史,再刷新 AppStorage;最后记录本次截图场景已退出。顺序反了,页面可能短暂读到空数据或演示数据。
class ReleaseScreenshotSession {
private snapshot: ReleaseUserSnapshot | null = null;
async enter(scenario: ReleaseScreenshotScenario): Promise<void> {
this.snapshot = await new ReleaseSnapshotStore().createSnapshot();
await new ReleaseScreenshotInstaller().installScenario(scenario);
await ReleaseChecklistRepository.markEntered({
scenarioId: scenario.scenarioId,
seedVersion: scenario.seedVersion,
pages: scenario.screenshotPages
});
}
async exit(): Promise<void> {
if (!this.snapshot) {
throw new Error('release screenshot snapshot missing');
}
await new ReleaseDeckSnapshotWriter().replaceAll(this.snapshot.decks);
await FavoriteRepository.saveAll(this.snapshot.favorites);
await QuestionHistoryRepository.saveAll(this.snapshot.questionHistory);
await PreferencesStore.setString(
PrefStoreName.App,
AppPrefKey.CurrentDeckId,
this.snapshot.currentDeckId
);
const now = Date.now();
AppStorage.setOrCreate(AppStorageKey.CurrentDeckId, this.snapshot.currentDeckId);
AppStorage.setOrCreate(AppStorageKey.LastDeckUpdateAt, now);
AppStorage.setOrCreate(AppStorageKey.LastFavoriteUpdateAt, now);
AppStorage.setOrCreate(AppStorageKey.LastQuestionHistoryUpdateAt, now);
}
}
这段设计的核心是“页面不感知发布截图环境”。页面仍然走原来的 DeckService、FavoriteService、QuestionHistoryService,只是进入截图环境后,这些 Service 读到的是演示数据。这样可以减少页面分支,也更容易在发布后移除或关闭这段能力。
日志复查不能只看有没有报错
发布截图常常会配合日志排障。当前项目里的日志大多记录 id、count、错误信息,例如结果页记录 show answer id,收藏服务记录总数。这种方向是对的:发布材料不需要打印答案全文。
// 结果页只记录 answerId,避免把答案正文写入 hilog。
hilog.info(DOMAIN, TAG, 'show answer id=%{public}s', p.answerId);
// 收藏服务只记录数量,不记录收藏文本。
hilog.info(DOMAIN, TAG, 'add favorite total=%{public}d', all.length);
// 提问历史只记录数量,不记录问题原文。
hilog.info(DOMAIN, TAG, 'add total=%{public}d', next.length);
发布前复查时要重点找反例:question、answerText、deckName 是否被直接写进日志;分享、复制、收藏失败时是否把业务正文拼进错误信息。日志能帮助排障,但不能成为第二条泄露渠道。
发布前复核要覆盖五条路径
发布截图完成前,建议按五条路径复核。每条路径都对应一个真实 owner,失败后能直接定位,而不是只说“页面看起来不对”。
| 路径 | 操作 | 通过标准 | 失败时先看 |
|---|---|---|---|
| 进入前备份 | 进入截图环境前读取题库、收藏、历史、当前题库 | 四类数据都有快照 | ReleaseSnapshotStore.createSnapshot() |
| 演示安装 | 安装 releaseScenarioV1 |
首页、结果页、收藏页只显示演示内容 | ReleaseScreenshotInstaller.installScenario() |
| 页面刷新 | 进入后重进首页、收藏页、历史弹层 | 页面读取最新数据 | AppStorageKey.Last*UpdateAt |
| 日志复查 | 搜索 hilog 附近的业务字段 |
不出现问题原文和答案全文 | AnswerPage、FavoriteService、QuestionHistoryService |
| 退出恢复 | 退出截图环境并冷启动 | 原题库、收藏、历史、当前题库恢复 | Repository 批量恢复和 CurrentDeckId |
如果某条路径当前还没有真机执行,就在发布记录里写清楚它仍需实机复核。不要把静态源码复查、一次手动点击或文章示例说成完整运行证明。
本地复查命令要围绕泄露字段写
下面这些命令不是为了证明功能已经上线,而是帮助写文章和做发布前复查时快速定位风险点。
rg -n "AnswerPage|param.text|deckName|FavoriteService.add" D:\ProgramData\huawei\lesson\The_Book_of_Answers\libraryHSP\src\main\ets
rg -n "QuestionHistoryService|FavoriteRepository|QuestionHistoryRepository" D:\ProgramData\huawei\lesson\The_Book_of_Answers
rg -n "hilog|question|answerText|fromDeckName|deckName" D:\ProgramData\huawei\lesson\The_Book_of_Answers\libraryHSP\src\main\ets
rg -n "PrefStoreName|AppPrefKey|AppStorageKey" D:\ProgramData\huawei\lesson\The_Book_of_Answers\libraryHAR\src\main\ets
第一条找结果页泄露入口,第二条找收藏和历史的持久边界,第三条看日志有没有正文风险,第四条确认恢复时要刷新哪些 live state。命令输出只能说明源码风险位置,不能替代真机截图和退出恢复验证。
常见问题与处理
| 现象 | 根因 | 处理 |
|---|---|---|
| 首页是演示题库,结果页仍是真实答案 | 只替换了题库列表,没有替换当前抽取参数 | 固定 currentDeckId,用演示题库完成抽取路径 |
| 收藏页出现真实答案 | 没有备份并替换 favorite_list |
进入截图环境时用演示收藏列表替换,退出时恢复 |
| 历史弹层出现真实问题 | 只清了页面状态,没有写 QuestionHistoryRepository |
用演示历史覆盖,并刷新 LastQuestionHistoryUpdateAt |
| 退出后当前题库丢失 | 恢复了题库数据,但没有恢复 CurrentDeckId |
快照里保存当前题库 id,退出后写回 Preferences 和 AppStorage |
| 补图时内容和上次不同 | 演示场景用了随机 id 或当前时间 | 固定 scenarioId、答案 id、题库 id 和种子版本 |
这些问题都不适合用 Toast 掩盖。Toast 只能提示当次操作,不能证明发布截图来自干净数据,也不能保证退出后真实数据恢复。
小结
第 50 篇真正要讲的是一个发布前数据沙箱:现有源码已经证明题库、答案、收藏和历史都会进入页面;发布截图前要把这几类数据统一换成演示场景,截图后再恢复真实快照。这个方案的关键不是新增多少类,而是把 owner 收清楚:SeedLoader 继续负责默认题库,业务 Service 继续服务页面,截图环境只负责备份、安装、恢复和清单。这样写出来的发布材料才可复现、可排障,也不会把用户内容带进截图和日志。
更多推荐



所有评论(0)