1. 从“试商”到“大冒险”:这个应用要解决什么教学问题
家里有四年级娃的家长应该都有同感:除数是两位数的除法,是孩子从简单计算迈向“策略计算”的第一道坎。单看算式,25×4=100谁都懂,可一旦变成 312÷26,孩子就卡在第一步——到底商几?
问题的根源在“试商”。以前除数是一位数,乘法口诀全覆盖,见几商几就行。两位数除数没口诀可背,必须先“估”,估完还要验证:把估的商乘回去,积大了说明商大了要调小,余数比除数大说明商小了要调大。这个“估—乘—比—调”的循环,在草稿纸上可能要来回擦好几次。很多孩子不是不会算,是烦这个反复试错的过程,一烦就乱,一乱就错。
我之前在网上看过不少计算练习工具,基本都是直接出题、判对错、给答案,孩子不会还是不会。真正缺的是把“试商过程”本身拆开、可视化、可交互的练习环境。这周用HarmonyOS ArkTS写了个“试商大冒险”应用,核心思路是把这种抽象的试商思维转成看得见摸得着的操作:孩子在屏幕上先估一个商,系统实时算出乘积和余数,告诉他“偏大还是偏小”,引导他自己调整,最后看到完整的竖式计算过程。
这个应用不是单纯刷题工具,它做三件事:
- 把试商的每一步单独拎出来,让孩子明确知道错在“估大了”还是“估小了”;
- 用闯关冒险的形式消解重复练习的枯燥感,每道题都有反馈,正确率达到条件才能进入下一关;
- 把竖式计算的完整过程逐步展示,家长讲不清的“为什么商是12而不是11”,屏幕上一目了然。
继续往下读之前,先明确这篇博文适合谁:如果你是HarmonyOS开发者,想找一套能直接用ArkTS实现的教学类应用骨架,这篇能给到完整的数据结构设计和交互实现;如果你是做教育工具产品的,试商逻辑的设计思路也许能给你启发;如果你只是关心孩子数学启蒙的家长,里面关于“为什么试商难”的拆解也值得一看。代码都在正文里,可以直接照着写。
2. 题目生成与试商判定:先把数学逻辑写扎实
写练习类应用,最忌讳UI先跑了、题目生成逻辑却是乱的。我第一版代码就是先搭界面,结果发现题目不是太简单就是太怪,比如被除数总是能被整除,孩子练不出“调商”的能力。后来我推倒重来,先把数学模块单独拎出来写,全部用纯函数实现,不和UI耦合。
2.1 除法题目的生成策略:控制难度不等于随机数
“除数是两位数的除法”难度分层很明显,题目生成不能全随机。我先按人教版四年级的坡度做了三档:
- 低档:除数在11~20之间,商控制在2~5,被除数不超过100。这个阶段孩子刚接触估商,除数接近整十数(11≈10,19≈20),用四舍五入法估商几乎不会大偏;
- 中档:除数在21~40之间,商的取值范围放宽到2~9,被除数在200以内。这时候估商会出现偏差,需要调商;
- 高档:除数在41~99之间,商2~9,被除数最大到900。这道坎在于除数离整十数较远(比如46≈50),估出的商经常要修正。
生成题目不能写成“被除数÷除数=随机商”这种本末倒置的方式,因为除到最后的余数必须是合法整数。正确做法是反过来:先定除数,再定商,最后反推被除数范围,在范围里随机挑一个被除数,余数自然就产生了。
// 题目生成核心逻辑(ArkTS) interface DivisionProblem { dividend: number; // 被除数 divisor: number; // 除数 quotient: number; // 精确整数商 remainder: number; // 余数 level: number; // 难度档位 } function generateProblem(level: number): DivisionProblem { // 1. 根据档位确定除数范围 let divisorMin = 11, divisorMax = 20; if (level === 2) { divisorMin = 21; divisorMax = 40; } if (level === 3) { divisorMin = 41; divisorMax = 99; } // 2. 随机生成除数 const divisor = getRandomInt(divisorMin, divisorMax); // 3. 随机生成商(2~9),避免商为0或1的极端情况 const quotient = getRandomInt(2, 9); // 4. 被除数 = 除数 × 商 + 余数(余数范围 0 ~ divisor-1) const maxRemainder = divisor - 1; const remainder = getRandomInt(0, maxRemainder); const dividend = divisor * quotient + remainder; return { dividend, divisor, quotient, remainder, level }; }这里有个设计细节值得展开:为什么不直接随机被除数,再取整?因为随机被除数除以随机除数,很可能被除数太小导致商只有1,或者被除数太大导致计算变成三位数除法,都不适合当前练习目标。先定商再反推被除数,相当于给被除数的范围划了一条清晰的绳,保证了每一道题都落在“两位除数、一位商”的练习区间里。
另一个容易被忽略的点:如果余数永远为0,孩子练不出调商;如果余数太大,又会让孩子觉得题太“脏”。我的做法是给余数一个倾向——大概60%的题带余数,40%整除,这样孩子两种情形都能见到。实现上就是在生成余数时做一个随机分支,故意让整除题穿插出现,防止形成“算出来肯定有余数”的惯性思维。
2.2 试商判定的核心函数:三个分支把话说清楚
试商过程的逻辑核心不是“判断对错”,而是“判断偏差方向”。我在代码里用一个返回结构体来承载结果,UI层只用读字段、不用自己算。
interface TrialResult { valid: boolean; // 试商是否成功 product: number; // 试商 × 除数 = 乘积 remainder: number; // 被除数 - 乘积 = 余数 reason: string; // 偏大 / 偏小 / 正确 suggestNext: number; // 建议下一次试商的值(-1表示无需调整) } function evaluateTrial(guess: number, problem: DivisionProblem): TrialResult { const product = guess * problem.divisor; const remainder = problem.dividend - product; if (product > problem.dividend) { // 乘积比被除数还大:商估大了,必须调小 return { valid: false, product, remainder, reason: '偏大', suggestNext: guess - 1 }; } if (remainder >= problem.divisor) { // 余数 ≥ 除数:说明还能再除一次,商估小了,必须调大 return { valid: false, product, remainder, reason: '偏小', suggestNext: guess + 1 }; } // product <= dividend 且 remainder < divisor,试商成功 return { valid: true, product, remainder, reason: '正确', suggestNext: -1 }; }这个函数三个分支,对应数学上的三种情况:乘积大于被除数说明商大了,余数大于等于除数说明商小了,否则就是正确答案。别看代码简单,这是整个应用的地基。孩子输入一个猜测值,系统立刻告诉他“哪里不对、往哪个方向调”,这就是把竖式验算的每一步拆开了。
我特别想强调的是:不建议直接告诉孩子正确答案。在冒险玩法里,孩子试错后只得到“偏大/偏小”的提示,得自己再输入下一个数。这个过程才是训练试商思维的关键。我测试过,一个对两位数除法不熟的孩子,连续三四次“偏大→调小→偏小→调大”之后,会突然顿悟“原来我应该看除数的首位来估”,这种自己总结出来的经验,比任何口诀都牢。
2.3 竖式过程的可视化数据:把草稿纸搬进手机
题目做对只是第一步。我加了一个“查看竖式”按钮,点开后显示完整的除法竖式过程,而且把试商过程中每次调商记录都保留下来。为此需要一个比DivisionProblem更复杂的数据结构:
interface StepRecord { stepIndex: number; // 第几步 trialValue: number; // 这一步试的商 product: number; // 试商 × 除数 compareText: string; // 当前比较过程描述 isAdjust: boolean; // 是否属于调商步骤 } interface DivisionSolution { problem: DivisionProblem; steps: StepRecord[]; finalQuotient: number; finalRemainder: number; }标准竖式教学里,除数是两位数的除法一般分三步:先看被除数前两位够不够除,够则在十位写商;不够则看前三位,在个位写商;然后用余数继续。我在“竖式展示页”把这几步用时间和空间两条线同时呈现:时间线记录试商调整的过程,空间上每一行对应竖式的某个部分(除号右侧的试商区、下方的乘积区、最下方的余数区)。
这个模块开发起来其实比主界面复杂,因为竖式在手机上排版很占宽度。我采用的方案是把它水平拆成三块卡片,从上到下依次是“试商记录 → 乘积计算 → 余数对比”,而不是硬去画一个标准横式竖式。效果反而更好,孩子一眼能看到“商估大了3 → 乘积超了18 → 余数变成负数”这个因果链。
提示:如果你的目标是做给孩子用,宁可放弃竖式的传统排版,也要保证每一步的文字提示够直白。标准竖式对孩子来说是一个“结果形态”,而我们这个应用的价值恰恰在于“过程形态”。
3. ArkUI页面骨架与状态管理:页面怎么搭才不卡壳
数学逻辑写清楚了,开始动页面。HarmonyOS应用实例开发用的是ArkTS声明式写法,这里没有MVC那一套,页面就是状态的孩子:状态变,页面自动刷新。所以第一件事是把页面里需要“被观察”的数据列全。
3.1 页面状态设计:@State该挂哪些变量
整个应用我用三个页面:主菜单页、冒险闯关页、竖式详情页。冒险闯关页是核心,状态设计直接决定代码写起来顺不顺:
@Entry @Component struct AdventurePage { // 当前关卡与得分 @State level: number = 1; @State score: number = 0; @State combo: number = 0; // 连击数 // 当前题目 @State problem: DivisionProblem = generateProblem(1); @State userGuess: string = ''; // 输入框中当前的猜测值 @State resultText: string = ''; // 试商结果提示文案 @State trialResult: TrialResult | null = null; // 答题进度:每关10题 @State correctCount: number = 0; @State totalCount: number = 0; // 题目序号 @State problemIndex: number = 1; // 是否已答对本题,答对后按钮状态要变化 @State solved: boolean = false; }使用@State装饰的变量发生变化时,ArkUI框架会自动刷新绑定了它的UI组件。这里有个经验:不要把整个答案对象放@State里然后改它的内部字段,最好把需要参与渲染的每个字段拆出来单独挂@State。比如用户猜的商,如果用@State userGuess: number,每次输入都要转类型、判空,很麻烦;我用字符串,在提交时才做校验转换,UI层就能直接用TextField的Text属性双向绑定。
另外提一下ArkTS的规范限制。ArkTS在build()里不允许随意写箭头函数作为事件回调(严格模式下降级处理),我都是用普通方法名绑定,比如onClick: () => this.submitGuess()这种写法虽然能在示例代码里跑,但更稳妥的是写成类方法绑定:
private handleSubmit(): void { // 提交试商 }然后Button里写onClick: this.handleSubmit.bind(this)。注意ArkTS严格模式对bind(this)的使用建议直接绑定类方法,这在编译阶段就能避免很多隐式this指向问题。
3.2 主布局:一屏之内完成“输入—反馈—调整”闭环
冒险页的主布局我做了“上下结构”,没有用复杂的多层嵌套。上半部分是题目卡片,下半部分从上到下依次是输入区、反馈区、操作按钮,保证孩子的大拇指能够到所有操作。
build() { Column({ space: 12 }) { // 顶部状态条 Row() { Text(`第${this.problemIndex}题 / 共10题`) Blank() Text(`得分 ${this.score}`) } .width('100%') .padding(16) // 题目卡片 Column() { Text(this.formatProblem(this.problem)) .fontSize(36) .fontWeight(FontWeight.Bold) .fontColor(Color.White) } .width('92%') .padding(20) .backgroundColor('#3D5AFE') .borderRadius(16) .alignItems(HorizontalAlign.Center) // 试商输入区 TextInput({ placeholder: '输入你估的商', text: this.userGuess }) .type(InputType.Number) .maxLength(2) .onChange((value: string) => { this.userGuess = value; }) .margin({ top: 24 }) // 反馈区 if (this.resultText) { Text(this.resultText) .fontSize(20) .fontColor(this.trialResult?.valid ? '#2E7D32' : '#E53935') .margin({ top: 16 }) } // 操作按钮 Row({ space: 12 }) { Button('试商') .enabled(!this.solved && this.userGuess.length > 0) .onClick(() => this.handleSubmit()) Button('下一题') .enabled(this.solved) .onClick(() => this.handleNext()) } .margin({ top: 24 }) // 查看竖式入口 Button('查看完整竖式过程') .enabled(this.solved) .onClick(() => this.showSolution()) .margin({ top: 8 }) } .width('100%') .height('100%') .backgroundColor('#F5F7FA') }这个布局看起来不难,但有几个细节是踩过坑才确定的:
- 题目卡片用
formatProblem(this.problem)来做展示格式化,不要把被除数、除号、除数拆成三个Text再拼一起。拆开的话,动手写的写法排版太死,动态切换题号时你还要处理文本对齐,很不灵活; - TextInput的
maxLength(2)限制一位数或两位数商。有人问过:商会不会是三位数?我们对两位数除法设定商2~9,最多就是两位数(比如99÷11=9,商是一位数;但如果是918÷27=34,商是两位数)。为防止商为十来的情况漏掉,我故意允许2位输入,但是生成题目时商只在2~9里取,这样绝大多数是1位,极少数是2位,孩子不太会触发“10以上的商”,但输入框不拦着; - “试商”按钮的
enabled条件是!this.solved && this.userGuess.length > 0。这保证孩子不能提交空值,也不能在已答对的情况下反复刷反馈。
还有一个布局上的小技巧:题目卡片的背景色我用了深蓝色(#3D5AFE),和白字搭配对比度够;而其余区域都是浅灰白底。这样孩子一眼就能定位到题目,不会在输入框和别的区域里分神。
3.3 提交试商的方法实现:状态更新的关键一步
handleSubmit是整个应用交互的核心,它的职责很纯粹:读输入、调判定、改状态。
private handleSubmit(): void { if (this.userGuess.length === 0 || this.solved) { return; } const guess = parseInt(this.userGuess, 10); if (isNaN(guess) || guess <= 0) { this.resultText = '请输入一个大于0的整数'; return; } // 调用纯函数判定 this.trialResult = evaluateTrial(guess, this.problem); this.resultText = this.buildFeedback(this.trialResult); if (this.trialResult.valid) { this.solved = true; this.combo += 1; // 积分规则:基础10分,连击加分 const gain = 10 + Math.min(this.combo - 1, 5) * 2; this.score += gain; } else { this.combo = 0; } }buildFeedback方法是把TrialResult转成一句给孩子看的话。比如:
private buildFeedback(result: TrialResult): string { if (result.valid) { return `正确!${this.problem.dividend} ÷ ${this.problem.divisor} = ${this.problem.quotient},余数 ${this.problem.remainder}`; } if (result.reason === '偏大') { return `你试的商是 ${parseInt(this.userGuess, 10)},乘积是 ${result.product},比被除数 ${this.problem.dividend} 大了 ${result.product - this.problem.dividend}。试试调小一些。`; } return `你试的商是 ${parseInt(this.userGuess, 10)},余数是 ${result.remainder},比除数 ${this.problem.divisor} 还大,说明还能继续除。试着调大一些。`; }这里有个加分项:反馈文案里我不只告诉“偏大/偏小”,还把具体的差值写出来。数学上,孩子知道“乘积比被除数大18”时,他就能判断“哦,我商多试了1,导致乘积比被除数多了18,而除数正好是18,所以我应该商减1”。这比模糊的“差异”更有操作指向性。
提示:ArkTS中
parseInt第二个参数是进制,别漏写10。我在早期版本漏掉了,测试时发现某些以0开头的输入会被按八进制解析,这种低级bug在儿童应用里非常危险。
3.4 竖式详情页:用Router传递当前题目的快照
竖式详情页我用了router.pushUrl从主页面带数据过去。HarmonyOS的路由传参需要把对象序列化成字符串,所以我在跳转前做一个快照:
private showSolution(): void { // 生成当前题目的完整过程数据 const solution: DivisionSolution = buildSolution(this.problem); // 将数据序列化后传给新页面 const params = JSON.stringify({ solution: solution }); router.pushUrl({ url: 'pages/SolutionPage', params: { solutionJson: params } }); }这里有一个小坑:@State挂载的对象即使内容变了,也不会触发页面刷新,除非重新赋值整个对象。在showSolution里,我buildSolution生成的是一个新对象,这没问题;但在主页面里保存试商记录时我就吃过亏——我用的是this.steps.push(newStep)这种原地修改,页面纹丝不动。后来改成:
this.steps = [...this.steps, newStep];数组重新赋值,@State才感知到变化。触摸过ArkTS开发的人应该都知道这个坑,但还是值得重复一次:别原地改对象/数组的内部字段,要整体替换引用。
竖式详情页的UI相对简单,一个Scroll容器从上到下排列步骤卡片,每个卡片显示“第几步”“试商值”“计算过程”“调商方向”。我用List组件而不是Row,保证步骤多时可滚动。竖式页完整体现了“为何先估大再调小”,对孩子来说是一次完整的过程回放。
4. 冒险机制与激励反馈:怎样让孩子愿意反复练
一个教育应用只有题没有激励,孩子玩不过五分钟。这版应用叫“试商大冒险”,那就要有冒险的样子。我不是游戏策划,但至少能做到:目标明确、反馈即时、失败不惩罚过度。
4.1 关卡与生命值:控制失败的挫败感
设计关卡时我定了一个规矩:每关10题,每题最多试5次,超过5次算“冒险失败”,需要重做本题。但重做不扣分,只重置本题的试商次数。为什么狠不下心扣分?因为这是数学练习,不是竞技游戏。孩子试错了本来就有挫败感,如果系统再扣虚拟金币,很可能直接劝退。
我用attemptCount字段记录单题试商次数,达到5次后强制重置本题并提示“换个思路,再看看除数的首位”。
连击机制反而是最能激励孩子的:连续答对会触发“连击加分”,每道题相互独立但连击延续。我设置了连击加分的上限(最多加5次)。这个数值不能无限膨胀,否则刷两关就能买皮肤,经济系统就崩了。
4.2 音效与动画:被低估的反馈力量
HarmonyOS的动画API在ArkUI里用起来很顺手。我在答对时用一个简单的缩放动画给题目标题做“弹跳”,答错时就只是在输入框旁边抖一下:
// 答对时的缩放效果 @State scaleValue: number = 1; // 在handleSubmit里更新后: this.scaleValue = 1.2; // 用animateTo让缩放平滑过渡 animateTo({ duration: 200, curve: Curve.EaseOut }, () => { this.scaleValue = 1; });然后题目卡片的scale(this.scaleValue)绑定这个状态值就行。UI反馈不是奢侈品,它直接决定孩子愿不愿意继续点“试商”。我试过完全无动画的版本,孩子反馈“就像在做卷子”,加了动画后,他们愿意为了看弹跳动画多试几次。
音效我用的是系统默认的AudioPlayer播放一个极短的自带音效,答对和答错分别一个。音量控制很关键:默认音量一定要低,否则教室里突然响一声会让家长尴尬,孩子也会被吓到。
4.3 数据持久化:用Preferences记住闯关进度
孩子在第三关练到一半退出,下次进来如果又从第一关开始,这个设计可以直接判死刑。我用@ohos.data.preferences保存进度:
import preferences from '@ohos.data.preferences'; const PREF_KEY_LEVEL = 'adventure_level'; const PREF_KEY_SCORE = 'adventure_score'; async function saveProgress(context: Context, level: number, score: number): Promise<void> { const store = await preferences.getPreferences(context, 'divergame'); await store.put(PREF_KEY_LEVEL, level); await store.put(PREF_KEY_SCORE, score); await store.flush(); } async function loadProgress(context: Context): Promise<{ level: number, score: number }> { const store = await preferences.getPreferences(context, 'divergame'); const level = await store.get(PREF_KEY_LEVEL, 1); const score = await store.get(PREF_KEY_SCORE, 0); return { level: level as number, score: score as number }; }调用时注意,普通函数直接传ApplicationContext进来。我会在页面aboutToAppear里读取一次,然后写入状态变量。Preferences的flush()是异步的,一定要await,否则退出应用时数据可能还没落盘。
开发者容易忽略的是,HarmonyOS某些版本上Preferences的存储路径在不同应用实例间不共享。如果你想做多端同步(平板、手机进度同步),得用自己的账号系统,这个应用暂未涉及,但至少本地进度不能丢。
5. 运行与调试:真机踩坑记录
这段时间在模拟器和真机上各跑了一遍,有几条经验单独列出来,省得你重复踩坑。
5.1 模拟器排版正常、真机上字体溢出
第一版我的题目卡片字号是40,在模拟器(MatePad 11寸)上正常,上了手机(P50,约6.5寸)就出现被除数“挤”出卡片的情况。后来发现是字体缩放机制在起作用,模拟器默认显示大小是标准的,真机上有系统字体放大设置。
解决办法有两种,我用了相对稳妥的一种:不锁定最大字号,而是用自适应布局。把题目文本的maxLines(1)和textOverflow({ overflow: TextOverflow.Ellipsis })挂上,同时字号降低到34,卡片宽度用百分比而不是固定px。这样系统字体加大时,文本会缩进,而不是溢出。
5.2 TextInput的软键盘遮挡
安全区内组件上移问题是儿童类应用重点。孩子靠下输入时,软键盘会把输入框和反馈按钮一起顶掉,体验很差。HarmonyOS里可以用expandSafeArea和setKeyboardAvoidMode处理。我的做法是页面根布局外挂keyboardAvoidMode(KeyboardAvoidMode.OFFSET),然后在输入框获取焦点时(onFocus事件里)用animateTo将反馈区和按钮组上移。这其实不算ArkUI标准的“避开模式”,但实测最直接有效。
另一种更简单的偷懒方案是:把整个可操作区固定在页面上半部分,下半部分默认放“查看竖式”按钮。这样键盘弹出时只遮挡下方的静态内容,不干扰输入区。但这个方案的代价是竖式按钮在小屏上可能划出可触区,所以我最终放弃,老老实实做了避让。
5.3 真机上@State数组刷新问题
前面说的“@State只监听引用变化”的坑,在真机上表现得比模拟器更极端。模拟器上原地修改数组偶尔还能刷出来,真机上几乎必现不刷新。而且真机上的打印日志没有额外的报错,就是UI不动。所以我还是强烈建议:只要涉及数组、对象的修改,一律生成新数组/新对象再赋值。
另外,ForEach的key生成器也要注意。如果不给key值生成器,同值不同位置的列表项可能会被错误复用。我给步骤卡片用stepIndex作为key,避免相同内容(比如连续两次试商同一个数)被复用渲染。
ForEach(this.steps, (step: StepRecord) => { StepCard({ step: step }); }, (step: StepRecord) => `${step.stepIndex}-${step.trialValue}-${step.product}`)看起来很简单,但漏了key生成器,你会看到列表项错乱的问题。
5.4 性能与包体:一个小体量应用别过度设计
这个应用核心只有三个页面、一个纯逻辑模块,没有网络请求、没有图片资源、没有动画帧。性能上没有压力。不过我还是做了两个优化:
- 竖式详情页的步骤列表数据不是每次点击都重新生成,而是在题目答对时就把
buildSolution的返回值缓存到成员变量里; - 页面销毁时关闭所有音频播放器释放资源,避免后台静默播放。
如果你只是学习HarmonyOS应用开发,不建议在非必要的页面组件上强行加状态管理框架,ArkTS自带的@State/@Prop足够支撑这种单页面教学应用。
6. 扩展方向:试商大冒险还能怎么长
写完这个版本,我自己也玩了几套题,总体感觉是:作为启蒙和练习工具,已经能让孩子直观理解试商是怎么回事。但如果要长期给孩子用,或者发布到应用市场,我想到几个可延展的方向:
第一,加入“错题本”。把试商次数超过3次的题单独收集,生成针对性的强化练习关卡。这个功能需要持久化更多数据,目前Preferences结构已经预留了扩展位。
第二,把试商过程做成动画。“偏大时商从高位往低位走,偏小时向上回跳”,配合除号和横线的绘制,让孩子像看动画片一样看懂每一步的逻辑。Canvas画竖式是纯ArkUI能力,可以实现。
第三,家长控制台。用@ohos.data.preferences存孩子每次练习的正确率曲线,家长在主页看到近7天的趋势。设计上可以加一个用时间筛选的统计页面。
第四,大屏适配。这个应用在平板上体验其实比手机更好,尤其是竖式展示页,横向空间很充裕。目前在手机和平板都能跑,但平板上没有针对性地使用更大字号和分栏布局,后续可以做响应式适配。
最后分享一个我在设计过程中最有价值的体会:教学类应用的核心,不在于把知识讲得多“正确”,而在于把学习过程中的试错变成可操作的反馈。纸面上的试商,错了就擦,擦完就忘;屏幕上的试商,错了会告诉你“偏大18”,孩子被迫思考“那我是不是该减1”。这个“被迫思考”的过程,正是从“算数”到“数学”的跨越。
如果你也想在HarmonyOS上做一个类似的教学小工具,我的建议是:先从最核心的数学逻辑写起,用纯函数跑通逻辑后,再花时间在ArkUI交互上。别一上来就盯着Button和TextInput怎么写——先把“判定逻辑”写对,UI只是它的表达层。