做一个工具类 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 = false

  inputDigit(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,我很建议你先把骨架想清楚,再去补每一个局部实现。因为一旦骨架稳了,后面的每个功能都会更容易接上来。

下一篇我会继续拆首页与底部导航,看看这个应用是怎么把“直达效率”和“多页面切换”平衡起来的。
 

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐