HarmonyOS实战-手把手教你实现一个具有桌面万能卡片的计算器

写在前面

最近撸完了HarmonyOS应用开发的相关知识,也通过了高级认证以及HCIP课程,正想着实战仿个经典的网易云Demo练练时收到了水电缴费单,看着用水量、二次供水费用、污水处理费、电费、维修基金等等一堆项目,难免要掏出计算器出来核算下金额,结果我滑了半天屏没找到,只好下拉搜索计算器才找到,事后我在一个大文件夹的众多图标中终于找到了计算器(吐槽下,这个图标色彩真不显眼)。 这时,我眉头一皱,就在想,能不能有款计算器,可以提供放在手机桌面直接输入计算的功能?不要让我在众多应用中眼花缭乱地去寻找,也不需要我下滑拉起搜索框去输入计算器(遥遥领先下滑唤起搜索框会卡顿下🙂)。

仔细一想,有了!这不就是桌面万能卡片吗?文档中也叫服务卡片官方对外白皮书中术语叫做万能卡片。 搞起搞起,先来看看最终实现效果:

桌面卡片效果 应用效果

直接在桌面进行计算,也可以在应用图标上上滑唤起卡片计算,也可以点击打开应用计算。非常nice。

项目分析

开工前参考了MAC端/PC端/移动端(ios & HarmonyOS)系统原生的计算器设计,大致思考分析了项目,包括需求、详细功能、可能用到的知识点、一些细节处理等,放了方便对照复盘,直接上脑图: 当然,项目中的问题是最后加的。 项目的核心目的是为了将学到的相关知识通过自己的脑子思考与双手敲码实际转换成应用,因此,需要尽可能完善地实现应用功能,不要偷懒,也不要轻视简单的知识点,不然每漏掉的一个点都可能是金钟罩上的一个命门。

根据官方文档中的应用开发以及服务卡片开发指南,可以了解到,只需要使用ArkTS、ArkUI,应用开发与卡片开发绝大部分特征是一致的,其中,服务卡片只支持部分能力,因此,先开发计算器应用,然后在卡片开发中复用绝大部分代码,针对于不支持的能力再做相关调整,这样可以节省大量编码工作。

计算器应用开发

为了方便处理,在这里只实现具有+-x÷四则运算的标准计算器。对于其他变种计算器,如科学计算器、货币换算、甚至亲戚计算器之类的,感兴趣的同学可以自己实现。它们涉及到的HarmonyOS开发知识点与此计算器基本一致,只是数据与背后的逻辑处理不一致而已。

UI开发

布局

css 复制代码
-Flex 采用一个主轴方向为Column的Flex布局填满整个屏幕,这样可以在不同的屏幕上伸缩自适应铺满
--Grid 一个6行4列的网格,放置计算器操作界面
---GridItem 网格子元素,第一项为输入历史以及结果展示区域,后续每一项都是按键。其中0占据1行2列,=占据2行1列
--Text 应用底部信息

输入历史以及结果显示

此部分区域需要做到:

  1. 展示用户除键入=符号外所有的输入历史,超出区域时,需要换行滚动,并且永远自动定位到最后一行展示最新输入;
  2. 展示用户键入后的计算结果,超出区域时,同输入历史处理逻辑展示
scss 复制代码
// ... More

// 滚动控制器
private historyScroller: Scroller = new Scroller()
private resultScroller: Scroller = new Scroller()

keyHandler = (key: string): void => {
  if (Constants.VAL_KEYS.includes(key) || Constants.DOT_KEY === key) {
    this.valHandler(key)
  } else {
    this.opHandler(key)
  }
  // 键入后永远自动定位到最后一行展示最新输入/结果
  this.historyScroller.scrollEdge(Edge.Bottom)
  this.resultScroller.scrollEdge(Edge.Bottom)
}

// ... More

// 输入历史&结果展示
GridItem() {
  Column() {
    Row() {
      // 允许超出滚动并 绑定 scroller 控制器
      Scroll(this.historyScroller) {
        Text(this.historyInputArr.join(''))
          .calcText(Color.Black, this.historyTextSize)
          .textAlign(TextAlign.End)
      }
      .height('50%')
      .scrollBar(BarState.Off)
      .align(Alignment.Bottom)
    }
    Blank()
    if (this.canShowResult) {
      Row() {
        // 允许超出滚动并 绑定 scroller 控制器
        Scroll(this.resultScroller) {
          Text(this.result)
            .calcText(Color.Black, this.resultTextSize)
            .textAlign(TextAlign.End)
            .fontWeight(FontWeight.Bold)
        }
        .height('45%')
        .scrollBar(BarState.Off)
        .align(Alignment.Bottom)
      }
    }

  }
  .width('100%')
  .alignItems(HorizontalAlign.End)
}
.columnStart(1)
.columnEnd(4)
.opAreaStyle()
.align(Alignment.End)
.padding(8)

按键渲染

按键按功能类型可分为值按键:['7', '8', '9', '4', '5', '6', '1', '2', '3', '0','.']&操作按键:['Back', 'C', '/', 'x', '+', '-', '=']。所有按键结构都如下:

less 复制代码
// ... More

// 按键可重用的样式
@Styles calcKeyStyle(){
  .borderRadius(8)
  .hoverEffect(HoverEffect.Highlight)
}

@Styles calcKeyPressStyle(){
  .backgroundColor('rgba(105, 105, 107, 1.00)')
  .opacity(0.6)
}

@Styles calcKeyNormalStyle(){
  .backgroundColor('rgba(105, 105, 107, 1.00)')
  .opacity(1)
}

// ... More

GridItem() {
  Text(item)
    .calcText(Constants.OP_TEXT_COLOR, ['-', '+'].includes(item) ? Constants.OP_TEXT_SIZE : Constants.VAL_TEXT_SIZE)
}
.calcKeyStyle()
.stateStyles({ // 多态样式
  normal: {
    .calcKeyNormalStyle()
  },
  pressed: {
    .calcKeyPressStyle()
  }
})
.onClick(() => this.keyHandler(item))

由于同类按钮样式一致,因此采用了@Styles定义按键可重用的样式。

其中.stateStyles是设置按键多态样式,增加按键反馈。文档传送门

效果如下按下按键6所示:

数据结构

整个应用的核心逻辑即:从旧到新依次读取用户的输入进行计算。先输入的先读取,符合队列的特征,因此此处采用字符串数组的方式存储用户的输入历史:

css 复制代码
@State historyInputArr: string[] = []
//Example: ['1','+','2','*','3']

对于计算结果,需要使用Text组件展示,因此采用字符串类型:

css 复制代码
@State result: string = ''

按键键入处理

按键键入顶层分为2个类型进行分发处理:值键入处理 & 操作符号键入处理。

值键入处理

值键入时,直到操作符键入之前都算当前输入,注意以下4点:

  1. 小数点键入。不能连续键入小数点,已存在小数点的情况下不能再继续输入小数点;
  2. 0键入。当本次输入记录第一项为0时,不能连续输入0;
  3. 整体长度限制。由于 IEEE-754 浮点数精度丢失问题 限制整数情况下,最多连续键入16个字符,限制小数情况下,最多连续键入17个字符;
  4. 与前面的输入历史构成有效可执行表达式时触发=逻辑,实时计算结果。
kotlin 复制代码
// ... More 
// 本次输入记录
inputVal: string = ''
valHandler = (val: string): void => {
  if (Constants.DOT_KEY === val && (~this.inputVal.indexOf(Constants.DOT_KEY))) {
    return null
  }
  if (this.inputVal === '0' && val === '0') {
    return null
  }
  let tempVal = `${this.inputVal}${val}`
  //限制输入有效数字长度,避免IEEE-754  精度问题
  this.inputVal = ~this.inputVal.indexOf(Constants.DOT_KEY) ? tempVal.slice(0, 17) : tempVal.slice(0, 16)
  if (this.historyInputArr.length) {
    const lastInputKey = this.historyInputArr[this.historyInputArr.length-1]
    if (Constants.OP_KEYS.includes(lastInputKey)) {
      this.historyInputArr.push(this.inputVal)
    } else {
      this.historyInputArr[this.historyInputArr.length-1] = this.inputVal
    }
  } else {
    this.historyInputArr.push(this.inputVal)
  }
  //构成可执行表达式,进行运算
  if (this.historyInputArr.length < 3 || (this.historyInputArr.length < 4 && this.historyInputArr[0] === '-')) return null
  this.equalHandler()
}

操作符键入处理

操作符键入处理分为4种逻辑进行分发处理:C、Back、=、运算符+-x/。

C

清除所有数据,将计算器还原至初始状态

ini 复制代码
clearAllInfo = (): void => {
  this.historyInputArr = []
  this.result = ''
  this.inputVal = ''
  this.canShowResult = false
  this.historyTextSize = Constants.DISPLAY_TEXT_SIZE_DEFAULT
  this.resultTextSize = Constants.DISPLAY_TEXT_SIZE_DEFAULT
}

Back

从最近的一次输入值开始回退输入历史,包括: 数字、. 操作符,但是不包括 = 号,同时触发=逻辑计算结果。 注意回退后historyInputArr最后一项的空字符串的处理。

kotlin 复制代码
backHandler = (): void => {
  //回退历史,从historyInputArr最后一项最后一个字符开始删除,删除至为空时,从数组中移除改项,重复操作
  if (this.historyInputArr.length) {
    const lastInput = this.historyInputArr[this.historyInputArr.length-1]
    if (lastInput === '') {
      this.historyInputArr.pop()
    } else {
      const backRes = lastInput.slice(0, lastInput.length - 1)
      if (backRes === '') {
        this.historyInputArr.pop()
      } else {
        this.historyInputArr[this.historyInputArr.length-1] = backRes
      }
    }
    this.inputVal = this.historyInputArr[this.historyInputArr.length-1]
  } else {
    this.inputVal = ''
  }
  this.equalHandler()
}

=

最简单的操作符处理逻辑,仅用来触发计算方法。触发后,允许显示计算结果(计算器初始状态不显示计算结果区域)

ini 复制代码
equalHandler = (): void => {
  this.result = Calculate(this.historyInputArr)??''
  this.canShowResult = true
}

运算符+-x/

运算符键入后,一般直接追加至输入记录,无需额外处理。只需注意以下2点:

  1. 第一次输入可以是-,且不能连续输入,且无法被其他运算符覆盖;
  2. 除1的情况外,连续键入运算符将替换上一次键入的运算符;
kotlin 复制代码
opHandler = (op: string): void => {
  if (op === '=') {
    this.equalHandler()
  } else if (op === 'Back') {
    this.backHandler()
  } else if (op === 'C') {
    this.clearAllInfo()
  } else {// 运算符+-x/ 处理逻辑
    if (op !== '-' && this.historyInputArr[0] === '-' && this.historyInputArr.length === 1) {
      return null
    }
    if (Constants.CALC_KEYS.includes(op) && Constants.CALC_KEYS.includes(this.historyInputArr[this.historyInputArr.length-1])) {
      this.historyInputArr[this.historyInputArr.length-1] = op
    } else {
      if ((op === '-' && this.historyInputArr.length === 0) || this.historyInputArr.length) {
        this.historyInputArr.push(op)
      }
    }

    this.clearInputVal()
  }
}

计算方法

无视运算符优先级,依次读取输入记录,从左至右依次运算,最后返回字符串类型的结果。注意以下6点处理:

  1. 某一项值是.,需返回错误;
  2. 除数是0,需返回错误;
  3. 对于不完整的小数格式需要补全或者取整。如:.3,3.
  4. 第一项是- 代表是负值,特殊处理;
  5. 如果最后一项是操作符,则忽略此项;
  6. 计算结果转换时,需处理IEEE-754浮点数精度问题
typescript 复制代码
export default function Calculate(historyInputArr: string[]): string {
  //处理每一项数字,对小数进行补全操作或者取整
  let targetInputArr: string[] = []
  if (!historyInputArr.length) {
    return ''
  }
  // 处理某个值是. 或者除数是0 的错误情况
  if (~historyInputArr.indexOf('.')) {
    return '错误'
  }
  const matchDivisorZero = historyInputArr.join('').match(//0/g)
  const matchDivisorZeroFloat = historyInputArr.join('').match(//0[.]/g)
  if (matchDivisorZero) {
    if (!matchDivisorZeroFloat) {
      return '错误'
    } else {
      if (matchDivisorZero.length !== matchDivisorZeroFloat.length) {
        return '错误'
      }
    }
  }
  targetInputArr = historyInputArr.map((i: string) => {
    if (~i.indexOf('.')) {
      if (i.length > 1) {
        return String(Number.parseFloat(i))
      }
    } else return i
  })
  //如果最后一项是操作符,则忽略此项
  if (Constants.OP_KEYS.includes(targetInputArr[targetInputArr.length-1])) {
    targetInputArr.pop()
  }
  //开始计算
  const resultVal: string = targetInputArr.reduce((res: string, i,index) => {
    if (isNaN(Number.parseFloat(i))) {
      // 如果是操作符,则将其拼接,带入到下次计算
      return res = `${res}${i}`
    } else {
      const val = Number.parseFloat(i)
      if (res === '') {
        return res += i
      } else {
        //第一项是- 代表是负值,特殊处理
        if (index===1 && res === '-') {
          return res += i
        }
        //正数输入处理逻辑
        const lastVal = Number.parseFloat(res.slice(0, res.length - 1))
        const op = res.slice(res.length - 1)
        if (op === '-') {
          return `${lastVal - val}`
        } else if (op === '+') {
          return `${lastVal + val}`
        } else if (op === 'x') {
          return `${lastVal * val}`
        } else if (op === '/') {
          return `${lastVal / val}`
        }
      }
    }
  }, '')
  // fix  IEEE-754 浮点数表示法 精度问题
  return String(Number.parseFloat(Number.parseFloat(resultVal).toFixed(10)))
}

计算器万能卡片开发

卡片开发基础知识,如创建卡片,本文中不再赘述,详情请见文档(ArkTS卡片开发指导)。

此处需要创建一个4*4大小的卡片,计算器界面才能比较好的展示与操作。

通过上述步骤,完成了计算器应用的开发。接下来开发文章开头提到的我核心需求-桌面万能卡片,开发文档中也称服务卡片。 为了提升开发体验与开发效率,这里采用ArkTS开发卡片,复用上述应用开发中绝大部分UI&逻辑代码(请见ArkTS卡片的优势

UI开发

卡片页面布局开发

由于卡片的能力限制,不支持Grid/GridItem网格布局,此处需要使用GridRow/GridCol栅格布局进行替换。 并且,需要指定子组件GridCol的span(占用列数)来实现网格Grid中的行列数量以及占比的配置。 可参考文档进行调整:

css 复制代码
-Flex 采用一个主轴方向为Column的Flex布局填满整个屏幕,这样可以在不同的屏幕上伸缩自适应铺满
--GridRow 栅格容器组件
---GridCol 栅格子元素
--Text 应用底部信息

最终效果:

展示区域字体大小动态缩放

由于卡片的能力限制,不支持Scroll,为了在输入历史以及结果展示过长时仍能正常显示,对这2个文本根据长度动态缩放字体大小。 在这里,采用@Watch监听输入历史以及计算结果的长度,然后计算字体的大小(每8个字符 缩小2,最小缩至8,放大同理):

kotlin 复制代码
// ... More
@LocalStorageProp('historyInputArr') @Watch('textLengthChange') historyInputArr: string[] = [];
@LocalStorageProp('result') @Watch('textLengthChange') result: string = '';
readonly DISPLAY_TEXT_SIZE_DEFAULT: number = 24
readonly DISPLAY_TEXT_SIZE_MINI: number = 8
@State historyTextSize: number = this.DISPLAY_TEXT_SIZE_DEFAULT
@State resultTextSize: number = this.DISPLAY_TEXT_SIZE_DEFAULT

// ... More

textLengthChange(propName: string) {
  const targetPropSize = propName === 'historyInputArr' ? 'historyTextSize' : 'resultTextSize'
  const targetText = propName === 'historyInputArr' ? this.historyInputArr.join('') : this.result
  // 每8个字符 缩小2,最小缩至8,放大同理
  const targetSize = this.DISPLAY_TEXT_SIZE_DEFAULT - 2 * (Math.floor(targetText.length / 8))
  this[targetPropSize] = targetSize > this.DISPLAY_TEXT_SIZE_MINI ? targetSize : this.DISPLAY_TEXT_SIZE_MINI
}
正常 超长动态缩小字体 回退动态放大字体

与应用通信

完成卡片UI相关开发后,就要开始计算方法的开发了,由于卡片的能力限制,无法通过import引入在应用中编写的计算方法模块,又不想重复把应用中的计算方法移植过来,毕竟UI开发阶段,使用栅格布局替换网格布局就折腾了一会了。 能不能直接使用应用中的计算方法呢? 幸运的是HarmonyOS提供了卡片与应用通信的方式,可以直接在后台唤起应用执行相关功能,然后刷新卡片内容。

详情请见文档:通过call事件刷新卡片内容

  1. 卡片ets文件中, = 键入,调用应用中的计算方法
kotlin 复制代码
// 卡片ets文件中, = 键入,调用应用中的计算方法
equalHandler = (): void => {
  // 调用 UIAbility 中的能力,然后通知卡片刷新
  console.info('postCardAction to EntryAbility');
  postCardAction(this, {
    'action': 'call',
    'abilityName': this.ABILITY_NAME, // 只能跳转到当前应用下的UIAbility
    'params': {
      'method': 'calculate',
      'formId': this.formId,
      'historyInputArr': JSON.stringify(this.historyInputArr) // 将输入历史以JSON字符串方式传递给应用
    }
  });
  this.canShowResult = true
}
  1. 应用 UIAbility中监听 callee 事件:
javascript 复制代码
// 监听处理,调用应用中的计算方法得到计算结果,并刷新卡片
function calculateListener(data) {
  // 获取call事件中传递的所有参数
  let params = JSON.parse(data.readString())
  if (params.formId !== undefined) {
    let curFormId = params.formId;
    let historyInputArr = JSON.parse(params.historyInputArr);
    console.info(`UpdateForm formId: ${curFormId}, message: ${historyInputArr}`);
    let formData = {
      "result": Calculate(historyInputArr)??''
    };
    let formMsg = formBindingData.createFormBindingData(formData)
    formProvider.updateForm(curFormId, formMsg).then((data) => {
      console.info('updateForm success.' + JSON.stringify(data));
    }).catch((error) => {
      console.error('updateForm failed:' + JSON.stringify(error));
    })
  }
  return null;
}

export default class EntryAbility extends UIAbility {
  onCreate(want, launchParam) {
    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
    //应用与卡片复用 Calculate 计算方法
    console.info('Want:' + JSON.stringify(want));
    try {
      this.callee.on(CALCULATE_METHOD, calculateListener)
    } catch (error) {
      console.log(`${CALCULATE_METHOD} register failed with error ${JSON.stringify(error)}`)
    }
  }
  // ... More
 }

至此,一个具有桌面服务卡片的HarmonyOS计算器应用开发完毕!
赶紧掏出你的遥遥领先,打包到手机上运行吧!
真机运行文档传送门:在Phone和Tablet中运行应用/服务-使用本地真机运行应用/服务

项目中遇到的问题

常量文件内容变动无效

当你在ets文件中引入一个常量模块,如:

javascript 复制代码
import Constants from '../constants'

如果constants文件内容有变更时,比如新增一个常量static readonly DOT_KEY: string = '.' ets文件中无法索引到Constants.DOT_KEY,编辑器会提示报错。

解决方案: 此为 DevEco Studio bug,重新同步项目即可。点击菜单栏:File->Sync and Refresh Project, 等待同步完成,报错消失。

ts文件中不能引用ets文件

规则如此。建议UI界面文件使用.ets,其他的文件使用ts,如:工具函数文件、常量文件等

卡片部分能力不支持

开发中遇到卡片UI无法使用import、Scroll、Grid等情况,原因是卡片的能力限制。需要使用其他的方案平替。

不允许使用eval()/new Function()/Function()

安全性要求

真机运行无法看到console控制台日志

DevEco Studio Bug 或者 HarmonyOS 4.0 bug,在手机上关闭重新打开开发者模式,再打包运行即可看到。

卡片添加到桌面后计算过程中没有显示计算结果

HarmonyOS 4.0 bug,首次添加后,桌面应用作为卡片使用方,没有收到有效的 formId,二次添加后才正常。

解决方案: EntryFormAbility onAddForm 生命周期中延迟手动刷新一次 formId。

javascript 复制代码
export default class EntryFormAbility extends FormExtensionAbility {
  delayUpdateFormId = null
  onAddForm(want) {
    // Called to return a FormBindingData object.
    let formId = want.parameters["ohos.extra.param.key.form_identity"];
    let formData = {"formId": formId};
    const data = formBindingData.createFormBindingData(formData);

    //FIX: 延时二次刷新数据,解决初次添加卡片call事件功能不正常的问题
    this.delayUpdateFormId = setTimeout(()=>{
      formProvider.updateForm(formId, data).then((data) => {
        console.info('FormAbility updateForm success.' + JSON.stringify(data));
      }).catch((error) => {
        console.error('FormAbility updateForm failed: ' + JSON.stringify(error));
      })
    }, 1500)
    return data
  }
  // 注意销毁时同步销毁定时器,避免内存泄漏
  onRemoveForm(formId) {
    // Called to notify the form provider that a specified form has been destroyed.
    if (typeof this.delayUpdateFormId !=='undefined' || this.delayUpdateFormId !== null) {
      this.delayUpdateFormId && clearTimeout(this.delayUpdateFormId)
    }
 }
}

总结

目前我主力使用的计算器就是本文自己开发的计算器,添加服务卡片到桌面上后,直接就能计算,再也不用找来找去了,非常省事! 通过此计算器应用以及桌面万能卡片开发,基本能把HarmonyOS应用开发ArkTS/ArkUI相关基础知识,如UI开发、状态管理、渲染控制等练习得比较熟练。实际企业项目中还需要涉及到网络通信、数据库、动画、系统级原生能力等知识的应用,这些都可以快速从文档中获得相关API运用,无需担忧。 起码,现在跟着此文你可以明白一个简单的HarmonyOS原生应用该如何去开发。

此文有帮助到你吗?喜欢的话可以点个关注,点个Star再走~

GitHub源码地址:github.com/hello-jun/C...

Gitee源码地址:gitee.com/luckyzjun/C...

相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang2 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、5 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui