一、鸿蒙开发背景与 ArkTS 语言概览

鸿蒙操作系统(HarmonyOS)作为华为自主研发的全场景分布式操作系统,从诞生之初就致力于打通手机、平板、智慧屏、车载设备、可穿戴设备等多种终端形态,实现"一次开发、多端部署"的愿景。在鸿蒙的应用开发体系中,ArkTS 语言扮演着至关重要的角色。ArkTS 是在 TypeScript 基础上扩展而来的编程语言,它保留了 TypeScript 的静态类型检查能力,同时针对声明式 UI 编程范式进行了深度优化和语法扩展。与传统的命令式 UI 开发方式不同,ArkTS 鼓励开发者以"描述界面应该是什么样子"而非"逐步操作界面"的方式来构建用户界面,这使得 UI 代码更加简洁、可读性更强,也更易于维护和扩展。

ArkTS 的类型系统非常严格,这一点在实际开发中体现得淋漓尽致。所有变量必须有明确的类型标注,函数的参数和返回值也需要精确的类型声明,接口(interface)的定义更是要求字段类型完整且不可缺省。这种强类型约束虽然在初期会增加一些编码量,但从长远来看,它能在编译阶段就捕获大量潜在的类型错误,显著降低运行时崩溃的风险。在本文所解析的代码中,我们可以看到大量 interface 定义和类型标注的实践,这正是 ArkTS 强类型哲学的典型体现。

声明式 UI 范式的核心思想是:开发者通过声明式的语法描述界面的结构和状态,框架负责在状态变化时自动重新渲染受影响的部分。ArkUI 是鸿蒙的声明式 UI 开发框架,它提供了一套丰富的内置组件(如 Text、Image、Button、Column、Row、Stack、Scroll、List 等)和装饰器(如 @Component、@Entry、@State、@Builder、@Prop 等),开发者通过组合这些组件和装饰器来构建复杂的页面结构。ArkUI 框架内部实现了高效的虚拟 DOM diff 算法和精确的状态依赖追踪机制,当 @State 修饰的变量发生变化时,框架会自动找到所有依赖该状态的 UI 片段并进行最小化更新,无需开发者手动调用刷新方法。

二、ArkUI 组件体系与布局系统

ArkUI 的组件体系可以分为三大类:基础组件、容器组件和画布组件。基础组件如 Text(文本)、Image(图片)、TextInput(输入框)、Button(按钮)、Progress(进度条)等,负责展示内容和接收用户交互;容器组件如 Column(纵向线性布局)、Row(横向线性布局)、Stack(层叠布局)、Flex(弹性布局)、Scroll(滚动容器)、List(列表容器)等,负责组织子组件的排列方式;画布组件如 Canvas 则用于自由绘制图形。每种容器组件都有其特定的布局规则,例如 Column 将子组件按从上到下的顺序纵向排列,Row 将子组件从左到右横向排列,Stack 则允许多个子组件叠放在同一位置并通过 zIndex 或声明顺序控制层级关系。

技术概念强调: Column 和 Row 是 ArkUI 中最基础的两个线性布局容器。Column 以垂直方向排列子组件,默认子组件在水平方向居中对齐;Row 以水平方向排列子组件,默认子组件在垂直方向居中对齐。它们都可以通过 space 参数设置子组件之间的间距,通过 justifyContentalignItems 属性控制主轴和交叉轴的对齐方式。

ArkUI 的布局系统深受 Flexbox 布局模型的影响。layoutWeight 属性类似于 CSS Flexbox 中的 flex-grow,它允许子组件按照权重比例分配父容器的剩余空间,这在实现自适应布局时极为关键。FlexAlign 枚举提供了 StartCenterEndSpaceBetweenSpaceAroundSpaceEvenly 等对齐模式,分别对应不同的空间分配策略。justifyContent 控制主轴方向的对齐,alignItems 控制交叉轴方向的对齐。此外,ArkUI 还支持通过 .linearGradient() 方法设置线性渐变背景,通过 .borderRadius() 设置圆角,通过 .border() 设置边框,通过 .padding().margin() 分别设置内边距和外边距,通过 .opacity() 设置透明度,通过 .scale() 设置缩放变换,通过 .animation() 设置属性动画,这些丰富的样式属性让开发者能够构建出视觉表现力极强的界面。

技术概念强调: layoutWeight 是 ArkUI 布局系统中用于实现弹性空间分配的核心属性。当一个容器中有多个子组件时,设置了 layoutWeight 的子组件会按照权重值瓜分父容器中扣除其他固定尺寸子组件后剩余的空间。在本文的代码中,大量使用了 Text('').layoutWeight(1) 这种技巧——一个空文本占位器配合 layoutWeight(1) 来"推开"两侧的元素,实现两端对齐的效果,这是 ArkUI 中极为常见的布局模式。

三、状态管理与装饰器体系

ArkUI 的状态管理是其声明式 UI 的核心引擎。@State 装饰器用于声明组件内部的状态变量,当这些变量的值发生变化时,框架会自动触发依赖这些变量的 UI 片段重新渲染。@Prop 装饰器用于在父组件向子组件单向传递数据时,子组件接收并拷贝一份数据作为本地副本,子组件对该副本的修改不会影响父组件的原始数据。@Link 装饰器则建立父子组件之间的双向数据绑定。@Builder 装饰器用于定义可复用的 UI 构建片段,类似于其他框架中的"渲染函数"或"模板片段",它可以将一段复杂的 UI 结构封装为一个方法,在多处调用以减少代码重复。@Entry 装饰器标记一个组件为页面入口组件,一个页面有且仅能有一个 @Entry 组件。@Component 装饰器声明一个结构体为自定义组件,自定义组件可以像内置组件一样在 build 方法中被使用。

技术概念强调: @State 是 ArkUI 中最基础的状态装饰器。它修饰的变量被框架追踪,当赋值操作发生时,框架会自动找到所有读取了该变量的 UI 节点并执行精确的局部刷新。需要注意的是,对于数组类型的 @State 变量,直接调用 pushspliceunshift 等数组方法也能触发 UI 更新,因为 ArkUI 框架对数组方法进行了代理拦截。

四、完整代码深度解析

4.1 数据模型层:接口定义

在 ArkTS 中,interface 是定义数据结构的标准方式。与 TypeScript 不同的是,ArkTS 的 interface 要求所有字段都必须显式声明类型,且不能使用可选属性(?),每个字段在创建对象时必须被完整赋值。下面是代码中定义的六个核心数据接口。

interface FeedItem {
  id: number
  name: string
  avatar: string
  time: string
  content: string
  likes: number
  comments: number
}

interface MakeItem {
  id: number
  name: string
  kind: string
  mins: number
  heat: number
  level: number
  state: number
}

interface LiveItem {
  id: number
  name: string
  title: string
  fans: number
  watch: number
  state: number
}

interface CourseItem {
  id: number
  name: string
  teacher: string
  lessons: number
  quota: number
  state: number
}

interface StarItem {
  id: number
  name: string
  field: string
  honor: number
  quota: number
  state: number
}

interface MyItem {
  id: number
  name: string
  tag: string
  time: string
}

在这里插入图片描述

这六个接口分别对应应用中的六类核心数据实体。FeedItem 描述了美妆社区动态信息,包含用户昵称、头像(用 Emoji 表示)、发布时间、正文内容、点赞数和评论数;MakeItem 描述了美妆教程条目,包含教程名称、分类、时长、热度值、难度等级和上下架状态;LiveItem 描述了直播信息,包含主播名称、直播标题、粉丝数、观看量和直播状态;CourseItem 描述了课程信息,包含课程名称、授课教师、课时数、剩余席位和报名状态;StarItem 描述了达人信息,包含达人名称、擅长领域、荣誉值、剩余名额和签约状态;MyItem 则描述了用户的收藏记录,包含收藏名称、分类标签和收藏时间。

技术概念强调: 在 ArkTS 中使用 interface 定义数据模型是最佳实践。每个字段都有明确的类型标注,这在编译阶段就能防止类型错误。例如,state: number 字段用数值 0 和 1 来分别表示两种状态(如"热推中"和"已下架"),这是一种轻量级的状态编码方式,在后续逻辑中通过函数将数值映射为可读文本和颜色值。

这些接口的设计体现了良好的领域建模思想。每个接口都聚焦于一个明确的业务实体,字段命名清晰、语义明确。state 字段在多个接口中重复出现,它使用数值 0 和 1 来编码"正常/异常"或"可用/不可用"两种状态,这种设计虽然简单但在小型应用中非常实用。对于更复杂的状态管理场景,ArkTS 也支持使用枚举(enum)来替代硬编码的数值,以获得更好的可读性和类型安全性。

4.2 静态数据集与常量定义

在接口定义之后,代码声明了大量的静态数据数组和常量,这些数据为应用提供了初始内容。

const FEEDS: FeedItem[] = [
  { id: 1, name: '甜心化妆师', avatar: '💄', time: '12分钟前', content: '粉底液测评第 21 期:油皮亲妈款 vs 干皮救星款,实测 8 小时带妆不脱不卡,底妆选对肤质真的很重要!', likes: 587, comments: 92 },
  { id: 2, name: '眼妆小妖', avatar: '👁️', time: '35分钟前', content: '肿眼泡必学的倒钩眼线教程来了,眼尾一勾立刻深邃,教程拆解了三遍,新手也能画得干净利落。', likes: 463, comments: 71 },
  // ... 更多数据
]

const MAKES: MakeItem[] = [
  { id: 1, name: '奶油肌底妆全攻略', kind: '底妆', mins: 8, heat: 98, level: 1, state: 0 },
  { id: 2, name: '倒钩眼线画法', kind: '眼妆', mins: 6, heat: 95, level: 2, state: 0 },
  // ... 更多数据
]

const LIVES: LiveItem[] = [
  { id: 1, name: '甜心化妆师', title: '实测 8 小时不脱妆粉底', fans: 3200, watch: 820000, state: 0 },
  { id: 2, name: '眼妆小妖', title: '倒钩眼线细节教学', fans: 2100, watch: 660000, state: 0 },
  // ... 更多数据
]

const COURSES: CourseItem[] = [
  { id: 1, name: '新手底妆必修课', teacher: '甜心老师', lessons: 8, quota: 20, state: 0 },
  { id: 2, name: '眼线眼影进阶班', teacher: '小妖老师', lessons: 10, quota: 15, state: 0 },
  // ... 更多数据
]

const STARS: StarItem[] = [
  { id: 1, name: '甜心化妆师', field: '底妆测评', honor: 98, quota: 6, state: 0 },
  { id: 2, name: '眼妆小妖', field: '眼妆教学', honor: 96, quota: 8, state: 0 },
  // ... 更多数据
]

const MYS: MyItem[] = [
  { id: 1, name: '奶油肌底妆全攻略', tag: '底妆', time: '收藏于 08-25' },
  { id: 2, name: '倒钩眼线画法', tag: '眼妆', time: '收藏于 08-23' },
  // ... 更多数据
]

const HEAT: number[] = [6, 9, 8, 7, 10, 9, 11, 8]

const CATS: string[] = ['全部', '底妆', '眼妆', '唇妆', '护肤', '发妆']

在这里插入图片描述

这里使用了 const 关键字声明了六个数据数组和两个辅助常量。在 ArkTS 中,const 声明的变量引用不可变(不能重新赋值为另一个数组),但数组内部的内容是可以通过方法修改的。FEEDSMAKESLIVESCOURSESSTARSMYS 这六个数组分别存储了社区动态、教程、直播、课程、达人和收藏的初始数据,每条数据都是一个符合对应 interface 约束的对象字面量。HEAT 数组存储了本周每日的热度值,用于在首页渲染柱状图。CATS 数组存储了分类标签的文本列表,用于在分类导航栏中渲染可切换的标签按钮。

技术概念强调: 在 ArkTS 中,const 声明的数组虽然引用不可变,但通过 unshiftsplicepush 等方法修改数组内容是允许的。这些数组在组件中通过 @State 修饰后,对这些方法的调用会被框架代理拦截,从而触发 UI 的局部刷新。这是 ArkUI 响应式系统的核心机制之一。

数据集的设计体现了内容驱动的应用特征。每条数据都有丰富的字段值,确保了页面初始渲染时就有充实的内容展示。state 字段在 MAKESLIVESCOURSESSTARS 数组中均有使用,其中大部分值为 0(表示"正常/可用"状态),少量为 1(表示"已结束/已满额"状态),这种设计让 UI 可以同时展示不同状态的条目,增强页面的真实感和场景覆盖度。

4.3 工具函数体系:状态映射与数据构造

代码中定义了一系列工具函数,它们负责将数值状态编码映射为可读文本和颜色值,以及构造数据对象。

function heatBar(v: number): string {
  return (12 + v * 4) + 'vp'
}

function playsText(p: number): string {
  if (p >= 10000) {
    return (p / 10000).toFixed(1) + '万'
  }
  return p.toString()
}

function makeStateText(s: number): string {
  if (s === 0) {
    return '热推中'
  }
  return '已下架'
}

function makeStateColor(s: number): string {
  if (s === 0) {
    return '#EC4899'
  }
  return '#C9A0B4'
}

function liveStateText(s: number): string {
  if (s === 0) {
    return '直播中'
  }
  return '已结束'
}

function liveStateColor(s: number): string {
  if (s === 0) {
    return '#EC4899'
  }
  return '#C9A0B4'
}

heatBar 函数接收一个热度数值,返回一个以 vp(virtual pixel,虚拟像素)为单位的字符串,用于动态计算柱状图中柱子的高度。它通过 12 + v * 4 的线性映射将热度值转换为高度值,确保柱子高度在合理范围内变化。playsText 函数接收一个播放量数值,当数值大于等于一万时格式化为"X.X万"的字符串,否则直接返回数字的字符串形式,这是中文应用中常见的数字格式化需求。

技术概念强调: vp(virtual pixel)是 ArkUI 中的长度单位之一,它会根据屏幕密度自动缩放,确保在不同分辨率的设备上呈现出一致的物理尺寸。与之相对的还有 px(物理像素)、fp(字体像素,受系统字体大小设置影响)和 lpx(逻辑像素)。在 ArkTS 代码中,当数值字符串以 'vp' 结尾时,ArkUI 会将其解析为 vp 单位。

makeStateTextmakeStateColor 这组函数体现了"状态映射器"的设计模式。makeStateText 将教程状态数值 0 映射为"热推中"、1 映射为"已下架";makeStateColor 将状态数值 0 映射为玫粉色 #EC4899、1 映射为灰粉色 #C9A0B4。类似地,liveStateText/liveStateColor 处理直播状态,courseStateText/courseStateColor 处理课程状态,starStateText/starStateColor 处理达人签约状态。trendText/trendColor 则处理趋势数据的文本格式化和颜色映射。这种将状态映射逻辑封装为独立函数的做法,使得 UI 代码中的三元表达式更加简洁,也便于在多处复用相同的映射逻辑。

function trendText(t: number): string {
  if (t > 0) {
    return '+' + t + '%'
  }
  return t + '%'
}

function trendColor(t: number): string {
  if (t >= 0) {
    return '#EC4899'
  }
  return '#C084FC'
}

function buildFeed(id: number, name: string, avatar: string, time: string, content: string, likes: number, comments: number): FeedItem {
  return { id: id, name: name, avatar: avatar, time: time, content: content, likes: likes, comments: comments }
}

function buildMy(id: number, name: string, tag: string, time: string): MyItem {
  return { id: id, name: name, tag: tag, time: time }
}

function buildMake(id: number, name: string, kind: string, mins: number, heat: number, level: number, state: number): MakeItem {
  return { id: id, name: name, kind: kind, mins: mins, heat: heat, level: level, state: state }
}

在这里插入图片描述

buildFeedbuildMybuildMakebuildLivebuildCoursebuildStar 这一系列工厂函数是"构造器模式"的体现。它们接收分散的参数,返回一个完整的数据对象。虽然 ArkTS 支持直接使用对象字面量创建数据,但使用工厂函数可以确保所有必填字段都被传入(因为函数签名要求所有参数),避免遗漏字段导致编译错误。此外,当接口字段发生变化时,只需修改工厂函数即可在编译阶段发现所有需要更新的调用点。

技术概念强调: 工厂函数模式在 ArkTS 中尤其实用。由于 ArkTS 的 interface 要求所有字段都必须赋值,直接使用对象字面量时容易遗漏字段,而工厂函数的参数列表会在编译阶段强制检查。这种方式将数据构造逻辑集中化管理,提高了代码的可维护性和类型安全性。

function buildCourseSign(c: CourseItem): CourseItem {
  let q = c.quota - 1
  let st = c.state
  if (q <= 0) {
    q = 0
    st = 1
  }
  return buildCourse(c.id, c.name, c.teacher, c.lessons, q, st)
}

在这里插入图片描述

buildCourseSign 是一个具有业务逻辑的工厂函数。它接收一个 CourseItem 对象,将其 quota(剩余席位)减 1,如果席位归零则将状态改为 1(已报满),然后返回一个新的 CourseItem 对象。这里使用的是不可变数据更新模式——不直接修改原对象,而是构造一个新的对象返回。在 ArkUI 的状态管理中,这种模式非常重要,因为框架需要检测到对象的引用变化才能触发 UI 更新。如果直接修改原对象的属性而不替换引用,框架可能无法正确感知变化。

4.4 主入口组件:Index 结构体

以下是整个应用的入口组件定义,它包含了所有的状态变量声明。

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State feeds: FeedItem[] = FEEDS
  @State makes: MakeItem[] = MAKES
  @State lives: LiveItem[] = LIVES
  @State stars: StarItem[] = STARS
  @State courses: CourseItem[] = COURSES
  @State mys: MyItem[] = MYS
  @State pickedFeed: FeedItem | null = null
  @State pickedMake: MakeItem | null = null
  @State pickedLive: LiveItem | null = null
  @State pickedCourse: CourseItem | null = null
  @State pickedStar: StarItem | null = null
  @State pickedMy: MyItem | null = null
  @State showFeedDetail: boolean = false
  @State showFeedAdd: boolean = false
  @State showMakeDetail: boolean = false
  @State showMakeEdit: boolean = false
  @State showLiveRule: boolean = false
  @State showLiveDetail: boolean = false
  @State showLiveAdd: boolean = false
  @State showCourseDetail: boolean = false
  @State showCourseSign: boolean = false
  @State showStarRule: boolean = false
  @State showStarDetail: boolean = false
  @State showStarSign: boolean = false
  @State showStarQuit: boolean = false
  @State showMyRemove: boolean = false
  @State showMyNick: boolean = false
  @State showMyCache: boolean = false
  @State showMyExit: boolean = false
  @State newFeedName: string = ''
  @State newFeedText: string = ''
  @State newMakeName: string = ''
  @State newMakeMins: string = ''
  @State newNick: string = ''
  @State catSel: number = 0

在这里插入图片描述

技术概念强调: @Entry 装饰器标记 Index 结构体为页面的入口组件。在鸿蒙应用中,每个页面有且仅有一个被 @Entry 修饰的组件,它是整个页面 UI 树的根节点。@Component 装饰器声明这是一个自定义组件,自定义组件拥有独立的 build() 方法来描述其 UI 结构。两者配合使用,构成了一个完整的页面入口。

这里的 @State 变量可以归纳为四类。第一类是标签索引currentTab 记录当前选中的底部 Tab 页索引(0-5),catSel 记录当前选中的分类标签索引。第二类是数据列表feedsmakeslivescoursesstarsmys 六个数组,它们在初始化时分别引用了前文定义的静态数据常量,在运行时可以通过 unshiftsplice 等方法修改内容。第三类是选中项pickedFeedpickedMakepickedLivepickedCoursepickedStarpickedMy 六个可空对象(类型为 XXXItem | null),它们在用户点击某个列表项时被赋值为对应的数据对象,用于在弹窗中展示详情。第四类是弹窗开关:大量以 show 开头的布尔变量,每个变量控制一个弹窗的显示与隐藏。

技术概念强调: @State 修饰的变量变化时,框架会自动重新渲染所有依赖该变量的 UI 片段。例如,当 showFeedDetailfalse 变为 true 时,if (this.showFeedDetail) 条件分支内的 UI 代码会被执行并渲染到界面上;当变回 false 时,对应的 UI 节点会被移除。这种基于条件渲染的弹窗实现方式是 ArkUI 中最简洁直接的做法。

这种设计模式虽然简单直接,但当弹窗数量增多时,状态变量的数量会线性增长。代码中定义了超过 30 个 @State 变量,其中 16 个是弹窗开关布尔值。在实际的大型项目中,可以考虑将这些弹窗状态合并为一个枚举或联合类型变量来减少状态数量,或者使用状态管理框架(如 AppStorage/LocalStorage)进行更系统化的状态管理。但对于一个功能完整的中型应用来说,这种"一弹窗一开关"的方式在可读性和调试便利性上仍有其优势。

4.5 @Builder 方法:Tab 标签项构建器

@Builder
tabItem(icon: string, label: string, tab: number) {
  Column({ space: 3 }) {
    Text(icon)
      .fontSize(22)
      .fontColor(this.currentTab === tab ? '#EC4899' : '#C9A0B4')
    Text(label)
      .fontSize(11)
      .fontColor(this.currentTab === tab ? '#EC4899' : '#C9A0B4')
  }
  .width('16.66%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .scale({ x: this.currentTab === tab ? 1.12 : 1, y: this.currentTab === tab ? 1.12 : 1 })
  .animation({ duration: 200, curve: Curve.EaseOut })
  .onClick(() => {
    this.currentTab = tab
  })
}

在这里插入图片描述

技术概念强调: @Builder 装饰器用于定义一个可复用的 UI 构建方法。被 @Builder 修饰的方法可以在 build() 方法中像调用普通方法一样被调用,其返回的 UI 片段会被嵌入到调用位置。与自定义组件(@Component)不同的是,@Builder 方法没有独立的状态和生命周期,它直接访问所属组件的状态变量,适合用于提取组件内部重复的 UI 片段。

tabItem 方法接收三个参数:icon(Emoji 图标字符串)、label(中文标签文本)和 tab(对应的 Tab 索引值)。在方法内部,它构建了一个 Column 容器,其中纵向排列了图标 Text 和标签 Text。两个 Text 组件的 fontColor 都通过三元表达式 this.currentTab === tab ? '#EC4899' : '#C9A0B4' 来动态决定颜色——如果当前选中的 Tab 索引等于该方法对应的 Tab 索引,则文字颜色为玫粉色(高亮选中态),否则为灰粉色(未选中态)。

Column 容器的 width 设为 '16.66%'(即 1/6,因为底部有 6 个 Tab 项),高度填满父容器。justifyContent(FlexAlign.Center) 使子组件在主轴(纵向)上居中对齐。scale 属性通过 xy 两个方向上 1.12 倍的缩放系数,让选中的 Tab 项在视觉上略微放大,形成突出效果。animation 属性配置了一个持续 200 毫秒、缓动曲线为 Curve.EaseOut 的动画,使得缩放变换以平滑过渡的方式呈现,而非瞬间跳变。

技术概念强调: animation 是 ArkUI 的属性动画 API。当被修饰组件的属性值发生变化时(如 scale 从 1 变为 1.12),框架会自动以指定的 duration(持续时间,毫秒)和 curve(缓动曲线)来执行过渡动画。Curve.EaseOut 表示先快后慢的缓动曲线,适合"出现/放大"类动画。其他常用曲线包括 Curve.Linear(匀速)、Curve.EaseIn(先慢后快)、Curve.EaseInOut(两端慢中间快)。

onClick 事件处理器将 this.currentTab 设置为该方法对应的 Tab 索引 tab。由于 currentTab@State 变量,赋值后框架会自动重新渲染所有依赖它的 UI 片段——包括所有六个 tabItem 调用点中的颜色和缩放属性,以及内容区中 if (this.currentTab === N) 条件分支的切换。这就是声明式 UI 的响应式更新机制在 Tab 切换场景中的具体体现。

4.6 主构建方法:Stack 层叠布局与页面骨架

build() {
  Stack({ alignContent: Alignment.Bottom }) {
    Column() {
      // ===== 头部区域 =====
      Column() {
        Row() {
          Row() {
            Text('💄')
              .fontSize(26)
            Column({ space: 2 }) {
              Text('BEAUTY LAB')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A2333')
              Text('美妆短工坊 · 官方')
                .fontSize(10)
                .fontColor('#C084FC')
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 8 })
          }
          .alignItems(VerticalAlign.Center)
          Text('').layoutWeight(1)
          Text('🔔')
            .fontSize(22)
            .opacity(0.85)
          Text('🔍')
            .fontSize(22)
            .margin({ left: 14 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 10 })

在这里插入图片描述

技术概念强调: Stack 是 ArkUI 的层叠布局容器。它允许多个子组件叠放在同一区域中,后声明的子组件会覆盖在先声明的子组件之上。alignContent 参数控制子组件在 Stack 内的对齐方式,Alignment.Bottom 表示子组件整体向底部对齐。在本文的代码中,Stack 被用作整个页面的根布局容器,其第一个子组件(Column)是页面的主体内容,而后续的 if 条件渲染块则是各种弹窗层,它们覆盖在主体内容之上。

build() 方法是每个 @Component 组件的核心方法,它返回该组件的 UI 结构树。在这个 Index 入口组件的 build() 方法中,最外层是一个 Stack 容器,设置了 alignContent: Alignment.Bottom,这意味着所有子组件默认向底部对齐。Stack 内嵌套了一个 Column 作为主体内容区域。

主体 Column 内从上到下依次包含三个部分:头部区域、内容区域和底部 Tab 栏。头部区域是一个嵌套的 Column,其内部又包含一个 Row(顶部导航栏)和一个带渐变背景的 Column(品牌横幅区)。顶部导航栏的 Row 中,左侧是一个图标加品牌名称的组合,使用 Row + Column 的嵌套结构实现图标与双行文字的横向排列;中间用 Text('').layoutWeight(1) 作为弹性占位符将两侧元素推开;右侧是通知和搜索两个图标。

          // 粉紫横幅
          Column({ space: 6 }) {
            Row() {
              Text('✨ 今日上新 86 款')
                .fontSize(11)
                .fontColor('#4A2333')
                .backgroundColor('rgba(236,72,153,0.2)')
                .borderRadius(20)
                .padding({ left: 10, right: 10, top: 3, bottom: 3 })
              Text('').layoutWeight(1)
              Text('🔴 直播中 128 场')
                .fontSize(11)
                .fontColor('#D97706')
            }
            .width('100%')

            Text('美妆短工坊')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4A2333')
              .letterSpacing(2)
            Text('BEAUTY LAB · 一分钟学会变美')
              .fontSize(11)
              .fontColor('#C084FC')
              .letterSpacing(1)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 14 })
          .linearGradient({
            angle: 180,
            colors: [['#FCE7F3', 0.0], ['#F5D0FE', 0.55], ['#FDF2F8', 1.0]]
          })

在这里插入图片描述

品牌横幅区使用了 linearGradient 方法设置线性渐变背景。angle: 180 表示渐变方向为从上到下(0度为从下到上,180度为从上到下)。colors 数组接收一个元组数组,每个元组的第一个元素是颜色值,第二个元素是该颜色在渐变线上的位置(0.0 到 1.0)。这里使用了三个颜色站点:起始处为浅粉色 #FCE7F3,中间 55% 处为淡紫色 #F5D0FE,结尾处为极浅粉色 #FDF2F8,形成了一个从粉到紫再到浅粉的柔和过渡效果。

技术概念强调: linearGradient 是 ArkUI 中设置线性渐变背景的方法。它接收一个配置对象,包含 angle(渐变角度,0-360 度)和 colors(颜色站点数组)。每个颜色站点是一个 [颜色值, 位置比例] 的元组。这种声明式的渐变定义方式与 CSS 的 linear-gradient 函数非常相似,开发者可以轻松上手。borderRadius 用于设置圆角,padding 用于设置内边距,这些属性与 CSS 中的同名属性语义一致。

横幅区内还包含一个花瓣符号带,使用 ForEach 渲染了 10 个花瓣 Emoji,每个 Emoji 的字体大小和颜色通过索引取模运算动态决定,形成一种色彩交替的装饰效果。

            Row() {
              ForEach([13, 17, 11, 19, 14, 12, 18, 15, 16, 13], (s: number, i: number) => {
                Text(i % 2 === 0 ? '✿' : '❀')
                  .fontSize(s)
                  .fontColor(i % 3 === 0 ? '#EC4899' : (i % 3 === 1 ? '#C084FC' : '#D97706'))
                  .opacity(0.7)
              }, (s: number, i: number) => 'petal' + i)
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceBetween)
            .padding({ left: 20, right: 20 })

技术概念强调: ForEach 是 ArkUI 的列表渲染指令。它接收三个参数:数据源数组、子项生成函数和键值生成函数。子项生成函数 (item, index) => { ... } 为每个数据项生成一个 UI 片段;键值生成函数 (item, index) => string 为每个项生成唯一标识符,框架使用这个标识符来进行高效的 diff 操作,在数据增删时精确地增删对应的 UI 节点,而非全量重建。FlexAlign.SpaceBetween 让子项在主轴方向上均匀分布,首尾两项紧贴容器边缘,中间项均匀间距。

4.7 内容区域:条件渲染与子组件路由

        // ===== 内容区 =====
        Column() {
          if (this.currentTab === 0) {
            HomeContent({
              feeds: this.feeds,
              heat: HEAT,
              catSel: this.catSel,
              showFeedDetail: (item: FeedItem) => {
                this.pickedFeed = item
                this.showFeedDetail = true
              },
              showFeedAdd: () => {
                this.showFeedAdd = true
              }
            })
          }
          if (this.currentTab === 1) {
            MakeContent({
              makes: this.makes,
              catSel: this.catSel,
              showMakeDetail: (item: MakeItem) => {
                this.pickedMake = item
                this.showMakeDetail = true
              },
              showMakeEdit: (item: MakeItem) => {
                this.pickedMake = item
                this.newMakeName = item.name
                this.newMakeMins = item.mins.toString()
                this.showMakeEdit = true
              }
            })
          }
          if (this.currentTab === 2) {
            LiveContent({
              lives: this.lives,
              showLiveRule: () => {
                this.showLiveRule = true
              },
              showLiveDetail: (item: LiveItem) => {
                this.pickedLive = item
                this.showLiveDetail = true
              },
              showLiveAdd: () => {
                this.showLiveAdd = true
              }
            })
          }
          // ... Tab 3/4/5 类似
        }
        .layoutWeight(1)
        .width('100%')

内容区域是页面的主体,它通过一系列 if (this.currentTab === N) 条件分支来决定当前显示哪个子组件。每个条件分支内部调用一个自定义组件(如 HomeContentMakeContentLiveContent 等),并传入数据和回调函数作为参数。

这种"条件分支 + 子组件"的模式实现了 Tab 页切换功能。当用户点击底部 Tab 栏时,currentTab 状态变量被更新,框架自动移除旧的条件分支 UI 并渲染新的条件分支 UI,从而实现页面内容的切换。由于使用的是 if 条件渲染而非页面路由(router),切换过程在同一个页面内完成,没有页面跳转的动画延迟,用户体验更加流畅。

技术概念强调: 在 ArkUI 中,if 条件渲染会根据条件表达式的真假来决定是否创建/销毁对应的 UI 节点。当条件从 true 变为 false 时,对应的 UI 子树会被完全销毁;当条件从 false 变为 true 时,UI 子树会被重新创建。这意味着条件分支内部的组件状态(如子组件内部的 @State 变量)不会在切换过程中保留。如果需要保留状态,应使用 @StorageLink 或考虑使用页面路由而非条件渲染。

值得注意的是,子组件在调用时传入了两种参数:数据属性回调函数。数据属性如 feeds: this.feedsmakes: this.makes 等,将父组件的 @State 数组传递给子组件;回调函数如 showFeedDetail: (item: FeedItem) => { ... }showFeedAdd: () => { ... } 等,将父组件的弹窗控制逻辑封装为闭包传递给子组件。当子组件内部需要触发弹窗时,只需调用传入的回调函数即可,父组件接收到回调后修改对应的 @State 变量,框架自动渲染弹窗。这种"子组件触发事件、父组件处理状态"的设计模式是 ArkUI 组件通信的标准实践。

技术概念强调: ArkUI 中父子组件通信的推荐方式是通过参数传递和回调函数。父组件通过组件调用参数将数据传递给子组件(子组件使用 @Prop 接收),子组件通过调用父组件传入的回调函数将事件传递回父组件。这种单向数据流模式确保了状态的可追溯性,避免了双向绑定可能导致的调试困难。

4.8 底部 Tab 栏

        // ===== 底部 tab 栏 =====
        Row() {
          this.tabItem('💄', '首页', 0)
          this.tabItem('🎬', '教程', 1)
          this.tabItem('🔴', '直播', 2)
          this.tabItem('📖', '课程', 3)
          this.tabItem('👑', '达人', 4)
          this.tabItem('👤', '我的', 5)
        }
        .width('100%')
        .height(58)
        .backgroundColor('#FFFFFF')
        .border({ width: { top: 1 }, color: '#F9D8E8' })

底部 Tab 栏是一个 Row 容器,内部调用了六次 this.tabItem() 方法(即前文解析的 @Builder 方法),分别传入不同的图标、标签和索引值。Rowwidth 为 100%、height 为 58(单位默认为 vp),背景色为白色。border 属性使用了对象语法 { width: { top: 1 }, color: '#F9D8E8' },通过 width: { top: 1 } 只设置顶边边框宽度为 1vp,其余三边为 0,配合 color 属性设置边框颜色为浅粉色,在 Tab 栏与内容区之间形成一条分隔线。

4.9 弹窗层:条件渲染与模态对话框

Stack 容器中,主体 Column 之后是一系列 if 条件渲染块,每个块对应一个弹窗。以下是动态详情弹窗的完整代码。

      // 动态详情
      if (this.showFeedDetail) {
        Column() {
          Column({ space: 10 }) {
            Row() {
              Text(this.pickedFeed !== null ? this.pickedFeed.avatar : '')
                .fontSize(30)
              Column({ space: 3 }) {
                Text(this.pickedFeed !== null ? this.pickedFeed.name : '')
                  .fontSize(17)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#4A2333')
                Text(this.pickedFeed !== null ? this.pickedFeed.time : '')
                  .fontSize(11)
                  .fontColor('#C084FC')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#4A2333')
                .padding(6)
                .onClick(() => {
                  this.showFeedDetail = false
                })
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)

            Text(this.pickedFeed !== null ? this.pickedFeed.content : '')
              .fontSize(15)
              .fontColor('#7A4A5E')
              .lineHeight(24)

            Row() {
              Text('👍 变美点赞 ' + (this.pickedFeed !== null ? this.pickedFeed.likes : 0))
                .fontSize(13)
                .fontColor('#EC4899')
                .backgroundColor('rgba(236,72,153,0.12)')
                .borderRadius(14)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              Text('💬 评论 ' + (this.pickedFeed !== null ? this.pickedFeed.comments : 0))
                .fontSize(13)
                .fontColor('#C084FC')
                .backgroundColor('rgba(192,132,252,0.12)')
                .borderRadius(14)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            }
            .width('100%')
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .border({ width: 1, color: '#EC4899' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(74,35,51,0.55)')
        .justifyContent(FlexAlign.Center)
      }

这个弹窗的 UI 结构分为两层。外层是一个 Column,宽度 100%、高度 100%,背景色为 rgba(74,35,51,0.55)——这是一个半透明的深色遮罩层,覆盖在整个页面上方,阻断了用户与底层内容的交互。justifyContent(FlexAlign.Center) 使内层卡片在遮罩层中垂直居中。内层是一个 Column 卡片,宽度 88%(留出两侧间距),背景色为白色,带有 14vp 圆角和 1vp 玫粉色边框,形成一张浮于遮罩层之上的卡片。

技术概念强调: ArkUI 中实现模态弹窗的标准方式就是使用 Stack 层叠布局配合条件渲染。Stack 容器允许子组件叠放,后声明的子组件覆盖在先声明的子组件之上。当弹窗的 @State 布尔变量为 true 时,遮罩层和卡片被渲染到 Stack 的最上层,覆盖在主体内容之上;当为 false 时,整个弹窗 UI 被销毁,底层内容恢复可交互。这种方式不需要使用 CustomDialogAlertDialog 等 API,纯声明式即可实现。

卡片内部从上到下依次是:用户信息行(头像 Emoji、昵称、时间、关闭按钮)、正文内容、互动数据行(点赞数和评论数标签)。值得注意的是,所有读取 this.pickedFeed 属性的地方都使用了 this.pickedFeed !== null ? this.pickedFeed.xxx : 默认值 的空安全三元表达式。这是因为 pickedFeed 的类型是 FeedItem | null(可空类型),ArkTS 要求在使用可空类型的属性前必须进行空值检查。虽然弹窗只有在 pickedFeed 不为 null 时才会显示,但编译器无法推断这种逻辑上的约束,因此仍需要显式的空值检查来通过类型检查。

技术概念强调: ArkTS 的空安全机制要求所有可空类型(T | null)在访问属性前必须进行空值检查。这是 ArkTS 从 TypeScript 继承并强化的安全特性。使用 !== null 的三元表达式是最基础的空值处理方式,此外还可以使用非空断言操作符 !(如 this.pickedFeed!.name)来绕过检查,但这需要开发者自行确保运行时确实非空。

4.10 发布动态弹窗:TextInput 输入框组件

      // 发布动态新增
      if (this.showFeedAdd) {
        Column() {
          Column({ space: 12 }) {
            Row() {
              Text('💄 发布美妆动态')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A2333')
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#4A2333')
                .padding(6)
                .onClick(() => {
                  this.showFeedAdd = false
                })
            }
            .width('100%')

            TextInput({ placeholder: '你的昵称', text: this.newFeedName })
              .width('100%')
              .height(42)
              .fontSize(14)
              .fontColor('#4A2333')
              .backgroundColor('#FDF2F8')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newFeedName = v
              })

            TextInput({ placeholder: '分享今天的美妆心得...', text: this.newFeedText })
              .width('100%')
              .height(88)
              .fontSize(14)
              .fontColor('#4A2333')
              .backgroundColor('#FDF2F8')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newFeedText = v
              })

            Row() {
              Text('💄 底妆')
                .fontSize(12)
                .fontColor('#EC4899')
                .backgroundColor('rgba(236,72,153,0.12)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              Text('👁️ 眼妆')
                .fontSize(12)
                .fontColor('#C084FC')
                .backgroundColor('rgba(192,132,252,0.12)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              Text('💋 唇妆')
                .fontSize(12)
                .fontColor('#D97706')
                .backgroundColor('rgba(217,119,6,0.12)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            }
            .width('100%')

            Text('发布')
              .textAlign(TextAlign.Center)
              .width('100%')
              .height(42)
              .backgroundColor('#EC4899')
              .borderRadius(8)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .onClick(() => {
                if (this.newFeedName !== '' && this.newFeedText !== '') {
                  this.feeds.unshift(buildFeed(this.feeds.length + 1, this.newFeedName, '💄', '刚刚', this.newFeedText, 0, 0))
                  this.newFeedName = ''
                  this.newFeedText = ''
                }
                this.showFeedAdd = false
              })
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .border({ width: 1, color: '#C084FC' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(74,35,51,0.55)')
        .justifyContent(FlexAlign.Center)
      }

技术概念强调: TextInput 是 ArkUI 的文本输入组件,用于接收用户的键盘输入。它接收一个配置对象,常用的配置项包括 placeholder(占位提示文本)和 text(当前文本值,用于设置初始值或与状态变量绑定)。onChange 事件回调在用户输入时触发,参数 v 为最新的输入内容。在 onChange 中将输入值赋给 @State 变量,即实现了双向数据绑定——输入框内容变化时更新状态变量,状态变量变化时也会反映到输入框的 text 属性上。

发布弹窗中使用了两个 TextInput 组件,分别用于输入昵称和美妆心得内容。两个输入框的 text 属性分别绑定到 this.newFeedNamethis.newFeedText 状态变量,onChange 回调中将用户输入的最新值赋给对应的状态变量。输入框的样式统一设置为浅粉色背景(#FDF2F8)和 8vp 圆角,昵称输入框高度为 42vp(单行),心得输入框高度为 88vp(多行区域)。

弹窗底部是一个"发布"按钮,使用 Text 组件模拟按钮样式(设为居中对齐、满宽、玫粉色背景、白色粗体文字)。onClick 事件处理器中,首先检查昵称和心得内容是否都非空,如果非空则调用 this.feeds.unshift(buildFeed(...)) 向动态列表头部插入一条新数据。unshift 方法将新元素添加到数组头部,框架会代理这个操作并触发 ForEach 列表的局部更新,在列表顶部插入新的卡片。插入完成后清空输入框的状态变量,最后将 showFeedAdd 设为 false 关闭弹窗。

技术概念强调: 在 ArkUI 中,@State 数组调用 unshiftsplicepush 等原数组方法时,框架会自动检测到数组变化并触发 UI 更新。这得益于 ArkUI 对数组方法的代理拦截机制。以 unshift 为例,框架会在方法执行后通知所有通过 ForEach 渲染该数组的 UI 节点,在列表头部插入一个新的 UI 卡片,而非全量重建整个列表。这种精确的增量更新是 ArkUI 高性能渲染的基础。

4.11 教程编辑弹窗:数据回写与数组替换

      // 教程信息编辑
      if (this.showMakeEdit) {
        Column() {
          Column({ space: 12 }) {
            Row() {
              Text('✏️ 编辑教程信息')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A2333')
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#4A2333')
                .padding(6)
                .onClick(() => {
                  this.showMakeEdit = false
                })
            }
            .width('100%')

            TextInput({ placeholder: '教程名称', text: this.newMakeName })
              .width('100%')
              .height(42)
              .fontSize(14)
              .fontColor('#4A2333')
              .backgroundColor('#FDF2F8')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newMakeName = v
              })

            TextInput({ placeholder: '时长(分钟)', text: this.newMakeMins })
              .width('100%')
              .height(42)
              .fontSize(14)
              .fontColor('#4A2333')
              .backgroundColor('#FDF2F8')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newMakeMins = v
              })

            Row() {
              Text('难度 入门')
                .fontSize(12)
                .fontColor('#EC4899')
                .backgroundColor('rgba(236,72,153,0.12)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              Text('难度 进阶')
                .fontSize(12)
                .fontColor('#C084FC')
                .backgroundColor('rgba(192,132,252,0.12)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              Text('难度 高级')
                .fontSize(12)
                .fontColor('#D97706')
                .backgroundColor('rgba(217,119,6,0.12)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            }
            .width('100%')

            Text('保存修改')
              .textAlign(TextAlign.Center)
              .width('100%')
              .height(42)
              .backgroundColor('#D97706')
              .borderRadius(8)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .onClick(() => {
                if (this.pickedMake !== null) {
                  let idx = this.makes.findIndex((it: MakeItem) => it.id === this.pickedMake!.id)
                  if (idx >= 0) {
                    let m = parseInt(this.newMakeMins)
                    if (isNaN(m)) {
                      m = this.pickedMake.mins
                    }
                    this.makes.splice(idx, 1, buildMake(this.pickedMake.id, this.newMakeName, this.pickedMake.kind, m, this.pickedMake.heat, this.pickedMake.level, this.pickedMake.state))
                  }
                }
                this.showMakeEdit = false
              })
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .border({ width: 1, color: '#D97706' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(74,35,51,0.55)')
        .justifyContent(FlexAlign.Center)
      }

教程编辑弹窗展示了"数据回写"的典型场景。当用户在教程列表中点击"编辑"时,父组件的回调函数中不仅设置了 pickedMake 为选中的教程对象,还将教程名称和时长分别赋值给 newMakeNamenewMakeMins 状态变量。这样,编辑弹窗的两个 TextInputtext 属性就能在弹窗打开时显示当前教程的原始数据,实现"回填"效果。

"保存修改"按钮的 onClick 逻辑是这个弹窗的核心。首先通过 this.makes.findIndex() 查找选中教程在数组中的索引位置。findIndex 是数组方法,接收一个箭头函数作为谓词,返回第一个满足条件的元素的索引。由于 pickedMake 是可空类型,在箭头函数中使用了非空断言操作符 ! 来访问 this.pickedMake!.id。如果找到索引(idx >= 0),则使用 parseInt 将输入的时长字符串转换为数字,如果转换结果为 NaN(用户输入了非数字内容),则回退使用原始时长值。

技术概念强调: splice 是 JavaScript/TypeScript 数组的原生方法,在 ArkTS 中同样可用。splice(idx, 1, newItem) 的含义是:从索引 idx 处删除 1 个元素,并在同一位置插入 newItem,即"替换"操作。在 ArkUI 中,对 @State 数组调用 splice 会被框架代理拦截,框架会精确更新 ForEach 列表中对应索引位置的 UI 节点,而非全量重建列表。这是实现列表项编辑更新最高效的方式。

最后调用 this.makes.splice(idx, 1, buildMake(...)) 将原数组中索引位置的对象替换为一个新构造的对象。新对象保留了原始的 idkindheatlevelstate 等不变字段,仅更新了 name(来自输入框)和 mins(来自输入框并经过解析和空值保护)。替换完成后关闭弹窗,框架自动更新列表中对应卡片的内容。

4.12 课程报名弹窗:席位递减逻辑

      // 报名确认
      if (this.showCourseSign) {
        Column() {
          Column({ space: 12 }) {
            Row() {
              Text('✅ 报名确认')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A2333')
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#4A2333')
                .padding(6)
                .onClick(() => {
                  this.showCourseSign = false
                })
            }
            .width('100%')

            Text('你即将报名「' + (this.pickedCourse !== null ? this.pickedCourse.name : '') + '」\n剩余席位 ' + (this.pickedCourse !== null ? this.pickedCourse.quota : 0) + ' 个')
              .fontSize(14)
              .fontColor('#7A4A5E')
              .lineHeight(24)

            Text('确认报名')
              .textAlign(TextAlign.Center)
              .width('100%')
              .height(42)
              .backgroundColor('#C084FC')
              .borderRadius(8)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .onClick(() => {
                if (this.pickedCourse !== null) {
                  let idx = this.courses.findIndex((it: CourseItem) => it.id === this.pickedCourse!.id)
                  if (idx >= 0) {
                    this.courses.splice(idx, 1, buildCourseSign(this.courses[idx]))
                  }
                }
                this.showCourseSign = false
              })
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .border({ width: 1, color: '#C084FC' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(74,35,51,0.55)')
        .justifyContent(FlexAlign.Center)
      }

报名确认弹窗的"确认报名"按钮逻辑与前文解析的 buildCourseSign 工厂函数紧密配合。点击后,先通过 findIndex 找到选中课程在数组中的索引,然后调用 this.courses.splice(idx, 1, buildCourseSign(this.courses[idx])) 替换该课程对象。buildCourseSign 函数接收当前课程对象,将其 quota(剩余席位)减 1,如果席位归零则将 state 改为 1(已报满),返回新的课程对象。替换后,框架自动更新列表中对应卡片的席位进度条和状态标签——进度条变短(席位减少),如果席位归零则状态标签从"报名中"变为"已报满",颜色从紫色变为灰粉色。

技术概念强调: 不可变数据更新(Immutable Update)模式在 ArkUI 开发中至关重要。buildCourseSign 函数不直接修改传入的课程对象的属性,而是构造一个全新的对象返回。这是因为 ArkUI 的状态追踪依赖于引用比较——如果对象引用不变,即使属性值变了,框架也可能无法感知变化。通过构造新对象并用 splice 替换旧对象,确保了引用变化,框架能够正确触发 UI 更新。

4.13 达人解约弹窗:数组删除

      // 达人解约
      if (this.showStarQuit) {
        Column() {
          Column({ space: 12 }) {
            Row() {
              Text('⚠️ 解除签约')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A2333')
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#4A2333')
                .padding(6)
                .onClick(() => {
                  this.showStarQuit = false
                })
            }
            .width('100%')

            Text('确认与「' + (this.pickedStar !== null ? this.pickedStar.name : '') + '」解除签约?\n解约后达人专属权益立即失效。')
              .fontSize(14)
              .fontColor('#7A4A5E')
              .lineHeight(24)

            Row() {
              Text('取消')
                .textAlign(TextAlign.Center)
                .width('46%')
                .height(40)
                .backgroundColor('rgba(201,160,180,0.3)')
                .borderRadius(8)
                .fontSize(14)
                .fontColor('#7A4A5E')
                .onClick(() => {
                  this.showStarQuit = false
                })
              Text('确认解约')
                .textAlign(TextAlign.Center)
                .width('46%')
                .height(40)
                .backgroundColor('#D97706')
                .borderRadius(8)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
                .onClick(() => {
                  if (this.pickedStar !== null) {
                    let idx = this.stars.findIndex((it: StarItem) => it.id === this.pickedStar!.id)
                    if (idx >= 0) {
                      this.stars.splice(idx, 1)
                    }
                  }
                  this.showStarQuit = false
                })
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceBetween)
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .border({ width: 1, color: '#D97706' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(74,35,51,0.55)')
        .justifyContent(FlexAlign.Center)
      }

达人解约弹窗展示了一个双按钮操作面板,包含"取消"和"确认解约"两个按钮。双按钮使用 Row 容器横向排列,每个按钮宽度设为 46%(而非 50%),留出中间间距,配合 justifyContent(FlexAlign.SpaceBetween) 实现两端对齐。"取消"按钮使用半透明灰粉色背景(rgba(201,160,180,0.3)),"确认解约"按钮使用裸金色背景(#D97706)加粗白色文字,形成视觉上的主次区分。

"确认解约"按钮的逻辑使用了 splice(idx, 1)——注意与前文的 splice(idx, 1, newItem) 不同,这里只传两个参数,表示从索引位置删除 1 个元素但不插入新元素。这会从 stars 数组中永久移除该达人条目,框架会自动从 ForEach 渲染的列表中移除对应的 UI 卡片。

技术概念强调: splice 方法是 ArkUI 中操作 @State 数组最常用的方法。splice(idx, 1) 删除指定位置的元素;splice(idx, 1, newItem) 替换指定位置的元素;splice(idx, 0, newItem) 在指定位置插入新元素。框架对 splice 的代理机制确保了增删改操作都能精确触发列表 UI 的增量更新,而非全量重建。

4.14 首页内容组件:HomeContent

@Component
struct HomeContent {
  @Prop feeds: FeedItem[]
  @Prop heat: number[]
  @Prop catSel: number
  showFeedDetail: (item: FeedItem) => void = (item: FeedItem) => {}
  showFeedAdd: () => void = () => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        // 头条横幅
        Column({ space: 8 }) {
          Row() {
            Text('🔥 头条 · 美妆风向标')
              .fontSize(11)
              .fontColor('#4A2333')
              .backgroundColor('rgba(236,72,153,0.18)')
              .borderRadius(20)
              .padding({ left: 10, right: 10, top: 3, bottom: 3 })
            Text('').layoutWeight(1)
            Text('今日 2.1 万条心得')
              .fontSize(11)
              .fontColor('#C084FC')
          }
          .width('100%')

          Text('秋冬妆造新趋势:奶咖眼妆横扫美妆圈')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A2333')
            .lineHeight(24)
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .border({ width: 1, color: '#F9A8D4' })

技术概念强调: @Prop 装饰器用于子组件接收父组件传递的数据。与 @State 不同,@Prop 修饰的变量在子组件内部是只读的——子组件可以读取但不能修改这些值。当父组件中对应的数据变化时,@Prop 变量会收到新的拷贝并触发子组件 UI 的重新渲染。@Prop 实现了父子之间的单向数据流:父组件是数据的"源",子组件是数据的"消费者"。

HomeContent 是首页内容区的子组件。它使用 @Prop 接收 feeds(动态列表)、heat(热度数组)和 catSel(分类选中索引)三个数据属性。同时声明了两个普通方法属性 showFeedDetailshowFeedAdd 作为回调函数,初始值为空函数。当父组件在调用 HomeContent 时传入了回调函数,子组件内部调用这些回调时就会执行父组件的逻辑。

build() 方法最外层是一个 Scroll 容器,表示内容区可以垂直滚动。Scroll 内嵌一个 Column 作为内容容器,space: 12 表示子组件之间的间距为 12vp。Scroll 组件的 scrollable(ScrollDirection.Vertical) 设置为垂直滚动,scrollBar(BarState.Off) 隐藏滚动条,实现更干净的视觉效果。

技术概念强调: Scroll 是 ArkUI 的滚动容器组件。它包裹超出屏幕高度的内容,允许用户通过滑动手势查看被遮挡的部分。scrollable 属性设置滚动方向(Vertical 垂直、Horizontal 水平),scrollBar 属性控制滚动条的显示状态(Auto 自动、On 显示、Off 隐藏)。在列表内容较长时,Scroll 是必不可少的容器。

4.15 周热度柱状图:ForEach 数据可视化

        // 周热度柱状图
        Column({ space: 8 }) {
          Row() {
            Text('📊 本周教程热度')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4A2333')
            Text('').layoutWeight(1)
            Text('近 7 日')
              .fontSize(11)
              .fontColor('#C084FC')
          }
          .width('100%')

          Row() {
            ForEach(this.heat, (v: number, i: number) => {
              Column({ space: 4 }) {
                Column()
                  .width(24)
                  .height(heatBar(v))
                  .backgroundColor(i % 2 === 0 ? '#EC4899' : '#C084FC')
                  .borderRadius(4)
                Text('周' + (i + 1))
                  .fontSize(9)
                  .fontColor('#C9A0B4')
              }
              .width('12.5%')
              .justifyContent(FlexAlign.End)
            }, (v: number, i: number) => 'h' + i)
          }
          .width('100%')
          .height(90)
          .alignItems(VerticalAlign.Bottom)
        }
        .width('100%')
        .padding(12)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)

这是一个利用 ForEach 和容器组件手工绘制的柱状图。外层 Column 包含标题行和图表行。图表行是一个 Row,高度 90vp,alignItems(VerticalAlign.Bottom) 让所有子组件在交叉轴(纵向)上底部对齐,这正是柱状图所需的底部对齐效果。

ForEach 遍历 this.heat 数组(值为 [6, 9, 8, 7, 10, 9, 11, 8]),为每个热度值生成一个 Column 子组件。每个 Column 的宽度设为 '12.5%'(1/8,因为数组有 8 个元素),内部包含一个柱子(Column 组件,宽度 24vp,高度通过 heatBar(v) 动态计算)和一个标签文字(Text 显示"周1"到"周8")。柱子的高度由 heatBar 函数计算,例如热度值为 6 时高度为 12 + 6*4 = 36vp,热度值为 11 时高度为 12 + 11*4 = 56vp。柱子的颜色通过 i % 2 取模交替使用玫粉色和雾紫色。每个 ColumnjustifyContent(FlexAlign.End) 让柱子在容器内部底部对齐(因为容器高度可能高于柱子高度加标签高度之和)。

技术概念强调: 在 ArkUI 中,数据可视化不一定要依赖图表库。对于简单的柱状图、进度条等,可以利用 Column/Row 容器配合动态尺寸属性(如 .height() 接受动态计算的值)和 ForEach 来手工绘制。这种方式的优势是零依赖、性能好、完全融入声明式 UI 体系。alignItems(VerticalAlign.Bottom) 是实现柱状图底部对齐的关键属性。

4.16 分类标签横滑:Scroll 水平滚动

        // 肤质标签横滑
        Scroll() {
          Row({ space: 8 }) {
            ForEach(CATS, (c: string, i: number) => {
              Text(c)
                .fontSize(12)
                .fontColor(this.catSel === i ? '#FFFFFF' : '#C084FC')
                .backgroundColor(this.catSel === i ? '#EC4899' : 'rgba(236,72,153,0.1)')
                .borderRadius(14)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            }, (c: string, i: number) => 'cat' + i)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

分类标签区使用了 Scroll 容器配合 scrollable(ScrollDirection.Horizontal) 实现水平滚动。内部 Row 包含多个 Text 标签,每个标签通过 ForEachCATS 数组生成。标签的颜色和背景色通过 this.catSel === i 判断当前是否选中来动态决定——选中时白字玫粉底,未选中时雾紫字浅粉底。标签使用 borderRadius(14) 设置大圆角形成胶囊形状。当标签数量超出屏幕宽度时,用户可以左右滑动浏览所有分类。

技术概念强调: ScrollDirection.Horizontal 设置 Scroll 容器为水平滚动模式。这在实现标签栏、图片轮播、水平列表等场景中非常实用。配合 scrollBar(BarState.Off) 隐藏滚动条,可以获得更整洁的视觉效果。borderRadius 设置的值大于元素高度的一半时,圆角会自动被裁剪为半圆形,形成胶囊形标签。

4.17 美妆动态列表:ForEach 卡片渲染

        // 美妆动态列表
        ForEach(this.feeds, (item: FeedItem) => {
          Column({ space: 8 }) {
            Row() {
              Text(item.avatar)
                .fontSize(26)
              Column({ space: 2 }) {
                Text(item.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#4A2333')
                Text(item.time)
                  .fontSize(10)
                  .fontColor('#C9A0B4')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 8 })
              Text('').layoutWeight(1)
              Text('🌸')
                .fontSize(18)
                .opacity(0.8)
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)

            Text(item.content)
              .fontSize(14)
              .fontColor('#7A4A5E')
              .lineHeight(22)

            Row() {
              Text('👍 ' + item.likes)
                .fontSize(12)
                .fontColor('#C084FC')
              Text('💬 ' + item.comments)
                .fontSize(12)
                .fontColor('#C084FC')
                .margin({ left: 16 })
              Text('').layoutWeight(1)
              Text('查看详情 ›')
                .fontSize(12)
                .fontColor('#EC4899')
            }
            .width('100%')
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .onClick(() => {
            this.showFeedDetail(item)
          })
        }, (item: FeedItem) => 'f' + item.id)

美妆动态列表是首页的核心内容。ForEach 遍历 this.feeds 数组,为每条动态生成一个卡片 Column。卡片内部结构为:用户信息行(头像 + 昵称/时间 + 右侧装饰图标)、正文内容、底部互动数据行(点赞数 + 评论数 + 查看详情链接)。

每个卡片的 onClick 事件调用了 this.showFeedDetail(item),即将被点击的动态数据对象传递给父组件的回调函数。父组件接收到回调后,将 pickedFeed 设为该对象、showFeedDetail 设为 true,框架自动渲染动态详情弹窗。这种"点击列表项 -> 传递数据 -> 打开弹窗"的交互流程是内容类应用的经典模式。

技术概念强调: ForEach 的第三个参数——键值生成函数 (item: FeedItem) => 'f' + item.id——是列表高效更新的关键。每个列表项的键值必须唯一且稳定(同一数据项在不同渲染轮次中生成相同的键值)。当数组内容变化时,框架通过比对前后两轮的键值列表,精确判断哪些项是新增、哪些是删除、哪些是移动,然后只对变化的项执行 DOM 操作,避免全量重建。在本例中,使用 'f' + item.id 作为键值,确保每条动态有唯一的标识。

4.18 教程内容组件:双列卡片布局

@Component
struct MakeContent {
  @Prop makes: MakeItem[]
  @Prop catSel: number
  showMakeDetail: (item: MakeItem) => void = (item: MakeItem) => {}
  showMakeEdit: (item: MakeItem) => void = (item: MakeItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        // 分类横滑
        Scroll() {
          Row({ space: 8 }) {
            ForEach(CATS, (c: string, i: number) => {
              Text(c)
                .fontSize(12)
                .fontColor(this.catSel === i ? '#FFFFFF' : '#C084FC')
                .backgroundColor(this.catSel === i ? '#EC4899' : 'rgba(236,72,153,0.1)')
                .borderRadius(14)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            }, (c: string, i: number) => 'sc' + i)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

        // 教程横幅
        Row() {
          Text('🎬 精选教程')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A2333')
          Text('').layoutWeight(1)
          Text('共 ' + this.makes.length + ' 款')
            .fontSize(12)
            .fontColor('#C084FC')
        }
        .width('100%')

        // 双列教程卡片
        ForEach([0, 2, 4, 6, 8], (i: number) => {
          Row({ space: 10 }) {
            Column({ space: 6 }) {
              Row() {
                Text('🎬')
                  .fontSize(22)
                Text('').layoutWeight(1)
                Text(this.makes[i].state === 0 ? '热推中' : '已下架')
                  .fontSize(10)
                  .fontColor(makeStateColor(this.makes[i].state))
                  .backgroundColor('rgba(236,72,153,0.1)')
                  .borderRadius(10)
                  .padding({ left: 8, right: 8, top: 2, bottom: 2 })
              }
              .width('100%')

              Text(this.makes[i].name)
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A2333')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width('100%')

              Text(this.makes[i].kind + ' · ' + this.makes[i].mins + ' 分钟')
                .fontSize(11)
                .fontColor('#C084FC')
                .width('100%')

              Row() {
                Text('🔥 热度 ' + this.makes[i].heat)
                  .fontSize(11)
                  .fontColor('#EC4899')
                Text('').layoutWeight(1)
                Text(this.makes[i].level === 3 ? '高级' : (this.makes[i].level === 2 ? '进阶' : '入门'))
                  .fontSize(11)
                  .fontColor('#D97706')
              }
              .width('100%')

              Row({ space: 6 }) {
                Text('详情')
                  .textAlign(TextAlign.Center)
                  .layoutWeight(1)
                  .height(30)
                  .backgroundColor('rgba(236,72,153,0.12)')
                  .borderRadius(6)
                  .fontSize(12)
                  .fontColor('#EC4899')
                  .onClick(() => {
                    this.showMakeDetail(this.makes[i])
                  })
                Text('编辑')
                  .textAlign(TextAlign.Center)
                  .layoutWeight(1)
                  .height(30)
                  .backgroundColor('rgba(192,132,252,0.15)')
                  .borderRadius(6)
                  .fontSize(12)
                  .fontColor('#C084FC')
                  .onClick(() => {
                    this.showMakeEdit(this.makes[i])
                  })
              }
              .width('100%')
            }
            .layoutWeight(1)
            .padding(10)
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .alignItems(HorizontalAlign.Start)

            // 右列卡片(结构同左列,索引为 i+1)
            Column({ space: 6 }) {
              // ... 与左列结构完全一致,使用 this.makes[i + 1]
            }
            .layoutWeight(1)
            .padding(10)
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
        }, (i: number) => 'mrow' + i)

教程内容组件实现了一个双列卡片网格布局。ForEach 的数据源是一个奇数索引数组 [0, 2, 4, 6, 8],每次迭代生成一个 Row,其中包含左右两个 Column 卡片。左卡片使用 this.makes[i](如索引 0),右卡片使用 this.makes[i + 1](如索引 1)。两个卡片都设置了 layoutWeight(1),平分 Row 的宽度。这种通过奇数索引遍历实现双列布局的方式虽然需要手动控制索引边界,但在数据量固定的场景下简单有效。

每个教程卡片内部包含:图标行(教程图标 + 状态标签)、教程名称(使用 maxLines(1)textOverflow({ overflow: TextOverflow.Ellipsis }) 实现单行截断省略号)、分类和时长信息、热度和难度信息行、"详情"和"编辑"双按钮行。

技术概念强调: maxLines 设置文本的最大行数,textOverflow 配合 TextOverflow.Ellipsis 在文本超出限定行数时自动截断并显示省略号。这在列表卡片场景中非常实用——卡片宽度有限,教程名称可能很长,单行截断省略号既能保持卡片高度一致,又能提示用户还有更多内容。如果不使用这些属性,长文本会自动换行导致卡片高度参差不齐。

"详情"和"编辑"两个按钮使用 Text 组件模拟,每个按钮设置 layoutWeight(1) 平分宽度,height(30) 固定高度,通过 backgroundColorborderRadius 设置不同颜色的圆角背景。"详情"按钮调用 this.showMakeDetail(this.makes[i]),"编辑"按钮调用 this.showMakeEdit(this.makes[i]),分别触发父组件的详情弹窗和编辑弹窗逻辑。

4.19 直播内容组件:状态指示与列表渲染

@Component
struct LiveContent {
  @Prop lives: LiveItem[]
  showLiveRule: () => void = () => {}
  showLiveDetail: (item: LiveItem) => void = (item: LiveItem) => {}
  showLiveAdd: () => void = () => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        // 3 指标横幅
        Row({ space: 10 }) {
          Column({ space: 4 }) {
            Text('9.6 亿')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#EC4899')
            Text('累计观看')
              .fontSize(11)
              .fontColor('#C084FC')
          }
          .layoutWeight(1)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor('#FFFFFF')
          .borderRadius(10)
          .justifyContent(FlexAlign.Center)

          Column({ space: 4 }) {
            Text('12.8 万')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#C084FC')
            Text('开播场次')
              .fontSize(11)
              .fontColor('#C084FC')
          }
          .layoutWeight(1)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor('#FFFFFF')
          .borderRadius(10)
          .justifyContent(FlexAlign.Center)

          Column({ space: 4 }) {
            Text('4.5 万')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#D97706')
            Text('粉丝打赏')
              .fontSize(11)
              .fontColor('#C084FC')
          }
          .layoutWeight(1)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor('#FFFFFF')
          .borderRadius(10)
          .justifyContent(FlexAlign.Center)
        }
        .width('100%')

直播内容组件的顶部是一个三等分指标横幅,使用 Row 包含三个 Column,每个 Column 通过 layoutWeight(1) 平分宽度,内部纵向排列数字和说明文字。三个指标分别使用玫粉色、雾紫色和裸金色作为数字颜色,形成色彩区分。

        // 直播列表
        ForEach(this.lives, (item: LiveItem) => {
          Column({ space: 8 }) {
            Row() {
              Text(item.state === 0 ? '🔴' : '⚫')
                .fontSize(18)
              Column({ space: 2 }) {
                Text(item.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#4A2333')
                Text(item.title)
                  .fontSize(11)
                  .fontColor('#C084FC')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 8 })
              Text('').layoutWeight(1)
              Text(liveStateText(item.state))
                .fontSize(10)
                .fontColor(liveStateColor(item.state))
                .backgroundColor(liveStateColor(item.state) === '#C9A0B4' ? 'rgba(201,160,180,0.2)' : 'rgba(236,72,153,0.12)')
                .borderRadius(10)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)

            Row() {
              Text('👥 ' + item.fans + ' 粉丝')
                .fontSize(11)
                .fontColor('#C9A0B4')
              Text('').layoutWeight(1)
              Text('👀 ' + playsText(item.watch))
                .fontSize(11)
                .fontColor('#D97706')
            }
            .width('100%')

            Row() {
              Text('进入直播间')
                .textAlign(TextAlign.Center)
                .width('100%')
                .height(32)
                .backgroundColor(item.state === 0 ? '#EC4899' : 'rgba(201,160,180,0.3)')
                .borderRadius(8)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(item.state === 0 ? '#FFFFFF' : '#C9A0B4')
                .onClick(() => {
                  this.showLiveDetail(item)
                })
            }
            .width('100%')
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .onClick(() => {
            this.showLiveDetail(item)
          })
        }, (item: LiveItem) => 'l' + item.id)

直播列表中每个卡片的第一个图标通过 item.state === 0 ? '🔴' : '⚫' 动态选择——正在直播的显示红色圆点,已结束的显示黑色圆点。状态标签的文字和颜色分别通过 liveStateTextliveStateColor 函数映射,背景色通过进一步的条件判断区分正常态和异常态。"进入直播间"按钮的背景色和文字色也根据直播状态动态变化——正在直播时为玫粉色底白字(可点击态),已结束时为半透明灰底灰字(不可点击态)。

技术概念强调: 在 ArkUI 中,组件的样式属性(如 backgroundColorfontColorheight 等)都可以接受三元表达式的动态值。这使得同一套 UI 代码可以根据数据状态渲染出不同的视觉效果,无需编写多套模板。例如本例中的"进入直播间"按钮,通过 item.state === 0 ? '#EC4899' : 'rgba(201,160,180,0.3)' 一行表达式就实现了可用态和禁用态的视觉区分。

4.20 课程内容组件:进度条实现

@Component
struct CourseContent {
  @Prop courses: CourseItem[]
  showCourseDetail: (item: CourseItem) => void = (item: CourseItem) => {}
  showCourseSign: (item: CourseItem) => void = (item: CourseItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        // 课程横幅
        Column({ space: 8 }) {
          Row() {
            Text('📖 美妆小班课')
              .fontSize(11)
              .fontColor('#4A2333')
              .backgroundColor('rgba(192,132,252,0.2)')
              .borderRadius(20)
              .padding({ left: 10, right: 10, top: 3, bottom: 3 })
            Text('').layoutWeight(1)
            Text('在开 ' + this.courses.length + ' 门')
              .fontSize(11)
              .fontColor('#C084FC')
          }
          .width('100%')

          Text('跟着认证美妆师系统学')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A2333')
          Text('直播精讲 + 录播回放 + 作业点评,结课颁发认证证书。')
            .fontSize(12)
            .fontColor('#C084FC')
            .lineHeight(20)
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .border({ width: 1, color: '#C084FC' })

        // 课程列表
        ForEach(this.courses, (item: CourseItem) => {
          Column({ space: 8 }) {
            Row() {
              Text('📖')
                .fontSize(22)
              Column({ space: 2 }) {
                Text(item.name)
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#4A2333')
                Text(item.teacher + ' · 共 ' + item.lessons + ' 节')
                  .fontSize(11)
                  .fontColor('#C084FC')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 8 })
              Text('').layoutWeight(1)
              Text(courseStateText(item.state))
                .fontSize(10)
                .fontColor(courseStateColor(item.state))
                .backgroundColor('rgba(192,132,252,0.12)')
                .borderRadius(10)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)

            // 席位进度条
            Row() {
              Row()
                .width((item.quota * 4) + '%')
                .height(8)
                .backgroundColor(item.state === 0 ? '#EC4899' : '#C9A0B4')
                .borderRadius(4)
            }
            .width('100%')
            .height(8)
            .backgroundColor('rgba(236,72,153,0.15)')
            .borderRadius(4)

            Row() {
              Text('剩余席位 ' + item.quota + ' 个')
                .fontSize(11)
                .fontColor('#C9A0B4')
              Text('').layoutWeight(1)
              Text('详情 ›')
                .fontSize(12)
                .fontColor('#C084FC')
                .onClick(() => {
                  this.showCourseDetail(item)
                })
              Text('报名')
                .fontSize(12)
                .fontColor(item.state === 0 ? '#EC4899' : '#C9A0B4')
                .margin({ left: 14 })
                .onClick(() => {
                  if (item.state === 0) {
                    this.showCourseSign(item)
                  }
                })
            }
            .width('100%')
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .onClick(() => {
            this.showCourseDetail(item)
          })
        }, (item: CourseItem) => 'c' + item.id)

课程列表中每个卡片最独特的部分是"席位进度条"。进度条由两层 Row 组成:外层 Row 是背景轨道,宽度 100%、高度 8vp、背景色为半透明粉色(rgba(236,72,153,0.15))、圆角 4vp;内层 Row 是进度填充条,宽度通过 (item.quota * 4) + '%' 动态计算——剩余席位越多,进度条越长。当席位为 20 时,进度条宽度为 80%;当席位为 5 时,进度条宽度为 20%;当席位为 0 时,进度条宽度为 0%(不可见)。填充条的颜色也根据课程状态动态变化——报名中时为玫粉色,已报满时为灰粉色。

技术概念强调: 在 ArkUI 中实现进度条不需要使用 Progress 组件,通过两层 Row(或 Column)的嵌套和动态宽度属性即可手工实现。外层 Row 作为轨道容器设置背景色,内层 Row 作为填充条设置动态宽度和前景色。这种方式的灵活性远高于 Progress 组件,可以自由定制圆角、颜色、动画等视觉效果。Progress 组件虽然使用更简单,但定制能力有限。

"报名"按钮的 onClick 逻辑中有一个条件判断 if (item.state === 0),只有课程状态为"报名中"时才调用 this.showCourseSign(item) 弹出报名确认弹窗。如果课程已报满(state === 1),点击报名按钮不会有任何反应。这是一种简单但有效的防误操作设计。同时,报名按钮的文字颜色也通过三元表达式区分状态——报名中时为玫粉色(可点击视觉提示),已报满时为灰粉色(不可点击视觉提示)。

4.21 达人内容组件:排名列表

@Component
struct StarContent {
  @Prop stars: StarItem[]
  showStarRule: () => void = () => {}
  showStarDetail: (item: StarItem) => void = (item: StarItem) => {}
  showStarSign: (item: StarItem) => void = (item: StarItem) => {}
  showStarQuit: (item: StarItem) => void = (item: StarItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        // 达人榜横幅
        Column({ space: 8 }) {
          Row() {
            Text('👑 达人荣耀榜')
              .fontSize(11)
              .fontColor('#4A2333')
              .backgroundColor('rgba(217,119,6,0.18)')
              .borderRadius(20)
              .padding({ left: 10, right: 10, top: 3, bottom: 3 })
            Text('').layoutWeight(1)
            Text('每月更新')
              .fontSize(11)
              .fontColor('#C084FC')
          }
          .width('100%')

          Text('美妆达人 · 签约合作')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A2333')
          Text('榜上达人经平台认证,签约合作名额有限。')
            .fontSize(12)
            .fontColor('#C084FC')
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .border({ width: 1, color: '#D97706' })

        // 达人列表
        ForEach(this.stars, (item: StarItem, index: number) => {
          Row() {
            Text('№' + (index + 1))
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(index === 0 ? '#D97706' : (index < 3 ? '#EC4899' : '#C9A0B4'))
              .width(36)

            Column({ space: 4 }) {
              Text(item.name)
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A2333')
              Text(item.field + ' · 荣誉 ' + item.honor)
                .fontSize(11)
                .fontColor('#C084FC')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Column({ space: 4 }) {
              Text(starStateText(item.state))
                .fontSize(10)
                .fontColor(starStateColor(item.state))
                .backgroundColor('rgba(217,119,6,0.12)')
                .borderRadius(10)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
              Text('剩余 ' + item.quota + ' 席')
                .fontSize(10)
                .fontColor('#C9A0B4')
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .alignItems(VerticalAlign.Center)
          .onClick(() => {
            this.showStarDetail(item)
          })
        }, (item: StarItem, index: number) => 's' + item.id)

达人列表使用了 ForEach 的第二个参数(索引值)来实现排名编号。每个卡片的左侧是一个排名编号 Text('№' + (index + 1)),排名颜色通过嵌套三元表达式区分:第一名(index === 0)为裸金色,前三名(index < 3)为玫粉色,其余为灰粉色。这种颜色编码让用户一眼就能识别出排名层级。

卡片中部是达人名称和领域信息(layoutWeight(1) 占据剩余空间),右侧是状态标签和剩余名额信息(alignItems(HorizontalAlign.End) 右对齐)。整个卡片的 onClick 调用 showStarDetail 弹出达人详情弹窗。

技术概念强调: ForEach 的子项生成函数可以接收第二个参数 index(索引值),在需要排名编号、序号显示等场景中非常实用。但需要注意,index 在数组增删后会发生变化,不能用作键值生成函数的唯一标识。键值应始终使用数据本身的唯一标识(如 item.id),而非索引值。在本文代码中,键值生成函数 (item, index) => 's' + item.id 正确使用了 item.id 而非 index 作为键值。

4.22 我的内容组件:收藏管理与设置

@Component
struct MeContent {
  @Prop mys: MyItem[]
  showMyRemove: (item: MyItem) => void = (item: MyItem) => {}
  showMyNick: () => void = () => {}
  showMyCache: () => void = () => {}
  showMyExit: () => void = () => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        // 美妆师档案卡
        Column({ space: 8 }) {
          Row() {
            Text('💄')
              .fontSize(40)
            Column({ space: 3 }) {
              Text('美妆师小糖')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A2333')
              Text('🎖️ Lv.15 认证美妆师')
                .fontSize(12)
                .fontColor('#D97706')
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 })
            Text('').layoutWeight(1)
            Text('✏️')
              .fontSize(18)
              .fontColor('#EC4899')
              .onClick(() => {
                this.showMyNick()
              })
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)

          Row() {
            Text('心得 216 条')
              .fontSize(12)
              .fontColor('#C084FC')
            Text('')
              .fontSize(12)
              .layoutWeight(1)
            Text('粉丝 4.2 万')
              .fontSize(12)
              .fontColor('#C084FC')
          }
          .width('100%')
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .border({ width: 1, color: '#F9A8D4' })

我的内容组件顶部是用户档案卡,展示头像、昵称、等级认证信息和创作数据。档案卡右侧有一个编辑图标,点击后调用 showMyNick() 弹出修改昵称弹窗。档案卡下方是统计数据行,使用 Text('').layoutWeight(1) 占位实现两端对齐。

        // 四宫格
        Row({ space: 10 }) {
          Column({ space: 4 }) {
            Text('📊')
              .fontSize(24)
            Text('创作数据')
              .fontSize(11)
              .fontColor('#C084FC')
          }
          .layoutWeight(1)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor('#FFFFFF')
          .borderRadius(10)
          .justifyContent(FlexAlign.Center)

          Column({ space: 4 }) {
            Text('🎁')
              .fontSize(24)
            Text('试用礼包')
              .fontSize(11)
              .fontColor('#C084FC')
          }
          .layoutWeight(1)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor('#FFFFFF')
          .borderRadius(10)
          .justifyContent(FlexAlign.Center)

          Column({ space: 4 }) {
            Text('📚')
              .fontSize(24)
            Text('我的课程')
              .fontSize(11)
              .fontColor('#C084FC')
          }
          .layoutWeight(1)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor('#FFFFFF')
          .borderRadius(10)
          .justifyContent(FlexAlign.Center)

          Column({ space: 4 }) {
            Text('🏅')
              .fontSize(24)
            Text('我的成就')
              .fontSize(11)
              .fontColor('#C084FC')
          }
          .layoutWeight(1)
          .padding({ top: 14, bottom: 14 })
          .backgroundColor('#FFFFFF')
          .borderRadius(10)
          .justifyContent(FlexAlign.Center)
        }
        .width('100%')

四宫格快捷入口使用 Row 包含四个 Column,每个 Column 通过 layoutWeight(1) 等分宽度,内部纵向排列图标和文字标签。justifyContent(FlexAlign.Center) 让内容在 Column 内居中对齐。这四个快捷入口分别对应创作数据、试用礼包、我的课程和我的成就。

技术概念强调: 四宫格布局是移动端"我的"页面的经典布局模式。使用 Row + layoutWeight(1) 的组合可以实现任意等分的横向网格。如果需要更复杂的网格(如 3x3),可以嵌套多行 Row,或使用 Grid/GridItem 组件。layoutWeight(1) 的等分效果类似于 CSS Flexbox 中的 flex: 1

收藏列表使用 ForEach 遍历 this.mys 数组,每条收藏记录显示为一个 Row,包含图标、名称(单行截断)、收藏时间、分类标签和删除按钮。删除按钮的 onClick 调用 showMyRemove(item) 弹出移除确认弹窗。

设置项区域包含"清理缓存"和"退出登录"两个条目,每个条目是一个带 onClick 事件的 Row,点击后分别调用 showMyCache()showMyExit() 弹出对应的确认弹窗。

五、应用架构流程图

以下是整个应用的 Tab 切换与弹窗触发流程图。

点击Tab 0

点击Tab 1

点击Tab 2

点击Tab 3

点击Tab 4

点击Tab 5

点击列表项/按钮

点击列表项/按钮

点击列表项/按钮

点击列表项/按钮

点击列表项/按钮

点击列表项/按钮

showXxx = true

showXxx = false

用户操作: 确认/取消

用户启动应用

渲染 Index 入口组件

Stack 层叠布局

主体 Column: 头部 + 内容区 + Tab栏

弹窗层: 条件渲染

底部 Tab 栏: 6个 tabItem

currentTab = 0

currentTab = 1

currentTab = 2

currentTab = 3

currentTab = 4

currentTab = 5

HomeContent 首页

MakeContent 教程

LiveContent 直播

CourseContent 课程

StarContent 达人

MeContent 我的

渲染模态弹窗

销毁弹窗 UI

以下是数据流动与状态更新的流程图。

视图层

逻辑层

数据层

初始化赋值

Prop传递

Prop传递

Prop传递

Prop传递

Prop传递

Prop传递

回调函数

回调函数

回调函数

回调函数

回调函数

回调函数

回调函数

回调函数

回调函数

回调函数

回调函数

回调函数

true时条件渲染

提供数据

双向绑定

用户操作: 发布/编辑/报名

用户操作: 报名

新对象

新对象

映射文本/颜色

映射文本/颜色

映射文本/颜色

映射文本/颜色

映射文本/颜色

格式化数字

格式化数字

格式化数字

静态常量: FEEDS/MAKES/LIVES等

State数组: feeds/makes/lives等

State选中项: pickedFeed等

State弹窗开关: showXxx系列

State输入框: newFeedName等

工厂函数: buildFeed/buildMake等

状态映射: makeStateText/Color等

格式化: playsText/heatBar等

业务逻辑: buildCourseSign等

Index 入口组件

HomeContent 子组件

MakeContent 子组件

LiveContent 子组件

CourseContent 子组件

StarContent 子组件

MeContent 子组件

六、核心概念与技术要点对比

以下是本文涉及的所有核心数据结构、组件、状态变量、装饰器和工具函数的对比表格。

类别名称类型/签名作用说明典型用法
装饰器@Entry修饰 struct标记页面入口组件,每页仅一个@Entry struct Index
装饰器@Component修饰 struct声明自定义组件,拥有独立 build 方法@Component struct HomeContent
装饰器@State修饰变量组件内部状态,变化时自动触发 UI 更新@State currentTab: number = 0
装饰器@Prop修饰变量子组件接收父组件数据的只读拷贝@Prop feeds: FeedItem[]
装饰器@Builder修饰方法定义可复用的 UI 构建片段@Builder tabItem(...)
容器组件Stack布局容器层叠布局,子组件可叠放覆盖Stack({ alignContent: Alignment.Bottom })
容器组件Column布局容器纵向线性布局,子组件从上到下排列Column({ space: 12 })
容器组件Row布局容器横向线性布局,子组件从左到右排列Row({ space: 10 })
容器组件Scroll滚动容器包裹可滚动内容,支持垂直/水平滚动Scroll() { ... }.scrollable(ScrollDirection.Vertical)
基础组件Text显示组件显示文本内容,支持字体/颜色/粗体等样式Text('BEAUTY LAB').fontSize(16)
基础组件TextInput输入组件接收用户键盘输入,支持 placeholder 和 onChangeTextInput({ placeholder: '昵称' })
布局属性layoutWeight属性方法按权重分配父容器剩余空间Text('').layoutWeight(1)
布局属性justifyContent属性方法设置主轴方向对齐方式.justifyContent(FlexAlign.Center)
布局属性alignItems属性方法设置交叉轴方向对齐方式.alignItems(VerticalAlign.Center)
布局属性linearGradient属性方法设置线性渐变背景.linearGradient({ angle: 180, colors: [...] })
布局属性borderRadius属性方法设置圆角半径.borderRadius(14)
布局属性position属性方法绝对定位,脱离文档流.position({ x: 0, y: 0 })
布局属性zIndex属性方法控制层叠顺序,值大者在上方.zIndex(999)
动画属性scale属性方法设置 X/Y 方向缩放比例.scale({ x: 1.12, y: 1.12 })
动画属性animation属性方法为属性变化添加过渡动画.animation({ duration: 200, curve: Curve.EaseOut })
动画APIanimateTo全局函数闭包式动画,在闭包内修改状态触发动画animateTo({ duration: 300 }, () => { this.x = 100 })
渲染指令ForEach渲染函数遍历数组生成列表 UI,支持键值追踪ForEach(this.feeds, (item) => {...}, (item) => 'f' + item.id)
渲染指令if条件渲染根据条件表达式创建/销毁 UI 节点if (this.showFeedDetail) { ... }
文本属性maxLines属性方法设置文本最大行数.maxLines(1)
文本属性textOverflow属性方法设置文本溢出处理方式.textOverflow({ overflow: TextOverflow.Ellipsis })
文本属性letterSpacing属性方法设置字符间距.letterSpacing(2)
文本属性lineHeight属性方法设置行高.lineHeight(24)
数据接口FeedIteminterface美妆社区动态数据模型{ id, name, avatar, time, content, likes, comments }
数据接口MakeIteminterface美妆教程数据模型{ id, name, kind, mins, heat, level, state }
数据接口LiveIteminterface直播数据模型{ id, name, title, fans, watch, state }
数据接口CourseIteminterface课程数据模型{ id, name, teacher, lessons, quota, state }
数据接口StarIteminterface达人数据模型{ id, name, field, honor, quota, state }
数据接口MyIteminterface收藏数据模型{ id, name, tag, time }

七、总结与深度思考

本文从一个完整的美妆社区应用代码出发,全面解析了鸿蒙 ArkTS 声明式 UI 开发的核心知识体系和实践方法。从数据模型的接口定义,到静态数据集的构建,从工具函数的状态映射逻辑,到主入口组件的状态管理架构,从 @Builder 方法的 UI 片段复用,到 Stack 层叠布局的弹窗实现,从 @Prop 单向数据流的父子组件通信,到 ForEach 列表渲染的键值追踪机制,每一个环节都体现了 ArkUI 框架的设计哲学——以声明式语法描述界面,以状态驱动界面更新,以组件化拆分复杂度。

在数据建模层面,代码使用了六个 interface 来定义应用的核心数据实体,每个 interface 的字段都有明确的类型标注,体现了 ArkTS 强类型语言的优势。工厂函数模式(buildFeed、buildMake 等)确保了数据对象的构造过程集中化管理,所有必填字段都在函数签名中强制要求。状态映射函数(makeStateText、makeStateColor 等)将数值状态编码映射为可读文本和颜色值,实现了数据与展示的解耦。不可变数据更新模式(buildCourseSign 构造新对象而非修改原对象)保证了 ArkUI 状态追踪的正确性。

在组件架构层面,代码采用了"一个入口组件 + 六个子组件"的分层结构。Index 入口组件通过 @State 管理所有状态变量,通过 @Builder 实现 Tab 标签项的 UI 复用,通过 if 条件渲染实现 Tab 页切换和弹窗显示/隐藏。六个子组件(HomeContent、MakeContent、LiveContent、CourseContent、StarContent、MeContent)各自负责一个 Tab 页的内容渲染,通过 @Prop 接收父组件传递的数据,通过回调函数将用户交互事件回传给父组件。这种单向数据流的架构模式确保了状态变化的可追溯性,每一处 UI 更新都能追溯到对应的状态变量赋值。

在 UI 布局层面,代码充分运用了 ArkUI 的容器组件和布局属性。Column 和 Row 的嵌套组合构建了从页面骨架到卡片内部的所有布局结构。layoutWeight 的弹性空间分配实现了等分网格(四宫格)、两端对齐(标题行)和自适应宽度(按钮组)。Stack 的层叠布局配合条件渲染实现了无需 CustomDialog API 的纯声明式模态弹窗。Scroll 的滚动容器包裹了所有可滚动内容区,配合 scrollable 和 scrollBar 属性控制滚动方向和滚动条显示。linearGradient 实现了品牌横幅的粉紫渐变背景,borderRadius 实现了卡片和按钮的圆角效果。


安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// 粉紫梦幻风:bg #FDF2F8 / 面板 #FFFFFF / 玫粉 #EC4899 / 雾紫 #C084FC / 裸金 #D97706 / 深字 #4A2333

interface FeedItem {
  id: number
  name: string
  avatar: string
  time: string
  content: string
  likes: number
  comments: number
}

interface MakeItem {
  id: number
  name: string
  kind: string
  mins: number
  heat: number
  level: number
  state: number
}

interface LiveItem {
  id: number
  name: string
  title: string
  fans: number
  watch: number
  state: number
}

interface CourseItem {
  id: number
  name: string
  teacher: string
  lessons: number
  quota: number
  state: number
}

interface StarItem {
  id: number
  name: string
  field: string
  honor: number
  quota: number
  state: number
}

interface MyItem {
  id: number
  name: string
  tag: string
  time: string
}

const FEEDS: FeedItem[] = [
  { id: 1, name: '甜心化妆师', avatar: '💄', time: '12分钟前', content: '粉底液测评第 21 期:油皮亲妈款 vs 干皮救星款,实测 8 小时带妆不脱不卡,底妆选对肤质真的很重要!', likes: 587, comments: 92 },
  { id: 2, name: '眼妆小妖', avatar: '👁️', time: '35分钟前', content: '肿眼泡必学的倒钩眼线教程来了,眼尾一勾立刻深邃,教程拆解了三遍,新手也能画得干净利落。', likes: 463, comments: 71 },
  { id: 3, name: '唇色研究所', avatar: '💋', time: '1小时前', content: '秋冬奶茶色系唇釉合集试色,黄皮显白天花板,雾面和镜面两种质地一次看全。', likes: 421, comments: 58 },
  { id: 4, name: '护肤课代表', avatar: '🧴', time: '2小时前', content: '换季敏感肌急救三步:修护面霜打底、精简护肤、防晒不能停,红脸一晚上就能压下去。', likes: 512, comments: 76 },
  { id: 5, name: '底妆工厂', avatar: '🎨', time: '4小时前', content: '遮瑕膏的三个神仙用法:泪沟提亮、黑眼圈校色、痘痘局部遮盖,一把刷子全搞定!', likes: 396, comments: 54 },
  { id: 6, name: '卷发精灵', avatar: '🌀', time: '6小时前', content: '慵懒大波浪卷发教程,28mm 卷棒全流程拆解,分束缠绕不炸毛,手残党也能卷出理发店效果。', likes: 448, comments: 63 },
  { id: 7, name: '美甲艺术家', avatar: '💅', time: '昨天', content: '猫眼美甲翻车补救指南,磁铁角度不对怎么救?附 5 款新手必入色号清单。', likes: 335, comments: 47 },
  { id: 8, name: '香水猎人', avatar: '🌸', time: '昨天', content: '木质调香水天花板盘点,雪松、檀香、琥珀三种调性,秋冬氛围感直接拉满。', likes: 529, comments: 84 }
]

const MAKES: MakeItem[] = [
  { id: 1, name: '奶油肌底妆全攻略', kind: '底妆', mins: 8, heat: 98, level: 1, state: 0 },
  { id: 2, name: '倒钩眼线画法', kind: '眼妆', mins: 6, heat: 95, level: 2, state: 0 },
  { id: 3, name: '奶茶唇釉试色', kind: '唇妆', mins: 5, heat: 93, level: 1, state: 0 },
  { id: 4, name: '修容高光手法', kind: '底妆', mins: 9, heat: 91, level: 2, state: 0 },
  { id: 5, name: '假睫毛粘贴技巧', kind: '眼妆', mins: 7, heat: 89, level: 1, state: 0 },
  { id: 6, name: '换季敏感肌护理', kind: '护肤', mins: 10, heat: 96, level: 1, state: 0 },
  { id: 7, name: '欧美截断式眼妆', kind: '眼妆', mins: 12, heat: 88, level: 3, state: 0 },
  { id: 8, name: '果冻唇教程', kind: '唇妆', mins: 6, heat: 90, level: 1, state: 0 },
  { id: 9, name: '骨相修容进阶', kind: '底妆', mins: 11, heat: 87, level: 3, state: 0 },
  { id: 10, name: '晨间通勤妆', kind: '底妆', mins: 5, heat: 92, level: 1, state: 0 }
]

const LIVES: LiveItem[] = [
  { id: 1, name: '甜心化妆师', title: '实测 8 小时不脱妆粉底', fans: 3200, watch: 820000, state: 0 },
  { id: 2, name: '眼妆小妖', title: '倒钩眼线细节教学', fans: 2100, watch: 660000, state: 0 },
  { id: 3, name: '唇色研究所', title: '秋冬口红新品开箱', fans: 1800, watch: 540000, state: 0 },
  { id: 4, name: '护肤课代表', title: '换季敏感肌在线答疑', fans: 1400, watch: 430000, state: 0 },
  { id: 5, name: '底妆工厂', title: '遮瑕膏神仙用法专场', fans: 2600, watch: 710000, state: 0 },
  { id: 6, name: '卷发精灵', title: '慵懒卷发实操回放', fans: 950, watch: 320000, state: 1 },
  { id: 7, name: '美甲艺术家', title: '猫眼美甲教程回放', fans: 760, watch: 280000, state: 1 },
  { id: 8, name: '香水猎人', title: '木质调香水盘点', fans: 1100, watch: 370000, state: 0 }
]

const COURSES: CourseItem[] = [
  { id: 1, name: '新手底妆必修课', teacher: '甜心老师', lessons: 8, quota: 20, state: 0 },
  { id: 2, name: '眼线眼影进阶班', teacher: '小妖老师', lessons: 10, quota: 15, state: 0 },
  { id: 3, name: '口红试色训练营', teacher: '研究所老师', lessons: 6, quota: 0, state: 1 },
  { id: 4, name: '敏感肌护肤课', teacher: '课代表老师', lessons: 12, quota: 25, state: 0 },
  { id: 5, name: '修容高光实战班', teacher: '底妆工厂老师', lessons: 9, quota: 18, state: 0 },
  { id: 6, name: '卷发造型课', teacher: '卷发精灵老师', lessons: 7, quota: 0, state: 1 },
  { id: 7, name: '美甲新手营', teacher: '美甲艺术家老师', lessons: 8, quota: 12, state: 0 },
  { id: 8, name: '香水搭配课', teacher: '香水猎人老师', lessons: 5, quota: 10, state: 0 }
]

const STARS: StarItem[] = [
  { id: 1, name: '甜心化妆师', field: '底妆测评', honor: 98, quota: 6, state: 0 },
  { id: 2, name: '眼妆小妖', field: '眼妆教学', honor: 96, quota: 8, state: 0 },
  { id: 3, name: '唇色研究所', field: '唇妆试色', honor: 95, quota: 0, state: 1 },
  { id: 4, name: '护肤课代表', field: '护肤科普', honor: 93, quota: 5, state: 0 },
  { id: 5, name: '底妆工厂', field: '底妆干货', honor: 92, quota: 7, state: 0 },
  { id: 6, name: '卷发精灵', field: '发型教程', honor: 90, quota: 0, state: 1 },
  { id: 7, name: '美甲艺术家', field: '美甲创作', honor: 89, quota: 9, state: 0 },
  { id: 8, name: '香水猎人', field: '香水测评', honor: 94, quota: 4, state: 0 }
]

const MYS: MyItem[] = [
  { id: 1, name: '奶油肌底妆全攻略', tag: '底妆', time: '收藏于 08-25' },
  { id: 2, name: '倒钩眼线画法', tag: '眼妆', time: '收藏于 08-23' },
  { id: 3, name: '奶茶唇釉试色合集', tag: '唇妆', time: '收藏于 08-20' },
  { id: 4, name: '换季敏感肌护理', tag: '护肤', time: '收藏于 08-17' },
  { id: 5, name: '慵懒大波浪卷发', tag: '发妆', time: '收藏于 08-14' },
  { id: 6, name: '猫眼美甲教程', tag: '美甲', time: '收藏于 08-11' },
  { id: 7, name: '木质调香水盘点', tag: '香水', time: '收藏于 08-08' },
  { id: 8, name: '晨间通勤妆', tag: '底妆', time: '收藏于 08-05' }
]

const HEAT: number[] = [6, 9, 8, 7, 10, 9, 11, 8]

const CATS: string[] = ['全部', '底妆', '眼妆', '唇妆', '护肤', '发妆']

function heatBar(v: number): string {
  return (12 + v * 4) + 'vp'
}

function playsText(p: number): string {
  if (p >= 10000) {
    return (p / 10000).toFixed(1) + '万'
  }
  return p.toString()
}

function makeStateText(s: number): string {
  if (s === 0) {
    return '热推中'
  }
  return '已下架'
}

function makeStateColor(s: number): string {
  if (s === 0) {
    return '#EC4899'
  }
  return '#C9A0B4'
}

function liveStateText(s: number): string {
  if (s === 0) {
    return '直播中'
  }
  return '已结束'
}

function liveStateColor(s: number): string {
  if (s === 0) {
    return '#EC4899'
  }
  return '#C9A0B4'
}

function courseStateText(s: number): string {
  if (s === 0) {
    return '报名中'
  }
  return '已报满'
}

function courseStateColor(s: number): string {
  if (s === 0) {
    return '#C084FC'
  }
  return '#C9A0B4'
}

function starStateText(s: number): string {
  if (s === 0) {
    return '签约中'
  }
  return '已满额'
}

function starStateColor(s: number): string {
  if (s === 0) {
    return '#D97706'
  }
  return '#C9A0B4'
}

function trendText(t: number): string {
  if (t > 0) {
    return '+' + t + '%'
  }
  return t + '%'
}

function trendColor(t: number): string {
  if (t >= 0) {
    return '#EC4899'
  }
  return '#C084FC'
}

function buildFeed(id: number, name: string, avatar: string, time: string, content: string, likes: number, comments: number): FeedItem {
  return { id: id, name: name, avatar: avatar, time: time, content: content, likes: likes, comments: comments }
}

function buildMy(id: number, name: string, tag: string, time: string): MyItem {
  return { id: id, name: name, tag: tag, time: time }
}

function buildMake(id: number, name: string, kind: string, mins: number, heat: number, level: number, state: number): MakeItem {
  return { id: id, name: name, kind: kind, mins: mins, heat: heat, level: level, state: state }
}

function buildLive(id: number, name: string, title: string, fans: number, watch: number, state: number): LiveItem {
  return { id: id, name: name, title: title, fans: fans, watch: watch, state: state }
}

function buildCourse(id: number, name: string, teacher: string, lessons: number, quota: number, state: number): CourseItem {
  return { id: id, name: name, teacher: teacher, lessons: lessons, quota: quota, state: state }
}

function buildStar(id: number, name: string, field: string, honor: number, quota: number, state: number): StarItem {
  return { id: id, name: name, field: field, honor: honor, quota: quota, state: state }
}

function buildCourseSign(c: CourseItem): CourseItem {
  let q = c.quota - 1
  let st = c.state
  if (q <= 0) {
    q = 0
    st = 1
  }
  return buildCourse(c.id, c.name, c.teacher, c.lessons, q, st)
}

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State feeds: FeedItem[] = FEEDS
  @State makes: MakeItem[] = MAKES
  @State lives: LiveItem[] = LIVES
  @State courses: CourseItem[] = COURSES
  @State stars: StarItem[] = STARS
  @State mys: MyItem[] = MYS
  @State pickedFeed: FeedItem | null = null
  @State pickedMake: MakeItem | null = null
  @State pickedLive: LiveItem | null = null
  @State pickedCourse: CourseItem | null = null
  @State pickedStar: StarItem | null = null
  @State pickedMy: MyItem | null = null
  @State showFeedDetail: boolean = false
  @State showFeedAdd: boolean = false
  @State showMakeDetail: boolean = false
  @State showMakeEdit: boolean = false
  @State showLiveRule: boolean = false
  @State showLiveDetail: boolean = false
  @State showLiveAdd: boolean = false
  @State showCourseDetail: boolean = false
  @State showCourseSign: boolean = false
  @State showStarRule: boolean = false
  @State showStarDetail: boolean = false
  @State showStarSign: boolean = false
  @State showStarQuit: boolean = false
  @State showMyRemove: boolean = false
  @State showMyNick: boolean = false
  @State showMyCache: boolean = false
  @State showMyExit: boolean = false
  @State newFeedName: string = ''
  @State newFeedText: string = ''
  @State newMakeName: string = ''
  @State newMakeMins: string = ''
  @State newNick: string = ''
  @State catSel: number = 0

  @Builder
  tabItem(icon: string, label: string, tab: number) {
    Column({ space: 3 }) {
      Text(icon)
        .fontSize(22)
        .fontColor(this.currentTab === tab ? '#EC4899' : '#C9A0B4')
      Text(label)
        .fontSize(11)
        .fontColor(this.currentTab === tab ? '#EC4899' : '#C9A0B4')
    }
    .width('16.66%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .scale({ x: this.currentTab === tab ? 1.12 : 1, y: this.currentTab === tab ? 1.12 : 1 })
    .animation({ duration: 200, curve: Curve.EaseOut })
    .onClick(() => {
      this.currentTab = tab
    })
  }

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column() {
        // ===== 头部:粉紫梦幻风 =====
        Column() {
          Row() {
            Row() {
              Text('💄')
                .fontSize(26)
              Column({ space: 2 }) {
                Text('BEAUTY LAB')
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#4A2333')
                Text('美妆短工坊 · 官方')
                  .fontSize(10)
                  .fontColor('#C084FC')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 8 })
            }
            .alignItems(VerticalAlign.Center)
            Text('').layoutWeight(1)
            Text('🔔')
              .fontSize(22)
              .opacity(0.85)
            Text('🔍')
              .fontSize(22)
              .margin({ left: 14 })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 10 })

          // 粉紫横幅
          Column({ space: 6 }) {
            Row() {
              Text('✨ 今日上新 86 款')
                .fontSize(11)
                .fontColor('#4A2333')
                .backgroundColor('rgba(236,72,153,0.2)')
                .borderRadius(20)
                .padding({ left: 10, right: 10, top: 3, bottom: 3 })
              Text('').layoutWeight(1)
              Text('🔴 直播中 128 场')
                .fontSize(11)
                .fontColor('#D97706')
            }
            .width('100%')

            Text('美妆短工坊')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4A2333')
              .letterSpacing(2)
            Text('BEAUTY LAB · 一分钟学会变美')
              .fontSize(11)
              .fontColor('#C084FC')
              .letterSpacing(1)

            // 花瓣符号带
            Row() {
              ForEach([13, 17, 11, 19, 14, 12, 18, 15, 16, 13], (s: number, i: number) => {
                Text(i % 2 === 0 ? '✿' : '❀')
                  .fontSize(s)
                  .fontColor(i % 3 === 0 ? '#EC4899' : (i % 3 === 1 ? '#C084FC' : '#D97706'))
                  .opacity(0.7)
              }, (s: number, i: number) => 'petal' + i)
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceBetween)
            .padding({ left: 20, right: 20 })

            // 搜索胶囊
            Row() {
              Text('🔍')
                .fontSize(14)
              Text('搜索教程、直播、课程、达人')
                .fontSize(13)
                .fontColor('#C084FC')
                .margin({ left: 6 })
            }
            .width('90%')
            .height(38)
            .backgroundColor('rgba(255,255,255,0.75)')
            .borderRadius(19)
            .padding({ left: 14 })
            .alignItems(VerticalAlign.Center)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 14 })
          .linearGradient({
            angle: 180,
            colors: [['#FCE7F3', 0.0], ['#F5D0FE', 0.55], ['#FDF2F8', 1.0]]
          })
        }
        .width('100%')
        .backgroundColor('#FFFFFF')

        // ===== 内容区 =====
        Column() {
          if (this.currentTab === 0) {
            HomeContent({
              feeds: this.feeds,
              heat: HEAT,
              catSel: this.catSel,
              showFeedDetail: (item: FeedItem) => {
                this.pickedFeed = item
                this.showFeedDetail = true
              },
              showFeedAdd: () => {
                this.showFeedAdd = true
              }
            })
          }
          if (this.currentTab === 1) {
            MakeContent({
              makes: this.makes,
              catSel: this.catSel,
              showMakeDetail: (item: MakeItem) => {
                this.pickedMake = item
                this.showMakeDetail = true
              },
              showMakeEdit: (item: MakeItem) => {
                this.pickedMake = item
                this.newMakeName = item.name
                this.newMakeMins = item.mins.toString()
                this.showMakeEdit = true
              }
            })
          }
          if (this.currentTab === 2) {
            LiveContent({
              lives: this.lives,
              showLiveRule: () => {
                this.showLiveRule = true
              },
              showLiveDetail: (item: LiveItem) => {
                this.pickedLive = item
                this.showLiveDetail = true
              },
              showLiveAdd: () => {
                this.showLiveAdd = true
              }
            })
          }
          if (this.currentTab === 3) {
            CourseContent({
              courses: this.courses,
              showCourseDetail: (item: CourseItem) => {
                this.pickedCourse = item
                this.showCourseDetail = true
              },
              showCourseSign: (item: CourseItem) => {
                this.pickedCourse = item
                this.showCourseSign = true
              }
            })
          }
          if (this.currentTab === 4) {
            StarContent({
              stars: this.stars,
              showStarRule: () => {
                this.showStarRule = true
              },
              showStarDetail: (item: StarItem) => {
                this.pickedStar = item
                this.showStarDetail = true
              },
              showStarSign: (item: StarItem) => {
                this.pickedStar = item
                this.showStarSign = true
              },
              showStarQuit: (item: StarItem) => {
                this.pickedStar = item
                this.showStarQuit = true
              }
            })
          }
          if (this.currentTab === 5) {
            MeContent({
              mys: this.mys,
              showMyRemove: (item: MyItem) => {
                this.pickedMy = item
                this.showMyRemove = true
              },
              showMyNick: () => {
                this.showMyNick = true
              },
              showMyCache: () => {
                this.showMyCache = true
              },
              showMyExit: () => {
                this.showMyExit = true
              }
            })
          }
        }
        .layoutWeight(1)
        .width('100%')

        // ===== 底部 tab 栏 =====
        Row() {
          this.tabItem('💄', '首页', 0)
          this.tabItem('🎬', '教程', 1)
          this.tabItem('🔴', '直播', 2)
          this.tabItem('📖', '课程', 3)
          this.tabItem('👑', '达人', 4)
          this.tabItem('👤', '我的', 5)
        }
        .width('100%')
        .height(58)
        .backgroundColor('#FFFFFF')
        .border({ width: { top: 1 }, color: '#F9D8E8' })
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#FDF2F8')

      // ================= 弹窗层 =================

      // 动态详情
      if (this.showFeedDetail) {
        Column() {
          Column({ space: 10 }) {
            Row() {
              Text(this.pickedFeed !== null ? this.pickedFeed.avatar : '')
                .fontSize(30)
              Column({ space: 3 }) {
                Text(this.pickedFeed !== null ? this.pickedFeed.name : '')
                  .fontSize(17)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#4A2333')
                Text(this.pickedFeed !== null ? this.pickedFeed.time : '')
                  .fontSize(11)
                  .fontColor('#C084FC')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#4A2333')
                .padding(6)
                .onClick(() => {
                  this.showFeedDetail = false
                })
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)

            Text(this.pickedFeed !== null ? this.pickedFeed.content : '')
              .fontSize(15)
              .fontColor('#7A4A5E')
              .lineHeight(24)

            Row() {
              Text('👍 变美点赞 ' + (this.pickedFeed !== null ? this.pickedFeed.likes : 0))
                .fontSize(13)
                .fontColor('#EC4899')
                .backgroundColor('rgba(236,72,153,0.12)')
                .borderRadius(14)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              Text('💬 评论 ' + (this.pickedFeed !== null ? this.pickedFeed.comments : 0))
                .fontSize(13)
                .fontColor('#C084FC')
                .backgroundColor('rgba(192,132,252,0.12)')
                .borderRadius(14)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            }
            .width('100%')

            Text('🌸 本期上架 · 单品 3 件 · 教程编号 ' + (this.pickedFeed !== null ? this.pickedFeed.id : 0))
              .fontSize(12)
              .fontColor('#D97706')
              .backgroundColor('rgba(217,119,6,0.1)')
              .borderRadius(10)
              .padding({ left: 10, right: 10, top: 6, bottom: 6 })
              .width('100%')
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .border({ width: 1, color: '#EC4899' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(74,35,51,0.55)')
        .justifyContent(FlexAlign.Center)
      }

      // 发布动态新增
      if (this.showFeedAdd) {
        Column() {
          Column({ space: 12 }) {
            Row() {
              Text('💄 发布美妆动态')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A2333')
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#4A2333')
                .padding(6)
                .onClick(() => {
                  this.showFeedAdd = false
                })
            }
            .width('100%')

            TextInput({ placeholder: '你的昵称', text: this.newFeedName })
              .width('100%')
              .height(42)
              .fontSize(14)
              .fontColor('#4A2333')
              .backgroundColor('#FDF2F8')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newFeedName = v
              })

            TextInput({ placeholder: '分享今天的美妆心得...', text: this.newFeedText })
              .width('100%')
              .height(88)
              .fontSize(14)
              .fontColor('#4A2333')
              .backgroundColor('#FDF2F8')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newFeedText = v
              })

            Row() {
              Text('💄 底妆')
                .fontSize(12)
                .fontColor('#EC4899')
                .backgroundColor('rgba(236,72,153,0.12)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              Text('👁️ 眼妆')
                .fontSize(12)
                .fontColor('#C084FC')
                .backgroundColor('rgba(192,132,252,0.12)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              Text('💋 唇妆')
                .fontSize(12)
                .fontColor('#D97706')
                .backgroundColor('rgba(217,119,6,0.12)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            }
            .width('100%')

            Text('发布')
              .textAlign(TextAlign.Center)
              .width('100%')
              .height(42)
              .backgroundColor('#EC4899')
              .borderRadius(8)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .onClick(() => {
                if (this.newFeedName !== '' && this.newFeedText !== '') {
                  this.feeds.unshift(buildFeed(this.feeds.length + 1, this.newFeedName, '💄', '刚刚', this.newFeedText, 0, 0))
                  this.newFeedName = ''
                  this.newFeedText = ''
                }
                this.showFeedAdd = false
              })
          }
          .width('88%')
          .padding(18)
          .backgr
    
    .width('100%')
    .height('100%')
    .backgroundColor('#FDF2F8')
  }
}

// ===== 2183.ets END =====


在这里插入图片描述

在状态管理与数据流层面,代码展示了 ArkUI 响应式系统的完整运作方式。@State 变量的赋值触发条件渲染(弹窗的显示/隐藏)、触发 ForEach 列表的增量更新(unshift 插入、splice 替换和删除)、触发样式属性的动态切换(Tab 选中态的颜色和缩放变换)。@Prop 实现了父子组件之间的单向数据传递,回调函数实现了子组件向父组件的事件通知。onChange 事件配合 @State 变量赋值实现了 TextInput 的双向数据绑定。animation 属性为 scale 变换添加了平滑过渡效果,让 Tab 切换时的缩放动画自然流畅。

在交互体验层面,代码通过多种技术手段实现了丰富的用户交互。onClick 事件绑定在 Text、Row、Column 等多种组件上,实现了点击触发弹窗、点击切换 Tab、点击提交表单等多种交互行为。三元表达式动态设置组件的样式属性(fontColor、backgroundColor、height 等),让同一套 UI 代码能够根据数据状态渲染出不同的视觉效果——例如直播按钮在"直播中"和"已结束"两种状态下呈现出完全不同的颜色和可用性提示。maxLines 和 textOverflow 配合实现了长文本的单行截断省略号,保持了卡片高度的一致性。ForEach 的键值生成函数确保了列表增删改时的精确增量更新,避免了全量重建带来的性能损耗和状态丢失。

整体来看,这段代码虽然是一个示例级别的应用,但其架构设计、代码组织和技术运用都体现了一个完整鸿蒙应用应有的水准。从数据建模到 UI 渲染,从状态管理到组件通信,从布局技巧到动画效果,每一个环节都经过了精心的设计和实现。对于学习鸿蒙 ArkTS 开发的开发者来说,这段代码是一个非常好的综合实践参考——它涵盖了声明式 UI 的几乎所有核心概念和技术点,同时又以一个贴近真实业务场景的美妆社区应用为载体,让技术学习不再停留在抽象的概念层面,而是与具体的业务需求和用户体验紧密结合在一起。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐