项目演示

在这里插入图片描述

目录

  1. 项目背景与业务场景
  2. 项目搭建与 API 24 适配详解
  3. ArkTS 声明式 UI 核心概念
  4. ColumnStart 纵向布局设计哲学
  5. 数据模型 — TravelPost 接口设计
  6. 页面入口 — @Entry Index 组件
  7. 标题栏组件 — TitleBar 拆解
  8. 核心卡片组件 — TravelCard 深度剖析
  9. 六段式卡片布局逐行解析
  10. 交互事件 — 点赞 / 收藏状态管理
  11. @Builder 复用 — actionButton 设计模式
  12. List + ForEach 滚动列表性能优化
  13. 视觉系统 — 圆角、阴影、分割线、配色
  14. API 24 严格模式 — 语法规则对照
  15. 全量代码与构建验证
  16. 总结与扩展方向

1. 项目背景与业务场景

1.1 什么是旅行景点打卡游记页面

旅行打卡游记页面是当下社交类 App 中最常见的功能模块之一。用户在一次旅行结束后,通过图文结合的方式记录游玩体验,包括景点照片、游玩文案、定位地址、打卡时间,并支持点赞、评论、收藏等社交互动。

这类页面的典型业务场景包括:

  • 小红书式旅行笔记:用户分享旅行攻略、打卡照
  • 朋友圈旅行动态:好友之间分享旅行见闻
  • 旅行产品评论:在携程、马蜂窝等平台上对景点的评价
  • 企业内部团建记录:团队出行后的集体回忆

1.2 本项目的目标

使用 HarmonyOS NEXT 的原生 ArkTS 语言,基于 API 24(SDK 7.0.0),构建一个完整的、可直接运行的旅行景点打卡游记页面,包含以下核心功能:

功能模块 技术实现
🖼️ 风景封面图 Image 组件 + objectFit(Cover) + borderRadius
📝 游玩文案 Text 组件 + lineHeight + textAlign
📍 定位地址 Row + Emoji + Text 组合
🕐 打卡时间 Row + Emoji + Text 组合
❤️ 点赞交互 @State 状态驱动 + 即时 Toast 反馈
💬 评论占位 @Builder 复用 + Toast 提示
⭐ 收藏交互 @State 状态驱动 + 双向切换
📜 滚动列表 List + ListItem + ForEach

1.3 为什么使用 ColumnStart 布局

本页面所有组件采用 顶部对齐(左对齐) 的纵向排列方式。这种设计选择基于以下考量:

  1. 信息流阅读习惯:用户从上到下浏览,左对齐减少视线跳转
  2. 一致的视觉起点:所有内容从同一垂直轴线开始,形成规整感
  3. 移动端适配友好:在窄屏设备上,左对齐比居中布局利用空间更高效
  4. 组件化复用:统一的对齐方式使得子组件的组合和替换更加可预测

在 CSS 的世界里,这相当于一个 display: flex; flex-direction: column; align-items: flex-start; 的容器。在 ArkUI 中,这就是 Column + alignItems(HorizontalAlign.Start)


2. 项目搭建与 API 24 适配详解

2.1 创建新项目

在 DevEco Studio NEXT 中创建项目时,关键配置如下:

配置项
项目模板 Empty Ability
Bundle Name com.example.myapplication4
Compile SDK API 24 (HarmonyOS SDK 7.0.0)
Device Type Phone
Language ArkTS
Model Stage

2.2 项目根级 build-profile.json5 配置

项目根目录的 build-profile.json5 是整个构建系统的总控文件。适配 API 24 的核心在于 targetSdkVersioncompatibleSdkVersion 两项配置:

{
  "app": {
    "signingConfigs": [],
    "products": [
      {
        "name": "default",
        "signingConfig": "default",
        // ★★★ 关键:targetSdkVersion 必须设为 7.0.0(24) ★★★
        "targetSdkVersion": "7.0.0(24)",
        "compatibleSdkVersion": "7.0.0(24)",
        "runtimeOS": "HarmonyOS",
        "buildOption": {
          "strictMode": {
            "caseSensitiveCheck": true,
            "useNormalizedOHMUrl": true
          }
        }
      }
    ],
    "buildModeSet": [
      { "name": "debug" },
      { "name": "release" }
    ]
  },
  "modules": [
    {
      "name": "entry",
      "srcPath": "./entry",
      "targets": [
        { "name": "default", "applyToProducts": ["default"] }
      ]
    }
  ]
}

💡 提示: compatibleSdkVersion 决定了运行的最低 API 版本。设为 24 意味着仅支持 HarmonyOS NEXT 7.0 及以上版本。如果你需要兼容旧设备,可以设为 6.0.2(22),但将无法使用 API 24 的新特性(如 @Animator 装饰器、AudioKit 等)。

2.3 entry 级 build-profile.json5 配置

模块级配置文件控制编译选项:

{
  "apiType": "stageMode",
  "buildOption": {
    "resOptions": {
      "copyCodeResource": {
        "enable": false
      }
    }
  },
  "buildOptionSet": [
    {
      "name": "release",
      "arkOptions": {
        "obfuscation": {
          "ruleOptions": {
            "enable": false,
            "files": ["./obfuscation-rules.txt"]
          }
        }
      }
    }
  ],
  "targets": [
    { "name": "default" },
    { "name": "ohosTest" }
  ]
}

2.4 module.json5 页面路由配置

entry/src/main/module.json5 中需要声明页面路由:

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": ["phone"],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["ohos.want.action.home"]
          }
        ]
      }
    ]
  }
}

2.5 main_pages.json 页面声明

页面的路由注册文件 resources/base/profile/main_pages.json

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

2.6 编译运行验证

在命令行中执行构建命令,验证 API 24 配置是否生效:

# 进入项目根目录,执行 HAP 包构建
hvigorw assembleHap --mode module -p product=default --no-daemon

# 构建成功后输出类似内容:
# BUILD SUCCESSFUL in 10s 366ms

如果构建失败,最常见的错误原因包括:

  • targetSdkVersion 格式错误(应为 7.0.0(24) 而非 7.0.0.0(24)
  • 使用了 API 22 的 @ohos.* 导入路径(API 24 要求使用 @kit.*
  • 代码中存在对象展开语法 {...obj}(API 24 禁止)

3. ArkTS 声明式 UI 核心概念

3.1 声明式 UI 范式

ArkTS 采用声明式 UI 范式——你描述"UI 应该长什么样",而不是一步步命令框架去绘制。这与 React 的 JSX、SwiftUI 的 @ViewBuilder 理念一致。

命令式 vs 声明式的对比:

// 命令式思维(伪代码——不推荐):"先创建一个列,再添加文字..."
let column = new Column();
let title = new Text('旅行游记');
column.addChild(title);

// 声明式思维(ArkTS 方式):"这是一个列,里面有文字..."
Column() {
  Text('旅行游记')
    .fontSize(20)
}

3.2 装饰器体系

ArkTS 通过装饰器来标记变量和组件的特殊行为。本项目中用到的装饰器:

装饰器 作用 使用位置
@Entry 标记页面入口组件 Index 结构体上
@Component 声明一个自定义组件 Index、TitleBar、TravelCard
@State 响应式状态,变化自动刷新 UI travelPosts、likes、isLiked
@Prop 父→子单向数据传递 postItem
@Builder 复用 UI 片段 actionButton

装饰器的本质: 装饰器并不改变变量的运行逻辑,而是在编译期生成额外的 UI 更新代码。例如 @State 会在赋值操作后自动触发 build() 方法的重建。

3.3 组件树的构建过程

当 ArkTS 应用运行时,组件树的构建遵循以下流程:

1. EntryAbility.onCreate()          ← 应用启动
2. windowStage.loadContent()        ← 加载页面
3. Index.build()                    ← 构建根组件
4.   ├── TitleBar.build()           ← 构建标题栏
5.   └── List()                     ← 构建列表
6.         ├── ListItem             ← 第1张卡片
7.         │     └── TravelCard.build()
8.         ├── ListItem             ← 第2张卡片
9.         │     └── TravelCard.build()
10.        └── ListItem             ← 第3张卡片
               └── TravelCard.build()

每次 @State 变量变化时,会触发该组件及其子组件的增量更新(而非全量重建),大幅提升渲染性能。


4. ColumnStart 纵向布局设计哲学

4.1 什么是 ColumnStart

ColumnStartColumn 容器 + alignItems(HorizontalAlign.Start) 的简称。它指:

  • 容器方向:纵向(垂直排列)
  • 子组件对齐:顶部对齐(水平方向靠左,即 Start)

在 ArkUI 的三维坐标系中:

对齐方式 X 轴(水平) Y 轴(垂直) 视觉效果
Column + Start 靠左 从上到下 规整的左对齐信息流
Column + Center 居中 从上到下 文字居中的列表
Column + End 靠右 从上到下 右对齐的侧边栏

4.2 本项目的 ColumnStart 层次

整个页面形成了三层嵌套的 ColumnStart 结构:

┌─ 第1层:Index 顶层 Column(Start) ─────────────────────┐
│  .alignItems(HorizontalAlign.Start)                    │
│  .backgroundColor('#F2F4F8')                           │
│                                                        │
│  ├─ 第2层:TitleBar ── Column(Start) ──────────────┐  │
│  │  .alignItems(HorizontalAlign.Start)               │  │
│  │  ├─ Text('🗺️ 旅行打卡游记')                      │  │
│  │  └─ Text('记录每一次出发,收藏每一段旅程')        │  │
│  └────────────────────────────────────────────────────┘  │
│                                                        │
│  ├─ 第2层:List ── (默认纵向) ──────────────────────┐  │
│  │  ├─ 第3层:TravelCard ── Column(Start) ────────┐  │  │
│  │  │  │  Row(头像+昵称)    ← justifyContent(Start) │  │  │
│  │  │  │  Image(封面图)     ← width(100%)           │  │  │
│  │  │  │  Text(文案)        ← textAlign(Start)      │  │  │
│  │  │  │  Row(📍定位)       ← justifyContent(Start) │  │  │
│  │  │  │  Row(🕐时间)       ← justifyContent(Start) │  │  │
│  │  │  │  Divider                                  │  │  │
│  │  │  │  Row(❤️💬⭐)       ← justifyContent(Start) │  │  │
│  │  │  └─────────────────────────────────────────────┘  │
│  │  ├─ TravelCard (同上结构)                           │  │
│  │  └─ TravelCard (同上结构)                           │  │
│  └────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────┘

每一层 Column 的 alignItems(HorizontalAlign.Start) 都确保了该层的所有子组件从同一垂直轴线开始排列,形成视觉上的规整感。

4.3 ColumnStart vs 其他布局的对比

对比项 ColumnStart(本项目) Flex 布局 Stack 层叠
排列方向 纵向(固定) 任意 层叠(Z 轴)
对齐控制 alignItems + justifyContent 灵活度最高 alignContent
子组件间距 space 参数 gap 参数 margin 控制
适用场景 信息流、表单、列表 复杂弹性面板 角标、遮罩、全屏
学习曲线 ⭐ 最容易 ⭐⭐⭐ 中等 ⭐⭐ 简单
性能开销

4.4 为什么不用 Grid / Flex 替代

有人可能会问:为什么不用 Grid 来实现这个游记卡片列表?原因有两点:

  1. 一列纵向流是 Column 的天然场景。 Grid 是为多列网格设计的,用在单列场景下会有不必要的性能开销。
  2. ColumnStart 的语义更清晰。 代码阅读者看到 Column + alignItems(Start) 就能立即理解这是一个"上下排列、全部左对齐"的布局,而 Grid 需要额外的 columnsTemplate('1fr') 才能表达同样的含义。

5. 数据模型 — TravelPost 接口设计

5.1 接口定义

数据模型是整个应用的基石。TravelPost 接口精确地描述了单条打卡游记的所有属性:

/**
 * 打卡游记数据结构 —— 描述一条完整的旅行记录
 */
interface TravelPost {
  /** 用户头像资源 ID —— 使用 Resource 类型而非 string */
  avatar: Resource;
  /** 用户昵称 */
  nickName: string;
  /** 景点封面图(本地资源) */
  coverImage: Resource;
  /** 游玩文案内容 */
  caption: string;
  /** 定位地址描述 */
  location: string;
  /** 打卡时间字符串 */
  time: string;
  /** 点赞数量 */
  likes: number;
  /** 评论数量 */
  comments: number;
  /** 当前用户是否已点赞 */
  isLiked: boolean;
  /** 当前用户是否已收藏 */
  isFavorited: boolean;
}

5.2 Resource 类型的选择

注意 avatarcoverImage 的类型是 Resource 而非普通的 string。这是鸿蒙开发中一个重要的设计选择:

方案 写法 优点 缺点
Resource 类型 $r('app.media.startIcon') 类型安全、支持主题切换、编译期检查 只能引用打包时的资源
string 路径 '/data/xxx/icon.png' 支持动态加载 无类型检查、路径易错、沙箱限制
PixelMap 运行时解码 最灵活 需要手动管理内存

在游记页面中,所有图片都是编译时确定的(内置的示例数据),因此 Resource 是最优选择。

5.3 ActionButtonParams 接口

为了在 @Builder 中传递参数,需要显式声明一个接口类型——这是 API 24 的强制要求(arkts-no-untyped-obj-literals 规则):

/**
 * 操作按钮参数类型 —— 用于 @Builder actionButton
 * 
 * API 24 强制要求:对象字面量必须对应显式声明的 interface,
 * 不允许使用匿名对象类型作为参数。
 */
interface ActionButtonParams {
  icon: string;         // Emoji 图标文字
  text: string;         // 按钮显示文字(如 "128"、"收藏")
  isActive: boolean;    // 是否处于激活状态(影响颜色)
  onClick: () => void;  // 点击回调
}

5.4 模拟数据的设计

项目中内置了 3 条模拟数据,涵盖不同的场景:

场景 地点 文案风格 是否已点赞 是否已收藏
🌅 洱海日落 大理洱海廊道 治愈系
🌄 黄山日出 黄山光明顶 励志系
🍢 成都夜市 成都建设巷 美食系

这种多样化的数据设计方便在开发阶段验证所有 UI 状态(已点赞/未点赞、已收藏/未收藏的样式差异)。


6. 页面入口 — @Entry Index 组件

6.1 组件声明

Index 是整个页面的入口组件,使用 @Entry 装饰器标记:

@Entry
@Component
struct Index {
  @State private travelPosts: TravelPost[] = [
    // ... 3 条模拟数据
  ];

@Entry 的作用: 告诉 ArkUI 框架,这个组件是一个页面级别的入口,可以独立加载和导航。

6.2 @State 响应式数组

travelPosts 使用 @State 装饰,意味着:

  1. 当数组中的元素被修改并重新赋值时,UI 自动更新
  2. onLike()onFavorite() 中,通过 this.travelPosts[index] = post 触发更新

6.3 build() 方法结构

build() {
  // ★★★ 顶层 Column:纵向排列,所有子组件顶部对齐 ★★★
  Column() {
    TitleBar()               // 1. 标题栏
    List() {                 // 2. 可滚动列表
      ForEach(/*...*/) {     //    遍历数据
        ListItem {
          TravelCard(/*...*/) //    卡片组件
        }
      }
    }
    .layoutWeight(1)         //    占满剩余高度
  }
  .alignItems(HorizontalAlign.Start)  // ★ 核心对齐
  .backgroundColor('#F2F4F8')
  .padding({ left: 16, right: 16, top: 48, bottom: 16 })
}

关键属性说明:

  • .layoutWeight(1):让 List 占满 Column 的剩余空间。相当于 CSS Flex 中的 flex: 1
  • .padding({ top: 48 }):顶部留出状态栏区域(Status Bar 高度通常为 24~48vp)。
  • .backgroundColor('#F2F4F8'):暖灰色背景,与卡片白色形成对比,增强层次感。

6.4 交互事件处理

Index 组件中定义了点赞和收藏的事件处理函数,并通过闭包传递给子组件:

/** 点赞/取消点赞 */
onLike(index: number): void {
  let post: TravelPost = this.travelPosts[index];
  post.isLiked = !post.isLiked;
  post.likes = post.likes + (post.isLiked ? 1 : -1);
  this.travelPosts[index] = post;  // ★ 重新赋值触发 UI 更新
  promptAction.showToast({
    message: post.isLiked ? '❤️ 已点赞' : '🤍 已取消点赞',
    duration: 1200
  });
}

/** 收藏/取消收藏 */
onFavorite(index: number): void {
  let post: TravelPost = this.travelPosts[index];
  post.isFavorited = !post.isFavorited;
  this.travelPosts[index] = post;
  promptAction.showToast({
    message: post.isFavorited ? '⭐ 已收藏' : '☆ 已取消收藏',
    duration: 1200
  });
}

注意点: 这里的 { ...post } 对象展开语法被刻意避免了,因为 API 24 的 arkts-no-spread 规则禁止对象展开。我们直接修改原对象再重新赋值给数组元素,同样能触发 @State 的响应式更新。


7. 标题栏组件 — TitleBar 拆解

7.1 组件设计

标题栏是一个极其轻量的子组件,体现了"单一职责"原则。它的唯一功能是展示页面标题和副标题:

@Component
struct TitleBar {
  build() {
    Column() {
      // 主标题
      Text('🗺️ 旅行打卡游记')
        .fontSize(26)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A2E')

      // 副标题
      Text('记录每一次出发,收藏每一段旅程')
        .fontSize(14)
        .fontColor('#8C8C8C')
        .margin({ top: 4 })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)   // 标题栏内部也左对齐
  }
}

7.2 字体系统

元素 字号 字重 颜色 用途
主标题 26vp Bold #1A1A2E(深蓝黑) 页面核心标题
副标题 14vp Regular #8C8C8C(中灰) 补充说明文字

设计原则: 主标题和副标题之间保持 4vp 的间距(.margin({ top: 4 })),形成清晰的视觉层次——用户第一眼看到主标题,自然过渡到副标题。

7.3 为什么把标题栏拆成独立组件

有人可能觉得标题栏很简单,直接写在 Index 的 build() 里就好了。但拆分成独立组件有两个好处:

  1. 可复用性: 如果后续页面需要在多个 tab 中使用不同的标题栏,可以直接复用 TitleBar 组件,传入不同的参数。
  2. 可维护性: Index 的 build() 方法更简洁,每个组件各司其职,便于团队协作。

8. 核心卡片组件 — TravelCard 深度剖析

8.1 组件接口

TravelCard 是页面中最核心的子组件,接收 3 个参数:

@Component
struct TravelCard {
  /** 当前打卡数据 —— 使用 @Prop 实现单向数据流 */
  @Prop postItem: TravelPost = TravelCard.getDefaultPost();
  /** 点赞回调 */
  private onLike: () => void = (): void => {};
  /** 收藏回调 */
  private onFavorite: () => void = (): void => {};

8.2 @Prop 与 @State 的区别

@Prop 的特性:

  • 由父组件传入初始值
  • 子组件可以读取和修改,但修改不会同步回父组件
  • 当父组件的 @State 变化导致重新传入时,@Prop 会更新
  • 必须提供默认值(= TravelCard.getDefaultPost()

为什么这里用 @Prop 而不是 @State:

因为打卡数据的所有权在父组件(Index)手中,子组件只负责展示和触发回调。这是一种典型的单向数据流模式——数据从上往下流,事件从下往上冒泡:

Index (@State travelPosts)
  │
  ├── TravelCard (@Prop postItem)  ← 数据流下
  │     └── onClick ──────────────→ Index.onLike()  ← 事件冒泡
  │
  └── TravelCard (@Prop postItem)
        └── onClick ──────────────→ Index.onLike()

8.3 getDefaultPost() 静态方法

@Prop 变量必须提供默认值。getDefaultPost() 是一个静态工厂方法,返回一个"空"的 TravelPost 实例:

static getDefaultPost(): TravelPost {
  return {
    avatar: $r('app.media.startIcon'),
    nickName: '',
    coverImage: $r('app.media.background'),
    caption: '',
    location: '',
    time: '',
    likes: 0,
    comments: 0,
    isLiked: false,
    isFavorited: false
  };
}

⚠️ 注意: API 24 不允许 {...} 对象字面量作为类型声明(arkts-no-obj-literals-as-types),但作为函数返回值并显式标注返回类型 TravelPost 是允许的——因为编译器能推断出对象的结构。


9. 六段式卡片布局逐行解析

一张完整的游记卡片包含 6 个行层次,从上到下依次排列。我们来逐行分析。

9.1 第1行:头像 + 昵称(Row)

Row() {
  Image(this.postItem.avatar)
    .width(40).height(40)
    .borderRadius(20)               // → 圆形头像
    .margin({ right: 10 })
  Text(this.postItem.nickName)
    .fontSize(16)
    .fontWeight(FontWeight.Medium)
    .fontColor('#1A1A2E')
}
.width('100%')
.alignItems(VerticalAlign.Center)  // Row 内垂直居中对齐
.justifyContent(FlexAlign.Start)   // 靠左排列

布局要点:

  • Image.width(40).height(40) + .borderRadius(20) = 圆形头像(半径为宽高的一半)
  • .margin({ right: 10 }) 在头像和昵称之间留出 10vp 间距
  • .justifyContent(FlexAlign.Start) 确保整行靠左排列

9.2 第2行:风景封面图(Image)

Image(this.postItem.coverImage)
  .width('100%')       // 宽度占满卡片
  .height(200)         // 固定高度
  .borderRadius(12)    // 圆角边缘
  .objectFit(ImageFit.Cover)  // 覆盖裁剪

ImageFit.Cover 的含义: 保持图片原始宽高比,缩放至完全覆盖容器。如果图片比例与容器不匹配,超出的部分会被裁剪。这与 CSS 的 object-fit: cover 效果一致。

ImageFit 枚举值 效果 适用场景
Cover 覆盖裁剪 风景封面图(本项目)
Contain 完整显示(可能有留白) 产品展示图
Fill 拉伸填满 图标
Auto 自动选择 通用场景

9.3 第3行:游玩文案(Text)

Text(this.postItem.caption)
  .fontSize(15)
  .lineHeight(22)          // 行高 22vp,约 1.47 倍行距
  .fontColor('#2C2C2C')
  .textAlign(TextAlign.Start)  // 左对齐
  .width('100%')

文字设计考量:

  • fontSize(15) 比正文略大,适合在移动端阅读
  • lineHeight(22) 提供舒适的行间距,1.47 倍行距是中文阅读的最佳比例(介于 1.4 到 1.6 之间)
  • textAlign(Start) 保持左对齐,与 ColumnStart 的整体风格一致

9.4 第4行:定位地址(Row + Emoji)

Row() {
  Text('📍')
    .fontSize(14)
    .margin({ right: 4 })
  Text(this.postItem.location)
    .fontSize(13)
    .fontColor('#8C8C8C')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.justifyContent(FlexAlign.Start)

为什么用 Emoji 而不是 Image 图标:

  • Emoji 是文本,不需要额外的资源加载
  • 在不同设备上 Emoji 风格一致(HarmonyOS 内置 Emoji 字体)
  • 代码更简洁,不需要维护图标资源

9.5 第5行:打卡时间(Row + Emoji)

结构与定位地址完全一致,只是文字不同:

Row() {
  Text('🕐').fontSize(14).margin({ right: 4 })
  Text(this.postItem.time).fontSize(13).fontColor('#8C8C8C')
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ bottom: 12 })

注意这里多了一个 .margin({ bottom: 12 }),在时间行和底部分割线之间留出间距。

9.6 第6行:分割线 + 操作按钮组

// ---- 分割线 ----
Divider()
  .width('100%')
  .height(1)
  .color('#E8E8E8')
  .margin({ bottom: 10 })

// ---- 操作按钮行 ----
Row() {
  this.actionButton({ /* 点赞 */ })
  this.actionButton({ /* 评论 */ })
  this.actionButton({ /* 收藏 */ })
}
.width('100%')
.justifyContent(FlexAlign.Start)

分割线的作用: Divider 组件在视觉上将内容区域和操作区域区分开,增强信息层级。

9.7 卡片容器整体样式

.width('100%')
.alignItems(HorizontalAlign.Start)    // ★ 核心:内部所有元素左对齐
.padding(14)
.backgroundColor(Color.White)
.borderRadius(16)                     // 大圆角
.shadow({
  radius: 8,
  offsetX: 0,
  offsetY: 2,
  color: 'rgba(0, 0, 0, 0.08)'       // 轻微阴影
})
.margin({ bottom: 14 })               // 卡片之间间距

阴影参数详解:

参数 效果
radius 8 阴影模糊半径,越大越模糊
offsetX 0 水平偏移,0 表示居中
offsetY 2 垂直偏移,2vp 向下
color rgba(0,0,0,0.08) 透明度 8% 的黑色,非常柔和

10. 交互事件 — 点赞 / 收藏状态管理

10.1 完整的事件流

用户点击 ❤️ 按钮
       ↓
TravelCard.actionButton.onClick()
       ↓
TravelCard 的 onLike 回调
       ↓
Index.onLike(index)
       ↓
1. 翻转 isLiked 状态
2. 更新 likes 计数
3. 重新赋值触发 @State 更新
4. 弹出 Toast 反馈
       ↓
TravelCard 收到新的 @Prop 值
       ↓
UI 自动刷新:
  ❤️ → 🤍 或 🤍 → ❤️
  数字 +1 或 -1
  颜色 #FF6B6B(激活)/ #8C8C8C(非激活)

10.2 为什么使用 showToast

promptAction.showToast 是鸿蒙系统级的轻提示组件,相比自定义 Toast 有以下优势:

  • 系统级渲染: 不受页面组件树影响,不会被覆盖
  • 自动消失: 不需要手动管理消失逻辑
  • 统一的 UI 风格: 与应用主题保持一致

⚠️ 注意:API 24 中 showToast 已被标记为 deprecated,建议迁移到 uiContext.showToast()。但当前版本仍然兼容,仅产生编译警告,不影响运行。

10.3 切换动画

ArkUI 为状态切换提供了隐式动画——当 @State 变化时,框架自动插值过渡:

// 不需要额外代码,ArkUI 会自动做以下事情:
// 1. isLiked: false → true 时,❤️ 出现,🤍 消失(自动过渡)
// 2. 颜色 #8C8C8C → #FF6B6B(自动渐变色过渡)
// 3. likes 数字变化时,位置自动调整(自动布局过渡)

这种"默认就美"的特性大大减少了开发者的动画编码量。


11. @Builder 复用 — actionButton 设计模式

11.1 什么是 @Builder

@Builder 是 ArkTS 中用于复用 UI 片段的装饰器。它类似于一个返回 UI 的函数,但比函数更强大——它可以直接在 build() 方法中调用,并且可以使用组件的状态变量。

11.2 actionButton 的实现

@Builder
actionButton(params: ActionButtonParams) {
  Row() {
    Text(params.icon)
      .fontSize(16)
      .margin({ right: 4 })
    Text(params.text)
      .fontSize(13)
      .fontColor(params.isActive ? '#FF6B6B' : '#8C8C8C')
  }
  .alignItems(VerticalAlign.Center)
  .margin({ right: 24 })
  .onClick((): void => {
    params.onClick();
  })
}

11.3 三种调用场景

场景 1:点赞按钮 — 根据点赞状态显示不同图标和颜色

this.actionButton({
  icon: this.postItem.isLiked ? '❤️' : '🤍',
  text: this.formatCount(this.postItem.likes),
  isActive: this.postItem.isLiked,
  onClick: this.onLike
})

场景 2:评论按钮 — 固定图标,点击触发 Toast

this.actionButton({
  icon: '💬',
  text: this.formatCount(this.postItem.comments),
  isActive: false,
  onClick: (): void => {
    promptAction.showToast({ message: '打开评论区...', duration: 1000 });
  }
})

场景 3:收藏按钮 — 根据收藏状态显示不同图标

this.actionButton({
  icon: this.postItem.isFavorited ? '⭐' : '☆',
  text: '收藏',
  isActive: this.postItem.isFavorited,
  onClick: this.onFavorite
})

11.4 @Builder vs @Component 的选择

什么时候用 @Builder,什么时候用 @Component

对比项 @Builder @Component
可复用范围 仅在当前组件内 跨文件复用
状态管理 不能有独立的 @State 可以有独立的 @State
生命周期 有完整生命周期
参数传递 通过函数参数 通过构造函数
适用场景 简单 UI 片段(按钮、标签) 复杂业务组件(卡片、表单)

经验法则: 如果一段 UI 需要独立的 @State 或生命周期钩子(aboutToAppear),就用 @Component;否则用 @Builder


12. List + ForEach 滚动列表性能优化

12.1 List 组件的选择

本页面使用 List + ListItem 组合来实现可滚动的游记列表,而不是 Scroll + Column。原因如下:

对比项 List + ListItem Scroll + Column
懒加载 ✅ 内置懒加载(超出屏幕的不渲染) ❌ 会渲染所有子组件
回收机制 ✅ 离屏 ListItem 自动回收 ❌ 不回收
滚动性能 ⭐⭐⭐ 高 ⭐ 低(数据量大时卡顿)
API 复杂度 中等 简单
适用数据量 任意 < 20 项

对于游记页面,虽然当前只有 3 条数据,但使用 List 为未来的扩展做好了准备——当用户添加更多游记时,不会出现性能问题。

12.2 ForEach 的 key 函数

ForEach(this.travelPosts, 
  (post: TravelPost, index?: number) => {
    ListItem() {
      TravelCard({...})
    }
  },
  (item: TravelPost, index?: number) => JSON.stringify(item) + index
)

第三个参数是key 生成函数,告诉 ArkUI 如何唯一标识每个列表项。这里使用 JSON.stringify(item) + index 的原因:

  • JSON.stringify(item) 序列化整个对象,保证数据变化时 key 变化,触发更新
  • 拼接 index 避免相同数据的 key 冲突

💡 最佳实践: 如果数据有唯一 ID(如 post.id),应该使用 ID 作为 key。这里手工拼接是因为 TravelPost 接口没有定义 ID 字段。

12.3 LayoutWeight 的使用

List() {
  // ...
}
.width('100%')
.layoutWeight(1)    // ★ 占满 Column 的剩余高度

layoutWeight(1) 让列表自动占据 Column 中除去 TitleBar 和 padding 外的所有剩余空间。这种"弹性填充"避免了手动计算高度。


13. 视觉系统 — 圆角、阴影、分割线、配色

13.1 圆角系统(BorderRadius)

页面中使用了三个层级的圆角:

层级 圆角值 应用位置
🔵 外层卡片 16vp TravelCard 的 borderRadius
🟢 封面图片 12vp Image 的 borderRadius
⚪ 用户头像 20vp(= 宽度一半) 圆形头像

设计原则: 外层圆角 > 内层圆角。卡片 16vp,图片 12vp,形成"俄罗斯套娃"式的视觉嵌套,层次分明。

13.2 阴影系统(Shadow)

.shadow({
  radius: 8,
  offsetX: 0,
  offsetY: 2,
  color: 'rgba(0, 0, 0, 0.08)'
})

阴影参数的含义:

  • radius: 8 — 阴影模糊半径。值越大,阴影边缘越柔和、扩散范围越大。
  • offsetX: 0, offsetY: 2 — 阴影向右下方向偏移 2vp,模拟"光源在上方"的自然照明效果。
  • color: rgba(0,0,0,0.08) — 透明度仅 8%,极其克制的阴影。儿童应用 / 内容型 App 适合使用轻柔阴影;工具型 / 金融 App 可适当加深到 15%~20%。

13.3 分割线(Divider)

Divider 组件在卡片内容区和操作区之间创建视觉分隔:

Divider()
  .width('100%')
  .height(1)
  .color('#E8E8E8')

高度仅 1vp、颜色为浅灰 #E8E8E8,确保分割线"存在但不抢眼"。

13.4 配色系统

页面中的颜色体系:

颜色值 用途 命名参考
#1A1A2E 主标题、昵称 深蓝黑
#2C2C2C 游玩文案正文 深灰
#8C8C8C 副标题、定位、时间、非激活按钮 中灰
#FF6B6B 激活状态的点赞按钮 珊瑚红
#F2F4F8 页面背景色 暖灰
Color.White 卡片背景色 纯白
#E8E8E8 分割线 浅灰

配色原则: 整体采用"黑白灰 + 单点高亮"的策略。页面大部分内容使用不同深浅的中性色,只有点赞激活态使用珊瑚红(#FF6B6B)作为强调色。这种设计让用户的注意力自然聚焦到互动元素上。


14. API 24 严格模式 — 语法规则对照

14.1 项目中的 API 24 合规点

本项目的代码已经全面适配 API 24 的严格编译规则。以下是具体的合规对照:

API 24 规则 要求 本项目的做法
arkts-no-spread 禁止对象展开 {...obj} 手动逐个属性赋值
arkts-no-untyped-obj-literals 对象字面量必须对应接口 定义了 ActionButtonParams 接口
arkts-no-obj-literals-as-types 字面量不能当类型声明 所有类型都显式声明
arkts-strict-typed 所有变量必须有明确类型 所有变量、参数、返回值都标注了类型
import from @kit.* 只能导入 Kit 化 API import { promptAction } from '@kit.ArkUI'
类方法声明 不使用 function 关键字 所有方法都使用简洁写法

14.2 常见迁移错误

错误 1:使用对象展开语法

// ❌ API 24 编译错误:arkts-no-spread
this.travelPosts[index] = { ...post, likes: post.likes + 1 };

// ✅ 正确做法
post.likes = post.likes + 1;
this.travelPosts[index] = post;

错误 2:@Builder 参数使用匿名类型

// ❌ API 24 编译错误:arkts-no-untyped-obj-literals
@Builder
myBuilder(params: { icon: string }) { ... }

// ✅ 正确做法:先定义接口
interface MyParams { icon: string; }
@Builder
myBuilder(params: MyParams) { ... }

错误 3:@Prop 缺少默认值

// ❌ 编译警告/错误
@Prop postItem: TravelPost;

// ✅ 正确做法
@Prop postItem: TravelPost = TravelCard.getDefaultPost();

错误 4:使用弃用的 API

// ❌ API 24 产生 deprecation 警告
import { promptAction } from '@kit.ArkUI';
promptAction.showToast({ message: 'Hello' });

// ✅ 推荐做法(API 24+)
// 使用 uiContext.showToast()

15. 全量代码与构建验证

15.1 完整文件代码

本文对应的完整代码位于 entry/src/main/ets/pages/Index.ets,共 372 行。为了便于读者理解,这里给出关键结构的总览:

代码区域 行数范围 内容
文件头注释 1-15 布局说明、知识点列表
import 语句 17-18 导入 promptAction
接口定义 20-55 TravelPost、ActionButtonParams
Index 组件 57-162 页面入口、@State、build、事件处理
TitleBar 组件 164-184 标题栏子组件
TravelCard 组件 186-372 卡片子组件、@Builder、辅助方法

15.2 编译构建验证

在项目根目录执行构建命令:

hvigorw assembleHap --mode module -p product=default --no-daemon

构建成功后输出:

BUILD SUCCESSFUL in 10s 366 ms

构建过程中产生的警告(共 6 条)分析:

警告内容 原因 是否影响运行
showToast has been deprecated API 24 中已弃用 ❌ 不影响,仍可运行
Function may throw exceptions showToast 可能抛异常 ⚠️ 建议加 try-catch
Property ‘onLike’ is private… 通过构造函数传参 ⚠️ 当前可运行

这些警告均不影响应用的正常运行。若需要消除警告,可以将 onLikeonFavorite 改为非 private,并使用 uiContext.showToast() 替代。

15.3 运行时效果

应用运行后,你将看到:

  1. 顶部标题栏显示 “🗺️ 旅行打卡游记” 主标题和副标题
  2. 下方依次排列 3 张白色卡片,每张卡片包含:
    • 头像 + 昵称
    • 风景封面图
    • 游玩文案(带 Emoji)
    • 定位地址(📍)
    • 打卡时间(🕐)
    • 分割线
    • 点赞 / 评论 / 收藏操作按钮
  3. 页面可以上下滑动浏览所有卡片
  4. 点击 ❤️ 按钮切换点赞状态,数字变化并弹出 Toast
  5. 点击 ⭐ 按钮切换收藏状态,弹出 Toast

16. 总结与扩展方向

16.1 本文要点回顾

本文围绕旅行景点打卡游记页面这个实际业务场景,系统讲解了鸿蒙 ArkTS + API 24 开发的完整流程:

  1. API 24 项目配置:详解了 build-profile.json5 中的 targetSdkVersioncompatibleSdkVersion 配置
  2. ColumnStart 布局哲学:三层 Column 嵌套,全部使用 alignItems(HorizontalAlign.Start) 实现顶部对齐
  3. 数据模型设计TravelPost 接口的 10 个字段,及 Resource 类型的选择理由
  4. 组件化架构Index → TitleBar + TravelCard 的组件树拆分
  5. 响应式状态管理@State + @Prop 的单向数据流模式
  6. @Builder 复用模式actionButton 三种调用场景
  7. 视觉系统:圆角 16/12/20vp、阴影 8/2/8%、配色分层
  8. API 24 严格模式arkts-no-spreadarkts-no-untyped-obj-literals 等规则的合规写法
  9. List + ForEach 性能:懒加载、key 函数、layoutWeight

16.2 项目文件清单

MyApplication4/
 ├── build-profile.json5                  # 项目构建配置 (API 24)
 ├── AppScope/app.json5                   # 应用信息
 ├── entry/
 │    ├── build-profile.json5             # 模块构建配置
 │    ├── src/main/
 │    │    ├── module.json5               # 模块注册
 │    │    ├── ets/
 │    │    │    ├── entryability/
 │    │    │    │    └── EntryAbility.ets  # 应用入口
 │    │    │    └── pages/
 │    │    │         └── Index.ets         # 游记页面 (核心)
 │    │    └── resources/
 │    │         └── base/
 │    │              ├── element/
 │    │              │    ├── string.json  # 字符串资源
 │    │              │    ├── color.json   # 颜色资源
 │    │              │    └── float.json   # 字号资源
 │    │              ├── media/            # 图片资源
 │    │              └── profile/
 │    │                   └── main_pages.json  # 页面路由
 │    └── oh-package.json5
 └── hvigor/hvigor-config.json5

16.3 扩展方向

本页面只是旅行打卡系统的一个页面。如果要将它扩展为完整的应用,可以考虑以下方向:

方向 1:添加发布功能

目前的数据是硬编码的。可以添加"发布游记"页面,包含图片选择器、文字输入、位置选择、时间选择等表单组件,最终将新游记插入到 travelPosts 数组中。

方向 2:对接数据服务

使用 @ohos.net.http@kit.NetworkKit 对接 REST API,实现游记的云端存储和加载。配合 @ohos.data.preferences 做本地缓存,实现离线浏览。

方向 3:评论系统

将"评论"按钮从 Toast 占位改为真正的评论列表弹窗。使用 @CustomDialog + TextInput + List 实现评论的展示和发布。

方向 4:地图集成

点击定位地址跳转到地图页面,使用 @kit.MapKit 展示景点的精确位置。这特别适合"查看附近景点"的功能。

方向 5:多页面路由

使用 router API 实现首页 → 详情页 → 发布页的导航。配合 @StorageLink 实现跨页面的登录状态共享。

方向 6:动画增强

  • 卡片入场动画:使用 transition + scale + opacity,让卡片依次淡入放大
  • 点赞动画:❤️ 图标点击时缩放弹跳
  • 下拉刷新:使用 RefreshComponent 支持下拉加载更多

16.4 学习路径建议

如果这篇博客让你对鸿蒙 ArkTS 开发产生了兴趣,建议按以下路径继续学习:

  1. 掌握 ArkTS 基础语法 — 重点是装饰器体系和声明式 UI 思维
  2. 深入布局系统 — Column、Row、Stack、Flex、Grid 五种容器
  3. 组件化开发 — @Component、@Builder、自定义组件的拆分与组合
  4. 状态管理 — @State、@Prop、@Link、@Provide/@Consume、AppStorage
  5. 动画系统 — @Animator、transition、Curve 曲线
  6. 媒体能力 — Image、Video、AudioKit 的使用
  7. 数据能力 — Preferences、KVStore、SQLite 持久化方案
  8. 网络能力 — HTTP 请求、WebSocket 长连接
  9. 设备能力 — 传感器、地理位置、震动反馈
  10. 发布上架 — 签名配置、HAP 打包、应用市场审核

本文由 AtomCode 撰写于 2026 年 6 月,基于 HarmonyOS NEXT API 24 (SDK 7.0.0) 示例应用。文中所有代码均经过编译验证,可在 DevEco Studio NEXT 中直接运行。

如果你觉得本文有帮助,欢迎点赞 ⭐ 收藏 📌 分享 🔁。我们下篇文章再见!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐