一、鸿蒙技术栈与声明式 UI 范式总览

HarmonyOS 作为华为全场景分布式操作系统,其应用开发框架经历了从 Java UI 到 ArkUI 声明式范式的重大演进。在 HarmonyOS 6.1.1 版本中,ArkTS 语言已经高度成熟,它基于 TypeScript 扩展而来,在保留 TypeScript 静态类型检查能力的同时,引入了面向 UI 开发的声明式语法体系。ArkTS 的核心设计哲学是"状态驱动视图",即通过状态变量的变化自动触发 UI 的重新渲染,开发者无需手动操作 DOM 或调用 invalidate() 之类的刷新方法。

声明式 UI 范式与传统命令式 UI 有着本质区别。在传统 Android View 体系中,开发者需要通过 findViewById 获取控件引用,然后调用 setText()setVisibility() 等方法来更新界面。而在 ArkUI 声明式范式中,开发者只需声明"界面长什么样"以及"界面依赖哪些状态",当状态发生变化时,框架会自动完成差异计算和局部刷新。这种模式极大地减少了样板代码,使得开发者能够将精力集中在业务逻辑本身。

HarmonyOS ArkTS API 24 带来了多项重要更新。首先是组件渲染性能的进一步提升,ForEach 的 diff 算法得到了优化,在处理大规模列表时能够更高效地进行最小化更新。其次,@Builder 装饰器支持了更灵活的参数传递模式,包括函数类型参数的传递,使得组件复用更加便捷。此外,动画系统也做了增强,animation 修饰器现在支持更丰富的曲线类型和更精细的控制参数。

ArkTS 的类型系统是构建大型应用的基础。与纯 JavaScript 不同,ArkTS 强制要求使用静态类型,这不仅能在编译期捕获潜在的类型错误,还能让 IDE 提供更精准的代码补全和重构支持。在本应用中,我们通过 interface 关键字定义了多个数据接口,这些接口充当了整个应用的数据契约,确保了数据在不同组件之间传递时的类型安全性。

HarmonyOS 6.1.1 的 ArkUI 框架还引入了更完善的布局体系。ColumnRowStack 作为三大基础布局容器,各自承担不同的布局职责。Column 负责垂直方向的线性排列,Row 负责水平方向的线性排列,而 Stack 则提供了层叠布局能力,允许子元素在 Z 轴方向上叠加。这三种布局容器的组合使用,几乎可以覆盖移动端所有的 UI 布局场景。

二、应用业务背景与产品定位

本应用是一款"拼多多风格"的多多视频应用,核心理念是"看视频赚金币带货"。它将短视频内容消费与电商带货深度融合,用户在观看视频的同时可以获得虚拟金币奖励,这些金币可以进一步兑换为现金红包、话费券、实物商品等。这种"内容 + 电商 + 激励"的三位一体模式,是当前移动互联网流量变现的主流路径之一。

应用采用深色主题设计,这在短视频类应用中是常见的选择。深色主题不仅能在 OLED 屏幕上节省电量,还能减少用户在夜间使用时的视觉疲劳,更重要的是,深色背景能够更好地突出视频封面和商品信息,提升内容的视觉冲击力。

应用包含六个底部 Tab 页面:视频、关注、消息、钱包、商城、我的。这六个页面覆盖了内容消费、社交关系、消息通知、资产管理、商品购买和个人中心等完整的功能闭环。每个页面都有其独立的组件结构和数据模型,但它们共享同一套设计语言和交互规范。

在交互层面,应用实现了六个功能弹窗:打赏弹窗(金额选择)、评论发布弹窗(底部抽屉 + 快捷标签)、举报弹窗(原因单选)、编辑签名弹窗、删除作品确认弹窗、金币兑换弹窗(比例计算)。这些弹窗覆盖了用户在内容消费过程中的主要交互行为,包括创作者激励、社区互动、内容治理、个人资料管理和虚拟资产兑换等场景。

此外,应用还包含了数据可视化元素——近 7 日金币收益柱状图,以及两种动效——点赞爱心缩放动画和金币收益进度动画。这些视觉效果增强了应用的动态感和趣味性。

多多视频应用

视频 Tab

关注 Tab

消息 Tab

钱包 Tab

商城 Tab

我的 Tab

视频流 Feed

打赏弹窗

评论弹窗

举报弹窗

达人横滑列表

动态信息流

互动通知三格

会话列表

金币大卡

7日柱状图

兑换好物横滑

金币明细

兑换弹窗

头部横幅

达人推荐

双列商品

个人头部

数据三格

作品网格

收益概览

编辑签名弹窗

删除作品弹窗

在这里插入图片描述

三、整体架构层次分析

从架构角度来看,本应用采用了"单入口 + 多组件"的组织模式。整个应用只有一个 @Entry 入口组件,即 DuoDuoVideoApp,它负责管理全局 Tab 状态和页面切换逻辑。在入口组件内部,通过条件渲染(if-else 链)来决定当前显示哪个页面组件。

每个页面组件(如 VideoFeedContentFollowListContent 等)都是独立的 @Component,它们各自管理自己的内部状态和 UI 构建。这种设计实现了良好的关注点分离——入口组件只关心"显示哪个页面",而页面组件关心"页面内部怎么展示"。

数据层面,应用使用了模块级的常量数组来模拟后端数据。这些数据通过 interface 接口进行类型约束,确保了数据的结构化访问。在实际生产环境中,这些数据会通过 HTTP 请求从服务端获取,但本应用通过静态数据演示了完整的数据流和 UI 渲染逻辑。

渲染错误: Mermaid 渲染失败: Parse error on line 3: ...ubgraph 入口层 Entry[DuoDuoVideoApp... ----------------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'subgraph', 'end', 'acc_title', 'acc_descr', 'acc_descr_multiline_value', 'AMP', 'COLON', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', 'direction_tb', 'direction_bt', 'direction_rl', 'direction_lr', 'direction_td', got 'LINK_ID'

在这里插入图片描述

状态管理架构图

ArkUI 的状态管理体系是声明式 UI 的核心引擎。在本应用中,状态管理采用了多层次的策略。入口组件通过 @State 管理当前激活的 Tab 索引,这是一个全局级别的状态,决定了整个应用展示哪个页面。每个页面组件内部又有各自的状态变量,用于管理弹窗的显示/隐藏、用户输入内容、选中项等交互状态。

activeTab = FEED

触发 onClick

FEED

FOLLOW

MESSAGE

WALLET

MALL

MINE

应用启动

默认视频页

视频流展示

用户点击Tab

activeTab

更新

条件渲染判断

视频页

关注页

消息页

钱包页

商城页

我的页

弹窗状态切换

页面重新渲染

四、类型定义深度剖析

4.1 VideoItem 接口

interface VideoItem {
  id: number
  title: string
  cover: string
  author: string
  avatar: string
  likes: string
  comments: string
  shares: string
  plays: string
  goods: string
  price: number
  followed: boolean
  duration: string
  earn: string
}

在这里插入图片描述

这段代码定义了视频流数据的核心类型接口 VideoItem。在 ArkTS 中,interface 用于声明对象的形状,它定义了对象必须包含哪些属性以及每个属性的类型。与 TypeScript 的 interface 类似,ArkTS 的 interface 在编译后会被擦除,不会产生运行时开销,但在编译期提供了严格的类型检查。

逐行分析每个字段:

id: number —— 视频的唯一标识符,类型为 number。在 ForEach 渲染列表时,这个 id 通常会作为 key 使用,帮助框架进行高效的 diff 计算。当列表数据发生变化时,框架通过对比 key 来决定哪些元素需要新增、删除或更新,而不是盲目地重建整个列表。

title: string —— 视频标题,类型为字符串。这个字段在 UI 中会通过 maxLines(2) 限制最多显示两行,超出部分会被截断。选择 string 而非 string | undefined 意味着每条视频数据都必须提供标题,这是一个设计约束。

cover: string —— 视频封面。在本应用中,封面使用 Emoji 字符代替了实际的图片 URL,这是一种轻量化的模拟手段。在实际项目中,这里通常会是一个图片资源 URL,配合 Image 组件使用。

author: stringavatar: string —— 分别表示作者名称和作者头像。同样是 Emoji 模拟。这两个字段在视频卡片的底部信息区域展示,帮助用户识别内容创作者。

likescommentssharesplays —— 这四个字段都使用 string 类型而非 number。这是一个值得注意的设计决策。因为这些数据在 UI 中直接作为文本展示,且格式已经过处理(如"12.6万"),使用字符串可以避免在 UI 层做数字格式化的额外工作。

goods: stringprice: number —— 视频挂载的商品名称和价格。price 使用 number 类型,因为需要在 UI 中进行 .toFixed(2) 的格式化运算,数值类型才能支持这种数学操作。

followed: boolean —— 是否已关注该作者。布尔类型非常适合表示这种二元状态。在 UI 层,这个字段直接决定了关注按钮的显示文本(“已关注"或”+ 关注")和背景色。

duration: stringearn: string —— 视频时长和观看收益。earn 字段如"+38金币"直接展示了用户观看该视频可获得的金币奖励,这是"看视频赚金币"核心机制的数据体现。

4.2 CreatorItem 接口

interface CreatorItem {
  id: number
  name: string
  avatar: string
  fans: string
  online: boolean
}

在这里插入图片描述

CreatorItem 定义了创作者(达人)的数据结构。相比 VideoItem,它的字段更加精简,因为创作者信息不需要包含视频相关的内容。

fans: string —— 粉丝数,同样是字符串格式(如"326万")。online: boolean —— 是否在线,用于在头像右下角显示绿色在线指示点。这个小细节是社交类应用常见的 UX 模式,通过视觉提示增强用户的社交连接感。

4.3 ChatItem 接口

interface ChatItem {
  id: number
  name: string
  avatar: string
  lastMsg: string
  time: string
  unread: number
  kind: string
}

ChatItem 定义了消息会话的数据结构。lastMsg 是最后一条消息内容,time 是消息时间(已格式化为"刚刚"、"12分钟前"等),unread 是未读消息数。

kind: string —— 消息类型,取值为"私信"、“互动”、"系统"之一。这个字段在 UI 中以小标签的形式展示,帮助用户区分消息来源。在实际项目中,更规范的做法是使用联合字面量类型 "私信" | "互动" | "系统" 或枚举,但使用 string 提供了更大的灵活性。

4.4 CoinExchange 接口

interface CoinExchange {
  id: number
  title: string
  icon: string
  coins: number
  value: string
  hot: boolean
}

CoinExchange 定义了金币兑换商品的数据结构。coins 使用 number 类型,因为需要在 UI 中展示数值。hot: boolean 标记是否为热门兑换项,热门项会在卡片上显示红色"HOT"标签。

4.5 CoinLog 接口

interface CoinLog {
  id: number
  time: string
  title: string
  coins: number
  icon: string
}

在这里插入图片描述

CoinLog 定义了金币变动记录的数据结构。coins 是金币变动数量,正数表示获得,负数表示消耗。在 UI 渲染时,正数会显示为金色加号前缀,负数则显示为灰色,这种颜色编码帮助用户快速区分收入和支出。

4.6 VideoGoods 接口

interface VideoGoods {
  id: number
  name: string
  icon: string
  price: number
  original: number
  sold: string
  fromVideo: string
}

VideoGoods 定义了视频同款商品的数据结构。price 是现价,original 是原价,两个字段都使用 number 类型,因为原价需要在 UI 中通过 decoration({ type: TextDecorationType.LineThrough }) 显示删除线效果。sold 使用字符串格式(如"12.6万"),fromVideo 记录了商品来源的视频创作者。

4.7 ReportReason 接口

interface ReportReason {
  id: number
  label: string
  desc: string
}

ReportReason 定义了举报原因的数据结构。label 是简短的原因名称(如"垃圾广告"),desc 是补充说明(如"过度营销、诱导购买")。这种两级文本结构在举报弹窗中分别以不同字号和颜色展示,提供了清晰的信息层级。

使用场景

类型定义层

VideoItem
14个字段

CreatorItem
5个字段

ChatItem
7个字段

CoinExchange
6个字段

CoinLog
5个字段

VideoGoods
7个字段

ReportReason
3个字段

视频流页面

关注页/商城页

消息页

钱包页

钱包页

商城页

举报弹窗

五、静态数据层分析

5.1 视频流数据 VIDEO_FEED

const VIDEO_FEED: VideoItem[] = [
  { id: 1, title: '苹果果园现摘现发,脆甜多汁,一箱才这个价!', cover: '🍎', author: '果农老张', avatar: '🧑‍🌾', likes: '12.6万', comments: '8923', shares: '1.2万', plays: '326万', goods: '洛川红富士 5斤装', price: 19.9, followed: true, duration: '00:45', earn: '+38金币' },
  // ... 共8条数据
]

在这里插入图片描述

const VIDEO_FEED: VideoItem[] 声明了一个不可变常量数组,数组元素的类型被显式标注为 VideoItem[]。使用 const 而非 let 表明这组数据在应用运行期间不会被重新赋值。

数组中每条数据都严格遵循 VideoItem 接口定义的字段结构。以第一条数据为例:id: 1 是唯一标识,title 描述了苹果果园现摘现发的卖点,cover 使用苹果 Emoji 作为封面占位符。author 为"果农老张",avatar 使用农民 Emoji。likes: '12.6万' 表示 12.6 万点赞,这里使用了中文化的数字格式。price: 19.9 是商品现价,followed: true 表示用户已关注该创作者。duration: '00:45' 是视频时长 45 秒,earn: '+38金币' 表示观看此视频可获得 38 金币。

数据涵盖了多个内容品类:农产品(苹果、海鲜)、日用品(保温杯、收纳箱)、数码产品(蓝牙耳机)、美食(包子)、家居(床帘)、宠物用品等。这种多元化的内容覆盖模拟了真实短视频平台的品类分布。

5.2 关注列表数据 CREATOR_LIST

const CREATOR_LIST: CreatorItem[] = [
  { id: 1, name: '果农老张', avatar: '🧑‍🌾', fans: '326万', online: true },
  // ... 共8条数据
]

这个数组定义了 8 位创作者信息,与 VIDEO_FEED 中的作者数据保持一致。online 字段交替设置为 truefalse,使得在线状态指示点的展示更加真实自然。在关注页面和商城页面都会引用这组数据。

5.3 消息列表数据 CHAT_LIST

const CHAT_LIST: ChatItem[] = [
  { id: 1, name: '果农老张', avatar: '🧑‍🌾', lastMsg: '老铁,苹果给你留了一箱最甜的', time: '刚刚', unread: 3, kind: '私信' },
  // ... 共10条数据
]

消息列表包含 10 条数据,涵盖了三种消息类型:私信(4条)、互动(3条)、系统(3条)。unread 字段的值从 0 到 12 不等,其中有未读消息的会话会在列表中显示红色数字角标。时间格式从"刚刚"到"昨天",模拟了不同时间维度的消息场景。

5.4 金币兑换数据 COIN_EXCHANGES

const COIN_EXCHANGES: CoinExchange[] = [
  { id: 1, title: '1元无门槛券', icon: '🎟️', coins: 100, value: '全场可用', hot: true },
  // ... 共5条数据
]

金币兑换商品包含 5 个选项,从 100 金币的 1 元券到 2000 金币的 10 元现金红包。coins 字段使用 number 类型,因为它需要参与金额计算和比较。hot: true 的项(如 1 元券、视频会员、现金红包)会在卡片上显示红色"HOT"标签,引导用户优先兑换。

5.5 金币记录数据 COIN_LOGS

const COIN_LOGS: CoinLog[] = [
  { id: 1, time: '今天 21:15', title: '看完视频《狗狗的夏天》', coins: 47, icon: '🎬' },
  // ...
  { id: 8, time: '昨天 21:30', title: '兑换话费券消耗', coins: -450, icon: ' exchange' }
]

金币记录包含 8 条数据,其中 7 条是正数(收入),1 条是负数(支出)。最后一条记录 coins: -450 表示兑换话费券消耗了 450 金币。在 UI 渲染时,正数会显示为金色带"+"前缀,负数则直接显示数值且为灰色。icon 字段最后一条使用了 ' exchange'(带前导空格),代码中通过 l.icon.trim() 来处理这个空格,体现了对数据清洗的考虑。

5.6 举报原因数据 REPORT_REASONS

const REPORT_REASONS: ReportReason[] = [
  { id: 1, label: '垃圾广告', desc: '过度营销、诱导购买' },
  // ... 共6条数据
]

在这里插入图片描述

举报原因包含 6 个选项,覆盖了常见的举报场景。每条数据都有 label(简短标签)和 desc(详细描述),在举报弹窗中以一行两列的方式展示,标签在前描述在后。

六、入口组件深度分析

6.1 VideoTab 枚举与入口声明

enum VideoTab {
  FEED = 0,
  FOLLOW = 1,
  MESSAGE = 2,
  WALLET = 3,
  MALL = 4,
  MINE = 5
}

在这里插入图片描述

enum 是 ArkTS 中的枚举类型,用于定义一组命名的常量。VideoTab 枚举了六个 Tab 的索引值,从 0 到 5。使用枚举而非魔法数字(如直接使用 012)的好处是显而易见的——代码可读性大幅提升,VideoTab.FEED0 更能表达"视频流页面"的语义。

@Entry
@Component
struct DuoDuoVideoApp {
  @State activeTab: VideoTab = VideoTab.FEED

@Entry 装饰器标记 DuoDuoVideoApp 为应用的入口组件。在一个 ArkTS 应用中,有且只有一个组件被标记为 @Entry,它是应用启动后渲染的第一个组件,也是整个组件树的根节点。

@Component 装饰器将 DuoDuoVideoApp 标记为一个自定义组件。被 @Component 装饰的 struct 会被编译器特殊处理,生成包含生命周期方法和渲染逻辑的组件类。每个 @Component 必须实现 build() 方法,该方法返回该组件的 UI 描述。

@State activeTab: VideoTab = VideoTab.FEED 声明了一个响应式状态变量。@State 是 ArkUI 状态管理中最基础也是最重要的装饰器。它的核心机制是:当被装饰的变量值发生变化时,ArkUI 框架会自动触发依赖该变量的 UI 部分的重新渲染。在这里,activeTab 的初始值为 VideoTab.FEED,意味着应用启动后默认显示视频流页面。

@State 装饰器深度讲解@State 装饰的变量被称为"状态变量"。它只能用于组件内部,不能跨组件传递。当状态变量的值改变时,框架会执行以下流程:首先检测值是否真正发生了变化(通过比较),然后找到所有依赖该变量的 UI 节点,最后只对这些节点进行重新渲染(而非整个组件树)。这种细粒度的更新机制是 ArkUI 高性能的关键。需要注意的是,@StateObjectArray 类型的变量有特殊处理规则——仅替换整个对象或数组引用才会触发更新,修改对象内部的属性或数组元素不会自动触发更新(除非使用 @Observed@ObjectLink 配合)。

6.2 contentArea 内容区域构建器

@Builder contentArea() {
    Column() {
      if (this.activeTab === VideoTab.FEED) {
        VideoFeedContent()
      } else if (this.activeTab === VideoTab.FOLLOW) {
        FollowListContent()
      } else if (this.activeTab === VideoTab.MESSAGE) {
        MessageCenterContent()
      } else if (this.activeTab === VideoTab.WALLET) {
        CoinWalletContent()
      } else if (this.activeTab === VideoTab.MALL) {
        VideoMallContent()
      } else {
        VideoMineContent()
      }
    }
    .layoutWeight(1)
  }

@Builder 装饰器是 ArkUI 中用于定义可复用 UI 片段的机制。被 @Builder 装饰的方法返回一段 UI 描述,可以在 build() 方法中通过 this.xxx() 的方式调用。@Builder@Component 的区别在于:@Component 是独立的组件,有自己的状态和生命周期;而 @Builder 更像是组件内部的 UI 宏,它共享所属组件的状态,适合提取组件内部的重复 UI 逻辑。

contentArea() 方法定义了内容区域的渲染逻辑。这里使用了 if-else 条件渲染链,根据 this.activeTab 的值决定渲染哪个页面组件。当 activeTabFEED 时渲染 VideoFeedContent(),为 FOLLOW 时渲染 FollowListContent(),以此类推。

Column 组件深度讲解Column 是 ArkUI 三大基础布局容器之一,它将子元素在垂直方向上从上到下线性排列。Column 的常用属性包括:justifyContent 控制子元素在主轴(垂直方向)上的对齐方式,可选值有 FlexAlign.Start(顶部对齐)、FlexAlign.Center(居中对齐)、FlexAlign.End(底部对齐)、FlexAlign.SpaceBetween(两端对齐,中间均匀分布)等;alignItems 控制子元素在交叉轴(水平方向)上的对齐方式,可选值有 HorizontalAlign.StartHorizontalAlign.CenterHorizontalAlign.End

.layoutWeight(1) 是一个非常重要的布局属性。它告诉布局系统:这个组件应该占据父容器中剩余的所有可用空间。在本例中,Column 使用 layoutWeight(1) 来填充底部 Tab 栏上方的所有区域。当有多个兄弟元素都设置了 layoutWeight 时,可用空间会按照 weight 值的比例分配。

条件渲染的机制值得深入理解。当 this.activeTab 的值从 FEED 变为 WALLET 时,ArkUI 框架会:首先卸载 VideoFeedContent 组件(触发其 aboutToDisappear 生命周期),然后创建并挂载 CoinWalletContent 组件(触发其 aboutToAppearaboutToBuild 生命周期)。这意味着页面切换时,旧页面的状态会丢失(除非通过其他方式持久化),新页面会以初始状态呈现。

6.3 bottomTabItem 底部 Tab 项构建器

@Builder bottomTabItem(icon: string, label: string, tab: VideoTab) {
    Column() {
      Text(icon).fontSize(18).opacity(this.activeTab === tab ? 1.0 : 0.42)
      Text(label).fontSize(9)
        .fontColor(this.activeTab === tab ? '#FF6B35' : '#888888')
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 1 })
      if (this.activeTab === tab) {
        Column().width(20).height(3).backgroundColor('#FF6B35').borderRadius(2).margin({ top: 2 })
      }
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 5, bottom: 5 })
    .onClick(() => { this.activeTab = tab })
  }

这个 @Builder 方法接收三个参数:icon(图标 Emoji 字符串)、label(标签文本)、tab(对应的 Tab 枚举值)。它返回一个底部 Tab 项的 UI 描述。

@Builder 方法支持参数传递,这是 ArkUI 组件复用的重要机制。通过传入不同的参数,同一个 @Builder 可以生成不同内容的 UI。在本例中,底部 6 个 Tab 项通过调用 this.bottomTabItem('🎬', '视频', VideoTab.FEED) 等方式复用同一个构建器,避免了重复代码。

逐行分析 UI 构建:

Text(icon).fontSize(18).opacity(this.activeTab === tab ? 1.0 : 0.42) —— 图标文本,字号 18,透明度通过三元运算符动态设置。当前 Tab 项的图标完全不透明(1.0),非当前 Tab 项的图标半透明(0.42)。这种透明度差异是视觉上区分"选中"和"未选中"状态的轻量手段。

Text(label).fontSize(9).fontColor(...) —— 标签文本,字号 9(非常小),颜色在激活时为 #FF6B35(橙色),非激活时为 #888888(灰色)。#FF6B35 是本应用的主色调,贯穿于所有激活状态的视觉元素中。

.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal) —— 字重在激活时加粗,非激活时恢复正常。FontWeight 是 ArkUI 提供的枚举类型,可选值包括 LighterNormalRegularMediumBoldBolder

if (this.activeTab === tab) { Column().width(20).height(3)... } —— 条件渲染一个底部指示条。只有当前激活的 Tab 项才会显示这个橙色小条。这是一个宽 20、高 3 的 Column,使用 borderRadius(2) 制作成圆角矩形。

.layoutWeight(1) —— 每个 Tab 项等分底部栏的宽度。6 个 Tab 项各自 layoutWeight(1),因此每个占据 1/6 的宽度。

.onClick(() => { this.activeTab = tab }) —— 点击事件处理。当用户点击某个 Tab 项时,将 this.activeTab 设置为对应的枚举值。由于 activeTab@State 装饰,这个赋值操作会触发 UI 的重新渲染,包括 contentArea() 中的条件渲染和所有 bottomTabItem 的视觉状态更新。

6.4 build 方法与整体布局

build() {
    Column() {
      this.contentArea()
      Row() {
        this.bottomTabItem('🎬', '视频', VideoTab.FEED)
        this.bottomTabItem('👥', '关注', VideoTab.FOLLOW)
        this.bottomTabItem('💬', '消息', VideoTab.MESSAGE)
        this.bottomTabItem('🪙', '钱包', VideoTab.WALLET)
        this.bottomTabItem('🛒', '商城', VideoTab.MALL)
        this.bottomTabItem('👤', '我的', VideoTab.MINE)
      }
      .width('100%')
      .backgroundColor('#1C1C1C')
      .padding({ top: 4, bottom: 6 })
      .shadow({ radius: 10, color: '#66000000', offsetY: -3 })
    }
    .width('100%').height('100%')
    .backgroundColor('#121212')
  }

build() 方法是每个 @Component 必须实现的核心方法,它返回该组件的完整 UI 描述。在这里,根容器是一个 Column,它垂直排列两个子元素:this.contentArea()(内容区域,占据剩余空间)和 Row()(底部 Tab 栏)。

Row 组件深度讲解RowColumn 相对,是水平方向的线性布局容器。它将子元素从左到右排列。Row 的属性与 Column 类似,但主轴和交叉轴交换了——RowjustifyContent 控制水平方向的对齐,alignItems 控制垂直方向的对齐。在本例中,Row 包含 6 个 bottomTabItem 调用,每个 Tab 项通过 layoutWeight(1) 等分宽度。

底部 Tab 栏的样式配置非常精细。.backgroundColor('#1C1C1C') 设置了深色背景。.padding({ top: 4, bottom: 6 }) 设置了上下不对称的内边距,顶部 4、底部 6,底部稍多以适配手势操作区域。

.shadow({ radius: 10, color: '#66000000', offsetY: -3 }) —— 阴影效果。radius: 10 是模糊半径,color: '#66000000' 是半透明黑色(前两位 66 是 alpha 通道,表示约 40% 透明度),offsetY: -3 是 Y 轴偏移量,负值表示向上偏移。这个阴影使得底部栏在内容区域上方产生了微妙的浮起效果,增强了视觉层次感。

最外层 Column.backgroundColor('#121212') 设置了整个应用的背景色。#121212 是一种接近纯黑但略带暖色的深色,它比纯黑 #000000 更柔和,是 Material Design 深色主题推荐的标准背景色。

七、页面导航流程与条件渲染机制

FEED

FOLLOW

MESSAGE

WALLET

MALL

MINE

应用启动

activeTab = FEED (默认值)

条件渲染判断

渲染 VideoFeedContent

渲染 FollowListContent

渲染 MessageCenterContent

渲染 CoinWalletContent

渲染 VideoMallContent

渲染 VideoMineContent

用户点击底部Tab

onClick 触发

this.activeTab = 新Tab值

@State 检测到值变化

触发 build 重新执行

卸载旧页面组件

创建新页面组件

ArkUI 的条件渲染机制是理解本应用导航逻辑的关键。当 @State activeTab 的值发生变化时,ArkUI 框架会重新执行 contentArea() 构建器中的 if-else 链。框架会比较新旧条件分支的结果,确定哪些组件需要卸载、哪些需要创建。

具体来说,当用户从视频页切换到钱包页时:框架检测到 this.activeTabVideoTab.FEED 变为 VideoTab.WALLETif (this.activeTab === VideoTab.FEED) 的结果从 true 变为 falseelse if (this.activeTab === VideoTab.WALLET) 的结果从 false 变为 true;框架卸载 VideoFeedContent 组件实例,创建新的 CoinWalletContent 组件实例。这个过程中,VideoFeedContent 内部的所有状态(如 showRewardshowComment 等)都会丢失。

八、视频流页面 VideoFeedContent 深度剖析

8.1 状态变量声明

@Component
struct VideoFeedContent {
  @State feedTab: number = 1
  @State likeScale: number = 1.0
  @State likedVideo: number = 0
  @State showReward: boolean = false
  @State showComment: boolean = false
  @State showReport: boolean = false
  @State rewardCoin: number = 66
  @State commentText: string = ''
  @State reportId: number = 0

视频流页面是整个应用最复杂的页面,它管理着 9 个状态变量。让我们逐一分析:

@State feedTab: number = 1 —— 视频流顶部的子标签索引(0=关注、1=推荐、2=同城),默认值为 1 即"推荐"。这个状态控制顶部标签的视觉激活状态。

@State likeScale: number = 1.0 —— 点赞图标的缩放比例。默认为 1.0(原始大小),用户点赞时会变为 1.45(放大 45%),然后在 190 毫秒后恢复为 1.0。这个状态驱动了点赞爱心缩放动画效果。

@State likedVideo: number = 0 —— 当前被点赞的视频 ID。记录哪个视频被点了赞,虽然目前主要用于标记,但在更复杂的应用中可以用于持久化用户的点赞状态。

@State showReward: boolean = false —— 打赏弹窗的显示状态。布尔类型的状态变量非常适合控制弹窗的显示和隐藏。当为 true 时渲染打赏弹窗,为 false 时不渲染。

@State showComment: boolean = false —— 评论弹窗的显示状态。

@State showReport: boolean = false —— 举报弹窗的显示状态。

@State rewardCoin: number = 66 —— 当前选中的打赏金额。默认为 66 金币,用户可以在 66、166、666 三个选项之间切换。

@State commentText: string = '' —— 用户输入的评论文本。初始为空字符串,当用户在评论弹窗的 TextArea 中输入时,通过 onChange 回调更新。

@State reportId: number = 0 —— 当前选中的举报原因 ID。初始为 0 表示未选中任何原因。

8.2 modalOverlay 遮罩层构建器

@Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.7)')
      .onClick(onClose)
  }

这个 @Builder 方法接收一个函数类型的参数 onClose: () => void。这是 ArkUI @Builder 的高级用法——传递函数作为参数,使得调用者可以自定义遮罩层被点击时的行为。

@Builder 方法接收函数参数的能力是 ArkUI API 24 的一个重要特性。在早期版本中,@Builder 只支持基本类型和对象类型的参数。函数参数的引入使得构建器可以实现更灵活的回调机制。

遮罩层本身是一个占满全屏的 Column,背景色为 rgba(0,0,0,0.7)(70% 透明度的黑色)。它覆盖在内容区域之上,起到视觉聚焦的作用——使用户的注意力集中在弹窗内容上。点击遮罩层会触发 onClose 回调,关闭对应的弹窗。

.width('100%').height('100%') 使用百分比字符串来设置尺寸。在 ArkUI 中,'100%' 表示占满父容器的全部可用空间。百分比布局是响应式设计的基础,能够自适应不同屏幕尺寸。

8.3 打赏弹窗 rewardModal

@Builder rewardModal() {
    Column() {
      this.modalOverlay(() => { this.showReward = false })
      Column() {
        Text('🎁 打赏支持创作者').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 22 })
        Text('你的打赏将全部给到作者,平台不抽成').fontSize(10).fontColor('#888888').margin({ top: 4 })

打赏弹窗是视频流页面的第一个功能弹窗。它的结构分为两层:外层 Column 包含遮罩层和弹窗内容容器;内层 Column 是实际的弹窗内容。

this.modalOverlay(() => { this.showReward = false }) —— 调用遮罩层构建器,传入关闭打赏弹窗的回调函数。当用户点击遮罩层时,showReward 被设置为 false,弹窗消失。

弹窗标题 Text('🎁 打赏支持创作者') 使用 17 号字体加粗白色文本。副标题 Text('你的打赏将全部给到作者,平台不抽成') 使用 10 号灰色字,这句话是一个重要的信任声明,告诉用户打赏金额不会被平台抽取。

Row() {
          Column() {
            Text('🪙').fontSize(28)
            Text('66金币').fontSize(11).fontColor('#FFFFFF').margin({ top: 4 })
            Text('¥0.66').fontSize(9).fontColor('#888888').margin({ top: 2 })
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
          .backgroundColor(this.rewardCoin === 66 ? '#FF6B35' : '#232323')
          .borderRadius(12).padding({ top: 12, bottom: 12 }).margin({ left: 4, right: 4 })
          .onClick(() => { this.rewardCoin = 66 })

打赏金额选择区域使用 Row 横向排列三个选项卡片。每个卡片是一个 Column,包含三行文本:Emoji 图标(28号字)、金币数额(11号字白色)、对应人民币金额(9号字灰色)。

.backgroundColor(this.rewardCoin === 66 ? '#FF6B35' : '#232323') —— 卡片背景色根据当前选中的打赏金额动态变化。选中的卡片显示橙色背景 #FF6B35,未选中的显示深灰 #232323。这种通过状态变量控制样式的方式是声明式 UI 的典型模式。

三个选项分别对应 66 金币(¥0.66)、166 金币(¥1.66)、666 金币(¥6.66)。.onClick(() => { this.rewardCoin = 66 }) 点击后将 rewardCoin 设置为对应值,由于 @State 的响应式机制,背景色会立即更新。

Row() {
          Text('🪙 金币余额:2,860').fontSize(11).fontColor('#FFD54F')
          Column().layoutWeight(1)
          Text('余额不足去充值 >').fontSize(10).fontColor('#888888')
        }

余额信息行。Column().layoutWeight(1) 是一个"弹簧"元素,它在 Row 中占据所有剩余空间,将左右两端的文本推向两侧。这是实现两端对齐布局的常用技巧——在两个元素之间插入一个 layoutWeight(1) 的空容器。金币余额数字使用 #FFD54F 金色字体,与金币主题保持视觉一致。

Text('确认打赏').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#FF6B35').borderRadius(20)
          .padding({ left: 44, right: 44, top: 10, bottom: 10 }).margin({ top: 20, bottom: 22 })
          .onClick(() => { this.showReward = false })

确认打赏按钮。使用橙色背景、白色文字、圆角 20。.padding({ left: 44, right: 44 }) 使按钮有足够的水平内边距,看起来像一个胶囊形按钮。点击后设置 showReward = false 关闭弹窗。

弹窗容器的定位使用了 .position({ x: '9%', y: '30%' })position 属性允许使用绝对定位将元素放置在指定坐标。这里使用百分比坐标,x: '9%' 使弹窗水平居中(弹窗宽度 82%,左右各留 9%),y: '30%' 使弹窗在垂直方向上偏上方位置显示。

.zIndex(999) 设置了 Z 轴层级为 999,确保弹窗覆盖在所有其他元素之上。

8.4 评论弹窗 commentModal

@Builder commentModal() {
    Column() {
      this.modalOverlay(() => { this.showComment = false })
      Column() {
        Row() {
          Column().width(36).height(4).backgroundColor('#555555').borderRadius(2)
        }
        .width('100%').justifyContent(FlexAlign.Center).padding({ top: 10, bottom: 4 })

评论弹窗采用了底部抽屉式设计,与打赏弹窗的居中弹窗不同。这种设计模式更符合移动端操作习惯——底部抽屉让用户可以用拇指轻松操作,不需要移动手指到屏幕中央。

弹窗顶部有一个"拖拽指示条"——一个宽 36、高 4 的灰色圆角条。.justifyContent(FlexAlign.Center) 使其在 Row 中水平居中。这个视觉元素向用户暗示"可以下滑关闭",是 iOS 和 Material Design 中常见的交互暗示。

Row() {
          Text('💬 发表评论').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Column().layoutWeight(1)
          Text('✕').fontSize(15).fontColor('#888888')
            .onClick(() => { this.showComment = false })
        }

标题行。左侧是"发表评论"标题,中间用 Column().layoutWeight(1) 占位,右侧是关闭按钮"✕"。关闭按钮使用 onClick 设置关闭弹窗。

TextArea({ placeholder: '说点什么吧,优质评论可得10金币…' })
          .placeholderColor('#666666').fontSize(13)
          .backgroundColor('#232323').borderRadius(10)
          .width('92%').height(76).margin({ top: 6 })
          .onChange((v: string) => { this.commentText = v })

TextArea 组件深度讲解TextArea 是 ArkUI 提供的多行文本输入组件,适用于需要输入较长文本的场景。与 TextInput(单行输入框)不同,TextArea 支持多行文本,自动换行。placeholder 参数设置占位提示文本,当输入框为空时显示。placeholderColor 设置占位文本的颜色。onChange 是文本变化事件回调,参数 v 是当前的输入文本。在这个回调中,将输入值赋给 this.commentText 状态变量,使得评论内容可以在组件其他地方使用。

占位文本"说点什么吧,优质评论可得10金币…"不仅提示用户输入内容,还巧妙地植入了激励机制——优质评论可以获得金币奖励。这种文案设计将产品策略融入了交互细节。

Scroll() {
          Row() {
            Text('支持一下').fontSize(11).fontColor('#AAAAAA')
              .backgroundColor('#232323').borderRadius(12).padding({ left: 10, right: 10, top: 5, bottom: 5 }).margin({ left: 12 })
            Text('好吃吗').fontSize(11).fontColor('#AAAAAA')
              .backgroundColor('#232323').borderRadius(12).padding({ left: 10, right: 10, top: 5, bottom: 5 }).margin({ left: 8 })
            // ...
          }
        }
        .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
        .margin({ top: 12 })

Scroll 组件深度讲解Scroll 是 ArkUI 的滚动容器组件,它可以让内容超出可视区域时支持滚动浏览。Scroll 可以设置 scrollable 属性来控制滚动方向,ScrollDirection.Horizontal 表示水平滚动,ScrollDirection.Vertical 表示垂直滚动(默认)。scrollBar(BarState.Off) 隐藏滚动条,使界面更简洁。

这里使用水平 Scroll 来实现快捷评论标签的横向滑动。每个标签是一个 Text 组件,带有灰色背景和圆角,点击后可以快速填充评论内容(虽然本应用中标签的点击事件未实现跳转逻辑,但在实际产品中通常会自动将标签文本填入 TextArea)。

快捷标签包括"支持一下"、“好吃吗”、“多少钱呀”、“已下单”、"太实惠了"等,这些是短视频评论区常见的高频用语。提供快捷标签可以降低用户输入成本,提高评论互动率。

Row() {
          Text('😊').fontSize(20)
          Text('💡 优质评论奖励10金币').fontSize(10).fontColor('#FFD54F').margin({ left: 10 })
          Column().layoutWeight(1)
          Text('发送').fontSize(13).fontColor('#FFFFFF')
            .backgroundColor('#FF6B35').borderRadius(16)
            .padding({ left: 26, right: 26, top: 8, bottom: 8 })
            .onClick(() => { this.showComment = false })
        }

底部操作行。左侧是表情按钮和金币奖励提示,右侧是发送按钮。"💡 优质评论奖励10金币"使用金色字体,这是对用户的即时激励提示。发送按钮点击后关闭弹窗。

弹窗容器的定位 .position({ x: 0, y: '62%' }) 将弹窗固定在屏幕底部 62% 的位置开始,即弹窗占据屏幕底部约 38% 的高度。圆角设置 .borderRadius({ topLeft: 22, topRight: 22 }) 只对上方两个角做圆角处理,营造出从底部滑出的视觉效果。

8.5 举报弹窗 reportModal

@Builder reportModal() {
    Column() {
      this.modalOverlay(() => { this.showReport = false })
      Column() {
        Text('🚨 举报该视频').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 20 })
        Text('请选择举报原因,我们将在24小时内处理').fontSize(10).fontColor('#888888').margin({ top: 4 })

举报弹窗采用居中弹窗设计。标题"举报该视频"使用警示性 Emoji 🚨 引导。副标题承诺了处理时效(24小时内),建立了用户对举报机制的信任感。

Column() {
          ForEach(REPORT_REASONS, (r: ReportReason) => {
            Row() {
              Text(r.label).fontSize(13)
                .fontColor(this.reportId === r.id ? '#FF6B35' : '#FFFFFF')
                .fontWeight(this.reportId === r.id ? FontWeight.Bold : FontWeight.Normal)
              Text(r.desc).fontSize(10).fontColor('#888888').margin({ left: 8 })
              Column().layoutWeight(1)
              if (this.reportId === r.id) {
                Text('✓').fontSize(14).fontColor('#FF6B35')
              }
            }
            .width('100%').padding({ top: 11, bottom: 11 })
            .backgroundColor(this.reportId === r.id ? '#2A1F1A' : 'transparent')
            .borderRadius(8)
            .onClick(() => { this.reportId = r.id })
          })
        }

ForEach 深度讲解ForEach 是 ArkUI 中用于列表渲染的核心组件。它接收三个参数:数据源数组、子项生成函数、以及可选的键值生成函数。ForEach 会遍历数据源数组,对每个元素调用子项生成函数来创建 UI 节点。当数据源发生变化时,ForEach 通过 diff 算法进行最小化更新,而不是重建整个列表。

在举报弹窗中,ForEach(REPORT_REASONS, (r: ReportReason) => { ... }) 遍历 6 条举报原因数据,为每条数据生成一个 Row 行。每行包含:原因标签(如"垃圾广告")、补充描述(如"过度营销、诱导购买")、占位空容器、以及选中状态的勾选标记。

单选交互的实现非常精巧。每行的样式都通过 this.reportId === r.id 条件判断来决定:选中行的标签变橙色加粗,背景变为 #2A1F1A(暗橙色调),并显示勾选标记"✓";未选中行则保持默认样式。当用户点击某行时,.onClick(() => { this.reportId = r.id })reportId 设置为该项的 ID。由于 reportId@State 变量,所有行的样式会立即重新评估——之前选中的行恢复未选中样式,新选中的行变为选中样式。这就是单选效果的实现原理——一个状态变量 + 条件样式。

Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#232323').borderRadius(18)
            .padding({ left: 28, right: 28, top: 9, bottom: 9 })
            .onClick(() => { this.showReport = false })
          Text('提交举报').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#FF3B30').borderRadius(18)
            .padding({ left: 24, right: 24, top: 9, bottom: 9 })
            .margin({ left: 12 })
            .onClick(() => { this.showReport = false })
        }

底部操作行包含取消和提交两个按钮。取消按钮使用深灰背景,提交按钮使用红色背景 #FF3B30——这是 iOS 系统的警示色,用于表示具有破坏性或不可逆的操作。两个按钮之间通过 .margin({ left: 12 }) 保持间距。

8.6 feedTopTab 顶部子标签构建器

@Builder feedTopTab(label: string, idx: number) {
    Text(label)
      .fontSize(idx === this.feedTab ? 17 : 13)
      .fontColor(idx === this.feedTab ? '#FFFFFF' : '#777777')
      .fontWeight(idx === this.feedTab ? FontWeight.Bold : FontWeight.Normal)
      .margin({ left: 14 })
      .onClick(() => { this.feedTab = idx })
  }

视频流页面顶部的子标签构建器,接收标签文本和索引值。激活标签使用 17 号白色加粗字体,非激活标签使用 13 号灰色普通字体。字号差异(17 vs 13)不仅表示选中状态,还通过视觉权重的差异引导用户关注当前选中的内容。

8.7 视频流主体 build 方法

build() {
    Stack() {
      Column() {
        Row() {
          this.feedTopTab('关注', 0)
          this.feedTopTab('推荐', 1)
          this.feedTopTab('同城', 2)
          Column().layoutWeight(1)
          Text('🔍').fontSize(17).margin({ right: 16 })
        }
        .width('100%').backgroundColor('#121212')
        .padding({ top: 12, bottom: 10 })

Stack 组件深度讲解Stack 是 ArkUI 的层叠布局容器,它将子元素在 Z 轴方向上堆叠。后声明的子元素会覆盖在先声明的子元素之上。StackalignContent 属性控制所有子元素的对齐方式,默认为 Alignment.CenterStack 在本应用中广泛用于实现弹窗层叠效果——基础内容在底层,弹窗覆盖在上层。

Stack 的层叠特性使得它非常适合实现"内容 + 浮层"的 UI 模式。在本例中,Stack 内部首先放置了 Column(包含视频流内容),然后通过条件渲染在最上层放置弹窗。这种结构确保了弹窗始终覆盖在内容之上。

顶部导航栏使用 Row 横向排列三个子标签和一个搜索图标。Column().layoutWeight(1) 作为弹簧元素将搜索图标推到右侧。

Scroll() {
          Column() {
            ForEach(VIDEO_FEED, (v: VideoItem) => {
              Column() {
                Stack() {
                  Column() {
                    Text(v.cover).fontSize(64)
                  }
                  .width('100%').height(190)
                  .linearGradient({ angle: 160, colors: [['#2A2A2A', 0], ['#3E2723', 1]] })
                  .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
                  .borderRadius({ topLeft: 12, topRight: 12 })

视频流主体使用 Scroll + Column + ForEach 的经典列表渲染模式。Scroll 提供垂直滚动能力,Column 作为内容容器,ForEach 遍历 VIDEO_FEED 数组生成视频卡片。

每个视频卡片的核心是封面区域,使用 Stack 实现层叠效果。最底层是一个 Column,设置了渐变背景 .linearGradient({ angle: 160, colors: [['#2A2A2A', 0], ['#3E2723', 1]] })linearGradient 是 ArkUI 的渐变背景属性,angle: 160 指定渐变角度为 160 度,colors 数组定义了渐变色标——从位置 0 的深灰色 #2A2A2A 渐变到位置 1 的深棕色 #3E2723。这种渐变背景为视频封面提供了层次感和深度感。

封面 Emoji 使用 64 号字体大小显示,居中对齐。

Column() {
                    Text('▶').fontSize(16).fontColor('#FFFFFF')
                      .backgroundColor('rgba(0,0,0,0.5)').borderRadius(16)
                      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                    Text(v.plays + '次播放').fontSize(10).fontColor('#FFFFFF')
                      .margin({ top: 6 })
                  }
                  .alignItems(HorizontalAlign.Center)
                  .position({ x: '40%', y: '36%' })

播放按钮层叠在封面上,使用绝对定位 .position({ x: '40%', y: '36%' }) 放置在封面区域的偏左上方位置。播放图标"▶"带有半透明黑色背景圆角,下方显示播放次数文本。这种层叠定位方式展示了 Stack + position 组合的灵活性。

Text(v.duration).fontSize(9).fontColor('#FFFFFF')
                    .backgroundColor('rgba(0,0,0,0.55)').borderRadius(6)
                    .padding({ left: 5, right: 5, top: 2, bottom: 2 })
                    .position({ x: '86%', y: 168 })

                  Text(v.earn).fontSize(9).fontColor('#FFD54F')
                    .backgroundColor('rgba(255,107,53,0.9)').borderRadius(8)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .position({ x: 10, y: 12 })

视频时长标签定位在封面右下角 .position({ x: '86%', y: 168 }),金币收益标签定位在左上角 .position({ x: 10, y: 12 })。两个标签都使用半透明背景以确保在封面上可读。金币标签使用橙色半透明背景和金色字体,视觉上非常醒目,突出了"看视频赚金币"的核心卖点。

Row() {
                  Column() {
                    Row() {
                      Text(v.avatar).fontSize(24)
                      Text(v.author).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ left: 6 })
                      Text(v.followed ? '已关注' : '+ 关注').fontSize(9)
                        .fontColor(v.followed ? '#777777' : '#FFFFFF')
                        .backgroundColor(v.followed ? '#232323' : '#FF6B35')
                        .borderRadius(9).padding({ left: 8, right: 8, top: 3, bottom: 3 })
                        .margin({ left: 8 })
                    }

视频卡片底部信息区域使用 Row 将内容分为左右两部分:左侧是作者信息和视频标题,右侧是互动按钮栏。

作者信息行包含头像 Emoji、作者名称和关注按钮。关注按钮的文本和样式根据 v.followed 布尔值动态变化:已关注时显示灰色"已关注",未关注时显示橙色"+ 关注"。这种通过数据驱动的条件样式是声明式 UI 的精髓。

Text(v.title).fontSize(12).fontColor('#DDDDDD').maxLines(2)
                      .margin({ top: 8 }).width('92%')

视频标题使用 12 号浅灰色字体 #DDDDDD,通过 .maxLines(2) 限制最多显示两行。超出两行的文本会被自动截断并省略号处理。.width('92%') 设置标题宽度为父容器的 92%,留出一定的边距。maxLines 是 ArkUI Text 组件的重要属性,在内容长度不确定的场景下,限制行数可以防止布局被过长文本撑开。

Row() {
                      Text(v.goods).fontSize(10).fontColor('#FFD54F').layoutWeight(1).maxLines(1)
                      Text('¥' + v.price.toFixed(2)).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FF6B35')
                      Text('去抢购').fontSize(9).fontColor('#FFFFFF')
                        .backgroundColor('#FF6B35').borderRadius(9)
                        .padding({ left: 8, right: 8, top: 3, bottom: 3 }).margin({ left: 8 })
                    }
                    .width('92%').backgroundColor('#232323').borderRadius(8)
                    .padding({ left: 8, right: 8, top: 6, bottom: 6 }).margin({ top: 8 })

商品挂载行是一个深灰色背景的小卡片,包含商品名称(金色字体)、价格(橙色加粗)和"去抢购"按钮。v.price.toFixed(2) 调用了 number 类型的 toFixed 方法,将价格格式化为两位小数(如 19.90)。这是为什么 price 字段必须使用 number 类型而非 string 类型的原因——只有数值类型才能调用 toFixed

商品名称使用 .layoutWeight(1) 占据剩余空间,确保价格和按钮始终靠右对齐。.maxLines(1) 限制商品名为单行,避免名称过长导致布局错乱。

8.8 右侧互动栏与点赞动画

Column() {
                    Column() {
                      Text('❤️').fontSize(22)
                        .scale({ x: this.likeScale, y: this.likeScale })
                        .animation({ duration: 180, curve: Curve.EaseOut })
                      Text(v.likes).fontSize(9).fontColor('#AAAAAA').margin({ top: 3 })
                    }
                    .alignItems(HorizontalAlign.Center)
                    .onClick(() => {
                      this.likedVideo = v.id
                      this.likeScale = 1.45
                      setTimeout(() => { this.likeScale = 1.0 }, 190)
                    })

右侧互动栏是视频卡片的交互核心区域。它是一个垂直排列的 Column,包含点赞、评论、分享、打赏、举报五个交互按钮。

点赞按钮的实现展示了 ArkUI 动画系统的基本用法。Text('❤️') 是点赞图标,通过 .scale({ x: this.likeScale, y: this.likeScale }) 设置缩放变换。this.likeScale@State 变量,初始值为 1.0。.animation({ duration: 180, curve: Curve.EaseOut }) 声明了动画参数——持续 180 毫秒,使用 EaseOut 缓动曲线。

animation 修饰器深度讲解animation 修饰器是 ArkUI 声明式动画的核心机制。它不会直接产生动画效果,而是"监听"其前面的属性变化。当被 animation 修饰的属性(如 scale)的值发生变化时,框架会自动在指定的 duration 时间内,使用指定的 curve 曲线,从旧值过渡到新值。Curve.EaseOut 是一种先快后慢的缓动曲线,适合"弹出"效果的动画。其他常用曲线包括 Curve.EaseIn(先慢后快)、Curve.EaseInOut(两端慢中间快)、Curve.Linear(匀速)、Curve.Spring(弹簧效果)。

点赞的交互流程:用户点击点赞按钮 → this.likedVideo = v.id 记录被点赞的视频 → this.likeScale = 1.45 将缩放比例设置为 1.45 → animation 修饰器检测到 scale 属性变化,启动 180ms 的放大动画 → setTimeout(() => { this.likeScale = 1.0 }, 190) 在 190ms 后将缩放恢复为 1.0 → 再次触发 animation,执行缩小回弹动画。注意 setTimeout 的延迟是 190ms,比动画时长 180ms 多了 10ms,确保放大动画完全结束后才开始缩小动画。

Column() {
                      Text('💬').fontSize(20)
                      Text(v.comments).fontSize(9).fontColor('#AAAAAA').margin({ top: 3 })
                    }
                    .alignItems(HorizontalAlign.Center).margin({ top: 14 })
                    .onClick(() => { this.showComment = true })

评论按钮点击后将 showComment 设置为 true,触发评论弹窗的渲染。每个互动按钮下方都显示对应的数量统计(评论数、分享数等),使用统一的 9 号灰色字体。

Column() {
                      Text('🎁').fontSize(20)
                      Text('打赏').fontSize(9).fontColor('#AAAAAA').margin({ top: 3 })
                    }
                    .alignItems(HorizontalAlign.Center).margin({ top: 14 })
                    .onClick(() => { this.showReward = true })

打赏按钮点击后触发打赏弹窗。注意打赏按钮的文本是固定的"打赏"而非动态数据,这是因为打赏不是一个统计数字,而是一个操作入口。

Column() {
                      Text('🚨').fontSize(18)
                      Text('举报').fontSize(9).fontColor('#777777').margin({ top: 3 })
                    }
                    .alignItems(HorizontalAlign.Center).margin({ top: 14 })
                    .onClick(() => { this.showReport = true; this.reportId = 0 })

举报按钮点击后同时做两件事:设置 showReport = true 显示举报弹窗,设置 reportId = 0 重置选中的举报原因。这种在打开弹窗时重置状态的做法很重要——确保用户每次打开举报弹窗时都是"未选中任何原因"的初始状态,而非上次的残留选择。

8.9 弹窗条件渲染

if (this.showReward) { this.rewardModal() }
      if (this.showComment) { this.commentModal() }
      if (this.showReport) { this.reportReport() }

Stack 容器的最外层,通过三个独立的 if 条件来控制三个弹窗的渲染。这种设计允许同时显示多个弹窗(虽然在实际使用中不太可能同时触发多个)。每个 if 语句独立评估一个布尔状态变量,当变量为 true 时渲染对应的弹窗构建器。

使用 Stack 作为根容器的好处在这里体现了——弹窗作为 Stack 的后续子元素,天然覆盖在前面的 Column(视频流内容)之上,无需额外的 z-index 管理。

点击点赞

点击评论

输入文本

点击发送

点击遮罩/关闭

点击打赏

选择金额

点击确认

点击遮罩

点击举报

选择原因

提交/取消

用户在视频流页面操作

操作类型

likeScale = 1.45

animation 触发放大动画

setTimeout 190ms

likeScale = 1.0

animation 触发缩小动画

showComment = true

渲染 commentModal

底部抽屉弹窗显示

用户操作

commentText 更新

showComment = false

showReward = true

渲染 rewardModal

居中弹窗显示

用户操作

rewardCoin 更新

showReward = false

showReport = true, reportId = 0

渲染 reportModal

居中弹窗显示

用户操作

reportId 更新

showReport = false

九、关注页 FollowListContent 深度剖析

9.1 页面整体结构

@Component
struct FollowListContent {
  build() {
    Scroll() {
      Column() {

关注页没有内部状态变量(build() 方法内直接使用静态数据),因此没有声明任何 @State。整个页面通过 Scroll 包裹 Column 实现垂直滚动。这种无状态的"纯展示"页面在 ArkUI 中是合法的——并非所有组件都需要状态管理。

9.2 达人横滑列表

Column() {
          Text('我关注的人').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            .width('100%').padding({ left: 16, top: 14, bottom: 8 })
          Scroll() {
            Row() {
              ForEach(CREATOR_LIST, (c: CreatorItem) => {
                Column() {
                  Stack() {
                    Column() {
                      Text(c.avatar).fontSize(26)
                    }
                    .width(52).height(52).backgroundColor('#232323').borderRadius(26)
                    .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
                    if (c.online) {
                      Column().width(10).height(10).backgroundColor('#4CAF50')
                        .borderRadius(5).position({ x: 40, y: 40 })
                    }
                  }
                  Text(c.name).fontSize(9).fontColor('#CCCCCC').margin({ top: 5 })
                  Text(c.fans + '粉').fontSize(8).fontColor('#666666').margin({ top: 1 })
                }
                .width(64).alignItems(HorizontalAlign.Center)
                .margin({ left: 6, right: 6 })
              })
            }
          }
          .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
          .padding({ bottom: 12 })
        }
        .width('100%').backgroundColor('#1C1C1C')
        .margin({ left: 12, right: 12, top: 10 }).borderRadius(14)

达人横滑区域使用嵌套的 Scroll(水平)+ Row + ForEach 结构。每个达人卡片是一个 Column,固定宽度 64,包含头像、名称和粉丝数。

头像使用 Stack 实现层叠效果:底层是一个 52x52 的圆形容器(borderRadius(26) 是直径的一半,实现正圆效果),内含 Emoji 头像。如果达人在线(c.onlinetrue),在头像右下角通过 .position({ x: 40, y: 40 }) 定位一个 10x10 的绿色圆点作为在线指示器。#4CAF50 是 Material Design 的绿色色值,常用于表示"在线"或"可用"状态。

9.3 动态信息流

ForEach(VIDEO_FEED, (v: VideoItem) => {
          if (v.followed) {
            Column() {
              Row() {
                Text(v.avatar).fontSize(32)
                Column() {
                  Row() {
                    Text(v.author).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                    Text(v.duration + ' · 发布新视频').fontSize(9).fontColor('#666666').margin({ left: 8 })
                  }
                  Text(v.title).fontSize(11).fontColor('#AAAAAA').maxLines(1).margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })
                Text('已关注').fontSize(9).fontColor('#777777')
                  .backgroundColor('#232323').borderRadius(9)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              }

动态列表复用了 VIDEO_FEED 数据,但通过 if (v.followed) 条件过滤,只显示用户已关注的创作者的动态。这种在 ForEach 内部使用 if 条件的方式可以灵活地控制列表项的显示——只渲染满足条件的项。

每个动态卡片使用 Row 水平布局:左侧是创作者头像,中间是作者名和视频标题,右侧是"已关注"标签。由于是关注页,所有显示的创作者都是已关注状态,因此关注按钮统一显示为灰色"已关注"。

Row() {
                Column() {
                  Text(v.cover).fontSize(38)
                }
                .width(120).height(78).backgroundColor('#2A2A2A').borderRadius(10)
                .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
                Column() {
                  Text('挂载好物:' + v.goods).fontSize(10).fontColor('#CCCCCC')
                  Text('¥' + v.price.toFixed(2)).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FF6B35').margin({ top: 5 })
                  Text(v.plays + '播放 · ' + v.likes + '赞').fontSize(9).fontColor('#666666').margin({ top: 5 })
                  Text('看视频 +金币').fontSize(9).fontColor('#FFD54F').margin({ top: 5 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
              }

动态卡片的下半部分使用 Row 水平排列视频缩略图和信息。左侧缩略图固定尺寸 120x78,右侧信息使用 layoutWeight(1) 自适应宽度。信息区包含挂载商品名、价格、播放/点赞统计和金币提示。这种左图右文的布局是信息流卡片的标准模式。

十、消息页 MessageCenterContent 深度剖析

10.1 互动通知三格

Row() {
          Column() {
            Stack() {
              Column() {
                Text('❤️').fontSize(22)
              }.width(44).height(44).backgroundColor('#232323').borderRadius(22)
              .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
              Column().width(16).height(16).backgroundColor('#FF3B30').borderRadius(8)
                .position({ x: 30, y: -2 })
                .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
            }
            Text('新赞').fontSize(10).fontColor('#CCCCCC').margin({ top: 5 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)

消息页顶部的互动通知区使用 Row 等分三格,分别对应"新赞"、“新评论”、“新粉丝”。每格内部是 Column 垂直排列图标和标签。

图标使用 Stack 层叠实现:底层是一个 44x44 的圆形容器,上层是一个 16x16 的红色角标,通过 .position({ x: 30, y: -2 }) 定位在右上角偏移位置。#FF3B30 红色角标是未读消息数的视觉指示器。注意第三个"新粉丝"格没有红色角标,因为 CHAT_LIST 中"新粉丝"的 unread 为 0。

三格之间通过 layoutWeight(1) 等分宽度,每格内部通过 .alignItems(HorizontalAlign.Center) 使内容居中。

10.2 会话列表

ForEach(CHAT_LIST, (c: ChatItem) => {
            Column() {
              Row() {
                Text(c.avatar).fontSize(26)
                Column() {
                  Row() {
                    Text(c.name).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#FFFFFF')
                    Text(c.kind).fontSize(8).fontColor('#666666')
                      .backgroundColor('#232323').borderRadius(6)
                      .padding({ left: 5, right: 5, top: 1, bottom: 1 }).margin({ left: 6 })
                    Column().layoutWeight(1)
                    Text(c.time).fontSize(9).fontColor('#666666')
                  }
                  Row() {
                    Text(c.lastMsg).fontSize(11).fontColor('#888888').maxLines(1)
                    Column().layoutWeight(1)
                    if (c.unread > 0) {
                      Text(c.unread.toString()).fontSize(8).fontColor('#FFFFFF')
                        .backgroundColor('#FF3B30').borderRadius(8)
                        .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                    }
                  }
                  .margin({ top: 4 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
              }
              .width('100%').padding({ top: 11, bottom: 11 })
              Divider().color('#2A2A2A').margin({ left: 62 })
            }
            .width('100%').alignItems(HorizontalAlign.Start)
          })

会话列表是消息页的核心内容。每个会话项是一个 Column,包含一个 Row(会话内容)和一个 Divider(分隔线)。

会话内容的 Row 分为左右两部分:左侧是头像 Emoji(26号字),右侧是信息 Column。信息列内部有两行:第一行是名称、消息类型标签、时间;第二行是最后消息和未读数角标。

Divider 组件讲解Divider 是 ArkUI 的分隔线组件,用于在列表项之间添加视觉分隔。.color('#2A2A2A') 设置分隔线颜色为深灰色,.margin({ left: 62 }) 设置左侧偏移 62vp,使分隔线从头像右侧开始,而非从屏幕左边缘开始——这是 iOS 风格列表的典型设计。

消息类型标签 c.kind 以小号灰色背景胶囊形式展示,帮助用户区分消息来源(私信/互动/系统)。未读数角标 c.unread.toString() 将数字转为字符串显示,仅在 c.unread > 0 时渲染。toString() 方法是 number 类型的标准方法,用于将数值转换为字符串。

消息类型分布

消息页数据流

CHAT_LIST 静态数据

ForEach 遍历

c.unread > 0?

渲染红色未读角标

不渲染角标

会话项渲染

Divider 分隔线

私信 4条

互动 3条

系统 3条

显示消息类型标签

十一、钱包页 CoinWalletContent 深度剖析

11.1 状态与数据

@Component
struct CoinWalletContent {
  @State showExchange: boolean = false
  @State exchangeNum: string = ''
  coinDays: number[] = [186, 142, 208, 96, 233, 176, 259]
  dayLabels: string[] = ['一', '二', '三', '四', '五', '六', '日']
  maxCoin: number = 259

钱包页管理两个状态变量和三个普通成员变量。

@State showExchange: boolean = false 控制金币兑换弹窗的显示。@State exchangeNum: string = '' 存储用户输入的兑换金币数量。

coinDays: number[] = [186, 142, 208, 96, 233, 176, 259] 是近 7 天的金币收益数据。注意这个变量没有被 @State 装饰——因为它是静态的展示数据,不需要响应式更新。在 ArkUI 中,并非所有成员变量都需要 @State,只有需要驱动 UI 更新的变量才需要。

dayLabels: string[] = ['一', '二', '三', '四', '五', '六', '日'] 是星期标签。maxCoin: number = 259 是 7 天中的最大值,用于计算柱状图高度的归一化比例。

11.2 金币兑换弹窗 exchangeModal

@Builder exchangeModal() {
    Column() {
      this.modalOverlay(() => { this.showExchange = false })
      Column() {
        Text('🪙 金币兑换').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 20 })
        Column() {
          Row() {
            Text('当前金币').fontSize(11).fontColor('#888888')
            Text('2,860').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFD54F').margin({ left: 6 })
          }
          Text('兑换比例 200金币 = ¥1.00').fontSize(10).fontColor('#888888').margin({ top: 6 })

金币兑换弹窗展示了应用的核心经济模型:200 金币 = 1 元人民币。这个比例关系在弹窗中多处使用——展示当前余额、计算兑换金额等。

Row() {
            Text('兑换金币数').fontSize(12).fontColor('#CCCCCC')
            TextInput({ placeholder: '输入金币数量' })
              .placeholderColor('#666666').fontSize(13).layoutWeight(1)
              .backgroundColor('#232323').borderRadius(8)
              .margin({ left: 10 })
              .onChange((v: string) => { this.exchangeNum = v })
          }

TextInput 组件深度讲解TextInput 是 ArkUI 的单行文本输入组件,与多行的 TextArea 相对。在本例中,TextInput 用于接收用户输入的兑换金币数量。placeholder 设置占位提示文本,layoutWeight(1) 使输入框占据行的剩余空间。onChange 回调将输入值实时同步到 this.exchangeNum 状态变量。

Text('可兑换现金:¥' + (this.exchangeNum === '' ? '0.00' : (Number(this.exchangeNum) / 200).toFixed(2)))
            .fontSize(13).fontWeight(FontWeight.Bold).fontColor('#FF6B35').margin({ top: 10 })

这行代码展示了 ArkUI 中表达式渲染的强大能力。文本内容是一个动态计算的字符串:如果用户没有输入(exchangeNum === ''),显示"¥0.00";否则将输入字符串通过 Number() 转为数值,除以兑换比例 200,再通过 toFixed(2) 格式化为两位小数。

由于 exchangeNum@State 变量,当用户在 TextInput 中输入时,onChange 回调更新 exchangeNum,框架自动重新评估这行 Text 的内容并更新显示。这就是声明式 UI 的"数据驱动视图"——开发者只需声明"文本内容依赖于 exchangeNum",框架负责所有更新逻辑。

11.3 金币大卡(渐变背景)

Column() {
            Row() {
              Text('🪙').fontSize(38)
              Column() {
                Text('我的金币').fontSize(11).fontColor('#FFE0B2')
                Text('2,860').fontSize(30).fontWeight(FontWeight.Bold).fontColor('#FFD54F').margin({ top: 2 })
                Text('≈ ¥14.30 · 今日已赚 259 金币').fontSize(10).fontColor('#FFE0B2').margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 14 })
              Text('兑换').fontSize(12).fontColor('#5D4037')
                .backgroundColor('#FFD54F').borderRadius(14)
                .padding({ left: 16, right: 16, top: 7, bottom: 7 })
                .onClick(() => { this.showExchange = true })

金币大卡是钱包页的视觉焦点。它使用橙色渐变背景 .linearGradient({ angle: 145, colors: [['#FF8F00', 0], ['#E65100', 1]] }),从亮橙色 #FF8F00 渐变到深橙色 #E65100,角度 145 �度。这种暖色调的渐变背景在深色主题应用中非常醒目,强化了"金币"这个核心概念的视觉权重。

卡片内容使用 Row 水平排列:左侧是大号金币 Emoji(38号字),中间是金币数额和说明信息(纵向排列),右侧是兑换按钮。兑换按钮使用金色背景 #FFD54F 和深色文字 #5D4037,与卡片背景形成对比,点击后打开兑换弹窗。

11.4 近 7 日金币柱状图

Column() {
            Text('📊 近7日金币收益').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              .width('100%').padding({ left: 16, top: 14, bottom: 8 })
            Row() {
              ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
                Column() {
                  Text(this.coinDays[d].toString()).fontSize(8).fontColor('#FFD54F').margin({ bottom: 3 })
                  Column()
                    .width(20)
                    .height((this.coinDays[d] / this.maxCoin * 72).toFixed(0) + 'vp')
                    .backgroundColor(d === 6 ? '#FF6B35' : '#5D4037')
                    .borderRadius({ topLeft: 4, topRight: 4 })
                    .animation({ duration: 350 })
                  Text(this.dayLabels[d]).fontSize(9).fontColor('#888888').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
              })
            }
            .padding({ left: 12, right: 12, bottom: 14 })
          }

这是应用中最具技术含量的数据可视化部分——一个纯 ArkUI 实现的柱状图,没有使用任何第三方图表库。

柱状图使用 ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => { ... }) 遍历 7 个索引值。注意这里的数据源不是数据数组本身,而是一个索引数组 [0, 1, 2, 3, 4, 5, 6],然后在循环体内通过 this.coinDays[d]this.dayLabels[d] 访问对应的数据。这是一种常见的遍历技巧——当需要同时访问多个平行数组时,通过索引遍历是最简洁的方式。

每个柱子是一个 Column,从上到下包含三部分:数值标签、柱体、星期标签。

柱体的高度是动态计算的:.height((this.coinDays[d] / this.maxCoin * 72).toFixed(0) + 'vp')。这个表达式做了以下计算:将当前天的金币数除以最大值 maxCoin(259)得到比例,乘以 72(最大柱高,单位 vp),通过 toFixed(0) 取整,再拼接 ‘vp’ 字符串形成高度值。例如第 7 天(d=6):259/25972 = 72vp,第 4 天(d=3):96/25972 ≈ 27vp。

vp 单位讲解vp(virtual pixel)是 HarmonyOS 的虚拟像素单位,它是 ArkUI 的默认长度单位。1vp 在不同设备上可能对应不同的物理像素,这取决于设备的屏幕密度。使用 vp 而非 px 可以确保 UI 在不同分辨率的设备上保持一致的视觉效果。在本例中,通过字符串拼接 'vp' 后缀来设置高度,是因为 .height() 方法接受 string 类型参数时可以包含单位后缀。

柱体颜色通过 d === 6 ? '#FF6B35' : '#5D4037' 区分——第 7 天(今天)使用橙色 #FF6B35,其他天使用深棕色 #5D4037。这种颜色编码让用户一眼就能识别今天的数据。

.borderRadius({ topLeft: 4, topRight: 4 }) 只对柱体上方两个角做圆角处理,模拟柱状图的标准外观。.animation({ duration: 350 }) 为柱体高度变化添加 350ms 的动画效果。

是 今天

柱状图布局

数值标签 8号字

柱体 width=20 height=动态

星期标签 9号字

coinDays 数组
186, 142, 208, 96, 233, 176, 259

maxCoin = 259
最大值

ForEach 遍历 0-6

计算柱高 = coinDays[d] / maxCoin * 72

d === 6?

颜色 #FF6B35 橙色

颜色 #5D4037 深棕

渲染柱体 + 数值 + 星期标签

animation 350ms 高度动画

11.5 兑换好物横滑与金币明细

Scroll() {
              Row() {
                ForEach(COIN_EXCHANGES, (e: CoinExchange) => {
                  Column() {
                    Row() {
                      if (e.hot) {
                        Text('HOT').fontSize(7).fontColor('#FFFFFF').backgroundColor('#FF3B30')
                          .borderRadius(4).padding({ left: 3, right: 3, top: 1, bottom: 1 })
                      }
                    }
                    .width('100%').justifyContent(FlexAlign.End)
                    Text(e.icon).fontSize(30)
                    Text(e.title).fontSize(10).fontColor('#FFFFFF').margin({ top: 4 })
                    Text(e.coins + '金币').fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FFD54F').margin({ top: 2 })
                    Text(e.value).fontSize(8).fontColor('#888888').margin({ top: 1 })
                  }
                  .width(96).alignItems(HorizontalAlign.Center)
                  .backgroundColor('#232323').borderRadius(12).padding(10)
                  .margin({ left: 6, right: 6 })
                })
              }
            }
            .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)

兑换好物区域使用水平滚动的卡片列表。每个卡片固定宽度 96,顶部有一个"HOT"标签的条件渲染区域——只有 e.hottrue 的商品才显示红色"HOT"标签,通过 .justifyContent(FlexAlign.End) 靠右对齐。

ForEach(COIN_LOGS, (l: CoinLog) => {
              Column() {
                Row() {
                  Column() {
                    Text(l.icon.trim()).fontSize(18)
                  }.width(34).height(34).backgroundColor('#232323').borderRadius(17)
                  .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
                  Column() {
                    Text(l.title).fontSize(12).fontColor('#CCCCCC')
                    Text(l.time).fontSize(9).fontColor('#666666').margin({ top: 2 })
                  }
                  .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })
                  Text((l.coins > 0 ? '+' : '') + l.coins.toString()).fontSize(13).fontWeight(FontWeight.Bold)
                    .fontColor(l.coins > 0 ? '#FFD54F' : '#888888')
                }
                .width('100%').padding({ top: 9, bottom: 9 })
                Divider().color('#2A2A2A').margin({ left: 58 })
              }
              .width('100%').alignItems(HorizontalAlign.Start)
            })

金币明细列表遍历 COIN_LOGS 数据。每条记录包含一个圆形图标容器(34x34,borderRadius(17) 实现正圆)、标题和时间、以及金币变动数额。

金币数额的显示逻辑值得注意:(l.coins > 0 ? '+' : '') + l.coins.toString()。当金币为正数时,在前面添加"+“号(如”+47");当为负数时,toString() 已经包含了负号(如"-450"),不需要额外添加。颜色也通过 l.coins > 0 ? '#FFD54F' : '#888888' 区分——正数金色,负数灰色。l.icon.trim() 处理了图标字符串中可能的空白字符,体现了防御性编程的意识。

兑换弹窗交互

用户点击兑换按钮

showExchange = true

渲染兑换弹窗

用户输入金币数

exchangeNum 更新

实时计算可兑现金
Number(exchangeNum) / 200

金币明细渲染逻辑

是 收入

否 支出

l.coins > 0?

显示 '+金币' 金色

显示 '-金币' 灰色

柱状图计算

coinDays = [186,142,208,96,233,176,259]

柱高 = coinDays[d] / maxCoin * 72 vp

maxCoin = 259

钱包页渲染流程

金币大卡
橙色渐变背景

7日柱状图
ForEach + 动态高度

兑换好物横滑
Scroll horizontal

金币明细列表
ForEach COIN_LOGS

十二、商城页 VideoMallContent 深度剖析

12.1 头部横幅与达人推荐

build() {
    Scroll() {
      Column() {
        Column() {
          Text('🛒 视频同款好物').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Text('达人实测 · 佣金直降 · 假一赔十').fontSize(10).fontColor('#FFE0B2').margin({ top: 3 })
        }
        .width('100%')
        .linearGradient({ angle: 135, colors: [['#FF6B35', 0], ['#BF360C', 1]] })
        .borderRadius(14).alignItems(HorizontalAlign.Center)
        .padding({ top: 16, bottom: 16 })
        .margin({ left: 12, right: 12, top: 12 })

商城页的头部横幅使用橙红色渐变 .linearGradient({ angle: 135, colors: [['#FF6B35', 0], ['#BF360C', 1]] }),从亮橙 #FF6B35 渐变到深红棕 #BF360C。这个渐变方向(135度)与金币大卡的 145 度不同,提供了细微的视觉差异。

横幅文本"达人实测 · 佣金直降 · 假一赔十"用三个关键词概括了商城的核心卖点——品质保证(达人实测)、价格优势(佣金直降)和售后保障(假一赔十)。这些文案使用浅橙色 #FFE0B2 与渐变背景协调。

Scroll() {
          Row() {
            ForEach(CREATOR_LIST, (c: CreatorItem) => {
              Column() {
                Text(c.avatar).fontSize(22)
                Text(c.name).fontSize(9).fontColor('#CCCCCC').margin({ top: 4 })
                Text(c.fans + '粉 · 在售').fontSize(8).fontColor('#666666').margin({ top: 1 })
              }
              .width(78).alignItems(HorizontalAlign.Center)
              .backgroundColor('#1C1C1C').borderRadius(12).padding({ top: 10, bottom: 8 })
              .margin({ left: 6, right: 6 })
            })
          }
        }
        .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)

达人推荐横滑列表复用了 CREATOR_LIST 数据。每个达人卡片宽度 78,显示头像、名称和"粉丝数 · 在售"信息。与关注页的达人卡片不同,这里增加了"在售"状态标识,符合商城页面的购物场景。

12.2 双列商品网格与 goodsCard 构建器

Row() {
          Column() {
            this.goodsCard(VIDEO_GOODS[0])
            this.goodsCard(VIDEO_GOODS[2])
            this.goodsCard(VIDEO_GOODS[4])
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
          Column() {
            this.goodsCard(VIDEO_GOODS[1])
            this.goodsCard(VIDEO_GOODS[3])
            this.goodsCard(VIDEO_GOODS[5])
          }
          .layoutWeight(1).alignItems(HorizontalAlign.End)
        }
        .width('100%').padding({ bottom: 20 })

双列商品网格的实现方式值得关注——它没有使用 ForEach 配合 Grid 组件,而是手动将 6 个商品分配到两个 Column 中:奇数索引(0, 2, 4)在左列,偶数索引(1, 3, 5)在右列。这种手动分配方式虽然不够优雅,但对于固定数量的小规模列表来说足够简洁。

左列使用 .alignItems(HorizontalAlign.Start) 使商品卡片靠左对齐,右列使用 .alignItems(HorizontalAlign.End) 使卡片靠右对齐。两列各 layoutWeight(1) 等分宽度。

@Builder goodsCard(g: VideoGoods) {
    Column() {
      Column() {
        Text(g.icon).fontSize(40)
      }
      .width('100%').height(84).backgroundColor('#232323').borderRadius(10)
      .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
      Text(g.name).fontSize(11).fontColor('#FFFFFF').maxLines(2)
        .width('100%').margin({ top: 6 })
      Row() {
        Text('📹 ' + g.fromVideo).fontSize(8).fontColor('#FF6B35')
          .backgroundColor('rgba(255,107,53,0.15)').borderRadius(4)
          .padding({ left: 4, right: 4, top: 1, bottom: 1 })
        Text(g.sold + '已抢').fontSize(8).fontColor('#666666').margin({ left: 4 })
      }
      .width('100%').margin({ top: 4 })
      Row() {
        Text('¥').fontSize(9).fontColor('#FF6B35')
        Text(g.price.toFixed(2)).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF6B35')
        Text('¥' + g.original.toFixed(2)).fontSize(9).fontColor('#666666').margin({ left: 4 })
          .decoration({ type: TextDecorationType.LineThrough })
      }
      .margin({ top: 5 })
    }
    .width('48%').backgroundColor('#1C1C1C').borderRadius(12).padding(8)
    .margin({ left: 4, right: 4, top: 8 })
  }

goodsCard 是一个接收 VideoGoods 类型参数的 @Builder 方法。它展示了 @Builder 传递对象参数的能力——将整个商品对象作为参数传入,在构建器内部访问其属性。

商品卡片从上到下包含:商品图标区(84 高度的灰色背景区域)、商品名称(最多两行)、来源标签和销量、价格行。

价格行的实现非常精细。使用三个 Text 组件拼接显示价格信息:小号"¥"符号(9号字橙色)、大号价格数字(16号字橙色加粗)、小号原价(9号字灰色带删除线)。.decoration({ type: TextDecorationType.LineThrough }) 为原价添加删除线效果,这是电商应用中展示折扣的标准视觉模式。TextDecorationType 是 ArkUI 的文本装饰枚举,可选值包括 LineThrough(删除线)、Underline(下划线)和 None(无装饰)。

来源标签 g.fromVideo 使用半透明橙色背景 rgba(255,107,53,0.15)(15% 透明度的橙色),配合前置的视频图标"📹",清晰地标识了商品的视频来源。

十三、我的页 VideoMineContent 深度剖析

13.1 状态变量

@Component
struct VideoMineContent {
  @State showEditSign: boolean = false
  @State showDeleteWork: boolean = false
  @State signText: string = ''
  @State signContent: string = '爱逛视频的省钱小能手,每天分享好物'

我的页管理四个状态变量。showEditSignshowDeleteWork 分别控制编辑签名弹窗和删除作品弹窗的显示。signText 存储用户正在编辑的签名文本。signContent 存储当前生效的签名内容,初始值为默认签名。

signTextsignContent 的分离设计是一个重要的模式——signText 是编辑过程中的临时状态,signContent 是已保存的持久状态。当用户打开编辑弹窗时,signText 开始接收输入;当用户点击保存时,signText 的值被提交到 signContent;如果用户取消,signContent 保持不变。这种"草稿 + 提交"的模式在表单编辑场景中非常常见。

13.2 编辑签名弹窗 editSignModal

@Builder editSignModal() {
    Column() {
      this.modalOverlay(() => { this.showEditSign = false })
      Column() {
        Text('✏️ 修改个性签名').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 20 })
        Text('好签名能让更多人记住你').fontSize(10).fontColor('#888888').margin({ top: 4 })
        TextArea({ placeholder: this.signContent })
          .placeholderColor('#888888').fontSize(13)
          .backgroundColor('#232323').borderRadius(10)
          .width('84%').height(66).margin({ top: 16 })
          .onChange((v: string) => { this.signText = v })
        Text('0/30').fontSize(9).fontColor('#666666')
          .width('84%').textAlign(TextAlign.End).margin({ top: 6 })

编辑签名弹窗使用 TextArea 组件接收多行文本输入。值得注意的是 placeholder 参数设置为 this.signContent——即当前签名内容作为占位文本显示。这种设计让用户在编辑时能看到当前签名作为参考。

字数统计 Text('0/30') 显示当前字数和最大限制。.textAlign(TextAlign.End) 使文本右对齐。在实际应用中,字数统计应该是动态的(根据 signText.length 计算),但这里使用了静态的"0/30"。

Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#232323').borderRadius(18)
            .padding({ left: 28, right: 28, top: 9, bottom: 9 })
            .onClick(() => { this.showEditSign = false })
          Text('保存').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#FF6B35').borderRadius(18)
            .padding({ left: 28, right: 28, top: 9, bottom: 9 })
            .margin({ left: 12 })
            .onClick(() => {
              if (this.signText !== '') { this.signContent = this.signText }
              this.showEditSign = false
            })
        }

保存按钮的 onClick 逻辑体现了"草稿 + 提交"模式:只有当 signText 不为空时,才将其值提交到 signContent。无论是否保存,最终都会关闭弹窗。这个简单的条件检查防止了用户清空所有内容后保存空签名的情况。

13.3 删除作品确认弹窗 deleteWorkModal

@Builder deleteWorkModal() {
    Column() {
      this.modalOverlay(() => { this.showDeleteWork = false })
      Column() {
        Text('🗑️').fontSize(36).margin({ top: 20 })
        Text('删除该作品?').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 8 })
        Text('删除后视频、评论与金币收益记录将一并消失').fontSize(10).fontColor('#888888').margin({ top: 5 })
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#232323').borderRadius(18)
            .padding({ left: 28, right: 28, top: 9, bottom: 9 })
            .onClick(() => { this.showDeleteWork = false })
          Text('删除').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#FF3B30').borderRadius(18)
            .padding({ left: 28, right: 28, top: 9, bottom: 9 })
            .margin({ left: 12 })
            .onClick(() => { this.showDeleteWork = false })
        }
        .margin({ top: 20, bottom: 22 })
      }

删除作品弹窗是一个典型的确认对话框,采用居中弹窗设计。与举报弹窗类似,删除按钮使用红色 #FF3B30 背景表示破坏性操作。弹窗内容简洁明了:垃圾桶图标、确认标题、后果说明、取消/删除按钮。

这种确认弹窗是防止用户误操作的重要 UX 设计模式。删除操作不可逆(文案明确说明了"视频、评论与金币收益记录将一并消失"),因此需要二次确认。

13.4 个人头部区域

Column() {
            Column()
              .width('100%').height(70)
              .linearGradient({ angle: 135, colors: [['#FF6B35', 0], ['#3E2723', 1]] })
            Row() {
              Text('🎬').fontSize(38)
                .border({ width: 3, color: '#121212' })
                .borderRadius(38)
            }
            .width(76).height(76).backgroundColor('#232323').borderRadius(38)
            .justifyContent(FlexAlign.Center)
            .margin({ top: -38 })

个人头部区域的实现巧妙地利用了负边距。顶部是一个 70 高度的渐变色块,下方是一个 76x76 的圆形头像容器,通过 .margin({ top: -38 }) 向上偏移 38vp,使头像"骑"在渐变色块和下方内容之间。

头像边框 .border({ width: 3, color: '#121212' }) 使用 3vp 宽的深色边框,与背景色一致,在视觉上创建了头像与渐变色块之间的分离感。borderRadius(38) 是直径 76 的一半,实现正圆效果。

Text('省钱小-video').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 8 })
            Row() {
              Text('Lv.3 活跃达人').fontSize(9).fontColor('#FFD54F')
                .backgroundColor('rgba(255,213,79,0.15)').borderRadius(8)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              Text('🪙 已赚 18,620 金币').fontSize(9).fontColor('#FF6B35')
                .backgroundColor('rgba(255,107,53,0.15)').borderRadius(8)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 6 })
            }
            .margin({ top: 8 })
            Text('"' + this.signContent + '"').fontSize(11).fontColor('#888888').margin({ top: 10 })

用户昵称下方是两个标签:等级标签"Lv.3 活跃达人"(金色背景半透明)和金币累计标签(橙色背景半透明)。签名显示 Text('"' + this.signContent + '"') 用引号包裹签名内容,这里的 signContent 就是前面编辑签名弹窗中可以修改的状态变量。

13.5 数据三格与作品网格

Row() {
            Column() {
              Text('9').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('作品').fontSize(10).fontColor('#888888').margin({ top: 2 })
            }.layoutWeight(1).alignItems(HorizontalAlign.Center)
            Column() {
              Text('1,286').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('粉丝').fontSize(10).fontColor('#888888').margin({ top: 2 })
            }.layoutWeight(1).alignItems(HorizontalAlign.Center)
            Column() {
              Text('8,932').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('获赞').fontSize(10).fontColor('#888888').margin({ top: 2 })
            }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          }
          .width('100%').backgroundColor('#1C1C1C').borderRadius(14).padding({ top: 14, bottom: 14 })
          .margin({ left: 12, right: 12, top: 10 })

数据三格展示作品数(9)、粉丝数(1,286)和获赞数(8,932)。每格使用 layoutWeight(1) 等分宽度,内部 Column 垂直排列数字和标签。数字使用 18 号白色加粗,标签使用 10 号灰色,通过字号和颜色差异建立信息层级。

Row() {
              ForEach(MY_WORKS, (w: string) => {
                Column() {
                  Stack() {
                    Column() {
                      Text(w).fontSize(30)
                    }
                    .width('100%').height(76).backgroundColor('#232323').borderRadius(8)
                    .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
                    Text('🗑').fontSize(11)
                      .position({ x: '78%', y: 4 })
                      .onClick(() => { this.showDeleteWork = true })
                  }
                  .width('100%')
                  Text('2.1万播放').fontSize(8).fontColor('#888888').margin({ top: 4 })
                  Text('+86金币').fontSize(8).fontColor('#FFD54F').margin({ top: 1 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
                .margin({ left: 4, right: 4, top: 6 })
              })
            }

作品网格使用 ForEach(MY_WORKS, (w: string) => { ... }) 遍历 9 个 Emoji 字符。MY_WORKSstring[] 类型,每个元素就是一个 Emoji 字符串。

每个作品卡片使用 Stack 实现层叠:底层是 76 高度的灰色缩略图区域,右上角通过 .position({ x: '78%', y: 4 }) 定位一个删除按钮"🗑"。点击删除按钮触发 showDeleteWork = true 显示删除确认弹窗。

卡片下方显示播放量和金币收益,都是静态文本。layoutWeight(1) 使每个作品卡片等分宽度,9 个卡片在一行中显示,形成 3 列的效果(由于屏幕宽度和卡片宽度的关系,实际可能换行显示)。

13.6 收益概览

Column() {
            Text('💰 我的收益').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              .width('100%').padding({ left: 16, top: 14, bottom: 6 })
            Row() {文到数字标签、按钮文本,几乎所有文本展示都依赖 `Text`。特别是通过 Emoji 字符作为 `Text` 内容来模拟图标,是一种轻量化的开发手段。

### 16.2 Column 与 Row 布局容器

`Column``Row` 是 ArkUI 两大基础线性布局容器。

`Column` 属性:
- `justifyContent(FlexAlign)`: 
        .width('100%')
      }
      .layoutWeight(1).scrollBar(BarState.Off)

      if (this.showEditSign) { this.editSignModal() }
      if (this.showDeleteWork) { this.deleteWorkModal() }
    }
    .width('100%').height('100%')
  }
}


在这里插入图片描述

37.10 总结

本应用在技术层面展现了以下深度:

ArkTS 语言层面:充分利用了 interface 类型系统、enum 枚举、const 常量、函数类型参数等语言特性,代码类型安全且可维护。

ArkUI 框架层面:综合运用了 @Entry@Component@State@Builder 四大装饰器,条件渲染、ForEach 列表渲染、animation 声明式动画、linearGradient 渐变背景、position 绝对定位等框架能力。

布局系统层面:通过 ColumnRowStack 三大布局容器的组合,配合 layoutWeight 弹性布局、百分比布局、固定尺寸、负边距等技巧,实现了复杂的 UI 布局。

状态管理层面:通过 16 个 @State 变量(分布在 4 个组件中)管理了整个应用的交互状态,包括页面导航、弹窗显示/隐藏、用户输入、选中项、视觉效果等多个维度。

交互设计层面:六个功能弹窗覆盖了创作者激励(打赏)、社区互动(评论)、内容治理(举报)、资产管理(兑换)、个人资料(编辑签名)、内容管理(删除作品)等核心交互场景。

数据可视化层面:通过纯 ArkUI 组件实现了柱状图,展示了数据驱动 UI 的能力。

动画系统层面:通过 animation 修饰器实现了点赞缩放动画和柱状图生长动画,展示了声明式动画的用法。

设计系统层面:建立了包含 15+ 色值的深色主题色彩体系和多层级字号体系,确保了视觉一致性。

Logo

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

更多推荐