HarmonyOS 应用实战 50:发布截图别暴露用户题库,把截图环境做成可回滚数据沙箱

发布截图最怕一种“看起来很干净”的做法:开发者先把自己的题库改名,删掉几条提问历史,再对结果页做几次手工打码,然后拿这些图去交材料。单张图里可能只露出一个题库名,连着首页、抽取页、结果页、收藏页一起看,就能还原出用户最近问过什么、收藏过哪条答案、从哪个题库抽到过结果。

答案之书是离线应用,没有账号体系,也没有服务端同步,但这并不代表发布材料没有隐私边界。它的题库、收藏、提问历史都在本地 Preferences,页面展示时又会把答案正文、题库来源和最近问题带出来。第 50 篇要解决的不是“怎么打码”,而是把发布截图前的本地数据切换做成可回滚的数据沙箱:进入前备份真实数据,截图时只展示演示数据,退出后恢复原状,并留下可复查的截图清单。

在这里插入图片描述

这篇文章解决四件事:

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

在这里插入图片描述
在这里插入图片描述

先把泄露链路从源码里找出来

第一个判断标准很简单:页面上能出现的文字,如果来自真实用户输入或真实本地题库,就不能直接进入发布截图。答案之书当前已经有清晰的数据边界,问题不在“找不到 owner”,而在发布前没有把这些 owner 组合成一条临时数据沙箱链路。

画面位置 真实来源 风险
首页题库卡片 DeckService.list() 返回 DeckSummary.namecount 真实题库名和规模暴露
抽取页参数 路由里的 questiondeckId 最近提问和当前题库暴露
结果页答案 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 提供的是 getStringsetStringgetJsonsetJsonremove 这些基础能力,并没有一键导出或导入整个 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.LastDeckUpdateAtLastFavoriteUpdateAtLastQuestionHistoryUpdateAt 这类时间戳触发刷新;如果只写 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_indexdeck:<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);
  }
}

这段设计的核心是“页面不感知发布截图环境”。页面仍然走原来的 DeckServiceFavoriteServiceQuestionHistoryService,只是进入截图环境后,这些 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);

发布前复查时要重点找反例:questionanswerTextdeckName 是否被直接写进日志;分享、复制、收藏失败时是否把业务正文拼进错误信息。日志能帮助排障,但不能成为第二条泄露渠道。

发布前复核要覆盖五条路径

发布截图完成前,建议按五条路径复核。每条路径都对应一个真实 owner,失败后能直接定位,而不是只说“页面看起来不对”。

路径 操作 通过标准 失败时先看
进入前备份 进入截图环境前读取题库、收藏、历史、当前题库 四类数据都有快照 ReleaseSnapshotStore.createSnapshot()
演示安装 安装 releaseScenarioV1 首页、结果页、收藏页只显示演示内容 ReleaseScreenshotInstaller.installScenario()
页面刷新 进入后重进首页、收藏页、历史弹层 页面读取最新数据 AppStorageKey.Last*UpdateAt
日志复查 搜索 hilog 附近的业务字段 不出现问题原文和答案全文 AnswerPageFavoriteServiceQuestionHistoryService
退出恢复 退出截图环境并冷启动 原题库、收藏、历史、当前题库恢复 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 继续服务页面,截图环境只负责备份、安装、恢复和清单。这样写出来的发布材料才可复现、可排障,也不会把用户内容带进截图和日志。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐