HarmonyOS应用实战-启示散页-05-随机抽答案要避免连续重复:把算法放进服务层

HarmonyOS应用实战-启示散页-05-随机抽答案要避免连续重复:把算法放进服务层

答案之书的核心体验是"提问后给一个答案"。如果连续两次抽到同一句,用户会立刻觉得应用敷衍;如果页面自己写随机算法,动画页、快捷入口和未来的小组件入口又可能各抽各的。随机不是 UI 小细节,它是产品体验规则,应该放在服务层统一处理。

这篇文章会把问题拆成四个可落地的点:

  1. 把抽取算法从页面移到 AnswerDrawService。
  2. 用最近结果窗口降低连续重复概率。
  3. 区分候选池、运行期状态和展示动画。
  4. 给出小题库、空题库、过滤后无结果的兜底策略。

1. 随机体验不等于 Math.random

Math.random() 能给出随机下标,但不能保证体验。题库只有十几条时,连续重复很容易出现;用户提问后看到同一句答案,会把算法概率理解成产品问题。答案之书把抽取逻辑放进 Service,让所有入口都使用同一套规则。

ts 复制代码
interface DrawAnswerOptions {
  deckId: string;
  question?: string;
  excludeRecent?: boolean;
}

interface DrawAnswerResult {
  deckId: string;
  answer: Answer;
  drawnAt: number;
  question?: string;
}

抽取结果不是一段文本,而是带题库、问题和时间的领域结果。这样后续收藏和历史才能拿到完整来源。

2. 候选池先由 Service 准备

页面不应该自己加载题库再筛答案。Service 先读取当前题库,过滤空答案,再决定是否允许抽取。空题库、损坏题库、当前题库不存在都在这里统一兜底。

ts 复制代码
async draw(options: DrawAnswerOptions): Promise<DrawAnswerResult> {
  const deck: Deck | null = await DeckRepository.loadDeck(options.deckId);
  if (!deck) {
    throw new Error('当前题库不存在,请重新选择题库');
  }
  const candidates: Answer[] = deck.answers
    .filter((item: Answer): boolean => item.text.trim().length > 0);
  if (candidates.length === 0) {
    throw new Error('当前题库没有可抽取的答案');
  }
  return this.pickFromCandidates(deck.id, candidates, options.question);
}

候选池准备属于业务层。页面只关心成功展示或失败提示,不应该处理题库损坏的细节。

3. 最近窗口只影响选择,不改题库

避免连续重复不需要修改题库顺序,也不需要给答案加权。答案之书维护一个运行期 recent 窗口,抽取时优先排除最近出现过的答案;如果题库太小导致排除后为空,就退回全量候选。

ts 复制代码
private readonly recentAnswerIds: string[] = [];
private readonly recentLimit: number = 3;

private filterRecent(candidates: Answer[]): Answer[] {
  const filtered: Answer[] = candidates.filter((item: Answer): boolean => {
    return !this.recentAnswerIds.includes(item.id);
  });
  return filtered.length > 0 ? filtered : candidates;
}

recent 是运行期体验状态,不是持久化数据。它影响下一次抽取,但不改变用户题库内容。

4. 选中后再更新 recent

recent 窗口要在抽取成功后更新,不能在动画结束后才更新。否则用户快速连续点击时,第二次抽取仍可能看到旧窗口。Service 返回结果前完成状态更新,页面动画只负责表现。

ts 复制代码
private remember(answerId: string): void {
  this.recentAnswerIds.unshift(answerId);
  const unique: string[] = [];
  this.recentAnswerIds.forEach((id: string): void => {
    if (!unique.includes(id)) {
      unique.push(id);
    }
  });
  this.recentAnswerIds.splice(0, this.recentAnswerIds.length, ...unique.slice(0, this.recentLimit));
}

这段代码把 recent 维护成去重队列。它不追求复杂概率,只解决连续重复这个最刺眼的体验问题。

5. 随机下标封装成可替换方法

本地小应用不需要一上来引入复杂随机库,但随机方法应该集中。未来如果要做权重、节日题库或测试可复现,只改一个方法,不需要翻页面代码。

ts 复制代码
private chooseIndex(total: number): number {
  return Math.floor(Math.random() * total);
}

private pickFromCandidates(deckId: string, candidates: Answer[], question?: string): DrawAnswerResult {
  const pool: Answer[] = this.filterRecent(candidates);
  const answer: Answer = pool[this.chooseIndex(pool.length)];
  this.remember(answer.id);
  return { deckId, answer, question, drawnAt: Date.now() };
}

集中封装不是为了过度抽象,而是为了让体验规则只有一个出口。

6. DrawingPage 只处理状态机

抽取页最容易把算法、动画和导航揉在一起。答案之书更稳的写法是:页面进入 loading,调用 Service 拿结果,进入 revealing,动画结束后进入 done。答案是什么由 Service 决定,页面只负责让结果被看见。

ts 复制代码
type DrawingState = 'idle' | 'drawing' | 'revealing' | 'done' | 'error';

@State private drawingState: DrawingState = 'idle';
@State private currentResult: DrawAnswerResult | null = null;

private async startDraw(): Promise<void> {
  this.drawingState = 'drawing';
  try {
    this.currentResult = await AnswerDrawService.draw({ deckId: this.deckId, question: this.question });
    this.drawingState = 'revealing';
  } catch (err) {
    this.drawingState = 'error';
  }
}

状态机让页面逻辑可读,也让失败态有入口。抽取失败不应该停在半段动画里。

7. 收藏和历史使用同一个 DrawAnswerResult

如果收藏只存文本,历史又存 question,后续就无法判断同一句答案来自哪个题库。抽取 Service 返回完整结果后,收藏和历史都使用同一份来源信息,能减少数据对不上的问题。

ts 复制代码
await HistoryService.append({
  deckId: result.deckId,
  answerId: result.answer.id,
  answerText: result.answer.text,
  question: result.question,
  createdAt: result.drawnAt
});

抽取结果是后续功能的源头。源头字段完整,收藏、历史、分享才不会各自补猜。

8. 小题库要接受重复,但不连续卡死

如果题库只有两条答案,强行不重复会很快无候选。Service 的策略应该是尽量避免最近重复,但候选池为空时回退全量。这比一直重试随机更稳,也不会出现死循环。

ts 复制代码
const pool: Answer[] = candidates.length > this.recentLimit
  ? this.filterRecent(candidates)
  : candidates;
const answer: Answer = pool[this.chooseIndex(pool.length)];

体验规则要尊重数据规模。小题库允许重复,但仍可以避免最常见的连续同一句。

9. 验证与排障

抽取验证不要只看"点一次有答案"。要连续抽 20 次观察重复分布,切换题库后 recent 是否影响新题库,空题库是否给出错误提示,收藏和历史是否拿到同一个结果来源。

text 复制代码
验证点:
1. 同一题库连续抽取不频繁出现相邻重复
2. 两条答案的小题库不会死循环
3. 当前题库不存在时页面进入 error 状态
4. 收藏记录包含 deckId、answerId、answerText
5. 历史记录 createdAt 使用抽取结果时间

如果动画页偶发空白,先看 Service 是否抛错后页面没有进入 error,而不是先调整动画时间。

验证清单

  • 清应用数据后从冷启动进入,确认默认数据、页面状态和日志分支符合预期。
  • 对本文涉及的写路径准备正常、空值、重复、越界四类输入,确认错误停在 Service 或 Repository。
  • 页面返回、重新进入、切换题库、收藏、历史或删除后,确认对应刷新信号触发重新读取。
  • 修改资源或模块归属后重新构建,确认 HAP、HAR、HSP 的依赖方向没有反转。
  • 涉及真机体验、备份恢复、发布素材的内容,单独记录是否已经在设备或平台侧验证。

常见问题与处理

现象 先看哪里 处理方式
连续抽到同一句 recentAnswerIds 是否更新 抽取成功后立即记入 recent
小题库抽取卡住 是否在 while 中强行排重 排除后为空就回退全量候选
收藏来源丢失 是否只保存 answerText 保存 DrawAnswerResult 中的 deckId 和 answerId
切换题库后结果异常 recent 是否跨题库污染 recent 可按 deckId 分组或切换时清空

小结

随机抽取是产品规则,不是页面按钮里的临时表达式。把候选池、recent 窗口、结果来源都放进服务层,答案之书的核心体验才有一致性。

相关推荐
世人万千丶6 小时前
鸿蒙Flutter Flexible与Expanded的区别
学习·flutter·harmonyos·鸿蒙
红烧大青虫6 小时前
setInterval 倒计时实现:60s 验证码发送逻辑
后端·华为·harmonyos·鸿蒙系统
程序员黑豆6 小时前
鸿蒙应用开发中的单位详解:px、vp、fp、lpx
前端·harmonyos
YM52e6 小时前
鸿蒙Flutter Center居中组件:Align对齐详解
android·学习·flutter·华为·harmonyos·鸿蒙
tyqtyq227 小时前
HarmonyOS AI 应用开发实战:英语口语情景对话系统
人工智能·生活·harmonyos·鸿蒙·宠物
listening7777 小时前
HarmonyOS 6.1 性能调优实战:从卡顿到丝滑的6个底层逻辑
华为·harmonyos
程序员黑豆7 小时前
鸿蒙开发入门:Row 和 Column 布局组件详解
前端·harmonyos
xd1855785558 小时前
[特殊字符] 宠物美容指南 —— 鸿蒙AI智能助手开发全流程解析
人工智能·华为·harmonyos·鸿蒙·宠物
JaneConan8 小时前
鸿蒙 ArkUI 深水区:@Watch 和 @Observed,状态变了「自动跑」+ 嵌套对象「深层重绘」
开发语言·后端·ui·harmonyos