HarmonyOS 分层架构 — common 模块通用组件开发实战(@ComponentV2)
图标资源说明:HarmonyOS 6.1 中
sys.media.ohos_ic_public_*和sys.symbol.*系统图标资源已大量废弃或不可用。本项目实际采用 Unicode 文本字符 替代所有系统图标(如箭头›、爱心♥、关闭✕、空状态∅等),图片资源采用渐变色 + 首字文字占位符。下文代码示例中保留原始sys.media.*写法以便理解 API 用法,实际运行请以项目源码为准。
效果
一、通用组件总览
common 模块作为整个项目的基础层,承载了所有 feature 模块共享的 UI 组件。每一个组件都遵循 纯展示、无业务逻辑、单向数据流 的设计原则,使用 HarmonyOS 6.1 的 @ComponentV2 装饰器体系构建。
| 组件名 | 用途 | 关键装饰器 | 所在文件 |
|---|---|---|---|
SectionHeader | 区块标题栏(标题 + “查看更多”) | @Param + @Event | components/SectionHeader.ets |
ScenicCard | 景点竖向卡片(封面 + 评分 + 门票) | @Param + @Event | components/ScenicCard.ets |
FoodCard | 美食横向卡片(左图右文) | @Param + @Event | components/FoodCard.ets |
HotelCard | 住宿横向卡片(左图右文 + 价格标签) | @Param + @Event | components/HotelCard.ets |
LoadingView | 加载占位(LoadingProgress + 文字) | @Param | components/LoadingView.ets |
EmptyView | 空状态占位(图标 + 提示文字) | @Param | components/EmptyView.ets |
ImageView | 通用图片容器 | @Param × 5 | components/ImageView.ets |
约定:所有颜色、字号、圆角等样式值统一从
ThemeConstants静态常量读取,组件内部不硬编码任何魔法数字。
二、SectionHeader — 区块标题栏
2.1 完整代码
import { ThemeConstants } from '../constants/ThemeConstants';
@ComponentV2
export struct SectionHeader {
@Param title: string = '';
@Param moreText: string = '';
@Event onMoreClick: () => void = () => {};
build() {
Row() {
Text(this.title)
.fontSize(ThemeConstants.FONT_SIZE_SUBTITLE)
.fontWeight(ThemeConstants.FONT_WEIGHT_BOLD)
.fontColor(ThemeConstants.TEXT_PRIMARY)
Blank()
if (this.moreText.length > 0) {
Row() {
Text(this.moreText)
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.TEXT_HINT)
Image($r('sys.media.ohos_ic_public_arrow_right'))
.width(ThemeConstants.ICON_SIZE_SMALL)
.height(ThemeConstants.ICON_SIZE_SMALL)
.fillColor(ThemeConstants.TEXT_HINT)
}
.onClick(() => {
this.onMoreClick();
})
}
}
.width(ThemeConstants.FULL_WIDTH)
.padding({
left: ThemeConstants.CARD_PADDING,
right: ThemeConstants.CARD_PADDING,
top: ThemeConstants.SECTION_MARGIN,
bottom: 12
})
}
}
2.2 使用示例
// 在首页"热门景点"区块头部使用
SectionHeader({
title: '热门景点',
moreText: '查看更多',
onMoreClick: () => {
RouterService.push(RouteConstants.PARK_LIST_PAGE);
}
})
2.3 设计要点
@Param title:由父组件传入,组件内部只读渲染,不参与任何状态变更。@Event onMoreClick:事件回调,当moreText非空时才显示右侧可点击区域——通过条件渲染自动隐藏不需要的 UI 元素。Blank()弹性空间:利用 ArkUI 的Blank()组件将标题与右侧"更多"推到两端,无需手动计算 margin。
三、ScenicCard — 景点卡片
3.1 完整代码
import { ScenicSpot } from '../models/ScenicSpot';
import { ThemeConstants } from '../constants/ThemeConstants';
@ComponentV2
export struct ScenicCard {
@Param spot: ScenicSpot = new ScenicSpot();
@Event onCardClick: (spot: ScenicSpot) => void = () => {};
build() {
Column() {
Stack() {
Image(this.spot.coverImage)
.width(160)
.height(120)
.objectFit(ImageFit.Cover)
.borderRadius({
topLeft: ThemeConstants.CARD_RADIUS,
topRight: ThemeConstants.CARD_RADIUS
})
if (this.spot.isFavorite) {
Image($r('sys.media.ohos_ic_public_favour_filled'))
.width(20)
.height(20)
.fillColor(ThemeConstants.FAVORITE)
.position({ right: 8, top: 8 })
}
}
Column() {
Text(this.spot.name)
.fontSize(ThemeConstants.FONT_SIZE_BODY)
.fontWeight(ThemeConstants.FONT_WEIGHT_MEDIUM)
.fontColor(ThemeConstants.TEXT_PRIMARY)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width(ThemeConstants.FULL_WIDTH)
Row() {
Text(this.spot.rating.toFixed(1))
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.WARNING)
.fontWeight(ThemeConstants.FONT_WEIGHT_BOLD)
Text(' · ' + this.spot.category)
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.TEXT_HINT)
}
.margin({ top: 4 })
Text(this.spot.ticketPrice.length > 0 ? this.spot.ticketPrice : '免费')
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(this.spot.hasTicket ? ThemeConstants.ACCENT : ThemeConstants.SUCCESS)
.margin({ top: 4 })
}
.width(ThemeConstants.FULL_WIDTH)
.padding(10)
}
.width(160)
.backgroundColor(ThemeConstants.CARD_BG)
.borderRadius(ThemeConstants.CARD_RADIUS)
.shadow({
radius: 8,
color: '#1A000000',
offsetY: 2
})
.onClick(() => {
this.onCardClick(this.spot);
})
}
}
3.2 使用示例
// 在 Swiper 或 Grid 中遍历景点列表
@Builder
scenicItemBuilder(item: ScenicSpot) {
ScenicCard({
spot: item,
onCardClick: (spot: ScenicSpot) => {
RouterService.push(RouteConstants.PARK_DETAIL_PAGE, { spotId: spot.id });
}
})
}
3.3 设计要点
- Stack 叠层布局:封面图上方通过
position叠放收藏心形图标,当isFavorite为true时才渲染——条件渲染减少无效 DOM 节点。 - 价格颜色语义化:
hasTicket为true时用强调色ACCENT(橙色)突出付费信息,免费景点用SUCCESS(绿色)传递正向信号。 shadow投影:radius: 8+#1A000000(10% 透明度黑色)+offsetY: 2,营造轻微悬浮质感,与 Material Design 的 elevation 理念一致。
四、FoodCard — 美食卡片
4.1 完整代码
import { FoodItem } from '../models/FoodItem';
import { ThemeConstants } from '../constants/ThemeConstants';
@ComponentV2
export struct FoodCard {
@Param food: FoodItem = new FoodItem();
@Event onCardClick: (food: FoodItem) => void = () => {};
build() {
Row() {
Image(this.food.coverImage)
.width(100)
.height(100)
.objectFit(ImageFit.Cover)
.borderRadius({
topLeft: ThemeConstants.CARD_RADIUS,
bottomLeft: ThemeConstants.CARD_RADIUS
})
Column() {
Text(this.food.name)
.fontSize(ThemeConstants.FONT_SIZE_BODY)
.fontWeight(ThemeConstants.FONT_WEIGHT_MEDIUM)
.fontColor(ThemeConstants.TEXT_PRIMARY)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.food.description)
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.TEXT_SECONDARY)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
Row() {
Text(this.food.restaurant)
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.PRIMARY)
Blank()
Text(this.food.priceRange)
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.ACCENT)
}
.width(ThemeConstants.FULL_WIDTH)
.margin({ top: 8 })
}
.layoutWeight(1)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
}
.width(ThemeConstants.FULL_WIDTH)
.backgroundColor(ThemeConstants.CARD_BG)
.borderRadius(ThemeConstants.CARD_RADIUS)
.shadow({
radius: 6,
color: '#14000000',
offsetY: 2
})
.onClick(() => {
this.onCardClick(this.food);
})
}
}
4.2 使用示例
// 在 List 组件中使用
List() {
ForEach(this.foodList, (item: FoodItem) => {
ListItem() {
FoodCard({
food: item,
onCardClick: (food: FoodItem) => {
// 跳转美食详情
}
})
}
.padding({ left: 16, right: 16, top: 8 })
})
}
4.3 设计要点
- 横向布局(Row):左侧固定宽度封面图(100×100),右侧
layoutWeight(1)自适应撑满,适配不同屏幕宽度。 - 双行截断:描述文字
maxLines(2)+textOverflow({ overflow: TextOverflow.Ellipsis }),超出两行优雅省略。 - 餐厅名用品牌色
PRIMARY:引导用户关注商家品牌信息;价格范围用ACCENT橙色,符合消费场景的视觉习惯。
五、HotelCard — 住宿卡片
5.1 完整代码
import { HotelItem } from '../models/HotelItem';
import { ThemeConstants } from '../constants/ThemeConstants';
@ComponentV2
export struct HotelCard {
@Param hotel: HotelItem = new HotelItem();
@Event onCardClick: (hotel: HotelItem) => void = () => {};
build() {
Row() {
Image(this.hotel.coverImage)
.width(120)
.height(100)
.objectFit(ImageFit.Cover)
.borderRadius({
topLeft: ThemeConstants.CARD_RADIUS,
bottomLeft: ThemeConstants.CARD_RADIUS
})
Column() {
Row() {
Text(this.hotel.name)
.fontSize(ThemeConstants.FONT_SIZE_BODY)
.fontWeight(ThemeConstants.FONT_WEIGHT_MEDIUM)
.fontColor(ThemeConstants.TEXT_PRIMARY)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.hotel.priceLevel)
.fontSize(ThemeConstants.FONT_SIZE_SMALL)
.fontColor(ThemeConstants.TEXT_WHITE)
.backgroundColor(ThemeConstants.PRIMARY)
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
.width(ThemeConstants.FULL_WIDTH)
Text(this.hotel.address)
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.TEXT_SECONDARY)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
Row() {
Text(this.hotel.rating.toFixed(1) + '分')
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.WARNING)
Blank()
Text('¥' + this.hotel.pricePerNight + '/晚')
.fontSize(ThemeConstants.FONT_SIZE_BODY)
.fontColor(ThemeConstants.ACCENT)
.fontWeight(ThemeConstants.FONT_WEIGHT_BOLD)
}
.width(ThemeConstants.FULL_WIDTH)
.margin({ top: 6 })
}
.layoutWeight(1)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
}
.width(ThemeConstants.FULL_WIDTH)
.backgroundColor(ThemeConstants.CARD_BG)
.borderRadius(ThemeConstants.CARD_RADIUS)
.shadow({ radius: 6, color: '#14000000', offsetY: 2 })
.onClick(() => {
this.onCardClick(this.hotel);
})
}
}
5.2 使用示例
// 住宿列表页
List() {
ForEach(this.hotelList, (item: HotelItem) => {
ListItem() {
HotelCard({
hotel: item,
onCardClick: (hotel: HotelItem) => {
// 跳转酒店详情
}
})
}
})
}
5.3 设计要点
- 价格等级标签
priceLevel:用品牌主色PRIMARY做背景、白色文字、小圆角,形成类似 “高档” / “豪华” 的角标效果,视觉上与酒店 App 的行业惯例一致。 - 底部价格行:评分用
WARNING黄色,房价用ACCENT橙色加粗——用户扫视卡片时最先捕获的两个关键信息。 Blank()两端对齐:评分与价格分居左右,无需 Flex 的justify-content: space-between,代码更简洁。
六、LoadingView — 加载占位
6.1 完整代码
import { ThemeConstants } from '../constants/ThemeConstants';
@ComponentV2
export struct LoadingView {
@Param text: string = '加载中...';
build() {
Column() {
LoadingProgress()
.width(48)
.height(48)
.color(ThemeConstants.PRIMARY)
Text(this.text)
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.TEXT_HINT)
.margin({ top: 12 })
}
.width(ThemeConstants.FULL_WIDTH)
.justifyContent(FlexAlign.Center)
.padding(40)
}
}
6.2 使用示例
// 配合 if/else 条件渲染
if (this.isLoading) {
LoadingView({ text: '正在获取景点数据...' })
} else {
// 正式内容
}
6.3 设计要点
- 系统
LoadingProgress组件:HarmonyOS 内置的加载动画,自带旋转效果,无需引入第三方 Lottie。 - 文字可定制:默认 “加载中…”,父组件可传入 “正在获取景点数据…” / “正在搜索附近酒店…” 等具体提示。
- 最小化设计:整个组件仅接受一个
@Param,无事件回调——加载状态本身就是终态,不需要用户交互。
七、EmptyView — 空状态占位
7.1 完整代码
import { ThemeConstants } from '../constants/ThemeConstants';
@ComponentV2
export struct EmptyView {
@Param message: string = '暂无数据';
@Param icon: Resource | null = null;
build() {
Column() {
if (this.icon) {
Image(this.icon)
.width(80)
.height(80)
.fillColor(ThemeConstants.TEXT_HINT)
} else {
Image($r('sys.media.ohos_ic_public_folder'))
.width(80)
.height(80)
.fillColor(ThemeConstants.TEXT_HINT)
}
Text(this.message)
.fontSize(ThemeConstants.FONT_SIZE_BODY)
.fontColor(ThemeConstants.TEXT_HINT)
.margin({ top: 16 })
}
.width(ThemeConstants.FULL_WIDTH)
.justifyContent(FlexAlign.Center)
.padding(60)
}
}
7.2 使用示例
// 收藏列表为空时
if (this.favorites.length === 0) {
EmptyView({
message: '还没有收藏景点哦',
icon: $r('sys.media.ohos_ic_public_favour')
})
} else {
// 收藏列表
}
7.3 设计要点
- 图标可空设计:
@Param icon: Resource | null = null,当不传图标时使用系统默认文件夹图标,保证组件始终有视觉锚点。 fillColor统一灰色:无论传入什么图标,都填充为TEXT_HINT灰色,保持空状态的"低调"视觉层级,不与正式内容抢夺注意力。- 大 padding 居中:
padding(60)确保空状态在页面中有足够的留白,避免紧贴边缘显得拥挤。
八、ImageView — 通用图片容器
8.1 完整代码
import { ThemeConstants } from '../constants/ThemeConstants';
@ComponentV2
export struct ImageView {
@Param src: string | Resource = '';
@Param width: number = 100;
@Param height: number = 100;
@Param radius: number = 0;
@Param fit: ImageFit = ImageFit.Cover;
build() {
Image(this.src)
.width(this.width)
.height(this.height)
.objectFit(this.fit)
.borderRadius(this.radius)
.backgroundColor(ThemeConstants.DIVIDER)
}
}
8.2 使用示例
// 头像
ImageView({
src: this.userInfo.avatar,
width: 64,
height: 64,
radius: 32
})
// Banner 图片
ImageView({
src: banner.imageUrl,
width: 360,
height: 180,
radius: 12,
fit: ImageFit.Cover
})
8.3 设计要点
- 多参数
@Param:5 个@Param覆盖了图片渲染的核心属性——来源、尺寸、圆角、适配模式。 src: string | Resource联合类型:同时支持网络 URL 和本地资源$r(),一处调用适配两种场景。backgroundColor: DIVIDER:图片加载前显示浅灰色背景,避免白色闪烁(FOUC),提升加载体验。
九、组件注册导出
所有通用组件在 common/Index.ets 中统一导出,feature 模块只需 import { XxxCard } from 'common' 即可使用:
// common/Index.ets(组件部分)
// Components
export { SectionHeader } from './src/main/ets/components/SectionHeader';
export { ScenicCard } from './src/main/ets/components/ScenicCard';
export { FoodCard } from './src/main/ets/components/FoodCard';
export { HotelCard } from './src/main/ets/components/HotelCard';
export { LoadingView } from './src/main/ets/components/LoadingView';
export { EmptyView } from './src/main/ets/components/EmptyView';
export { ImageView } from './src/main/ets/components/ImageView';
导出规范:
- 每个组件使用
export struct声明,确保可以被外部模块导入。 Index.ets是 common 模块的唯一入口文件,所有导出在此集中管理。- 组件导出时不附加任何路径前缀——消费者只需
from 'common'。
十、设计原则总结
10.1 @Param 单向数据流
@ComponentV2 中的 @Param 装饰器表示只读参数:父组件传入值,子组件仅用于渲染,无法反向修改。这与 @Prop(V1)类似,但在 V2 体系中语义更明确——@Param 就是"参数",不是"属性"。
@Param title: string = ''; // 父 → 子单向传递
@Param spot: ScenicSpot = new ScenicSpot(); // 对象类型同样适用
10.2 @Event 事件回调
当子组件需要向父组件传递信息时(如卡片被点击),使用 @Event 装饰器声明回调函数:
@Event onCardClick: (spot: ScenicSpot) => void = () => {};
- 默认值为空函数
() => {},父组件不传也不会报错。 - 回调参数携带业务数据(如
ScenicSpot对象),父组件拿到后执行路由跳转等业务逻辑。 - 组件本身不包含任何导航、网络请求等业务代码——这是 common 组件与 feature 组件的核心分界线。
10.3 无业务逻辑原则
通用组件的 build() 方法中不允许出现:
RouterService.push()等路由调用HttpService.get()等网络请求RdbService等本地存储操作
所有业务行为通过 @Event 回调上抛给 feature 模块处理。这使得通用组件可以跨 feature 复用,且便于单元测试。
10.4 类型安全
- 所有
@Param都有明确的类型声明和默认值(string = ''、ScenicSpot = new ScenicSpot())。 @Event的回调签名严格约束参数类型和返回值。ImageView的src使用联合类型string | Resource,编译期即可捕获非法传值。
这套类型系统确保了在大型多人协作项目中,组件接口的变更能在编译阶段被及时发现,而非等到运行时崩溃。

1080

被折叠的 条评论
为什么被折叠?



