本实验属于哪门课程?中国海洋大学26夏《移动软件开发》
实验名称?实验5:鸿蒙开发入门及计算器开发
代码仓库地址?https://github.com/Melia-pp/mobile_dev_test5

一、实验内容

1.开发环境搭建

1.安装DevEco Studio
步骤 1 进入下载页面https://developer.huawei.com/consumer/cn/download/,选择最新版本下载。
在这里插入图片描述

步骤 2 参考页面进行安装:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/ide-software-install-V5

在这里插入图片描述

2.创建模拟器
整体步骤参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/ide-emulator-create-V5

3.开发及运行环境验收

步骤 1: 打开DevEco Studio,在欢迎页单击Create Project,创建一个新工程。

步骤 2: 根据工程创建向导,选择创建Application或Atomic Service。选择Empty Ability模板,然后单击Next。
在这里插入图片描述

步骤 3: 填写工程相关信息,单击Finish。关于各个参数的详细介绍,请参考创建一个新的工程。
在这里插入图片描述

2.实现简易计算器

教程链接:https://developer.huawei.com/consumer/cn/doc/architecture-guides/calculator-0000002298744774

1.计算逻辑函数的定义

标准计算器的核心逻辑封装在 CalculateUtil.ets 中,先将中缀表达式转换为后缀表达式,再对后缀表达式求值。

(1)运算符识别与优先级

// 判断是否为运算符
isSymbol(value: string) {
  if (CheckEmptyUtil.isEmpty(value)) {
    return;
  }
  return (CommonConstants.OPERATORS.indexOf(value) !== -1);
}

// 获取运算符优先级:+ - 为 MEDIUM,× ÷ 为 HIGH
getPriority(value: string): number {
  let result = 0;
  switch (value) {
    case SymbolicEnumeration.ADD:
    case SymbolicEnumeration.MIN:
      result = Priority.MEDIUM;
      break;
    case SymbolicEnumeration.MUL:
    case SymbolicEnumeration.DIV:
      result = Priority.HIGH;
      break;
    default:
      result = Priority.LOW;
      break;
  }
  return result;
}

(2)中缀转后缀(调度场算法)
表达式在页面层以数组形式存储(数字与运算符交替,如 ['12', '+', '3']),parseExpression 借助运算符栈将其转换为后缀队列:

parseExpression(expressions: Array<string>): string {
  let outputStack: string[] = [];   // 运算符栈
  let outputQueue: string[] = [];   // 后缀队列
  expressions.forEach((item: string, index: number) => {
    // 处理百分号:将 "50%" 替换为 50÷100 的结果
    if (item.indexOf(CommonConstants.PERCENT_SIGN) !== -1) {
      expressions[index] = (this.mulOrDiv(item.slice(0, item.length - 1),
        CommonConstants.ONE_HUNDRED, CommonConstants.DIV)).toString();
    }
    // 表达式末尾若是运算符则弹出(如 "12+3+=" 中的末尾 +)
    if ((index === len - 1) && this.isSymbol(item)) {
      expressions.pop();
    }
  });
  while (expressions.length > 0) {
    let current = expressions.shift();
    if (this.isSymbol(current)) {
      // 栈顶优先级 >= 当前运算符时,弹栈进入后缀队列
      while (outputStack.length > 0 &&
        this.comparePriority(current, outputStack[outputStack.length - 1])) {
        outputQueue.push(outputStack.pop());
      }
      outputStack.push(current);
    } else {
      outputQueue.push(current);   // 数字直接入队
    }
  }
  while (outputStack.length > 0) {
    outputQueue.push(outputStack.pop());
  }
  return this.dealQueue(outputQueue);
}

(3)后缀表达式求值

dealQueue(queue: Array<string>): string {
  let outputStack: string[] = [];
  while (queue.length > 0) {
    let current = queue.shift();
    if (!this.isSymbol(current)) {
      outputStack.push(current);          // 数字压栈
    } else {
      let second = outputStack.pop();     // 弹出右操作数
      let first = outputStack.pop();      // 弹出左操作数
      outputStack.push(this.calResult(first, second, current));
    }
  }
  return outputStack[0];
}

// 二元运算分发
calResult(arg1: string, arg2: string, symbol: string): string {
  switch (symbol) {
    case SymbolicEnumeration.ADD: result = this.add(arg1, arg2, CommonConstants.ADD); break;
    case SymbolicEnumeration.MIN: result = this.add(arg1, arg2, CommonConstants.MIN); break;
    case SymbolicEnumeration.MUL: result = this.mulOrDiv(arg1, arg2, CommonConstants.MUL); break;
    case SymbolicEnumeration.DIV: result = this.mulOrDiv(arg1, arg2, CommonConstants.DIV); break;
  }
  return this.numberToScientificNotation(result);
}

2.按键与页面设计

(1)按键数据模型(PressKeysItem.ets):每个按键包含 flag(渲染类型)、宽高、文本值和可选图标资源:

export class PressKeyItem {
  flag: number;      // 0=图片键  1=数字黑字  2=符号蓝字  3=等号白字大按钮
  width: string;
  height: string;
  value: string;
  source?: Resource;
  constructor(flag, width, height, value, source?) { ... }
}

(2)按键布局数据(PresskeysViewModel.ets):外层数组是键盘列,内层数组是每列从上到下的按键。渲染时按列遍历,因此修改按键只需增删数据,无需改动页面:

getPressKeys(): Array<Array<PressKeyItem>> {
  return [
    [ // 第 0 列:C、7、4、1、%
      new PressKeyItem(2, '28vp', '37vp', 'C', $r('app.media.clean')),
      new PressKeyItem(1, '26vp', '35vp', '7'),
      ...
    ],
    [ // 第 1 列:÷、8、5、2、0
      new PressKeyItem(2, '42vp', '56vp', '÷', $r('app.media.ic_div')),
      ...
    ],
    ...
    [ // 第 3 列:del、-、+、=(最后一行大按钮)
      new PressKeyItem(0, '30.48vp', '20vp', 'del', $r('app.media.ic_delete')),
      ...
      new PressKeyItem(3, '28vp', '54vp', '=', $r('app.media.ic_equ'))
    ]
  ];
}

(3)页面渲染(HomePage.ets):通过双重 ForEach 按"列 → 键"渲染,依据 flag 决定图片/文字样式;最后一列的最后一个按键(=)特殊处理为 148 高、40 圆角、蓝色背景的大按钮,与整体键盘视觉统一:

ForEach(keysModel.getPressKeys(), (columnItem, columnItemIndex?) => {
  Column() {
    ForEach(columnItem, (keyItem, keyItemIndex?) => {
      Column() {
        if (keyItem.flag === 0) {
          Image(keyItem.source)                    // 图片键
        } else if (keyItem.flag === 3) {
          Text(keyItem.value).fontSize(36).fontColor(Color.White)  // = 键白字
        } else {
          Text(keyItem.value)                      // 数字黑字 / 符号蓝字
        }
      }
      .width(64)
      .height((columnItemIndex === 3 && keyItemIndex === 3) ? 148 : 64)
      .borderRadius(... 40 : 32)
      .backgroundColor(... equals_back_color : 白色)
      ...
    })
  }
})

3.调用逻辑

页面层通过 onClick 依据 flag 分发到"输入符号"或"输入数字",再统一交给计算工具类求值,形成完整的调用链:

// 1. 按键点击分发
.onClick(() => {
  if (keyItem.flag === 0 || keyItem.flag === 2 || keyItem.flag === 3) {
    this.inputSymbol(keyItem.value);   // 符号(C/del/=/运算符)
  } else {
    this.inputNumber(keyItem.value);   // 数字
  }
})

// 2. 等号结算
case Symbol.EQU:
  this.getResult().then((result: boolean) => {
    if (!result) return;
    this.inputValue = this.calValue;   // 结果上屏
    this.expressions = [];
    this.expressions.push(this.inputValue);  // 结果可继续参与运算
  });
  break;

// 3. 调用工具类求值
async getResult() {
  let calResult = CalculateUtil.parseExpression(this.deepCopy());
  if (calResult === 'NaN') {
    this.calValue = this.resourceToString($r('app.string.error'));
    return false;
  }
  this.calValue = calResult;
  return true;
}

3.科学计算器的完成

1.计算逻辑函数的定义
科学计算器改用中缀表达式字符串 + 双栈直接求值算法,操作数栈与运算符栈协同工作,支持括号、一元负号、函数与右结合运算符。
(1)分词器 tokenize:把 "sin(30)+2^3" 拆成 数字/运算符/函数/括号 记号序列,并区分一元负号(neg)与二元减号:

tokenize(expression: string): Array<Token> {
  let tokens: Array<Token> = [];
  while (i < expression.length) {
    let ch = expression[i];
    if (this.isDigit(ch) || ch === '.') {          // 数字(含小数)
      ...
      tokens.push(new Token(0, num));
    }
    if (ch === 'π') { tokens.push(new Token(0, Math.PI.toString())); }
    if (ch === 'e') {
      // 若是科学计数法结果(如 1.23e+18),合并进前一个数字;否则为自然常数
      ...
    }
    if (this.isAlpha(ch)) {                        // 函数名 sin/cos/...
      let name = '';
      while (i < expression.length && this.isAlpha(expression[i])) { name += expression[i]; i++; }
      if (this.isFunc(name)) { tokens.push(new Token(2, name)); }
    }
    if (ch === '√') { tokens.push(new Token(2, 'sqrt')); }
    ...
    if (ch === '-') {
      // 前一个是数字/右括号/%/! 时为二元减,否则为一元负号
      let isBinary = prev !== undefined &&
        (prev.type === 0 || prev.type === 4 || prev.value === '%' || prev.value === '!');
      tokens.push(new Token(1, isBinary ? '-' : 'neg'));
    }
  }
  return tokens;
}

(2)双栈求值 evaluate:核心循环按记号类型处理——数字压操作数栈、函数/左括号压运算符栈、右括号弹栈至左括号、运算符按优先级与结合性弹栈计算:

evaluate(expression: string): string {
  let completeExpr = this.completeBrackets(expression);  // 自动补全右括号
  let tokens = this.tokenize(completeExpr);
  let valStack: Array<number> = [];   // 操作数栈
  let opStack: Array<Token> = [];     // 运算符栈
  for (let token of tokens) {
    if (token.type === 0) {
      valStack.push(Number(token.value));          // 数字直接入栈
    } else if (token.type === 3) {
      opStack.push(token);                         // 左括号入栈
    } else if (token.type === 4) {                 // 右括号:弹栈至左括号
      while (opStack.length > 0 && opStack[opStack.length - 1].type !== 3) {
        this.applyTop(valStack, opStack);
      }
      opStack.pop();                               // 弹出左括号
      if (opStack.length > 0 && opStack[opStack.length - 1].type === 2) {
        this.applyTop(valStack, opStack);          // 应用函数(sin/√/...)
      }
    } else {
      if (token.value === '%' || token.value === '!') {  // 后缀运算符立即计算
        let v = valStack.pop();
        valStack.push(token.value === '%' ? v / 100 : this.factorial(v));
        continue;
      }
      // 栈顶优先级更高(或同级且当前非右结合)时先弹栈计算
      while (opStack.length > 0 && opStack[opStack.length - 1].type === 1 &&
        (priority(栈顶) > priority(当前) ||
         (priority(栈顶) === priority(当前) && !this.rightAssoc(token.value)))) {
        this.applyTop(valStack, opStack);
      }
      opStack.push(token);
    }
  }
  while (opStack.length > 0) { this.applyTop(valStack, opStack); }
  return this.formatResult(valStack[0]);
}

(3)函数库与结果格式化:calcFunc 实现三角函数(角度制)、对数、开方;factorial 实现阶乘;formatResult 处理去尾零与科学计数法:

calcFunc(name: string, arg: number): number {
  switch (name) {
    case 'sin': return Math.sin(arg * this.ANGLE_CONVERT);  // 角度制
    case 'cos': return Math.cos(arg * this.ANGLE_CONVERT);
    case 'tan': return Math.tan(arg * this.ANGLE_CONVERT);
    case 'ln':  return Math.log(arg);
    case 'log': return Math.log10(arg);
    case 'sqrt': return Math.sqrt(arg);
    default:    return Number.NaN;
  }
}

formatResult(value: number): string {
  if (Number.isNaN(value) || !Number.isFinite(value)) return 'NaN';
  if (Math.abs(value) >= 1e16 || Math.abs(value) < 1e-9) {
    result = value.toExponential(10);     // 极大/极小值转科学计数法
  } else {
    result = value.toPrecision(12);       // 常规值保留 12 位有效数字
  }
  // 去除尾部的 0(如 123.456000 -> 123.456)
  ...
}

2.按键与页面设计

科学计算器采用 5 列 × 7 行布局,按键模型在标准版基础上增加 fontSize 字段,以适配"sin""x²"等多字符小字号功能键:

// ScientificKeysItem.ets
export class ScientificKeyItem {
  flag: number;      // 0=图片键 1=数字黑字 2=功能蓝字 3=等号大按钮
  width: number;
  height: number;
  value: string;
  fontSize: number;  // 新增:功能键用小字号(20),数字/运算符用大字号(24)
  source?: Resource;
}

键盘布局(ScientificKeysViewModel.ets),与标准计算器统一——白色圆角按键、蓝色符号、右下角蓝色大等号:

getSciKeys(): Array<Array<ScientificKeyItem>> {
  return [
    [ // 列0:C、7、4、1、0、sin、ln
      new ScientificKeyItem(2, 52, 48, 'C', 24),
      new ScientificKeyItem(1, 52, 48, '7', 24),
      ...
      new ScientificKeyItem(2, 52, 48, 'sin', 20),
      new ScientificKeyItem(2, 52, 48, 'ln', 20)
    ],
    [ // 列1:del、8、5、2、.、cos、log
      new ScientificKeyItem(0, 52, 48, 'del', 0, $r('app.media.ic_delete')),
      ...
    ],
    ...  // 列2(÷、9、6、3、%、tan、1/x)、列3(×、(、)、x²、x³、√、xⁿ)
    [ // 列4:-、+、π、e、±、n!、=(大按钮)
      new ScientificKeyItem(2, 52, 48, '-', 24),
      new ScientificKeyItem(2, 52, 48, '+', 24),
      new ScientificKeyItem(1, 52, 48, 'π', 24),
      new ScientificKeyItem(1, 52, 48, 'e', 24),
      new ScientificKeyItem(2, 52, 48, '±', 24),
      new ScientificKeyItem(2, 52, 48, 'n!', 20),
      new ScientificKeyItem(3, 52, 108, '=', 36)
    ]
  ];
}

ScientificPage.ets 的渲染结构、颜色、圆角、阴影参数均与 HomePage 保持一致

3.调用逻辑

科学页所有按键点击统一进入 onKey 分发,再调用 appendInput(组装表达式)、calculate(等号结算)、tryPreview(实时预览):

onKey(keyItem: ScientificKeyItem) {
  switch (keyItem.value) {
    case 'C':    this.expression = ''; this.result = ''; break;   // 清空
    case 'del':  this.deleteLast(); this.tryPreview(); break;     // 退格
    case '=':    this.calculate(); break;                          // 结算
    default:     this.appendInput(keyItem.value); this.tryPreview(); break;
  }
}

// 追加输入:函数作用于当前数值段(先输 30 再点 sin -> "sin(30")
case 'sin': case 'cos': case 'tan': case 'ln': case 'log': case '√': case '1/x':
  expr = this.appendFunc(expr, value);
  break;
case 'x²':   // 负数自动加括号:2±x² -> (-2)^2
  expr = this.appendPow(expr, '^2');
  break;
case '±':    // 对当前数字段取反
  expr = this.toggleSign(expr);
  break;

// 等号结算:表达式 -> 结果,结果作为新表达式可继续运算
calculate() {
  let calResult: string = ScientificUtil.evaluate(this.expression);
  if (calResult === 'NaN') { this.result = 'error'; return; }
  this.expression = calResult;
  this.result = '';
}

// 实时预览:输入过程中即时求值显示
tryPreview() {
  let calResult: string = ScientificUtil.evaluate(this.expression);
  if (calResult === 'NaN') { this.result = ''; return; }  // 中间态不打扰
  this.result = calResult;
}

4.页面注册与跳转

HarmonyOS 新增页面必须注册到 main_pages.json,否则运行时找不到页面:

{
  "src": [
    "pages/HomePage",
    "pages/ScientificPage"
  ]
}

跳转入口加在标准页标题栏右侧(HomePage.ets):

import { router } from '@kit.ArkUI';

Text($r('app.string.scientific_text'))
  .fontColor('rgba(10, 89, 247, 1)')
  .onClick(() => {
    router.pushUrl({ url: 'pages/ScientificPage' });  // 进入科学计算器
  });

科学页右上角"普通"按钮调用 router.back() 返回标准计算器。

二、问题解决与收获

1.问题与解决

1.电脑缺少Hyper-v管理器,无法启动模拟器。要解决这个问题,需要打开BIOS,启用硬件虚拟化,再在“启用或关闭Windows功能”这里勾选“虚拟机平台”和“Windows虚拟机监控程序平台”。重启电脑即可。
2.科学计算器页面的归零、除号、乘号、删除图标比例过大。后面发现其中的归零、除号和乘号图标应该是直接用蓝色文字表示的。后续将归零、除号、乘号改回蓝色文字与普通页一致;del 保留图片但改用 28×28 + objectFit(Contain) 等比显示,不再变形。

2.收获与体会

1.对于鸿蒙系统软件的开发有了一定的了解。通过本次实验了解了DevEco Studio的使用方法,包括启动模拟器、创建项目、工程结构分布等。
2.对于计算器设计需要运用到的算法也有了进一步了解,包括标准计算器的"中缀转后缀 + 后缀求值"与科学计算器的"双栈直接求值"。这两个算法都是利用栈的 L 特性处理运算符优先级。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐