HarmonyOS开发实例:运算定律拼图游戏,掌握ArkTS状态管理与交互
这个系列做到第 73 个实例我反而越来越喜欢挑这种“看起来不大”的题目来写。运算定律定律拼图游戏名字听起来像教育 App 的 Demo但它把 HarmonyOS 开发里最绕人的几个点——ArkTS 数据建模、State 数组更新、ForEach 渲染机制、点击交互反馈——全塞进了一个能让人在半个下午写完的小项目里。更难得的是它有一个特别清晰且适合用“拼图”承载的知识目标小学阶段的加法交换律、乘法分配律这些运算定律平时背起来枯燥一旦拆成算式卡片让人去配对玩家就得真正看懂每个等式左右两边的结构而不是机械背口诀。所以无论你是想练 ArkUI 的初学者还是正在做教育类应用开发、需要一套可复用配对玩法的人这个实例都值得完整做一遍。下面我会按需求拆解、数据模型、界面实现、代码落地、问题排查这样一条线讲最后再聊聊还能怎么扩展。1. 先拆解标题这款“拼图”到底在拼什么1.1 从数学目标反推游戏玩法过去我做工具类开发习惯先列功能清单再做界面。但这个实例恰好相反游戏设计完全取决于“运算定律怎么被孩子掌握”。运算定律在小学数学里集中在四年级左右常见的有加法交换律、加法结合律、乘法交换律、乘法结合律和乘法分配律。孩子们最容易出问题的点是两个一是几条定律的名字记混交换律和结合律分不清二是乘法分配律的理解只停留在“分配”两个字上真到应用时会漏乘后半部分。如果做成传统练习册那就是反复做计算题孩子很容易烦而且收益很低——因为计算能力不差的孩子照样可能因为粗心写错分配律。拼图这个形态恰好适合解决“理解”问题。把一条定律拆成左半边和右半边比如a × (b c)是一张卡片a × b a × c是另一张卡片玩家的任务就是判断这两张卡片是否属于同一条定律的左右两侧并放到对应的槽位里。这个过程逼着玩家去观察式子的结构哪边是括号展开前的形态哪边是展开后的形态两边的运算符号发生了什么变化。一旦配对成功等于在游戏里完成了一次完整的定律识别比嘴上背三遍“分配律就是展开括号”都管用。1.2 把玩法翻译成开发需求从开发者角度把上面的游戏设计翻译成需求模块就会变得非常清晰。整款游戏其实就是“一张卡片池 一组槽位 一套判定规则”的组合。我在这里先给出完整的功能拆解表后面所有代码都是围绕这张表展开的。功能模块具体需求对应实现点定律题库提供加法交换律、加法结合律、乘法交换律、乘法分配律等定律每条定律包含左式、右式两个碎片常量数组 LAW_POOL卡片池把本轮所有参与定律的碎片随机打乱以卡片形式展示Fisher-Yates 洗牌后存入 cardPool拼接区每条定律对应一组左右槽位槽位初始为空等待卡片填入slots 数组按 left right 成组展示匹配判定玩家选卡片后点槽位系统按定律 ID 和左右侧判断是否配对lawId side 双重校验轮次与结算完成一轮后进入下一轮最终按错误次数给出星级评价finishedRounds、wrongTimes、结算弹窗为什么要给每条定律单独设一个 lawId因为界面上要同时出现多组定律只有文本很容易撞车。比如加法交换律的左右式是a b和b a乘法交换律的左右式是a × b和b × a如果不带归属信息系统根本不知道你点的那张卡片属于哪一组。引入 lawId 之后匹配判定的逻辑就变得非常简单同一编号的定律、指定的一侧就允许配对。2. 数据模型与出题逻辑用“碎片”表达定律2.1 每条定律拆成左右两块为什么不是拆成三块四块这里有一个我自己踩过的坑先拿出来说第一版我为了体现“定律的证明过程”把加法交换律拆成了“两个数相加”“交换加数的位置”“和不变”三块打算让玩家按顺序拼接。结果界面复杂判定逻辑也麻烦而且对小学生来说非常抽象——他们反而搞不清到底要在拼图里表达什么。后来我改成“等式两侧配对”的形式左式一张卡右式一张卡配对成功就是一条完整定律。这个设计的好处非常明显判定逻辑从“顺序排列”变成“配对”代码简单了一个量级玩家不需要理解“结合律文字描述”这种抽象表述只需要比较两个算式结构出题时可以同时扔好几条定律的碎片进来制造干扰提升难度。所以本实例的 LawFragment 就四个字段partId 唯一标识、lawId 属于哪条定律、side 是左侧还是右侧、text 是显示文本。这个模型足够支撑目前的玩法未来要加“文字描述碎片”做扩展只需要再加一个类型字段不影响现有逻辑。2.2 定义定律库与槽位结构定律库是整个玩法的基础数据源我建议单独放在一个模型文件里。下面这段代码可以直接抄进项目的models/LawModels.ets// models/LawModels.ets export interface LawFragment { partId: string; text: string; lawId: number; side: left | right; } export interface LawDefinition { lawId: number; lawName: string; fragments: LawFragment[]; } export interface GameSlot { slotId: string; lawId: number; lawName: string; side: left | right; filledText: string; } export const LAW_POOL: LawDefinition[] [ { lawId: 1, lawName: 加法交换律, fragments: [ { partId: add_comm_left, text: a b, lawId: 1, side: left }, { partId: add_comm_right, text: b a, lawId: 1, side: right } ] }, { lawId: 2, lawName: 加法结合律, fragments: [ { partId: add_assoc_left, text: (a b) c, lawId: 2, side: left }, { partId: add_assoc_right, text: a (b c), lawId: 2, side: right } ] }, { lawId: 3, lawName: 乘法交换律, fragments: [ { partId: mul_comm_left, text: a × b, lawId: 3, side: left }, { partId: mul_comm_right, text: b × a, lawId: 3, side: right } ] }, { lawId: 4, lawName: 乘法分配律, fragments: [ { partId: mul_dist_left, text: a × (b c), lawId: 4, side: left }, { partId: mul_dist_right, text: a × b a × c, lawId: 4, side: right } ] } ];实际项目里你可以继续往 LAW_POOL 里加乘法结合律(a × b) × c a × (b × c)、减法的性质等。唯一要注意的是每条定律的 lawId 不能重复partId 也要全局唯一否则后面 ForEach 渲染和匹配判定都会出问题。GameSlot 是拼接区槽位的模型。它记录了这条槽绑定的是哪条定律的哪一侧filledText 初始为空一旦卡片填入成功就记录卡片文本界面会优先显示 filledText空的时候显示问号。这个模型和 LawFragment 完全是解耦的后续不管换什么交互方式数据都不会乱。2.3 洗牌与轮次如何保证每一轮都不一样玩法如果每次都出同样的题小孩子们玩两遍就会发现规律然后开始背顺序而不是背定律。所以每轮都要重新洗牌。我用的洗牌算法是经典的 Fisher-Yates代码很短function shuffleT(source: T[]): T[] { const arr source.slice(); for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]]; } return arr; }用source.slice()而不是直接修改原数组这一点很关键。定律库 LAW_POOL 是全局常量如果洗牌过程污染了它后面每一轮生成题目都会带着上一轮的痕迹越玩越不随机。轮次逻辑我设计成每轮从题库里随机抽 3 条定律。第一轮可能是加法交换律、加法结合律、乘法交换律第二轮可能就变成加法交换律、乘法分配律、加法结合律每次组合都不同。抽取规则依然是基于洗牌的this.currentLaws shuffle(LAW_POOL).slice(0, 3);生成槽位的时候我选择不打乱槽位顺序而是按定律分组排列每条定律一行左边槽、等于号、右边槽。这样用户看到的是“四个待补全的等式”而不是一屏乱序的槽理解成本低很多。卡片池乱、槽位不乱这个“一乱一不乱”的设计是搭配关系别两个都乱否则就成了记忆游戏而不是定律训练。2.4 匹配判定为什么用 lawId side 而不是字符串判断匹配判定的核心逻辑非常短if (selected.lawId slot.lawId selected.side slot.side) { // 配对成功 }有人可能会想直接比较 selected.text 和槽位期望的 text 不行吗表面上可行因为槽位也知道自己是哪条定律、哪一侧。但用 lawId side 是更稳定的做法。原因在于未来你要扩展配对规则——比如用户拖到槽里的卡片文本经过了下标处理或者出现了多种等价写法ab和ba从数学上确实等价但字符串不同此时字符串比较就会误判。而 lawId side 表达的是“你属于哪条定律的哪半边”这个语义和显示文本解耦判定永远不会因为样式差异出问题。这也是我一直强调的游戏逻辑别在 UI 层写死数据模型设计好了匹配判定就只是简单的字段比较。3. ArkUI界面实现与状态管理从点卡片到填槽位3.1 页面布局三个区段一个提示界面结构我采用了最常见的上下分区顶部轮次、错误次数等信息栏中部拼接区展示每条定律的左右槽位下部卡片区展示剩余的算式卡片底部操作提示文字和“下一轮”按钮通关后出现。在 ArkUI 里用 Column 作为根容器三个区域从上到下排列。需要注意中部拼接区占了很大比例我用layoutWeight(1)让它自动撑满剩余高度下部卡片区保持固定高度即可。拼接区内容多时用 Scroll 包裹防止小屏幕设备放不下。布局代码骨架如下build() { Column() { // 顶部信息栏 Row() { Text(第 ${this.finishedRounds 1} 轮 / 共 ${this.totalRounds} 轮) .fontSize(14) Blank() Text(错误 ${this.wrongTimes} 次) .fontSize(14) .fontColor(this.wrongTimes 0 ? #D32F2F : #333333) } .width(100%) .padding(16) // 中部拼接区 Scroll() { Column({ space: 12 }) { ForEach(this.currentLaws, (law: LawDefinition) { // 每组定律名 左右槽位 等号 }, (law: LawDefinition) law.lawId.toString()) } .width(100%) .padding(12) } .layoutWeight(1) .scrollBar(BarState.Off) // 下部卡片区 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) { ForEach(this.cardPool, (card: LawFragment) { // 每张算式卡片 }, (card: LawFragment) card.partId) } .width(100%) .padding(12) // 底部提示与按钮 Row() { Text(this.getHintText()) .fontSize(14) .layoutWeight(1) if (this.roundPassed) { Button(下一轮) .onClick(() this.goNextRound()) } } .width(100%) .padding(16) } .width(100%) .height(100%) .backgroundColor(#FAFAFA) }需要注意Blank()这个组件它在 Row 里会吃掉所有剩余空间把左右两边的文字推开比手动计算 padding 和 margin 灵活很多适合顶部状态栏这种“左一项右一项”的布局。3.2 卡片区与拼接区的状态联动状态管理是整个实例的精华。我把每一轮涉及的数据全部定义在 State 里页面中主要有这些状态状态变量类型作用currentLawsLawDefinition[]本轮参与的定律列表cardPoolLawFragment[]剩余卡片匹配成功后逐步减少slotsGameSlot[]所有槽位含每条定律的左右槽selectedCardIdstring当前选中卡片 IDwrongSlotIdstring错误反馈时高亮的槽位 IDwrongTimesnumber累计错误次数finishedRoundsnumber已完成轮数roundPassedboolean本轮是否已通关gameOverboolean全部轮次是否结束这里最核心的一句话是界面不需要主动操作 DOM只需要修改这些状态ArkUI 会自动把变化反映到视图上。点卡片改 selectedCardId点击槽位后改 slots、cardPool、wrongTimes界面自然跟着变化。卡片和槽位虽然是两个界面区域但它们通过 selectedCardId 和 slots 这两个状态串联。卡片被选中时卡片按钮背景色变成高亮色点击槽位时placeToSlot 方法读取当前 selectedCardId去 cardPool 里找到对应碎片再和槽位信息做匹配判断。这个“先选再放”的交互模型比直接实现拖拽要简单得多也比较适合小学阶段的孩子在触屏上操作不需要很精细的手指控制。3.3 反馈设计让每一次点击都有着落教育类应用和普通工具类应用最大的区别在于反馈。孩子做对了要立刻表扬做错了不要生硬地跳红叉而是要让他知道“为什么不对”。我实现的反馈有三个层次。第一层是选中反馈。卡片被点击之后立即高亮成黄色同时底部提示文字会从“请先点击一张算式卡片”变成“已选中一张卡片请点击上方空位放入”。孩子能明确知道下一步该干什么。第二层是匹配反馈。配对成功时槽位底色从浅灰变成浅绿卡片从卡片池里消失等式被完整拼出来了配对失败时槽位底色会短暂变红并记录一次错误。这个红色我用 wrongSlotId 控制400 毫秒后自动清除避免一直红着影响判断。第三层是轮次反馈。一轮全部拼完后底部出现“下一轮”按钮三轮结束弹出结算弹窗根据错误次数给 1 到 3 颗星。零错误给三星错误不超过 3 次给两星再多就只有一星。这个标准我调过几版最终感觉“3 次以内”对小学生来说既有一点压力又不至于太苛刻。4. 完整代码实现与运行要点4.1 工程结构建议把这个实例放到 HarmonyOS 工程的entry/src/main/ets下我建议拆成两个文件一个放数据模型和定律库一个放游戏页面本身。这不是说要刻意追求模块化而是把 LAW_POOL 和接口定义独立出来以后将来做网络题库、替换题目、写单元测试都会方便很多。entry/src/main/ets/ pages/ LawPuzzleGame.ets // 游戏页面包含 UI 与交互逻辑 common/ models/ LawModels.ets // LawFragment、LawDefinition、GameSlot、LAW_POOL、shuffle创建页面之后在main_pages.json里注册LawPuzzleGame或者从其他页面用router.pushUrl({ url: pages/LawPuzzleGame })跳转进入两种方式都可以。4.2 页面核心逻辑代码下面是页面里的核心逻辑代码我按模块拆开讲。这部分建议先跑通再逐步替换成自己的题目。// LawPuzzleGame.ets import { promptAction } from kit.ArkUI; import { LawDefinition, LawFragment, GameSlot, LAW_POOL, shuffle } from ../common/models/LawModels; Entry Component struct LawPuzzleGame { State currentLaws: LawDefinition[] []; State cardPool: LawFragment[] []; State slots: GameSlot[] []; State selectedCardId: string ; State wrongSlotId: string ; State wrongTimes: number 0; State finishedRounds: number 0; State roundPassed: boolean false; State gameOver: boolean false; private readonly totalRounds: number 3; aboutToAppear(): void { this.startNewRound(); } startNewRound(): void { if (LAW_POOL.length 1) { return; } this.currentLaws shuffle(LAW_POOL).slice(0, Math.min(3, LAW_POOL.length)); const fragments: LawFragment[] []; this.currentLaws.forEach((law: LawDefinition) { law.fragments.forEach((frag: LawFragment) { fragments.push({ ...frag }); }); }); this.cardPool shuffle(fragments); const slotList: GameSlot[] []; this.currentLaws.forEach((law: LawDefinition) { law.fragments.forEach((frag: LawFragment) { slotList.push({ slotId: slot_${frag.partId}, lawId: law.lawId, lawName: law.lawName, side: frag.side, filledText: }); }); }); this.slots slotList; this.selectedCardId ; this.wrongSlotId ; this.roundPassed false; } selectCard(card: LawFragment): void { if (this.roundPassed) { return; } if (this.selectedCardId card.partId) { this.selectedCardId ; } else { this.selectedCardId card.partId; } } placeToSlot(slot: GameSlot): void { if (this.roundPassed || slot.filledText ! ) { return; } if (this.selectedCardId ) { return; } const selected this.cardPool.find((c: LawFragment) c.partId this.selectedCardId); if (!selected) { return; } if (selected.lawId slot.lawId selected.side slot.side) { this.fillSlot(slot, selected); this.selectedCardId ; this.cardPool this.cardPool.filter((item: LawFragment) item.partId ! selected.partId); if (this.cardPool.length 0) { this.finishedRounds 1; this.roundPassed true; this.gameOver this.finishedRounds this.totalRounds; } } else { this.wrongTimes 1; this.wrongSlotId slot.slotId; this.selectedCardId ; setTimeout(() { this.wrongSlotId ; }, 400); } } fillSlot(slot: GameSlot, selected: LawFragment): void { const nextSlots this.slots.map((item: GameSlot) { if (item.slotId slot.slotId) { return { ...item, filledText: selected.text }; } return item; }); animateTo({ duration: 250, curve: Curve.EaseOut }, () { this.slots nextSlots; }); } getSlot(lawId: number, side: left | right): GameSlot { const found this.slots.find((s: GameSlot) s.lawId lawId s.side side); return found || { slotId: , lawId, lawName: , side, filledText: }; } getHintText(): string { if (this.roundPassed) { return 本轮拼图完成点击“下一轮”继续。; } if (this.selectedCardId ) { return 请先点击一张算式卡片再点击上方对应的空位。; } return 已选中一张卡片请点击上方空位放入。; } goNextRound(): void { if (this.gameOver) { this.showResult(); return; } this.startNewRound(); } restartGame(): void { this.wrongTimes 0; this.finishedRounds 0; this.gameOver false; this.startNewRound(); } showResult(): void { const stars this.wrongTimes 0 ? 3 : (this.wrongTimes 3 ? 2 : 1); let message 全部完成错误 ${this.wrongTimes} 次获得 ${stars} 星。; if (this.wrongTimes 0) { message 全部完成错误 0 次获得 3 星非常棒; } promptAction.showDialog({ title: 挑战完成, message: message, buttons: [ { text: 再来一次, color: #007DFF, action: () this.restartGame() }, { text: 退出, color: #999999, action: () {} } ] }); } build() { // UI 代码见 4.4这里省略具体编排 } }需要提醒几个版本兼容点。promptAction在 API 9 到 API 11 的工程里通常从ohos.promptAction导入新版 SDK 统一改成了kit.ArkUI我这里写的是新写法。如果你用的 DevEco Studio 版本比较老导入语句要换成旧路径。另外animateTo和Curve属于 ArkUI 基础能力不同版本差异不大可以放心用。4.3 界面代码中的关键编排build 方法里最需要注意的是 ForEach 的写法。我建议每一条 ForEach 都带上第三个参数 keyGenerator也就是用来生成唯一 key 的函数。ArkUI 在渲染列表时会根据 key 判断哪些节点需要创建、更新或销毁没有稳定 key 可能导致删除中间项时 UI 错乱。拼接区的每条定律我用一个白色圆角容器包起来内部放定律名和一行左右槽位ForEach(this.currentLaws, (law: LawDefinition) { Column({ space: 6 }) { Text(law.lawName) .fontSize(16) .fontWeight(FontWeight.Bold) Row({ space: 10 }) { // 左槽 Button(this.getSlot(law.lawId, left).filledText ? ? : this.getSlot(law.lawId, left).filledText) .width(110) .height(52) .backgroundColor(this.wrongSlotId this.getSlot(law.lawId, left).slotId ? #FFCDD2 : (this.getSlot(law.lawId, left).filledText ? #F1F3F4 : #C8E6C9)) .fontColor(this.getSlot(law.lawId, left).filledText ? #AAAAAA : #1B5E20) .fontSize(this.getSlot(law.lawId, left).filledText ? 22 : 16) .borderRadius(12) .onClick(() this.placeToSlot(this.getSlot(law.lawId, left))) Text() .fontSize(20) .fontColor(#333333) // 右槽 Button(this.getSlot(law.lawId, right).filledText ? ? : this.getSlot(law.lawId, right).filledText) .width(150) .height(52) .backgroundColor(this.wrongSlotId this.getSlot(law.lawId, right).slotId ? #FFCDD2 : (this.getSlot(law.lawId, right).filledText ? #F1F3F4 : #C8E6C9)) .fontColor(this.getSlot(law.lawId, right).filledText ? #AAAAAA : #1B5E20) .fontSize(this.getSlot(law.lawId, right).filledText ? 22 : 16) .borderRadius(12) .onClick(() this.placeToSlot(this.getSlot(law.lawId, right))) } .width(100%) } .width(100%) .padding(12) .backgroundColor(#FFFFFF) .borderRadius(12) }, (law: LawDefinition) law.lawId.toString())这里 getSlot 方法每次 build 都会重新从 this.slots 里查找所以槽位一变化整个界面也跟着刷新。缺点是查找方法的调用次数比较多但目前数据量只有 4 条定律、8 个槽位完全不需要做性能优化。等以后题库扩展到几十条、卡片上百张我会考虑把槽位拆成子组件并用 Link 传递减少不必要的整页重构。卡片区也比较简单一个支持换行的 Flex 容器每张卡片是一个 ButtonFlex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) { ForEach(this.cardPool, (card: LawFragment) { Button(card.text) .width(130) .height(50) .fontSize(16) .fontColor(#333333) .backgroundColor(this.selectedCardId card.partId ? #FFE082 : #FFFFFF) .borderRadius(12) .shadow({ radius: 6, color: rgba(0, 0, 0, 0.08), offsetY: 2 }) .margin(6) .onClick(() this.selectCard(card)) }, (card: LawFragment) card.partId) } .width(100%)卡片没有被选中时保持白底加轻微阴影有“拿起来”的感觉被选中之后变成黄底表示状态已切换。这里我特意没有让被选中的卡片移出卡片区而是通过 selectedCardId 高亮来区分因为小孩子的操作容易误触多点一次可以取消选中体验更友好。4.4 运行效果与发布要点在 DevEco Studio 里新建工程时项目模板选“Empty Ability”即可语言模板会默认 ArkTS。把上面的模型文件和页面文件放入工程编译到手机模拟器或真机就能看到完整的拼图游戏了。运行之前建议先做两件小事。第一件在module.json5里确认应用支持横竖屏如果只做了竖屏适配建议把 orientation 锁成竖屏避免平板横屏时卡片区布局被拉伸变形。第二件真机调试时开启动态预览工具先跑一遍三轮完整流程重点观察槽位状态和卡片池的联动是否正常。真机上如果发现卡片宽度在不同屏幕下忽大忽小多半是 Button 的 width 用了固定值导致的。小屏设备上 130 宽度的卡片放不下会换行大屏上可能又显得太窄。你可以改成.width(30%)加.constraintSize({ maxWidth: 140 })让卡片在 wrap 容器里自动分配宽度。这个细节我在调平板适配时踩过一次值得留意。5. 踩坑记录与排查思路5.1 State 数组更新不刷新一个最常见的“假Bug”这个坑我几乎在每个 ArkUI 实例里都会遇到。第一次写卡片从 cardPool 移除时我直接写了this.cardPool.splice(index, 1)结果界面纹丝不动。后来才确认State 修饰的数组如果只是原地修改了数组内容UI 引擎可能无法感知到变化。你需要让 State 持有新数组引用。// 错误写法界面不刷新 this.cardPool.splice(index, 1); // 正确写法产生一个新数组并整体赋值 this.cardPool this.cardPool.filter(item item.partId ! selected.partId);同样的道理也适用于 slots、currentLaws。所有对 State 数组的增删改都尽量用 map、filter、展开运算符生成新数组再赋回去。这不仅是性能问题更是 ArkUI 响应式更新机制的基本要求。5.2 ForEach 不写 keyGenerator 的隐患很多初学者写 ForEach 只传两个参数第二个参数生成 UIkeyGenerator 直接省略。如果列表永远不增不减这样问题不大。但卡片区是会变化的匹配成功后某张卡片从数组里消失如果没有 keyGeneratorArkUI 在复用节点时可能出现错位表现为“消失的卡片不对”或“残留的卡片卡住不动”。稳定 key 的选择也有讲究。我一开始偷懒用 index 做 key结果删除中间项时后面的卡片全部重建了一遍伴随着明显的闪动。后来全部改成 partId这个问题彻底消失。凡是业务上唯一的数据字段都应该优先作为 key不要使用 index。5.3 Builder 参数传对象导致 UI 不更新我在第一个版本里把槽位封装成了一个Builder renderSlot(slot: GameSlot)以为只要槽位对象里 filledText 变了UI 就会自动更新。实际测试数据确实变了但界面仍然显示问号。查下来发现 Builder 在传参时对普通对象存在值拷贝槽位对象并不是 State 数据无法触发内部的响应式更新。解决办法有两个一个是把槽位改成子组件通过 Prop 传入并保持数据单向流动另一个是干脆不在 Builder 里传对象像上面代码那样在 build 的 ForEach 内通过this.getSlot(lawId, side)实时查找。第二种对当前数据量来说最省事所以我最终版的代码也用了第二种。5.4 动画要克制尤其不能吓到孩子这里我用了一个非常克制的动画配对成功时槽位底色变化配合 animateTo给大约 250 毫秒的缓动。它的作用是让孩子感受到“这个动作被接受了”而不是生硬的瞬间跳变。但学习游戏里动画不宜多尤其不能在错误反馈时用剧烈抖动或长动画。我踩过一版加了抖动动画测试时发现小孩子们连续点错两次就开始频繁摆手明显是被吓到了后来果断换成简单的变色提示错误反馈变得柔和很多。5.5 其他容易忽视的小问题中文全角字符在 Button 上显示会偏宽如果卡片宽度固定 130a × b这种短式子还好换成(a b) c后可能会出现文字截断。建议用.maxLines(1)和.textOverflow({ overflow: TextOverflow.Ellipsis })备着至少保证 UI 不破相。setTimeout 里修改 State 是可以的但要注意页面销毁后回调可能还在执行最好在 aboutToDisappear 里清掉定时器或者用标志位判断页面是否存活。我这个实例页面生命周期很短暂时没有做清理但如果你把轮次做得更长一定要补上。真机上字体渲染和 IDE 预览器有差异×和÷这类符号建议用 Unicode 字符而不是图片避免资源加载失败后显示方块。6. 后续扩展思路这个拼图游戏做到能跑只是一个起点。它最大的优势是数据模型和玩法完全解耦所以扩展空间非常大。第一个方向是加干扰卡片。现在卡片池里只有本轮定律的真实碎片玩家只要把见过的等式配对就行。如果往卡片池里塞进一两张不属于任何定律的算式比如a - b玩家就必须先判断“这张卡有没有对应的槽位”难度立刻增加也更贴近真实考试的辨别场景。第二个方向是难度分层。低年级可以保留定律名显示高年级直接把定律名隐掉只留下一组空槽和卡片池玩家要自己判断哪些碎片能组成一条完整定律这已经接近代数结构思维训练了。第三个方向是计时与结算扩展。在 State 里加一个 timer每一轮限制 60 秒错误或者超时都会扣分排行榜可以用首选项ohos.data.preferences做本地持久化或者接入应用内评分。这些功能不需要改核心数据结构只是在现有状态机上做增量。第四个方向是自定义题库。把一个班级的易错题通过 JSON 导入生成专属的定律拼图包。因为 LAW_POOL 本身就是一个常量数组只要把来源改成网络请求或本地文件UI 完全不用改。我个人经验是先把今天这套 4 条定律的版本给孩子或者同学试玩一周你自然会发现新的需求。教育类应用最怕的不是功能少而是设计者闭门造车以为按钮、动画、配色做得越花越好。实际上孩子最需要的是对每次点击都给出清晰、稳定的反馈。最后分享一个我自己调整了三次的小细节结算弹窗里的文案。最初我写的是“你的成绩是 1 星请再接再厉”测试的孩子看到之后直接不想玩了。后来改成按错误次数分类回复0 次错误强调“非常棒”哪怕只有 1 星也强调“完成了所有拼图”反馈的重心从评分变成了完成。这个改变不需要任何技术含量却让整个游戏的可玩性上了一个档次。做教育类应用技术和情感反馈从来都是一件事。