OUC Mobile Development 实验五:鸿蒙开发入门及计算器开发
| 项目 | 内容 |
|---|---|
| 本实验属于哪门课程? | 中国海洋大学26夏《移动软件开发》 |
| 实验名称? | 实验5:鸿蒙开发入门及计算器开发 |
一、实验内容
1. 实验目标与开发环境
本实验使用 DevEco Studio 开发鸿蒙计算器应用,采用 ArkTS 编写业务逻辑,通过 ArkUI 声明式组件构建界面。实验围绕一个完整应用展开,学习工程结构、应用入口、组件布局、事件处理、状态管理、页面跳转和本地持久化,并将表达式求值与函数绘图算法应用到移动端界面中。
项目名称为 Lab05,运行平台为 HarmonyOS,entry 模块配置的设备类型为 phone。项目配置中的 targetSdkVersion 为 26.0.0,compatibleSdkVersion 为 6.1.1(24),具体运行环境需与本机安装的 SDK 和模拟器对应。项目构建使用 Hvigor,依赖通过 oh-package.json5 等文件管理。本次运行截图中的模拟设备为 Pura X View 7.0.0(26.0.0),应用在 DevEco Studio 模拟器中展示。
实验内容分为基础功能和扩展功能。基础功能包括数字输入、四则运算、小数、百分数、正负号、退格、清空及结果显示;扩展功能包括科学计算、历史记录、深浅主题切换和交互式函数绘图。以下代码均摘自项目当前源码,代码块用于说明对应实现,需要结合原文件中的类、状态和依赖阅读。
2. 工程结构与页面入口
(1)工程组织
AppScope 保存应用级配置和资源,entry/src/main 保存业务模块的配置、ArkTS 代码及页面资源。为了避免将计算、输入和界面全部集中在同一个页面中,项目按职责划分为页面、视图模型、计算工具和绘图模型。
| 文件或模块 | 主要职责 |
|---|---|
| EntryAbility.ets | 处理应用生命周期,在窗口创建后加载首页 |
| CalculatorPage.ets | 布局计算器页面,处理模式、主题、按键及历史面板交互 |
| CalculatorViewModel.ets | 管理输入 token、预览结果、连续计算和历史记录 |
| ScientificCalculateUtil.ets | 统一求解基础四则与科学表达式,返回计算值或错误信息 |
| CalculateUtil.ets | 提供数字精度、科学计数法和千分位格式化,并保留基础求值方法 |
| CommonConstants.ets | 集中定义按键、运算符、精度、历史上限和存储键 |
| GraphPage.ets | 绘制坐标系与曲线,处理触摸、函数列表和参数滑块 |
| ExpressionParser.ets | 将手动输入的绘图表达式解析为 token 和后缀表达式 |
| GraphFunction.ets | 保存函数类型、颜色、可见状态和参数,计算函数值 |
| CoordinateSystem.ets | 统一进行数学坐标与屏幕坐标转换,管理平移和缩放 |
界面与计算之间的数据流为:用户点击按键,页面调用 ViewModel 更新输入,计算工具求值,页面再读取模型状态更新显示。这样增加新的科学函数时,主要修改按键映射和计算逻辑,无需重写整个显示界面。
(2)加载计算器首页
应用入口为 EntryAbility,窗口创建时使用 loadContent() 加载 pages/CalculatorPage。加载失败时记录错误日志,便于定位页面路径或资源配置问题。
代码摘自:EntryAbility.ets(节选)。
windowStage.loadContent('pages/CalculatorPage', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
return;
}
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
});
main_pages.json 注册了 CalculatorPage、Index 和 GraphPage。Index 中保留了初始示例页面,实际启动页面由 EntryAbility 指向 CalculatorPage。图1中编辑器打开的是 Index.ets,但模拟器中运行的是计算器首页,体现了工程文件与应用启动入口的区别。函数绘图作为独立页面,通过路由从计算器菜单进入。
3. 计算器界面与按键交互
(1)基础键盘布局
计算器使用纵向布局,顶部放置历史、模式和主题按钮,中间为表达式与结果区域,底部为键盘。基础键盘按 4 列、5 行排列,使用二维数组保存每行的按键,再通过 ForEach 生成组件,统一控制间距、宽度和外观。
代码摘自:CalculatorPage.ets(节选)。
private baseRows: Array<Array<string>> = [
[CommonConstants.DEL, CommonConstants.CLEAR, CommonConstants.PERCENT, CommonConstants.DIV],
['7', '8', '9', CommonConstants.MUL],
['4', '5', '6', CommonConstants.MIN],
['1', '2', '3', CommonConstants.ADD],
[CommonConstants.KEY_SIGN, '0', CommonConstants.DOT, CommonConstants.EQU]
];
数字键、功能键和运算符使用不同配色,运算符统一采用橙色。基础模式按键高度为 68 vp,科学模式缩小为 48 vp,为上方科学键盘腾出空间。相同行中的按键使用 layoutWeight(1) 分配宽度。
(2)可复用按键组件与触摸反馈
baseKey() 使用 @Builder 封装单个按键,将标签、字体、背景色和事件处理集中在一起。按下时记录 pressedKeyId,并通过透明度与缩放产生反馈;松开或取消触摸时清空状态,点击后将业务按键交给 onKeyClick()。
代码摘自:CalculatorPage.ets(节选)。
@Builder
baseKey(key: string, id: string) {
Text(this.getBaseLabel(key))
.fontSize(this.getBaseFontSize(key))
.fontWeight(FontWeight.Medium)
.fontColor(this.getBaseTextColor(key))
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(this.isScientific ? 48 : 68)
.borderRadius(this.isScientific ? 24 : 34)
.backgroundColor(this.getBaseBg(key))
.shadow({ radius: 4, color: 'rgba(0,0,0,0.08)', offsetY: 2 })
.opacity(this.pressedKeyId === id ? 0.75 : 1)
.scale(this.pressedKeyId === id ? { x: 0.96, y: 0.96 } : { x: 1, y: 1 })
.animation({ duration: 100, curve: Curve.EaseInOut })
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.pressStart(id);
} else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.pressEnd();
}
})
.onClick(() => {
this.pressEnd();
this.onKeyClick(key);
})
}
这里分别处理 TouchType.Up 和 TouchType.Cancel,避免手指滑出或触摸被取消后仍保持按下效果。运算符选中状态另由 pendingOp 管理,例如输入 5+ 时突出显示加号,开始输入下一操作数后再恢复普通颜色。
(3)显示区域与状态刷新
表达式与结果分别显示:表达式采用较小字号和次级文字色,结果采用较大字号,并根据长度分档缩小。显示区使用 layoutWeight(1) 占据键盘之外的剩余高度,结果组件设置独立高度。过长文本使用单行省略显示,减少对按键区布局的影响。
模型中的普通字段不能直接替代页面响应式状态,因此页面读取计算状态后,将结果同步到 @State 字段:
代码摘自:CalculatorPage.ets(节选)。
refreshDisplay(): void {
this.exprText = this.model.getDisplayExpression();
// 预览文本带 "= " 前缀:等号不作为结果的一部分放大显示,只取数值本身
let raw: string = this.model.getDisplayResult();
let val: string = raw.startsWith('= ') ? raw.substring(2) : raw;
this.resultText = val;
if (val !== '') {
this.lastShownResult = val;
}
this.errorText = this.model.errorMessage;
this.pendingOp = this.model.getPendingOperator();
this.refreshTick++;
}
预览结果带有“= ”前缀,刷新时取出数值部分用于大字显示。输入暂时停留在 1×2+ 等中间状态时,模型可能没有新的预览值,页面通过 lastShownResult 保留最近一次有效显示结果。

图1 浅色基础计算器:12+3×4 的计算结果为 24
4. 输入管理与校验
(1)使用 token 保存表达式
CalculatorViewModel 使用 Array 保存表达式,将数字、运算符、函数、括号和后缀运算分别存储。例如,12+3×4 对应 [‘12’, ‘+’, ‘3’, ‘×’, ‘4’],sin(30) 对应 [‘sin(’, ‘30’, ‘)’]。
pressKey() 作为统一入口,将按键分发给 inputDigit()、inputDot()、inputOperator()、deleteLast() 和 evaluate() 等方法。数字判断同时检查 key.length === 1,避免将 1/x、10ˣ 等科学按键误判为数字。
(2)前导零与输入长度限制
输入数字时,程序先区分是否为新操作数,再决定追加还是替换。已有数字为 0 时,继续输入 5 会替换为 5;已有数字包含百分号时,阻止直接继续拼接数字;普通数字则在长度校验后追加。
代码摘自:CalculatorViewModel.ets(节选)。
private appendToNumber(d: string): void {
let len: number = this.tokens.length;
if (len === 0) {
return;
}
let last: string = this.tokens[len - 1];
if (last.indexOf(CommonConstants.PERCENT) !== -1) {
// 50% 之后不允许继续输入数字
return;
}
// 前导零替换:'0' 后按 5 变成 '5'
if (last === '0') {
this.tokens[len - 1] = d;
return;
}
if (last === '-0') {
this.tokens[len - 1] = CommonConstants.MIN + d;
return;
}
// 长度限制(去掉负号与小数点后计数)
let digits: string = last.replace(/-/g, '').replace(/\./g, '');
if (digits.length >= CommonConstants.MAX_DIGIT_LEN) {
return;
}
this.tokens[len - 1] = last + d;
}
MAX_DIGIT_LEN 在常量类中设置为 12,用于限制单个数字 token 的输入长度。小数点输入另由 inputDot() 处理:空输入时补为 0.,同一个数字已有小数点时忽略再次输入,避免产生多个小数点的非法数字。
(3)运算符替换与负号识别
减号既可以表示减法,也可以表示负数,需要结合上下文判断。空表达式允许输入负号;乘除等运算符之后输入减号时,将其视为一元负号;连续输入其他运算符时,替换前一个运算符。
代码摘自:CalculatorViewModel.ets(节选)。
private inputOperator(op: string): void {
this.justEvaluated = false;
let len: number = this.tokens.length;
if (len === 0) {
// 允许以负号开头输入负数,如 -3+5
if (op === CommonConstants.MIN) {
this.tokens.push(op);
}
return;
}
let last: string = this.tokens[len - 1];
if (this.isOperatorToken(last)) {
if (op === CommonConstants.MIN && last !== CommonConstants.MIN) {
// 运算符后按 '-' 表示一元负号,如 5×-
this.tokens.push(op);
return;
}
// 一元负号被其他运算符替换时,连同前面的二元运算符一起移除:5×- 按 + 变成 5+
if (last === CommonConstants.MIN && len >= 2 && this.isOperatorToken(this.tokens[len - 2])) {
this.tokens.pop();
this.tokens.pop();
this.tokens.push(op);
return;
}
// 一般情况:替换末尾运算符
this.tokens[len - 1] = op;
return;
}
this.tokens.push(op);
}
例如输入 5×-3 时,inputDigit() 会将末尾负号与数字合并成 -3,形成 [‘5’, ‘×’, ‘-3’]。括号输入还支持部分隐式乘法场景,如 2(3+1) 会补入乘号。右括号输入会检查是否存在尚未闭合的左括号,并阻止空括号直接闭合。
退格对普通数字删除最后一个字符,对函数和后缀 token 整体删除;清空操作重置输入与计算完成标志。按等号后再输入数字会开始新的表达式,输入运算符则继续使用上一次结果。
5. 表达式求值与结果处理
(1)运算优先级与后缀表达式
实际业务统一调用 ScientificCalculateUtil.evaluate(),先补全右括号,再通过调度场算法将中缀表达式转成后缀表达式,最后使用数值栈求值。转换过程中用运算符栈暂存待处理的运算符,根据优先级决定是否弹出。
例如 12+3×4 转换为 12 3 4 × +。求值时先将 3 与 4 相乘得到 12,再与最先入栈的 12 相加,得到 24。这样可以按优先级计算,而不是按按键先后顺序直接逐次运算。
代码摘自:ScientificCalculateUtil.ets(节选)。
private getPriority(t: string): number {
if (t === CommonConstants.POW) {
return PRIORITY_POW;
}
if (t === UNARY_MINUS) {
return PRIORITY_UNARY;
}
if (t === CommonConstants.MUL || t === CommonConstants.DIV) {
return 2;
}
return 1;
}
加减、乘除和幂使用不同的优先级,幂运算按右结合处理。转换阶段比较栈顶与当前运算符的代码如下;其他分支负责函数、括号、后缀运算和数字。
代码摘自:ScientificCalculateUtil.ets(节选)。
let rightAssoc: boolean = t === CommonConstants.POW;
let p: number = this.getPriority(t);
while (opStack.length > 0) {
let top: string = opStack[opStack.length - 1];
if (!(this.isBinaryOp(top) || top === UNARY_MINUS)) {
break;
}
let topP: number = this.getPriority(top);
// 左结合:栈顶优先级 >= 当前优先级时弹出;
// 右结合(^):仅栈顶优先级 > 当前优先级时弹出
let shouldPop: boolean = rightAssoc ? topP > p : topP >= p;
if (!shouldPop) {
break;
}
let op: string | undefined = opStack.pop();
if (op !== undefined) {
output.push(op);
}
}
opStack.push(t);
数值栈遇到二元运算时,先弹出右操作数 b,再弹出左操作数 a,调用 applyBinary(a, b, t),随后将结果压回栈中。该顺序对减法与除法尤其关键。函数和后缀运算则按各自需要弹出操作数,计算失败时立即返回错误信息。
(2)等号计算与连续运算
按等号前,模型检查末尾是否缺少操作数。符合求值条件时补全右括号、设置三角函数单位并调用求值器。成功后记录表达式与结果,将结果写回 tokens,供下一次连续运算使用。
代码摘自:CalculatorViewModel.ets(节选)。
private evaluate(): void {
let len: number = this.tokens.length;
if (len === 0) {
return;
}
let last: string = this.tokens[len - 1];
if (this.isOperatorToken(last) || last === CommonConstants.LEFT_P || ScientificCalculateUtil.isFunction(last)) {
return;
}
// 自动补全右括号:√(16 按等号按 √(16) 计算,历史记录也保存补全后的完整表达式
this.tokens = ScientificCalculateUtil.autoCloseTokens(this.tokens);
ScientificCalculateUtil.trigDegrees = this.angleDegrees;
let r: CalcResult = ScientificCalculateUtil.evaluate(this.tokens);
if (r.error !== '') {
this.errorMessage = r.error;
return;
}
let resultStr: string = CalculateUtil.formatNumber(r.value);
// 历史与显示区保存带千分位的完整表达式(如 10,000-9,797)
let exprStr: string = this.getExpressionText();
this.lastExpression = exprStr;
this.history.unshift(new HistoryRecord(exprStr, resultStr));
if (this.history.length > CommonConstants.MAX_HISTORY) {
this.history.pop();
}
this.tokens = [resultStr];
this.justEvaluated = true;
}
例如计算 12+3×4 得到 24 后继续按 +6,可以基于 24 继续运算。表达式出错时保留输入,方便退格修改。历史记录只有在计算成功后才新增,并限制最多保留 50 条。
(3)实时预览与数字格式化
输入过程中,updatePreview() 在表达式具备操作数时调用求值器,将结果写入 previewText;输入尚不完整时不生成预览。预览和最终计算使用同一计算工具,使两者遵循相同的运算规则。
数字格式化负责控制结果显示。普通数值使用 12 位有效数字,极大或极小的数值使用科学计数法,并去除科学计数法尾部多余的零。
代码摘自:CalculateUtil.ets(节选)。
formatNumber(n: number): string {
if (!Number.isFinite(n)) {
return '';
}
if (n === 0) {
return '0';
}
let abs: number = Math.abs(n);
if (abs >= 1e16 || abs < 1e-9) {
let sci: string = n.toExponential(6);
// 去掉科学计数法尾部的 0:1.500000e+15 → 1.5e+15
sci = sci.replace(/(\.\d*?[1-9])0+e/, '$1e').replace(/\.0+e/, 'e');
return sci;
}
return String(Number(n.toPrecision(CommonConstants.PRECISION)));
}
该处理可以减少 0.1+0.2 等浮点运算中不适合直接展示的尾数,但仍使用浮点数计算,并不等同于任意精度计算。千分位格式化单独用于显示,表达式的内部运算保留原始数值 token。
6. 科学计算与异常提示
(1)科学按键与角度单位
科学模式增加 6 列、5 行功能键,提供括号、平方、立方、幂、倒数、平方根、立方根、任意次方根、阶乘、指数、对数、三角函数、反三角函数和双曲函数等操作,还支持 π、e、Rand、EE 以及记忆寄存器。
2nd 负责切换三角与反三角函数按键;Rad/Deg 负责切换角度与弧度。页面在分发按键前处理这两类模式状态:
代码摘自:CalculatorPage.ets(节选)。
onKeyClick(key: string): void {
// 2nd:纯 UI 状态,切换三角/反三角标签
if (key === CommonConstants.KEY_SECOND) {
this.secondActive = !this.secondActive;
return;
}
// Rad/Deg:切换角度/弧度制(默认角度制)
if (key === CommonConstants.KEY_RAD) {
this.radMode = !this.radMode;
this.model.setAngleDegrees(!this.radMode);
return;
}
let realKey: string = key;
// 2nd 模式下三角函数键分发为反三角
if (this.secondActive) {
if (key === CommonConstants.KEY_SIN) {
realKey = CommonConstants.KEY_ASIN;
} else if (key === CommonConstants.KEY_COS) {
realKey = CommonConstants.KEY_ACOS;
} else if (key === CommonConstants.KEY_TAN) {
realKey = CommonConstants.KEY_ATAN;
}
}
this.model.pressKey(realKey);
this.syncHistory();
}
默认 radMode 为 false,模型采用角度制,此时按钮显示 Rad,表示点击后切换为弧度制。sin、cos 等求值时根据模型单位将输入转换为弧度后调用 Math 函数;接近零或整数的三角结果会进行容差修正,减少 sin(180°) 等结果的浮点残差。

图2 科学计算器:角度制下 sin(30) 的计算结果为 0.5
(2)函数输入与记忆操作
函数键在输入为空或末尾为运算符时追加函数 token;已有完整操作数时,可以将函数放到当前表达式前部,并在求值阶段补全括号。例如输入 3+4 后按 sin,按该项目的输入规则将对当前表达式整体应用正弦函数。
EE 会插入 ×10^,用于输入科学计数法,如 1.5×10^3。mc 清空记忆值,m+ 和 m− 将当前表达式的数值累加或减去,mr 将记忆值读回输入。记忆寄存器为 ViewModel 的内存字段,仅在本次会话内有效。
(3)统一返回计算错误
求值器通过 CalcResult 同时返回 value 和 error。页面无需自行判断所有异常,只要读取错误字符串即可显示提示。以下二元运算方法处理四则和幂运算,并检查除零、无效结果及数值溢出:
代码摘自:ScientificCalculateUtil.ets(节选)。
private applyBinary(a: number, b: number, op: string): CalcResult {
let v: number = 0;
if (op === CommonConstants.ADD) {
v = a + b;
} else if (op === CommonConstants.MIN) {
v = a - b;
} else if (op === CommonConstants.MUL) {
v = a * b;
} else if (op === CommonConstants.DIV) {
if (b === 0) {
return new CalcResult(0, ScientificCalculateUtil.ERR_DIV_ZERO);
}
v = a / b;
} else {
// '^' 幂运算
v = Math.pow(a, b);
}
if (Number.isNaN(v)) {
return new CalcResult(0, ScientificCalculateUtil.ERR_INVALID);
}
if (!Number.isFinite(v)) {
return new CalcResult(0, ScientificCalculateUtil.ERR_OVERFLOW);
}
return new CalcResult(v, '');
}
科学函数还分别检查定义域:平方根要求输入非负,对数要求真数大于零,阶乘要求非负整数,反三角函数中的 asin、acos 要求输入位于 [-1, 1]。这些检查将非法运算转换成中文提示,便于用户修改输入。

图3 除零异常:输入 1÷0 后显示中文错误提示
7. 历史记录与本地持久化
(1)保存与恢复历史数据
每条 HistoryRecord 包含 expression 和 result 两个字段。计算成功后通过 unshift() 将新记录放在最前方,超过上限时删除末尾记录。显示历史时以表达式和结果组成列表项,便于查看之前的计算过程。
页面初始化时从持久化字符串加载历史数据。解析失败时将历史重置为空数组,避免损坏的 JSON 影响页面初始化。
代码摘自:CalculatorViewModel.ets(节选)。
loadHistory(json: string): void {
this.history = [];
if (json === '') {
return;
}
try {
let arr: Array<HistoryRecord> = JSON.parse(json) as Array<HistoryRecord>;
for (let i = 0; i < arr.length; i++) {
this.history.push(new HistoryRecord(arr[i].expression, arr[i].result));
}
} catch (e) {
this.history = [];
}
}
写回时由 serializeHistory() 使用 JSON.stringify() 生成字符串。项目使用 PersistentStorage 注册历史、模式和主题三个属性:
代码摘自:CalculatorPage.ets(节选)。
PersistentStorage.persistProp(CommonConstants.KEY_HISTORY, '[]');
PersistentStorage.persistProp(CommonConstants.KEY_SCIENTIFIC, false);
PersistentStorage.persistProp(CommonConstants.KEY_THEME, false);
代码摘自:CalculatorPage.ets(节选)。
@StorageLink('calcHistory') calcHistoryJson: string = '[]';
@StorageLink('isScientific') isScientific: boolean = false;
@StorageLink('isDarkTheme') isDarkTheme: boolean = false;
@StorageLink 将页面字段与存储状态关联。aboutToAppear() 中加载历史并复制到 historyList;数据发生变化后,页面统一调用 syncHistory() 更新持久化字符串、响应式列表和显示结果。
代码摘自:CalculatorPage.ets(节选)。
private syncHistory(): void {
this.calcHistoryJson = this.model.serializeHistory();
// 用新数组替换 @State 副本,保证历史列表(清空/删除/新增)立即重渲染
this.historyList = this.model.history.slice();
this.refreshDisplay();
}
使用 history.slice() 生成新数组引用,便于页面观察列表变化。历史面板提供编辑、删除、清空以及结果回填入口,关闭面板时重置编辑状态。图4展示了编辑状态下的历史记录列表,包含 sin(30)、12+3×4 和 log(8) 等表达式及结果,各记录左侧出现删除按钮,顶部提供“完成”和“清空”操作。列表中的“过去7天”为当前界面标签;历史模型仅保存表达式与结果,未实现按日期筛选。

图4 历史记录编辑面板:展示计算记录及删除、清空入口
8. 深浅主题与页面跳转
主题由 isDarkTheme 控制,默认为浅色。点击主题按钮时执行布尔取反,颜色方法根据当前状态返回不同的背景、按键和文字颜色。
代码摘自:CalculatorPage.ets(节选)。
getColorBg(): ResourceColor { return this.isDarkTheme ? '#000000' : '#F5F5F7'; }
getColorDigit(): ResourceColor { return this.isDarkTheme ? '#2C2C2E' : '#FFFFFF'; }
getColorFunc(): ResourceColor { return this.isDarkTheme ? '#636366' : '#E5E5EA'; }
getColorPanel(): ResourceColor { return this.isDarkTheme ? '#1C1C1E' : '#FFFFFF'; }
getColorBorder(): ResourceColor { return this.isDarkTheme ? '#3A3A3C' : '#E5E5EA'; }
getColorTextMain(): ResourceColor { return this.isDarkTheme ? '#FFFFFF' : '#111111'; }
浅色主题以浅灰背景、白色数字键和深色文字为主;深色主题改用黑色背景、深灰按键和白色文字。模式菜单、历史面板和函数绘图页面也读取主题状态,使页面切换时保持一致的配色。
模式菜单内的基础和科学选项修改 isScientific,函数绘图选项则关闭菜单并跳转:
代码摘自:CalculatorPage.ets(节选)。
router.pushUrl({ url: 'pages/GraphPage' });
图3与图5展示了同一科学计算界面的浅色和深色外观:切换后背景变为黑色,科学按键及数字键改为深灰色,文字改为浅色,橙色运算符与错误提示保持突出。主题和计算模式的重启恢复属于单独的持久化验证项目。

图5 深色科学计算器:背景、按键及文字随主题变化
9. 函数绘图与参数交互
(1)添加函数与表达式解析
GraphPage 使用 Canvas 绘制网格、坐标轴、刻度和函数曲线,底部提供输入框、函数模板、列表和参数区域。用户可以输入 x^2、sin(x) 等表达式,也可以从模板中添加常用函数。
添加手动表达式时,程序去除首尾空格,构造 GraphFunction,并通过 isValid() 检查解析结果。合法函数使用新数组追加到列表,自动选中后请求重绘。
代码摘自:GraphPage.ets(节选)。
addFunction(): void {
let e: string = this.expr.trim();
if (e === '') {
return;
}
let f: GraphFunction = new GraphFunction(e, PALETTE[this.funcs.length % PALETTE.length]);
if (!f.isValid()) {
this.errorText = '函数表达式有误';
return;
}
this.errorText = '';
this.expr = '';
let arr: Array<GraphFunction> = this.funcs.slice();
arr.push(f);
this.funcs = arr;
// 自动选中新函数:有参数时立即显示参数 Slider
this.selectedIndex = arr.length - 1;
this.showFxPanel = false;
this.requestDraw();
}
GraphFunction 构造过程中调用 ExpressionParser.parse()。解析器识别数字、变量 x、常量、函数、括号和运算符,并生成后缀表达式。除保留名称外,还会识别单字母参数,交由函数模型保存,供后续滑块调节使用。绘图函数的三角计算直接使用 Math.sin 等方法,采用弧度制,与计算器页面可切换的角度单位分别管理。
(2)数学坐标转换
Canvas 的原点位于左上角,横轴向右、纵轴向下;数学坐标中的纵轴向上。因此绘制前必须进行坐标转换,统一使用 centerX、centerY 表示数学原点在画布中的位置,用 scale 表示一个数学单位对应的 vp 数。
代码摘自:CoordinateSystem.ets(节选)。
mathXToScreenX(mx: number): number {
return this.centerX + mx * this.scale;
}
mathYToScreenY(my: number): number {
return this.centerY - my * this.scale;
}
screenXToMathX(sx: number): number {
return (sx - this.centerX) / this.scale;
}
screenYToMathY(sy: number): number {
return (this.centerY - sy) / this.scale;
}
数学坐标转屏幕坐标用于绘图,屏幕坐标转数学坐标用于采样和触摸定位。把两类转换集中到 CoordinateSystem 后,平移、缩放和拖动对象都可以使用同一套规则,减少不同交互之间的坐标偏差。
(3)曲线采样与定义域处理
普通函数按照画布横向位置逐点采样,将屏幕横坐标换算为数学横坐标,再计算函数值并转回屏幕纵坐标。函数不可见或无效时跳过,圆这类模板则调用专门的绘制方法。
代码摘自:GraphPage.ets(节选)。
for (let px = 0; px <= w; px++) {
let mx: number = c.screenXToMathX(px);
let my: number = f.evaluateAt(mx);
let sy: number = c.mathYToScreenY(my);
let ok: boolean = !isNaN(my) && !isNaN(sy) && isFinite(my) && isFinite(sy);
if (!ok) {
started = false;
continue;
}
if (started && Math.abs(my - prevY) > yRange * 1.5) {
// tan 等渐近线:相邻采样点跳变过大时不连线,避免贯穿画布的竖线
ctx.moveTo(px, sy);
} else {
ctx.lineTo(px, sy);
}
started = true;
prevY = my;
}
代码对 NaN 和 Infinity 等结果停止当前连续路径;相邻采样点的纵向差值超过视野高度的一定比例时使用 moveTo(),减少在 tan(x) 或 1/x 的间断位置跨越连线。这里采用采样与阈值判断处理间断点,图6展示了连续正弦曲线的实际绘制效果;渐近线附近的绘制仍可使用 tan(x)、1/x 等函数进一步验证。
(4)函数模板与参数滑块
模板包括正弦、余弦、正切、一次、二次、指数、对数、平方根、绝对值和圆。模板通过 ParameterModel 保存参数名称、标签、当前值、范围和步长。正弦模板对应 A·sin(B·(x−C))+D,其中 A 控制振幅,B 控制频率,C、D 控制平移。
Slider 从模型读取当前值,并在 onChange 中更新对应参数、刷新数组引用和请求重绘:
代码摘自:GraphPage.ets(节选)。
Slider({ value: p.value, min: p.min, max: p.max, step: p.step, style: SliderStyle.OutSet })
.layoutWeight(1)
.selectedColor('#FF9500')
.trackColor(this.getColorBorder())
.blockColor(this.getColorTextMain())
.margin({ left: 6, right: 6 })
.onChange((value: number) => {
p.value = value;
this.funcs[this.selectedIndex].setParam(p.name, value);
// 新数组引用触发标签数值刷新;Slider 与曲线同源同值不会跳动
this.funcs = this.funcs.slice();
this.requestDraw();
})
GraphFunction.displayExpression() 根据模板当前参数重新生成表达式文本,evaluateAt() 使用相同参数计算函数值。图6中振幅 A=2.30、频率 B=1.90、水平位移 C=3.00、垂直位移 D=0.60,列表显示 2.3sin(1.9(x−3))+0.6,画布同时展示对应的正弦曲线,说明参数值与表达式文本保持对应。
圆模板使用 h、k、r 分别表示圆心坐标和半径。选中圆后显示圆心与半径控制点,触摸开始时判断命中位置,再区分拖动圆心、修改半径和移动整个视图。
(5)平移、缩放与绘制节流
绘图区通过 Canvas.onTouch 跟踪触点。单指在空白区域拖动时修改坐标原点位置,双指操作时根据两点距离变化计算缩放倍率。缩放以指定屏幕点为锚点,保证锚点下的数学坐标保持不变:
代码摘自:CoordinateSystem.ets(节选)。
zoomAt(sx: number, sy: number, factor: number, minScale: number, maxScale: number): void {
if (factor <= 0 || factor === 1) {
return;
}
let newScale: number = this.scale * factor;
if (newScale < minScale) {
newScale = minScale;
}
if (newScale > maxScale) {
newScale = maxScale;
}
if (newScale === this.scale) {
return;
}
let mx: number = this.screenXToMathX(sx);
let my: number = this.screenYToMathY(sy);
this.scale = newScale;
this.centerX = sx - mx * newScale;
this.centerY = sy + my * newScale;
}
顶部还提供复位入口,将原点和比例恢复为默认视图。拖动与滑块会连续产生事件,为减少重复绘制,requestDraw() 通过时间间隔和挂起标志合并重绘请求:
代码摘自:GraphPage.ets(节选)。
private requestDraw(): void {
let now: number = Date.now();
if (now - this.lastDrawAt < 33) {
if (!this.drawPending) {
this.drawPending = true;
setTimeout(() => {
this.drawPending = false;
this.lastDrawAt = Date.now();
this.draw();
}, 33);
}
return;
}
this.lastDrawAt = now;
this.draw();
}
该方法将高频操作的绘制请求控制在约 33 ms 的间隔,避免每一个触摸更新都立即触发全量绘制。实际流畅度需要在设备上观察,不能仅依据节流间隔认定达到固定帧率。

图6 函数绘图:2.3sin(1.9(x−3))+0.6 与对应参数滑块
10. 功能验证与运行展示
本次在 DevEco Studio 模拟器中展示了基础计算、科学计算、除零提示、历史记录、主题切换和函数绘图六个场景。对应界面已分别插入图1至图6,截图可确认的运行结果如下。
| 验证项目 | 输入或操作状态 | 截图中的运行结果 | 对应截图 |
|---|---|---|---|
| 基础运算优先级 | 12+3×4 | 显示 24,符合先乘后加规则 | 图1 |
| 科学计算 | 角度制 sin(30) | 显示 0.5,科学键盘完整显示 | 图2 |
| 除零提示 | 1÷0 | 显示“错误:除数不能为 0” | 图3 |
| 历史列表与编辑界面 | 打开历史记录并进入编辑状态 | 显示多条表达式和结果,提供单条删除及清空入口 | 图4 |
| 深色主题 | 科学模式切换至深色 | 背景、文字和按键使用深色配色,错误提示清晰显示 | 图5 |
| 函数绘图与参数显示 | A=2.30,B=1.90,C=3.00,D=0.60 | 显示 2.3sin(1.9(x−3))+0.6、曲线及四个参数滑块 | 图6 |
基础计算结果 24 和科学计算结果 0.5 与相应表达式一致;除零场景显示中文提示;历史面板展示了之前的计算记录与编辑入口;函数绘图区同时呈现表达式、坐标系、曲线和参数,体现了从输入到显示的完整界面流程。
除上述截图验证外,还设计了以下补充用例,用于检查输入边界与需要连续操作的功能。这些用例的预期结果与截图已展示的结果分别记录。
| 补充验证项目 | 操作示例 | 预期结果或核对重点 |
|---|---|---|
| 括号运算 | 科学模式输入 (12+3)×4 | 60 |
| 小数显示 | 输入 0.1+0.2 | 显示 0.3 |
| 重复小数点 | 同一数字中连续输入两次小数点 | 第二个小数点不重复追加 |
| 前导零与负数 | 分别输入 05、5×-3 | 显示输入 5;负数运算结果为 -15 |
| 百分数 | 输入 50% | 0.5 |
| 连续计算 | 12+3×4= 后继续 +6= | 30 |
| 括号补全 | 输入 √(16 后按等号 | 4,历史表达式补全右括号 |
| 阶乘与定义域 | 分别输入 5!、ln(0) | 120;对数真数非正时提示错误 |
| 历史删除与回填 | 删除记录,选取非最新记录继续运算 | 核对列表更新,以及表达式、显示结果和参与运算的值是否一致 |
| 持久化 | 保存记录和设置后重新启动应用 | 核对历史、主题和计算模式是否恢复 |
| 视图操作 | 单指平移、双指缩放后复位 | 核对视野变化与复位效果 |
| 圆模板 | 调整圆心和半径控制点 | 核对参数与圆的位置、大小是否对应 |
二、问题总结与体会
实现中重点处理了按键松开后视觉状态残留、历史列表刷新和绘图触摸交互问题:通过 onTouch 显式清除按压状态,以新数组赋值更新历史列表,并根据触点数量区分平移与缩放。计算部分则统一处理浮点显示和非法输入提示。
通过本次实验,我熟悉了 ArkUI 布局、状态管理、页面跳转与本地持久化,理解了表达式栈求值和 Canvas 坐标转换。将界面与计算逻辑分离有利于扩展功能。建议课程增加异常输入和不同屏幕尺寸适配的实践案例。
更多推荐



所有评论(0)