项目内容
本实验属于哪门课程?中国海洋大学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

图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 科学计算器:角度制下 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 后显示中文错误提示

图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 历史记录编辑面板:展示计算记录及删除、清空入口

图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 深色科学计算器:背景、按键及文字随主题变化

图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 与对应参数滑块

图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)×460
小数显示输入 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 坐标转换。将界面与计算逻辑分离有利于扩展功能。建议课程增加异常输入和不同屏幕尺寸适配的实践案例。

Logo

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

更多推荐