第一篇我们折腾完了开发环境和工程创建,能跑起一个空白页面了。但很多朋友卡在下一步:打开Index.ets之后,看着那一堆build()里的代码,不知道该怎么往上面堆界面。这篇就专门解决“把基础 UI 搭起来”这件事,我会用 ArkUI 的声明式写法,拿一个非常小的“待办事项”页面作为贯穿全文的例子,把组件、布局、状态管理、列表渲染这些写 UI 最先要用的东西过一遍。
先说清楚这篇的边界:不聊复杂路由,不碰网络请求,不扯动画。就盯着一个页面,从上到下把它写出来、跑起来、点得动。适合刚看完第一篇、对 ArkTS 语法还半生不熟的朋友;如果你已经写过一些页面,也可以重点看第四、五节里关于布局和状态更新的理解,以及第六节我实际踩过的坑。
1. 写 UI 之前,先弄懂 ArkUI 的思维方式
很多从 Android 或者传统前端转过来的朋友,第一次写 ArkUI 都会觉得别扭。因为它跟你习惯的那套 UI 写法,思考方式完全不一样。先花几分钟把这件事想明白了,后面写起来会顺手很多。
1.1 不是“画界面”,是“描述界面”
传统写法是命令式的:你要一个文本框,就 new 一个控件,把它 add 到布局里,再调用 setText 这样的方法去改它。每一步都是你在指挥系统“接下来做什么”。
ArkUI 不是这样。它的核心思路是声明式:你只需要“描述”这个界面应该长成什么样,系统负责把描述渲染出来。你写出一个Column里面放两个Text,系统就知道要画一个纵向容器再放两行文字。你不需要手动去 add、去挂载、去刷新,写出来就是最终界面。
打个比方:命令式 UI 是你站在厨房里一步步告诉厨师“先放油、再放葱、然后下菜”,声明式 UI 是直接跟厨师说“来一盘西红柿炒鸡蛋”,材料摆在那,最终成品由框架搞定。你只关心“要什么”,不关心“怎么做出来的”。
这个思维转变是最关键的一步。很多新手刚开始写 ArkUI,下意识想找“获取控件实例”的方法,然后手动改它的属性——这种思路在 ArkUI 里基本用不上。
1.2 组件树:一个页面就是一棵树
任何 ArkUI 页面,本质上都是一棵组件树。根节点通常是一个容器组件,子节点是其他组件,容器里再套容器。你跟系统描述的“界面长什么样”,最后都会被编译成一棵组件树。
举个例子:
Column() { Text('标题') Row() { Text('左') Text('右') } }这棵树就是:Column是根,底下有三个子节点,其中第二个子节点Row下面还有两个Text子节点。
写 UI 的核心工作,就是决定两件事:选什么容器、容器里怎么排。容器决定了子组件的排布方向,选错了容器,后面调再多对齐属性都是事倍功半。
1.3 UI 是状态的投影:数据变了,界面自己会变
声明式 UI 里最核心的一句话:UI 是状态的投影。你定义一个状态变量,界面上显示的内容由这个变量决定。变量一变,用到它的 UI 部分会自动更新。
这句话很多人听懂了,但写的时候还是会犯老毛病。比如想“点按钮之后让某个文字变色”,脑子里第一反应是“找到那个文字控件,把颜色改了”——这就是命令式思维。在 ArkUI 里正确的做法是:定义一个状态变量isActive,文字的字体颜色绑定到这个变量,点按钮的时候只改isActive的值。剩下的刷新工作由框架完成。
咱们待办事项这个 Demo,从头到尾都会贯彻这个思想:
- 输入框内容变化,存到一个变量;
- 点“添加”按钮,把变量内容塞进一个数组;
- 列表 UI 自动多出一行,完全不用手动操作列表控件。
2. 从空白工程到第一个页面骨架
理清了思维,咱们动手。先看看新建工程默认生成的页面长什么样,然后把待办事项页面的骨架搭起来。
2.1 默认工程里到底有什么
新建一个 Empty Ability 工程之后,打开entry/src/main/ets/pages/Index.ets,默认代码大概是这样的:
@Entry @Component struct Index { build() { Column() { Text('Hello World') .fontSize(50) .fontWeight(FontWeight.Bold) } .width('100%') .height('100%') } }先认识这几个关键字:
@Entry:标记这个组件是页面入口。一个页面文件里可以有多个组件,但@Entry只能有一个,它表示谁是这个页面的根。@Component:把一个 struct 变成自定义组件。结构体里定义自己的状态变量和build()方法,就是一个可以复用的组件单元。build():组件内部有个build(),你在里面写组件树,描述这个组件长什么样。Column:纵向容器,子组件从上往下排。
struct Index里的Index是组件名字,可以随便改,不影响功能。默认代码的中心是一个撑满全屏的Column,里面放了一行大字号文字,仅此而已。
提示:ArkUI 的页面文件后缀名是
.ets,不是.ts。ArkTS 在 TypeScript 基础上做了一些约束,比如禁止用any、要求显式标注类型等,写 UI 这块影响不大,但被类型检查卡住的时候别奇怪。
2.2 把待办事项的页面骨架写出来
咱们的目标页面很简单:顶部一个标题“待办事项”,标题下面一行是“输入框 + 添加按钮”,再往下是事项列表。这个页面是全屏纵向结构,所以根容器用Column是最自然的。
先把骨架搭出来:
@Entry @Component struct TodoPage { build() { Column() { // 顶部标题 Text('待办事项') .fontSize(24) .fontWeight(FontWeight.Bold) // 输入区:输入框 + 按钮 Row({ space: 8 }) { TextInput({ placeholder: '请输入待办事项' }) Button('添加') } .width('100%') // 列表区:先用一个占位文本 Text('这里放列表') .fontSize(16) .fontColor('#999999') } .padding(16) .width('100%') .height('100%') } }骨架写完之后,页面上能看到标题、输入框、按钮和一个占位文本。有些朋友会遇到一个问题:明明给Column设置了.padding(16),为什么输入框宽度没有贴到屏幕边缘?答案是Column里的子组件默认宽度是“适应内容”的,TextInput不会自动填满整行。你得手动给它设置宽度。
所以输入框这里要加上.width('100%')或者.layoutWeight(1)。区别在于:width('100%')是占满父容器整个宽度,但旁边还有按钮,会挤出去;layoutWeight(1)则是“把剩余空间都给我”,旁边按钮按内容宽度显示,正好合适。
Row({ space: 8 }) { TextInput({ placeholder: '请输入待办事项' }) .layoutWeight(1) // 占据剩余宽度 Button('添加') } .width('100%') .justifyContent(FlexAlign.SpaceBetween)这样输入区就合理了。骨架完成,下一步把每个组件的基本用法摸清楚。
3. 基础组件:Text、Button、TextInput、Image 的使用与避坑
这节把写页面最常用的几个组件过一遍。我不会把所有 API 列出来,只挑实际写页面时一定会用到的属性和最容易踩的坑。
3.1 Text:文本组件的基础玩法
Text用来显示文本,直接传入字符串:
Text('待办事项') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor('#333333')常用属性就这几个:fontSize字号,fontWeight字重,fontColor文字颜色,textAlign对齐方式。需要注意,fontSize不写单位时,默认单位是 vp(虚拟像素)。鸿蒙的 vp 类似安卓的 dp,会根据屏幕密度自动缩放,所以别把它当成 px 来理解。
文本一长就要考虑换行和截断。默认情况下Text会自动换行,但如果你不想让它换行,可以配合maxLines和textOverflow:
Text('这是一段很长的文字可能会超出屏幕宽度') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis })这样超出一行后,末尾会显示省略号。要注意textOverflow只有在maxLines限制了行数时才会生效,只写maxLines不写textOverflow,多余文字会被直接截断而不是显示省略号。
如果要做“一行文字里不同颜色/不同字号”的效果,用Span子组件:
Text() { Span('已完成') .fontColor('#999999') Span(' 3') .fontColor('#e84026') .fontWeight(FontWeight.Bold) }Span是Text的子组件,不能单独拿出来用,写的时候注意嵌套关系就行。
3.2 Button:按钮类型与点击事件
Button有两种写法,一种直接传字符串:
Button('添加') .onClick(() => { // 点击逻辑 })另一种传参数对象,可以配置更多选项:
Button({ type: ButtonType.Capsule, stateEffect: true }) { Text('添加') .fontSize(16) } .backgroundColor('#e84026') .fontColor('#ffffff')默认的按钮类型是ButtonType.Capsule,胶囊形状,适合做常用的操作按钮。其他还有Circle(圆形)、Normal(直角矩形)等。用哪种看设计需要,日常写页面用到最多的就是Capsule和Normal。
点击事件通过.onClick(() => {})绑定,事件里写你要执行的动作。在声明式写法里,这个动作通常就是“修改状态变量”。注意:不要在 onClick 里做复杂的耗时操作,比如网络请求这种,基础阶段先养成“点击只改状态”的习惯,后面进阶学异步任务再往里面加逻辑。
另一个容易忽略的属性是stateEffect,它控制按钮按下时有没有按压效果。默认是开的,如果你自己实现了按下变色效果,记得把它关掉,否则会出现“按下去颜色很奇怪”的问题。
3.3 TextInput:输入框、双向绑定与键盘
TextInput是单行输入框,最基础的用法:
TextInput({ placeholder: '请输入待办事项' }) .onChange((value: string) => { // value 是当前输入框内容 })这里有个新手容易踩的坑:onChange回调里拿到的value,是输入框当前最新的完整内容,不是“相对于上一次变化多了哪个字符”。你不需要自己维护一个“上次的值”再拼接,直接把这个新值赋值给状态变量就好。
如果要做“提交后清空输入框”的效果,让输入框的内容跟状态变量绑定:
@State inputValue: string = '' build() { TextInput({ placeholder: '请输入待办事项', text: this.inputValue }) .onChange((value: string) => { this.inputValue = value }) }注意关键点:只要text参数传了this.inputValue,那么每次inputValue变化,输入框内容会跟着变化。这样你清空inputValue,输入框也会清空。如果不传text参数,输入框内容由用户输入自由控制,你改状态变量它也不会变。两种模式各有适用场景,但做“提交清空”这种交互,用绑定模式最省事。
输入框的键盘类型可以设定,比如.type(InputType.Password)会变成密码输入,.type(InputType.Number)弹数字键盘。还有.enterKeyType(EnterKeyType.Done)可以改键盘右下角按钮的文字,配合.onSubmit()响应回车/完成事件。做搜索、登录场景时会用到。
placeholder 样式默认是灰色,想改颜色用placeholderStyle:
TextInput({ placeholder: '请输入待办事项' }) .placeholderStyle({ fontColor: '#bbb', fontSize: 14 })3.4 Image:图片资源放哪、比例怎么控
页面里显示图片,用Image组件:
Image($r('app.media.icon')) .width(24) .height(24)$r('app.media.icon')是引用资源文件的固定写法。图片资源放在entry/src/main/resources/base/media/目录下,把图片文件丢进去,然后在代码里用$r('app.media.文件名不带后缀')引用。
新手最常见的两个问题:
一、图片不显示。先检查资源路径对不对,$r('app.media.xxx')里的 xxx 必须跟资源文件名完全一致(不带后缀);再看权限,如果你显示的是网络图片,需要申请网络权限,这点放到进阶再说,本地图片没这个问题。
二、图片比例不对。Image默认会用objectFit属性控制图片在组件区域内的填充方式。默认值是Cover,也就是“填满整个区域,超出部分裁掉”,所以一张方形图片放到很窄的组件里,两边会被裁掉。如果你想保持原图比例完整显示,用Contain或者Auto:
Image($r('app.media.icon')) .width(120) .height(80) .objectFit(ImageFit.Contain)Contain会缩放到完整显示整个图片,可能留白;Auto则更智能一些。做头像、Logo 这类对完整性要求高的图片,建议用Contain或Auto,做背景图再考虑Cover。
基础组件就先说这些。把Text、Button、TextInput、Image这几个用熟练,已经能拼出不少界面了。接下来解决更关键的问题:怎么把它们摆放得好看合理。
4. 布局的关键理解:主轴、间距、权重与滚动
布局是新手最容易混乱的部分。一会儿justifyContent,一会儿alignItems,傻傻分不清楚。这节我用最直白的方式讲清 ArkUI 的排布逻辑。
4.1 先分清主轴与交叉轴
Column是纵向容器,子组件从上往下排;Row是横向容器,子组件从左往右排。但光知道方向不够,你得理解“主轴”和“交叉轴”这两个概念。
主轴,就是容器排列子组件的方向。Column的主轴是垂直方向,Row的主轴是水平方向。交叉轴,就是跟主轴垂直的方向。Column的交叉轴是水平方向,Row的交叉轴是垂直方向。
联系人的方式理解:一堆人排队买奶茶,排成一列纵队,前后关系就是主轴,左右散开就是交叉轴;站成一横排,左右关系是主轴,前后是交叉轴。
justifyContent控制的是主轴方向的分布方式,alignItems控制的是交叉轴方向的对齐方式。所以:
Column里justifyContent管上下分布,alignItems管左右对齐;Row里justifyContent管左右分布,alignItems管上下对齐。
这个概念理清了,布局代码至少不会瞎试。
4.2 justifyContent 和 alignItems 的搭配
justifyContent常用的取值有这些:
.justifyContent(FlexAlign.Start) // 靠主轴起点 .justifyContent(FlexAlign.Center) // 主轴居中 .justifyContent(FlexAlign.End) // 靠主轴终点 .justifyContent(FlexAlign.SpaceBetween) // 两端对齐,中间间距均分 .justifyContent(FlexAlign.SpaceAround) // 每个项目两侧间距相等 .justifyContent(FlexAlign.SpaceEvenly) // 所有间距完全相等alignItems的取值则要区分容器类型。Column里用:
.alignItems(HorizontalAlign.Center) // 水平居中 .alignItems(HorizontalAlign.Start) // 水平靠左 .alignItems(HorizontalAlign.End) // 水平靠右Row里用:
.alignItems(VerticalAlign.Center) // 垂直居中 .alignItems(VerticalAlign.Top) // 垂直靠上 .alignItems(VerticalAlign.Bottom) // 垂直靠下写布局的基本步骤是:先选对容器方向,再用justifyContent搞定主轴,最后用alignItems微调交叉轴。比如咱们待办事项的输入区,想让按钮靠右、输入框占满剩余空间,就用了Row+layoutWeight的组合。
容器间距也很常用。Column({ space: 12 })和Row({ space: 8 })里的space参数,表示子组件之间的间距。这个参数在容器构造时直接传,比如:
Column({ space: 12 }) { Text('第一行') Text('第二行') }效果是第一行和第二行之间留 12vp 的间隙,比给每个子组件单独加margin省事得多。
4.3 组件占不满空间怎么办:layoutWeight 的妙用
layoutWeight是布局里的“弹性权重”属性,非常实用。它的作用是:让组件占据父容器的剩余空间。
什么叫剩余空间?比如一个Row总宽度是 300,里面第一个子组件占 100,第二个子组件设置了layoutWeight(1),那么它就会拿到剩下的 200。如果还有第三个子组件也设置了layoutWeight(1),那剩余空间会被第二个和第三个平分,各拿 100。
咱们待办事项页面的三个部分正好用上:
Column() { Text('待办事项') Row({ space: 8 }) { TextInput(...).layoutWeight(1) Button('添加') } List() {...} .layoutWeight(1) }标题和输入区都是“按内容高度显示”,而列表区设置了layoutWeight(1),它就会填满剩余的所有高度。这样不管屏幕多高,列表区总能自适应地撑满剩余空间。这个技巧在做“头部固定、内容滚动”的页面时几乎是标配。
需要注意,layoutWeight要生效,父容器必须有确定尺寸。你给Column设置了.height('100%'),子组件的权重才分得到空间;如果父容器高度也是“适应内容”,权重就没什么可分的了。
4.4 内容超出屏幕:Scroll 和 List 怎么选
页面内容一旦多了,就涉及滚动。ArkUI 里有两个常见方案:Scroll和List。
Scroll是通用滚动容器,用法是把内容包起来:
Scroll() { Column() { // 一堆内容 } } .width('100%') .height('100%')Scroll适合内容不多、结构比较简单的页面。它的特点是里面随便放什么组件都行,Column、Row、Stack 随便套,整个 Column 的高度超过 Scroll 可视区域后就可以滚动。
List则是专门的长列表组件,它的性能更好,更适合数据量大的场景(比如几十上百条待办事项)。List的使用方式跟Scroll不太一样,它内部需要配合ListItem和ForEach使用:
List({ space: 8 }) { ForEach(this.tasks, (task: string) => { ListItem() { Text(task) } }, (task: string) => task) } .width('100%') .layoutWeight(1)List自带懒加载机制,滚动时只会渲染可见区域附近的内容,数据很多也不会卡。咱们这个 Demo 数据量不大,但用List是更正确的方向,后续加删除、加滑动交互也方便。
提示:
List本身不支持justifyContent这种主轴分布属性,因为列表项的位置是由内容长度决定的。想让列表项之间有间距,用List({ space: 8 })或者给ListItem加margin都行。
5. 让页面活起来:@State 状态管理与交互闭环
布局决定了页面长什么样,但一个页面真正“能用”,靠的是交互。咱们待办事项的完整交互是:输入文字、点添加、列表新增一条、点某条完成、列表删除这条。整个交互闭环的核心,就是状态管理。
5.1 @State 到底帮我们做了什么
@State是 ArkUI 状态管理里最基本、最常用的装饰器。它标记一个变量为“状态变量”,表示:这个变量的变化需要驱动 UI 更新。
看这段代码:
@State tasks: string[] = []它表示tasks是一个状态数组。注意,跟普通变量最大的区别是:当你往tasks里推入新数据、删除某项,或者重新赋值,任何依赖tasks的 UI 都会自动刷新。你不用写任何“通知 UI 刷新”的代码。
ArkUI 会建立一个依赖关系图:@State变量是数据源,build()里使用了哪个状态变量,框架就记录“这部分 UI 依赖了它”。状态变了,框架精确地更新依赖它的那部分 UI。这就是声明式 UI 的核心机制。
在这个机制下,新手最需要克制的是“手动操作 UI”的冲动。千万别想着“拿到某个 Text 控件,把文字改掉”。你只需要改状态,UI 自己会变。
5.2 输入到添加:一个完整的事件驱动闭环
咱们把输入框、按钮和列表串起来。先定义状态:
@State inputValue: string = '' @State tasks: string[] = []然后写输入框,每次内容变化都更新inputValue:
TextInput({ placeholder: '请输入待办事项', text: this.inputValue }) .onChange((value: string) => { this.inputValue = value })注意,这里给TextInput传了text: this.inputValue,这构成了“双向绑定”的基础:用户在输入框里打字,onChange把新值存进inputValue;反过来,当inputValue被改成空字符串时,输入框的内容也会跟着变空。这正是后面“添加后清空输入框”的实现原理。
再写添加按钮:
Button('添加') .onClick(() => { if (this.inputValue.trim() === '') { return } this.tasks.push(this.inputValue.trim()) this.inputValue = '' })这串代码干三件事:判断输入是否为空(空就不添加,trim()去掉首尾空格)、把输入内容推进tasks数组、把inputValue清空。
当this.inputValue = ''执行时,输入框内容立即被清空;当this.tasks.push(...)执行时,列表 UI 会新增一行。这两个更新都自动发生,不需要你在build()里写任何特殊处理。这就是事件驱动 UI 更新最典型的形态:事件 -> 改状态 -> UI 刷新。
5.3 用 ForEach 渲染列表,并给列表项绑定交互
有了数据之后,把数据渲染成列表。ForEach是基础篇最常用的渲染语法:
List() { ForEach(this.tasks, (task: string, index: number) => { ListItem() { Row() { Text(`${index + 1}. ${task}`) Text('完成') .fontColor('#e84026') .onClick(() => { this.tasks.splice(index, 1) }) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) } }, (task: string) => task) } .width('100%') .layoutWeight(1) .divider({ strokeWidth: 1, color: '#f0f0f0' })ForEach接收三个参数:数据源、item 生成器、key 生成器。
- 数据源是
this.tasks数组; - item 生成器接收数组元素和下标,返回要渲染的组件;
- key 生成器返回一个唯一标识,用来告诉框架“哪个 item 是哪个”。
给“完成”文字绑定onClick,点击时通过splice(index, 1)从数组里删除这一项。删除后 UI 自动去掉这一行。
这里有个细节值得注意:key 生成器(task: string) => task用的是内容本身作为 key。如果列表里出现重复文字,比如添加了两条“买牛奶”,key 就重复了,可能导致列表项的行为错乱。在真实项目中,每条数据最好有一个唯一 id 作为 key,比如:
interface TodoItem { id: number text: string }然后 key 生成器返回item.id。Demo 阶段用内容当 key 够用,但你得知道这个隐患,后面数据复杂了迟早会碰见。
.divider({ strokeWidth: 1, color: '#f0f0f0' })是给列表项之间加分隔线,参数是线的粗细和颜色。这个属性只能用在List上,Scroll里没有,注意区分。
到这步,咱们的待办事项页面已经从“静态展示”变成了“能添加、能删除”的可用页面。核心也就三件事:状态变量、事件绑定、ForEach 渲染。这个“三角关系”想明白之后,鸿蒙里大部分基础页面你都能写了。
6. 我实际写这个 Demo 时踩过的三个坑
最后分享几个我写这种基础页面时真实踩过的坑,不是从文档里抄来的,都是代码跑出来不对劲之后一点点试出来的。每一个都值得你留意。
6.1 修改数组后页面不刷新
有段时间我遇到一个诡异的问题:往@State tasks数组里push数据,UI 居然没反应。查了半天,发现问题是:我在一个普通函数里直接修改了数组,然后把状态变量重新赋了值,但 UI 没刷新。
后来才明白,ArkUI 对数组的观察是有规则的。以下操作可以触发 UI 更新:
push()、pop()、splice()、concat()这些数组方法;- 对整个数组重新赋值,比如
this.tasks = newArray。
但下面这种操作不会触发 UI 更新:
this.tasks[0] = '修改后的内容'直接下标赋值是不会被观察到的。如果你要修改某一项,正确做法是:
this.tasks = this.tasks.map((item, index) => { return index === 0 ? '修改后的内容' : item })生成一个新数组再整体赋值,或者用splice替换这一项:
this.tasks.splice(0, 1, '修改后的内容')这个坑在数据稍微复杂一点之后非常容易踩。记住一条原则:改对象属性用重新赋值或触发 setter,改数组别用下标赋值。
6.2 样式写了却不生效
还有一种特别恼火的情况:代码里写了.backgroundColor,但界面上一片空白,背景色根本没出来。我碰到过好几次,最后发现都是同一个原因:组件本身的尺寸是 0。
比如你写了:
Row() { // 空内容 } .backgroundColor('#f5f5f5')这个Row里什么都没有,默认高度 0,背景色当然看不见。新手容易忽略:容器组件在没有内容、也没有显式设置宽高的情况下,尺寸就是 0。你得给它width('100%').height(50)或者塞进去实际内容。
还有一个常见问题是链式调用顺序导致的覆盖。比如:
Text('测试') .fontColor('#333333') .backgroundColor('#ff0000') .backgroundColor('#00ff00')后写的样式会覆盖先写的,最终背景色是绿色。如果你代码里多个地方设置了同一个属性,检查一下是不是被后面的覆盖了。
6.3 键盘弹出来挡住输入框
我在做底部固定输入框的聊天界面时,遇到过软键盘把输入框挡死的状况。但在咱们这个 Demo 里不会有这个问题,因为输入框在页面顶部。
经验告诉我们:做页面设计时,输入框尽量别放进“底部固定区域”,除非你有把握处理好键盘避让。如果输入框必须在底部,建议把整个布局放进Scroll或者List,并且给滚动区域设置keyboardAvoidMode相关的属性,让键盘弹出来时页面可以往上顶。这部分配置在基础阶段先不展开,但你要有这个意识:输入框在底部,就要考虑键盘遮挡问题。
还有一个更简单的办法:把输入区放在页面顶部(像这个 Demo 一样),或者放在列表上方而不是整个页面的最底部。很多基础页面都是这么设计的,省掉一大部分键盘适配的麻烦。
写到这里,待办事项这个 Demo 你完全可以照着敲一遍了。最后再分享一个小技巧:新手调布局的时候,别死盯着预览器看,找不到问题就给每个容器临时加一个明显的背景色,比如Column加蓝色、Row加红色,一下就能看出每个组件的实际边界和尺寸分布。等布局调试好了,再把临时颜色删掉。这个土办法我到现在还在用,排查布局问题比看代码更快。