三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

鸿蒙 ArkTS 实战:星座查询 Horoscope

鸿蒙 ArkTS 实战:星座查询 Horoscope

引言

星座查询是社交、生活、娱乐类应用里十分常见的轻量工具:用户想知道自己属于哪个星座、每个星座的日期区间是什么、各自的性格特点是怎样的。本文要讲解的示例 28「星座查询 Horoscope」,用 ArkTS 在单个页面里实现了「今日星座提示 + 十二星座宫格 + 选中星座详情」的完整交互

这个示例最大的亮点,是它没有引入任何第三方算法库,而是自己用一段十几行的数学逻辑完成「根据月日判断星座」的核心功能:通过一个代表各月星座起始日的数组加上一次取模运算,就能把任意日期精确映射到 12 个星座之一。配合Grid网格布局、@State状态管理与生命周期回调,页面能够在打开的一瞬间自动定位「今日星座」并高亮显示。

全文同样分为八个章节,从整体功能、核心知识点、源码逐段解析,到星座区间判定算法的推导、运行效果、可扩展方向与调试技巧,所有内容都严格对应源码中的真实变量名与函数名,希望帮助你把「数据、算法、界面」三者的协作关系一次看懂。

一、应用概述与功能

「星座查询」页面自上而下由五个部分组成,功能清晰、层次分明。

第一是顶部返回栏。左上角是蓝色「返回」按钮,点击后调用router.back()回到上一页;中间是加粗标题「星座查询」;右侧用Blank()弹性占位,保证布局两端对齐。

第二是今日星座提示行。它显示两段信息:左侧灰字「今日 x月x日」,右侧橙色加粗「所属:白羊座」之类的文案。这行数据并非写死的,而是在页面出现时通过new Date()读取系统当前日期,再调用getZodiac算法计算出来的,因此每一天打开应用,这里都会自动跟着变化。

第三是十二星座宫格。页面用Grid以四列布局排列 12 个星座单元格,每个单元格上半部分显示星座名,下半部分显示日期区间(如「3.21-4.19」)。单元格有三级背景态:默认白色;「今日星座」所在格为浅黄色#fff7e6;用户当前选中的格为主蓝色#1a6cff并带 2 号蓝色描边。三者叠加时,选中态优先于今日态。

第四是选中星座详情卡。页面底部有一张白色圆角卡,展示当前选中星座的完整信息:星座名(24 号加粗)、如果恰好是今日星座则出现一个橙色「今日星座」小徽标、日期区间、幸运色(主题蓝加粗),以及一条 24 行高的人格描述。整张卡片用Divider分割线把「基本信息」与「性格描述」分成上下两段。

第五是整体滚动容器。全部内容包在一个Scroll里,配scrollBar(BarState.Off)隐藏滚动条;页面背景为浅灰#f2f3f5,白色宫格与详情卡浮于其上,视觉风格与示例 27 的天气卡片保持统一。

整页的交互只有「点击宫格切换选中星座」一处,但背后同时涉及日期算法、网格布局、条件渲染与动态样式四条技术线,信息量并不小。

二、核心知识点

示例 28 覆盖的知识点可以分为五类,逐一拆解如下。

第一是@State状态管理。页面声明了四个状态:stars保存 12 个星座的完整数据(也是整个页面的数据源)、selected记录当前选中星座的下标、current记录今日所属星座的下标、todayText保存格式化后的今日日期文本。其中selectedcurrent都是 number 类型,界面上的高亮与详情展示全部由这两个下标驱动。

第二是interface数据模型。单个星座包含名称、日期区间、幸运色、性格描述四个字段,源码用interface Star { name: string; date: string; lucky: string; desc: string; }建模,再用@State stars: Star[]装载 12 条预置数据。把数据集中放在一个数组里,后续无论渲染宫格还是详情卡,都只依赖这一个数据源,不会出现多份数据不同步的问题。

第三是网格布局GridGridItem。宫格部分使用Grid()容器,通过.columnsTemplate('1fr 1fr 1fr 1fr')把宽度切成四等份,配合columnsGap(10)rowsGap(10)设置行列间距,height(260)固定整体高度,12 个单元格恰好组成三行四列。每个单元格必须用GridItem包裹,这是网格布局的基本要求。

第四是日期区间判定算法。核心函数是getZodiac(month, day):它先用一个borders数组存放「每个月星座更替的起始日」,再通过day < borders[idx]判断日期是否落在前一个星座区间内,若是则用(idx + 11) % 12做带环绕的索引回退。这一段是理解全篇的关键,第四章会专门推导。

第五是生命周期与条件渲染。aboutToAppear()在页面首次出现时读取系统时间、计算今日星座并把selected对齐到current,让页面一打开就自动定位;aboutToDisappear()负责清理预留的定时器。详情卡里的「今日星座」橙色徽标则是用if (this.selected === this.current) { ... }条件渲染出来的,只有选中项恰好是今日星座时才出现。

此外,ForEach的 key 生成器、cellBg等三个封装单元格样式的工具函数、router.back()路由返回,也都是值得留意的实现细节。

最后再强调一点:本页面的 UI 只依赖starsselectedcurrenttodayText四个状态,其中三个是展示用的标量,一个是数据源数组。状态数量的克制意味着每次交互需要更新的点很少,框架的重绘开销也就很小。「小状态集 + 大数组数据」这一经典组合,正是这类查询型页面能保持流畅的关键。

以上便是示例 28 涉及的全部核心知识点,下一章进入源码逐段解析。

三、源码逐段解析

源码的组织顺序是「数据模型 → 状态 → 算法 → 生命周期 → 样式工具 → build 布局」,我们按顺序拆解。

3.1 数据模型与预置数据

Star接口用四个字符串字段描述一个星座,定义非常简洁:

interface Star { name: string; date: string; lucky: string; desc: string; }

数据源是一条 12 项的数组@State stars: Star[],前两条示例为白羊座(3.21-4.19,幸运色红色,性格「热情直率,行动力强,敢于挑战,喜欢新鲜事物」)与金牛座(4.20-5.20,幸运色绿色,性格「踏实稳重,重视物质享受,忠诚可靠,耐心十足」)。后续十条依次是双子、巨蟹、狮子、处女、天秤、天蝎、射手、摩羯、水瓶、双鱼,各自的日期区间、幸运色与性格描述都与星座常识保持一致,例如狮子座 7.23-8.22 幸运色金色、天蝎座 10.24-11.22 幸运色紫色、摩羯座 12.22-1.19 幸运色棕色。这样一份完整的 12 星座数据集,宫格与详情卡都在复用它,做到了「一份数据、多处渲染」。注意date字段的格式统一为「月.日-月.日」,这正是第四章算法推导时会用到的区间信息。

3.2 星座判定算法

getZodiac是页面最核心的函数,输入月份与日期,返回星座在stars数组中的下标:

getZodiac(month: number, day: number): number { const borders: number[] = [20, 19, 21, 20, 21, 22, 23, 23, 23, 24, 23, 22]; let idx: number = month - 1; if (day < borders[idx]) { idx = (idx + 11) % 12; } return idx; }

borders数组的第 i 个元素代表「第 i 个月里,该月对应星座的起始日」。例如borders[0] = 20表示 1 月的星座水瓶座从 1 月 20 日开始;borders[11] = 22表示 12 月的摩羯座从 12 月 22 日开始。算法先把下标定位到当月星座,再判断日期是否早于其起始日,早于则退回上一个月对应的星座,并用(idx + 11) % 12解决 1 月回退到 12 月的环形问题。

3.3 生命周期初始化

页面出现时读取系统时间并完成三件事:拼出「x月x日」文本、算出今日星座下标、让选中项默认等于今日星座:

aboutToAppear(): void { const now: Date = new Date(); const month: number = now.getMonth() + 1; const day: number = now.getDate(); this.todayText = month + '月' + day + '日'; this.current = this.getZodiac(month, day); this.selected = this.current; }

注意now.getMonth()返回的月份从 0 开始(1 月是 0),所以这里必须加 1 才是真实的月份数;getDate()则直接返回当月的第几天,无需换算。this.selected = this.current一行让页面一打开就高亮今日星座,用户体验非常顺手。

3.4 单元格样式工具函数

三个小函数把「选中 / 今日 / 默认」三态的样式决策集中起来,UI 层调用时完全不用关心判断逻辑,这里展示其中两个核心函数:

cellBg(idx: number): string { if (idx === this.selected) return '#1a6cff'; if (idx === this.current) return '#fff7e6'; return '#ffffff'; } cellTextColor(idx: number): string { if (idx === this.selected) return '#ffffff'; return '#333333'; }

cellBg的优先级是「选中 > 今日 > 默认」,先判断selected再判断currentcellTextColor让选中格的文字变白以适配蓝色底。第三个函数cellDateColorcellTextColor结构完全同构,负责返回日期文字的浅蓝#d9e6ff(选中态)或灰#9a9a9a(默认态)。把样式逻辑从build()里抽成带返回值的小函数,是保持声明式代码整洁的常见手法,也让三个状态的配色规则可以集中调整。

3.5 星座宫格

宫格由Grid容器承载,四列等宽,每个单元格是一个GridItem

Grid() { ForEach(this.stars, (item: Star, idx: number) => { GridItem() { Column({ space: 2 }) { Text(item.name).fontSize(14).fontWeight(FontWeight.Medium).fontColor(this.cellTextColor(idx)) Text(item.date).fontSize(10).fontColor(this.cellDateColor(idx)) } .borderRadius(8) .borderWidth(idx === this.selected ? 2 : 0).borderColor('#1a6cff') .backgroundColor(this.cellBg(idx)) .onClick(() => { this.selected = idx; }) } }, (item: Star, idx: number) => idx.toString()) } .columnsTemplate('1fr 1fr 1fr 1fr').columnsGap(10).rowsGap(10).height(260)

单元格内容是一个两行结构:上面是星座名,下面是日期区间,字号分别 14 与 10,主次分明;内容通过justifyContent(FlexAlign.Center)垂直居中。borderWidth(idx === this.selected ? 2 : 0)让选中格出现 2vp 的蓝色描边,点击单元格则执行this.selected = idx更新状态,详情卡随之刷新。

四、关键实现细节分析

4.1 星座日期区间判定算法的完整推导

borders数组实际上把 12 个星座的「换座日」压缩成了一行数据,这是整个算法最精妙的地方。数组下标 0 到 11 依次对应 1 月到 12 月,每个元素代表该月内星座更替的日期。以双子座为例,它的区间是 5.21-6.21,意味着 5 月 21 日起进入双子座,因此borders[4] = 21;而 5 月 20 日之前属于金牛座,所以borders[3] = 20

算法执行时先令idx = month - 1,把「当月对应的星座」作为初始候选;随后比较dayborders[idx]:如果day < borders[idx],说明今天还没到换座日,实际应属于前一个星座,于是idx回退一位。回退采用(idx + 11) % 12而不是简单的idx - 1,原因在于 1 月时idx = 0,减一变成负数会导致数组越界;取模后(0 + 11) % 12 = 11,恰好指向 12 月的摩羯座,环形回退一次完成。

我们验算几个边界例子:4 月 19 日,idx = 3borders[3] = 20,19 小于 20,回退到 2,即白羊座(3.21-4.19),正确;4 月 20 日,20 不小于 20,保持下标 3,即金牛座,正确;1 月 10 日,idx = 0,10 小于 20,回退到 11,即摩羯座(12.22-1.19),正确;1 月 20 日,20 不小于 20,保持 0,即水瓶座,正确。四个边界全部命中,可见这个算法对每个月的交界日都做了精确处理。

实际产品中还要考虑「闰年」问题吗?答案是不需要。星座的划分只看月与日,与年份、是否闰年、2 月有多少天都无关,因此getZodiac的两个入参只取monthday,天然对任何年份成立。另外这个算法也不受时区影响,因为new Date()拿到的是设备本地时间,设备显示的是什么日期,就按什么日期计算,用户身处任何时区,结果都是自洽的。

4.2 下标驱动的三态视觉

selectedcurrent两个下标把「用户行为」与「算法结果」统一到了一套状态体系里:点击宫格只改selected,打开页面时则把两者对齐。单元格的背景、文字色、描边全部由这两个下标经三目运算或工具函数决定,界面与状态严格一一对应。值得注意优先级设计:如果今日星座恰好被用户点击选中,单元格应显示选中蓝而不是浅黄,因此cellBg里先判断selected再判断current,两个条件顺序不能颠倒。

描边的实现也值得一提:borderWidth(idx === this.selected ? 2 : 0)在未选中时宽度为 0,相当于完全隐藏描边,选中时瞬间变为 2vp 的主题蓝描边,配合背景色由白转蓝,形成「描边 + 底色」的双重强调。而cellDateColor把选中格内的日期文字调成浅蓝#d9e6ff,是为了在蓝色底上依然保持日期可读——若沿用默认灰#9a9a9a,灰字压蓝底会显得浑浊。三个颜色函数的分工,恰好构成了一套完整的「状态配色矩阵」。

4.3 Grid 四列模板与固定高度

.columnsTemplate('1fr 1fr 1fr 1fr')表示四列各占一份比例,等价于四等分;columnsGap(10)rowsGap(10)定义行列间距为 10vp。12 个元素在四列模板下自动排成三行四列。Grid必须有确定高度才能参与布局,源码给出height(260),平均每行约 80vp,单元格内部再通过justifyContent(FlexAlign.Center)垂直居中,保证名称与日期紧凑地待在格子中央。

四列模板还有一个隐藏好处:1fr的比例写法让列宽完全由容器宽度决定,无论屏幕宽窄,四列都自动等分、自动填满,不需要关心具体像素。单元格顺序由ForEach按下标依次填充,第 1 到第 12 个星座从左上向右下自然排布,正好三行。若以后把星座扩充到 13 个,Grid会自动补出第四行,无需任何代码改动,这正是声明式布局「由数据驱动行列」的体现。

4.4 条件渲染「今日星座」徽标

详情卡顶部的橙色小徽标不是始终存在的,而是由if (this.selected === this.current)条件渲染控制:只有当选中项就是今日星座时才显示「今日星座」四个字,其余情况下该区域不占位、不显示。这是 ArkTS 声明式语法if/else在 UI 上的直接用法,配合白色圆角背景与#ff8c00橙色,形成一处亮眼的点睛。徽标本身是一个Text组件:字号 12、白色文字、橙色背景、圆角 4vp,四周有padding({ left: 6, right: 6, top: 2, bottom: 2 }),视觉上呈现为胶囊形的小标签,与宫格里的浅黄高亮遥相呼应,让「今日星座」的身份在页面两处同时得到提示。

4.5 数据模型与下标安全

详情卡用this.stars[this.selected]直接按下标取值,之所以敢这么写,是因为selected只可能来自两个途径:初始时等于current(由getZodiac保证落在 0 到 11 之间),或点击宫格时由ForEach的下标参数赋值。两个源头都严格收敛在数组范围内,因此不需要额外的越界保护。这也从侧面说明:状态的设计若能保持「取值范围天然合法」,业务代码就可以少写很多防御性的判断。

顺带可以做一次安全性的推演:stars数组的下标与borders数组的下标遵循同一套排序(0 对应白羊、11 对应双鱼),二者由getZodiac返回的下标直接对接,中间没有任何转换层。因此只要borders的顺序与stars的顺序保持一致,算法结果就一定落在正确的星座上。这也提醒我们:当项目里同时存在多个按索引对齐的数组或对象时,务必让它们的顺序约定统一,否则一处顺序错位会引发全盘错乱。

五、运行效果与操作指南

5.1 如何运行

用 DevEco Studio 打开工程,完成同步后选择一个模拟器或真机作为运行目标,点击 Run 编译安装。应用启动后从入口列表点进「星座查询」这一项,就会进入index28.ets渲染出的页面。同样可以使用 Previewer 直接预览:打开源码文件,点击右侧 Preview 标签即可看到宫格与详情卡的实时渲染,点击宫格单元格也能触发选中态变化。

5.2 页面打开时的自动定位

页面一出现,aboutToAppear就会把系统当前日期换算成「x月x日」显示在提示行,同时计算出今日星座并把selected对齐到current。也就是说,无论你今天哪天打开应用,提示行右侧都会显示正确的「所属:xxx」,宫格中对应星座自动呈现浅黄色,详情卡自动展示该星座的信息并带一枚「今日星座」橙色徽标。这个「打开即定位」的行为完全由代码自动完成,不需要任何用户操作。

顺带可以做一个验证实验:把系统时间临时改到 1 月 15 日再打开页面,提示行会显示「所属:摩羯座」,对应格子浅黄高亮;改到 1 月 20 日再打开,则变为「所属:水瓶座」。这直观地证明getZodiac的边界判断(20 日换座)在真实设备上工作正常,也说明aboutToAppear每次进入页面都会重新读取时间,无需重启应用就能拿到最新日期。

5.3 交互操作与预期效果

页面上唯一需要用户操作的是点击宫格。随便点一个星座单元格,可以观察到三件事同时发生:被点中的格子背景变为主蓝色、文字变白、出现 2 号蓝色描边,其余格子恢复默认白色(若其中有今日星座则保持浅黄);底部的详情卡整体刷新,星座名、日期区间、幸运色与性格描述全部切换为新选中项;如果新选中项恰好是今日星座,卡片顶部会多出「今日星座」徽标,否则徽标消失。

例如今天是 8 月 15 日,打开页面后提示行显示「今日 8月15日 所属:狮子座」,狮子座格子为浅黄;此时点击「天蝎座」,狮子座格恢复白色、天蝎座格变蓝,详情卡显示天蝎座 10.24-11.22、幸运色紫色及其性格描述;若点击回「狮子座」,则其格子变蓝且详情卡顶部出现橙色「今日星座」徽标。整个交互流畅、反馈即时,充分体现了「改一个selected,整页联动刷新」的声明式特征。

六、可扩展方向

「星座查询」目前是一个漂亮的静态示例,把它推向真实产品时可以沿以下方向扩展。

第一是接入今日运势。详情卡目前只有固定的性格描述,可以增加「今日运势」「爱情指数」「事业指数」等动态字段,数据来源可以是本地按日期伪随机生成的规则表,也可以是后端接口。这类扩展只需要在Star接口上增加字段、在详情卡中增加展示行即可,现有结构无需大改。

第二是动态数据源与缓存。stars数组目前写死在代码里,可以改成从云端拉取,配合本地缓存与更新时间戳;星座边界日期、幸运色等数据如果后续调整,也只需改数据而不动代码逻辑。

第三是日期选择器与手动查询。目前的定位完全依赖系统今天的日期,可以加入DatePicker或一个滑动条让用户自由选择「某月某日」,实时重算该日所属星座,把页面从「今日工具」升级成「任意日期查询」,进一步突出getZodiac算法的复用价值。

第四是动画与视觉增强。宫格点击时用animateTo给背景色、描边加上过渡动画;详情卡切换时让内容做轻微的位移或透明度渐入;星座名称还可以配一个圆形图标,强化辨识度。

第五是组件化拆分。可以把宫格、详情卡分别抽成@Component子组件,通过@Prop接收下标或星座对象,页面主体只保留状态与算法的编排。组件拆好后,宫格还能复用到「好友星座匹配」「今日运势」等其他页面。

第六是分享与营销玩法。选中星座后生成一张带幸运色底色的分享图,调用系统分享能力发给好友,让工具型页面具备传播属性。

第七是国际化与本地化。12 个星座的名称与描述目前是中文硬编码,若要出海,可以把stars数据抽成按语言区分的资源表,只替换展示文案;星座区间属于天文常识,全球通用,getZodiac算法本身可以原样复用,改动成本非常低。

七、常见问题与调试技巧

第一,月份取值少算一个月。Date.getMonth()返回 0 到 11,1 月对应 0,如果忘记加 1 直接传入getZodiac,会导致所有星座判定整体错位一个月。源码里const month: number = now.getMonth() + 1;+ 1是关键,改动时务必保留。

第二,(idx + 11) % 12的意义。如果手误改成idx - 1,1 月 20 日之前的日期会算出-1,用它索引stars数组会越界。用取模实现环形回退,是这段算法能正确处理 1 月边界的原因,不要简化掉。

第三,宫格不换行或布局错乱。Grid的行列数由columnsTemplate决定,如果不写它,单元格会按照默认单列排列,12 个星座会纵向铺开。确认columnsTemplate('1fr 1fr 1fr 1fr')columnsGaprowsGap三个属性都写在了Grid上。

第四,GridItem内容没有撑满。单元格内部的Column需要.width('100%').height('100%')才能铺满网格单元,配合justifyContent(FlexAlign.Center)才能让星座名与日期正居格子中央;如果漏掉尺寸属性,内容会缩在角落。

第五,选中态与今日态显示冲突。cellBg里两个 if 的顺序决定了「选中蓝」优先于「今日浅黄」。如果哪天改成先判断current,会出现点击今日星座时格子仍是浅黄的诡异现象,排查时优先检查这段优先级。

第六,selected下标越界。虽然源码已经保证了selected永远合法,但在扩展时如果引入「滑动删除星座」这类会改变数组长度的功能,请同步约束selected的边界,否则this.stars[this.selected]会直接取到undefined并导致渲染异常。

第七,详情卡刷新不动。点击宫格只更新了selected,详情卡依赖this.stars[this.selected],因为stars本身是@State数组而selected是独立@State,二者任一变化都会触发重绘。如果以后把selected改成普通成员变量,UI 就再也不会刷新,注意保持它的@State身份。

第八,调试技巧。可以在aboutToAppear里用console.info打印monthdaycurrent三个值,对照 12 个星座的边界日期验证算法是否正确;用 Previewer 快速点击宫格观察三态切换;如果徽标不出现,检查this.selected === this.current的比较是否被改成别的方式。

第九,关于aboutToAppear与首次渲染。aboutToAppear在组件构建之后、首次渲染之前执行,此时给@State赋值是安全的,页面第一帧就能带上正确的初始数据。如果将来把日期计算逻辑搬进build(),每次重绘都会重复执行,白白浪费;保持「数据准备放生命周期、界面声明放 build」的划分是最稳妥的写法。

八、总结

示例 28「星座查询」用一个页面把算法、数据与界面三件事编排得干净利落:getZodiac用一行borders数组加一次取模运算完成全年的星座区间判定;@Stateselectedcurrent两个下标驱动宫格三态高亮与详情卡联动;aboutToAppear让页面打开即定位今日星座;Grid的四列模板把 12 个单元格排成工整的三行四列;条件渲染则让「今日星座」徽标只在恰当的时候出现。

从学习角度看,这个示例最有价值的是两处:一是那个看似简单的取模算法,它把「跨月、跨年」这类容易写错的边界问题压缩成了三个字符,理解它等于掌握了一类「环形索引」的编程技巧;二是「下标驱动界面」的设计,全篇没有一行直接操作 DOM 样式的代码,所有的选中、高亮、描边都由数据推导而来,这正是声明式 UI 的精髓。

建议读者动手做三件事:一是把borders数组改成对象映射或枚举,体会不同建模方式对代码可读性的影响;二是给getZodiac补一个「输入日期区间直接返回星座」的单元测试,用一年 365 天的数据跑一遍,验证边界正确;三是把详情卡抽成子组件并用@Prop接收星座数据,走一遍组件通信的流程。星座虽小,却是把「算法落地为界面」的绝佳范本。

← 返回列表