寫在前面
最近擼完了HarmonyOS應用開發的相關知識,也通過了高級認證以及HCIP課程,正想着實戰仿個經典的網易雲Demo練練時收到了水電繳費單,看着用水量、二次供水費用、污水處理費、電費、維修基金等等一堆項目,難免要掏出計算器出來核算下金額,結果我滑了半天屏沒找到,只好下拉搜索計算器才找到,事後我在一個大文件夾的眾多圖標中終於找到了計算器(吐槽下,這個圖標色彩真不顯眼)。
這時,我眉頭一皺,就在想,能不能有款計算器,可以提供放在手機桌面直接輸入計算的功能?不要讓我在眾多應用中眼花繚亂地去尋找,也不需要我下滑拉起搜索框去輸入計算器(遙遙領先下滑喚起搜索框會卡頓下🙂)。
仔細一想,有了!這不就是桌面萬能卡片嗎?文檔中也叫服務卡片官方對外白皮書中術語叫做萬能卡片。
搞起搞起,先來看看最終實現效果:
| 桌面卡片效果 | 應用效果 |
|---|---|
直接在桌面進行計算,也可以在應用圖標上上滑喚起卡片計算,也可以點擊打開應用計算。非常nice。
項目分析
開工前參考了MAC端/PC端/移動端(ios & HarmonyOS)系統原生的計算器設計,大致思考分析了項目,包括需求、詳細功能、可能用到的知識點、一些細節處理等,放了方便對照覆盤,直接上腦圖:
當然,項目中的問題是最後加的。
項目的核心目的是為了將學到的相關知識通過自己的腦子思考與雙手敲碼實際轉換成應用,因此,需要儘可能完善地實現應用功能,不要偷懶,也不要輕視簡單的知識點,不然每漏掉的一個點都可能是金鐘罩上的一個命門。
根據官方文檔中的應用開發以及服務卡片開發指南,可以瞭解到,只需要使用ArkTS、ArkUI,應用開發與卡片開發絕大部分特徵是一致的,其中,服務卡片只支持部分能力,因此,先開發計算器應用,然後在卡片開發中複用絕大部分代碼,針對於不支持的能力再做相關調整,這樣可以節省大量編碼工作。
計算器應用開發
為了方便處理,在這裏只實現具有+-x÷四則運算的標準計算器。對於其他變種計算器,如科學計算器、貨幣換算、甚至親戚計算器之類的,感興趣的同學可以自己實現。它們涉及到的HarmonyOS開發知識點與此計算器基本一致,只是數據與背後的邏輯處理不一致而已。
UI開發
佈局
-Flex 採用一個主軸方向為Column的Flex佈局填滿整個屏幕,這樣可以在不同的屏幕上伸縮自適應鋪滿
--Grid 一個6行4列的網格,放置計算器操作界面
---GridItem 網格子元素,第一項為輸入歷史以及結果展示區域,後續每一項都是按鍵。其中0佔據1行2列,=佔據2行1列
--Text 應用底部信息
輸入歷史以及結果顯示
此部分區域需要做到:
- 展示用户除鍵入
=符號外所有的輸入歷史,超出區域時,需要換行滾動,並且永遠自動定位到最後一行展示最新輸入; - 展示用户鍵入後的計算結果,超出區域時,同輸入歷史處理邏輯展示
// ... 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', '+', '-', '=']。所有按鍵結構都如下:
// ... 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所示:
數據結構
整個應用的核心邏輯即:從舊到新依次讀取用户的輸入進行計算。先輸入的先讀取,符合隊列的特徵,因此此處採用字符串數組的方式存儲用户的輸入歷史:
@State historyInputArr: string[] = []
//Example: ['1','+','2','*','3']
對於計算結果,需要使用Text組件展示,因此採用字符串類型:
@State result: string = ''
按鍵鍵入處理
按鍵鍵入頂層分為2個類型進行分發處理:值鍵入處理 & 操作符號鍵入處理。
值鍵入處理
值鍵入時,直到操作符鍵入之前都算當前輸入,注意以下4點:
- 小數點鍵入。不能連續鍵入小數點,已存在小數點的情況下不能再繼續輸入小數點;
- 0鍵入。當本次輸入記錄第一項為0時,不能連續輸入0;
- 整體長度限制。由於 IEEE-754 浮點數精度丟失問題 限制整數情況下,最多連續鍵入16個字符,限制小數情況下,最多連續鍵入17個字符;
- 與前面的輸入歷史構成有效可執行表達式時觸發
=邏輯,實時計算結果。
// ... 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
清除所有數據,將計算器還原至初始狀態
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最後一項的空字符串的處理。
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()
}
=
最簡單的操作符處理邏輯,僅用來觸發計算方法。觸發後,允許顯示計算結果(計算器初始狀態不顯示計算結果區域)
equalHandler = (): void => {
this.result = Calculate(this.historyInputArr)??''
this.canShowResult = true
}
運算符+-x/
運算符鍵入後,一般直接追加至輸入記錄,無需額外處理。只需注意以下2點:
- 第一次輸入可以是
-,且不能連續輸入,且無法被其他運算符覆蓋; - 除1的情況外,連續鍵入運算符將替換上一次鍵入的運算符;
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點處理:
- 某一項值是
.,需返回錯誤; - 除數是0,需返回錯誤;
- 對於不完整的小數格式需要補全或者取整。如:
.3,3.; - 第一項是- 代表是負值,特殊處理;
- 如果最後一項是操作符,則忽略此項;
-
計算結果轉換時,需處理IEEE-754浮點數精度問題
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中的行列數量以及佔比的配置。
可參考文檔進行調整:
- 柵格佈局-子組件佔比設置
- 柵格佈局-柵格組件的嵌套使用
-
網格佈局-網格行列設置
-Flex 採用一個主軸方向為Column的Flex佈局填滿整個屏幕,這樣可以在不同的屏幕上伸縮自適應鋪滿 --GridRow 柵格容器組件 ---GridCol 柵格子元素 --Text 應用底部信息最終效果:
展示區域字體大小動態縮放
由於卡片的能力限制,不支持Scroll,為了在輸入歷史以及結果展示過長時仍能正常顯示,對這2個文本根據長度動態縮放字體大小。
在這裏,採用@Watch監聽輸入歷史以及計算結果的長度,然後計算字體的大小(每8個字符 縮小2,最小縮至8,放大同理):
// ... 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事件刷新卡片內容
-
卡片ets文件中, = 鍵入,調用應用中的計算方法
// 卡片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 } -
應用 UIAbility中監聽 callee 事件:
// 監聽處理,調用應用中的計算方法得到計算結果,並刷新卡片 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中運行應用/服務-使用本地真機運行應用/服務項目中遇到的問題
Q1-常量文件內容變動無效
當你在ets文件中引入一個常量模塊,如:
import Constants from '../constants'如果
constants文件內容有變更時,比如新增一個常量static readonly DOT_KEY: string = '.'ets文件中無法索引到Constants.DOT_KEY,編輯器會提示報錯。
解決方案:
此為 DevEco Studio bug,重新同步項目即可。點擊菜單欄:File->Sync and Refresh Project, 等待同步完成,報錯消失。
Q2-ts文件中不能引用ets文件
規則如此。建議UI界面文件使用.ets,其他的文件使用ts,如:工具函數文件、常量文件等
Q3-卡片部分能力不支持
開發中遇到卡片UI無法使用import、Scroll、Grid等情況,原因是卡片的能力限制。需要使用其他的方案平替。
Q4-不允許使用eval()/new Function()/Function()
安全性要求
Q5-真機運行無法看到console控制枱日誌
DevEco Studio Bug 或者 HarmonyOS 4.0 bug,在手機上關閉重新打開開發者模式,再打包運行即可看到。
Q6-卡片添加到桌面後計算過程中沒有顯示計算結果
HarmonyOS 4.0 bug,首次添加後,桌面應用作為卡片使用方,沒有收到有效的 formId,二次添加後才正常。
解決方案:
EntryFormAbility onAddForm 生命週期中延遲手動刷新一次 formId。
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源碼地址:https://github.com/hello-jun/Calculator
Gitee源碼地址:https://gitee.com/luckyzjun/Calculator