news 2026/7/14 20:32:33

HarmonyOS菜谱——ArkTS语言特性的实战应用总结

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HarmonyOS菜谱——ArkTS语言特性的实战应用总结

适用读者:已经完成菜谱 App 开发、想系统梳理 ArkTS 语言特性的开发者。本文不讲解语法基础,而是从项目实际代码出发,分析每个语言特性"在什么场景下用、为什么这样用、不用会怎样"。

完整效果

一、四个装饰器的职责划分

菜谱 App 用到了四个装饰器:@Entry@Component@State@Builder。每个装饰器解决一个特定问题。

@Entry + @Component:页面入口声明

@Entry@Componentstruct Index{build(){/* ... */}}

@Entry标记这个 struct 是页面入口。一个 .ets 文件里只能有一个 @Entry struct。多个 @Entry 会导致编译错误——系统不知道哪个是入口。

@Component标记这个 struct 是可复用组件。没有 @Component 的 struct 不能用build()方法,也不能使用 @State 等状态装饰器。

为什么 @Entry 和 @Component 要一起用?@Entry声明"这是页面入口",@Component声明"这是一个组件"。两者缺一不可——只有 @Entry 没有 @Component,struct 不是组件,无法渲染;只有 @Component 没有 @Entry,struct 是组件但不是页面入口,无法独立运行。

踩坑记录:最初给 RecipeData 的接口也加了 @Component,导致编译报错——接口不能用 @Component 装饰。@Component 只能用在 struct 上,不能用在 interface 或 class 上。

@State:页面级状态管理

@StatesearchText:string=''@Staterecipes:Recipe[]=[]@Statefav:boolean=false@Stateservings:number=1

@State 让 struct 的属性成为响应式状态。当 @State 变量的值变化时,框架会自动重新调用build()方法,更新 UI。

@State 的三个限制:

限制原因项目中的体现
只能在 @Component 内使用@State 是组件级状态RecipeData 里的函数不能用 @State
修改基本类型直接赋值触发框架检测值变化this.servings++直接触发刷新
修改数组需要替换引用框架检测引用变化this.recipes = copy必须创建新数组

"检测引用变化"是 @State 最容易踩坑的地方。直接修改数组元素(this.recipes[0].name = '新名字')不会触发刷新——因为数组引用没变。必须创建新数组并替换引用(this.recipes = newArray)才能触发。

@Builder:组件内复用的 UI 片段

@BuilderNavBtn(label:string,active:boolean){Column({space:2}){Text(label==='菜谱'?'📖':(label==='收藏'?'❤️':'🛒')).fontSize(18)Text(label).fontSize(9).fontColor(active?'#FF6B6B':'#C0BFC6')}.onClick(()=>{/* ... */})}

@Builder 是组件内的 UI 复用机制——不是独立组件。@Builder 定义在 @Component 内部,只能在当前组件的build()方法里调用。它不能跨组件复用——如果另一个页面也需要 NavBtn,需要重新定义。

@Builder vs @Component 的选择标准:

特性@Builder@Component
定义位置@Component 内部独立 struct
复用范围当前组件内跨组件、跨页面
参数传递支持支持
状态管理不能用 @State可以用 @State
适用场景简单的 UI 片段有独立状态的组件

菜谱 App 只有 1 个 @Builder(NavBtn),因为页面结构简单,没有需要跨组件复用的复杂 UI 片段。

二、interface 的设计模式

单层接口

interfaceCategory{name:string;icon:string;color:string}interfaceIngredient{name:string;amount:string}

简单的数据结构用单层接口——字段都是基本类型(string、number),没有嵌套。

嵌套接口

interfaceRecipe{id:number;name:string;icon:string;category:string;time:string;difficulty:stringservings:string;desc:string;ingredients:Ingredient[];steps:string[];tips:string}

复杂的数据结构用嵌套——Recipe 的ingredients字段是Ingredient[]类型,引用了另一个接口。

嵌套接口的设计原则:如果一个字段的数据结构会被多处复用(Ingredient 在 RecipeDetail 的食材列表和加入清单功能里都用到),就抽成独立接口。如果只在一个地方用(比如 Recipe 的 tips 字段),不需要抽接口。

接口 vs type alias

// 接口interfaceRecipe{id:number;name:string}// type aliastypeRecipe={id:number;name:string}

项目里全部用 interface 而不是 type。原因是 interface 在 TypeScript/ArkTS 里有更好的错误信息——当类型不匹配时,interface 的报错会指出具体哪个字段有问题,type 的报错可能更模糊。在开发阶段,清晰的错误信息能节省调试时间。

三、类型系统的四个实战用法

用法一:联合类型处理可选数据

exportfunctiongetRecipeById(id:number):Recipe|undefined{for(leti:number=0;i<RECIPES.length;i++){if(RECIPES[i].id===id)returnRECIPES[i]}returnundefined}

Recipe | undefined是联合类型——返回值可能是 Recipe 也可能是 undefined。调用方必须处理 undefined 的情况,否则访问recipe.name时会报错。

为什么不返回 null?TypeScript/ArkTS 的惯例是用 undefined 表示"不存在",null 表示"空值"。undefined在语义上更明确——"这个 id 没有对应的菜谱"比"找到一个空的菜谱"更准确。

用法二:类型断言处理路由参数

constp=router.getParams()asRecord<string,Object>this.cat=p['cat']asstring

as Record<string, Object>是类型断言——告诉编译器"我知道这个值的类型"。router.getParams()返回的是Record<string, Object> | undefined,用 as 强制断言为对象类型,方便后续通过 key 访问。

类型断言的风险:如果断言的类型和实际类型不匹配,运行时会报错。比如p['cat'] as number实际上p['cat']是字符串,后续用数字操作会出错。类型断言是"开发者和编译器之间的信任契约"——开发者保证类型正确,编译器不再检查。

为什么不用p?.['cat'] as string可选链 + 断言也能实现,但代码可读性不如先判断再断言:

// 推荐:先判断再断言if(p&&p['cat']){this.cat=p['cat']asstring}// 不推荐:可选链 + 断言(嵌套太深)this.cat=(p?.['cat']asstring)??''

用法三:泛型数组的类型标注

@Staterecipes:Recipe[]=[]let_favs:number[]=[]let_shop:string[]=[]

数组类型用T[]标注——明确数组元素的类型。Recipe[]表示"Recipe 类型的数组",number[]表示"数字类型的数组"。没有类型标注的数组(let arr = [])会被推断为any[],失去类型检查。

踩坑记录:最初getFavs的返回类型写成了Recipe[]但没有在函数签名里标注,编译器推断为any[]。调用方this.recipes = getFavs()不报错,但this.recipes[0].name会报"any 上不存在 name 属性"的错误。加上显式返回类型Recipe[]后问题解决。

用法四:非空断言处理 @State 变量

if(idx<this.recipe!.steps.length-1){Row(){}.width(2).height(16).backgroundColor('#FFF0F0').margin({left:14})}

this.recipe!是非空断言——告诉编译器"this.recipe 一定不是 undefined"。外层已经有if (this.recipe)判断,进入这个分支后this.recipe肯定有值。但 TypeScript 编译器不会自动推断 if 分支内的类型收窄(因为 @State 变量可能在异步操作中被改为 undefined),所以需要手动断言。

为什么不用可选链?this.recipe?.steps.length虽然也能避免 undefined 错误,但可选链返回的是number | undefined,后续的比较运算(< this.recipe!.steps.length - 1)需要额外处理 undefined。非空断言更直接——断言后直接当 Recipe 类型使用。

四、字符串操作的实战技巧

拼接显示文本

Text(this.recipe.name).fontSize(15)Text('⏱ '+this.recipe.time+' · '+this.recipe.difficulty)Text(this.timerMin.toString()+':'+(this.timerSec<10?'0':'')+this.timerSec.toString())

三种拼接场景:

场景写法例子
简单拼接'前缀' + 变量'⏱ ' + r.time
多段拼接'A' + 变量1 + ' · ' + 变量2'⏱ ' + r.time + ' · ' + r.difficulty
条件拼接三元表达式 + toString()计时器的补零格式化

toString()的必要性。数字类型不能直接和字符串拼接——this.timerMin + ':'会把:当成数字相加而不是字符串拼接。必须先.toString()转成字符串。

踩坑记录:最初计时器显示写成了this.timerMin + ':' + this.timerSec,结果显示的是数字相加的结果(比如3:5显示为35而不是03:05)。加上toString()和补零逻辑后正常。

trim() 处理用户输入

privateadd():void{if(this.input.trim().length===0)returnaddToShop(this.input.trim())this.input=''this.refresh()}

trim()去除首尾空格后再检查长度。用户可能输入空格后点击添加,trim()保证不会添加空白项。addToShop也用trim()后的值,保证存入的数据是干净的。

五、循环与数组操作的四种模式

模式一:for 循环遍历

for(leti:number=0;i<RECIPES.length;i++){if(RECIPES[i].category===cat)r.push(RECIPES[i])}

ArkTS 里最稳妥的遍历方式。虽然 TypeScript 支持forEachmapfilter等高阶函数,但 ArkTS 对这些函数的支持不如 TypeScript 完善。用 for 循环最可靠,不会遇到兼容性问题。

模式二:for 循环创建新数组

constn:number[]=[]for(letj:number=0;j<_favs.length;j++){if(j!==i)n.push(_favs[j])}_favs=n

"创建新数组 + 替换引用"是 @State 刷新的标准模式。不直接修改原数组,而是创建一个新数组,过滤或转换后替换 @State 的引用。

模式三:双重嵌套循环

for(leti:number=0;i<RECIPES.length;i++){for(letj:number=0;j<_favs.length;j++){if(RECIPES[i].id===_favs[j]){r.push(RECIPES[i]);break}}}

外层遍历数据源,内层遍历条件列表。break在找到匹配后立即退出内层循环,避免不必要的遍历。时间复杂度 O(n×m),在数据量小的场景下可接受。

模式四:ForEach 遍历渲染

ForEach(this.recipes,(r:Recipe)=>{Row(){/* 菜谱卡片 */}})

ForEach 是 ArkTS 的 UI 遍历语法——专门为列表渲染设计。和 for 循环不同,ForEach 的回调函数返回的是 UI 组件,不是数据操作。ForEach 的第二个参数是当前项的索引(idx),在 ShoppingListPage 的删除操作里用到了。

四种模式的选择场景

场景模式原因
查询/过滤数据for 循环遍历需要条件判断和 push
修改 @State 数组for 循环 + 创建新数组需要替换引用触发刷新
多条件匹配双重嵌套循环两个数组的交叉匹配
渲染列表 UIForEach专门为 UI 遍历设计

六、路由系统的三个 API

router.pushUrl:页面跳转

router.pushUrl({url:'pages/CategoryPage',params:{'cat':cat.name}asRecord<string,Object>})

url是目标页面的路径——相对于 pages 目录。不需要写文件扩展名(.ets),也不需要写完整路径。

params是传递给目标页面的参数。参数类型必须是Record<string, Object>——键是字符串,值是 Object。基本类型(string、number)需要通过as Record<string, Object>断言。

router.back:返回上一页

router.back()

最简单的路由 API——不需要参数。自动返回上一个页面。如果上一个页面存在,直接显示;如果不存在(比如从通知直接打开详情页),行为取决于系统配置。

router.getParams:接收路由参数

constp=router.getParams()asRecord<string,Object>if(p&&p['cat']){this.cat=p['cat']asstring}

返回值是Record<string, Object> | undefined没有参数时返回 undefined,需要判断后再使用。取值后需要类型断言(as stringas number),因为 Object 类型不支持直接操作。

三个 API 的使用时机

API使用场景调用位置
pushUrl从当前页跳到新页面onClick 回调
back从新页面返回当前页返回按钮 onClick
getParams新页面接收上一页传来的数据aboutToAppear

七、生命周期方法的使用

aboutToAppear:页面创建时

aboutToAppear():void{constp=router.getParams()asRecord<string,Object>if(p&&p['id']){this.recipe=getRecipeById(p['id']asnumber)}}

aboutToAppear在页面首次创建时触发——接收路由参数、初始化数据。它是页面生命周期的第一个方法,保证在build()之前执行。

onPageShow:页面显示时

onPageShow():void{this.refresh()}

onPageShow在页面每次显示时触发——从其他页面返回时也会调用。用于刷新数据——用户在详情页取消收藏后返回收藏页,onPageShow重新查询最新数据。

两个方法的区别

方法触发时机适用场景
aboutToAppear页面首次创建接收路由参数、初始化 @State
onPageShow页面每次显示刷新数据、同步状态

踩坑记录:最初 FavoritesPage 只写了aboutToAppear没写onPageShow,用户在详情页取消收藏后返回收藏页,列表没有更新。加上onPageShow后每次显示都重新查询,问题解决。

八、模块导入的规则

importrouterfrom'@ohos.router'import{CATS,RECIPES,Category,Recipe}from'../services/RecipeData'import{getRecipeById,toggleFav,isFav,addToShop}from'../services/RecipeData'

三种导入方式:

方式语法适用场景
默认导入import X from 'path'整个模块(如 router)
命名导入import { A, B } from 'path'指定的导出项
路径'@ohos.xxx''../services/X'系统模块用 @ohos,本地模块用相对路径

命名导入的优势:只导入需要的项,不导入整个模块。import { CATS, RECIPES }import * as Data from 'path'更精确——代码里用到什么就导入什么,不会引入不必要的依赖。

踩坑记录:最初把所有导出项写在一行import { CATS, RECIPES, Category, Recipe, getRecipeById, toggleFav, isFav, getFavs, addToShop, getShop, clearShop, removeFromShop } from '../services/RecipeData',行太长不好读。改成按需导入——每个页面只导入用到的项,代码更清晰。

九、颜色透明度拼接的规律

A+'12'// #FF6B6B12 → 分类图标背景色(7% 透明度)A+'25'// #FF6B6B25 → 投影颜色(14% 透明度)A+'30'// #FF6B6B30 → 边框颜色(18% 透明度)'rgba(0,0,0,0.03)'// 返回按钮背景(3% 透明度)'rgba(0,0,0,0.05)'// 导航栏投影(5% 透明度)

透明度值的选择规律:

元素类型透明度范围原因
大面积背景5%-12%太浓会抢内容的视觉焦点
边框/线条15%-20%需要可见但不刺眼
投影3%-14%装饰性元素,越淡越自然
按钮背景8%-15%需要可识别但不抢文字

颜色 + 两位十六进制透明度的格式。A + '12''12'是十六进制的 18(0x12 = 18),表示 18/255 ≈ 7% 透明度。这种写法比rgba()更简洁,但透明度值需要手动计算十六进制。

十、ArkTS 的限制与应对

限制表现应对方式
高阶函数支持有限filtermap可能有兼容问题用 for 循环替代
@State 只检测引用变化修改数组元素不触发刷新创建新数组替换引用
@Builder 不能跨组件不能在另一个页面调用改用 @Component 或重复代码
路由参数只支持基本类型不能传对象/数组传 id,目标页面重新查询
Set/Map 支持不完善可能有兼容问题用数组 + for 循环替代

这些限制不是缺点——它们是 ArkTS 为了性能和安全性做的取舍。限制高阶函数是为了避免闭包带来的性能问题;限制 @State 检测引用变化是为了降低追踪成本;限制路由参数类型是为了保证序列化安全。理解这些限制背后的原因,才能写出更符合 ArkTS 设计理念的代码。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/14 20:27:39

Unity 之 Android Logcat实战:从设备连接到符号表解析疑难排查

1. 为什么需要Android Logcat调试工具 做Unity开发的朋友应该都遇到过这样的场景&#xff1a;游戏在编辑器里跑得好好的&#xff0c;一打包到安卓真机就各种闪退崩溃。最头疼的是&#xff0c;错误日志里全是 libunity.0x123456(Native Method) 这样的十六进制地址&#xff0c…

作者头像 李华
网站建设 2026/7/14 20:27:28

预训练模型 ---- BERT的双向编码器架构与微调实战

1. BERT的双向编码器架构解析第一次接触BERT时&#xff0c;我被它"双向编码"的特性惊艳到了。传统的语言模型像GPT只能从左到右分析文本&#xff0c;而BERT能同时看到单词的左右上下文&#xff0c;就像我们人类阅读时自然理解句子的方式。这种突破性的设计源于Transf…

作者头像 李华
网站建设 2026/7/14 20:25:12

博弈论重构PCA:面向比特币价格预测的策略共识特征提取

1. 项目概述&#xff1a;当博弈论撞上主成分分析&#xff0c;再联手深度学习押注比特币价格你有没有试过用PCA降维后发现关键信号全被“平滑”掉了&#xff1f;或者训练了一个看似完美的LSTM模型&#xff0c;结果在比特币价格剧烈跳空时直接失效&#xff1f;我做量化策略开发整…

作者头像 李华
网站建设 2026/7/14 20:25:02

高性能电机控制方案:L9958与PIC18F57Q43的完美结合

1. 高性能电机控制方案的核心价值在工业自动化设备、机器人关节控制和精密仪器领域&#xff0c;电机控制系统的性能瓶颈往往决定了整个设备的响应速度与定位精度。传统方案使用分立MOSFET搭建驱动电路&#xff0c;配合通用MCU实现控制&#xff0c;不仅开发周期长&#xff0c;还…

作者头像 李华
网站建设 2026/7/14 20:24:37

电脑如何进行磁盘碎片整理?【图文讲解】完整安全提速实操教程

&#xff08;1&#xff09;问题背景 不少朋友手里的电脑越用越拖沓&#xff0c;打开文件夹要卡几秒&#xff0c;复制视频、压缩包进度慢得离谱&#xff0c;开机时间直接拉长一倍&#xff0c;明明没装一堆流氓软件&#xff0c;也定期清空回收站、浏览器缓存&#xff0c;可卡顿问…

作者头像 李华