旅行景点打卡游记-鸿蒙 Next ArkTS 实战:基于HarmonyOS API 24的旅行景点打卡游记页面
项目演示

目录
- 项目背景与业务场景
- 项目搭建与 API 24 适配详解
- ArkTS 声明式 UI 核心概念
- ColumnStart 纵向布局设计哲学
- 数据模型 — TravelPost 接口设计
- 页面入口 — @Entry Index 组件
- 标题栏组件 — TitleBar 拆解
- 核心卡片组件 — TravelCard 深度剖析
- 六段式卡片布局逐行解析
- 交互事件 — 点赞 / 收藏状态管理
- @Builder 复用 — actionButton 设计模式
- List + ForEach 滚动列表性能优化
- 视觉系统 — 圆角、阴影、分割线、配色
- API 24 严格模式 — 语法规则对照
- 全量代码与构建验证
- 总结与扩展方向
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 布局
本页面所有组件采用 顶部对齐(左对齐) 的纵向排列方式。这种设计选择基于以下考量:
- 信息流阅读习惯:用户从上到下浏览,左对齐减少视线跳转
- 一致的视觉起点:所有内容从同一垂直轴线开始,形成规整感
- 移动端适配友好:在窄屏设备上,左对齐比居中布局利用空间更高效
- 组件化复用:统一的对齐方式使得子组件的组合和替换更加可预测
在 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 的核心在于 targetSdkVersion 和 compatibleSdkVersion 两项配置:
{
"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
ColumnStart 是 Column 容器 + 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 来实现这个游记卡片列表?原因有两点:
- 一列纵向流是 Column 的天然场景。 Grid 是为多列网格设计的,用在单列场景下会有不必要的性能开销。
- 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 类型的选择
注意 avatar 和 coverImage 的类型是 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 装饰,意味着:
- 当数组中的元素被修改并重新赋值时,UI 自动更新
- 在
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() 里就好了。但拆分成独立组件有两个好处:
- 可复用性: 如果后续页面需要在多个 tab 中使用不同的标题栏,可以直接复用
TitleBar组件,传入不同的参数。 - 可维护性: 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… | 通过构造函数传参 | ⚠️ 当前可运行 |
这些警告均不影响应用的正常运行。若需要消除警告,可以将 onLike 和 onFavorite 改为非 private,并使用 uiContext.showToast() 替代。
15.3 运行时效果
应用运行后,你将看到:
- 顶部标题栏显示 “🗺️ 旅行打卡游记” 主标题和副标题
- 下方依次排列 3 张白色卡片,每张卡片包含:
- 头像 + 昵称
- 风景封面图
- 游玩文案(带 Emoji)
- 定位地址(📍)
- 打卡时间(🕐)
- 分割线
- 点赞 / 评论 / 收藏操作按钮
- 页面可以上下滑动浏览所有卡片
- 点击 ❤️ 按钮切换点赞状态,数字变化并弹出 Toast
- 点击 ⭐ 按钮切换收藏状态,弹出 Toast
16. 总结与扩展方向
16.1 本文要点回顾
本文围绕旅行景点打卡游记页面这个实际业务场景,系统讲解了鸿蒙 ArkTS + API 24 开发的完整流程:
- API 24 项目配置:详解了
build-profile.json5中的targetSdkVersion和compatibleSdkVersion配置 - ColumnStart 布局哲学:三层 Column 嵌套,全部使用
alignItems(HorizontalAlign.Start)实现顶部对齐 - 数据模型设计:
TravelPost接口的 10 个字段,及Resource类型的选择理由 - 组件化架构:
Index → TitleBar + TravelCard的组件树拆分 - 响应式状态管理:
@State+@Prop的单向数据流模式 - @Builder 复用模式:
actionButton三种调用场景 - 视觉系统:圆角 16/12/20vp、阴影 8/2/8%、配色分层
- API 24 严格模式:
arkts-no-spread、arkts-no-untyped-obj-literals等规则的合规写法 - 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 开发产生了兴趣,建议按以下路径继续学习:
- 掌握 ArkTS 基础语法 — 重点是装饰器体系和声明式 UI 思维
- 深入布局系统 — Column、Row、Stack、Flex、Grid 五种容器
- 组件化开发 — @Component、@Builder、自定义组件的拆分与组合
- 状态管理 — @State、@Prop、@Link、@Provide/@Consume、AppStorage
- 动画系统 — @Animator、transition、Curve 曲线
- 媒体能力 — Image、Video、AudioKit 的使用
- 数据能力 — Preferences、KVStore、SQLite 持久化方案
- 网络能力 — HTTP 请求、WebSocket 长连接
- 设备能力 — 传感器、地理位置、震动反馈
- 发布上架 — 签名配置、HAP 打包、应用市场审核
本文由 AtomCode 撰写于 2026 年 6 月,基于 HarmonyOS NEXT API 24 (SDK 7.0.0) 示例应用。文中所有代码均经过编译验证,可在 DevEco Studio NEXT 中直接运行。
如果你觉得本文有帮助,欢迎点赞 ⭐ 收藏 📌 分享 🔁。我们下篇文章再见!
更多推荐




所有评论(0)