移动软件开发_实验5:鸿蒙开发入门及计算器开发
| 本实验属于哪门课程? | 中国海洋大学26夏《移动软件开发》 |
| 实验名称? | 实验5:鸿蒙开发入门及计算器开发 |
| 代码仓库地址? | Mobile-Software-Development/lab5 at main · shen5920/Mobile-Software-Development · GitHub |
一、实验内容
1.1 实验目的
-
熟悉 DevEco Studio 的下载安装与鸿蒙应用开发环境搭建,了解 HarmonyOS NEXT 的应用架构;
-
掌握 ArkTS 语言基础与 ArkUI 声明式 UI 开发范式,理解 @State、@Builder、@StorageLink 等状态管理装饰器的用法;
-
实现一款功能完整的计算器应用,包含普通计算器、科学计算器、单位换算、历史记录四大基础模块;
-
在此基础上扩展函数绘图(Canvas 自定义绘制)、日期计算、多类别换算等进阶功能;
-
掌握全屏布局适配、页面路由、自定义弹窗、Canvas 绘图等鸿蒙开发核心技能。
1.2 开发环境准备
本实验使用华为官方提供的 DevEco Studio 进行鸿蒙应用开发,SDK 版本为 HarmonyOS NEXT 6.0.2 (API 22)。DevEco Studio 基于 IntelliJ IDEA 社区版构建,安装时需勾选 HarmonyOS SDK 组件,安装完成后通过 Tools → SDK Manager 确认 API Version 已安装。
创建项目时选择 Empty Ability 模板,语言选择 ArkTS,编译 SDK 选择 API 22,项目命名为 Calculator,存储路径为 F:\deveco。
1.3 项目创建与目录结构
创建完成后,在参考样例项目 Calculator 的基础上进行功能扩展。最终项目的核心目录结构如下:
deveco/ ├── AppScope/ // 应用全局配置 │ └── resources/base/element/ │ └── string.json // 应用名称(已改为"计算器") ├── entry/src/main/ │ ├── ets/ │ │ ├── entryability/ │ │ │ └── EntryAbility.ets // 应用入口,全屏+状态栏避让 │ │ ├── pages/ │ │ │ ├── HomePage.ets // 主页(普通/科学计算器) │ │ │ ├── ConvertPage.ets // 单位换算页 │ │ │ ├── HistoryPage.ets // 历史记录页 │ │ │ ├── OtherPage.ets // "其他"页(绘图/日期/换算入口) │ │ │ ├── DrawPage.ets // 函数绘图页 │ │ │ └── DateCalcPage.ets // 日期计算页 │ │ ├── common/ │ │ │ ├── constants/ │ │ │ │ └── CommonConstants.ets // 常量定义 │ │ │ └── util/ │ │ │ ├── SciCalculateUtil.ets // 科学计算引擎(调度场算法) │ │ │ ├── FuncEvalUtil.ets // 函数表达式求值器(绘图用) │ │ │ ├── HistoryUtil.ets // 历史记录管理 │ │ │ ├── CalculateUtil.ets // 普通计算工具(移植自参考项目) │ │ │ ├── CheckEmptyUtil.ets │ │ │ └── Logger.ets │ │ └── viewmodel/ // 移植自参考项目的视图模型 │ └── resources/base/ │ ├── element/ // color.json / string.json │ ├── media/ // 图片资源 │ └── profile/ │ └── main_pages.json // 页面路由配置 └── build-profile.json5 // 构建配置(targetSdk=22)
页面路由在 main_pages.json 中注册,共六个页面:
{
"src": [
"pages/HomePage",
"pages/ConvertPage",
"pages/HistoryPage",
"pages/OtherPage",
"pages/DrawPage",
"pages/DateCalcPage"
]
}
1.4 应用入口与全屏布局适配
1.4.1 EntryAbility 入口配置
EntryAbility.ets 是应用的入口 Ability,在 onWindowStageCreate 中加载主页 pages/HomePage,并设置窗口全屏布局。全屏后系统状态栏和导航条会覆盖在应用内容之上,因此需要获取避让区域高度并传递给各页面:
windowStage.loadContent('pages/HomePage', (err) => {
let windowClass = windowStage.getMainWindowSync();
// 设置窗口全屏
windowClass.setWindowLayoutFullScreen(true);
// 获取状态栏高度(TYPE_SYSTEM)
let type = window.AvoidAreaType.TYPE_SYSTEM;
let avoidArea = windowClass.getWindowAvoidArea(type);
AppStorage.setOrCreate('topRectHeight', avoidArea.topRect.height);
// 获取导航条高度(TYPE_NAVIGATION_INDICATOR)
type = window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR;
avoidArea = windowClass.getWindowAvoidArea(type);
AppStorage.setOrCreate('bottomRectHeight', avoidArea.bottomRect.height);
// 注册监听,横竖屏切换时动态更新避让区域
windowClass.on('avoidAreaChange', (data) => {
if (data.type === window.AvoidAreaType.TYPE_SYSTEM) {
AppStorage.setOrCreate('topRectHeight', data.area.topRect.height);
}
});
});
解释:
-
setWindowLayoutFullScreen(true)让应用内容延伸到状态栏和导航条区域,实现沉浸式布局; -
getWindowAvoidArea获取系统栏的像素高度,存入AppStorage全局共享; -
on('avoidAreaChange')监听避让区域变化(如旋转屏幕),动态更新高度; -
各页面通过
@StorageLink('topRectHeight')接收高度,用uiContext.px2vp()将像素转为 vp 单位设置 margin。
1.4.2 应用名称中文化
将 AppScope/resources/base/element/string.json 和 entry/src/main/resources/base/element/string.json 中的 app_name 和 label 均从英文改为「计算器」,确保桌面图标和任务栏显示中文名称。
1.5 普通计算器
普通计算器为四列圆形按键布局,包含 AC(清空)、%(百分数)、del(删除)、÷×−+ 四则运算、00/0/. 数字键和橙色等号。表达式显示区支持点击定位光标,在任意位置插入或删除字符。
按键数据通过二维数组定义,每一项包含 value(显示文字)和 type(类型:num/op/action/equal):
private normalKeys: KeyButton[][] = [
[{ value: 'AC', type: 'action' }, { value: '%', type: 'action' },
{ value: 'del', type: 'action' }, { value: '÷', type: 'op' }],
[{ value: '7', type: 'num' }, { value: '8', type: 'num' },
{ value: '9', type: 'num' }, { value: '×', type: 'op' }],
// ... 其余行
];
表达式编辑采用 TextInput 组件配合 TextInputController 实现光标定位。关键是设置 enableKeyboardOnFocus(false) 禁用系统软键盘,所有输入通过自定义按键完成:
TextInput({ text: $$this.expression, placeholder: '0', controller: this.inputController })
.enableKeyboardOnFocus(false)
.onTextSelectionChange((start: number, end: number) => {
this.caretPos = start; // 记录光标位置
})
在光标处插入字符的核心方法:
private insertAtCaret(text: string): void {
let before = this.expression.substring(0, this.caretPos);
let after = this.expression.substring(this.caretPos);
this.expression = before + text + after;
this.caretPos = before.length + text.length;
this.inputController.caretPosition(this.caretPos); // 同步光标
}
1.6 科学计算器
1.6.1 计算引擎设计
科学计算器的核心是独立封装的 SciCalculateUtil 类,采用调度场算法(Shunting-yard Algorithm)将中缀表达式转为逆波兰表达式(RPN)后求值。支持的运算符包括 + − × ÷ ^(幂)!(阶乘)%(百分数)mod(取模),支持的函数包括 sin/cos/tan 及其反函数 asin/acos/atan、双曲函数 sinh/cosh/tanh、log/ln/sqrt/abs/exp/floor/ceil/frac,常量 π 和 e。
求值入口 evaluate 方法的处理流程:
evaluate(expr: string): string {
// 1. 自动补全未闭合的左括号(如 sin(30 → sin(30))
let balanced = this.balanceParens(expr);
// 2. 替换常量 π 和独立的 e(单词边界避免匹配函数名中的e)
let processed = balanced.replace(/π/g, '(' + Math.PI + ')')
.replace(/\be\b/g, '(' + Math.E + ')');
// 3. 分词 → 4. 中缀转逆波兰 → 5. 逆波兰求值
let tokens = this.tokenize(processed);
let rpn = this.toRPN(tokens);
let result = this.evalRPN(rpn);
return this.formatResult(result);
}
balanceParens 自动补全括号是关键容错设计——用户输入 sin(30 后直接按等号,引擎会自动补为 sin(30) 再计算,避免因漏按右括号导致全部显示"错误"。
1.6.2 科学计算器界面
科学计算器严格参考参考应用设计,自上而下包含四个区域:
-
状态行:DEG/RAD(点击切换角度制)、F-E、2nd/HYP/M 状态指示灯;
-
内存键行:MC(清内存)、MR(读入)、M+(加)、M-(减)、MS(存)、M⌄(读出并清空);
-
下拉菜单行:「三角学 ▼」和「函数 ▼」两个可展开菜单;
-
主键盘:七行五列圆形按键,等号为青色。
三角学下拉展开后显示 2nd / sin cos tan / hyp sec csc cot 八个键,其中 2nd 开启后 sin→asin、cos→acos、tan→atan;hyp 开启后 sin→sinh、cos→cosh、tan→tanh;两者同时开启则为 asinh/acosh/atanh。sec/csc/cot 分别转换为 1/cos(、1/sin(、1/tan(。
函数下拉展开后显示 |x|(绝对值)、[x](向下取整 floor)、⌈x⌉(向上取整 ceil)、{x}(小数部分 frac)、rand(随机数)。
内存功能的实现:
private onMemKey(label: string): void {
if (label === 'MC') {
this.memory = 0;
} else if (label === 'MR') {
this.insertAtCaret(this.memory.toString());
} else if (label === 'M+') {
let val = Number(SciCalculateUtil.evaluate(this.expression));
if (!isNaN(val)) this.memory += val;
} else if (label === 'MS') {
let val = Number(SciCalculateUtil.evaluate(this.expression));
if (!isNaN(val)) this.memory = val;
}
// M-、M⌄ 类似
}
1.7 单位换算
单位换算模块包含十个分类:汇率、长度、面积、体积、重量、温度、速度、压强、功率、进制。入口页为三列网格,点击分类进入详情页。
1.7.1 换算算法
除温度和进制外,其余分类均采用线性因子换算:每个单位定义一个 factor(1 单位 = factor 个基准单位),换算公式为 结果 = 输入值 × fromFactor ÷ toFactor。以长度为例,基准单位为米:
private lengthUnits: UnitDef[] = [
{ name: '毫米', symbol: 'mm', factor: 0.001 },
{ name: '厘米', symbol: 'cm', factor: 0.01 },
{ name: '米', symbol: 'm', factor: 1 },
{ name: '千米', symbol: 'km', factor: 1000 },
{ name: '英里', symbol: 'mi', factor: 1609.344 },
// ... 共12种单位
];
温度为非线性换算,单独处理——先将输入值转为摄氏度,再从摄氏度转为目标单位:
private convertTemp(val: number, srcIdx: number, dstIdx: number): string {
let c: number;
if (srcIdx === 0) c = val; // 摄氏度
else if (srcIdx === 1) c = (val - 32) * 5 / 9; // 华氏度
else c = val - 273.15; // 开尔文
let result: number;
if (dstIdx === 0) result = c;
else if (dstIdx === 1) result = c * 9 / 5 + 32;
else result = c + 273.15;
return this.formatNum(result);
}
进制换算独立分支,使用 parseInt(input, fromBase).toString(toBase) 实现二/八/十/十六进制互转,并根据当前输入方进制自动置灰非法按键(如二进制下 A-F 键不可点)。
1.7.2 双向输入与单位选择
详情页采用双卡片设计,上方卡片和下方卡片各显示一个单位的数值。点击任意卡片即将其设为输入方(橙色高亮),另一方实时反算结果。中间的 ⇅ 按钮可交换两个单位。
单位选择弃用了系统 ActionSheet(样式简陋),改为自定义全屏选择面板,顶部支持搜索框(按中文名/英文名/缩写过滤),下方按首字母 A-Z 分组展示,当前选中项右侧显示青色对勾:
private getGroupedUnits(): Array<UnitGroup> {
// 1. 按搜索词过滤
let filtered = units.filter(u =>
u.name.includes(search) || u.symbol.toLowerCase().includes(search)
|| (u.enName && u.enName.toLowerCase().includes(search))
);
// 2. 按英文名首字母分组(汇率有23种货币,按A-Z排序)
let groupMap: Map<string, UnitDef[]> = new Map();
for (let u of filtered) {
let letter = u.enName ? u.enName.charAt(0).toUpperCase() : u.name.charAt(0);
if (!groupMap.has(letter)) groupMap.set(letter, []);
groupMap.get(letter).push(u);
}
// 3. 按字母排序返回
}
汇率分类收录了 23 种主流货币(AUD、BRL、CAD、CHF、CNY、DKK、EUR、GBP、HKD、INR、JPY、KRW、MXN、MYR、NOK、NZD、RUB、SEK、SGD、THB、TRY、USD、ZAR),以人民币为基准采用固定汇率。
1.8 历史记录
历史记录基于 AppStorage 实现全局存储,最多保留 50 条。每次按等号计算成功后,将表达式、结果、时间戳存入历史列表。历史记录页支持单条左滑删除和一键清空,每条记录以白色卡片展示表达式、结果和日期。
class HistoryUtil {
private static readonly MAX = 50;
static addHistory(expression: string, result: string): void {
let list = AppStorage.get<HistoryItem[]>('history_list') || [];
list.unshift({ expression, result, time: new Date().toLocaleString() });
if (list.length > this.MAX) list = list.slice(0, this.MAX);
AppStorage.setOrCreate('history_list', list);
}
}
1.9 函数绘图
函数绘图页包含两个视图:坐标视图和函数编辑视图,通过右上角「坐标」「fx」按钮切换。
1.9.1 坐标视图(Canvas 绘制)
坐标视图使用 Canvas 组件自定义绘制,包含网格线、X/Y 坐标轴、箭头、刻度标注和函数曲线。支持双指缩放、拖动平移,右下角三个圆形按钮分别为放大(+)、缩小(−)、回到原点(◎)。
绘制核心逻辑:
private redraw(): void {
let ctx = this.context;
let cx = w / 2 + this.offsetX; // 原点X坐标(像素)
let cy = h / 2 + this.offsetY; // 原点Y坐标(像素)
let sc = this.zoomScale; // 每单位像素数
// 绘制网格
ctx.strokeStyle = '#EAEAEA';
let step = this.niceStep(48 / sc); // 自适应刻度间距
// 绘制坐标轴和箭头
// 逐像素采样绘制函数曲线
for (let px = 0; px < w; px++) {
let x = (px - cx) / sc;
let y = FuncEvalUtil.eval(expr, x); // 函数求值
let py = cy - y * sc;
// 渐近线跳变时抬笔断开
}
}
函数求值由独立的 FuncEvalUtil 完成,支持隐式乘法(2x → 2*x)、一元负号、右结合幂运算、sin/cos/tan/asin/acos/atan/log/ln/sqrt/abs/exp/floor/ceil 等函数。
1.9.2 函数编辑视图
函数编辑视图支持添加多个函数,每个函数可独立设置曲线颜色(6 色调色板)和粗细(细/中/粗),也可删除。函数键盘顶部配备「三角学」「不等式」「函数」三个下拉菜单,与科学计算器的下拉菜单一致。
函数列表使用 ForEach 渲染,关键是 keyGenerator 必须包含表达式、颜色、粗细等所有变化属性,否则修改函数后不会触发重新渲染:
ForEach(this.funcs, (item: FuncItem) => {
this.buildFuncRow(item);
}, (item: FuncItem) => item.id + '_' + item.expr + '_' + item.color + '_' + item.width);
由于 Canvas 是非响应式组件,每次修改函数后必须手动调用 redraw() 重绘画布。
1.10 日期计算
日期计算页包含两个功能:日期间隔和日期推算。
日期间隔通过 DatePickerDialog.show() 弹出系统日期选择器,选择两个日期后自动计算相差天数。日期推算支持「加/减」切换按钮(解决了 TextInput type=Number 无法输入负号的问题),输入纯数字后计算目标日期,并提供 ±7 天、±30 天快捷键。
// 日期推算:先选加减方向,再输入纯天数
@State addOrMinus: number = 1; // 1=加, -1=减
private calcResult(): void {
let base = new Date(this.startDate);
let n = Number(this.days) || 0;
base.setDate(base.getDate() + n * this.addOrMinus);
this.resultDate = base.toLocaleDateString();
}
1.11 "其他"页
主页右上角第二个图标进入「其他」页,包含三个加粗靠左的分区入口:绘图(∿)、日期计算(日)、换算(⇄),点击分别跳转到对应功能页。
1.12 运行效果汇总
应用已在 DevEco Studio 模拟器中完成编译运行,所有六个页面均可正常访问,普通计算器、科学计算器、单位换算、历史记录、函数绘图、日期计算六大模块功能完整。编译环境为 HarmonyOS NEXT 6.0.2 (API 22),构建工具为 hvigor。
二、问题总结与体会
遇到的问题及解决:
-
@Builder 方法内出现编译错误 10905209/10905204:在
@Builder装饰的方法中写了let变量声明、bgColor = '#FF9500'赋值语句和return语句,编译器报「Only UI component syntax can be written here」。根本原因是 ArkUI 的@Builder方法体内只允许编写 UI 组件语法,不允许任何普通逻辑语句。解决方式是将所有样式计算、变量声明抽取为普通方法(如getKeyBgColor()、getKeyTextColor()),在@Builder内仅调用这些方法的返回值。 -
右上角三个图标点击无反应且与状态栏重叠:应用设置了全屏布局
setWindowLayoutFullScreen(true),但页面顶部没有做状态栏避让,导致图标被压在系统状态栏下方,触摸事件被状态栏拦截。解决方式是在EntryAbility中通过getWindowAvoidArea获取状态栏像素高度并存入AppStorage,各页面用@StorageLink接收后通过uiContext.px2vp()转为 vp 单位设置顶部 margin。同时需注意:DevEco Studio 的 Previewer 预览器不支持router页面跳转,必须在模拟器中运行才能测试页面跳转。 -
科学计算器所有运算都显示"错误":用户输入
sin(30后直接按等号(漏按右括号),未闭合的左括号在逆波兰求值时被当作运算符处理,返回 NaN。解决方式是在求值入口增加balanceParens方法,统计左括号多于右括号的数量,自动在表达式末尾补全)。 -
换算页自定义数字键盘只显示两行:键盘的每一行使用了
layoutWeight(1)弹性高度,当父容器高度不足时行被压缩到几乎不可见。解决方式是将键盘行改为固定高度72vp,保证四行或五行按键完整显示。 -
函数绘图页输入函数后曲线不实时更新:原因有二。一是
ForEach的keyGenerator仅使用函数id,表达式/颜色/粗细变化时 key 不变,不触发列表重新渲染;二是Canvas是非响应式组件,数据变化后不会自动重绘。解决方式是将 key 改为id + '_' + expr + '_' + color + '_' + width,并在所有修改函数的操作(添加、删除、改颜色、改粗细、键盘输入)末尾主动调用redraw()。 -
日期推算输入框无法输入负号:
TextInput的type设为InputType.Number后,系统不接受+/-符号字符,导致用户输入-7只显示7且始终按加法计算。解决方式是增加「加/减」切换按钮,输入框仅接受纯数字,计算时根据切换方向决定n * addOrMinus。 -
计算器表达式只能在末尾追加/删除:初期使用
Text组件显示表达式,无法定位光标。解决方式是替换为TextInput,设置enableKeyboardOnFocus(false)禁用系统软键盘,通过TextInputController.caretPosition()管理光标位置,onTextSelectionChange监听用户点击移动光标,自定义键盘在caretPos位置插入或删除字符。注意该回调的正确名称是onTextSelectionChange而非onSelectionChange。 -
ActionSheet API 兼容性问题:初期使用
uiContext.showActionMenu报MenuElement类型不存在,改用全局ActionSheet.show({title, message, sheets})后又报message属性缺失——当前 SDK 版本中message为必填参数,且SheetInfo的属性名是title而非text。最终因样式简陋,弃用系统 ActionSheet,改为自定义全屏选择面板。 -
成员变量名
scale导致编译错误:在绘图页声明@State scale: number时报「Type 'number' is not assignable to type '{ (value: ScaleOptions): CommonAttribute }'」,原因是scale是CustomComponent基类的保留属性名。解决方式是改名为zoomScale。 -
科学计算器最后一行按键显示不全:增加状态行、内存键行、下拉菜单后,主键盘仍使用固定 54vp 按键且未设弹性高度,七行按键超出可用空间被截断。解决方式是将主键盘包入
layoutWeight(1)容器自动适配,按键尺寸从 54vp 缩至 48vp,同时压缩各辅助行高度。
收获与体会:
通过本次实验,我系统掌握了 HarmonyOS 声明式 UI 开发的完整流程,从 DevEco Studio 环境搭建、ArkTS 语言基础,到 ArkUI 状态管理、页面路由、Canvas 自定义绘制、全屏布局适配,最终完成了一款包含六大模块的功能完整的计算器应用。
最大的体会是声明式开发中"数据驱动视图"的思维转变——所有界面变化都应通过 @State 状态变量驱动,而非直接操作组件实例。这在 Canvas 这类非响应式组件上需要特别注意,必须在数据变化后主动调用重绘方法。同时,@Builder 的语法约束(只能写 UI)、ForEach 的 keyGenerator 设计、AppStorage 全局状态共享等机制,都需要在实践中反复踩坑才能深刻理解。
另一个重要收获是计算引擎与 UI 分离的架构设计——SciCalculateUtil(科学计算器)和 FuncEvalUtil(函数绘图)各自独立封装,通过纯函数接口与 UI 层交互,使得两个模块可以复用不同的求值策略而互不干扰,也便于后续扩展新功能。在开发过程中,我还学会了用 Node.js 将 ArkTS 核心逻辑转为 JS 进行单元测试,在不启动模拟器的情况下快速验证算法正确性,这大大提高了调试效率。
更多推荐



所有评论(0)