ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HarmonyOS Next实战:用ArkTS声明式UI开发翻转卡片口算练习

HarmonyOS Next实战:用ArkTS声明式UI开发翻转卡片口算练习 我家孩子上大班最近在练10以内的加减法。市面上口算App不少但要么广告多要么动画音效太花哨孩子点着玩半天没做几道题。后来我干脆在HarmonyOS NextSDK 5.0.0(12)对应API 12上自己写了个翻转卡片练习屏幕上铺一排气算题卡片点一下卡片翻到背面就是答案自己出题自己判反而玩得挺认真。这篇文章把整个项目的设计和实现完整拆开UI怎么布局、翻转动画怎么做的、10以内加减法怎么生成才不出现超纲题目以及我调试中踩过的几个坑。想直接照着写一个的看完第3节就能动手想理解每个方案为什么这么选可以从头顺一遍。这个项目不大却把HarmonyOS的声明式UI、状态管理、自定义组件、三维动画全串起来了非常适合作为API 12阶段的小练习。1. 项目概述与需求拆解1.1 翻转卡片的形式解决了什么问题先聊聊为什么选翻转卡片这个交互。低龄孩子做口算最大的问题不是不会算而是不感兴趣。普通的口算本或者练习册做完一页才看一次对错反馈太慢而App里如果做成闯关答题形式又容易把注意力全放在分数和特效上。翻转卡片的好处是把练习变成自测游戏卡片正面只露出算式孩子先心算在心里给出答案后点一下卡片背面显示正确答案对不对立刻就知道。这个先想后验证的过程其实就是把主动回忆这个高效学习机制落了地。每次验证都是一次即时反馈比写完一整页再判对错强得多。而且这个应用不需要复杂的交互逻辑看题、翻牌、换一批孩子一分钟就能学会操作。对家长来说题目是随机生成的每一批都不一样等于无限量口算卡再也不用手写出题了。市面上的启蒙练习册大多有固定题量用完了还得再买这种随机出题的方式反而更适合日常快速练。1.2 技术选型为什么用HarmonyOS Next ArkTS可能朋友会说这种小卡片用网页、小程序甚至Flutter都能做为什么非要用HarmonyOS Next我的理由有两点。第一这是设备场景决定的现在家里有HarmonyOS系统的平板和手机原生应用装上去一键直达不需要开浏览器、不需要找小程序入口。对孩子来说这算是个正经应用用起来有仪式感也少了很多干扰。第二从学习角度讲HarmonyOS Next从API 12开始全面落地ArkTS声明式开发范式这套东西和SwiftUI、Jetpack Compose有相似之处但又有自己的一套状态管理和转场动画机制。做一个翻转卡片正好能把State、Prop、属性动画、条件渲染这些核心概念完整摸一遍以后做更复杂的应用这些基础都是一通百通的。顺便说一句HarmonyOS Next SDK 5.0.0(12)我用下来的体验是ArkTS编译器的类型检查比早期版本严格不少很多在TypeScript里允许的写法比如any、自由的对象结构在ArkTS里都要改掉。这个过程一开始有点烦但习惯之后反而觉得更安全因为类型错误在编译期就被拦下了真机调试阶段省了不少事。1.3 功能清单与交互流程动手写代码之前先把功能范围定下来我做的第一版是最小可用版本首页展示一批卡片每张卡片正面是一道10以内加减法算式。点击任意卡片卡片以3D翻转动画展示背面答案。再点一次卡片翻回正面可以重新计算验证。页面底部一个换一批题目按钮随机生成新的一组算式。卡片数量设为6张2行3列排列字号足够大孩子看得很清楚。是否显示对错标记我特意没有加入第一版。因为对低龄孩子来说翻到答案的瞬间自己就能发现算对了没有不需要额外的红绿标记。做减法反而要保持这个翻牌过程的纯粹感。后面如果要改成多人比赛玩法再加计分也不迟。这个交互流程走下来只有两步看题翻牌验证。可以说把干扰降到了最低。2. 核心设计思路与方案选型2.1 翻转动画的本质三维旋转的正反面叠加翻转卡片最核心的技术点是动画。很多新手第一反应是做个缩放加淡入淡出不就完了但那样的效果只能叫切换没有卡片的体积感。真正的翻卡效果需要让卡片绕Y轴旋转180度并且在旋转过程中正面逐渐翻过去、背面逐渐露出来。在ArkUI里实现这个效果靠的是.rotate()属性。它支持三维旋转参数是旋转轴x、y、z和角度。把y轴设为1、角度从0变到180卡片就会像翻书一样绕纵轴转半圈。但这里有个视觉上的关键问题如果只把一张卡片旋转180度你会发现转到90度的时候卡片薄成一条线而且背面的内容其实是镜像显示的根本没法看。所以标准做法是准备两个面正面和背面叠放在同一个位置正面在卡片未翻转时可见背面预先绕Y轴旋转180度。当外层卡片整体旋转180度后背面正好转了360度看起来就跟没转一样文字保持正向。这就像你拿一张硬纸片背面贴上另一张字面朝你的纸片然后把整个纸片水平转半圈。原本朝外的纸面跑到后面去了原本朝内的纸面转出来朝向你。理解了这一步翻转动画的核心原理就通了。2.2 正反面切换的时序透明度与角度的配合原理说清楚了实操时还有一个细节正反面两张子卡片叠在一起如果透明度不处理翻转过程中会出现上下两层都半透明的鬼影非常难看。我的做法是让正面和背面的透明度由同一个翻转状态控制未翻转时正面opacity为1、背面为0翻转完成后正面为0、背面为1。配合rotateY动画转到一半时正面文字跟着卡片转走视觉上背面自然显现。透明度切换如果单独做动画很容易在中间阶段出现两面文字重叠所以我让透明度跟着同一个状态变量走交给ArkUI的动画系统自动插值。实测600ms左右动画时长下这个方案在真机上非常顺滑。还有一个值得提的点是动画曲线。我一开始默认用Linear线性运动翻起来像个机器人硬邦邦的后来改成Curve.EaseOut曲线开始快、结束慢卡片翻到快180度的时候速度降下来最后像真实卡片一样轻轻落定手感好了不是一点半点。这个小改动的影响远超预期建议你拿到代码后自己切换Linear和EaseOut对比一下一上手就能感觉出差别。2.3 题目生成算法10以内加减法的约束条件这个应用的核心是10以内加减法看起来简单但生成题目时其实有隐含约束加法结果不能超过10减法结果不能小于0。要是随随便Math.random() * 100生成个13 25就闹笑话了。加法我用的是这个策略先取第一个加数a在1到9之间然后第二个加数b在1到(10-a)之间取随机数。这样ab最小是2最大是10既保证不超范围又避免了0 几这种过于简单的题。有人可能会问为什么不保留0一年级孩子刚学数的分解0加几确实不怎么体现凑十的思维所以我在这个练习场景里有意识地排除了0加数和几减0这种特殊情况。减法类似先取被减数a在2到10之间然后减数b在1到(a-1)之间取随机数。这样差最小是1最大是9同样排除了几减0和几减几等于0这种一眼出答案的情况。每次生成题目时我用Math.random() 0.5判断这题出加法还是减法确保一局里加减法都能练到。另外同一批6张卡片里我会尽量避免一模一样的算式方法是用Set记录已生成的题目表达式重复就重新随机一次。因为取值范围有限重试一两次基本就能命中。这段算法看起来几句话就讲完了但边界很容易出错。我在写的时候吃了个亏下面第5节会专门讲怎么用Python脚本先做一轮穷举验证。3. 工程实现从零搭建翻转卡片页面3.1 新建工程与目录结构我用的是DevEco Studio 5.0创建工程时选择Empty Ability模板Compatible SDK设置成API 12SDK 5.0.0(12)语言模板默认就是ArkTS不用额外改。工程建好之后主要涉及两个文件entry/src/main/ets/pages/Index.ets作为页面入口以及自定义卡片组件FlashCard.ets。如果你喜欢把组件和页面放在同一个文件里也可以都写进Index.ets项目小怎么组织都行。不过我的习惯是单独拆一个组件文件原因很现实翻转卡片后面很可能要复用比如做拼音卡片认识数字卡片。拆成独立组件后页面代码更清爽改动画逻辑也不会碰到页面布局。3.2 自定义组件FlashCard的实现先看卡片组件。它的职责很单一接收一个算式和答案渲染正反两面处理点击翻转。我定义了一个QuestionItem数据结构// 题目数据结构 export interface QuestionItem { id: number; expression: string; answer: number; }然后组件里用Prop接收父页面传进来的题目数据。之所以用Prop而不是Link是因为卡片不需要反向修改题目数据它只需要自己内部维护是否翻转这个状态import { QuestionItem } from ./QuestionItem; Component export struct FlashCard { expression: string ; answer: number 0; State isFlipped: boolean false; build() { Stack() { // 正面算式 Column() { Text(this.expression) .fontSize(34) .fontWeight(FontWeight.Bold) .fontColor(#1565C0) } .width(100%) .height(100%) .backgroundColor(#E3F2FD) .borderRadius(16) .opacity(this.isFlipped ? 0 : 1) // 背面答案 Column() { Text(${this.answer}) .fontSize(44) .fontWeight(FontWeight.Bold) .fontColor(#E65100) } .width(100%) .height(100%) .backgroundColor(#FFF3E0) .borderRadius(16) .rotate({ x: 0, y: 1, z: 0, angle: 180 }) .opacity(this.isFlipped ? 1 : 0) } .width(140) .height(160) .rotate({ x: 0, y: 1, z: 0, angle: this.isFlipped ? 180 : 0 }) .animation({ duration: 600, curve: Curve.EaseOut }) .onClick(() { this.isFlipped !this.isFlipped; }) } }这段代码核心就三件事。第一两个Column叠在一个Stack里正反面宽高一致保证翻转时边缘对齐。第二背面Column预先rotate 180度这一步决定了翻转后答案文字是正向的。第三外层Stack随着isFlipped状态旋转0到180度并挂上animation属性让角度变化变成平滑动画。有朋友可能会问透明度为什么要跟着isFlipped直接切换而不是也做动画我试过给opacity单独挂动画的版本结果翻转过程中两面文字会短暂重叠看起来非常重影。直接跟随状态的好处是翻转跨过90度后正面基本不可见背面完全接管视觉连续性最好。注意正反面的尺寸、圆角、背景色深浅要保持一致否则翻到一半的时候两个矩形边缘对不上就像两张不同尺寸的卡片叠在一起观感很怪。3.3 父页面题目生成与网格布局接下来是页面主体。父组件负责维护题目数组、生成随机题目、渲染网格并提供一个按钮来重置题目。import { FlashCard } from ./FlashCard; import { QuestionItem } from ./QuestionItem; Entry Component struct FlashCardPage { State questions: QuestionItem[] []; cardCount: number 6; aboutToAppear(): void { this.questions this.generateQuestions(this.cardCount); } generateQuestions(count: number): QuestionItem[] { const result: QuestionItem[] []; const usedSet: Setstring new Set(); while (result.length count) { const isAdd: boolean Math.random() 0.5; let a: number; let b: number; let expr: string; let answer: number; if (isAdd) { a Math.floor(Math.random() * 9) 1; // 1 ~ 9 b Math.floor(Math.random() * (10 - a)) 1; // 1 ~ (10-a) expr ${a} ${b}; answer a b; } else { a Math.floor(Math.random() * 9) 2; // 2 ~ 10 b Math.floor(Math.random() * (a - 1)) 1; // 1 ~ (a-1) expr ${a} - ${b}; answer a - b; } if (!usedSet.has(expr)) { usedSet.add(expr); result.push({ id: result.length, expression: expr, answer: answer }); } } return result; } build() { Column({ space: 16 }) { Text(10以内加减法 · 翻转卡片) .fontSize(22) .fontWeight(FontWeight.Bold) .margin({ top: 24 }) Grid() { ForEach(this.questions, (item: QuestionItem) { GridItem() { FlashCard({ expression: item.expression, answer: item.answer }) } }, (item: QuestionItem) ${item.id}) } .columnsTemplate(1fr 1fr) .rowsTemplate(1fr 1fr 1fr) .columnsGap(16) .rowsGap(16) .padding(16) .layoutWeight(1) Button(换一批题目) .width(200) .height(48) .fontSize(18) .backgroundColor(#1565C0) .onClick(() { this.questions this.generateQuestions(this.cardCount); }) .margin({ bottom: 32 }) } .width(100%) .height(100%) .backgroundColor(#F5F5F5) } }跑起来之后6张卡片会整整齐齐排在屏幕中间偏上的区域每张卡片正面是蓝色背景、白色算式点击后翻转成橙色背景、加粗答案底部一个长条按钮用来刷新题目。整个过程没有任何弹窗和跳转孩子拿过去就能用。这里有几个设计上的细节值得解释。先说ForEach的第三个参数也就是键值生成函数。我用的是${item.id}因为题目数组里id是唯一的以它作为keyArkUI才能准确识别哪张卡片需要更新、哪张需要复用。如果图省事不给key或者直接用表达式字符串当key都可能引发状态错乱的隐性bug。再说Grid的列模板。columnsTemplate(1fr 1fr)表示两列等宽自适应rowsTemplate(1fr 1fr 1fr)表示三行等高。配合columnsGap和rowsGap设置卡片间距6张卡片正好占满可用区域。把Grid放进Column里并设置layoutWeight(1)是让网格区域占据除标题和按钮以外的全部剩余空间这样在不同尺寸的屏幕上都能保持比例协调。3.4 为什么不用页面路由而用单页面可能有人会想是不是应该做一个开始练习的首页再跳转到卡片页我确实一度想过毕竟看起来更像完整应用。但后来发现对这个工具来说单页面反而更好孩子自己打开应用就能直接开始少一次点击就少一个可能被动画吸引而忘记做题的干扰环节。HarmonyOS的router跳转在这里不是不能用而是没必要。做产品时有一条很朴素的道理去掉不必要的跳转往往比增加更多功能更能提升体验。对这个应用来说学习场景的临门一脚就是打开即练。4. 关键细节与状态管理4.1 Prop和Link的选择上面的代码里卡片组件接收题目数据用的是普通属性传递因为ArkTS中自定义组件可以通过构造函数入参来初始化成员变量我也刻意没有把题目数据做成响应式变量——题目本身在卡片生命周期内不会变化卡片真正需要响应式处理的只有isFlipped。这里可以多说一点状态设计的心得。State是组件内部状态Prop是父组件单向同步进来的值Link则是父子双向绑定。翻转卡片里isFlipped只影响卡片自己所以放内部State最合适。如果哪天要加一个全部翻开按钮那就必须把6张卡片的翻转状态提升到父组件统一管理此时Link或ObjectLink就该登场了。这个状态提升的思路和React里的lifting state up很像。HarmonyOS的ArkUI虽然语法不同核心思想是通用的。新手可以先把State和普通入参这套用熟练再去碰Link、Observed、ObjectLink这些更重的同步机制。小项目里如果一上来就全部状态提升代码会膨胀得很快反而不好维护。4.2 动画参数调优心得动画时长600ms来自一个很简单的对比实验。300ms太快卡片刚翻起来就结束了像跳而不是翻1000ms又太拖沓孩子点完要等半天才看到答案注意力就断了。600ms左右是最舒服的区间既保留了3D翻转的质感又不会让孩子等得不耐烦。曲线选Curve.EaseOut是因为卡片翻转最后阶段需要一点缓冲落定的感觉。想象一下实际生活中翻一张硬卡纸开头发力快、末尾因为阻力慢下来这个曲线模拟的就是那种手感。ArkUI的Curve枚举里还有EaseIn、EaseInOut、Spring等我都试过一遍EaseIn翻起来后半段太拖沓EaseInOut整体偏平Spring虽然回弹有弹性但对幼儿练习应用来说太皮了看着不像正经学习工具。最后EaseOut是综合效果最稳的。4.3 重新出题时的状态重置换一批题目按钮的逻辑看起来只有一行this.questions this.generateQuestions(this.cardCount)。但这里面藏着一个容易踩的坑旧卡片的翻转状态怎么办如果卡片组件里的isFlipped是内部State那题目换掉之后如果组件实例被ForEach复用了key相同旧卡片可能保持翻转状态新题目直接显示在背面上。孩子看到的就不是算式而是上一张卡片的答案这肯定不对。我的处理方式是用id作为key并且生成新题目时让id从0重新计数。ArkUI的ForEach在key变化时会重新创建组件实例isFlipped自然恢复成false。如果非要复用卡片实例那就得在重置时显式让所有isFlipped归零这就回到了状态提升的方案。两种做法都能解决问题但显然通过key重建实例更省心。经验写ArkTS代码时不要假设组件一定会被销毁重建也不要假设它一定不会。要看清楚key的变化如何影响组件生命周期否则就会在这种看着不起眼的地方翻车。5. 常见问题与排查实录5.1 翻转过程中出现文字鬼影或镜像这个问题我在2.2节提到过。典型症状是翻到90度附近时正面和背面的文字同时出现或者答案文字左右颠倒。先说镜像。原因几乎可以肯定是背面的预旋转没加。背面Column必须设置.rotate({ x: 0, y: 1, z: 0, angle: 180 })这个预旋转和外部Stack的旋转叠加最后才能让文字翻到正面时保持正常方向。如果漏掉这一行答案会像照镜子一样反着写孩子当然看不懂。再说鬼影。透明度必须由isFlipped状态直接决定不要给opacity单独挂一个长动画更不要给正反面设置中间透明度值。如果你用了我上面的方案还有重影检查一下是不是给正反面子组件也加了animation属性。理想情况是整个Stack挂一个animation正反面只设置不同opacity值让ArkUI统一完成插值。5.2 卡片在真机上出现白边或圆角不一致模拟器上看着好好的卡片到真机上背面上方偶尔会出现一条细细的白边。这个问题和翻转动画本身无关是Alpha通道和父容器背景合成导致的极细缝。排查下来问题出在正反面背景色带了半透明通道。把背景色改成完全不透明的0xFFE3F2FD、0xFFFFF3E0这类ARGB格式白边就消失了。另外圆角半径不一致也会在翻转时露馅。正面圆角16、背面设成12翻到一半能明显看出两个矩形边缘对不上。正反面的尺寸、圆角、阴影参数必须完全一致这是这类叠层翻转方案的基本要求也是我在开发前期最容易忽略的细节。5.3 用Python脚本验证题目生成正确性这个可能不算bug但我觉得值得单独写一条。题目生成算法写完后我担心边界情况没考虑全就写了一个很小的Python脚本把10以内加减法所有合法组合都打印出来核对import random def gen(): used set() result [] while len(result) 6: is_add random.random() 0.5 if is_add: a random.randint(1, 9) b random.randint(1, 10 - a) expr f{a} {b} ans a b else: a random.randint(2, 10) b random.randint(1, a - 1) expr f{a} - {b} ans a - b if expr not in used: used.add(expr) result.append((expr, ans)) return result for _ in range(5): print(gen())跑几轮之后确认加法结果最大10、最小2减法结果最大9、最小1且没有重复题。这种做法本质上是把逻辑验证和界面开发解耦先用最快的工具把算法摸清楚再放心写进ArkTS。遇到更复杂的随机规则时我还会用Python把全部可能组合穷举一遍确保边界都覆盖到。5.4 模拟器正常、真机动画卡顿我遇到过一两次模拟器流畅、真机动画掉帧的情况。原因多半不是翻转动画本身而是页面里有大面积模糊、阴影或复杂的视觉效果。在我这个应用里正反面卡片都开了阴影6张卡片同时有阴影真机首帧渲染压力会明显变大。解决办法有两个一是减少阴影模糊半径把shadow参数从默认值调小二是确保卡片复用不要每次翻转都重建视图。ArkUI的ForEach对固定key的重建策略是友好的只要不改key6个卡片组件实例会一直活着翻转只是改属性不会反复创建销毁对象。实测把阴影模糊半径降到8之后几年前的平板上也没有掉帧感。6. 扩展思路从练习卡到学习小工具6.1 加入计时与计分但别让分数抢了注意力第一版我特意没做计分原因前面说过。但如果你想给应用增加一点游戏化色彩可以加一个本轮用时统计从点击第一张卡片开始计时到所有卡片全部翻开为止。这个统计以时间作为维度能鼓励孩子加快心算速度但又不像得分那样给人压力。实现上也很简单在父组件里加一个State elapsedSeconds: number用setInterval推进所有卡片翻完时清除定时器。比起答对10分答错扣5分这种机制时间维度的统计更温和也更适合幼儿阶段的练习心态。6.2 扩展题型与难度分级这个项目的扩展空间其实很大。最简单的扩展是支持20以内加减法只需要调整题目生成时的数值上下界。再往后可以加入连加连减但那种题型对低龄孩子来说难度曲线会陡增我建议按年龄段做难度分级Level 1只出5以内加减Level 2出10以内Level 3出20以内。难度分级比想象中容易只要把生成算法里a和b的取值范围抽成可配置参数就行。我后来把generateQuestions改成了带min/max参数的通用方法页面上加两个按钮切换难度。孩子长大一点之后这个练习应用不用重写直接调难度档位就能继续用。6.3 组件复用拼音卡片、英语单词卡最后想提一个顺便赚到的价值。FlashCard组件本身不关心内容是不是数学算式只要传入的文本和答案是正确配对的它就能工作。所以我把FlashCard.ets单独维护之后后面要做英语单词卡、拼音卡其实只是换数据来源的事。这种底层交互组件抽象化的收益只有当你真的做第二个、第三个卡片类应用时才能体会。做一个翻转卡片不难难的是把翻转这件事本身做好并且让它成为一个可以到处复用的积木。这也是我在这个项目里学到的最有价值的一件事。最后说点个人体会。这个应用做完之后我最大的感受是小项目也要认真对待状态和key这两个基础概念。翻转卡片看起来只是转一转、翻一翻但真正决定它好不好用的不是动画特效有多炫而是正反面切换是否干净利落、换一批题目后状态是否完全正确。这些恰好是ArkUI里最容易被新手忽略的地方。如果你和我一样是边学HarmonyOS Next边做东西建议别急着抄代码先把代码里的每一个.rotate、每一个State、每一个animation都问一句为什么这么写改一改参数看效果变化然后你大概率会比我更快趟平这些坑。把这段代码跑起来的那天下午我家孩子一口气翻了好几轮卡片还自己给自己出题那一刻我觉得这个应用成了。
返回列表