1. 项目拆解:算术大闯关到底在练什么
先把话说在前面:这不是一个复杂到让人望而却步的项目,但它是一个非常典型的“麻雀虽小五脏俱全”的HarmonyOS应用实例。标题里“总复习”三个字意味着,它不是单纯让你照着抄一遍代码,而是把前面学过的页面布局、组件状态管理、路由跳转、数据持久化这些基础能力串起来,在一个完整的场景里重新过一遍。我个人的观点一直很明确:练技术最怕的是东一榔头西一棒子,刷了几十个Demo却始终串不成一个完整应用,而算术大闯关这种带完整业务闭环的小游戏,恰好能把碎片化的知识点焊成一块整铁。
这个项目解决的需求也很直白:给低龄学童一个不枯燥的口算练习工具。小学数学口算训练是刚需,但传统练习册和App往往缺少即时反馈和激励设计,孩子容易抵触。算术大闯关的破局思路是——把做题变成闯关,答对加分、限时速答、关卡递进难度、通关给成就感。对学习者来说,它把加减乘除的四则运算能力训练,包装成了一种游戏化体验。对HarmonyOS开发者来说,它则是一个极好的综合练习载体,适合已经学完基础组件和基本语法、想通过一个完整项目来查漏补缺的人。
技术层面的核心骨架并不复杂:ArkTS声明式UI配合状态管理实现交互界面,router做多页面路由切换,首选项(Preferences)或关系型数据库做关卡进度和最高分的持久化存储。再加上随机数生成题目、倒计时逻辑、难度分级算法,一个完整的应用闭环就立起来了。整个项目最值得圈出来的不是某一个高深技术点,而是“怎么把一堆基础能力在真实场景里有机组合”的思路。
我见过不少初学者,UI写得很热闹,一到业务逻辑就乱套,原因就在于没有把“界面”和“数据”分离思考。算术大闯关正好是治这个毛病的良药——它的核心逻辑是题目生成、答案判定、分数累计、关卡推进,这些全部可以独立于UI去设计和测试,而UI只是把这些逻辑的结果呈现出来。如果你能从这个项目里悟到“先想清楚数据流转,再去画界面”这个道理,那这趟复习的价值就已经回本了。
2. 核心设计思路与方案选型解析
2.1 为什么选游戏化闯关这个模式
先说设计动机。如果这个应用只是把100道算术题罗列在白底页面上,那它跟纸质练习册没有任何本质区别,也就失去了做成App的意义。游戏化是这类工具类应用留住用户的关键。
闯关模式的好处在于,它给枯燥的练习提供了一个天然的“进度反馈环”。孩子每做完一组题,就能看到自己前进了一关、拿到了一颗星,这种及时的成就感反馈非常管用。从技术实现角度说,闯关模式还有一个额外优势:它天然把复杂业务切成了多个相对独立的模块——关卡配置、题目生成、判定计分、进度保存,每一块都边界清晰,非常适合作为复习案例来拆解。
具体到关卡难度设计上,我采用的是按年级梯度配置的方式:低年级关卡只涉及10以内的加减法,高年级关卡引入两位数乘法和简单的除法混合运算。难度梯度通过“数值范围”和“运算符集合”两个维度来控制,配合“每关规定题数和答对率要求”构成完整的闯关条件。这个设计不是拍脑袋想的,实际参考了小学口算的分阶段训练规律,也考虑到了应用的使用时长——单关控制在1分钟以内,符合低龄用户的注意力特点。
2.2 技术选型的取舍逻辑
技术选型上,我特意没有引入复杂的第三方库,也没有用过于进阶的API。原因很简单:这是总复习项目,核心目标是巩固基础,而不是秀操作。
UI层用ArkTS声明式语法,组件上用Column、Row做线性布局,Grid用在一关多题的宫格场景,弹窗用系统提供的CustomDialog或AlertDialog。状态管理用的是组件级别的@State和@Prop,顶多再配合@Link做父子组件数据同步,没有一上来就上@Provide/@Consume或AppStorage。路由用router模块就够了,它是页面级跳转最轻量的方案。数据持久化用@ohos.data.preferences(首选项),以键值对形式保存“当前关卡数”和“每个关卡的最好成绩”,理由是这个项目需要持久化的数据结构非常简单,不需要动用数据库。
这里有一个容易被忽略的设计细节:题目生成模块必须是纯逻辑、无UI依赖的。我把“生成一道题”封装成一个独立函数,参数是运算符类型与数值范围,返回值是题目字符串和正确答案。这样一来,模块可以做单元测试,也可以随时替换难度策略,UI完全不感知底层逻辑。这是整个项目里我认为最有工程价值的小设计,哪怕代码量不大,但体现的是一个严肃开发者应有的分层意识。
2.3 应用整体架构与页面流转设计
整个应用设计为四个页面,它们的流转关系构成一个清晰的业务闭环:
| 页面 | 职责 | 关键能力 |
|---|---|---|
| 启动页/首页 | 展示应用名称、开始闯关按钮、总星星数 | 页面路由跳转、本地数据读取 |
| 关卡选择页 | 展示10个关卡的解锁状态、每关星级评价 | 条件渲染(是否解锁)、动态数据绑定 |
| 答题页 | 展示题目、四个选项按钮、倒计时、当前得分 | 定时器、随机数组题、点击事件、状态刷新 |
| 结算页 | 展示本次得分、获得星级、进入下一关按钮 | 路由参数传递、条件判定、数据持久化写入 |
页面流转设计上,我采用了最简单的router.pushUrl方式,把“用户状态”(比如当前玩的是第几关)通过URL参数传递,而不是用全局状态管理。这符合这个体量应用的实际情况,也降低了复习门槛。
还有一个细节值得注意:结算页的“进入下一关”按钮,只有在当前关通关成功时才激活。这个逻辑看似简单,但如果放在全局状态里,两个页面之间会隐约耦合;放在路由参数里,页面传递信息一目了然,后续接全局状态管理也顺理成章。
3. 核心模块拆解与实操实现
3.1 工程创建与目录结构规范
实操第一步是创建工程。IDE新建项目时选择“Empty Ability”模板,语言选择ArkTS。工程建好后,我建议顺手整理一下目录结构,把不同类型的代码分门别类放好,不要全堆在MainAbility里。
我推荐的目录组织方式是这样:
- /entry/src/main/ets/entryability:保留能力入口文件,不做改动
- /entry/src/main/ets/pages:存放四个页面文件,命名与功能对应
- /entry/src/main/ets/model:存放数据模型定义,比如关卡配置数据结构
- /entry/src/main/ets/util:存放工具函数,比如题目生成器、计分工具
这个划分不必过于教条,但至少在复习阶段就应该养成“按职责分目录”的习惯。项目越到后期,这个习惯的价值越明显——你不会在两百行之后找不到某个函数到底写在哪个文件里。
模块划分设计上,核心是三个文件:QuestionGenerator(题目生成器)、LevelConfig(关卡配置)、ScoreCalculator(计分器)。这三个文件都不依赖UI运行时,compile-safe也更好,配合har包做单元测试也很方便。这里顺带提一个经验:哪怕是练习项目,也尽量把纯逻辑和UI解耦,后面适配平板布局或者做自动化测试时,你会感谢当初这个决定。
需要特别留意模块的config字段。在module.json5里注册所有页面,并确保第一个页面是首页,这里有个初学者必踩的坑:忘记注册新页面导致路由跳转时找不到目标页,报错信息还不直观。
3.2 题目生成与难度配置实现
题目生成是整个应用的核心逻辑模块。我用一个类来封装,构造函数接收“最大数值范围”和“运算符类型”。
export class QuestionGenerator { private maxNumber: number; private operators: string[]; constructor(maxNumber: number, operators: string[]) { this.maxNumber = maxNumber; this.operators = operators; } // 生成一道题,返回 { text: 题目文本, answer: 正确答案 } public generate(): { text: string, answer: number } { const operator = this.operators[Math.floor(Math.random() * this.operators.length)]; let a = 0; let b = 0; let answer = 0; switch (operator) { case '+': a = this.randomInt(this.maxNumber); b = this.randomInt(this.maxNumber); answer = a + b; break; case '-': a = this.randomInt(this.maxNumber); b = this.randomInt(Math.min(a, this.maxNumber)); // 保证结果不为负 answer = a - b; break; case '×': // 乘法用小值区间,降低难度 a = this.randomInt(Math.min(9, this.maxNumber)); b = this.randomInt(Math.min(9, this.maxNumber)); answer = a * b; break; case '÷': // 除法:生成商再反推被除数,保证整除 b = this.randomInt(Math.min(9, this.maxNumber)) + 1; const quotient = this.randomInt(Math.min(9, this.maxNumber)); a = b * quotient; answer = quotient; break; } return { text: `${a} ${operator} ${b} =`, answer: answer }; } private randomInt(max: number): number { return Math.floor(Math.random() * (max + 1)); } }这几个设计的价值不在于算法有多高深,而在于处理了数学约束。减法运算我刻意让b不超过a,确保结果非负,适合低龄用户;除法先定商再反推被除数,保证整除。这些细节直接关系到用户会不会在练习中遇到“负数答案”或者“小数答案”的尴尬场景。
关卡的差异主要体现在构造参数上。第1关到第3关只有加减,数值范围从10逐步放大到20;第4关到第6关引入乘法;第7关到第10关加入除法,并且数值范围进一步变大。
3.3 全局状态管理与页面间数据传递
题目的实时状态放在答题页的组件状态里就足够了。当前题目、当前得分、剩余时间、当前第几题,这些用@State声明后,改变时界面自动刷新。
回答一题后的流程是:点击选项按钮,比对答案,正确则加分并跳到下一题,错误则减掉一次“生命值”并提示正确答案。为了记录每题的对错,方便结算页展示“本次正确率”,我在答题页维护了一个一维数组。
@State questionIndex: number = 0; @State currentScore: number = 0; @State answerList: boolean[] = []; // 每题是否正确checkAnswer(selected: number): void { const isCorrect = selected === this.currentQuestion.answer; this.answerList.push(isCorrect); if (isCorrect) { this.currentScore += 10; } else { // 简化的容错处理:错题也跳到下一题,但不加分 } // 更新当前题目,或判断是否闯关结束 this.questionIndex++; if (this.questionIndex < this.totalQuestions) { this.loadNextQuestion(); } else { this.finishLevel(); } }从哪里获取“当前关卡”信息呢?我通过路由参数传递。首页跳转关卡选择页时不传参,但关卡选择页跳转答题页时,会带上用户点选的那一关的编号:
router.pushUrl({ url: 'pages/AnswerPage', params: { levelId: this.currentLevelId } })答题页在aboutToAppear生命周期里通过router.getParams()把参数取出来,解析出levelId,再结合关卡配置表拿到这一关的出题参数。这样答题页不用关心自己是怎么被打开的,逻辑完全复用,不管从哪个入口进来都可以正常答题。
结算页同样接收答题页传过去的参数:得分、总题数、答对题数、当前关卡和关卡内获得的星数。结算页根据接收到的数据决定展示内容,并调用Preferences写回本地数据。
aboutToAppear(): void { const params = router.getParams() as AnswerResultParams; if (params) { this.finalScore = params.score; this.correctCount = params.correct; this.totalCount = params.total; this.levelId = params.levelId; } }这种数据传递方式的优势一目了然:页面间相互独立,调试时可以模拟任意参数,不必从头走一遍流程。这也是路由传参相比全局变量更推荐的原因之一。
3.4 答题页逻辑细节与定时器实现
答题页是“算术大闯关”业务逻辑最密集的页面。既要展示题目,又要管理倒计时,还要处理用户点击和页面退出场景下的资源释放。
我设定每关的总时间为60秒,页面顶部用Progress组件画了一个倒计时进度条。倒计时通过setInterval实现,每1秒更新一次remainingTime状态:
private timerId: number = -1; startTimer(): void { this.timerId = setInterval(() => { if (this.remainingTime > 0) { this.remainingTime--; if (this.remainingTime <= 0) { clearInterval(this.timerId); this.finishLevel(); // 超时自动结束 } } }, 1000); }这里有一个特别重要的工程细节:页面不可见或销毁时必须清除定时器。在HarmonyOS页面栈里,aboutToDisappear生命周期对应组件即将销毁,如果没有在这里clearInterval,定时器会继续持有页面引用,轻则内存泄漏,重则导致后续导航出现诡异问题。
aboutToDisappear(): void { if (this.timerId !== -1) { clearInterval(this.timerId); } }选项UI我用四个Button组件,分别绑定四个可能的答案值。正确答案由题目生成器算出来,错误答案则在正确答案基础上加减几个随机偏移量生成,但必须保证四个选项互不相同且都为正数。生成干扰项是另一个容易被忽略的细节:
buildOptions(correct: number): number[] { const options: number[] = [correct]; let temp = correct; while (options.length < 4) { const offset = Math.floor(Math.random() * 5) + 1; temp = correct + (Math.random() > 0.5 ? -offset : offset); if (temp > 0 && !options.includes(temp)) { options.push(temp); } } // 打乱顺序 for (let i = options.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [options[i], options[j]] = [options[j], options[i]]; } return options; }如果干扰项生成逻辑写得不够谨慎,很容易出现两个选项相同的情况,这是实际测试中最高频的Bug之一。
3.5 本地数据持久化:进度与星级的保存方案
关卡进度和最高成绩需要持久化存储。这个项目的持久化数据量极小——就是“当前解锁到第几关”和“每关获得几颗星”,所以不需要引入SQLite,用首选项(Preferences)就好。
Preferences的实际用法值得把几个坑讲清楚。获取实例的方法在不同版本里略有差异,但整体思路一致:通过上下文获得Preferences实例,再进行读写操作。
import preferences from '@ohos.data.preferences'; async saveLevelProgress(levelId: number, star: number): Promise<void> { const context = getContext(this); const pref = await preferences.getPreferences(context, 'gameProgress'); const stars = await pref.get('levelStars', [] as number[]) as number[]; stars[levelId] = Math.max(star, stars[levelId] || 0); await pref.put('levelStars', stars); await pref.flush(); }读操作则在首页和关卡选择页的aboutToAppear里同步调用即可。有一个细节必须强调:Preferences写入之后需要调用flush()才会落盘,否则只改内存、断电即失。
解锁关卡的判定是:第N关通关,则解锁第N+1关。这个逻辑我放在了结算页通关成功分支里。
请看一下这些页面的功能设计:
finishLevel(star: number): void { // 通关:星数大于0即为过关 if (star > 0) { this.saveLevelProgress(this.levelId, star); // 如果下一关未解锁,这里需要更新解锁标记 router.pushUrl({ url: 'pages/ResultPage', params: { levelId: this.levelId, score: this.finalScore, star: star } }); } else { // 失败不跳结算页,而是直接返回关卡选择页 router.back(); } }“失败是否进入结算页”是个产品决策。我的做法是失败不展示结算页,直接返回关卡列表页,附带一个Toast提示。这样一来,页面跳转逻辑更简单,用户也能立刻看到失败结果,重新尝试的路径非常短。
4. 实操踩坑记录:那些调试到怀疑人生的瞬间
4.1 Preferences异步时序引发数据更新延迟
首次实现“通关后回关卡选择页,马上看到新解锁的关卡”时,我遇到了UI不刷新的问题。原因不复杂:Preferences读写是异步的,从结算页返回到关卡选择页时,aboutToAppear里的读取操作尚未完成,或者读取后没有正确赋值给@State变量。
我当时的调试策略是在aboutToAppear里直接打印读取结果,发现数据其实已经读回来了,但UI没变。问题出在我把读取结果赋给了一个普通字段,而不是@State变量。在ArkTS声明式UI里,只有被@State装饰的变量发生变化时才会触发UI刷新。后来我把解锁状态声明为@State,并确保读取完成后更新它,问题就消失了。
这个坑对初学者的教育意义特别大:声明式UI的更新不是靠手动调用“刷新方法”,而是靠你在状态变量里放入新值。写惯了命令式UI的人,在这里非常容易踩坑。
4.2 选项重复问题与随机数的陷阱
前面提到干扰项生成时的去重逻辑,我在第一轮实现时没有做去重,结果经常出现两个选项相同的情况,测试时连点两个按钮都能答对,逻辑上属于严重Bug。
另一个随机数的坑是:Math.random()得到的浮点数如果直接取整,分布不均匀且边界容易出错。我改用Math.floor(Math.random() * (max + 1))之后,边界问题才消除。同时,为了避免连续两题出现一模一样的题目,我还加了一个小技巧:记录上一题的操作数和运算符,如果下一题完全相同就重新生成一次。
4.3 定时器倒计时与页面生命周期纠缠
倒计时归零后,页面自动结束闯关,这个逻辑本身没问题。但有一种特殊情况:用户在倒计时尚未结束时点击了返回键,页面的aboutToDisappear触发了,定时器被清了。但如果用户又迅速重新进入这一关,定时器还是从60秒开始倒计时,处于正常初始状态——这没问题。
真正的问题是:倒计时归零后弹出了结束提示,但在用户还没有点击弹窗确认时,倒计时函数又被重新触发了一次。我在结束分支里没有做“是否已经结束”的判断,导致finishLevel被调用了两次。后来我在状态里加了isFinished标记,在finishLevel开头判断是否已结束:
private isFinished: boolean = false; finishLevel(): void { if (this.isFinished) { return; } this.isFinished = true; // ... }这种“重复执行保护”在实际业务里几乎必然会遇到,早学会早受益。
4.4 页面路由传参类型丢失问题
router传参有个隐蔽问题:通过params传递的对象,在某些场景下取出来后可能不是预期的类型。比如我传了一个number类型的levelId,取出来之后如果需要它参与运算,建议先显式做一次类型转换:
const params = router.getParams() as Record<string, number>; const levelId = Number(params.levelId);这个转换看似多此一举,但能避免大量“类型不匹配”的低级报错。尤其是当结算页还想从参数里取一个对象时,如果对象属性名拼写不一致,取出来就是undefined,排查起来非常费神。
把上面这些坑整理成一个速查表,方便参考:
| 典型问题 | 根因 | 解决方案 |
|---|---|---|
| UI不更新 | 非@State变量变化不会触发刷新 | 确保变化的数据用@State装饰 |
| 选项重复 | 干扰项生成未去重 | 生成时比较并重新生成,直至互不相同 |
| 倒计时重复结束 | 缺少结束状态保护 | 增加isFinished标志位,入口处判空 |
| 路由参数类型不对 | 参数序列化/反序列化丢失类型 | 取出后用Number()或JSON.parse显式转换 |
| Preferences未落盘 | 忘记调用flush | 写入后同步调用flush |
| 配置页面未注册 | module.json5遗漏 | 新增页面后手动检查注册项 |
5. 从复习到重构:一次让项目质变的收尾优化
做一个完整的复习项目,不能以“能跑起来”就算结束。做完核心功能后,我又做了一轮重构级优化,这里挑两个最有代表性的点来说。
第一个优化是:把关卡配置数据从代码里抽出来,形成一个独立的数据文件。原来所有关卡的参数都散落在各个if判断里,不仅难以调整,还容易出错。抽出后用一个数组集中管理,每个关卡就是一个配置对象,新增关卡只加一条记录,业务代码完全不用动。
export interface LevelConfig { id: number; title: string; maxNumber: number; operators: string[]; questionCount: number; timeLimit: number; } export const LEVEL_CONFIGS: LevelConfig[] = [ { id: 1, title: '入门加减', maxNumber: 10, operators: ['+', '-'], questionCount: 5, timeLimit: 60 }, { id: 2, title: '百内加减', maxNumber: 50, operators: ['+', '-'], questionCount: 8, timeLimit: 60 }, // ... 以此类推 ];这个改动让“关卡选择页的列表展示”“答题页的参数设置”“结算页的下一关判断”三个地方都直接复用同一份配置数据,消除了数据不一致的隐患。如果以后想增加“每日挑战”模式,也只需要在配置里多加一个模式维度。
第二个优化是:给答题页加了一个非常轻的“题目进度条”指示器。不是复杂的动画,就是一行小字“第 X / N 题”加一个线性进度条。这个改动用户感知极强,孩子能直观看到“还剩多少题”,完成意愿明显提升。从工程角度看,它的实现成本极低,只是读取一下当前题目索引和总题数。
第三,我还做了一版适配平板的简单尝试。HarmonyOS的声明式布局天然支持响应式尺寸,我用的是百分比和弹性布局,在宽屏设备上题目和选项会自动拉开间距,不需要额外写一套布局文件。这让我体会到声明式UI在跨尺寸设备适配上的优势——只要布局设计得当,适配成本非常低。
收尾阶段我还做了一件很有价值的事:把逻辑层的核心函数(题目生成器、计分计算)单独提出来跑了一遍纯单元测试。IDE自带的测试框架配合本地模拟器跑了几组用例,确认边界情况都覆盖到了。这个动作对总复习项目尤其重要,因为它的业务逻辑是确定的,值得被测试稳定固化。
6. 经验沉淀:做完这个项目后我才真正想明白的事
最后分享几点我在开发“算术大闯关”过程中反复咀嚼到的东西。
第一点是关于“项目体量”的选择。很多人做练习项目喜欢一上来就做一个“完整商城”或者“社交平台”,结果做一半就搁浅。算术大闯关这种体量的项目,恰好在“可完成”和“综合性”之间取得了平衡。它能让你完整走一遍需求拆解、架构设计、编码落地、测试优化的全流程,又不会在重复的CRUD里耗光耐心。对复习基础知识的开发者来说,这个体量是最合适的训练场。
第二点是在这个项目里首次强烈体会到“数据驱动UI”的含义。早期我写界面,脑袋里想的是“这个按钮点击后要执行什么操作”;写完这个项目后,我下意识地想的是“这个数据变化后,界面应该呈现出什么状态”。这个思维转变是质的飞跃,它意味着你已经真正理解了声明式UI的核心范式。
第三点是关于“出错的价值”。整个项目最让我印象深刻的两个Bug——Preferences异步导致UI不刷新、定时器被重复触发,都不是什么高深概念,但都在真实场景里让我抓狂了一阵。这次经历让我养成一个习惯:凡是涉及异步操作和生命周期的地方,都会提前思考“如果这个回调走到一半用户退出了怎么办”,而不是想当然地默认一切顺利。
如果你是用这个项目做总复习的初学者,我的建议是:先把整体架构在纸上画清楚,再动笔写代码;写完一版能跑通的功能后,回头再做一轮重构优化;最后一定要把那些摸不着头脑的报错记录下来。这套“设计—实现—重构—复盘”的方式,比多做几个Demo对你的能力提升大得多。
最后再分享一个小技巧:给答题页的选项按钮加上点击后的瞬间反馈,比如让选中的按钮短暂变灰或改变透明度,再弹回正常状态。这个效果在HarmonyOS里只需要设置按钮的stateEffect属性就能实现。它不会增加任何编码负担,但会让应用的手感提升一个档次,孩子玩起来也更愿意多试几道题。好的用户体验,往往就是这些不起眼的小细节堆出来的。