基于HarmonyOS ArkTS API 24 buildXxx 函数是各实体类型的工厂函数(Factory Function),接收所有字段值作为参数,返回一个完整构造的对象
鸿蒙开发背景与 ArkUI 声明式范式概述
鸿蒙操作系统(HarmonyOS)是华为推出的一款面向万物互联时代的分布式操作系统,其核心设计理念是"一次开发,多端部署"。在鸿蒙的应用开发体系中,ArkUI 框架承担着构建用户界面的核心职责。ArkUI 采用了声明式 UI 编程范式,开发者只需描述界面在任意状态下的样子,框架会自动处理状态变化到界面更新的映射关系,这从根本上改变了传统命令式编程中手动操作 DOM 节点、手动刷新界面的繁琐模式。声明式 UI 的最大优势在于:代码结构清晰、可读性强、易于维护,同时框架层面可以做更激进的渲染优化,因为开发者声明的是"结果"而非"过程",框架可以自行决定最优的更新策略。
ArkTS 是鸿蒙生态中专门为应用开发设计的编程语言,它在 TypeScript 的基础上进行了扩展和约束。ArkTS 保留了 TypeScript 的类型系统优势,同时针对声明式 UI 编程做了大量语法层面的增强。在 ArkTS 中,我们可以使用
interface定义严格类型的数据结构,使用const声明不可变常量数据,使用函数封装可复用的业务逻辑。更重要的是,ArkTS 引入了一批装饰器(Decorator)语法,如@Entry、@Component、@State、@Prop、@Builder等,这些装饰器是 ArkUI 声明式范式的核心基石。通过装饰器,开发者可以用最简洁的语法标记组件的入口、状态变量、构建器函数等关键概念,让编译器在编译阶段就能进行充分的静态分析和优化。
ArkUI 的组件体系是其声明式 UI 范式的具体落地载体。ArkUI 提供了丰富的基础组件和容器组件,基础组件如 Text(文本)、Image(图片)、TextInput(输入框)、Button(按钮)、Progress(进度条)、Toggle(开关)等;容器组件如 Column(纵向布局)、Row(横向布局)、Stack(层叠布局)、Flex(弹性布局)、Scroll(滚动容器)、List(列表)等。这些组件通过链式调用的方式配置属性和事件,形成一种极具表达力的 DSL(领域特定语言)风格。每一个组件都可以通过点语法串联多个属性方法,如 .width()、.height()、.fontSize()、.fontColor()、.backgroundColor()、.borderRadius()、.padding()、.margin() 等,这种链式调用方式让 UI 代码既紧凑又直观。
在状态管理方面,ArkUI 提供了一套精细化的状态驱动机制。@State 装饰器用于声明组件内部的可变状态变量,当这些变量的值发生变化时,框架会自动触发依赖该变量的 UI 片段重新渲染。@Prop 装饰器用于父子组件之间的单向数据传递,父组件数据变化会自动同步到子组件。此外,ArkUI 还提供了 @Builder 装饰器用于定义可复用的 UI 构建函数,@Entry 装饰器标记应用的根组件入口,@Component 装饰器声明一个自定义组件结构体。这些装饰器协同工作,构成了鸿蒙声明式 UI 开发的完整工具链。本文将以一个功能完整的钓鱼直播站应用为例,逐段拆解其代码实现,深入剖析 ArkUI 的各项技术要点。
一、数据建模层:接口定义与类型约束
在任何一款结构良好的应用中,数据建模都是最基础也是最关键的一环。ArkTS 的 interface 语法为我们提供了定义数据结构的强类型手段。下面是本应用中定义的六组核心数据接口。
interface FeedItem {
id: number
name: string
avatar: string
time: string
content: string
likes: number
comments: number
}
interface SpotItem {
id: number
name: string
kind: string
depth: number
fish: number
quota: number
state: number
}
interface LiveItem {
id: number
name: string
title: string
fans: number
watch: number
state: number
}
interface WaterItem {
id: number
name: string
area: number
quota: number
price: number
state: number
}
interface MasterItem {
id: number
name: string
skill: string
honor: number
quota: number
state: number
}
interface MyItem {
id: number
name: string
tag: string
time: string
}

这六组接口分别对应应用中的六大业务实体:渔获动态(FeedItem)、钓点信息(SpotItem)、直播信息(LiveItem)、水域资源(WaterItem)、大师信息(MasterItem)、我的收藏(MyItem)。每一个接口都明确定义了该实体所包含的字段名称和字段类型,这种严格的类型约束是 ArkTS 区别于普通 JavaScript 的核心特征之一。
技术概念强调: 在 ArkTS 中,
interface定义的类型会在编译阶段进行严格检查。如果在代码中试图给一个FeedItem对象赋值时遗漏了某个字段,或者给字段赋了错误类型的值,编译器会立即报错并阻止构建。这种"编译期类型安全"机制能在开发早期就暴露大量潜在 Bug,极大降低了运行时出错的风险。
观察这些接口的设计,我们可以发现几个共同的模式。首先,每一个实体都有一个 id 字段作为唯一标识,这在列表渲染时作为 ForEach 的键值(key)非常重要,能帮助框架高效地进行列表差分更新。其次,state 字段在多个接口中反复出现,它是一个数值类型,用于表示实体的当前状态(如 0 表示可用、1 表示已满),这种用数值编码状态的方式在数据存储和传输上非常经济。
技术概念强调:
ForEach是 ArkUI 中用于列表渲染的核心组件。它接受三个参数:数据数组、子项生成函数、键值生成函数。键值函数返回的字符串用于框架内部的 Diff 算法,当数据变化时,框架通过对比键值来决定哪些列表项需要创建、更新或销毁。因此,为每条数据提供稳定且唯一的id是确保列表高效更新的关键实践。
此外,不同接口根据业务需求定义了不同的字段。FeedItem 包含 likes(点赞数)和 comments(评论数),体现了社交动态的互动属性;SpotItem 包含 depth(水深)和 fish(渔获指数),体现了钓点资源的专业属性;LiveItem 包含 fans(粉丝数)和 watch(观看数),体现了直播的热度指标。这些字段的精心设计,使得每个接口都能精准表达其业务实体的核心信息。
二、静态数据初始化:常量数组的定义
在定义完数据接口之后,应用通过 const 关键字声明了一批静态数据数组。这些数组作为应用的初始数据源,为界面渲染提供内容。下面展示其中几组核心数据。
const FEEDS: FeedItem[] = [
{ id: 1, name: '野钓阿强', avatar: '🎣', time: '12分钟前', content: '昨晚夜钓青龙湖,18 斤大鲤鱼上岸!主线切了三次,换 0.8 子线硬扛,这手感绝了!', likes: 628, comments: 93 },
{ id: 2, name: '路亚小北', avatar: '🐟', time: '35分钟前', content: '白鹭湾路亚翘嘴连竿,亮片搜边三竿中两尾,傍晚窗口期到了,钓友们冲!', likes: 451, comments: 67 },
{ id: 3, name: '矶钓老周', avatar: '🪨', time: '1小时前', content: '双月湾矶钓爆箱,黄鳍鲷排着队上钩,虾仁打窝加南极虾,今天收成十五斤!', likes: 389, comments: 54 },
{ id: 4, name: '筏钓阿斌', avatar: '⛵', time: '2小时前', content: '雾山水库筏钓鲫鱼连竿,半天一百多条,水深 22 米钓中层,串钩挂红虫效果好。', likes: 342, comments: 49 },
{ id: 5, name: '台钓小陈', avatar: '🎯', time: '4小时前', content: '黑坑正钓鲤鱼口好到爆,饵料配方分享:颗粒粉加果酸,散炮带软粘,抢鱼阶段稳拿前三!', likes: 517, comments: 86 },
{ id: 6, name: '冰钓老刘', avatar: '🧊', time: '6小时前', content: '冰窟窿下面全是大鲫鱼,穿得厚就行,冰钓竿加七星漂,一提一个准。', likes: 276, comments: 38 },
{ id: 7, name: '溪流阿泽', avatar: '🏞️', time: '昨天', content: '樱花溪上游马口成群,2.7 米溪流竿配 0.6 主线,羽毛钩慢收,一下午拉到手软。', likes: 305, comments: 42 },
{ id: 8, name: '海钓大鹏', avatar: '🛥️', time: '昨天', content: '出海船钓金枪鱼,搏斗整整两小时终于拿下,深海 60 米铁板抽到没脾气,值了!', likes: 742, comments: 128 }
]

这段代码定义了 FEEDS 常量数组,类型标注为 FeedItem[],表示这是一个元素类型为 FeedItem 的数组。数组中每个对象字面量都严格遵循 FeedItem 接口定义的字段结构,包含了 id、name、avatar、time、content、likes、comments 七个字段。使用 const 关键字声明意味着这个变量绑定不可重新赋值,但要注意数组内部的对象属性在运行时仍然可以被修改。
技术概念强调: ArkTS 中
const声明的数组,其"不可变"仅限于变量绑定层面——不能将FEEDS重新指向另一个数组对象,但数组内部的元素是可以被增删改的(如push、splice、unshift等操作)。这种语义与 TypeScript/JavaScript 一致。在应用运行时,这些初始数据会被赋值给@State状态变量,之后用户操作(如发布新动态、删除收藏)会通过unshift、splice等方法修改数组内容,从而触发界面更新。
数据中值得注意的设计细节是使用了 Emoji 表情符号作为 avatar(头像)字段值,如 '🎣'、'🐟'、'🪨' 等。这是一种轻量化的视觉表达方案,在不需要引入图片资源文件的情况下就能实现直观的图形化展示。在 ArkUI 的 Text 组件中,Emoji 可以直接作为文本内容渲染,无需额外的图片加载逻辑,这对应用的包体积和渲染性能都是友好的。
除了实体数据数组外,应用还定义了两个辅助常量数组:
const HEAT: number[] = [5, 8, 7, 9, 6, 10, 8, 12]
const CATS: string[] = ['全部', '湖库', '河湾', '海钓', '溪流', '黑坑']

HEAT 数组存储了本周水域的热度数值,用于在首页绘制柱状图。CATS 数组存储了钓法分类标签,用于在界面顶部展示可横向滑动的分类筛选条。这两个数组虽然简单,但它们体现了"数据驱动 UI"的设计思想——界面的内容由数据决定,而非硬编码在 UI 结构中。
三、工具函数层:状态映射与数据构建
在数据层和 UI 层之间,应用定义了一批工具函数,承担着"状态映射"和"数据构建"两类职责。先来看状态映射类函数。
function spotStateText(s: number): string {
if (s === 0) {
return '可约钓'
}
return '约满'
}
function spotStateColor(s: number): string {
if (s === 0) {
return '#4ADE80'
}
return '#FB923C'
}
function liveStateText(s: number): string {
if (s === 0) {
return '直播中'
}
return '已结束'
}
function liveStateColor(s: number): string {
if (s === 0) {
return '#FB923C'
}
return '#64748B'
}
function waterStateText(s: number): string {
if (s === 0) {
return '可认领'
}
return '已认满'
}
function waterStateColor(s: number): string {
if (s === 0) {
return '#38BDF8'
}
return '#FB923C'
}
function masterStateText(s: number): string {
if (s === 0) {
return '收徒中'
}
return '已满额'
}
function masterStateColor(s: number): string {
if (s === 0) {
return '#4ADE80'
}
return '#FB923C'
}

这一组函数遵循了统一的命名模式:xxxStateText 返回状态对应的中文描述文本,xxxStateColor 返回状态对应的十六进制颜色值。这些函数将数值类型的状态码(0 或 1)映射为用户可读的文本和视觉上可区分的颜色,实现了"数据状态"到"界面呈现"的转换逻辑。
技术概念强调: 在 ArkUI 开发中,将状态到文本/颜色的映射逻辑提取为独立函数是一种优秀的工程实践。这样做的好处有三:一是避免了 UI 代码中充斥大量三元运算符或 if-else 分支,保持了 UI 代码的简洁性;二是映射逻辑集中管理,修改状态文案或配色时只需改一处;三是函数可以被多个组件复用,保证了同一状态在应用各处的一致性表达。
颜色编码方面,应用采用了一套统一的色彩体系。从文件开头的注释可以看出,整体设计风格为"晨雾水岸风",定义了六个核心色值:背景色 #0B1F2A(深墨蓝)、面板色 #132B38(暗青绿)、水蓝色 #38BDF8、雾白色 #E0F2FE、草绿色 #4ADE80、霞橙色 #FB923C。这些颜色在状态映射函数中被精确使用:绿色系(#4ADE80)表示"可用/积极"状态,橙色系(#FB923C)表示"已满/警示"状态,蓝色系(#38BDF8)表示"信息/中性"状态,灰色系(#64748B)表示"结束/禁用"状态。
接下来看数据格式化和趋势显示函数:
function heatBar(v: number): string {
return (14 + v * 4) + 'vp'
}
function playsText(p: number): string {
if (p >= 10000) {
return (p / 10000).toFixed(1) + '万'
}
return p.toString()
}
function trendText(t: number): string {
if (t > 0) {
return '+' + t + '%'
}
return t + '%'
}
function trendColor(t: number): string {
if (t >= 0) {
return '#38BDF8'
}
return '#FB923C'
}

heatBar 函数将热度数值转换为柱状图的高度字符串,使用 vp(虚拟像素)作为单位。这是一种动态计算尺寸的技巧——柱子高度不是固定的,而是根据数据值线性映射到一个合理范围内(14vp 到 54vp 之间),让不同数据值的柱子在视觉上有明显的高低差异。
playsText 函数实现了观看人数的格式化显示:当数字超过一万时,自动转换为"万"为单位并保留一位小数,如 680000 显示为 "68.0万"。这种"大数据友好显示"在中文应用中非常常见,能让用户更直观地感知数值规模。
技术概念强调:
vp(virtual pixel)是 ArkUI 中的长度单位之一,它会根据屏幕密度自动缩放,确保在不同分辨率设备上呈现相近的物理尺寸。与之对应的还有fp(font pixel,用于字体大小,会受系统字体缩放影响)、px(物理像素,不缩放)等。在heatBar函数中拼接'vp'后缀,是 ArkUI 中以字符串形式表达带单位尺寸的常见写法。
trendText 和 trendColor 函数处理趋势数据的显示:正趋势前加 + 号并用蓝色显示,负趋势用橙色显示。这种"正蓝负橙"的配色策略与股票等数据可视化应用的惯例类似,能让用户一眼判断趋势方向。
再看数据构建类函数:
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 buildSpot(id: number, name: string, kind: string, depth: number, fish: number, quota: number, state: number): SpotItem {
return { id: id, name: name, kind: kind, depth: depth, fish: fish, quota: quota, 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 buildWater(id: number, name: string, area: number, quota: number, price: number, state: number): WaterItem {
return { id: id, name: name, area: area, quota: quota, price: price, state: state }
}
function buildMaster(id: number, name: string, skill: string, honor: number, quota: number, state: number): MasterItem {
return { id: id, name: name, skill: skill, honor: honor, quota: quota, state: state }
}

这组 buildXxx 函数是各实体类型的工厂函数(Factory Function),接收所有字段值作为参数,返回一个完整构造的对象。使用工厂函数而非直接写对象字面量的好处在于:一是参数列表明确标注了类型,编译器能在调用点进行类型检查;二是构造逻辑集中,如果将来需要在创建对象时添加默认值或校验逻辑,只需修改函数内部即可。
最后一个工具函数是 buildWaterBook,它实现了水域认领的业务逻辑:
function buildWaterBook(w: WaterItem): WaterItem {
let q = w.quota - 1
let st = w.state
if (q <= 0) {
q = 0
st = 1
}
return buildWater(w.id, w.name, w.area, q, w.price, st)
}

这个函数接收一个 WaterItem 对象,将其 quota(剩余名额)减一。当名额减到零或以下时,自动将 state 切换为 1(已认满),并将名额钳制(clamp)在 0 而不允许变为负数。这是一个典型的"业务状态转换"函数,封装了认领操作的完整逻辑:减名额、判断是否售罄、更新状态。通过提取为独立函数,这段逻辑可以在多处被复用,且修改时只需改一处。

四、根组件入口:@Entry 与 @Component 装饰器
现在进入应用的核心部分——根组件的定义。在 ArkUI 中,每个页面有且仅有一个被 @Entry 装饰的组件作为入口,而 @Component 装饰器则用于声明一个自定义组件。
@Entry
@Component
struct Index {
@State currentTab: number = 0
@State feeds: FeedItem[] = FEEDS
@State spots: SpotItem[] = SPOTS
@State lives: LiveItem[] = LIVES
@State waters: WaterItem[] = WATERS
@State masters: MasterItem[] = MASTERS
@State mys: MyItem[] = MYS
@State pickedFeed: FeedItem | null = null
@State pickedSpot: SpotItem | null = null
@State pickedLive: LiveItem | null = null
@State pickedWater: WaterItem | null = null
@State pickedMaster: MasterItem | null = null
@State pickedMy: MyItem | null = null
@State showFeedDetail: boolean = false
@State showFeedAdd: boolean = false
@State showSpotDetail: boolean = false
@State showSpotEdit: boolean = false
@State showLiveRule: boolean = false
@State showLiveDetail: boolean = false
@State showLiveAdd: boolean = false
@State showWaterDetail: boolean = false
@State showWaterBook: boolean = false
@State showMasterRule: boolean = false
@State showMasterDetail: boolean = false
@State showMasterSign: boolean = false
@State showMasterQuit: 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 newSpotName: string = ''
@State newSpotKind: string = ''
@State newSpotDepth: string = ''
@State newNick: string = ''
@State catSel: number = 0
这段代码是整个应用的状态管理中枢。@Entry 标记 Index 为页面入口组件,@Component 声明它是一个自定义组件结构体。结构体内部通过大量 @State 装饰器声明了应用运行期间需要追踪的状态变量。
这些状态变量可以分为四类。第一类是数据列表状态:feeds、spots、lives、waters、masters、mys,它们分别在初始化时赋值为前面定义的常量数组。这些数组在运行时会被用户操作修改(如新增动态、删除收藏、认领水域),因此必须用 @State 标记以确保变化能触发界面刷新。
第二类是选中项状态:pickedFeed、pickedSpot、pickedLive、pickedWater、pickedMaster、pickedMy,它们的类型是 XxxItem | null(联合类型,可为具体对象或 null)。这些变量用于记录用户当前点击查看的某条数据,当用户点击列表项时被赋值,弹出详情弹窗时读取这些值显示对应内容。初始值为 null 表示未选中任何项。
技术概念强调:
@State装饰器是 ArkUI 状态管理的基础。被@State修饰的变量,其值变化会被框架自动观察。当值改变时,框架会重新执行build()函数中依赖该变量的 UI 片段,实现"数据变→界面变"的自动更新。注意@State仅能观察变量本身的赋值变化,对于对象内部属性的深层变化或数组元素的修改,ArkUI 通过重写数组方法(如splice、unshift等)来实现观察。因此使用this.feeds.unshift(...)和this.feeds.splice(...)也能触发界面刷新。
第三类是弹窗显示状态:showFeedDetail、showFeedAdd、showSpotDetail、showSpotEdit 等一系列布尔值变量。每个弹窗对应一个布尔开关,true 表示显示、false 表示隐藏。这种"布尔开关控弹窗"的模式在 ArkUI 中非常常见,通过条件渲染(if (this.showXxx))来控制弹窗的显示与隐藏。
第四类是表单输入状态:newFeedName、newFeedText、newSpotName、newSpotKind、newSpotDepth、newNick,用于存储用户在弹窗输入框中输入的内容。这些变量通过 TextInput 组件的 onChange 回调实时更新,提交时读取这些值构建新数据对象。
第五类是选择状态:catSel 用于记录当前选中的分类标签索引,currentTab 用于记录当前激活的底部标签页索引。
五、@Builder 构建器:底部标签栏的复用单元
@Builder 装饰器是 ArkUI 中用于定义可复用 UI 片段的重要工具。下面是底部标签栏单项的构建器定义。
@Builder
tabItem(icon: string, label: string, tab: number) {
Column({ space: 3 }) {
Text(icon)
.fontSize(22)
.fontColor(this.currentTab === tab ? '#38BDF8' : '#7E9BA8')
Text(label)
.fontSize(11)
.fontColor(this.currentTab === tab ? '#38BDF8' : '#7E9BA8')
}
.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 修饰的 tabItem 方法接收三个参数:icon(图标符号)、label(文字标签)、tab(标签编号)。方法体内部构建了一个 Column 容器,其中纵向排列了一个图标 Text 和一个标签 Text。
技术概念强调:
@Builder装饰器定义的方法与普通方法的本质区别在于:@Builder方法的返回值是一段 UI 描述(虚拟 DOM 节点),可以被直接嵌入到build()方法的组件树中。普通方法返回的是数据值,无法作为 UI 节点使用。@Builder方法支持参数传递,使得同一套 UI 模板可以通过不同参数渲染出不同内容,实现了 UI 的参数化复用。
Column 是 ArkUI 的纵向布局容器组件,子元素按从上到下方向排列。{ space: 3 } 参数设置了子元素之间的间距为 3vp。两个 Text 组件的 fontColor 通过三元运算符根据当前激活的标签决定颜色:如果 this.currentTab === tab(当前标签正是此标签),则显示水蓝色 #38BDF8(高亮态);否则显示灰蓝色 #7E9BA8(普通态)。
技术概念强调:
Column容器组件是 ArkUI 三大基础布局容器之一(另两个是Row和Stack)。Column使子元素纵向排列,Row使子元素横向排列。两者都支持justifyContent属性控制主轴方向的对齐方式(如FlexAlign.Center居中、FlexAlign.Start起始端、FlexAlign.End末端、FlexAlign.SpaceBetween两端对齐等),以及alignItems属性控制交叉轴方向的对齐方式。
外层 Column 的 .width('16.66%') 将宽度设为父容器的六分之一(因为底部有 6 个标签),.height('100%') 撑满高度。.justifyContent(FlexAlign.Center) 使图标和文字在纵向居中对齐。
.scale() 属性实现了选中标签的放大效果:当前标签的 x、y 缩放比例为 1.12(放大 12%),未选中标签为 1(原始大小)。配合 .animation() 属性,这个缩放变化会有 200 毫秒的过渡动画,使用 Curve.EaseOut(先快后慢的缓出曲线),给用户一种"选中标签弹出"的视觉反馈。
技术概念强调:
.animation()是 ArkUI 的隐式动画属性。它不需要开发者显式调用动画 API,而是自动监听组件属性变化并产生过渡效果。当.scale()的值从 1 变为 1.12(或反向)时,.animation()配置的duration(时长)和curve(曲线)决定了过渡的节奏。Curve.EaseOut是预定义的缓动曲线之一,动画开始快速、结束缓慢,适合"选中放大"这种强调终态的效果。除了EaseOut,ArkUI 还提供Linear(匀速)、EaseIn(缓入)、EaseInOut(缓入缓出)、FastOutSlowIn等曲线。
.onClick() 事件回调简单地将 this.currentTab 设为当前标签编号 tab。由于 currentTab 是 @State 变量,赋值后框架会自动重新渲染所有依赖它的 UI 片段——包括所有 tabItem 的颜色和缩放状态,以及内容区的标签页切换。
六、build() 主入口:Stack 层叠布局与整体结构
build() 方法是每个组件的核心,它返回组件的 UI 描述。根组件的 build() 方法使用了 Stack 作为最外层容器。
build() {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
// ===== 头部 =====
Column() {
// 头部内容...
}
.width('100%')
.backgroundColor('#132B38')
// ===== 内容区 =====
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) {
SpotContent({
spots: this.spots,
catSel: this.catSel,
showSpotDetail: (item: SpotItem) => {
this.pickedSpot = item
this.showSpotDetail = true
},
showSpotEdit: (item: SpotItem) => {
this.pickedSpot = item
this.newSpotName = item.name
this.newSpotKind = item.kind
this.newSpotDepth = item.depth.toString()
this.showSpotEdit = 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('#132B38')
.border({ width: { top: 1 }, color: '#1E3A4A' })
}
.width('100%')
.height('100%')
.backgroundColor('#0B1F2A')
// ================= 弹窗层 =================
// 各种弹窗的条件渲染...
}
.width('100%')
.height('100%')
}
技术概念强调:
Stack是 ArkUI 的层叠布局容器组件。与Column(纵向排列)和Row(横向排列)不同,Stack将子元素堆叠在一起——后声明的子元素覆盖在先声明的子元素之上,形成"图层"关系。alignContent参数控制子元素在 Stack 内的对齐方式,Alignment.Bottom表示子元素底部对齐。Stack 在本应用中扮演着关键角色:最底层是主界面(头部+内容+底栏),上层是各种弹窗,弹窗通过覆盖在主界面上方实现"模态对话框"效果。
build() 方法内部的 Stack 包含两大部分。第一部分是一个 Column,纵向排列了三个区域:头部 Column、内容区 Column、底部标签栏 Row。内容区使用了 .layoutWeight(1),这是 ArkUI 的权重分配属性。
技术概念强调:
layoutWeight是 ArkUI 中进行弹性空间分配的属性。在一个Column或Row容器中,如果某些子元素设置了固定尺寸(如头部和底栏有明确高度),而另一个子元素设置了layoutWeight(1),那么该子元素会自动占满容器中剩余的所有可用空间。layoutWeight的值是一个权重数,如果多个子元素都设置了layoutWeight,则按权重比例分配空间。本例中内容区layoutWeight(1)使其占据头部和底栏之间的全部剩余高度,实现了"头部固定+内容滚动+底栏固定"的经典三段式布局。
内容区内部的标签页切换逻辑使用了条件渲染。if (this.currentTab === 0) 判断当前标签编号,为 0 时渲染 HomeContent 组件,为 1 时渲染 SpotContent 组件,依此类推。每次切换标签时,currentTab 变化触发 build() 重新执行,对应的条件分支被激活,渲染对应的子组件。
这里值得特别关注的是父子组件通信机制。以 HomeContent 为例:
HomeContent({
feeds: this.feeds,
heat: HEAT,
catSel: this.catSel,
showFeedDetail: (item: FeedItem) => {
this.pickedFeed = item
this.showFeedDetail = true
},
showFeedAdd: () => {
this.showFeedAdd = true
}
})
父组件 Index 在创建子组件 HomeContent 时,通过参数传递了两类信息。第一类是数据属性:feeds、heat、catSel,这些数据从父组件传递到子组件,子组件通过 @Prop 接收。第二类是回调函数:showFeedDetail 和 showFeedAdd,它们是箭头函数,捕获了父组件的 this,在子组件内部被调用时,实际修改的是父组件的状态变量。
技术概念强调: 这种"数据向下、事件向上"的模式是 ArkUI 组件通信的核心范式。父组件通过属性将数据传递给子组件(单向数据流),子组件不能直接修改父组件数据,而是通过调用父组件传入的回调函数来间接通知父组件"有事发生了"。父组件在回调中修改自身状态,状态变化再通过属性传递回流向子组件。这种单向数据流设计保证了数据变化的可追踪性,避免了双向绑定带来的状态混乱问题。
底部标签栏的 Row 容器横向排列了六个 tabItem 构建器调用。.border({ width: { top: 1 }, color: '#1E3A4A' }) 只设置了上边框,实现了底栏与内容区之间的一条分隔线。{ width: { top: 1 } } 这种对象写法允许单独指定某一条边的边框宽度,是 ArkUI 边框属性的精细控制方式。
七、头部区域:渐变背景与视觉设计
头部区域是用户打开应用后首先看到的部分,其视觉设计直接影响用户的第一印象。下面是头部区域的完整代码。
Column() {
Row() {
Row() {
Text('🎣')
.fontSize(26)
Column({ space: 2 }) {
Text('FISH CAST')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text('钓鱼直播站 · 官方')
.fontSize(10)
.fontColor('#7DD3FC')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
}
.alignItems(VerticalAlign.Center)
Text('').layoutWeight(1)
Text('🔔')
.fontSize(22)
.opacity(0.9)
Text('🔍')
.fontSize(22)
.margin({ left: 14 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 10 })
// 晨雾横幅
Column({ space: 6 }) {
Row() {
Text('🌊 晨雾水岸 · 开钓中')
.fontSize(11)
.fontColor('#E0F2FE')
.backgroundColor('rgba(56,189,248,0.25)')
.borderRadius(20)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
Text('').layoutWeight(1)
Text('🔴 直播中 128 场')
.fontSize(11)
.fontColor('#7DD3FC')
}
.width('100%')
Text('钓鱼直播站')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
.letterSpacing(2)
Text('FISH CAST · 水岸晨雾 一竿入梦')
.fontSize(11)
.fontColor('#7DD3FC')
.letterSpacing(1)
// 水波符号带
Row() {
ForEach([12, 16, 10, 18, 14, 11, 17, 13, 15, 12], (s: number, i: number) => {
Text(i % 2 === 0 ? '~' : '≈')
.fontSize(s)
.fontColor(i % 3 === 0 ? '#38BDF8' : (i % 3 === 1 ? '#4ADE80' : '#FB923C'))
.opacity(0.8)
}, (s: number, i: number) => 'wave' + i)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20 })
// 搜索胶囊
Row() {
Text('🔍')
.fontSize(14)
Text('搜索钓点、直播、渔获、大师')
.fontSize(13)
.fontColor('#7DD3FC')
.margin({ left: 6 })
}
.width('90%')
.height(38)
.backgroundColor('rgba(19,43,56,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: [['#082F49', 0.0], ['#132B38', 0.55], ['#0B1F2A', 1.0]]
})
}
.width('100%')
.backgroundColor('#132B38')
头部区域分为两层。第一层是状态栏行 Row,左侧是 Logo 区(图标+应用名称+副标题),右侧是通知和搜索图标。值得注意的是 Text('').layoutWeight(1) 这个看似空白的文本元素——它其实是一个"弹性占位符",利用 layoutWeight(1) 占满中间所有剩余空间,将左右两侧的元素推到两端,实现了经典的"两端对齐"布局效果。
技术概念强调:
Text('').layoutWeight(1)是 ArkUI 中实现弹性布局的一个经典技巧。在一个Row中,如果需要将某些子元素推到左端、某些推到右端,可以在它们之间插入一个设置了layoutWeight(1)的空Text。这个空元素会占据所有可用空间,把右边的元素挤到最右边。这个技巧在应用中被大量使用,是替代 Flex 布局的轻量方案。
第二层是晨雾横幅 Column,使用了 .linearGradient() 实现线性渐变背景。linearGradient 接收 angle(角度)和 colors(颜色断点数组)两个参数。angle: 180 表示渐变方向从上到下。colors 数组中每个元素是 [颜色值, 位置比例] 的二元组,定义了渐变过程中的颜色锚点。本例中从 #082F49(顶部深蓝)经 #132B38(中部暗青)渐变到 #0B1F2A(底部墨蓝),营造出水面晨雾的深邃氛围。
技术概念强调:
linearGradient是 ArkUI 的渐变背景属性,支持线性渐变、角度渐变和径向渐变三种模式。通过精确控制颜色断点的位置比例,可以创造出非常细腻的视觉过渡效果。渐变背景比纯色背景更有层次感和氛围感,在应用的头部、横幅、卡片等视觉重点区域被广泛使用。
横幅中的水波符号带是一个值得细读的 ForEach 应用:
Row() {
ForEach([12, 16, 10, 18, 14, 11, 17, 13, 15, 12], (s: number, i: number) => {
Text(i % 2 === 0 ? '~' : '≈')
.fontSize(s)
.fontColor(i % 3 === 0 ? '#38BDF8' : (i % 3 === 1 ? '#4ADE80' : '#FB923C'))
.opacity(0.8)
}, (s: number, i: number) => 'wave' + i)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20 })
ForEach 遍历一个包含 10 个数字的内联数组 [12, 16, 10, 18, 14, 11, 17, 13, 15, 12]。对于每个元素,生成一个 Text 显示波浪符号——偶数索引显示 '~'、奇数索引显示 '≈'。字体大小直接取数组中的数值 s,因此每个波浪符号的大小都不同,形成高低错落的视觉效果。颜色则通过 i % 3 取模循环使用蓝、绿、橙三色。
技术概念强调:
ForEach的第三个参数是键值生成函数(s: number, i: number) => 'wave' + i,它返回字符串'wave0'、'wave1'… 作为每个列表项的唯一标识。虽然这里的数组是静态的(不会增删),但 ArkUI 仍然要求提供键值函数以保证 Diff 算法的正确性。键值函数的返回值必须满足"同一数据项始终返回同一键值"的稳定性要求。
搜索胶囊是一个 Row 容器,.borderRadius(19) 将其圆角半径设为高度的一半(38/2=19),形成完全圆角的胶囊形状。.backgroundColor('rgba(19,43,56,0.75)') 使用了带透明度的 rgba 颜色,0.75 的透明度让背景能透出底层渐变,增加视觉层次。
八、弹窗系统:条件渲染与模态覆盖
应用的弹窗系统是整个代码中最具特色的架构设计之一。所有弹窗都定义在 Stack 的第二层(主界面之上),通过布尔状态变量控制显示。下面以动态详情弹窗为例进行详细拆解。
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('#E0F2FE')
Text(this.pickedFeed !== null ? this.pickedFeed.time : '')
.fontSize(11)
.fontColor('#7DD3FC')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
Text('').layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#E0F2FE')
.padding(6)
.onClick(() => {
this.showFeedDetail = false
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(this.pickedFeed !== null ? this.pickedFeed.content : '')
.fontSize(15)
.fontColor('#BAE6FD')
.lineHeight(24)
Row() {
Text('👍 渔获点赞 ' + (this.pickedFeed !== null ? this.pickedFeed.likes : 0))
.fontSize(13)
.fontColor('#4ADE80')
.backgroundColor('rgba(74,222,128,0.15)')
.borderRadius(14)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
Text('💬 评论 ' + (this.pickedFeed !== null ? this.pickedFeed.comments : 0))
.fontSize(13)
.fontColor('#38BDF8')
.backgroundColor('rgba(56,189,248,0.15)')
.borderRadius(14)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
}
.width('100%')
Text('🐟 鱼获 18.6 斤 · 主线 0.8 · 夜光漂')
.fontSize(12)
.fontColor('#FB923C')
.backgroundColor('rgba(251,146,60,0.12)')
.borderRadius(10)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.width('100%')
}
.width('88%')
.padding(18)
.backgroundColor('#132B38')
.borderRadius(14)
.border({ width: 1, color: '#38BDF8' })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(11,31,42,0.88)')
.justifyContent(FlexAlign.Center)
}
弹窗的结构分为两层。外层 Column 覆盖整个屏幕(width('100%') + height('100%')),背景色为半透明深色 rgba(11,31,42,0.88),这层半透明遮罩(mask)使背后的主界面变暗,视觉上"退后",让用户的注意力集中到弹窗内容上。.justifyContent(FlexAlign.Center) 使内层内容在纵向居中显示。
内层 Column 是实际的弹窗卡片,宽度为屏幕的 88%,留出两侧的边距让弹窗有"浮于"屏幕之上的视觉感。.borderRadius(14) 设为圆角卡片,.border({ width: 1, color: '#38BDF8' }) 加上一圈水蓝色边框,与主题色呼应。
技术概念强调: 条件渲染
if (this.showXxx)是 ArkUI 中实现弹窗显示/隐藏的核心机制。当布尔变量从false变为true时,条件分支内的 UI 被创建并插入组件树;从true变为false时,UI 被销毁移除。这种方式与"始终渲染但通过 visibility 属性控制显隐"的方式不同——条件渲染在隐藏时不占用任何渲染资源,但显隐切换时会有创建/销毁的开销。对于弹窗这种"大部分时间隐藏"的场景,条件渲染是更优的选择。
弹窗内容中反复出现的 this.pickedFeed !== null ? this.pickedFeed.xxx : '' 模式值得特别说明。由于 pickedFeed 的类型是 FeedItem | null(可能为 null),直接访问 this.pickedFeed.name 会在编译期报错(不能保证不为 null)。因此每次访问都需要进行 null 检查。这里使用了三元运算符:如果不为 null 则取实际值,为 null 则返回空字符串。这是一种防御性编程实践,确保即使状态异常也不会导致运行时崩溃。
技术概念强调: ArkTS 的空安全(Null Safety)机制要求开发者显式处理可能为 null 的情况。对于
XxxItem | null联合类型,访问属性前必须先判断不为 null。这虽然在代码层面增加了一些样板代码,但从根本上消除了空指针异常——这类 Bug 在传统 JavaScript 中是最常见的运行时错误之一。ArkTS 还提供了非空断言操作符!(如this.pickedFeed!.name),在开发者确信变量不为 null 时可跳过检查,但需谨慎使用。
关闭按钮 Text('✕') 的 .onClick() 回调将 this.showFeedDetail 设为 false,触发条件渲染移除整个弹窗。这种"点击关闭按钮→翻转布尔开关→条件渲染销毁弹窗"的模式在本应用的所有弹窗中反复使用,形成了一致的交互范式。
弹窗中的内容展示也有值得学习的地方。点赞数和评论数使用带半透明背景色和圆角的 Text 呈现为"标签胶囊"样式,不同数据用不同颜色区分(点赞绿色、评论蓝色),视觉上一目了然。最后的鱼获信息行使用了橙色系,与前两者形成色彩层次区分。
九、表单弹窗:TextInput 输入框与数据提交
发布渔获动态弹窗展示了表单交互的完整流程,包括输入框、标签选择和提交逻辑。
if (this.showFeedAdd) {
Column() {
Column({ space: 12 }) {
Row() {
Text('🎣 发布渔获动态')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text('').layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#E0F2FE')
.padding(6)
.onClick(() => {
this.showFeedAdd = false
})
}
.width('100%')
TextInput({ placeholder: '你的昵称', text: this.newFeedName })
.width('100%')
.height(42)
.fontSize(14)
.fontColor('#E0F2FE')
.backgroundColor('#0B1F2A')
.borderRadius(8)
.onChange((v: string) => {
this.newFeedName = v
})
TextInput({ placeholder: '分享今天的渔获...', text: this.newFeedText })
.width('100%')
.height(88)
.fontSize(14)
.fontColor('#E0F2FE')
.backgroundColor('#0B1F2A')
.borderRadius(8)
.onChange((v: string) => {
this.newFeedText = v
})
Row() {
Text('🌊 野钓')
.fontSize(12)
.fontColor('#38BDF8')
.backgroundColor('rgba(56,189,248,0.15)')
.borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
Text('🎣 路亚')
.fontSize(12)
.fontColor('#4ADE80')
.backgroundColor('rgba(74,222,128,0.15)')
.borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
Text('🐟 台钓')
.fontSize(12)
.fontColor('#FB923C')
.backgroundColor('rgba(251,146,60,0.15)')
.borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
}
.width('100%')
Text('发布')
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.backgroundColor('#38BDF8')
.borderRadius(8)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#082F49')
.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('#132B38')
.borderRadius(14)
.border({ width: 1, color: '#4ADE80' })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(11,31,42,0.88)')
.justifyContent(FlexAlign.Center)
}
技术概念强调:
TextInput是 ArkUI 的文本输入框组件,支持用户输入文本内容。它通过{ placeholder: '...', text: '...' }参数配置占位提示文本和当前值。placeholder在输入框为空时显示灰色提示文字,text用于绑定当前输入值。.onChange((v: string) => { ... })回调在用户输入时触发,参数v是当前输入框的完整文本内容。在回调中将v赋值给@State变量,实现了"输入框内容→状态变量"的同步。
两个 TextInput 分别用于输入昵称(高度 42vp,单行)和渔获内容(高度 88vp,多行区域)。它们的 .onChange() 回调分别更新 this.newFeedName 和 this.newFeedText 两个状态变量。这种"输入框 onChange 实时更新状态"的模式是 ArkUI 表单处理的标准做法,状态变量始终持有最新的输入值。
标签选择行展示了三个可选标签(野钓、路亚、台钓),用不同颜色的胶囊样式呈现。虽然这里标签目前是静态展示(未绑定选中逻辑),但从设计意图可以看出,这是为用户选择动态分类预留的 UI 位置。
提交按钮 Text('发布') 的 .onClick() 回调包含了核心的业务逻辑:
.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
})
技术概念强调:
unshift是 JavaScript/ArkTS 数组的原生方法,在数组头部插入一个新元素。在本应用中,新发布的动态通过unshift插入到feeds数组的最前面,使其在列表渲染时显示在最顶部——这符合"最新内容优先展示"的社交应用惯例。由于feeds是@State变量,unshift操作会被 ArkUI 的数组观察机制捕获,自动触发ForEach列表的增量更新,在列表头部插入一个新的卡片 UI。
这段逻辑首先检查昵称和内容是否都非空(简单的表单校验),满足条件时调用 buildFeed 工厂函数构造一个新的 FeedItem 对象。新对象的 id 使用 this.feeds.length + 1 生成,头像固定为 '🎣',时间显示为"刚刚",点赞和评论初始为 0。插入后清空输入状态变量,最后无论是否提交都关闭弹窗。
十、编辑功能:数组查找与 splice 替换
钓点编辑弹窗展示了"查找→修改→替换"的数据更新模式,这是一种比单纯插入更复杂的数组操作。
Text('保存修改')
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.backgroundColor('#FB923C')
.borderRadius(8)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#0B1F2A')
.onClick(() => {
if (this.pickedSpot !== null) {
let idx = this.spots.findIndex((it: SpotItem) => it.id === this.pickedSpot!.id)
if (idx >= 0) {
let d = parseInt(this.newSpotDepth)
if (isNaN(d)) {
d = this.pickedSpot.depth
}
this.spots.splice(idx, 1, buildSpot(this.pickedSpot.id, this.newSpotName, this.newSpotKind, d, this.pickedSpot.fish, this.pickedSpot.quota, this.pickedSpot.state))
}
}
this.showSpotEdit = false
})
这段保存逻辑的流程是:首先确认 pickedSpot 不为 null,然后通过 findIndex 在 spots 数组中找到与当前选中项 ID 匹配的元素索引。findIndex 接收一个箭头函数作为谓词(predicate),遍历数组返回第一个满足条件的元素索引,找不到返回 -1。
技术概念强调:
splice是 JavaScript/ArkTS 数组的原生方法,功能强大——可以删除指定位置的元素、在指定位置插入新元素,或同时完成删除和插入。splice(idx, 1, newItem)表示从索引idx处删除 1 个元素,并在同一位置插入newItem,实现"原地替换"效果。ArkUI 的状态观察机制能识别splice对@State数组的修改,触发对应列表项的 UI 更新。
水深字段的处理展示了数据健壮性的考量。parseInt(this.newSpotDepth) 尝试将输入的字符串转为整数。如果用户输入了非数字内容(如字母或留空),parseInt 返回 NaN(Not a Number)。isNaN(d) 检查捕获这种情况,回退使用原始的 this.pickedSpot.depth 值,确保数据不会被无效输入污染。
技术概念强调:
parseInt是将字符串解析为整数的全局函数。如果字符串不是合法数字,返回NaN。isNaN函数用于检测一个值是否为NaN。在表单输入场景中,用户可能输入任何内容,对数字型字段进行parseInt+isNaN的双重处理是防御性编程的常见模式,确保即使输入无效也不会导致后续计算崩溃。
这里使用了非空断言操作符 !:this.pickedSpot!.id。虽然外层已经检查了 this.pickedSpot !== null,但 ArkTS 编译器在闭包内部不会自动延续外层的 null 检查结果(因为在闭包执行时变量可能已被其他代码修改),因此需要在访问属性时使用 ! 断言告知编译器"此处确定不为 null"。
十一、首页内容组件:Scroll 滚动与 ForEach 列表
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('#E0F2FE')
.backgroundColor('rgba(251,146,60,0.3)')
.borderRadius(20)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
Text('').layoutWeight(1)
Text('昨日 2.4 万条渔获')
.fontSize(11)
.fontColor('#7DD3FC')
}
.width('100%')
Text('青龙湖夜钓爆护:18 斤大鲤登顶本周渔获榜')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
.lineHeight(24)
Row() {
Text('🎣 钓友阿强')
.fontSize(12)
.fontColor('#7DD3FC')
Text('').layoutWeight(1)
Text('🚀 热度 ' + trendText(26))
.fontSize(12)
.fontColor(trendColor(26))
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor('#132B38')
.borderRadius(12)
.border({ width: 1, color: '#38BDF8' })
// ... 更多内容
}
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
.backgroundColor('#0B1F2A')
}
}
技术概念强调:
@Prop装饰器用于父子组件之间的单向数据传递。父组件通过属性将数据传递给子组件的@Prop变量,当父组件数据变化时,变化会自动同步到子组件。但子组件不能修改@Prop变量(单向约束),这保证了数据流的单向性和可预测性。@Prop适合传递值类型数据或只读的数组引用。与@Prop对应的还有@Link(双向同步)和@ObjectLink(嵌套对象同步),分别用于不同的数据同步场景。
HomeContent 组件通过 @Prop 接收三个数据属性:feeds、heat、catSel。同时声明了两个普通方法属性 showFeedDetail 和 showFeedAdd 作为回调函数(注意它们没有装饰器,而是直接赋值为默认的空函数)。这种写法是 ArkUI 子组件接收父组件回调的标准模式。
技术概念强调:
Scroll是 ArkUI 的滚动容器组件,使其子内容可以滚动浏览。.scrollable(ScrollDirection.Vertical)设置为纵向滚动,.scrollBar(BarState.Off)隐藏滚动条(但滚动功能仍然可用)。Scroll是处理"内容超出屏幕"场景的核心组件,几乎所有信息流类界面都需要Scroll包裹。Scroll内部通常只放一个Column或Row作为内容容器。
头条横幅卡片展示了信息层次的设计。顶部是一个小标签(橙色背景圆角胶囊),中间是大标题(17vp 粗体),底部是作者和热度信息。.border({ width: 1, color: '#38BDF8' }) 给卡片加了水蓝色边框,使其在深色背景中更突出。.lineHeight(24) 设置文本行高为 24vp,让多行文本有舒适的呼吸感。
首页中还有一个柱状图区域,用纯组件方式实现了数据可视化:
Row() {
ForEach(this.heat, (v: number, i: number) => {
Column({ space: 4 }) {
Column()
.width(24)
.height(heatBar(v))
.backgroundColor(i % 2 === 0 ? '#38BDF8' : '#4ADE80')
.borderRadius(4)
Text('周' + (i + 1))
.fontSize(9)
.fontColor('#7E9BA8')
}
.width('12.5%')
.justifyContent(FlexAlign.End)
}, (v: number, i: number) => 'h' + i)
}
.width('100%')
.height(90)
.alignItems(VerticalAlign.Bottom)
这个柱状图的实现思路非常巧妙。外层 Row 横向排列多个柱子,每个柱子占宽度 '12.5%'(1/8,共 8 根柱子正好填满)。每根柱子是一个 Column,内部包含一个空 Column 作为柱体本体和一个 Text 作为标签。
柱体的高度通过 heatBar(v) 函数动态计算,该函数返回 (14 + v * 4) + 'vp' 字符串——热度值越大,柱子越高。背景色通过索引取模交替使用蓝色和绿色,形成双色柱状图。.alignItems(VerticalAlign.Bottom) 使外层 Row 的子元素底部对齐,所有柱子从底部生长向上,符合柱状图的视觉惯例。
技术概念强调: ArkUI 没有内置的图表组件,但通过
Column、Row等基础容器和动态计算高度,可以实现柱状图、进度条等数据可视化效果。这种"用基础组件拼装图表"的方式灵活性极高,开发者可以完全控制图表的视觉细节。对于更复杂的图表需求,可以使用第三方图表库或XComponent组件嵌入自定义渲染。
渔获动态列表是首页的核心内容,通过 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('#E0F2FE')
Text(item.time)
.fontSize(10)
.fontColor('#7E9BA8')
}
.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('#BAE6FD')
.lineHeight(22)
Row() {
Text('👍 ' + item.likes)
.fontSize(12)
.fontColor('#7DD3FC')
Text('💬 ' + item.comments)
.fontSize(12)
.fontColor('#7DD3FC')
.margin({ left: 16 })
Text('').layoutWeight(1)
Text('查看详情 ›')
.fontSize(12)
.fontColor('#FB923C')
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor('#132B38')
.borderRadius(12)
.onClick(() => {
this.showFeedDetail(item)
})
}, (item: FeedItem) => 'f' + item.id)
每条动态卡片是一个 Column,内部纵向排列三部分:头部行(头像+名称+时间+鱼图标)、正文内容、底部统计行(点赞数+评论数+查看详情链接)。整个卡片的 .onClick() 调用 this.showFeedDetail(item) 回调,将当前项传递给父组件。
技术概念强调:
ForEach的键值函数(item: FeedItem) => 'f' + item.id返回'f1'、'f2'… 等唯一字符串。当用户发布新动态时,unshift在数组头部插入新元素,ForEach的 Diff 算法通过键值发现新增了一个'f9'(假设新 id 为 9),于是只创建一个新卡片插入到列表头部,已有的 8 个卡片不会重新创建。这种增量更新机制是ForEach高性能列表渲染的关键。
卡片设计中的细节值得注意。.maxLines(1) 和 .textOverflow({ overflow: TextOverflow.Ellipsis }) 这对属性组合用于文本截断——当文本超出容器宽度时,限制为一行显示并在末尾添加省略号。这在列表项中很常用,防止单条内容过长破坏整体布局。
十二、钓点内容组件:双列网格与分类筛选
SpotContent 组件展示了一个双列网格布局的钓点列表,以及横向滑动的分类筛选条。
@Component
struct SpotContent {
@Prop spots: SpotItem[]
@Prop catSel: number
showSpotDetail: (item: SpotItem) => void = (item: SpotItem) => {}
showSpotEdit: (item: SpotItem) => void = (item: SpotItem) => {}
build() {
Scroll() {
Column({ space: 12 }) {
// 分类横滑
Scroll() {
Row({ space: 8 }) {
ForEach(CATS, (c: string, i: number) => {
Text(c)
.fontSize(12)
.fontColor(this.catSel === i ? '#0B1F2A' : '#7DD3FC')
.backgroundColor(this.catSel === i ? '#38BDF8' : 'rgba(56,189,248,0.12)')
.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%')
// ... 双列钓点卡片
}
}
}
}
分类筛选条使用了嵌套的 Scroll 组件——外层是纵向滚动(钓点列表),内层是横向滚动(分类标签)。.scrollable(ScrollDirection.Horizontal) 设置为横向滚动模式。标签的选中态通过颜色反转实现:选中时文字深色、背景亮蓝;未选中时文字浅蓝、背景淡蓝。
技术概念强调:
ScrollDirection枚举定义了滚动方向:Horizontal(横向)、Vertical(纵向)。一个Scroll组件只能设置一个方向的滚动。如果需要同时支持横纵滚动(如可左右滑动的分类条+可上下滚动的列表),需要嵌套使用两个Scroll,分别设置不同方向。外层纵向Scroll包裹整个内容,内层横向Scroll包裹分类条。
双列钓点卡片的实现方式比较特殊——不是用 Grid 网格组件,而是用 ForEach 遍历索引数组 [0, 2, 4, 6, 8],每次迭代渲染一行 Row 包含两个 Column 卡片:
ForEach([0, 2, 4, 6, 8], (i: number) => {
Row({ space: 10 }) {
Column({ space: 6 }) {
// 第 i 个钓点卡片内容
Text(this.spots[i].name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
// ... 更多字段
}
.layoutWeight(1)
.padding(10)
.backgroundColor('#132B38')
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
Column({ space: 6 }) {
// 第 i+1 个钓点卡片内容
Text(this.spots[i + 1].name)
// ... 更多字段
}
.layoutWeight(1)
.padding(10)
.backgroundColor('#132B38')
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
}, (i: number) => 'srow' + i)
技术概念强调: 这种"遍历步进索引+每行两个卡片"的方式是实现双列网格的技巧之一。每个
Column设置.layoutWeight(1)使两个卡片平分Row的宽度,{ space: 10 }设置卡片之间的间距。相比Grid组件,这种方式的好处是可以在两个卡片中放置完全不同的布局结构,灵活性更高。但缺点是代码量较大(每个卡片的内容需要写两遍),且如果数据项数为奇数需要特殊处理最后一行的单个卡片。
每个钓点卡片内部包含了丰富的信息:状态标签(可约钓/约满)、钓点名称、类型和水深、渔获指数和名额数、详情和编辑按钮。这些信息通过 Column 和 Row 的组合实现了层次清晰的卡片布局。
技术概念强调:
.alignItems(HorizontalAlign.Start)在Column容器中设置子元素的横向对齐方式为起始端(左对齐)。Column的默认alignItems是HorizontalAlign.Center(居中),当需要左对齐时需显式设置。与之对应,Row的alignItems默认是VerticalAlign.Center,可通过VerticalAlign.Top或VerticalAlign.Bottom改为顶部或底部对齐。
十三、直播内容组件:状态驱动的视觉变化
LiveContent 组件展示了如何根据数据状态动态改变 UI 视觉表现。
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('#E0F2FE')
Text(item.title)
.fontSize(11)
.fontColor('#7DD3FC')
.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) === '#64748B' ? 'rgba(100,116,139,0.2)' : 'rgba(251,146,60,0.15)')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Text('👥 ' + item.fans + ' 钓友')
.fontSize(11)
.fontColor('#7E9BA8')
Text('').layoutWeight(1)
Text('👀 ' + playsText(item.watch))
.fontSize(11)
.fontColor('#FB923C')
}
.width('100%')
Row() {
Text('进入直播间')
.textAlign(TextAlign.Center)
.width('100%')
.height(32)
.backgroundColor(item.state === 0 ? '#38BDF8' : 'rgba(100,116,139,0.3)')
.borderRadius(8)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(item.state === 0 ? '#082F49' : '#7E9BA8')
.onClick(() => {
this.showLiveDetail(item)
})
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor('#132B38')
.borderRadius(12)
.onClick(() => {
this.showLiveDetail(item)
})
}, (item: LiveItem) => 'l' + item.id)
这个直播列表卡片最值得关注的是多层状态驱动的视觉变化。首先是图标:正在直播的显示红色圆点 '🔴',已结束的显示黑色圆点 '⚫'。其次是状态标签:通过 liveStateText() 和 liveStateColor() 函数动态生成文本和颜色。更复杂的是背景色的判断:
.backgroundColor(liveStateColor(item.state) === '#64748B' ? 'rgba(100,116,139,0.2)' : 'rgba(251,146,60,0.15)')
这行代码先调用 liveStateColor 获取状态颜色值,再判断该颜色是否为灰色 #64748B(已结束),据此选择不同的半透明背景色。这种"根据状态颜色推断背景色"的写法虽然有些绕,但实现了视觉语义的一致性:正在直播的标签用橙色系背景,已结束的用灰色系背景。
技术概念强调: 三元运算符
? :是 ArkUI 中实现条件视觉的最常用工具。在链式属性调用中,三元运算符可以根据条件返回不同的属性值,如.backgroundColor(condition ? colorA : colorB)。这种写法让 UI 的视觉表现直接由数据驱动,无需手动操作 DOM 样式。当状态变化时,@State变量触发重新渲染,三元运算符重新求值,组件属性自动更新。
"进入直播间"按钮的样式也根据状态变化:正在直播的按钮为亮蓝色背景+深色文字(可操作的视觉暗示),已结束的为灰色半透明背景+灰色文字(不可操作的视觉暗示)。这种通过颜色明暗传达可操作性的设计是移动端交互的重要原则。
十四、水域内容组件:进度条与条件交互
WaterContent 组件引入了进度条这种新的可视化元素,展示认领名额的消耗情况。
ForEach(this.waters, (item: WaterItem) => {
Column({ space: 8 }) {
Row() {
Text('🌊')
.fontSize(22)
Column({ space: 2 }) {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text('面积 ' + item.area + ' 亩 · ¥' + item.price + '/月')
.fontSize(11)
.fontColor('#7DD3FC')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
Text('').layoutWeight(1)
Text(waterStateText(item.state))
.fontSize(10)
.fontColor(waterStateColor(item.state))
.backgroundColor('rgba(56,189,248,0.15)')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 认领进度条
Row() {
Row()
.width((item.quota * 6) + '%')
.height(8)
.backgroundColor(item.state === 0 ? '#38BDF8' : '#FB923C')
.borderRadius(4)
}
.width('100%')
.height(8)
.backgroundColor('rgba(224,242,254,0.15)')
.borderRadius(4)
Row() {
Text('剩余名额 ' + item.quota + ' 席')
.fontSize(11)
.fontColor('#7E9BA8')
Text('').layoutWeight(1)
Text('详情 ›')
.fontSize(12)
.fontColor('#38BDF8')
.onClick(() => {
this.showWaterDetail(item)
})
Text('认领')
.fontSize(12)
.fontColor(item.state === 0 ? '#4ADE80' : '#7E9BA8')
.margin({ left: 14 })
.onClick(() => {
if (item.state === 0) {
this.showWaterBook(item)
}
})
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor('#132B38')
.borderRadius(12)
.onClick(() => {
this.showWaterDetail(item)
})
}, (item: WaterItem) => 'w' + item.id)
进度条的实现采用了嵌套 Row 的方式:外层 Row 是轨道(track),内层 Row 是填充条(fill)。外层宽度 100%、高度 8vp,背景为半透明白色作为轨道底色。内层宽度通过 (item.quota * 6) + '%' 动态计算——名额越多,填充比例越大。背景色根据状态选择蓝色或橙色。
技术概念强调: ArkUI 内置了
Progress进度组件,可以直接使用Progress({ value: 50, total: 100 })创建进度条。但本例使用嵌套Row手动实现进度条,这是因为需要更精细的视觉控制——自定义的圆角、颜色、渐变等效果可能超出Progress组件的属性范围。这种"用基础容器拼装自定义控件"的方式在 ArkUI 开发中非常常见。
认领按钮的 .onClick() 回调包含了条件判断:
.onClick(() => {
if (item.state === 0) {
this.showWaterBook(item)
}
})
只有当水域状态为 0(可认领)时,点击认领才会触发认领弹窗。已认满的水域(state 为 1)点击认领不会有任何反应。这种"状态守卫"逻辑防止了用户对不可操作项的误操作。同时,按钮文字颜色也根据状态变化——可认领时为绿色,已满时为灰色,从视觉上告知用户当前是否可操作。
十五、大师内容组件:排行榜与排名标识
MasterContent 组件展示了排行榜的设计模式,包括排名编号和前三名特殊高亮。
ForEach(this.masters, (item: MasterItem, index: number) => {
Row() {
Text('№' + (index + 1))
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(index === 0 ? '#FB923C' : (index < 3 ? '#38BDF8' : '#7E9BA8'))
.width(36)
Column({ space: 4 }) {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text('擅长 ' + item.skill + ' · 荣誉 ' + item.honor)
.fontSize(11)
.fontColor('#7DD3FC')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 4 }) {
Text(masterStateText(item.state))
.fontSize(10)
.fontColor(masterStateColor(item.state))
.backgroundColor('rgba(74,222,128,0.15)')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
Text('剩余 ' + item.quota + ' 席')
.fontSize(10)
.fontColor('#7E9BA8')
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(12)
.backgroundColor('#132B38')
.borderRadius(12)
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.showMasterDetail(item)
})
}, (item: MasterItem, index: number) => 'm' + item.id)
技术概念强调:
ForEach的子项生成函数可以接收第二个参数index(索引),在遍历时从 0 开始递增。利用index可以实现排名编号、隔行变色、前三名特殊标识等与位置相关的逻辑。注意index是数组中的位置索引,如果列表项被重新排序,index会随之变化,因此不应将index用作数据的唯一标识。
排名编号 №1、№2… 的颜色使用了三层嵌套的三元运算符:
.fontColor(index === 0 ? '#FB923C' : (index < 3 ? '#38BDF8' : '#7E9BA8'))
第一名用橙色 #FB923C(金银铜牌中的"金"色系)、第二三名用蓝色 #38BDF8(银牌色系)、第四名及以后用灰色 #7E9BA8。这种通过索引位置区分视觉层级的设计在排行榜类界面中非常经典。
排行榜卡片的布局采用了"三段式"横向排列:左侧排名编号(固定宽度 36vp)、中间名称和擅长领域(layoutWeight(1) 占据剩余空间)、右侧状态和名额。.alignItems(HorizontalAlign.End) 使右侧信息列的子元素右对齐,与左侧的左对齐形成对称布局。
十六、我的内容组件:用户档案与设置项
MeContent 是最后一个内容组件,展示了用户个人档案、四宫格快捷入口、收藏列表和设置项。
@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('#E0F2FE')
Text('🎖️ Lv.18 夜钓达人')
.fontSize(12)
.fontColor('#FB923C')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
Text('').layoutWeight(1)
Text('✏️')
.fontSize(18)
.fontColor('#38BDF8')
.onClick(() => {
this.showMyNick()
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Text('渔获 328 条')
.fontSize(12)
.fontColor('#7DD3FC')
Text('')
.fontSize(12)
.layoutWeight(1)
Text('足迹 46 处')
.fontSize(12)
.fontColor('#7DD3FC')
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor('#132B38')
.borderRadius(12)
.border({ width: 1, color: '#38BDF8' })
// ... 四宫格、收藏列表、设置项
}
}
}
}
用户档案卡的设计是一个标准的"头像+信息+操作"三段横向布局。左侧是 Emoji 头像(40vp 大字体),中间是用户名和等级标签,右侧是编辑按钮。底部的统计行(渔获数、足迹数)使用了 Text('').layoutWeight(1) 作为分隔占位符。
四宫格快捷入口使用了 Row 横向排列四个 Column,每个 Column 设置 .layoutWeight(1) 平分宽度。这种"四等分横向网格"是个人页面的经典布局模式。
收藏列表的每项卡片中有一个删除按钮,点击触发移除确认弹窗:
Text('✕')
.fontSize(14)
.fontColor('#FB923C')
.margin({ left: 12 })
.onClick(() => {
this.showMyRemove(item)
})
技术概念强调: 在列表项中嵌入操作按钮时,需要注意事件冒泡(Event Bubbling)问题。如果整个卡片有
.onClick()且内部按钮也有.onClick(),点击按钮时会同时触发两个事件。ArkUI 的事件系统会处理这种情况,内部元素的点击事件会被优先处理。但如果需要阻止事件冒泡,可以使用stopPropagation或调整事件绑定策略。在本应用中,收藏列表项本身没有绑定点击事件(不同于直播列表),因此不存在冒泡冲突。
设置项部分展示了两个设置行(清理缓存、退出登录),每行都是 Row 布局:左侧图标和文字、右侧状态值和箭头。.onClick() 绑定对应的弹窗触发回调。
十七、水域认领弹窗:数据更新与状态流转
水域认领确认弹窗展示了完整的"确认→更新数据→状态流转"业务流程。
if (this.showWaterBook) {
Column() {
Column({ space: 12 }) {
Row() {
Text('✅ 确认认领')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text('').layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#E0F2FE')
.padding(6)
.onClick(() => {
this.showWaterBook = false
})
}
.width('100%')
Text('你即将认领「' + (this.pickedWater !== null ? this.pickedWater.name : '') + '」\n剩余名额 ' + (this.pickedWater !== null ? this.pickedWater.quota : 0) + ' 席')
.fontSize(14)
.fontColor('#BAE6FD')
.lineHeight(24)
Text('确认认领')
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.backgroundColor('#4ADE80')
.borderRadius(8)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#0B1F2A')
.onClick(() => {
if (this.pickedWater !== null) {
let idx = this.waters.findIndex((it: WaterItem) => it.id === this.pickedWater!.id)
if (idx >= 0) {
this.waters.splice(idx, 1, buildWaterBook(this.waters[idx]))
}
}
this.showWaterBook = false
})
}
.width('88%')
.padding(18)
.backgroundColor('#132B38')
.borderRadius(14)
.border({ width: 1, color: '#4ADE80' })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(11,31,42,0.88)')
.justifyContent(FlexAlign.Center)
}
确认按钮的 .onClick() 回调执行了完整的认领流程:先通过 findIndex 定位水域在数组中的位置,然后调用 buildWaterBook 函数生成更新后的 WaterItem 对象(名额减一、可能状态翻转),最后用 splice 原地替换数组中的旧对象。
技术概念强调: 不可变数据(Immutable Data)更新模式在声明式 UI 中至关重要。与其直接修改对象属性
this.waters[idx].quota -= 1,不如用buildWaterBook构造一个全新的对象再splice替换。虽然 ArkUI 的@State能观察splice操作,但创建新对象的方式更安全——它保证了旧对象的引用不会意外被其他代码修改,且在调试时更容易追踪状态变化历史。
十八、退赛/移除弹窗:确认对话与数组删除
退出拜师弹窗和收藏移除弹窗展示了两步确认的交互模式——用户需要点击"取消"或"确认"来决定是否执行破坏性操作。
if (this.showMasterQuit) {
Column() {
Column({ space: 12 }) {
Row() {
Text('⚠️ 退出拜师')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text('').layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#E0F2FE')
.padding(6)
.onClick(() => {
this.showMasterQuit = false
})
}
.width('100%')
Text('确认退出「' + (this.pickedMaster !== null ? this.pickedMaster.name : '') + '」的拜师?\n退出后学徒积分清零。')
.fontSize(14)
.fontColor('#BAE6FD')
.lineHeight(24)
Row() {
Text('取消')
.textAlign(TextAlign.Center)
.width('46%')
.height(40)
.backgroundColor('rgba(100,116,139,0.3)')
.borderRadius(8)
.fontSize(14)
.fontColor('#BAE6FD')
.onClick(() => {
this.showMasterQuit = false
})
Text('确认退出')
.textAlign(TextAlign.Center)
.width('46%')
.height(40)
.backgroundColor('#FB923C')
.borderRadius(8)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#0B1F2A')
.onClick(() => {
if (this.pickedMaster !== null) {
let idx = this.masters.findIndex((it: MasterItem) => it.id === this.pickedMaster!.id)
if (idx >= 0) {
this.masters.splice(idx, 1)
}
}
this.showMasterQuit = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.width('88%')
.padding(18)
.backgroundColor('#132B38')
.borderRadius(14)
.border({ width: 1, color: '#FB923C' })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(11,31,42,0.88)')
.justifyContent(FlexAlign.Center)
}
这个弹窗包含了两个按钮:取消和确认退出。按钮布局使用了 .justifyContent(FlexAlign.SpaceBetween) 使两个按钮分别贴向左右两端。两个按钮的宽度都是 '46%',中间留出约 8% 的间距。
技术概念强调:
FlexAlign.SpaceBetween是弹性布局的对齐模式之一,使子元素在主轴方向两端对齐,元素之间的间距均匀分配。与之类似的还有FlexAlign.SpaceAround(每个元素两侧间距相等,包括首尾)和FlexAlign.SpaceEvenly(所有间距完全相等)。在两按钮确认弹窗中,SpaceBetween是最常用的对齐方式。
确认退出的逻辑使用了 splice(idx, 1)——与认领弹窗的 splice(idx, 1, newItem) 不同,这里只传两个参数,表示在索引 idx 处删除 1 个元素,不插入新元素。删除后 ForEach 列表会自动移除对应的卡片 UI。
技术概念强调: 破坏性操作(删除、退出、移除)在交互设计中通常需要两步确认——第一步触发确认弹窗,第二步在弹窗中再次确认。这种设计能有效防止用户误操作导致的数据丢失。在视觉上,破坏性操作的按钮通常使用橙色或红色系(如本例的
#FB923C),与安全操作(绿色系#4ADE80)形成对比,让用户在点击前有所警觉。
十九、改昵称弹窗:数组元素属性更新
修改昵称弹窗展示了另一种数据更新模式——替换数组中特定位置的元素。
Text('保存')
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.backgroundColor('#38BDF8')
.borderRadius(8)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#082F49')
.onClick(() => {
if (this.newNick !== '') {
this.mys[0] = buildMy(this.mys[0].id, this.newNick, this.mys[0].tag, this.mys[0].time)
this.newNick = ''
}
this.showMyNick = false
})
这段代码通过 this.mys[0] = buildMy(...) 直接赋值替换数组第一个元素。与 splice 不同,直接索引赋值是一种更直观的元素替换方式。ArkUI 的状态观察机制能捕获通过索引直接赋值的操作,触发对应列表项的 UI 更新。
技术概念强调: ArkUI 的
@State数组观察机制不仅支持push、unshift、splice、fill等数组方法的观察,还支持通过索引直接赋值(如this.arr[0] = newValue)的观察。这得益于 ArkTS 编译器对数组访问的重写(Proxy 模式)。开发者无需手动调用setState或类似 API,数据变化会自动反映到 UI 上。
二十、缓存清理与退出登录:确认弹窗的变体
清理缓存弹窗和退出登录弹窗在结构上与退赛弹窗类似,都是两按钮确认模式。但它们的业务逻辑不同——这两个弹窗实际上不修改任何数据,纯粹是 UI 状态的翻转。
// 清理缓存弹窗的确认按钮
.onClick(() => {
this.showMyCache = false
})
// 退出登录弹窗的确认按钮
.onClick(() => {
this.showMyExit = false
})
这两个弹窗的确认按钮只做了一件事:关闭弹窗本身(将对应的 showXxx 布尔值设为 false)。在实际应用中,这里应该添加真正的业务逻辑(如调用清理缓存的系统 API、执行登出请求等),但在本示例中作为 UI 框架演示,仅实现了交互层面的弹窗关闭。
技术概念强调: 即使某些操作暂时没有真实业务逻辑,先搭建完整的交互流程(弹窗→确认→反馈)也是一种良好的开发习惯。这种"UI 先行、逻辑后补"的模式让产品的交互体验在开发早期就可以被验证,避免后期因交互设计不合理而大规模返工。确认弹窗的框架一旦搭好,后续只需在
.onClick()中填充真实逻辑即可。
二十一、整体架构回顾与流程总结
经过以上对全部代码的逐段拆解,我们已经完整理解了这个钓鱼直播站应用从数据建模到 UI 呈现的全流程。让我们用流程图来总结整个应用的运行架构。
二十二、核心要素对比汇总表
下面将本应用中涉及的各类数据结构、组件、状态变量、装饰器等核心要素进行系统化对比。
| 类别 | 名称 | 类型/作用域 | 核心特征 | 典型用途 | 状态观察 | 备注 |
|---|---|---|---|---|---|---|
| 接口 | FeedItem | 数据结构 | 7字段:id/name/avatar/time/content/likes/comments | 渔获动态数据建模 | 否 | 编译期类型安全 |
| 接口 | SpotItem | 数据结构 | 7字段:id/name/kind/depth/fish/quota/state | 钓点信息建模 | 否 | state编码0/1 |
| 接口 | LiveItem | 数据结构 | 6字段:id/name/title/fans/watch/state | 直播信息建模 | 否 | watch需格式化 |
| 接口 | WaterItem | 数据结构 | 6字段:id/name/area/quota/price/state | 水域资源建模 | 否 | 含价格字段 |
| 接口 | MasterItem | 数据结构 | 6字段:id/name/skill/honor/quota/state | 大师信息建模 | 否 | honor为荣誉值 |
| 接口 | MyItem | 数据结构 | 4字段:id/name/tag/time | 收藏记录建模 | 否 | 最精简的接口 |
| 常量 | FEEDS | FeedItem[] | 8条初始动态数据 | 首页列表数据源 | 否 | const声明 |
| 常量 | SPOTS | SpotItem[] | 10条钓点数据 | 钓点列表数据源 | 否 | 双列渲染 |
| 常量 | LIVES | LiveItem[] | 8条直播数据 | 直播列表数据源 | 否 | 含状态驱动UI |
| 常量 | WATERS | WaterItem[] | 8条水域数据 | 水域列表数据源 | 否 | 含进度条 |
| 常量 | MASTERS | MasterItem[] | 8条大师数据 | 排行榜数据源 | 否 | 含排名高亮 |
| 常量 | MYS | MyItem[] | 8条收藏数据 | 收藏列表数据源 | 否 | 可删除 |
| 常量 | HEAT | number[] | 8个热度数值 | 柱状图数据 | 否 | 动态计算柱高 |
| 常量 | CATS | string[] | 6个分类标签 | 分类筛选条 | 否 | 横向滚动 |
| 函数 | heatBar | 工具函数 | 返回(14+v*4)+‘vp’ | 柱状图高度计算 | 否 | vp单位 |
| 函数 | playsText | 工具函数 | 万以上转"万"单位 | 观看数格式化 | 否 | toFixed(1) |
| 函数 | spotStateText | 状态映射 | 0→可约钓/1→约满 | 钓点状态文本 | 否 | 配套color函数 |
| 函数 | liveStateColor | 状态映射 | 0→橙/1→灰 | 直播状态颜色 | 否 | 区分直播/结束 |
| 函数 | trendText | 状态映射 | 正→+x%/负→x% | 趋势百分比 | 否 | 正负前缀 |
| 函数 | buildFeed | 工厂函数 | 构造FeedItem对象 | 新建动态 | 否 | 参数化构造 |
| 函数 | buildWaterBook | 业务函数 | 名额减1+状态翻转 | 水域认领 | 否 | 含钳制逻辑 |
| 装饰器 | @Entry | 组件标记 | 标记页面入口组件 | Index根组件 | - | 每页仅一个 |
| 装饰器 | @Component | 组件标记 | 声明自定义组件 | 所有struct | - | struct前修饰 |
| 装饰器 | @State | 状态管理 | 组件内部可变状态 | currentTab/feeds等 | 是 | 值变化触发重渲染 |
| 装饰器 | @Prop | 状态管理 | 父→子单向传递 | 子组件数据接收 | 是(父变化同步) | 子不可修改 |
| 装饰器 | @Builder | UI复用 | 定义可复用UI片段 | tabItem方法 | - | 参数化UI模板 |
| 组件 | Stack | 容器布局 | 子元素层叠堆叠 | 弹窗覆盖主界面 | - | alignContent对齐 |
| 组件 | Column | 容器布局 | 子元素纵向排列 | 头部/卡片内容 | - | space设间距 |
| 组件 | Row | 容器布局 | 子元素横向排列 | 统计行/按钮行 | - | space设间距 |
| 组件 | Scroll | 容器布局 | 内容滚动浏览 | 各内容区外层 | - | 支持横纵方向 |
| 组件 | Text | 基础组件 | 显示文本 | 标题/正文/标签 | - | 支持Emoji |
| 组件 | TextInput | 基础组件 | 文本输入框 | 表单弹窗 | - | onChange回调 |
| 属性 | layoutWeight | 布局属性 | 弹性权重分配 | 占位符/等分布局 | - | 值为权重数 |
| 属性 | linearGradient | 视觉属性 | 线性渐变背景 | 头部横幅 | - | angle+colors |
| 属性 | animation | 动画属性 | 隐式过渡动画 | 标签选中放大 | - | duration+curve |
| 属性 | scale | 变换属性 | 缩放变换 | 标签选中放大 | - | x/y轴比例 |
| 属性 | borderRadius | 视觉属性 | 圆角半径 | 卡片/胶囊 | - | 数值或百分比 |
| 属性 | opacity | 视觉属性 | 透明度 | 图标/遮罩 | - | 0~1 |
| 属性 | letterSpacing | 文本属性 | 字间距 | 大标题 | - | vp单位 |
| 属性 | maxLines | 文本属性 | 最大行数 | 长文本截断 | - | 配合textOverflow |
| API | ForEach | 列表渲染 | 遍历数组生成UI | 所有列表 | - | 需键值函数 |
| API | findIndex | 数组方法 | 查找满足条件的索引 | 定位编辑/删除项 | - | 返回-1表示未找到 |
| API | splice | 数组方法 | 删除/插入/替换 | 数据更新 | 是 | 原地修改 |
| API | unshift | 数组方法 | 头部插入元素 | 新增动态/直播 | 是 | 原地修改 |
| API | parseInt | 全局函数 | 字符串转整数 | 水深输入解析 | 否 | 返回NaN容错 |
| 枚举 | FlexAlign | 对齐枚举 | 主轴对齐方式 | Center/SpaceBetween等 | - | 7种模式 |
| 枚举 | ScrollDirection | 方向枚举 | 滚动方向 | Horizontal/Vertical | - | 2种方向 |
| 枚举 | Curve | 缓动枚举 | 动画曲线 | EaseOut等 | - | 预定义曲线 |
二十三、全文总结
通过对这款钓鱼直播站应用的完整代码拆解,我们可以清晰地看到鸿蒙 ArkUI 声明式 UI 开发范式的全貌。从最底层的 interface 类型定义,到 const 常量数据初始化,再到工具函数层的状态映射和数据构建,整个数据层的设计严谨而富有层次。每一个业务实体都有明确的接口定义和初始数据,工具函数将状态码到视觉表现的映射逻辑集中管理,工厂函数统一了对象的构造方式。这种"数据层→函数层→状态层→组件层→渲染层"的分层架构,使得代码各司其职、职责清晰,为后续的功能扩展和维护奠定了良好基础。
状态管理是本应用最核心的技术亮点。根组件 Index 通过近 40 个 @State 变量构建了一个完整的状态中枢,涵盖了数据列表、选中项、弹窗开关、表单输入、选择索引五大类状态。这些状态变量通过 @Prop 单向传递到六个子内容组件,子组件通过回调函数向父组件上报用户操作,形成了"数据向下、事件向上"的清晰数据流。弹窗系统通过布尔开关变量配合条件渲染实现,每个弹窗都是独立的条件分支,互不干扰。这种设计虽然状态变量数量较多,但每个变量职责单一、语义明确,整体可维护性反而优于少数复杂状态对象的方案。
UI 布局方面,应用充分运用了 ArkUI 的容器组件体系。Stack 层叠布局实现了弹窗覆盖主界面的效果,Column 和 Row 的组合构建了从页面骨架到卡片内容的完整布局层级。layoutWeight 弹性权重分配解决了"固定高度+自适应内容+固定高度"的三段式布局问题,也在列表内部实现了等分网格和两端对齐。Scroll 滚动容器包裹所有内容区,配合 scrollBar(BarState.Off) 隐藏滚动条,提供了流畅的信息浏览体验。ForEach 列表渲染配合键值函数实现了高效的增量更新,无论是头部插入新动态还是原地替换编辑后的数据,都能精确更新到对应的 UI 片段。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
// 晨雾水岸风:bg #0B1F2A / 面板 #132B38 / 水蓝 #38BDF8 / 雾白 #E0F2FE / 草绿 #4ADE80 / 霞橙 #FB923C
interface FeedItem {
id: number
name: string
avatar: string
time: string
content: string
likes: number
comments: number
}
interface SpotItem {
id: number
name: string
kind: string
depth: number
fish: number
quota: number
state: number
}
interface LiveItem {
id: number
name: string
title: string
fans: number
watch: number
state: number
}
interface WaterItem {
id: number
name: string
area: number
quota: number
price: number
state: number
}
interface MasterItem {
id: number
name: string
skill: 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: '昨晚夜钓青龙湖,18 斤大鲤鱼上岸!主线切了三次,换 0.8 子线硬扛,这手感绝了!', likes: 628, comments: 93 },
{ id: 2, name: '路亚小北', avatar: '🐟', time: '35分钟前', content: '白鹭湾路亚翘嘴连竿,亮片搜边三竿中两尾,傍晚窗口期到了,钓友们冲!', likes: 451, comments: 67 },
{ id: 3, name: '矶钓老周', avatar: '🪨', time: '1小时前', content: '双月湾矶钓爆箱,黄鳍鲷排着队上钩,虾仁打窝加南极虾,今天收成十五斤!', likes: 389, comments: 54 },
{ id: 4, name: '筏钓阿斌', avatar: '⛵', time: '2小时前', content: '雾山水库筏钓鲫鱼连竿,半天一百多条,水深 22 米钓中层,串钩挂红虫效果好。', likes: 342, comments: 49 },
{ id: 5, name: '台钓小陈', avatar: '🎯', time: '4小时前', content: '黑坑正钓鲤鱼口好到爆,饵料配方分享:颗粒粉加果酸,散炮带软粘,抢鱼阶段稳拿前三!', likes: 517, comments: 86 },
{ id: 6, name: '冰钓老刘', avatar: '🧊', time: '6小时前', content: '冰窟窿下面全是大鲫鱼,穿得厚就行,冰钓竿加七星漂,一提一个准。', likes: 276, comments: 38 },
{ id: 7, name: '溪流阿泽', avatar: '🏞️', time: '昨天', content: '樱花溪上游马口成群,2.7 米溪流竿配 0.6 主线,羽毛钩慢收,一下午拉到手软。', likes: 305, comments: 42 },
{ id: 8, name: '海钓大鹏', avatar: '🛥️', time: '昨天', content: '出海船钓金枪鱼,搏斗整整两小时终于拿下,深海 60 米铁板抽到没脾气,值了!', likes: 742, comments: 128 }
]
const SPOTS: SpotItem[] = [
{ id: 1, name: '青龙湖大坝', kind: '湖库', depth: 18, fish: 96, quota: 8, state: 0 },
{ id: 2, name: '白鹭湾湿地', kind: '河湾', depth: 5, fish: 92, quota: 0, state: 1 },
{ id: 3, name: '双月湾矶石', kind: '海钓', depth: 25, fish: 95, quota: 10, state: 0 },
{ id: 4, name: '柳溪河下游', kind: '溪流', depth: 3, fish: 88, quota: 12, state: 0 },
{ id: 5, name: '雾山水库', kind: '湖库', depth: 22, fish: 91, quota: 6, state: 0 },
{ id: 6, name: '金沙湾排口', kind: '海钓', depth: 12, fish: 89, quota: 0, state: 1 },
{ id: 7, name: '老桥洞钓位', kind: '河湾', depth: 6, fish: 87, quota: 9, state: 0 },
{ id: 8, name: '野猪湖芦苇荡', kind: '湖库', depth: 4, fish: 90, quota: 14, state: 0 },
{ id: 9, name: '断崖深水区', kind: '湖库', depth: 30, fish: 93, quota: 4, state: 0 },
{ id: 10, name: '樱花溪上游', kind: '溪流', depth: 2, fish: 85, quota: 7, state: 0 }
]
const LIVES: LiveItem[] = [
{ id: 1, name: '阿强夜钓', title: '青龙湖夜钓 主线0.8 夜光漂', fans: 2354, watch: 680000, state: 0 },
{ id: 2, name: '小北路亚', title: '白鹭湾路亚 亮片搜边', fans: 1567, watch: 450000, state: 0 },
{ id: 3, name: '老周矶钓', title: '双月湾矶钓 礁石打窝', fans: 982, watch: 320000, state: 0 },
{ id: 4, name: '阿斌筏钓', title: '雾山水库筏钓鲫鱼', fans: 1230, watch: 410000, state: 0 },
{ id: 5, name: '小陈黑坑', title: '黑坑正钓抢鱼 配方分享', fans: 2056, watch: 530000, state: 0 },
{ id: 6, name: '老刘冰钓', title: '冰钓大鲫鱼 七星漂教学', fans: 645, watch: 210000, state: 1 },
{ id: 7, name: '阿泽溪流', title: '溪流马口 羽毛钩慢收', fans: 721, watch: 260000, state: 0 },
{ id: 8, name: '大鹏船钓', title: '深海拖钓 铁板抽金枪', fans: 1890, watch: 740000, state: 1 }
]
const WATERS: WaterItem[] = [
{ id: 1, name: '青龙湖水域', area: 2800, quota: 12, price: 500, state: 0 },
{ id: 2, name: '白鹭湾水域', area: 1600, quota: 8, price: 400, state: 0 },
{ id: 3, name: '雾山水域', area: 3500, quota: 15, price: 600, state: 0 },
{ id: 4, name: '野猪湖水域', area: 1200, quota: 0, price: 350, state: 1 },
{ id: 5, name: '柳溪河水域', area: 900, quota: 5, price: 300, state: 0 },
{ id: 6, name: '金沙湾水域', area: 2100, quota: 10, price: 550, state: 0 },
{ id: 7, name: '断崖深水区', area: 800, quota: 3, price: 450, state: 0 },
{ id: 8, name: '樱花溪水域', area: 600, quota: 4, price: 280, state: 0 }
]
const MASTERS: MasterItem[] = [
{ id: 1, name: '钓王老郑', skill: '湖库巨物', honor: 98, quota: 6, state: 0 },
{ id: 2, name: '路亚一姐阿兰', skill: '路亚鲈鱼', honor: 96, quota: 8, state: 0 },
{ id: 3, name: '矶钓高手老顾', skill: '海矶钓', honor: 95, quota: 0, state: 1 },
{ id: 4, name: '筏钓宗师老韩', skill: '筏钓', honor: 93, quota: 5, state: 0 },
{ id: 5, name: '黑坑达人小马', skill: '竞技黑坑', honor: 92, quota: 7, state: 0 },
{ id: 6, name: '冰钓活字典老孙', skill: '冰钓', honor: 90, quota: 0, state: 1 },
{ id: 7, name: '溪流精灵阿康', skill: '溪流钓', honor: 89, quota: 9, state: 0 },
{ id: 8, name: '船钓船长老吴', skill: '深海船钓', honor: 94, quota: 4, state: 0 }
]
const MYS: MyItem[] = [
{ id: 1, name: '18斤大鲤上岸实录', 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[] = [5, 8, 7, 9, 6, 10, 8, 12]
const CATS: string[] = ['全部', '湖库', '河湾', '海钓', '溪流', '黑坑']
function heatBar(v: number): string {
return (14 + v * 4) + 'vp'
}
function playsText(p: number): string {
if (p >= 10000) {
return (p / 10000).toFixed(1) + '万'
}
return p.toString()
}
function spotStateText(s: number): string {
if (s === 0) {
return '可约钓'
}
return '约满'
}
function spotStateColor(s: number): string {
if (s === 0) {
return '#4ADE80'
}
return '#FB923C'
}
function liveStateText(s: number): string {
if (s === 0) {
return '直播中'
}
return '已结束'
}
function liveStateColor(s: number): string {
if (s === 0) {
return '#FB923C'
}
return '#64748B'
}
function waterStateText(s: number): string {
if (s === 0) {
return '可认领'
}
return '已认满'
}
function waterStateColor(s: number): string {
if (s === 0) {
return '#38BDF8'
}
return '#FB923C'
}
function masterStateText(s: number): string {
if (s === 0) {
return '收徒中'
}
return '已满额'
}
function masterStateColor(s: number): string {
if (s === 0) {
return '#4ADE80'
}
return '#FB923C'
}
function trendText(t: number): string {
if (t > 0) {
return '+' + t + '%'
}
return t + '%'
}
function trendColor(t: number): string {
if (t >= 0) {
return '#38BDF8'
}
return '#FB923C'
}
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 buildSpot(id: number, name: string, kind: string, depth: number, fish: number, quota: number, state: number): SpotItem {
return { id: id, name: name, kind: kind, depth: depth, fish: fish, quota: quota, 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 buildWater(id: number, name: string, area: number, quota: number, price: number, state: number): WaterItem {
return { id: id, name: name, area: area, quota: quota, price: price, state: state }
}
function buildMaster(id: number, name: string, skill: string, honor: number, quota: number, state: number): MasterItem {
return { id: id, name: name, skill: skill, honor: honor, quota: quota, state: state }
}
function buildWaterBook(w: WaterItem): WaterItem {
let q = w.quota - 1
let st = w.state
if (q <= 0) {
q = 0
st = 1
}
return buildWater(w.id, w.name, w.area, q, w.price, st)
}
@Entry
@Component
struct Index {
@State currentTab: number = 0
@State feeds: FeedItem[] = FEEDS
@State spots: SpotItem[] = SPOTS
@State lives: LiveItem[] = LIVES
@State waters: WaterItem[] = WATERS
@State masters: MasterItem[] = MASTERS
@State mys: MyItem[] = MYS
@State pickedFeed: FeedItem | null = null
@State pickedSpot: SpotItem | null = null
@State pickedLive: LiveItem | null = null
@State pickedWater: WaterItem | null = null
@State pickedMaster: MasterItem | null = null
@State pickedMy: MyItem | null = null
@State showFeedDetail: boolean = false
@State showFeedAdd: boolean = false
@State showSpotDetail: boolean = false
@State showSpotEdit: boolean = false
@State showLiveRule: boolean = false
@State showLiveDetail: boolean = false
@State showLiveAdd: boolean = false
@State showWaterDetail: boolean = false
@State showWaterBook: boolean = false
@State showMasterRule: boolean = false
@State showMasterDetail: boolean = false
@State showMasterSign: boolean = false
@State showMasterQuit: 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 newSpotName: string = ''
@State newSpotKind: string = ''
@State newSpotDepth: 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 ? '#38BDF8' : '#7E9BA8')
Text(label)
.fontSize(11)
.fontColor(this.currentTab === tab ? '#38BDF8' : '#7E9BA8')
}
.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('FISH CAST')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text('钓鱼直播站 · 官方')
.fontSize(10)
.fontColor('#7DD3FC')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
}
.alignItems(VerticalAlign.Center)
Text('').layoutWeight(1)
Text('🔔')
.fontSize(22)
.opacity(0.9)
Text('🔍')
.fontSize(22)
.margin({ left: 14 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 10 })
// 晨雾横幅
Column({ space: 6 }) {
Row() {
Text('🌊 晨雾水岸 · 开钓中')
.fontSize(11)
.fontColor('#E0F2FE')
.backgroundColor('rgba(56,189,248,0.25)')
.borderRadius(20)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
Text('').layoutWeight(1)
Text('🔴 直播中 128 场')
.fontSize(11)
.fontColor('#7DD3FC')
}
.width('100%')
Text('钓鱼直播站')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
.letterSpacing(2)
Text('FISH CAST · 水岸晨雾 一竿入梦')
.fontSize(11)
.fontColor('#7DD3FC')
.letterSpacing(1)
// 水波符号带
Row() {
ForEach([12, 16, 10, 18, 14, 11, 17, 13, 15, 12], (s: number, i: number) => {
Text(i % 2 === 0 ? '~' : '≈')
.fontSize(s)
.fontColor(i % 3 === 0 ? '#38BDF8' : (i % 3 === 1 ? '#4ADE80' : '#FB923C'))
.opacity(0.8)
}, (s: number, i: number) => 'wave' + i)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20 })
// 搜索胶囊
Row() {
Text('🔍')
.fontSize(14)
Text('搜索钓点、直播、渔获、大师')
.fontSize(13)
.fontColor('#7DD3FC')
.margin({ left: 6 })
}
.width('90%')
.height(38)
.backgroundColor('rgba(19,43,56,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: [['#082F49', 0.0], ['#132B38', 0.55], ['#0B1F2A', 1.0]]
})
}
.width('100%')
.backgroundColor('#132B38')
// ===== 内容区 =====
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) {
SpotContent({
spots: this.spots,
catSel: this.catSel,
showSpotDetail: (item: SpotItem) => {
this.pickedSpot = item
this.showSpotDetail = true
},
showSpotEdit: (item: SpotItem) => {
this.pickedSpot = item
this.newSpotName = item.name
this.newSpotKind = item.kind
this.newSpotDepth = item.depth.toString()
this.showSpotEdit = 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) {
WaterContent({
waters: this.waters,
showWaterDetail: (item: WaterItem) => {
this.pickedWater = item
this.showWaterDetail = true
},
showWaterBook: (item: WaterItem) => {
this.pickedWater = item
this.showWaterBook = true
}
})
}
if (this.currentTab === 4) {
MasterContent({
masters: this.masters,
showMasterRule: () => {
this.showMasterRule = true
},
showMasterDetail: (item: MasterItem) => {
this.pickedMaster = item
this.showMasterDetail = true
},
showMasterSign: (item: MasterItem) => {
this.pickedMaster = item
this.showMasterSign = true
},
showMasterQuit: (item: MasterItem) => {
this.pickedMaster = item
this.showMasterQuit = 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('#132B38')
.border({ width: { top: 1 }, color: '#1E3A4A' })
}
.width('100%')
.height('100%')
.backgroundColor('#0B1F2A')
// ================= 弹窗层 =================
// 动态详情
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('#E0F2FE')
Text(this.pickedFeed !== null ? this.pickedFeed.time : '')
.fontSize(11)
.fontColor('#7DD3FC')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
Text('').layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#E0F2FE')
.padding(6)
.onClick(() => {
this.showFeedDetail = false
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(this.pickedFeed !== null ? this.pickedFeed.content : '')
.fontSize(15)
.fontColor('#BAE6FD')
.lineHeight(24)
Row() {
Text('👍 渔获点赞 ' + (this.pickedFeed !== null ? this.pickedFeed.likes : 0))
.fontSize(13)
.fontColor('#4ADE80')
.backgroundColor('rgba(74,222,128,0.15)')
.borderRadius(14)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
Text('💬 评论 ' + (this.pickedFeed !== null ? this.pickedFeed.comments : 0))
.fontSize(13)
.fontColor('#38BDF8')
.backgroundColor('rgba(56,189,248,0.15)')
.borderRadius(14)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
}
.width('100%')
Text('🐟 鱼获 18.6 斤 · 主线 0.8 · 夜光漂')
.fontSize(12)
.fontColor('#FB923C')
.backgroundColor('rgba(251,146,60,0.12)')
.borderRadius(10)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.width('100%')
}
.width('88%')
.padding(18)
.backgroundColor('#132B38')
.borderRadius(14)
.border({ width: 1, color: '#38BDF8' })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(11,31,42,0.88)')
.justifyContent(FlexAlign.Center)
}
// 发布动态新增
if (this.showFeedAdd) {
Column() {
Column({ space: 12 }) {
Row() {
Text('🎣 发布渔获动态')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text('').layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#E0F2FE')
.padding(6)
.onClick(() => {
this.showFeedAdd = false
})
}
.width('100%')
TextInput({ placeholder: '你的昵称', text: this.newFeedName })
.width('100%')
.height(42)
.fontSize(14)
.fontColor('#E0F2FE')
.backgroundColor('#0B1F2A')
.borderRadius(8)
.onChange((v: string) => {
this.newFeedName = v
})
TextInput({ placeholder: '分享今天的渔获...', text: this.newFeedText })
.width('100%')
.height(88)
.fontSize(14)
.fontColor('#E0F2FE')
.backgroundColor('#0B1F2A')
.borderRadius(8)
.onChange((v: string) => {
this.newFeedText = v
})
Row() {
Text('🌊 野钓')
.fontSize(12)
.fontColor('#38BDF8')
.backgroundColor('rgba(56,189,248,0.15)')
.borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
Text('🎣 路亚')
.fontSize(12)
.fontColor('#4ADE80')
.backgroundColor('rgba(74,222,128,0.15)')
.borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
Text('🐟 台钓')
.fontSize(12)
.fontColor('#FB923C')
.backgroundColor('rgba(251,146,60,0.15)')
.borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
}
.width('100%')
Text('发布')
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.backgroundColor('#38BDF8')
.borderRadius(8)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#082F49')
.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('#132B38')
.borderRadius(14)
.border({ width: 1, color: '#4ADE80' })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(11,31,42,0.88)')
.justifyContent(FlexAlign.Center)
}
// 钓点详情
if (this.showSpotDetail) {
Column() {
Column({ space: 10 }) {
Row() {
Text('📍')
.fontSize(30)
Column({ space: 3 }) {
Text(this.pickedSpot !== null ? this.pickedSpot.name : '')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text(this.pickedSpot !== null ? this.pickedSpot.kind + ' · 水深 ' + this.pickedSpot.depth + ' 米' : '')
.fontSize(12)
.fontColor('#7DD3FC')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
Text('').layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#E0F2FE')
.padding(6)
.onClick(() => {
this.showSpotDetail = false
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Text('渔获指数 ' + (this.pickedSpot !== null ? this.pickedSpot.fish : 0))
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FB923C')
Text('').layoutWeight(1)
Text(this.pickedSpot !== null ? spotStateText(this.pickedSpot.state) : '')
.fontSize(12)
.fontColor(this.pickedSpot !== null ? spotStateColor(this.pickedSpot.state) : '#4ADE80')
}
.width('100%')
Text('主钓鱼种:鲤鱼 / 草鱼 / 翘嘴\n最佳时段:清晨 05:00 - 08:00、傍晚 17:00 - 19:00\n饵料建议:玉米打窝 + 腥香饵')
.fontSize(13)
.fontColor('#BAE6FD')
.lineHeight(22)
Row() {
Text('✏️ 编辑记录')
.textAlign(TextAlign.Center)
.width('46%')
.height(40)
.backgroundColor('rgba(56,189,248,0.2)')
.borderRadius(8)
.fontSize(14)
.fontColor('#38BDF8')
.onClick(() => {
this.showSpotDetail = false
this.showSpotEdit = true
})
Text('🗺️ 导航去钓')
.textAlign(TextAlign.Center)
.width('46%')
.height(40)
.backgroundColor('#38BDF8')
.borderRadius(8)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#082F49')
.onClick(() => {
this.showSpotDetail = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.width('88%')
.padding(18)
.backgroundColor('#132B38')
.borderRadius(14)
.border({ width: 1, color: '#38BDF8' })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(11,31,42,0.88)')
.justifyContent(FlexAlign.Center)
}
// 钓点记录编辑
if (this.showSpotEdit) {
Column() {
Column({ space: 12 }) {
Row() {
Text('✏️ 编辑钓点记录')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text('').layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#E0F2FE')
.padding(6)
.onClick(() => {
this.showSpotEdit = false
})
}
.width('100%')
TextInput({ placeholder: '钓点名称', text: this.newSpotName })
.width('100%')
.height(42)
.fontSize(14)
.fontColor('#E0F2FE')
.backgroundColor('#0B1F2A')
.borderRadius(8)
.onChange((v: string) => {
this.newSpotName = v
})
TextInput({ placeholder: '水域类型', text: this.newSpotKind })
.width('100%')
.height(42)
.fontSize(14)
.fontColor('#E0F2FE')
.backgroundColor('#0B1F2A')
.borderRadius(8)
.onChange((v: string) => {
this.newSpotKind = v
})
TextInput({ placeholder: '水深(米)', text: this.newSpotDepth })
.width('100%')
.height(42)
.fontSize(14)
.fontColor('#E0F2FE')
.backgroundColor('#0B1F2A')
.borderRadius(8)
.onChange((v: string) => {
this.newSpotDepth = v
})
Text('保存修改')
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.backgroundColor('#FB923C')
.borderRadius(8)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#0B1F2A')
.onClick(() => {
if (this.pickedSpot !== null) {
let idx = this.spots.findIndex((it: SpotItem) => it.id === this.pickedSpot!.id)
if (idx >= 0) {
let d = parseInt(this.newSpotDepth)
if (isNaN(d)) {
d = this.pickedSpot.depth
}
this.spots.splice(idx, 1, buildSpot(this.pickedSpot.id, this.newSpotName, this.newSpotKind, d, this.pickedSpot.fish, this.pickedSpot.quota, this.pickedSpot.state))
}
}
this.showSpotEdit = false
})
}
.width('88%')
.padding(18)
.backgroundColor('#132B38')
.borderRadius(14)
.border({ width: 1, color: '#FB923C' })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(11,31,42,0.88)')
.justifyContent(FlexAlign.Center)
}
// 直播规则
if (this.showLiveRule) {
Column() {
Column({ space: 10 }) {
Row() {
Text('📜 开播规则')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text('').layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#E0F2FE')
.padding(6)
.onClick(() => {
this.showLiveRule = false
})
}
.width('100%')
Text('1. 直播必须在水域边进行,画面清晰\n2. 禁止虚假渔获、禁止电鱼毒鱼\n3. 开播前需预约时段,每天最多 2 场\n4. 单场观看满 10 万可上热门榜单')
.fontSize(14)
.fontColor('#BAE6FD')
.lineHeight(26)
Text('知道了')
.textAlign(TextAlign.Center)
.width('100%')
.height(40)
.backgroundColor('#38BDF8')
.borderRadius(8)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#082F49')
.onClick(() => {
this.showLiveRule = false
})
}
.width('88%')
.padding(18)
.backgroundColor('#132B38')
.borderRadius(14)
.border({ width: 1, color: '#7DD3FC' })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(11,31,42,0.88)')
.justifyContent(FlexAlign.Center)
}
// 直播详情
if (this.showLiveDetail) {
Column() {
Column({ space: 10 }) {
Row() {
Text('🔴')
.fontSize(24)
Column({ space: 3 }) {
Text(this.pickedLive !== null ? this.pickedLive.name : '')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text(this.pickedLive !== null ? liveStateText(this.pickedLive.state) : '')
.fontSize(12)
.fontColor(this.pickedLive !== null ? liveStateColor(this.pickedLive.state) : '#FB923C')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
Text('').layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#E0F2FE')
.padding(6)
.onClick(() => {
this.showLiveDetail = false
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(this.pickedLive !== null ? this.pickedLive.title : '')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#F0FDFA')
Row() {
Text('👥 钓友 ' + (this.pickedLive !== null ? this.pickedLive.fans : 0))
.fontSize(13)
.fontColor('#7DD3FC')
Text('').layoutWeight(1)
Text('👀 观看 ' + (this.pickedLive !== null ? playsText(this.pickedLive.watch) : '0'))
.fontSize(13)
.fontColor('#FB923C')
}
.width('100%')
Text('直播间正在水边实况,当前鱼口不错,主播会在抛竿间隙回复弹幕问题。')
.fontSize(13)
.fontColor('#BAE6FD')
.lineHeight(22)
Row() {
Text('🎁 送礼')
.textAlign(TextAlign.Center)
.width('46%')
.height(40)
.backgroundColor('rgba(251,146,60,0.2)')
.borderRadius(8)
.fontSize(14)
.fontColor('#FB923C')
.onClick(() => {
this.showLiveDetail = false
})
Text('💬 发弹幕')
.textAlign(TextAlign.Center)
.width('46%')
.height(40)
.backgroundColor('#38BDF8')
.borderRadius(8)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#082F49')
.onClick(() => {
this.showLiveDetail = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.width('88%')
.padding(18)
.backgroundColor('#132B38')
.borderRadius(14)
.border({ width: 1, color: '#FB923C' })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(11,31,42,0.88)')
.justifyContent(FlexAlign.Center)
}
// 预约开播新增
if (this.showLiveAdd) {
Column() {
Column({ space: 12 }) {
Row() {
Text('🎥 预约开播')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E0F2FE')
Text('').layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#E0F2FE')
.padding(6)
.onClick(() => {
this.showLiveAdd = false
})
}
.width('100%')
TextInput({ placeholder: '主播昵称', text: this.newFeedName })
.width('100%')
.height(42)
.fontSize(14)
.fontColor('#E0F2FE')
.backgroundColor('#0B1F2A')
.borderRadius(8)
.onChange((v: string) => {
this.newFeedName = v
})
TextInput({ placeholder: '直播标题,如:青龙湖夜钓', text: this.newFeedText })
.width('100%')
.height(42)
.fontSize(14)
.fontColor('#E0F2FE')
.backgroundColor('#0B1F2A')
.borderRadius(8)
.onChange((v: string) => {
this.newFeedText = v
})
Row() {
Text('⏰ 06:00 - 09:00')
.fontSize(12)
.fontColor('#38BDF8')
.backgroundColor('rgba(56,189,248,0.15)')
.borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
Text('🌙 19:00 - 22:00')
.fontSize(12)
.fontColor('#4ADE80')
}
}
// ===== 2182.ets END =====

视觉设计方面,应用采用了一套精心设计的"晨雾水岸风"色彩体系,六个核心色值(深墨蓝/暗青绿/水蓝/雾白/草绿/霞橙)贯穿全局。通过 linearGradient 线性渐变营造头部氛围,通过半透明 rgba 色实现遮罩和背景层次,通过状态映射函数确保同一业务状态在应用各处的视觉表达一致。柱状图、进度条等数据可视化元素完全使用基础组件拼装实现,展现了 ArkUI 在无内置图表组件情况下的灵活性。animation 隐式动画为标签切换提供了平滑的缩放过渡,scale 变换和 letterSpacing 字间距等细节优化了视觉精致度。
交互设计方面,应用实现了完整的增删改查流程。新增(发布动态、预约开播)、编辑(钓点记录修改、昵称修改)、删除(移除收藏、退出拜师)、状态流转(水域认领名额递减、大师拜师名额递减)等操作都有对应的弹窗和确认流程。破坏性操作采用两步确认模式,通过橙色按钮和警示文案降低误操作风险。状态守卫逻辑(如已认满的水域不可再认领)从交互层面防止了非法操作。每个弹窗都遵循统一的布局模式——半透明遮罩+居中卡片+关闭按钮+内容+操作按钮,形成了一致的用户体验。
在工程实践层面,这个应用展示了 ArkUI 开发的多个最佳实践:防御性编程(null 检查、isNaN 容错)、不可变数据更新(构造新对象再 splice 替换而非直接修改属性)、关注点分离(状态映射逻辑提取为函数、UI 构建提取为 @Builder)、参数化复用(@Builder 通过参数渲染不同内容)、以及一致的代码风格(统一的命名模式、统一的弹窗结构、统一的卡片布局)。这些实践使得代码虽然体量较大(近 2900 行),但结构清晰、可读性强、易于维护,充分体现了声明式 UI 范式在构建复杂应用方面的优势。
更多推荐


所有评论(0)