【转盘决定吧|07】HarmonyOS ArkTS 6.0.2.130 趣味题库:场景分类、随机选择与详情页解耦

趣味问答页面看起来只是"从字符串数组里随机取一项",真正接入应用后却很容易出现几类隐蔽问题:入口卡片写着"情侣问答",详情页却拿到了另一组题;路由参数缺失时页面静默落到默认场景;空题库时按钮虽然灰了,业务方法仍可能被其他调用路径直接触发;翻牌动画执行了一千多毫秒,结果在动画期间被重复计算;新增一个分类时,需要在分类列表、题库映射和详情标题三个位置同时修改。

本文基于"转盘决定吧"1.0.9 的真实工程,沿着 FunQuestionBank.ets、FunMode.ets 与 FunDetail.ets 三个文件复盘现有链路。当前源码已经完成了分类入口、题库访问、详情抽取、翻牌反馈和历史记录的基本分工;在此基础上,我们再讨论怎样收紧数据契约,使新增场景、异常路由和长文本题目都更容易维护。

唯一标记:转盘决定吧趣味题库稳定键契约

本文会解决五个具体问题:

  1. 用稳定 key 建立分类、题库和详情页之间的唯一关系。
  2. 避免标题与题库分别从路由传入后发生错配。
  3. 让题库服务返回独立快照,防止调用方意外改写共享数组。
  4. 在动画开始前只生成一次 winner,让卡片、声音和历史记录引用同一结果。
  5. 为未知分类、空题库、长文本和离页计时任务准备明确路径。

本文核对范围 :ArkTS SDK 6.0.2.130(API 22),应用版本 1.0.9。SDK、Hvigor 6.22.3、Node.js 25.9.0 与 release assembleApp 成功信息来自仓库已有的 report-202606131742406040.json;本轮精修没有重新构建、安装或执行真机测试。示例中的页面与服务名称来自项目源码,带"改进写法"的代码属于建议实现,不表示当前工程已经采用该结构。

一、先还原真实运行链路

入口页 FunMode 在状态初始化时调用 FunQuestionBank.categories(),得到四个分类:今天吃什么、真心话大冒险、情侣问答和幸运抽签。每张卡片通过 ForEach 渲染,用户点击后把分类 key 与标题写入路由参数。详情页 FunDetail 读取参数,再调用 FunQuestionBank.get(key) 取得字符串数组,最后由 RandomUtil.int(length) 产生下标。

把源码中的职责列出来,能更快看清哪些部分已经分离,哪些事实仍然重复:

位置 当前职责 传出的关键数据
FunQuestionBank.categories() 创建入口卡片元数据 key、标题、描述、图标名
FunMode 渲染分类、处理按压和入场动效 路由参数 key、title
FunQuestionBank.get() 根据 key 选择字符串数组 当前场景题目集合
FunDetail 抽题、翻牌、反馈、结果展示 固定的 winner
HistoryService.add() 保存趣味模式记录 类型、标题、结果

趣味题库解耦的关键不是把字符串搬进单独文件,而是让分类元数据、题目集合、路由键和抽取结果都通过同一个稳定标识建立可验证关系。

二、稳定 key 才是跨页面契约

当前分类构造代码已经做对了一个重要选择 :业务标识使用英文 key,显示名称则放在 title。页面标题未来可以改文案,图标可以换资源,描述也可以调整,但 eat、truth、couple、lottery 不应该跟着展示内容变化。

ts 复制代码
static categories(): FunCategory[] {
  return [
    new FunCategory('eat', '今天吃什么', '吃货必备', 'ill_hotpot'),
    new FunCategory('truth', '真心话大冒险', '聚会破冰神器', 'ill_truth_or_dare'),
    new FunCategory('couple', '情侣问答', '增进感情小游戏', 'ill_couple'),
    new FunCategory('lottery', '幸运抽签', '今日运势一签即知', 'ill_lottery_fish')
  ];
}

ForEach 也使用 c.key 作为稳定标识:

ts 复制代码
ForEach(this.cats, (c: FunCategory, idx: number) => {
  // 分类卡片
}, (c: FunCategory) => c.key)

这比数组下标可靠。下标只表示"目前排在第几个",一旦运营顺序调整或中间插入新场景,原下标就会指向另一项;稳定 key 表示"它是谁",适合作为列表标识、路由参数、题库索引和历史分类字段。

实践中可以把约束写成一张小表:

字段 是否适合作为跨页标识 原因
idx 否 顺序变化后语义变化
title 否 展示文案可能改名或国际化
iconRes 否 资源替换不应改变业务身份
key 是 短、稳定、与显示内容无关

三、当前重复事实会怎样产生错配

现有实现把分类目录和题库映射放在同一个类中,但仍是两份独立定义。新增"周末去哪玩"时,开发者至少要给 categories() 增加一项,再给 get() 增加一个分支;如果只改其中一处,入口能出现但题库为空,或者题库已经存在却没有入口。

ts 复制代码
static get(key: string): string[] {
  if (key === 'eat') return FunQuestionBank.EAT;
  if (key === 'truth') return FunQuestionBank.TRUTH.concat(FunQuestionBank.DARE);
  if (key === 'couple') return FunQuestionBank.COUPLE;
  if (key === 'lottery') return FunQuestionBank.LOTTERY;
  return [];
}

第二个重复点在路由。FunMode 同时传 key 和 title:

ts 复制代码
router.pushUrl({
  url: Routes.FUN_DETAIL,
  params: { key: c.key, title: c.title }
});

详情页又分别信任这两个值。理论上完全可能传入 { key: 'eat', title: '情侣问答' },此时页面标题与实际题库不一致。代码不会崩溃,但用户会看到一个难以解释的业务错误。更稳的约束是:跨页只传 key,详情标题、描述和题目都从同一条场景定义派生。

四、把场景元数据和题目放进同一条定义

下面是一种改进写法。它不是为了追求复杂抽象,而是让"新增场景"变成一次原子修改。一个场景定义同时拥有入口信息和题目集合,代码里不再维护第二张映射表。

ts 复制代码
export interface FunSceneDefinition {
  key: string;
  title: string;
  description: string;
  iconRes: string;
  questions: readonly string[];
}

const FUN_SCENES: readonly FunSceneDefinition[] = [
  {
    key: 'eat',
    title: '今天吃什么',
    description: '吃货必备',
    iconRes: 'ill_hotpot',
    questions: [
      '火锅', '烧烤', '披萨', '汉堡', '沙拉', '饺子', '面条', '米线',
      '寿司', '韩式拌饭', '麻辣烫', '盖浇饭', '炒饭', '麦当劳', '肯德基', '螺蛳粉'
    ]
  },
  {
    key: 'couple',
    title: '情侣问答',
    description: '增进感情小游戏',
    iconRes: 'ill_couple',
    questions: [
      '我们第一次约会是在哪里?',
      '我们最难忘的一次旅行是?',
      '如果只能保留一段共同的回忆,你选哪一段?'
    ]
  }
];

这个结构拥有四个直接收益 :入口不会引用不存在的题库;详情标题与题目天然来自同一对象;后续增加内容版本、排序或适用人群时有明确位置;单元用例可以遍历目录,逐项确认 key 唯一且题目非空。

五、目录接口与详情接口应返回不同视图

入口页只需要 key、标题、描述和图标,并不需要把全部题目跟着页面状态保存。详情页则需要完整场景。可以让服务提供两个语义清楚的接口:categories() 返回轻量目录,findByKey() 返回单个场景。

ts 复制代码
export interface FunCategoryView {
  key: string;
  title: string;
  description: string;
  iconRes: string;
}

export class FunQuestionBank {
  static categories(): FunCategoryView[] {
    return FUN_SCENES.map((scene: FunSceneDefinition) => ({
      key: scene.key,
      title: scene.title,
      description: scene.description,
      iconRes: scene.iconRes
    }));
  }

  static findByKey(key: string): FunSceneDefinition | undefined {
    return FUN_SCENES.find((scene: FunSceneDefinition) => scene.key === key);
  }
}

这里的"轻量"不是性能口号,而是页面职责表达:入口页不应依赖题目细节,详情页也不应重新拼装标题。目录视图与完整定义都由同一份 FUN_SCENES 生成,因此不会漂移成两套事实。

如果场景数量未来从四个增长到几十个,可以再建立 Map<string, FunSceneDefinition>;当前规模使用 find() 已经足够直观,不必提前增加维护成本。

六、题库返回值要避免共享可变数组

当前 get() 的行为有一个细微差异:truth 使用 concat(),每次返回新数组;eat、couple、lottery 直接返回静态数组引用。页面目前只读取数组,所以暂时没有问题,但一旦调用方执行 shuffle()、splice() 或排序,就可能永久改变静态题库,而且不同场景的表现不一致。

如果暂时保留现有类结构,最小改动是统一返回副本:

ts 复制代码
static get(key: string): string[] {
  if (key === 'eat') return FunQuestionBank.EAT.slice();
  if (key === 'truth') {
    return FunQuestionBank.TRUTH.concat(FunQuestionBank.DARE);
  }
  if (key === 'couple') return FunQuestionBank.COUPLE.slice();
  if (key === 'lottery') return FunQuestionBank.LOTTERY.slice();
  return [];
}

若采用统一场景定义,则在详情初始化时创建快照:

ts 复制代码
const scene = FunQuestionBank.findByKey(this.bankKey);
this.bank = scene ? Array.from(scene.questions) : [];

服务保存只读定义,页面得到本次会话自己的数组。以后即使页面需要临时乱序展示,也不会污染全局目录。

七、路由只传 key,标题由详情页派生

入口点击逻辑可以收窄为一个参数:

ts 复制代码
router.pushUrl({
  url: Routes.FUN_DETAIL,
  params: { key: c.key }
});

详情页定义也随之变得简单:

ts 复制代码
interface FunRouteParams {
  key?: string;
}

private loadScene(): void {
  const params = router.getParams() as FunRouteParams;
  const key = params?.key ?? '';
  const scene = FunQuestionBank.findByKey(key);

  if (!scene) {
    this.pageState = 'invalid';
    this.title = '趣味模式';
    this.bank = [];
    return;
  }

  this.bankKey = scene.key;
  this.title = scene.title;
  this.bank = Array.from(scene.questions);
  this.pageState = this.bank.length > 0 ? 'ready' : 'empty';
}

这里不再把未知 key 静默替换成 eat。默认回退看似友好,实际会掩盖路由拼写错误:开发时传错参数,页面仍能工作,却展示了错误业务。显式 invalid 状态更容易定位问题,也能给用户提供"返回趣味模式"的可理解动作。

八、页面状态要覆盖无效路由与空题库

当前按钮通过 disabled: this.picking || this.bank.length === 0 阻止空题库点击,这是必要的界面保护。不过业务方法 startPick() 只判断了 picking。如果以后增加快捷键、语音入口或自动重试,它们可能绕过按钮直接调用该方法。

更稳的做法是界面和方法各守一次边界:

ts 复制代码
private startPick(): void {
  if (this.picking || this.bank.length === 0) {
    return;
  }

  const index = RandomUtil.int(this.bank.length);
  const winner = this.bank[index];
  if (!winner) {
    this.pageState = 'empty';
    return;
  }

  this.beginPickAnimation(winner);
}

双层保护并不重复。按钮负责向用户表达"当前不可操作",方法负责守住业务前置条件。即使调用入口变化,空数组也不会进入随机数工具。

页面状态可以保持简单:

状态 页面内容 主操作
ready 显示待抽取卡片 开始抽取
picking 显示翻牌和光晕 禁止重复触发
result 显示固定结果 再来一题
empty 提示当前场景暂无内容 返回分类页
invalid 提示入口参数无效 返回分类页

九、随机结果必须在动效开始前冻结

FunDetail 中最关键的一行不是旋转角度,而是动画之前生成 winner:

ts 复制代码
const winner = this.bank[RandomUtil.int(this.bank.length)];

animateTo({
  duration: 1600,
  curve: curves.cubicBezierCurve(0.15, 0.7, 0.25, 1),
  onFinish: () => {
    this.current = winner;
    this.result = winner;
    this.picking = false;
    HistoryService.add('fun', this.title, winner);
  }
}, () => {
  this.flipAngle = this.flipAngle + 1440;
});

这段源码遵守了"一次交互只有一个结果事实"的原则。声音、震动、卡片、结果文字和历史写入都只是同一 winner 的不同呈现。动画期间不重新调用随机函数,历史写入时也不再抽一次。

错误写法通常把随机调用放进动画刷新或完成回调的多个位置。这样很容易出现屏幕最终显示 A,历史却保存 B。动效持续多久、刷新多少帧都不应该改变业务结果;动效只是承诺结果的揭晓过程。

十、1440 度翻牌为什么最终仍显示正面

当前页面用 flipAngle 控制卡片绕 Y 轴旋转,并通过归一化角度判断显示哪一面:

ts 复制代码
private isFront(): boolean {
  const norm = ((this.flipAngle % 360) + 360) % 360;
  return norm < 90 || norm >= 270;
}

一次抽取增加 1440 度,也就是四个完整圆周。最终角度相对起点不变,因此卡片停在正面。中间进入 90 到 270 度区间时展示背面,背面内容自身预先旋转 180 度,叠加外层旋转后文字仍然正读。

这套实现有两个值得保留的点:

  1. 正反面判断先把角度归一化到 0..359,连续多次抽取也不会因角度不断累加而改变逻辑。
  2. 最终角度是 360 的整数倍,结果出现时不需要额外纠正卡片方向。

业务结果和视觉相位应保持分开。winner 决定显示什么,flipAngle 只决定什么时候揭晓。

十一、真心话与大冒险合并后丢失了类型

当前 truth 场景把两个数组直接连接:

ts 复制代码
if (key === 'truth') {
  return FunQuestionBank.TRUTH.concat(FunQuestionBank.DARE);
}

如果页面只显示一句文字,这样足够简洁。但连接后无法知道抽中项属于"真心话"还是"大冒险",未来想显示不同颜色、增加类型图标、分别统计次数或过滤不适合的动作时,就只能依赖文字内容猜测。

可以把题目从裸字符串升级为带标识的对象:

ts 复制代码
export type FunQuestionKind = 'choice' | 'truth' | 'dare' | 'couple' | 'lottery';

export interface FunQuestion {
  id: string;
  kind: FunQuestionKind;
  text: string;
}

const PARTY_QUESTIONS: readonly FunQuestion[] = [
  { id: 'truth-001', kind: 'truth', text: '你最大的梦想是什么?' },
  { id: 'dare-001', kind: 'dare', text: '用夸张的语气朗读一段广告词' }
];

id 用于记录与去重,kind 用于展示和策略,text 才是用户看到的内容。这样可以扩展能力,又不需要让详情页理解每种场景的内部数组名称。

十二、题目内容也需要产品边界

内置题库属于代码资产,但它仍然是用户直接执行或回答的内容。聚会动作、情侣问题和抽签文案的风险不同,不能只把它们当普通字符串。比如涉及快速旋转、单脚站立的动作,在狭小空间、身体不适或低龄用户场景下并不总合适。

可在题目模型中加入轻量约束:

ts 复制代码
export interface FunQuestion {
  id: string;
  kind: FunQuestionKind;
  text: string;
  enabled: boolean;
  audience: 'general' | 'adult-group';
}

这不是要把本地小游戏改造成内容平台,而是让启用状态和适用范围有正式字段。发布新版本前可以按场景逐条核对,停用不合适的内容,而不必删除历史标识或在页面里写特殊字符串判断。

十三、结果卡片需要为长文本留下完整阅读路径

当前详情页已经根据字符串长度调整字号,并设置两行省略:

ts 复制代码
Text(this.result ? this.result : '?')
  .fontSize(this.result
    ? (this.result.length > 8 ? 24 : (this.result.length > 5 ? 30 : 38))
    : 56)
  .maxLines(2)
  .textOverflow({ overflow: TextOverflow.Ellipsis })
  .width('100%');

对于"火锅""中签"等短结果,这个策略清晰有效;对于完整问句,两行省略可能把关键条件截断。字体缩小可以缓解,但不能成为唯一方案。比较稳妥的路径是:卡片保持稳定高度用于动画,结果落定后允许正文区域自适应到合理上限;超过上限时使用内部滚动,或增加一个可访问的完整文本区域。

还要注意,字符串 length 按代码单元计数,不等同于实际排版宽度。中文、英文、数字和表情符号的视觉宽度不同。当前分档适合小规模内置内容;如果题目来源以后可配置,应使用容器约束、最大行数与可滚动兜底共同处理,而不是继续堆更多长度阈值。

十四、入口动效的计时任务要跟随页面生命周期

FunMode.aboutToAppear() 为四张卡片分别启动延迟任务,延迟为 80 + i * 130 毫秒。每个任务再触发弹簧动画,把旋转、位移和透明度恢复到目标值。四张卡片时体验很自然,但如果用户进入页面后立即返回,延迟回调仍可能尝试修改已经离开的页面状态。

可以保存任务编号,并在页面消失时统一取消:

ts 复制代码
private entranceTimers: number[] = [];

private scheduleEntrance(): void {
  this.cats.forEach((_item: FunCategory, index: number) => {
    const timer = setTimeout(() => {
      animateTo({ duration: 700, curve: curves.springMotion(0.4, 0.6) }, () => {
        this.rotations[index] = 0;
        this.translateYs[index] = 0;
        this.translateXs[index] = 0;
        this.opacities[index] = 1;
      });
    }, 80 + index * 130);
    this.entranceTimers.push(timer);
  });
}

aboutToDisappear(): void {
  this.entranceTimers.forEach((timer: number) => clearTimeout(timer));
  this.entranceTimers = [];
}

同样,详情页的 60 毫秒卡片入场任务也适合保存并清理。生命周期治理的目标不是害怕任何延迟任务,而是确保页面离开后不再有回调持有它的短期状态。

十五、无限光晕需要明确停止条件

抽取开始时,源码用 iterations: -1 启动交替光晕,翻牌完成后又通过一次 300 毫秒动画尝试把 glowOpacity 收回到 0。当前代码没有保存这个无限动画的控制句柄,也没有离页取消逻辑,因此不能仅凭静态源码断言它在所有生命周期场景都已停止。还要考虑页面在一千六百毫秒内被关闭的情况:结果回调、声音、震动和历史写入是否仍应继续,需要由产品语义决定。

一种清晰约束是把一次抽取视为页面内事务:页面离开后不再展示结果,也不写入历史。可以增加会话令牌:

ts 复制代码
private pickSession: number = 0;
private active: boolean = false;

aboutToAppear(): void {
  this.active = true;
  this.loadScene();
}

aboutToDisappear(): void {
  this.active = false;
  this.pickSession += 1;
}

private completePick(session: number, winner: string): void {
  if (!this.active || session !== this.pickSession) {
    return;
  }
  this.result = winner;
  this.picking = false;
  HistoryService.add('fun', this.title, winner);
}

开始抽取时记录当前 session,完成回调只提交仍然有效的那次操作。这样连续点击、离页和重新进入都不会让旧回调污染新页面。

十六、历史记录要保存业务快照而不是动画状态

当前代码调用:

ts 复制代码
HistoryService.add('fun', this.title, winner);

保存的是模式、场景标题和最终结果,没有把 flipAngle、光晕透明度或卡片位移写入历史。这一边界是正确的:历史服务只关心用户完成了哪次决定,不关心界面怎样把结果演出来。

如果后续采用结构化题目,历史记录最好同时保存稳定标识与当时文案:

ts 复制代码
export interface FunHistorySnapshot {
  sceneKey: string;
  sceneTitle: string;
  questionId: string;
  resultText: string;
  createdAt: number;
}

只存 questionId 会导致题库升级后旧记录无法独立显示;只存文案又无法统计某一道题。标识与文本同时保存,可以兼顾追踪和历史快照。页面仍然只把已经冻结的 winner 转换成记录,不在历史层重新选择题目。

十七、随机工具的契约必须写清楚

详情页调用 RandomUtil.int(this.bank.length),隐含契约通常是返回 [0, length) 范围内的整数。只要上界是排他的,下标就不会越界。使用时至少要固定三条规则:

  1. length 必须大于零,空集合在进入工具前被拦截。
  2. 返回值必须是整数,且小于数组长度。
  3. 一次用户动作只调用一次,结果赋给局部常量。

对应的最小用例可以直接验证边界:

ts 复制代码
it('random index stays inside bank', 0, () => {
  const size = 4;
  for (let i = 0; i < 200; i++) {
    const index = RandomUtil.int(size);
    expect(index >= 0).assertTrue();
    expect(index < size).assertTrue();
  }
});

随机行为不适合断言某次一定抽中哪个文本,但适合验证范围、空集合保护和提交次数。若需要复现特定序列,可以给服务注入一个返回整数的函数,在用例中使用固定实现。

十八、分类完整性可以自动核对

统一场景定义后,很多人工疏漏可以由简单用例提前发现:

ts 复制代码
it('all fun scenes have unique keys and questions', 0, () => {
  const scenes = FunQuestionBank.allScenes();
  const keys = new Set<string>();

  scenes.forEach((scene: FunSceneDefinition) => {
    expect(scene.key.length > 0).assertTrue();
    expect(keys.has(scene.key)).assertFalse();
    expect(scene.title.length > 0).assertTrue();
    expect(scene.questions.length > 0).assertTrue();
    keys.add(scene.key);
  });
});

还可以逐项确认题目 id 唯一、文本去除空白后不为空、入口图标资源存在。这样新增场景不再依赖开发者记住多个文件的修改顺序,目录本身就是可遍历的事实集合。

十九、离线题库扩展时仍要保持服务边界

当前题库全部写在 ArkTS 常量中,适合内容数量小、随应用版本发布、无需联网的场景。若以后内容增长,可以迁移到资源文件或本地 JSON,但页面接口没有必要变化:入口页继续读取目录,详情页继续按 key 获取场景快照。

一个稳妥的演进顺序是:

阶段 数据位置 页面依赖
当前 ArkTS 静态常量 FunQuestionBank
内容增长 应用资源或本地 JSON 仍是 FunQuestionBank
需要版本迁移 本地仓库与版本号 仍是服务接口

页面不直接读取文件,也不解析 JSON。解析错误、字段缺失和版本兼容由服务或仓库转换为 ready、empty、invalid 等页面可理解的结果。这样数据位置改变时,抽取和翻牌逻辑无需重写。

二十、针对真实页面的核验路径

完成改造后,不要只看"能否进入详情页",而要沿同一条数据链逐项核对:

  1. 进入趣味模式,四张卡片标题、描述和图标与场景定义一致。
  2. 调整分类顺序,卡片动画数组仍与当前列表长度一致,列表标识保持 key。
  3. 依次进入四个分类,详情标题由 key 解析,不从第二个路由字段取得。
  4. 人为传入未知 key,页面出现明确异常状态,不静默跳到"今天吃什么"。
  5. 将某个题库暂时设为空,按钮不可用,直接调用抽取方法也安全返回。
  6. 连续点击主按钮,只创建一次有效抽取;抽取期间按钮保持不可用。
  7. 动画结束后,卡片文本与历史记录中的结果完全一致。
  8. 抽到较长问句时,正文可以完整阅读,按钮与卡片不互相遮挡。
  9. 动画未结束时返回,再次进入页面不出现旧回调提交的结果。
  10. 在窄窗口与大字号设置下,分类描述、详情标题和结果文本仍然可达。

二十一、常见问题与定位顺序

现象 优先查看 常见原因 处理方式
入口存在但按钮不可用 场景 key 与题库 目录新增后遗漏题库映射 使用统一场景定义,保证题目非空
标题与抽中内容不属于同一场景 路由参数 key 与 title 分别传递且错配 只传 key,标题由目录派生
首次抽取后题库顺序永久变化 get() 返回值 页面改写了静态数组引用 返回 slice() 或 Array.from() 快照
空题库触发随机异常 startPick() 只禁用了按钮,方法未守边界 方法开头再次判断数组长度
卡片显示和历史内容不同 随机调用位置 动画与历史各自抽取 动画前冻结单个 winner
连续进入页面后卡片位置异常 延迟任务 离页后旧计时回调仍修改状态 保存任务编号并在离页时取消
长问句只显示前半段 结果布局 固定高度叠加两行省略 结果落定后提供自适应或滚动区域
真心话无法使用独立颜色 题目模型 合并后只剩字符串 增加 kind 与稳定题目标识

定位时先确认 key,再确认场景定义,再确认本次题库快照,最后确认固定 winner。这条顺序能把路由错误、内容错误和动画错误分开,不必从界面效果反推所有可能性。

二十二、工程落地清单

  • 分类使用稳定 key,不以数组下标或标题作为业务身份。
  • 分类标题、描述、图标和题目来自同一条场景定义。
  • 入口路由只传必要字段,详情页不同时信任 key 与 title。
  • 未知 key 有明确状态,不用默认场景掩盖参数错误。
  • 题库服务返回只读定义或独立数组快照。
  • 空题库在按钮层和业务方法层都被阻止。
  • 每次交互只生成一次 winner,动画、结果和历史复用它。
  • 真心话与大冒险需要差异化能力时保留 kind。
  • 长文本有完整阅读路径,不只依赖缩小字号。
  • 页面离开时取消延迟任务,并让旧完成回调失效。
  • 分类 key、题目 id、非空集合都有自动化用例。
  • 历史保存稳定标识与当时文本,不保存纯视觉状态。

本篇要点

配置收敛 :开关集中一处管理,散落即失控;行为可测 :每个分支配断言,无断言的分支等于没测;降级明确:异常路径的兜底写进注释与回归清单。三条构成该模块的质量底线。

总结

"转盘决定吧"的现有实现已经把分类入口、题库查询、详情抽取与历史保存拆进不同文件,并且在动画开始前固定 winner,这是保证结果一致性的核心。继续演进时,最值得收紧的不是随机算法,而是数据契约:让稳定 key 成为唯一跨页身份,让标题与题目从同一场景定义派生,让页面拿到独立题库快照,并为无效路由和空集合提供显式状态。

当这些边界明确后,增加一个趣味场景只需新增一条完整定义;翻牌、声音、震动和历史都围绕同一结果工作;页面动效再丰富,也不会反过来改变业务事实。这样的题库服务规模不大,却能为后续内容扩展、分类调整和回归核验留下清晰路径。

二十三、源码事实与建议实现的边界

本文既复盘现状,也给出演进代码。为了避免把建议误读成仓库已经具备的能力,下面按真实调用链逐项标注边界。

关注点 当前源码事实 建议实现
分类目录 categories() 每次创建四个 FunCategory,键为 eat、truth、couple、lottery 用统一 FunSceneDefinition 同时保存目录元数据与题目集合
列表身份 FunMode 的 ForEach 已使用 c.key 作为稳定键 保持稳定键,并对目录键唯一性做自动化检查
路由参数 入口同时传 key 与 title;详情缺少参数时分别回退为 eat 与"今天吃什么" 路由只传 key,详情标题从统一场景定义派生;未知键进入显式异常状态
题库返回 eat、couple、lottery 直接返回静态数组引用;truth 因 concat() 返回新数组;未知键返回空数组 所有分支返回只读定义或独立快照,避免调用方改写共享数组
真心话大冒险 TRUTH 8 项与 DARE 7 项在 get('truth') 中合并成 15 个字符串,来源类型随合并丢失 当 UI 需要区分玩法时,为题目增加稳定 ID 与 truth/dare 类型
随机结果 startPick() 在 1600 毫秒翻牌前生成一次 winner,完成时复用到结果和历史 把结果建模为不可变快照,并增加提交 ID 防止旧回调重复提交
空集合 主按钮在 bank.length === 0 时禁用,但 startPick() 本身只判断 picking 业务方法再次守卫空集合,返回明确错误状态
入场计时 FunMode 为四张卡设置 80 + i * 130 毫秒延迟;FunDetail 另有 60 毫秒延迟,均未保存任务编号 保存计时任务并在页面离开时取消,或用运行令牌让旧回调失效
无限光晕 当前以 iterations: -1 启动光晕,完成时写回 glowOpacity = 0,未显式持有取消句柄 使用可控动画阶段或令牌,明确正常完成与离页中断的停止行为
历史写入 完成回调调用 HistoryService.add('fun', this.title, winner),页面没有等待 Promise 根据产品要求等待落盘并反馈失败,或明确异步落盘和重试策略

因此,统一场景定义、只传 key、显式 invalid/empty 状态、数组快照、题目类型、任务取消、提交幂等都属于本文建议;当前源码没有这些实现。当前仓库已经做到的是:分类入口与题库服务分文件、列表使用稳定键、按钮阻止空题库点击,并且一次抽取只生成一个 winner 供卡片结果和历史记录复用。两类事实不能混写。

参考文件

相关推荐
贾伟康42 分钟前
【电路实验模拟|03】HarmonyOS ArkTS 交通灯实战:用定时状态切换红黄绿灯
harmonyos·arkts·canvas·arkui·交通灯
贾伟康18 小时前
HarmonyOS API 26 嵌套 Navigation:getParent、父子栈边界与返回分发
harmonyos·arkts·navigation·嵌套路由·navpathstack
行者-全栈开发19 小时前
【鸿蒙心迹】多线程并发实战——TaskPool 与 Worker 选型与踩坑(HarmonyOS 7.x)
华为·harmonyos·多线程并发·worker·taskpool·线程通信·harmonyos 并发
OH_TPC21 小时前
@ohos/aki 源码解读(一):一行绑定背后,宏、模板与索引分发
harmonyos
贾伟康21 小时前
【解压时刻|13】HarmonyOS ArkTS 启动链路审计:从 EntryAbility、AppStore 到 Navigation 首屏
harmonyos·arkts·appstorage·应用启动·entryability
贾伟康1 天前
【古诗词闯关|12】HarmonyOS ArkTS 单页组件拆分实战:降低题库、闯关与结果弹层耦合
harmonyos·arkts·状态管理·arkui·组件化
威哥爱编程1 天前
模拟面试智能体开发实战 07:答完一题就点评一题,结构化输出给分建议,让每道题都变成复盘
python·agent·harmonyos
威哥爱编程1 天前
模拟面试智能体开发实战 08:总评报告先过三道闸,防泄题防夸大防错建议,才敢发到候选人手上
python·agent·harmonyos
熊猫钓鱼>_>2 天前
普通用户视角的端侧智能体验手记【鸿蒙心迹】
开发语言·人工智能·华为·智能手机·性能优化·ai编程·harmonyos