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

这篇文章会把问题拆成四个可落地的点:
- 把抽取算法从页面移到 AnswerDrawService。
- 用最近结果窗口降低连续重复概率。
- 区分候选池、运行期状态和展示动画。
- 给出小题库、空题库、过滤后无结果的兜底策略。


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 窗口、结果来源都放进服务层,答案之书的核心体验才有一致性。