在这里插入图片描述
在这里插入图片描述

1. 前言:为什么需要 ColumnStart?

在移动端应用开发中,纵向列表垂直排列是最基础也是最常见的 UI 布局需求。无论是微信的消息列表、淘宝的商品瀑布流,还是各类 App 的注册登录表单,其底层布局架构都可以归结为「子组件自上而下依次排列」。

HarmonyOS NEXT 作为华为自研的全场景操作系统,其原生 UI 框架 ArkUI 提供了一系列声明式布局容器。其中 Column 是最核心的垂直布局容器,而 alignItems(ItemAlign.Start) + justifyContent(FlexAlign.Start) 的组合则解决了绝大多数纵向页面的布局诉求。

我们称这种组合为 ColumnStart —— 它代表一种「垂直排列,顶部对齐,靠左对齐」的布局范型。本文将通过一个完整的实战项目,从零到一剖析 ColumnStart 布局的方方面面。


2. Column 布局容器概述

2.1 什么是 Column?

Column 是 ArkUI 中最基础的布局容器之一,其核心特点是:所有子组件在主轴(垂直方向)上从上到下依次排列。可以将其类比为前端 Flexbox 布局中的 flex-direction: column,或者 Android 原生开发中的 LinearLayout(orientation: vertical)

2.2 Column 与 Row 的对比

容器 主轴方向 交叉轴方向 典型场景
Column 垂直(从上到下) 水平(从左到右) 列表、表单、信息流
Row 水平(从左到右) 垂直(从上到下) 导航栏、按钮组、标签栏

2.3 Column 的声明语法

在 ArkTS 中,Column 以闭包形式使用:

Column() {
  // 子组件按顺序添加到 Column 中
  Text('第一个子组件')
  Text('第二个子组件')
  Button('第三个子组件')
}
// 容器级属性通过链式调用设置
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')

2.4 Column 的核心控制属性

Column 容器通过两个关键属性控制其子组件的排列布局:

  • alignItems —— 控制子组件在**交叉轴(水平方向)**上的对齐方式
  • justifyContent —— 控制子组件在**主轴(垂直方向)**上的间距分布

这正是 ColumnStart 布局的精髓所在:两方面同时控制,实现精准的垂直排列。


3. alignItems 与 justifyContent 深入理解

理解这两个属性是掌握 Column 布局的关键。我们用一张概念图来厘清它们的职责:

      ┌─────────────────────────────────────┐
      │          Column 容器                 │
      │    justifyContent 控制这个方向 ↓      │
      │         (主轴 · 垂直)               │
      │                                      │
      │    ← alignItems 控制这个方向 →        │
      │    (交叉轴 · 水平)                   │
      │                                      │
      │   ┌─────────────────────────┐        │
      │   │    子组件 A              │        │
      │   └─────────────────────────┘        │
      │   ┌─────────────────────────┐        │
      │   │    子组件 B              │        │
      │   └─────────────────────────┘        │
      │   ┌─────────────────────────┐        │
      │   │    子组件 C              │        │
      │   └─────────────────────────┘        │
      └─────────────────────────────────────┘

3.1 alignItems 详解

alignItems 作用于 交叉轴(Cross Axis)。对于 Column 来说,交叉轴就是水平方向。它决定了子组件在水平方向上的对齐行为。

ArkUI 提供了四种对齐选项:

枚举值 效果 说明
ItemAlign.Start 靠左对齐 所有子组件在容器左侧边缘对齐(默认行为)
ItemAlign.Center 居中对齐 所有子组件在容器水平中央对齐
ItemAlign.End 靠右对齐 所有子组件在容器右侧边缘对齐
ItemAlign.Stretch 拉伸填充 子组件在水平方向拉伸至填满容器宽度(默认行为,若不设置则默认为 Stretch)

3.2 justifyContent 详解

justifyContent 作用于 主轴(Main Axis)。对于 Column 来说,主轴就是垂直方向。它决定了子组件在垂直方向上的间距分布方式。

ArkUI 提供了以下选项:

枚举值 效果 说明
FlexAlign.Start 顶部对齐 所有子组件从容器顶部开始排列(默认行为)
FlexAlign.Center 垂直居中 所有子组件在容器垂直方向中央排列
FlexAlign.End 底部对齐 所有子组件从容器底部开始排列
FlexAlign.SpaceBetween 两端对齐 第一个子组件在顶部,最后一个在底部,中间等距分布
FlexAlign.SpaceAround 环绕间距 每个子组件两侧间距相等
FlexAlign.SpaceEvenly 均匀分布 子组件之间及与容器边界的间距均相等

4. ItemAlign.Start 与其他对齐方式的对比

为了更直观地理解 ItemAlign.Start 的效果,我们对比四种对齐方式的可视化表现。

4.1 ItemAlign.Start(靠左对齐)

Column() {
  Text('短文本')
  Text('这是一段比较长的文本内容,长度不同')
  Button('按钮')
}
.width('100%')
.backgroundColor('#E8F5E9')
.alignItems(ItemAlign.Start)

效果:所有子组件以左侧为基准对齐。短文本在左,长文本也从左开始,按钮同样靠左。这是阅读习惯中最自然的排版方式,也是绝大多数纵向页面的首选。

4.2 ItemAlign.Center(居中对齐)

.alignItems(ItemAlign.Center)

效果:所有子组件的水平中心线与容器中心对齐。适合标题、提示语等需要视觉居中的场景。但如果子组件宽度不一致,会导致左侧起始位置参差不齐,视觉上反而不够整洁。

4.3 ItemAlign.End(靠右对齐)

.alignItems(ItemAlign.End)

效果:所有子组件以右侧为基准对齐。适用于操作菜单、更多按钮等需要放在右侧的场景,但不适合大面积的文本列表。

4.4 ItemAlign.Stretch(拉伸填充)

.alignItems(ItemAlign.Stretch)

效果:所有子组件在水平方向上拉伸至填满容器的宽度。这是 Column 在未显式设置 alignItems 时的默认行为。适合需要子组件撑满宽度的场景,例如列表项带有底部分隔线、表单输入框需要占满屏幕宽度等。

4.5 选择建议

场景 推荐的对齐方式 原因
消息列表、新闻列表 ItemAlign.Start 文本左对齐符合阅读习惯
表单标签 + 输入框 ItemAlign.Start 标签和输入框左对齐,视觉整齐
模态弹窗内容 ItemAlign.Center 让弹窗内容视觉居中
操作菜单底栏 ItemAlign.End 操作按钮聚集在右侧
卡片式列表项 ItemAlign.Stretch 卡片撑满宽度,底部分隔线对齐

5. FlexAlign.Start 主轴排列详解

5.1 FlexAlign.Start 的行为

FlexAlign.Start 告诉 Column 容器:所有子组件从顶部开始依次向下排列。如果子组件的总高度小于容器高度,底部会留出空白区域。

Column() {
  Text('第一个')
  Text('第二个')
  Text('第三个')
}
.width('100%')
.height(500)
.backgroundColor('#FFF3E0')
.justifyContent(FlexAlign.Start)

效果:三个 Text 均从容器顶部开始排列,三者在顶部紧密排列,底部留白。

5.2 与其他 FlexAlign 的对比

排列方式 顶部间距 底部间距 子组件间距
FlexAlign.Start 0 留白 0(紧凑)
FlexAlign.Center 等比例 等比例 0(紧凑)
FlexAlign.End 留白 0 0(紧凑)
FlexAlign.SpaceBetween 0 0 均分剩余空间
FlexAlign.SpaceAround 一半间距 一半间距 均分剩余空间
FlexAlign.SpaceEvenly 均分 均分 与边界间距相等

5.3 ColumnStart 的完整组合

ColumnStart 布局的完整声明:

Column() {
  // 子组件列表
}
.width('100%')
.height('100%')
.alignItems(ItemAlign.Start)   // 子组件在水平方向靠左对齐
.justifyContent(FlexAlign.Start) // 子组件在垂直方向从顶部开始排列

这种组合确保了子组件无论数量多少、高度几何,都从左上角原点开始依次排列,是最自然、最符合阅读顺序的布局方式。


6. 项目结构全景概览

在开始编写代码之前,我们先看一下整个项目的目录结构,明确各个文件的职责。

MyApplication
├── AppScope/
│   ├── app.json5              # 应用级配置
│   └── resources/             # 全局资源
├── entry/                     # 模块入口
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/
│   │   │   │   └── EntryAbility.ets    # Ability 生命周期管理 & 页面加载
│   │   │   └── pages/
│   │   │       ├── Index.ets           # 默认首页(本项目中未使用)
│   │   │       └── ColumnStartPage.ets # ★ 核心:ColumnStart 布局演示页
│   │   ├── module.json5      # 模块配置(声明 Ability、页面路由等)
│   │   └── resources/        # 模块级资源
│   └── build-profile.json5   # 构建配置
├── oh-package.json5          # 包管理配置
└── hvigor/                   # 构建工具配置

6.1 关键文件职责

  • EntryAbility.ets:应用的入口 Ability,负责在 onWindowStageCreate 阶段通过 loadContent 加载 ColumnStartPage 页面
  • ColumnStartPage.ets:核心演示页面,包含三种 ColumnStart 布局场景的完整实现
  • module.json5:通过 "pages": "$profile:main_pages" 映射到 main_pages.json,后者声明了所有可用页面的路由

6.2 main_pages.json 配置

{
  "src": [
    "pages/ColumnStartPage"
  ]
}

这意味着应用启动时,框架会根据路由表找到 pages/ColumnStartPage 并将其作为可加载的页面。EntryAbility 中调用 loadContent('pages/ColumnStartPage', callback) 即可加载该页。


7. 从零搭建 ColumnStartPage 页面

下面我们开始构建核心的 ColumnStartPage 页面。整个页面将按照 「外层 Column 容器 → 标题栏 → 标签栏 → 内容区」 的结构进行搭建。

7.1 页面整体布局结构

Column(最外层容器)
├── buildTitleBar()      ── 标题栏(内部又是 Column)
├── buildTabBar()        ── 标签切换栏(使用 Row 水平排列)
└── 条件渲染内容区
    ├── [Tab 0] buildMessageList()  ── 消息列表
    ├── [Tab 1] buildFormDemo()     ── 注册表单
    └── [Tab 2] buildInfoFeed()     ── 信息流

7.2 页面入口与装饰器

@Entry
@Component
struct ColumnStartPage {
  // 状态变量和构建方法...
}
  • @Entry:标记该组件为页面的入口组件,表示这是一个完整的页面,而不是一个可复用的子组件
  • @Component:标记该结构体为 ArkUI 组件,使其具备组件化的能力
  • struct ColumnStartPage:使用 struct 定义组件结构体,这是 ArkTS 自定义组件的标准写法

7.3 build() 方法与 Column 根容器

build() {
  Column() {
    this.buildTitleBar()
    this.buildTabBar()
    if (this.activeTabIndex === 0) {
      this.buildMessageList()
    } else if (this.activeTabIndex === 1) {
      this.buildFormDemo()
    } else {
      this.buildInfoFeed()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F5F5F5')
  .alignItems(ItemAlign.Start)
  .justifyContent(FlexAlign.Start)
}

这里最关键的三行代码:

  1. .alignItems(ItemAlign.Start) —— 所有直接子组件(标题栏、标签栏、内容区)在水平方向靠左对齐
  2. .justifyContent(FlexAlign.Start) —— 所有直接子组件从容器顶部开始垂直排列
  3. .width('100%').height('100%') —— 容器撑满整个屏幕,为子组件提供完整的布局空间

#F5F5F5 的浅灰色背景作为页面底色,让白色卡片内容区自然悬浮在页面上方,形成良好的层次感。

7.4 @Builder 装饰器:构建可复用 UI 片段

在 ArkTS 中,@Builder 装饰器用于定义可复用的 UI 构建函数:

@Builder
buildTitleBar() {
  // UI 构建逻辑
}

@Builder 方法需要通过 this.方法名() 的方式在 build() 或另一个 @Builder 方法中调用。使用 @Builder 有以下好处:

  • 代码模块化:将大型 UI 拆分为独立的构造块,每个块职责单一
  • 可复用性:同一 @Builder 可以在多处调用
  • 可读性build() 方法本身变得简洁清晰,只需列出关键模块即可

8. 场景一:消息列表 —— 纯 Column 垂直列表

8.1 数据模型定义

首先定义消息项的数据结构:

interface MessageItem {
  id: number;
  avatar: string;    // 发件人头像(用 emoji 占位)
  name: string;      // 发件人名称
  summary: string;   // 消息摘要
  time: string;      // 时间戳
  unread: boolean;   // 是否已读
}

使用 interface 定义类型,而非 class,因为这里只需定义数据结构,不需要方法。ArkTS 中的 interface 编译开销更小,更适合纯数据模型。

8.2 状态变量初始化

@State messages: MessageItem[] = [
  { id: 1, avatar: '👤', name: '张三', summary: '项目方案已更新,请查收最新版本', time: '09:32', unread: true },
  { id: 2, avatar: '👥', name: '设计团队', summary: '首页UI切图已上传至共享文件夹', time: '昨天', unread: true },
  { id: 3, avatar: '📋', name: '任务提醒', summary: '你有3个待办事项即将到期', time: '昨天', unread: false },
  { id: 4, avatar: '📢', name: '系统通知', summary: '系统将于本周六02:00-05:00进行维护升级', time: '周三', unread: false },
  { id: 5, avatar: '💬', name: '李四', summary: '收到,我明天下午回复你', time: '周三', unread: false }
];

@State 装饰器标记的变量是响应式的。当 messages 数组内容发生变化时,UI 会自动重新渲染。这是 ArkTS 声明式 UI 的核心机制 —— 数据驱动 UI

8.3 buildMessageList():消息列表容器

@Builder
buildMessageList() {
  Column() {
    Text('最近消息')
      .fontSize(16)
      .fontColor('#333333')
      .margin({ left: 4, top: 8, bottom: 4 })

    ForEach(this.messages, (item: MessageItem) => {
      this.messageItem(item)
    }, (item: MessageItem) => item.id.toString())
  }
  .width('100%')
  .padding(16)
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ left: 16, right: 16, top: 12 })
}

这里有几个布局要点:

  1. 外层 Column:承载整个消息列表区,alignItems(ItemAlign.Start) 使标题和消息条目都靠左对齐
  2. 白色卡片背景backgroundColor('#FFFFFF') + borderRadius(12) 创建一个圆角卡片效果
  3. 外边距margin({ left: 16, right: 16, top: 12 }) 让卡片与屏幕边缘保持间距

8.4 ForEach 循环渲染

ForEach 是 ArkUI 中的列表循环渲染语法:

ForEach(
  this.messages,                 // 数据源数组
  (item: MessageItem) => {       // UI 生成函数
    this.messageItem(item)
  },
  (item: MessageItem) => item.id.toString()  // key 生成函数(用于列表复用优化)
)

第三个参数是 key 生成器,为每个列表项生成唯一标识符。当列表数据发生变化时(增删改),框架根据 key 值精确判断哪些项需要重新渲染,而非刷新整个列表。这能显著提升长列表的性能表现。

8.5 messageItem():单个消息条目的 Column+Row 混合布局

@Builder
messageItem(item: MessageItem) {
  Row() {
    // 左侧:头像
    Text(item.avatar)
      .fontSize(28)
      .width(48)
      .height(48)
      .backgroundColor('#F0F0F0')
      .borderRadius(24)
      .textAlign(TextAlign.Center)

    // 右侧:文字区域(嵌套 Column)
    Column() {
      // 第一行:姓名 + 时间
      Row() {
        Text(item.name)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor('#1A1A1A')

        Text(item.time)
          .fontSize(12)
          .fontColor('#AAAAAA')
          .margin({ left: 8 })
      }
      .alignItems(VerticalAlign.Center)

      // 第二行:消息摘要
      Text(item.summary)
        .fontSize(13)
        .fontColor('#666666')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .width('100%')
        .margin({ top: 4 })

      // 未读标记
      if (item.unread) {
        Text('● 未读')
          .fontSize(11)
          .fontColor('#FF3B30')
          .margin({ top: 2 })
      }
    }
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .margin({ left: 12 })
  }
  .width('100%')
  .padding({ top: 12, bottom: 12 })
  .borderRadius(8)
}

这是一个典型的 Column + Row 混合布局案例,展示了如何在 Column 容器中嵌套 Row 来实现复杂的 UI 结构。

布局层级分析

Row(水平:头像 | 文字区域)
├── Text(头像 emoji)
└── Column(垂直:姓名行 | 摘要 | 未读标记)
    ├── Row(水平:姓名 + 时间)
    │   ├── Text(姓名)
    │   └── Text(时间)
    ├── Text(消息摘要)
    └── Text(未读标记,条件渲染)

关键布局代码解释

  1. 外层 Row 将头像和文字区域在水平方向并排排列
  2. 右侧 Column 内部使用垂直排列,但没有显式设置 alignItems(ItemAlign.Start),因为 Column 默认行为就是靠左对齐
  3. 内层 Row(姓名+时间)使用 alignItems(VerticalAlign.Center) 实现文本在垂直方向居中对齐
  4. .justifyContent(FlexAlign.Center) 让文字区域在垂直方向居中,使姓名行和摘要行的整体居于 Row 高度的中央
  5. maxLines(1) + textOverflow({ overflow: TextOverflow.Ellipsis }) 实现单行文本溢出省略号的效果

关于 alignItems 的重要提醒:在 Column 上,alignItems 控制水平对齐;在 Row 上,alignItems 控制垂直对齐。两者方向不同,但概念一致。


9. 场景二:注册表单 —— 纵向表单项排列

9.1 表单字段枚举

enum FormField {
  USERNAME = 0,
  EMAIL = 1,
  PASSWORD = 2,
  CONFIRM_PASSWORD = 3
}

使用 enum 枚举来区分不同的表单字段,比用字符串字面量更安全、更可维护。在 onChange 回调中通过枚举值判断当前是哪个字段在变化,避免散布多个独立的回调函数。

9.2 表单状态变量

@State username: string = '';
@State email: string = '';
@State password: string = '';
@State confirmPassword: string = '';
@State formMessage: string = '';

每个表单字段对应一个 @State 变量。formMessage 用于显示验证错误提示信息。当表单提交或字段内容变化时,这些响应式变量驱动 UI 重新渲染。

9.3 buildFormDemo():表单容器

@Builder
buildFormDemo() {
  Column() {
    Text('用户注册')
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1A1A1A')
      .margin({ bottom: 20 })

    this.formField('用户名', '请输入用户名', FormField.USERNAME)
    this.formField('邮箱地址', '请输入邮箱', FormField.EMAIL)
    this.formField('密码', '请输入密码', FormField.PASSWORD)
    this.formField('确认密码', '请再次输入密码', FormField.CONFIRM_PASSWORD)

    if (this.formMessage.length > 0) {
      Text(this.formMessage)
        .fontSize(13)
        .fontColor('#FF3B30')
        .margin({ top: 8, left: 4 })
    }

    Button('注册')
      .width(120)
      .height(44)
      .backgroundColor('#007AFF')
      .borderRadius(22)
      .fontColor('#FFFFFF')
      .fontSize(16)
      .margin({ top: 24 })
      .onClick(() => {
        this.handleRegister()
      })
  }
  .width('100%')
  .padding(24)
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ left: 16, right: 16, top: 12 })
  .alignItems(ItemAlign.Start)
}

布局要点

  1. alignItems(ItemAlign.Start) 确保标题、所有表单项、提示文本以及注册按钮都靠左对齐
  2. 按钮设置了固定宽度 120,而不是撑满父容器,配合左对齐形成「小按钮在左」的视觉效果
  3. 错误提示文本仅在 formMessage 非空时条件渲染

9.4 formField():单个表单项

@Builder
formField(label: string, placeholder: string, field: FormField) {
  Column() {
    Text(label)
      .fontSize(14)
      .fontWeight(FontWeight.Medium)
      .fontColor('#333333')
      .margin({ bottom: 4 })

    TextInput({ placeholder: placeholder })
      .width('100%')
      .height(44)
      .backgroundColor('#F8F8F8')
      .borderRadius(8)
      .padding({ left: 12 })
      .fontSize(14)
      .onChange((value: string) => {
        if (field === FormField.USERNAME) {
          this.username = value
        } else if (field === FormField.EMAIL) {
          this.email = value
        } else if (field === FormField.PASSWORD) {
          this.password = value
        } else if (field === FormField.CONFIRM_PASSWORD) {
          this.confirmPassword = value
        }
      })
  }
  .width('100%')
  .margin({ bottom: 16 })
}

设计思路

  • 每个表单项是一个独立的 Column 容器,上层是标签 Text,下层是输入框 TextInput
  • 标签和输入框左对齐,符合表单填写的视觉引导
  • TextInput 宽度设为 '100%' 撑满父容器,保证在不同屏幕宽度下均能自适应
  • 背景色 #F8F8F8 与纯白卡片背景形成微妙的层次差异,暗示输入框区域是可交互的
  • 圆角 borderRadius(8) 使输入框风格柔和

9.5 handleRegister():表单验证逻辑

handleRegister(): void {
  if (!this.username || !this.email || !this.password || !this.confirmPassword) {
    this.formMessage = '请填写所有字段'
    return
  }
  if (this.password !== this.confirmPassword) {
    this.formMessage = '两次输入的密码不一致'
    return
  }
  if (this.password.length < 6) {
    this.formMessage = '密码长度不能少于6位'
    return
  }
  this.formMessage = ''
  console.info(`[ColumnStartDemo] 注册成功: ${this.username}`)
}

这是一个完整的客户端表单验证逻辑:

  1. 空值检查:所有字段必须填写
  2. 一致性检查:密码与确认密码必须相同
  3. 长度检查:密码不能少于 6 位
  4. 通过处理:清空错误信息,输出成功日志

当验证失败时,this.formMessage 被赋予错误文本。由于 formMessage@State 变量,UI 中的条件渲染 if (this.formMessage.length > 0) 会刷新,红色错误提示文本随即出现。


10. 场景三:信息流 —— 可滚动的 Column 布局

10.1 信息流数据

@State feedItems: string[] = [
  'HarmonyOS NEXT 5.0 发布:全面升级原生体验',
  'ArkTS 3.0 新特性:更简洁的状态管理',
  '鸿蒙生态设备数量突破 10 亿',
  '开发者大会 2025 议程公布'
];

这里使用 string[] 简单数组,实际项目中可以替换为更复杂的结构化数据。

10.2 buildInfoFeed():可滚动的 Column

@Builder
buildInfoFeed() {
  Scroll() {
    Column() {
      Text('最新资讯')
        .fontSize(16)
        .fontColor('#333333')
        .margin({ left: 4, bottom: 12 })

      ForEach(this.feedItems, (item: string, index: number) => {
        Row() {
          Text(`${index + 1}`)
            .width(24)
            .height(24)
            .backgroundColor('#007AFF')
            .borderRadius(12)
            .fontColor('#FFFFFF')
            .fontSize(12)
            .textAlign(TextAlign.Center)

          Text(item)
            .fontSize(14)
            .fontColor('#333333')
            .margin({ left: 12 })
        }
        .width('100%')
        .padding({ top: 10, bottom: 10, left: 4 })
        .borderRadius(6)
        .alignItems(VerticalAlign.Center)
      }, (item: string) => item)
    }
    .width('100%')
  }
  .width('100%')
  .padding(16)
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ left: 16, right: 16, top: 12 })
  .height(300)
}

10.3 Scroll 容器详解

Scroll 是 ArkUI 的滚动容器组件,当内容超出容器尺寸时自动启用滚动能力。

使用 Scroll 的注意点

  1. Scroll 只能有一个直接子组件。因此这里 Scroll 内部只放了一个 Column,再由 Column 承载所有信息流条目
  2. Column 本身不设置高度,其高度由子内容撑开。Scroll 通过比较 Column 的实际高度与 Scroll 自身的固定高度(300)来判定是否需要滚动
  3. 如果内容不足一屏,Scroll 不会出现滚动条,表现如同普通 Column

10.4 信息流条目设计

每个信息流条目使用 Row 水平排列序号圆圈和新闻标题:

  • 序号圆圈:固定 24×24 大小的圆形蓝色背景,白色数字居中,形成清晰的可视化编号
  • 标题文本:靠左排列,与序号保持 12px 间距
  • .alignItems(VerticalAlign.Center):使序号与文本在垂直方向居中对齐

11. Column + Row 混合布局技巧

在实际项目中,几乎不会只使用纯 Column 布局。Column 与 Row 的混合嵌套是构建复杂 UI 的基础。

11.1 嵌套原则

Column(纵向主框架)
├── Row(导航栏、标签栏)
├── Column(内容区块)
│   ├── Row(区块内的行布局)
│   └── Row(区块内的另一行)
└── Row(底部操作栏)

11.2 本项目中使用的混合布局模式

模式一:Row 内部嵌套 Column

Row() {
  // 左侧固定内容
  Text('头像')
  // 右侧可变内容(用 Column 实现纵向排列)
  Column() {
    Text('标题行')
    Text('详情行')
    Text('额外行')
  }
}

这种模式适用于「左侧固定图标/头像 + 右侧垂直文字信息」的场景,如联系人列表、消息列表、评论列表等。

模式二:Column 内部嵌套 Row

Column() {
  Row() {
    Text('姓名')
    Text('时间')
  }
  Text('详细内容')
}

这种模式适用于「标题行(多元素水平排列)+ 详情行(单元素独占一行)」的场景。

11.3 对齐属性的继承与覆盖

重要概念alignItemsjustifyContent容器属性,只作用于直接子组件,不会递归应用到孙组件。

Column() {          // 父 Column:alignItems(Start)
  Column() {        // 子 Column:没有设置 alignItems → 默认 Stretch
    Text('文本')     // 孙组件:被拉伸填满子 Column 宽度
  }
}
.alignItems(ItemAlign.Start)

这意味着每个容器都需要独立设置其对齐属性,不能依赖父容器的对齐设置传递到后代组件。


12. @Builder 装饰器:复用 Column 布局片段

12.1 @Builder 的基本用法

@Builder 是 ArkTS 中定义可复用 UI 的方法装饰器:

@Builder
buildTitleBar() {
  Column() {
    Text('ColumnStart 布局演示')
      .fontSize(22)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1A1A1A')

    Text('Column + alignItems(Start) + justifyContent')
      .fontSize(13)
      .fontColor('#888888')
      .margin({ top: 4 })
  }
  .width('100%')
  .padding({ left: 20, top: 48, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .alignItems(ItemAlign.Start)
}

然后在 build() 中调用:

build() {
  Column() {
    this.buildTitleBar()  // ← 调用 @Builder 方法
    // 其他内容...
  }
}

12.2 @Builder 方法的参数传递

@Builder 方法可以接收参数,使其更加灵活:

@Builder
tabButton(label: string, index: number) {
  Column() {
    Text(label).fontSize(14)
      .fontColor(this.activeTabIndex === index ? '#007AFF' : '#666666')

    if (this.activeTabIndex === index) {
      Divider().width('80%').height(3).color('#007AFF').borderRadius(2).margin({ top: 4 })
    }
  }
  .onClick(() => {
    this.activeTabIndex = index;
  })
}

// 调用时传入不同参数
this.tabButton('📬 消息列表', 0)
this.tabButton('📝 注册表单', 1)
this.tabButton('📰 信息流', 2)

12.3 @Builder 与 @Component 的选择

特性 @Builder @Component
定义方式 结构体中的方法 独立 struct
状态管理 访问外部 @State 拥有自己的 @State
复用范围 当前组件内部 全局可复用
性能开销 轻量(内联展开) 较重(独立组件实例)
适用场景 页面内部 UI 片段复用 独立可复用的组件单元

13. @State 状态管理与布局联动

13.1 响应式状态驱动

ArkTS 的 @State 装饰器实现了响应式编程。当状态变量变化时,框架自动追踪依赖该状态的 UI 部分并触发最小粒度的重新渲染。

@State activeTabIndex: number = 0;

当用户点击标签按钮时:

.onClick(() => {
  this.activeTabIndex = index;  // 状态变化
})

UI 自动更新:

if (this.activeTabIndex === 0) {
  this.buildMessageList()       // 重新渲染对应内容
} else if (this.activeTabIndex === 1) {
  this.buildFormDemo()
} else {
  this.buildInfoFeed()
}

同时,标签按钮的样式也随状态变化:

Text(label)
  .fontColor(this.activeTabIndex === index ? '#007AFF' : '#666666')
  .fontWeight(this.activeTabIndex === index ? FontWeight.Medium : FontWeight.Regular)

13.2 @State 的变更检测

@State 采用浅比较机制来检测变化:

  • 对于基本类型(stringnumberboolean),直接比较值是否改变
  • 对于引用类型(ArrayObject),比较引用地址是否改变

因此,修改数组元素的属性并不会触发 UI 更新:

// ❌ 不会触发 UI 更新
this.messages[0].unread = false;

// ✅ 会触发 UI 更新
this.messages = [...this.messages]; // 创建新数组

14. 完整的 ColumnStartPage.ets 源码

下面给出项目中所用的完整源码(去除注释后的精简版,但保留了关键的布局属性说明):

@Entry
@Component
struct ColumnStartPage {
  /* ─── 状态变量 ─── */
  @State messages: MessageItem[] = [
    { id: 1, avatar: '👤', name: '张三', summary: '项目方案已更新,请查收最新版本', time: '09:32', unread: true },
    { id: 2, avatar: '👥', name: '设计团队', summary: '首页UI切图已上传至共享文件夹', time: '昨天', unread: true },
    { id: 3, avatar: '📋', name: '任务提醒', summary: '你有3个待办事项即将到期', time: '昨天', unread: false },
    { id: 4, avatar: '📢', name: '系统通知', summary: '系统将于本周六02:00-05:00进行维护升级', time: '周三', unread: false },
    { id: 5, avatar: '💬', name: '李四', summary: '收到,我明天下午回复你', time: '周三', unread: false }
  ];
  @State activeTabIndex: number = 0;
  @State username: string = '';
  @State email: string = '';
  @State password: string = '';
  @State confirmPassword: string = '';
  @State formMessage: string = '';
  @State feedItems: string[] = [
    'HarmonyOS NEXT 5.0 发布:全面升级原生体验',
    'ArkTS 3.0 新特性:更简洁的状态管理',
    '鸿蒙生态设备数量突破 10 亿',
    '开发者大会 2025 议程公布'
  ];

  build() {
    Column() {
      this.buildTitleBar()
      this.buildTabBar()
      if (this.activeTabIndex === 0) {
        this.buildMessageList()
      } else if (this.activeTabIndex === 1) {
        this.buildFormDemo()
      } else {
        this.buildInfoFeed()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
    .alignItems(ItemAlign.Start)       // ★ 核心:子组件靠左对齐
    .justifyContent(FlexAlign.Start)    // ★ 核心:子组件从顶部排列
  }

  /* ── 标题栏 ── */
  @Builder
  buildTitleBar() {
    Column() {
      Text('ColumnStart 布局演示').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
      Text('Column + alignItems(Start) + justifyContent').fontSize(13).fontColor('#888888').margin({ top: 4 })
    }
    .width('100%')
    .padding({ left: 20, top: 48, bottom: 12 })
    .backgroundColor('#FFFFFF')
    .alignItems(ItemAlign.Start)
  }

  /* ── 标签栏 ── */
  @Builder
  buildTabBar() {
    Row() {
      this.tabButton('📬 消息列表', 0)
      this.tabButton('📝 注册表单', 1)
      this.tabButton('📰 信息流', 2)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 12, bottom: 12 })
    .backgroundColor('#FFFFFF')
    .justifyContent(FlexAlign.SpaceEvenly)
  }

  @Builder
  tabButton(label: string, index: number) {
    Column() {
      Text(label)
        .fontSize(14)
        .fontColor(this.activeTabIndex === index ? '#007AFF' : '#666666')
        .fontWeight(this.activeTabIndex === index ? FontWeight.Medium : FontWeight.Regular)
      if (this.activeTabIndex === index) {
        Divider().width('80%').height(3).color('#007AFF').borderRadius(2).margin({ top: 4 })
      }
    }
    .onClick(() => { this.activeTabIndex = index })
  }

  /* ── 消息列表 ── */
  @Builder
  buildMessageList() {
    Column() {
      Text('最近消息').fontSize(16).fontColor('#333333').margin({ left: 4, top: 8, bottom: 4 })
      ForEach(this.messages, (item: MessageItem) => { this.messageItem(item) },
        (item: MessageItem) => item.id.toString())
    }
    .width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(12)
    .margin({ left: 16, right: 16, top: 12 })
  }

  @Builder
  messageItem(item: MessageItem) {
    Row() {
      Text(item.avatar).fontSize(28).width(48).height(48).backgroundColor('#F0F0F0')
        .borderRadius(24).textAlign(TextAlign.Center)
      Column() {
        Row() {
          Text(item.name).fontSize(15).fontWeight(FontWeight.Medium).fontColor('#1A1A1A')
          Text(item.time).fontSize(12).fontColor('#AAAAAA').margin({ left: 8 })
        }.alignItems(VerticalAlign.Center)
        Text(item.summary).fontSize(13).fontColor('#666666').maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%').margin({ top: 4 })
        if (item.unread) {
          Text('● 未读').fontSize(11).fontColor('#FF3B30').margin({ top: 2 })
        }
      }.height('100%').justifyContent(FlexAlign.Center).margin({ left: 12 })
    }.width('100%').padding({ top: 12, bottom: 12 }).borderRadius(8)
  }

  /* ── 注册表单 ── */
  @Builder
  buildFormDemo() {
    Column() {
      Text('用户注册').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1A1A1A').margin({ bottom: 20 })
      this.formField('用户名', '请输入用户名', FormField.USERNAME)
      this.formField('邮箱地址', '请输入邮箱', FormField.EMAIL)
      this.formField('密码', '请输入密码', FormField.PASSWORD)
      this.formField('确认密码', '请再次输入密码', FormField.CONFIRM_PASSWORD)
      if (this.formMessage.length > 0) {
        Text(this.formMessage).fontSize(13).fontColor('#FF3B30').margin({ top: 8, left: 4 })
      }
      Button('注册').width(120).height(44).backgroundColor('#007AFF').borderRadius(22)
        .fontColor('#FFFFFF').fontSize(16).margin({ top: 24 })
        .onClick(() => { this.handleRegister() })
    }
    .width('100%').padding(24).backgroundColor('#FFFFFF').borderRadius(12)
    .margin({ left: 16, right: 16, top: 12 }).alignItems(ItemAlign.Start)
  }

  @Builder
  formField(label: string, placeholder: string, field: FormField) {
    Column() {
      Text(label).fontSize(14).fontWeight(FontWeight.Medium).fontColor('#333333').margin({ bottom: 4 })
      TextInput({ placeholder: placeholder }).width('100%').height(44).backgroundColor('#F8F8F8')
        .borderRadius(8).padding({ left: 12 }).fontSize(14)
        .onChange((value: string) => {
          if (field === FormField.USERNAME) { this.username = value }
          else if (field === FormField.EMAIL) { this.email = value }
          else if (field === FormField.PASSWORD) { this.password = value }
          else if (field === FormField.CONFIRM_PASSWORD) { this.confirmPassword = value }
        })
    }.width('100%').margin({ bottom: 16 })
  }

  handleRegister(): void {
    if (!this.username || !this.email || !this.password || !this.confirmPassword) {
      this.formMessage = '请填写所有字段'; return
    }
    if (this.password !== this.confirmPassword) {
      this.formMessage = '两次输入的密码不一致'; return
    }
    if (this.password.length < 6) {
      this.formMessage = '密码长度不能少于6位'; return
    }
    this.formMessage = ''
    console.info(`[ColumnStartDemo] 注册成功: ${this.username}`)
  }

  /* ── 信息流 ── */
  @Builder
  buildInfoFeed() {
    Scroll() {
      Column() {
        Text('最新资讯').fontSize(16).fontColor('#333333').margin({ left: 4, bottom: 12 })
        ForEach(this.feedItems, (item: string, index: number) => {
          Row() {
            Text(`${index + 1}`).width(24).height(24).backgroundColor('#007AFF')
              .borderRadius(12).fontColor('#FFFFFF').fontSize(12).textAlign(TextAlign.Center)
            Text(item).fontSize(14).fontColor('#333333').margin({ left: 12 })
          }.width('100%').padding({ top: 10, bottom: 10, left: 4 }).borderRadius(6)
            .alignItems(VerticalAlign.Center)
        }, (item: string) => item)
      }.width('100%')
    }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(12)
      .margin({ left: 16, right: 16, top: 12 }).height(300)
  }
}

interface MessageItem {
  id: number;
  avatar: string;
  name: string;
  summary: string;
  time: string;
  unread: boolean;
}

enum FormField {
  USERNAME = 0,
  EMAIL = 1,
  PASSWORD = 2,
  CONFIRM_PASSWORD = 3
}

15. EntryAbility 页面加载配置

应用启动时,EntryAbility 负责加载 ColumnStartPage 页面。

15.1 EntryAbility.ets 源码

import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

const DOMAIN = 0x0000;

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    try {
      this.context.getApplicationContext()
        .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
    } catch (err) {
      hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s',
        JSON.stringify(err));
    }
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/ColumnStartPage', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, 'testTag',
          'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
    });
  }
  // 其他生命周期方法...
}

15.2 关键调用链

  1. 应用启动 → EntryAbility.onCreate() 调用
  2. 窗口创建 → EntryAbility.onWindowStageCreate() 调用
  3. windowStage.loadContent('pages/ColumnStartPage', callback) 加载页面
  4. 框架根据 main_pages.json 的路由映射找到 pages/ColumnStartPage 对应的 .ets 文件
  5. 页面的 @Entry @Component struct ColumnStartPage 被实例化并渲染

15.3 module.json5 配置

{
  "module": {
    "name": "entry",
    "type": "entry",
    "mainElement": "EntryAbility",
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "exported": true,
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["ohos.want.action.home"]
          }
        ]
      }
    ]
  }
}

"skills" 配置中的 entity.system.homeohos.want.action.home 表示该 Ability 是应用的主入口,会在桌面显示应用图标。


16. 常见问题与避坑指南

16.1 Column 的默认 alignItems 是 Stretch

初学者最容易犯的错误是认为 Column 默认左对齐。实际上,Column 在不设置 alignItems 时,默认行为是 ItemAlign.Stretch,即子组件会拉伸填满容器的宽度。

// ❌ 子组件会拉伸,Text 背景会撑满整个宽度
Column() {
  Text('左对齐文本')
    .backgroundColor('#FFE0E0')
}
.width('100%')

// ✅ 显式设置 Start,Text 背景仅包裹文本内容
Column() {
  Text('左对齐文本')
    .backgroundColor('#FFE0E0')
}
.width('100%')
.alignItems(ItemAlign.Start)

16.2 alignItems 不影响容器自身

alignItems 控制的是子组件的对齐,不是容器自身的对齐。容器的对齐需要由其父容器alignItems 控制。

// 外部 Column 控制内部 Column 的对齐
Column() {
  Column() {
    // 内部 Column 的内容
  }
  .width(200)          // 显式设置宽度
  .backgroundColor('#E0E0FF')
}
.width('100%')
.alignItems(ItemAlign.Center)  // 内部 Column 在外部 Column 中居中

16.3 justifyContent 需要确定性的容器高度

justifyContent 的效果依赖于容器的高度。如果容器高度由子组件撑开(即没有显式设置高度),则 justifyContent 的效果不明显,因为剩余空间为 0。

// ❌ justifyContent 无效果——容器高度由子组件撑满
Column() {
  Text('A')
  Text('B')
}
.alignItems(ItemAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)

// ✅ 容器有固定高度,SpaceBetween 才能生效
Column() {
  Text('A')
  Text('B')
}
.height(400)
.alignItems(ItemAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)

16.4 ForEach 的 key 生成器不可省略

// ❌ 省略 key 生成器——列表更新时性能差
ForEach(this.messages, (item: MessageItem) => {
  this.messageItem(item)
})

// ✅ 提供 key 生成器——框架可以精确追踪每个列表项
ForEach(this.messages, (item: MessageItem) => {
  this.messageItem(item)
}, (item: MessageItem) => item.id.toString())

16.5 @Builder 方法不能独立修改状态

@Builder
tabButton(label: string, index: number) {
  Column() {
    Text(label)
  }
  // ✅ 正确:通过回调修改组件的 @State
  .onClick(() => {
    this.activeTabIndex = index
  })

  // ❌ 错误:@Builder 内部不能使用 @State 装饰器定义新状态
  // @State localState: boolean = false
}

16.6 Scroll 容器内不要使用固定高度

Scroll() {
  Column() {
    // ❌ 不需要给 Column 设置固定高度
    // .height('100%')  ← 这会破坏滚动
  }
}

Scroll 判断是否需要滚动的依据是:子内容的高度 > Scroll 容器的高度。如果子 Column 设置了固定高度等于 Scroll 高度,滚动将不会生效。


17. 总结与最佳实践

17.1 ColumnStart 布局的精髓

ColumnStart 布局的核心可以归结为两行代码:

.alignItems(ItemAlign.Start)    // 左对齐
.justifyContent(FlexAlign.Start) // 顶部排列

这两者组合,确保了所有子组件从左上角原点开始依次排列。这是最符合用户阅读习惯的布局方式,也是绝大多数信息型页面的默认布局方案。

17.2 何时使用 ColumnStart?

  • 消息列表:每条消息垂直排列,左对齐展示
  • 注册/登录表单:表单项从上到下依次排列
  • 设置页面:设置项分组纵向排列
  • 文章/资讯列表:标题+摘要垂直排列
  • 个人主页:头像+昵称+简介垂直堆叠
  • 评论区:每条评论垂直排列

17.3 何时不适合 ColumnStart?

  • 水平导航栏:应使用 Row
  • 网格/瀑布流:应使用 Grid / WaterFlow
  • 居中弹窗:应使用 Column + .alignItems(ItemAlign.Center)
  • 底部操作栏:应使用 Row + .justifyContent(FlexAlign.SpaceEvenly)

17.4 布局性能优化建议

  1. 减少嵌套层级:过深的 Column 嵌套会增加布局计算开销,尽量控制在 3 层以内
  2. 合理使用 ForEach:长列表提供 key 生成器,避免全量重建
  3. Scroll 与 Column 搭配:内容超出屏幕时,Scroll 是必要的;内容不足时,去掉 Scroll 可减少组件开销
  4. 避免不必要的 @State:只需要渲染的数据才用 @State 装饰,普通常量数据不需要响应式

17.5 最后的话

ColumnStart 布局看似简单,却是构建鸿蒙原生应用 UI 的基石。掌握了 Column + alignItems(ItemAlign.Start) + justifyContent(FlexAlign.Start) 的组合,再配合 Row 的水平排列能力,你已经能够搭建出 90% 以上常见的移动端页面布局。

实际项目开发中,建议从最外层的 Column 开始规划页面的纵向结构,然后逐步在各个内容区块内部使用 Row 或嵌套 Column 来实现更精细的布局。始终记住 ArkUI 的布局哲学:容器决定排列方向,对齐属性控制具体位置,状态驱动 UI 更新

祝愿你在 HarmonyOS NEXT 的开发旅程中,用 ColumnStart 布局构建出优雅高效的用户界面。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐