拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

HarmonyOS API 22之前UI开发:组件创建、属性设置与状态管理实战

HarmonyOS API 22之前UI开发:组件创建、属性设置与状态管理实战

做HarmonyOS原生开发的朋友应该都有体会,UI组件的创建和属性设置,是每天写代码绕不开的基本功。在API 22之前,HarmonyOS的UI开发已经形成了一套相对稳定的范式:以ArkUI声明式开发为主,开发者不再像早期Java UI那样手动控制View树,而是通过@Entry、@Component这些装饰器声明页面,再在build方法里把组件和属性方法按声明顺序组合起来。这篇文章不讲抽象概念,就讲实际操作:组件怎么建、属性怎么设、状态怎么联动、踩过哪些坑,附带完整可复现的示例,帮你把API 22之前这套写法的底层逻辑彻底理清楚。

1. API22之前的UI开发范式:为什么版本比想象中重要

1.1 版本演进与声明式模式定型

聊HarmonyOS的UI开发,必须先聊API版本。标题里的“API 22之前”,严格来说覆盖了一整段演进周期:从API 9引入ArkUI声明式开发范式开始,到API 12伴随HarmonyOS NEXT的ArkTS全面落地,再到后续SDK持续迭代。API 9之前,HarmonyOS还同时存在Java UI和JS UI两套写法,那时候创建组件、设置属性基本都是命令式的:

  • Java UI里,需要先拿到ComponentContainer,new一个Text或Button,再通过setText、setTextSize等方法逐个设置属性,最后手动addComponent到容器里。
  • JS UI里,虽然写法靠近前端,但支持的组件和自定义能力都比较有限,遇到复杂交互经常会绕路。

API 9之后,ArkUI声明式范式成为主流,组件的创建与属性设置变成了一段集中描述UI长什么样的结构体代码。这个转变本质上是从“告诉系统怎么做”变成“告诉系统我要什么”,系统的渲染引擎自己会去diff、更新。API 22之前,这套声明式模型已经酝酿并成熟,组件创建、属性设置的操作规律也基本稳定下来,之后版本更多是能力和性能的增强,而核心心智模型没有推翻。

1.2 声明式UI的心智模型:状态驱动视图

我把这套模型的精髓总结成一句话:build方法里的代码,就是UI的“图纸”;状态变量是图纸上的“可替换零件”。当状态变化时,系统重新“印发”图纸,受影响的组件自动更新。

举个例子,命令式写法里,如果你想把按钮文字从“登录”改成“登录中”,需要先拿到按钮实例,再调用setText方法。声明式写法里,你只需要在组件属性里绑定一个状态变量:

@State loginText: string = '登录' ... Button(this.loginText) .onClick(() => { this.loginText = '登录中...' })

系统发现loginText变了,自动重新渲染按钮。你不用关心按钮实例在内存里叫什么名字,也不用自己调用任何刷新方法。这种心智模型,和React的setState、Vue的ref/响应式状态一模一样,如果之前写过前端,上手几乎没有认知成本。

理解这一点之后,再去看“组件创建”和“属性设置”这两个操作,就会发现他们其实是一件事的两面:创建组件是把UI节点放进视图树,属性设置是往节点上挂各种配置和事件。

2. 组件创建:从页面入口到自定义封装

2.1 页面入口:@Entry装饰器与struct组件

在API 22之前,一个页面的标准外壳长这样:

@Entry @Component struct Index { build() { // 组件树从这里开始 } }

@Entry标记了一个页面的入口,App启动时首先加载它。@Component表示下面这个struct是一个自定义组件,它可以是一个页面,也可以是页面里的一块区域。struct里必须实现build方法,build里只能有一个根组件(可以是一个Column/Row等容器,也可以是单个组件),组件树通过嵌套形成布局层级。

这里有个新手容易踩的坑:@Entry不一定是页面里唯一的@Component,一个页面可以由多个自定义组件组成。但@Entry只能有一个,且要写在能代表整个页面的那个组件上。如果把@Entry写错位置,IDE会直接报“Only one @Entry is allowed”之类的错误。

2.2 基础组件的创建要点

在build方法里创建组件,本质就是“调用组件的构造函数”。最常用的几个组件,构造参数需要记清楚:

Column() { Text('这是一段文本') .fontSize(16) .fontColor('#333333') Button('点击我') .type(ButtonType.Capsule) .width(200) .height(48) Image($r('app.media.logo')) .width(120) .height(120) .borderRadius(60) }

Text可以直接传入字符串或资源引用;Button第一个参数是按钮文案,第二个可选参数可以配置按钮类型(比如轮播样式的Capsule或圆形的Circle);Image的构造参数是图片来源,可以是resources下的Media资源$r('app.media.xxx'),也可以是网络地址字符串,或者PixelMap内存图片。

这里强调一下Image资源引用的用法:API 22之前,老项目里经常出现直接写图片路径字符串的情况,比如Image('file:///data/xxx.png'),在真机上经常因为权限问题加载失败。正确做法是先把图片放进resources/base/media目录,然后用$r('app.media.xxx')引用。$r返回的是一个Resource对象,它比裸字符串更可靠,还能自动匹配不同屏幕密度下的资源。

再比如列表组件List、滚动组件Scroll,构造语法和Column类似,但内部子组件有数量、懒加载等方面的限制,这属于组件自身的规则,和“属性设置”关系不大,可以先不纠结。

2.3 自定义组件与@Builder复用机制

如果一段UI在多个页面重复出现,别复制粘贴。API 22之前的推荐做法有两种:自定义组件或@Builder函数。

自定义组件的核心结构如下:

@Component struct Avatar { @Prop name: string @Prop size: number = 48 build() { Column() { Image($r('app.media.avatar')) .width(this.size) .height(this.size) .borderRadius(this.size / 2) Text(this.name) .fontSize(14) } } }

在父组件中直接像基础组件一样使用它:

Avatar({ name: '张三', size: 64 })

自定义组件的优势是自带封装性,但注意要在struct里使用@Prop、@Link等装饰器同步状态,不能直接传普通HashMap之类的复杂类型,后面会专门讲状态同步。

@Builder则是轻量级复用方案,它本质是一个生成UI描述的函数:

@Builder function Greeting(text: string) { Text(text) .fontSize(20) .fontWeight(FontWeight.Bold) }

在build中调用即可:

build() { Column() { Greeting('早上好') Greeting('中午好') Greeting('晚上好') } }

@Builder适合复用“组件片段”,自定义组件适合复用“带状态的完整UI块”。这两者没有绝对优劣,但API 22前后的官方样例里,自定义组件出现频率明显高于@Builder,原因在于状态管理和生命周期更立体。

3. 属性设置:链式调用背后的设计逻辑

3.1 属性方法为什么能链式调用

在声明式UI中,组件创建后马上被多个属性方法修饰。这类方法的返回值,就是组件自身。比如Text().fontSize(20).fontColor('#333'),fontSize返回Text对象,fontColor继续在同一个对象上操作,于是就可以一直点下去。

这种设计让代码顺序和视觉声明顺序一致:宽度在哪、间距在哪、字体在哪,读代码的时候一目了然。相比之下,命令式写法里属性分散在多个代码块,维护时需要在类文件里上下跳转。

链式调用有一个容易被忽略的优点:属性顺序本身就是一种隐性文档。有经验的开发者会把“影响布局的尺寸类属性放在前面,视觉类属性放中间,事件绑定放最后”,这样别人读起来能快速抓住重点。

3.2 常见属性分类大盘点

API 22之前,ArkUI组件的属性方法虽然多,但分类很清晰,可以对照下面这个表来系统性记忆:

类别典型属性方法作用
尺寸类width、height、size、constraintSize控制组件宽高,支持数字vp、百分比
布局类padding、margin、alignSelf、position、offset、flex控制组件在容器中的位置与边距
视觉类backgroundColor、borderRadius、border、shadow、opacity、blur控制颜色、圆角、边框、阴影等
文本类fontSize、fontColor、fontWeight、fontStyle、textAlign、lineHeight仅文本类组件可用
事件类onClick、onTouch、onAppear、onDisAppear、onAreaChange绑定交互与感知事件

属性设置不是只有样式,事件绑定也属于属性设置范畴。在命令式UI里,事件监听和属性设置是分开的两个体系;在ArkUI里,onClick本身就是挂在组件后的一个属性方法。理解了这一点,就不会在写事件时忘了链式写法。

3.3 属性设置的几个容易被忽略的规则

链式写法看起来自由,实际上有几个隐性规则,是API 22之前的开发中我踩过最多的坑:

先设置尺寸再设置位置类属性。如果先写position后写width,某些布局场景下width会被外部约束修正,表现不符合预期。合理顺序是:尺寸、边距、视觉、文本、事件。

单位问题。数字默认单位是vp(虚拟像素),字体默认单位是fp(跟随系统字体缩放)。可以直接写数字,也可以写'100%',但百分比的分母是父容器尺寸,不是组件自己的初始尺寸。字体不能用'30%'这种写法,必须用数字或资源。

动态属性值别忘了用ternary表达式。很多场景下,属性值要根据状态变化,比如按钮不可用时透明度降低:

Button(this.label) .opacity(this.isLoading ? 0.5 : 1) .enabled(!this.isLoading)

直接在属性方法里做三元判断,是声明式UI最常用的写法,比在外面写一堆if然后复制代码要清爽得多。

不要在build方法里做耗时操作或随机值生成。build方法可能会被调用多次,随机值会导致UI闪烁、状态不稳定。属性值应该来自状态变量或纯计算函数。

3.4 完整登录页:组件创建+属性设置实操

下面这个登录页,聚集了前面说的组件创建和属性设置技巧,比较有代表性:

@Entry @Component struct LoginPage { @State username: string = '' @State password: string = '' @State isLogin: boolean = false build() { Column() { // Logo容器 Column() { Image($r('app.media.logo')) .width(80) .height(80) .borderRadius(40) } .width('100%') .padding({ top: 80, bottom: 40 }) // 用户名输入框 TextInput({ placeholder: '请输入用户名', text: this.username }) .width('85%') .height(48) .backgroundColor('#F5F5F5') .borderRadius(8) .padding({ left: 16, right: 16 }) .onChange((value: string) => { this.username = value }) // 密码输入框 TextInput({ placeholder: '请输入密码', text: this.password }) .type(InputType.Password) .width('85%') .height(48) .backgroundColor('#F5F5F5') .borderRadius(8) .padding({ left: 16, right: 16 }) .margin({ top: 16 }) .onChange((value: string) => { this.password = value }) // 登录按钮 Button(this.isLogin ? '登录中...' : '登录') .width('85%') .height(48) .margin({ top: 32 }) .backgroundColor('#007DFF') .borderRadius(24) .fontColor('#FFFFFF') .fontSize(16) .enabled(!this.isLogin) .onClick(() => { if (!this.username || !this.password) { return } this.isLogin = true // 模拟登录请求 setTimeout(() => { this.isLogin = false }, 2000) }) } .width('100%') .height('100%') .backgroundColor('#FFFFFF') } }

这段代码里有两个关键点:登录按钮的文案和禁用状态都跟着isLogin状态走,实现了“点击后置灰并转文案”的用户反馈;整个页面所有组件都是通过属性方法做样式微调,没有一句手动刷新逻辑。这就是API 22之前HarmonyOS原生UI操作的日常形态。

4. 状态管理驱动属性动态变化

4.1 一个属性值是如何自动更新的

前面提到属性方法可以绑定状态变量,但很多人不知道底层实现逻辑。简单说,装饰器会建立一条“依赖收集”链路:当状态变量被读取(比如Text(this.message)),该组件会被自动标记为“依赖这个消息变量”;当状态变量被赋值修改时,系统会把依赖它的组件标记为脏,并在下一帧重建这部分UI。

所以,不要在build里给状态变量赋值,那会触发无限循环。正确做法是:build里只读状态,状态变量在事件回调、生命周期、定时器里修改。

以下是一个计数器按钮的标准写法:

@Entry @Component struct Counter { @State count: number = 0 build() { Column() { Text(`当前计数:${this.count}`) .fontSize(24) .fontWeight(FontWeight.Bold) Button('加一') .margin({ top: 20 }) .onClick(() => { this.count++ }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }

count状态每次变化,Text和Button都会重新跟渲染引擎同步一次,但不会白白重建整个页面,系统内部有diff机制。这也是声明式UI性能可靠的根基。

4.2 @Prop和@Link的父子组件同步

自定义组件的成员变量,如果只是普通变量,父组件改变值后子组件不会刷新。要让子组件的属性跟着父组件走,需要用装饰器。

@Prop是单向同步:父组件传值给子组件,子组件内部赋值,不会影响父组件。@Link是双向同步:子组件改了,父组件对应的状态也改。做一个父子联动的界面演示一下:

@Component struct ChildPanel { @Link count: number build() { Column() { Text(`子组件看到的数:${this.count}`) .fontSize(18) Button('子组件加一') .onClick(() => { this.count++ }) } .padding(16) .borderRadius(8) .backgroundColor('#F0F0F0') .margin(8) } } @Entry @Component struct ParentPage { @State count: number = 0 build() { Column() { Text(`父组件状态:${this.count}`) .fontSize(18) ChildPanel({ count: $count }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }

注意@Link传值时,父组件写法是count: $count,前面带$符号。@Prop传值时不需要带$,直接写count: this.count。这两者非常容易记混,API 22之前的老工程师基本都在这上面翻过车。

4.3 生命周期钩子对组件创建的影响

自定义组件有一组生命周期钩子:aboutToAppear、aboutToDisappear,以及页面级的onPageShow、onPageHide、onBackPress(仅在@Entry组件中生效)。

组件创建后、build执行前,aboutToAppear会被调用,这里适合做初始化赋值、请求数据。如果数据请求完成后需要改UI,直接给状态变量赋值即可,系统会自动刷新。

我实际开发中踩过一个坑:在aboutToDisappear里清空状态变量是不必要的,因为组件销毁后,状态也跟着没了,强行赋值反而可能在销毁过程中触发不必要的渲染警告。

生命周期的另一个作用是,避免在build里写异步逻辑。比如想在页面加载后请求接口,如果写在build里,每次刷新都可能重复请求。放在aboutToAppear里,则只会在组件实例创建后请求一次,这才是正确归位。

5. 常见问题与排查技巧实录

5.1 属性不生效:先查顺序和单位

这是出现频率最高的问题。明明写了width: 200,组件却还是铺满父容器。原因通常是:

  • 父容器没有给子组件限制可用空间,比如宽度写在Row的子组件上,但Row自身宽度是'100%',子组件width被Row的flex属性拉长。
  • 单位写错,比如数字直接写'200px',ArkUI不支持px单位,直接忽略。
  • 属性顺序问题,比如先设置position再设置width,在某些场景下position会强制组件脱离文档流,width表现异常。

排查方法很固定:先给组件加一个明显的背景色,确定组件实际占据的区域;再逐个注释属性方法,看是哪一步影响的;最后检查父容器约束。

5.2 状态修改了UI却不刷新

状态变了UI不动,十有八九是下面三种情况:

现象原因解决
子组件内修改普通变量没加@State/@Prop/@Link补装饰器
修改@State对象的自建类属性自建类没有实现@Observed装饰类上加@Observed,数组/对象需要额外观察
异步回调里修改@State但UI不更新忘记绑定this导致装饰器上下文丢失使用箭头函数或bind(this)

API 22之前,ArkTS对@Observed和@ObservedV2的支持逐步增强,如果你在数组里push新元素希望UI变化,光靠@State是不够的,还需要结合@Observed和对象数组更新策略。最简单的处理方式是:修改数组时不要原地push,而是新创建一个数组赋值给@State变量,比如this.list = [...this.list, newItem],这样系统一定能检测到变化。

5.3 新手必看:组件重绘性能排查表

有些界面操作卡,不是算法问题,而是组件重绘太频繁。我排查性能问题时,基本按这个顺序走:

  1. 状态变量粒度太大。一个@State包含整个页面数据,任何字段变化都导致全页面重绘。应该拆分成多个小状态或使用@Observed局部观察。
  2. ForEach没有写key生成器。列表里item位置变化时,如果key生成器不合理,系统会重新创建组件而不是复用。比如ForEach(this.list, (item) => Text(item), item => item.id),key必须稳定且唯一。
  3. 属性方法里写复杂表达式。build每次刷新都会重新计算整个属性链,建议把复杂逻辑抽成方法,尽管方法调用本身也有开销,但至少比每次渲染都做复杂循环要好。

5.4 一个小技巧:调试UI组件用弹窗代替Log

在真机上调试属性是否生效,老用console.log看不到视图层级。可以临时用一个Button或者Text把关键状态显示在页面上,比如在角落放一个Text,内容直接绑定状态变量。实测下来,这种“视觉化状态调试”比打日志更直观,能快速定位是状态没更新还是组件属性写错。

6. 关于API22之后:这套经验没有过时

我在写API 22之前这套UI创建和属性设置操作的时候,一直在想一个问题:这些经验会不会因为新版本而作废?目前看,不会。声明式UI的最大魅力在于设计哲学上的稳定——只要还是“组件构造 + 属性方法 + 状态驱动”这套骨架,换多少API版本,核心操作逻辑都不变。API 12之后HarmonyOS NEXT用ArkTS全面统一了UI开发语言,之前用TypeScript写ArkUI的开发风格仍然被兼容;后续新版本更多是能力增强,而不是推翻既有写法。

而且,把这些基础的组件创建、属性设置方法掌握透了,再去学新特性会非常快。因为新组件、新属性本质上还是在同一个build模型里加新的构造参数、新的属性方法。地基没变,往上盖楼就轻松。

最后分享一个个人习惯:我喜欢在写每个页面前,先在纸上把组件树结构画出来,再对照结构写build代码。这样组件创建和属性设置的顺序就有参考依据,代码里不容易出现嵌套混乱的情况。API 22之前如此,之后我估计也如此。

返回列表