【全能计算助手实战系列 01】从 0 到 1 设计一个 HarmonyOS 全能计算助手
做一个工具类 App,看起来像是很基础的练手项目,但真正动手之后很快就会遇到一个问题:如果它只会加减乘除,用户不会留下来;如果它想覆盖更多场景,页面、功能、状态、存储和导航复杂度又会迅速上升。
这也是我这次做“全能计算助手”时的核心出发点。我不想只做一个能算结果的页面,而是想做一个真正能在日常生活里被反复打开的工具应用。它既要覆盖科学计算,也要补齐房贷、理财、日期、税费、BMI、油耗、温度换算这些高频场景;既要保证输入顺手,也要保证结果能保存、设置能记住、页面能在不同设备宽度下稳定展示。
这篇文章先不急着讲某一个计算公式,而是先把整个应用的骨架讲清楚。把骨架看明白,后面再拆首页、工具页、仓储和设置层,才不会越写越乱。
一、为什么全能计算助手不能只做“一个页面”
很多初学者做这类工具项目时,第一反应是直接写一个大页面,把按钮、输入框和公式都堆进去。这样短期能看到结果,但中后期几乎一定会遇到三个问题:
1. 功能一多,页面会迅速失控。
2. 每个计算工具的输入输出形式都不同,复用困难。
3. 用户需要的不是一次性演示,而是可重复使用的完整体验。
这个项目一开始就不是“单功能演示”,而是一个真正面向使用场景的工具集合。当前应用里,首页、工具页、历史页、设置页已经形成了完整链路:
- 首页:承担功能入口和快速跳转职责。
- 工具页:承载科学计算和多种专项工具。
- 历史页:保存用户曾经算过的表达式和结果。
- 设置页:控制小数位、千分位、提示音、振动反馈等体验参数。
换句话说,这已经不是“计算器页面”,而是一个小型工具产品。
二、先看入口:应用启动后到底落到哪里
架构文章如果只讲概念,很容易显得虚。先看当前项目真实的入口代码,在 [`MainLayout.ets`](/d:/Lesson/calcs/entry/src/main/ets/pages/MainLayout.ets:1) 里,应用启动后并没有停在一个空白壳页,而是立即把路由切到首页:
import { PageRouter } from '../common/PageRouter'
@Entry
@Component
struct MainLayout {
aboutToAppear() {
PageRouter.replace(this, 'pages/Index')
}
}
这个入口设计背后其实传达了一个非常明确的产品判断:
- 打开应用就进入可操作页面
- 不额外插启动介绍页
- 不让用户先看说明,再找功能
对工具应用来说,这种决策比很多视觉装饰都更重要。因为用户第一次感知到的不是“设计语言”,而是“我能不能马上开始算”。
三、首页结构为什么采用“直达卡片”而不是传统列表
首页在 [`Index.ets`](/d:/Lesson/calcs/entry/src/main/ets/pages/Index.ets:1) 里实现,它的设计非常明确:让用户一打开应用就能看到全部工具,并且尽快进入目标功能。
首页结构大致分成两段:
- 头部区域:显示“首页直达”和一个跳转设置页的按钮。
- 工具区域:用网格卡片展示全部工具。
真实代码里,这个页面没有做多余包装,而是直接把“标题区 + 工具区 + 导航”拼成主界面:
Scroll() {
Column() {
this.HeaderSection()
this.ToolsSection()
}
.width('100%')
.constraintSize({ maxWidth: this.contentMaxWidth() })
.alignSelf(ItemAlign.Center)
.padding({ top: 14, bottom: 22 })
}
这个设计的好处很直
第一,信息密度更高。
相比纵向列表,网格卡片能在同一屏容纳更多工具入口。
第二,识别成本更低。
卡片里同时展示标题、副标题和强调色,比纯文本列表更容易让用户快速定位目标工具。
第三,更适合平板和横屏。
首页会根据宽度动态切换 2 列、3 列或 4 列,这一点后面在自适应章节会展开讲。
如果你正在做一个“工具型应用”,首页其实不是展示品牌故事的地方,而是帮助用户尽快完成任务的地方。这个项目首页的价值就是“直达”。
四、工具为什么不能散着长,必须先有注册表
工具集合项目如果没有一份统一的工具定义表,后面一定会越来越乱。
在这个应用里,工具元数据集中定义在 [`ToolRegistry.ets`](/d:/Lesson/calcs/entry/src/main/ets/common/ToolRegistry.ets:1)。每个工具都包含这些信息:
`id`
`title`
`subtitle`
`icon`
`accentColor`
`tabLabel`
`keywords`
`openInToolsPage`
`favoriteEligible`
看一段真实定义就很清楚了:
const TOOL_DEFINITIONS: ToolDefinition[] = [
{
id: 'scientific',
title: '科学计算',
subtitle: '函数、幂运算与常量',
icon: ICON_CALCULATOR,
accentColor: '#2F80ED',
tabLabel: '科学',
keywords: ['scientific', 'function', 'power', 'constant', '科学', '函数', '幂', '常量', '计算器'],
openInToolsPage: true,
favoriteEligible: true
},
{
id: 'mortgage',
title: '房贷计算',
subtitle: '月供、总利息与总还款',
icon: ICON_HOUSE,
accentColor: '#637BFF',
tabLabel: '房贷',
keywords: ['loan', 'mortgage', 'payment', 'interest', '房贷', '贷款', '月供', '利息'],
openInToolsPage: true,
favoriteEligible: true
}
]
这看上去像是“多写了一个文件”,其实是在给整个项目降成本。
因为只要有了这份注册表,很多逻辑都不需要散落在各个页面里:
- 首页显示哪些工具
- 工具页 tab 顺序怎么排
- 搜索时按哪些关键词命中
- 哪些工具允许被收藏
后面你如果还想接入“推荐工具”或者“最近常用”,也都可以继续围绕这份注册表演进。
五、工具页为什么是整个项目的核心页面
真正复杂的地方在 [`ToolsPage.ets`](/d:/Lesson/calcs/entry/src/main/ets/pages/ToolsPage.ets:1)。
这个页面不是只放一个工具,而是把多种计算场景统一在一个壳内切换:
- 科学计算
- 进制转换
- 房贷计算
- 单位换算
- 理财计算
- 日期计算
- 税费计算
- BMI 体脂指数
- 油耗计算
- 温度换算
从状态定义也能看出来,这一页实际上承担了整套工具能力的聚合职责:
@State private mode: string = 'scientific'
@State private display: string = '0'
@State private mortgageAmount: string = '100'
@State private financePrincipal: string = '10000'
@State private startDate: string = '2024-01-01'
@State private bmiHeight: string = '170'
@State private fuelDistance: string = '300'
@State private tempCelsius: string = '25'
这类页面最容易犯的错误,是为每个工具单独建一套完全不同的结构,最后导致代码大量重复。当前项目没有这么做,而是采取了“统一框架 + 局部面板”的方式:
- 顶部统一使用 `ToolPageHeader`
- 工具切换统一使用 `ToolTabsBar`
- 输入统一尽量复用 `ToolInputField`
- 结果统一收敛到 `ResultValueBlock`、`ResultLineRow`、`WhiteMetricBlock`
- 保存动作统一收敛到 `PrimaryActionButton`
这套做法的价值,在于你可以在一个大页面内管理多个工具,但又不会每多一个功能就把代码复杂度翻倍。
六、为什么计算逻辑不能直接写在按钮点击事件里
做计算类项目时,最常见的坑之一是把所有公式都直接写在页面事件里。这样短期很快,但长期非常难维护。
这个应用里,计算相关逻辑被拆成了两块:
- [`CalculatorService.ets`](/d:/Lesson/calcs/entry/src/main/ets/services/CalculatorService.ets:1):负责科学计算器的状态流转、按键输入、表达式展示和结果计算。
- [`ToolCalculators.ets`](/d:/Lesson/calcs/entry/src/main/ets/common/ToolCalculators.ets:1):负责房贷、税费、BMI、日期、油耗、温度等工具型计算公式。
拿 `CalculatorService` 来说,它明显不是“算一下就完”的函数,而是一个持续维护输入状态的服务:
export class CalculatorService {
private currentDisplay: string = '0'
private previousValue: string = ''
private operation: string = ''
private expressionDisplay: string = ''
private waitingForNewValue: boolean = falseinputDigit(digit: string): void { ... }
inputDecimal(): void { ... }
performOperation(nextOperation: string): void { ... }
calculateResult(): void { ... }
}
这样的拆分非常重要。
因为科学计算器和“表单型工具”不是一回事:
- 科学计算器更像一个状态机,要处理输入过程。
- 税费、BMI、日期这类工具更像纯函数,给输入就返回结果。
如果把这两种逻辑混在一起,你后面既难调试,也难测试。
七、为什么历史记录和设置必须做本地持久化
一个工具 App 想从“演示项目”变成“可用产品”,有两个能力非常关键:
- 用户上次的设置要记住
- 用户算过的结果要能回看
本项目分别用两个仓储完成这件事:
- [`HistoryRepository.ets`](/d:/Lesson/calcs/entry/src/main/ets/repositories/HistoryRepository.ets:1)
- [`SettingsRepository.ets`](/d:/Lesson/calcs/entry/src/main/ets/repositories/SettingsRepository.ets:1)
它们都基于 `@ohos.data.preferences` 做本地存储,但职责不同。
历史仓储的核心动作是“写入并裁剪历史”:
async addHistory(expression: string, result: string): Promise<void> {
const history = await this.getAllHistory()
const newItem: HistoryItem = {
id: Date.now(),
expression: expression,
result: result,
timestamp: Date.now()
}
history.unshift(newItem)
if (history.length > 100) {
history.splice(100)
}
await this.preferences?.put(this.HISTORY_KEY, JSON.stringify(history))
await this.preferences?.flush()
}
设置仓储的核心动作则是“读取默认值并合并落盘配置”:
async getSettings(): Promise<Settings> {
const settingsStr = await this.preferences?.get(this.SETTINGS_KEY, '')
if (settingsStr) {
const parsedSettings = JSON.parse(settingsStr as string) as Settings
return {
darkMode: parsedSettings.darkMode ?? this.defaultSettings.darkMode,
soundEnabled: parsedSettings.soundEnabled ?? this.defaultSettings.soundEnabled,
vibrationEnabled: parsedSettings.vibrationEnabled ?? this.defaultSettings.vibrationEnabled,
decimalPlaces: parsedSettings.decimalPlaces ?? this.defaultSettings.decimalPlaces,
separatorEnabled: parsedSettings.separatorEnabled ?? this.defaultSettings.separatorEnabled,
language: parsedSettings.language ?? this.defaultSettings.language
}
}
return this.cloneSettings(this.defaultSettings)
}
这类项目不一定一开始就需要数据库,但一定需要清晰的持久化边界。把存储逻辑放进 Repository,而不是直接写进页面,是后面继续扩展的基础。
八、为什么我把“体验设置”也当成项目主体来做
很多人做工具项目时,会把设置页看成附属页,只随便放几个开关。但这个项目里,设置不是摆设,而是和计算体验直接相关的核心能力。
当前设置页包含:
- 千分位分隔符开关
- 小数位数调整
- 提示音开关
- 振动反馈开关
- 清空历史记录
- 重置设置
这背后有一个很实际的产品判断:
同一个公式,不同用户对结果展示的要求并不一样。
比如有人希望 `1000000` 显示成 `1,000,000`,有人希望尽量简洁;有人做理财计算时在意两位小数,有人做工程计算时希望保留更多位数。把这些能力做成可配置项,应用才真正从“我写给自己看”变成“别人也能顺手用”。
九、调试时我会先看哪几个点
第一章虽然偏架构,但并不意味着它不需要调试思路。相反,越是总览篇,越适合把关键观察点先交代清楚。
我自己在这个项目里最常用的检查顺序是:
1. 应用启动后是否直接进入首页
2. 首页工具数量是否与 ToolRegistry 一致
3. 点击首页卡片后是否进入 ToolsPage
4. ToolsPage 当前 mode 是否与 selectedTool 一致
5. 历史记录和设置仓储是否完成 init()
如果你要快速验证首页入口和工具注册表是否打通,至少要手动过一遍下面这条链路:
```text
启动应用 -> 首页展示全部工具 -> 点击任意工具卡片 -> 跳到工具页 -> 顶部标题与当前工具一致
```
这条链路一旦不通,后面写再多公式都没有意义。
十、开发过程中我实际踩到的几个方向性问题
在这个项目里,我最早并不是一下子就把结构想清楚的,而是在不断拆分中慢慢确认了几个原则:
1. 页面不是功能的唯一边界
不要因为某个功能长得不一样,就立刻新建一个页面。很多工具其实更适合共享一个统一壳层。
2. 公式不是业务的全部
计算公式写对只是基础,输入体验、结果展示、保存能力同样重要。
3. 工具项目也需要内容组织
当工具数量超过 5 个以后,就必须考虑首页入口、分类方式、搜索关键词和后续推荐逻辑。
4. 越早抽公共层,后面越轻松
像 `ToolRegistry`、`AdaptiveLayout`、`NumberFormatter`、`PageRouter` 这类公共层,越早抽出来,后面每加一个功能的成本就越低。
十一、这篇文章给后续拆解打下了什么基础
这一篇解决的不是“某个函数怎么写”,而是“整个项目为什么这样组织”。
后面继续拆解时,我会优先按这个顺序往下展开:
1. 首页和导航怎么搭骨架
2. 多工具页怎么组织状态
3. 科学计算和工具型计算怎么分层
4. 历史记录和设置怎么持久化
5. 自适应布局和交互体验怎么做
也就是说,后面的每一篇都不是孤立文章,而是对这一篇架构总览的细化。
十二、验收清单
- 能说清首页、工具页、历史页、设置页各自职责
- 能解释为什么要抽 `ToolRegistry`
- 能解释为什么要拆 `CalculatorService` 与 `ToolCalculators`
- 能理解 Repository 在本地存储中的角色
- 能手动走通“首页卡片 -> 工具页 -> 当前工具模式”这条主链路
- 能看出这个项目已经具备“工具产品”而不只是“演示页面”的雏形
十三、总结
这个 HarmonyOS 全能计算助手项目最有价值的地方,不是它支持了多少公式,而是它从一开始就按“真实可用应用”的思路来组织结构:入口清晰、工具聚合、状态分层、存储独立、设置可记忆、布局可适配。
如果你也在做一个看似简单、但实际会不断长功能的 App,我很建议你先把骨架想清楚,再去补每一个局部实现。因为一旦骨架稳了,后面的每个功能都会更容易接上来。
下一篇我会继续拆首页与底部导航,看看这个应用是怎么把“直达效率”和“多页面切换”平衡起来的。
更多推荐



所有评论(0)