趣味问答页面看起来只是"从字符串数组里随机取一项",真正接入应用后却很容易出现几类隐蔽问题:入口卡片写着"情侣问答",详情页却拿到了另一组题;路由参数缺失时页面静默落到默认场景;空题库时按钮虽然灰了,业务方法仍可能被其他调用路径直接触发;翻牌动画执行了一千多毫秒,结果在动画期间被重复计算;新增一个分类时,需要在分类列表、题库映射和详情标题三个位置同时修改。
本文基于"转盘决定吧"1.0.9 的真实工程,沿着 FunQuestionBank.ets、FunMode.ets 与 FunDetail.ets 三个文件复盘现有链路。当前源码已经完成了分类入口、题库访问、详情抽取、翻牌反馈和历史记录的基本分工;在此基础上,我们再讨论怎样收紧数据契约,使新增场景、异常路由和长文本题目都更容易维护。
唯一标记:转盘决定吧趣味题库稳定键契约

本文会解决五个具体问题:
- 用稳定
key建立分类、题库和详情页之间的唯一关系。 - 避免标题与题库分别从路由传入后发生错配。
- 让题库服务返回独立快照,防止调用方意外改写共享数组。
- 在动画开始前只生成一次
winner,让卡片、声音和历史记录引用同一结果。 - 为未知分类、空题库、长文本和离页计时任务准备明确路径。
本文核对范围 :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 度,叠加外层旋转后文字仍然正读。
这套实现有两个值得保留的点:
- 正反面判断先把角度归一化到
0..359,连续多次抽取也不会因角度不断累加而改变逻辑。 - 最终角度是
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) 范围内的整数。只要上界是排他的,下标就不会越界。使用时至少要固定三条规则:
length必须大于零,空集合在进入工具前被拦截。- 返回值必须是整数,且小于数组长度。
- 一次用户动作只调用一次,结果赋给局部常量。
对应的最小用例可以直接验证边界:
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 等页面可理解的结果。这样数据位置改变时,抽取和翻牌逻辑无需重写。
二十、针对真实页面的核验路径
完成改造后,不要只看"能否进入详情页",而要沿同一条数据链逐项核对:
- 进入趣味模式,四张卡片标题、描述和图标与场景定义一致。
- 调整分类顺序,卡片动画数组仍与当前列表长度一致,列表标识保持
key。 - 依次进入四个分类,详情标题由
key解析,不从第二个路由字段取得。 - 人为传入未知
key,页面出现明确异常状态,不静默跳到"今天吃什么"。 - 将某个题库暂时设为空,按钮不可用,直接调用抽取方法也安全返回。
- 连续点击主按钮,只创建一次有效抽取;抽取期间按钮保持不可用。
- 动画结束后,卡片文本与历史记录中的结果完全一致。
- 抽到较长问句时,正文可以完整阅读,按钮与卡片不互相遮挡。
- 动画未结束时返回,再次进入页面不出现旧回调提交的结果。
- 在窄窗口与大字号设置下,分类描述、详情标题和结果文本仍然可达。
二十一、常见问题与定位顺序
| 现象 | 优先查看 | 常见原因 | 处理方式 |
|---|---|---|---|
| 入口存在但按钮不可用 | 场景 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 供卡片结果和历史记录复用。两类事实不能混写。
参考文件
entry/src/main/ets/service/FunQuestionBank.etsentry/src/main/ets/pages/FunMode.etsentry/src/main/ets/pages/FunDetail.etsentry/src/main/ets/service/HistoryService.etsentry/src/main/ets/utils/Random.ets- Huawei Developer:ArkTS 概述,https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-overview
- Huawei Developer:ArkUI 概述,https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkui-overview