【观止·诗史汇 HarmonyOS 实战系列 03】ArkUI 首页搭建:每日诗句、每日史事与功能入口
【观止·诗史汇 HarmonyOS 实战系列 03】ArkUI 首页搭建:每日诗句、每日史事与功能入口
前两篇先把《观止·诗史汇》的底座讲清楚了:第一篇从产品目标出发,把诗文、历史、地理、练习、收藏和统计串成一个本地优先的学习闭环;第二篇继续拆entry、features、commons三层边界,说明入口层只做装配,业务能力收束在features,公共能力沉到commons。到了第三篇,就可以真正落到用户第一眼看到的页面:首页。
首页看起来最容易写,也最容易写散。很多 App 的首页最后会变成“功能按钮集合”:上面一张图,下面一堆入口,再加几个列表。页面能跑,但用户打开以后不知道今天该读什么、为什么要点下一个入口、读完以后如何继续学习。
《观止·诗史汇》的首页不是目录页,而是学习路径的起点。它要在一个首屏里完成三件事:给用户一个今天可读的诗句,给用户一个今天可理解的史事,再给用户五个稳定的学习入口。本文就沿着真实源码拆解这个 ArkUI 首页如何落地。
上图来自本地 DevEco 模拟器运行的真实应用。页面里的山水 hero、每日一文、一日一史和五个入口卡,并不是静态拼图:诗句来自PoemPackRepo,史事来自DailyService,入口路由来自AppRoutes,布局响应式依赖AppBp和GridRow/GridCol。
| 维度 | 内容 |
|---|---|
| 应用 | 观止·诗史汇 |
| 技术栈 | HarmonyOS NEXT、ArkTS、ArkUI、Stage 模型 |
| 本篇主题 | ArkUI 首页搭建:每日诗句、每日史事与功能入口 |
| 核心文件 | HomePage.ets、EntryCard.ets、DailyPoemCard.ets、DailyEventCard.ets、DailyService.ets、PoemPackRepo.ets |
| 验收目标 | 首页能承接学习叙事,数据来源稳定,入口路由清晰,多端布局不重复造页面 |
本章导读
本文按首页的数据流和 UI 结构来讲。
先看首页为什么不能只是功能目录。然后拆HomePage的状态模型,理解为什么它只做编排,不直接沉淀业务数据。接着看 hero 诗句、五个入口卡、每日一文和一日一史三个 UI 区块。最后回到响应式布局、路由跳转、每日内容稳定性和验收命令,形成一套可以复用到后续页面的 ArkUI 页面拆解方法。
当前验证环境
| 项目 | 版本或说明 |
|---|---|
| DevEco Studio | 6.x 系列 |
| HarmonyOS SDK | targetSdkVersion: 6.1.0(23) |
| 应用模型 | Stage 模型 |
| 页面文件 | features/src/main/ets/home/HomePage.ets |
| 组件文件 | EntryCard.ets、DailyPoemCard.ets、DailyEventCard.ets |
| 数据来源 | DailyService、PoemPackRepo、DynastyService |
| 验证页面 | 首页首屏、入口网格、每日双卡、子页面跳转 |
首页的验收重点不是“卡片是否好看”,而是四件事:首屏是否有学习方向,每日内容是否稳定,入口是否能通往真实页面,布局是否能从手机自然延展到平板。
首页不是目录,而是学习任务的起点
第一篇已经提到,《观止·诗史汇》不是只做诗词列表,而是要把阅读、理解、练习、记录和统计串起来。首页承担的是这条学习路径的第一步。
如果首页只放功能入口,用户会看到“诗文时空、兴替明鉴、古今地理、文试默写、文脉纵览”,但不知道今天该从哪里开始。当前设计把首页拆成三个层次:
| 层次 | 页面表现 | 工程对象 |
|---|---|---|
| 视觉引导 | 山水 hero + 五分钟切换的诗句 | heroPoem、heroQuote、quoteTimer |
| 学习入口 | 五个功能卡片 | entries: EntryCardModel[]、EntryCard |
| 今日任务 | 每日一文 + 一日一史 | DailyPoemCard、DailyEventCard |
这样用户打开首页时,不会只看到“功能超市”。他会先被诗句和山水氛围带入,然后看到可以进入的学习模块,最后得到两张今日卡片:今天读一篇,今天看一件史事。
首页文件职责:只编排,不越界
HomePage.ets位于features/src/main/ets/home,属于业务页面层。它导入的能力很典型:
import { AppColors, AppDimens, AppText, AppTopBar, AppRoutes, Navigator, NavigateParams, LoadingView, AppBp } from 'commons'; import { DailyService, DailyBundle, createDailyService } from '../services/DailyService'; import { createDynastyService, DynastyService } from '../services/DynastyService'; import { PoemBrief, PoemDetail } from '../poem/PoemPackTypes'; import { PoemPackRepo } from '../poem/PoemPackRepo'; import { EntryCard, EntryCardModel } from './components/EntryCard'; import { DailyPoemCard } from './components/DailyPoemCard'; import { DailyEventCard } from './components/DailyEventCard';这里正好承接第二篇的分层规则:
| 来源 | 首页使用什么 | 为什么合理 |
|---|---|---|
commons | 主题 token、路由、Loading、断点 | 基础能力,不带首页业务 |
services | 每日内容、朝代服务 | 业务数据来源 |
poem | 诗文内容包仓储 | 首页要展示今日诗句 |
home/components | 三个局部组件 | 首页内部 UI 拆分 |
HomePage没有直接读 Preferences,也没有在页面里写一堆全局路由字符串,更没有把 Entry 页面的业务逻辑搬进来。它只负责把这些能力编排成一个首页。
HomeState:首屏状态要能解释页面结构
首页状态被收束成一个接口:
interface HomeState { loading: boolean; daily: DailyBundle | null; dailyPoem: PoemBrief | null; dailyPoemLine: string; heroPoem: PoemBrief | null; heroQuote: string; heroSlot: number; eventDynastyName: string; }这个状态设计有一个好处:它和页面分区一一对应。
| 状态字段 | 服务哪个区域 | 说明 |
|---|---|---|
loading | 整页加载态 | 首次进入时显示LoadingView |
daily | 一日一史 | 包含今日日期、诗、史事 |
dailyPoem | 每日一文 | 今日诗文轻量信息 |
dailyPoemLine | 每日一文摘要 | 从正文里截取一句适合展示的内容 |
heroPoem | hero 区 | 当前 hero 诗句来源 |
heroQuote | hero 区 | 实际展示的完整短句 |
heroSlot | hero 轮换 | 五分钟一个时间片 |
eventDynastyName | 一日一史 | 把事件的朝代 ID 补成朝代名 |
页面状态不是随手堆变量,而是能解释 UI。以后如果首页新增“今日练习”或“继续阅读”,也应该先问:它是新的学习任务,还是现有每日双卡的扩展?状态模型能帮助页面避免变成无序变量池。
aboutToAppear:进入首页时只做一次数据准备
首页生命周期入口很短:
async aboutToAppear() { await this.refresh(); this.startQuoteTimer(); } aboutToDisappear(): void { this.stopQuoteTimer(); }这里做了两件事:加载今日内容,启动 hero 诗句轮换。对应地,离开页面时停止定时器,避免页面不可见后还继续更新状态。
这类处理看似简单,但在 ArkUI 页面里很关键。定时器、订阅、动画和异步请求都应该有清晰的生命周期边界。否则页面切来切去之后,很容易出现重复刷新、状态抖动或内存占用增加。
refresh:首页数据流的主干
refresh()是首页数据流的核心。它先把页面置为 loading,然后依次取今日内容、今日诗句、hero 诗句和史事朝代名。
async refresh() { this.state = { loading: true, daily: null, dailyPoem: null, dailyPoemLine: '', heroPoem: this.state.heroPoem, heroQuote: this.state.heroQuote, heroSlot: this.state.heroSlot, eventDynastyName: '' }; const bundle: DailyBundle = await this.dailySvc.getToday(); const dailyPoem: PoemBrief | null = await this.poemRepo.pickBriefBySeed(this.hashString(`daily:${this.todayKey()}`)); }这里有个值得注意的细节:刷新时保留了旧的heroPoem、heroQuote、heroSlot。这样即使今日双卡重新加载,hero 区也不会立即空掉,页面观感更稳定。
完整的数据来源可以拆成四条线:
| 数据 | 来源 | 用途 |
|---|---|---|
| 今日 bundle | DailyService.getToday() | 取今日日期和今日史事 |
| 今日诗文 | PoemPackRepo.pickBriefBySeed() | 每日一文卡片 |
| 诗文详情 | PoemPackRepo.getDetail() | 从正文中截取展示句 |
| 朝代名 | DynastyService.getById() | 一日一史卡片展示 |
这就是首页和第一篇、第二篇的关系:第一篇定义学习闭环,第二篇定义分层边界,第三篇把这些边界落成一个可见的数据流。
每日诗句:同一天稳定,而不是每次随机
首页没有直接Math.random()随机选诗,而是用日期作为 seed:
const dailyPoem: PoemBrief | null = await this.poemRepo.pickBriefBySeed(this.hashString(`daily:${this.todayKey()}`));这个选择非常适合学习类 App。每日内容如果每次打开都变,用户很难形成“今天我读了这首”的记忆。用日期 seed 后,同一天打开多次,得到的是同一首诗;第二天再自然切换。
PoemPackRepo.pickBriefBySeed()的底层逻辑也很克制:
async pickBriefBySeed(seed: number): Promise<PoemBrief | null> { await this.ensureAllFlat(); if (this.allFlat.length === 0) return null; const idx: number = this.positiveMod(seed, this.allFlat.length); return this.allFlat[idx]; }这里先确保全量轻量索引已加载,再用正向取模选出一首。它没有读取所有详情,也没有让首页承担内容包细节。首页只拿到PoemBrief,需要展示正文句子时再按 shard 读取详情。
DailyService:每日双卡的业务入口
DailyService负责每日诗文与史事的基础 bundle:
export interface DailyBundle { date: string; poem: Poem | null; event: HistoryEvent | null; } export class DailyService { async getToday(): Promise<DailyBundle> { const date: string = todayString(); const poem: Poem | null = await this.poemSvc.getById(pickDailyPoemId()); const event: HistoryEvent | null = await this.eventSvc.getById(pickDailyEventId()); return { date, poem, event }; } }从当前实现看,DailyService已经把“今日内容”抽成了业务服务。后面如果要把每日推荐从 mock 规则升级为可配置内容,只需要扩展 service,不需要重写首页 UI。
这里也能看到一个阶段性取舍:今日史事来自DailyService,今日诗文卡片又通过PoemPackRepo从内容包中按日期 seed 选择。短期看有两条来源,但它们承担的职责不同:
| 对象 | 负责什么 |
|---|---|
DailyService | 今日 bundle、今日史事、传统 mock 内容 |
PoemPackRepo | 真实诗文内容包的轻量索引和详情读取 |
HomePage | 把两者组织成每日双卡 |
这正是实战项目常见的演进方式:旧数据服务继续可用,新内容包逐步接入,页面层负责把它们拼成稳定体验。
hero 区:山水不是装饰,而是诗句展示容器
首页顶部的 hero 区使用Stack:
Stack({ alignContent: Alignment.Center }) { Image($r('app.media.img_home_hero')) .width('100%') .height('100%') .objectFit(ImageFit.Cover) Column({ space: 8 }) { Text(this.state.heroQuote.length > 0 ? this.state.heroQuote : '清风明月本无价') .fontSize(AppBp.isMdUp(this.curBp) ? 20 : 15) .fontColor(AppColors.heroQuoteText) .textAlign(TextAlign.Center) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } }这里的山水图不是单纯背景。它承担了首页的第一层叙事:用户先看到一句短诗,再看到诗名和作者,然后向下进入功能入口和每日双卡。
为了避免 hero 区压缩变形,页面用了两套尺寸策略:
.height(AppBp.isLg(this.curBp) ? 240 : (AppBp.isMdUp(this.curBp) ? 200 : undefined)) .aspectRatio(AppBp.isMdUp(this.curBp) ? undefined : 16 / 9)手机上用aspectRatio(16 / 9)保证比例,大屏上用固定高度控制视觉稳定。这比简单写一个固定高度更可靠,也比为手机和平板复制两套 hero 组件更干净。
heroQuote:五分钟一换,但不让页面跳动
首页 hero 诗句不是每天只变一次,而是五分钟一个 slot:
const HERO_QUOTE_INTERVAL_MS: number = 5 * 60 * 1000; private currentHeroSlot(): number { return Math.floor(Date.now() / HERO_QUOTE_INTERVAL_MS); }定时器每 30 秒检查一次,但只有 slot 变化时才真正更新:
private async updateHeroQuote(): Promise<void> { const slot: number = this.currentHeroSlot(); if (this.state.heroSlot === slot) return; const heroPoem: PoemBrief | null = await this.poemRepo.pickBriefBySeed(this.hashString(`hero:${slot}`)); }这避免了频繁更新页面。轮询间隔是 30 秒,实际内容周期是 5 分钟,中间大部分时间只是快速判断后返回。对首页这种常驻页面来说,这个细节很朴素,但能减少不必要的状态变更。
pickCompleteSentence:展示一句完整的话
hero 区需要一句短句,不能直接截固定长度。项目里用pickCompleteSentence()尽量保留完整句子:
private pickCompleteSentence(text: string): string { const compact: string = text.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim(); if (!compact) return ''; const parts: string[] = compact.split(/(?<=[。!?;])/); for (let i = 0; i < parts.length; i++) { const sentence: string = parts[i].trim(); if (sentence.length > 0 && sentence.length <= 32) return sentence; } return compact; }诗文内容往往包含换行、空格和多个句子。这里先压缩空白,再按中文标点切句,优先选择 32 字以内的完整句。这样 hero 区不会出现“半截诗句”,也不会因为一句太长挤爆版面。
五个入口卡:功能不是越多越好
首页入口定义在entries数组里:
private entries: EntryCardModel[] = [ { id: 'e_poem', label: '诗文时空', hint: '按朝代/作者/主题检索经典诗文', icon: $r('app.media.ic_entry_poem'), routeUrl: AppRoutes.POEM_LIST }, { id: 'e_dynasty', label: '兴替明鉴', hint: '一朝兴替,四维总览,六模块详解', icon: $r('app.media.ic_entry_dynasty'), routeUrl: AppRoutes.DYNASTY_INSIGHT } ];五个入口分别对应五条学习路径:
| 入口 | 定位 | 路由 |
|---|---|---|
| 诗文时空 | 查诗、读诗、进入诗文详情 | AppRoutes.POEM_LIST |
| 兴替明鉴 | 看朝代兴衰与结构化分析 | AppRoutes.DYNASTY_INSIGHT |
| 古今地理 | 理解诗文与历史地名 | AppRoutes.GEO_DETAIL |
| 文试默写 | 进入练习闭环 | AppRoutes.PRACTICE_HOME |
| 文脉纵览 | 看体裁、流派和思潮 | AppRoutes.LITERATURE |
这些入口不是随便排的。它们正好承接第一篇的学习闭环:阅读诗文、理解历史、连接地理、进入练习、看到文脉。首页的按钮区因此不是“功能列表”,而是一张学习地图。
EntryCard:局部组件只关心展示和点击
EntryCard的职责很简单:
export interface EntryCardModel { id: string; label: string; hint: string; icon: Resource; routeUrl: string; } @Component export struct EntryCard { @Prop model: EntryCardModel; onTap: (routeUrl: string) => void = () => {}; }组件内部只展示 icon、标题、说明和箭头:
Row({ space: AppDimens.spaceLg }) { Image(this.model.icon) .width(AppDimens.iconEntry) .height(AppDimens.iconEntry) Column({ space: 6 }) { Text(this.model.label) .fontSize(AppText.subtitle) .fontWeight(FontWeight.Bold) Text(this.model.hint) .fontSize(AppText.caption) .maxLines(2) } Text('›') } .onClick(() => this.onTap(this.model.routeUrl))路由动作没有写在卡片组件里,而是回调给父级HomePage。这样EntryCard保持通用,后面如果有别的页面也想复用入口卡,不会被首页路由逻辑绑死。
GridRow:一套入口组件适配三档断点
入口区使用GridRow/GridCol:
GridRow({ columns: { sm: 4, md: 8, lg: 12 }, gutter: { x: AppDimens.spaceMd, y: AppDimens.spaceMd } }) { ForEach(this.entries, (it: EntryCardModel) => { GridCol({ span: { sm: 4, md: 4, lg: 4 } }) { EntryCard({ model: it, onTap: (url: string) => this.handleEntryTap(url) }) } }, (it: EntryCardModel) => it.id) }这段代码很好地体现了 ArkUI 响应式写法:
| 断点 | columns | span | 实际效果 |
|---|---|---|---|
sm | 4 | 4 | 单列 |
md | 8 | 4 | 双列 |
lg | 12 | 4 | 三列 |
五个入口在手机上纵向排列,在中屏上变成两列,在大屏上自然变成三列。不需要写if phone then... else tablet...,也不需要维护两套首页。
ForEach key:入口卡需要稳定 id
入口区的ForEach使用it.id作为 key:
ForEach(this.entries, (it: EntryCardModel) => { GridCol({ span: { sm: 4, md: 4, lg: 4 } }) { EntryCard({ model: it, onTap: (url: string) => this.handleEntryTap(url) }) } }, (it: EntryCardModel) => it.id)这个细节很重要。入口卡后续可能增加、调整顺序或替换图标,如果用数组索引做 key,ArkUI 复用组件时更容易出现状态错位。e_poem、e_dynasty、e_geo这种稳定 id 能让 UI 更新更可靠。
每日一文:卡片只展示诗文,不负责选诗
DailyPoemCard接收PoemBrief和previewLine:
@Component export struct DailyPoemCard { @Prop poem: PoemBrief | null; @Prop previewLine: string = ''; onTap: (poemId: string, shard: number) => void = () => {}; }组件内部展示标题、朝代作者和一句摘要:
Text(this.poem.title) .fontSize(AppText.poemTitle) .fontWeight(FontWeight.Bold) Text(`${this.poem.dynasty} · ${this.poem.author}`) .fontSize(AppText.caption) Text(this.previewLine.length > 0 ? this.previewLine : this.poem.firstLine) .fontSize(AppText.body) .lineHeight(28) .maxLines(2)它不关心这首诗是怎么选出来的,也不关心诗文详情页怎么实现。点击时只把poemId和shard交给父级:
if (this.poem) { this.onTap(this.poem.poemId, this.poem.shard); }这就是局部组件的边界:展示自己需要的数据,事件向外抛,不直接碰仓储和路由。
一日一史:史事卡补齐朝代语境
DailyEventCard展示今日史事:
@Component export struct DailyEventCard { @Prop event: HistoryEvent | null; @Prop dynastyName: string = ''; onTap: (eventId: string) => void = () => {}; }卡片展示标题、朝代、年份、类别和摘要:
Text(this.event.title) .fontSize(AppText.subtitle) .fontWeight(FontWeight.Bold) Text(`${this.dynastyName} · ${this.event.year} 年 · ${this.event.category}`) .fontSize(AppText.caption) Text(this.event.summary) .fontSize(AppText.bodySm) .lineHeight(22) .maxLines(3)这里有个业务细节:HistoryEvent里是dynastyId,但卡片展示要用朝代名。这个转换放在HomePage.refresh()中完成:
let eventDyn: string = ''; if (bundle.event) { const d: Dynasty | null = await this.dynastySvc.getById(bundle.event.dynastyId); eventDyn = d ? d.name : ''; }这样DailyEventCard只负责展示,不负责再去查服务。页面层做一次数据补齐,组件层保持纯展示。
子页面跳转:入口和卡片都回到 Navigator
首页跳转有三类:
private handleEntryTap(url: string): void { Navigator.push(url); } private openPoem(poemId: string, shard: number): void { const params: NavigateParams = { poemId, poemShard: shard }; Navigator.push(AppRoutes.POEM_DETAIL, params); } private openEvent(eventId: string): void { const params: NavigateParams = { eventId }; Navigator.push(AppRoutes.TIMELINE_EVENT_DETAIL, params); }这正好接住第二篇里的路由边界:
| 操作 | 跳转方式 | 参数 |
|---|---|---|
| 点击功能入口 | Navigator.push(routeUrl) | 无或后续扩展 |
| 点击每日一文 | Navigator.push(AppRoutes.POEM_DETAIL, params) | poemId、poemShard |
| 点击一日一史 | Navigator.push(AppRoutes.TIMELINE_EVENT_DETAIL, params) | eventId |
首页没有直接写router.pushUrl,也没有到处散落页面字符串。所有路由都回到Navigator和AppRoutes,后续页面改名或参数调整时,维护边界更清楚。
LoadingView:首屏加载态也要有边界
首页 build 中先判断 loading:
if (this.state.loading) { LoadingView({ message: '加载今日内容…' }) .layoutWeight(1) } else { Scroll() { Column({ space: AppDimens.spaceLg }) { // hero / entries / daily cards } } }LoadingView来自commons,因为加载态不是首页独有能力。首页只传入文案,具体样式由公共 UI 组件负责。这个小设计也在延续第二篇的分层原则:通用 UI 状态放公共层,业务数据和页面编排留在features。
AppTopBar:标题栏不持有业务数据
首页顶部使用公共标题栏:
AppTopBar({ title: '观止·诗史汇', subtitle: '唐宋元明月共赏,古今兴衰一音通' })AppTopBar本身在commons/ui:
@Component export struct AppTopBar { @Prop title: string = ''; @Prop subtitle: string = ''; @Prop showBack: boolean = false; @Prop rightIcon: Resource | null = null; }这类组件适合公共化,因为它不理解“诗文”“史事”“练习”,只知道标题、副标题、返回和右侧动作。首页使用它,是在消费公共 UI 能力,而不是把首页业务下沉到公共层。
主题 token:视觉风格从 commons 统一读取
首页和卡片大量使用AppColors、AppDimens、AppText:
.padding(AppDimens.pagePadding) .backgroundColor(AppColors.pageBg) .borderRadius(AppDimens.radiusLg) .fontColor(AppColors.textPrimary)AppColors背后绑定的是资源 token:
export class AppColors { static readonly pageBg: Resource = $r('app.color.page_bg'); static readonly cardBg: Resource = $r('app.color.card_bg'); static readonly accent: Resource = $r('app.color.accent'); static readonly seal: Resource = $r('app.color.seal'); }这让首页视觉和全应用保持一致。后面第 12 篇写设置、暗色模式和无障碍时,就不需要逐个页面改颜色,只需要把资源和设置状态打通。
AppBp:响应式状态从入口广播到页面
首页使用:
@StorageLink('curBp') curBp: string = AppBp.SM;第二篇里讲过,entry监听断点变化后写入AppStorage。首页通过@StorageLink订阅这个值,然后用于 hero 高度、字体大小和布局判断。
AppBp的断点定义是:
export class AppBp { static readonly SM: string = 'sm'; static readonly MD: string = 'md'; static readonly LG: string = 'lg'; static isLg(bp: string): boolean { return bp === AppBp.LG; } static isMdUp(bp: string): boolean { return bp === AppBp.MD || bp === AppBp.LG; } }这让首页不需要关心窗口宽度具体是多少,只需要问“是不是中大屏”。响应式逻辑变成稳定语义,而不是到处写数字。
为什么每日双卡在 lg 才左右并排
每日一文和一日一史使用第二个GridRow:
GridRow({ columns: { sm: 4, md: 8, lg: 12 }, gutter: { x: AppDimens.spaceMd, y: AppDimens.spaceMd } }) { GridCol({ span: { sm: 4, md: 8, lg: 6 } }) { DailyPoemCard(...) } GridCol({ span: { sm: 4, md: 8, lg: 6 } }) { DailyEventCard(...) } }与入口卡不同,每日双卡在md仍然是上下排列,只有lg才左右并排:
| 断点 | 每日双卡布局 |
|---|---|
sm | 上下堆叠 |
md | 上下堆叠 |
lg | 左右各半 |
这是因为每日卡片里有标题、来源和摘要,横向空间太窄会影响阅读。入口卡只是一行标题和两行 hint,可以较早进入双列;每日双卡更偏内容阅读,需要等到大屏再并排。
页面滚动:首屏信息多,但不能拥挤
首页内容放在Scroll里:
Scroll() { Column({ space: AppDimens.spaceLg }) { // hero // entry grid // daily cards Blank().height(AppDimens.spaceXl) } .padding(AppDimens.pagePadding) } .scrollBar(BarState.Off)这个布局保留了两个体验点:
| 设计 | 作用 |
|---|---|
Column({ space: AppDimens.spaceLg }) | 统一区块间距,不让页面拥挤 |
Blank().height(AppDimens.spaceXl) | 给底部留白,避免贴底 |
scrollBar(BarState.Off) | 首页视觉更干净 |
layoutWeight(1) | 顶部栏外的内容区占满剩余空间 |
首页不追求“一屏塞完所有东西”。如果屏幕较小,让用户自然滚动比压缩卡片更好。
取舍复盘:为什么不把功能入口放到底部 Tab
第一篇里已经把主 Tab 定为:首页、时间轴、收藏、统计、设置。第三篇可以解释得更细:诗文时空、兴替明鉴、古今地理、文试默写、文脉纵览并不适合都放到底部 Tab。
| 功能 | 为什么不放底部 Tab |
|---|---|
| 诗文时空 | 属于首页进入的阅读任务 |
| 兴替明鉴 | 属于历史理解路径的一部分 |
| 古今地理 | 与诗文/史事联动,不是长期主容器 |
| 文试默写 | 是学习动作,不是全局容器 |
| 文脉纵览 | 更像专题入口 |
底部 Tab 应该放长期常驻能力,首页入口则放学习任务。这个取舍让 App 结构更稳,也让首页承担了“今天从哪里开始”的产品职责。
工程验收记录
本篇可以从源码、模拟器、页面链路三层验收。
第一类,看首页相关文件是否存在:
Get-Content -LiteralPath .\features\src\main\ets\home\HomePage.ets -Encoding UTF8 Get-Content -LiteralPath .\features\src\main\ets\home\components\EntryCard.ets -Encoding UTF8 Get-Content -LiteralPath .\features\src\main\ets\home\components\DailyPoemCard.ets -Encoding UTF8 Get-Content -LiteralPath .\features\src\main\ets\home\components\DailyEventCard.ets -Encoding UTF8第二类,看服务和内容包:
Get-Content -LiteralPath .\features\src\main\ets\services\DailyService.ets -Encoding UTF8 Get-Content -LiteralPath .\features\src\main\ets\poem\PoemPackRepo.ets -Encoding UTF8 Get-ChildItem -LiteralPath .\entry\src\main\resources -Recurse | Select-String -Pattern "img_home_hero"第三类,看模拟器启动与截图:
& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" list targets & "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" shell aa start -a EntryAbility -b com.example.app_project02 & "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" shell snapshot_display -i 0 -f /data/local/tmp/app_home.png -w 1080 -h 2400 -t png第四类,人工点验:
| 检查项 | 期望 |
|---|---|
| 首屏 | 能看到应用名、hero 诗句、五个入口 |
| 每日一文 | 有诗名、朝代作者、摘要句 |
| 一日一史 | 有标题、朝代、年份、类别和摘要 |
| 入口卡 | 点击后进入对应子页面 |
| 每日诗文 | 同一天打开保持稳定 |
| 响应式 | 手机单列,中大屏自然扩展 |
常见问题复盘
1. 为什么首页里同时有 DailyService 和 PoemPackRepo?
因为项目正处在从 mock 内容到真实内容包的演进阶段。DailyService继续负责每日 bundle 和史事,PoemPackRepo承接真实诗文包。首页把两者组合起来,不影响后续把 DailyService 升级为统一推荐服务。
2. 为什么 hero 诗句五分钟一换,而每日一文一天一换?
两者承担的任务不同。每日一文是学习任务,需要稳定;hero 诗句是视觉引导,可以轻微变化。五分钟一换让首页有新鲜感,但不会影响用户对“今日学习内容”的记忆。
3. 为什么 EntryCard 不直接调用 Navigator?
EntryCard是局部展示组件,不应该知道页面路由体系。它只把routeUrl通过回调交给父组件。这样组件边界更清楚,也方便以后复用。
4. 为什么md断点下每日双卡仍然上下排列?
每日卡片比入口卡更重,包含标题、来源和摘要。中屏横向并排会让文本空间变窄,影响阅读。等到lg再左右并排更稳。
5. 为什么首页不直接读取 Preferences?
首页首屏目前展示的是公共学习内容,不是用户私有状态。收藏、统计、错题这类状态由后续页面和状态仓承接。首页如果过早读取太多个人状态,会让首屏变重,也会模糊页面职责。
文件职责整理
| 文件 | 职责 |
|---|---|
features/src/main/ets/home/HomePage.ets | 首页状态、数据流、hero、入口网格、每日双卡编排 |
features/src/main/ets/home/components/EntryCard.ets | 功能入口卡片 |
features/src/main/ets/home/components/DailyPoemCard.ets | 每日一文卡片 |
features/src/main/ets/home/components/DailyEventCard.ets | 一日一史卡片 |
features/src/main/ets/services/DailyService.ets | 今日诗文和史事 bundle |
features/src/main/ets/poem/PoemPackRepo.ets | 诗文内容包、索引、详情和 seed 选诗 |
commons/src/main/ets/router/RouteNames.ets | 首页入口使用的路由常量 |
commons/src/main/ets/theme/Breakpoints.ets | 首页响应式断点 |
commons/src/main/ets/ui/AppTopBar.ets | 顶部标题栏 |
验收清单
- 首页首屏能看到应用名、副标题、hero 诗句。
- 首页五个入口来自
entries数组,并使用稳定id作为ForEachkey。 - 入口卡点击通过
Navigator.push进入对应页面。 - 每日一文展示诗名、朝代作者和正文摘句。
- 每日一文点击传递
poemId和poemShard到诗文详情。 - 一日一史展示史事标题、朝代、年份、类别和摘要。
- 一日一史点击传递
eventId到事件详情。 - hero 诗句使用
heroSlot控制轮换,不频繁刷新。 - 每日诗文使用日期 seed,同一天保持稳定。
- 首页布局使用
GridRow/GridCol,不复制多套页面。 - 主题、间距、字号、断点来自
commons。 - 首页组件只展示数据,不直接读取仓储或写全局状态。
本章小结
第三篇的核心不是“写一个漂亮首页”,而是把第一篇的学习闭环和第二篇的工程边界落在用户第一眼看到的页面上。
HomePage负责组织首页数据流,EntryCard、DailyPoemCard、DailyEventCard负责局部展示,DailyService和PoemPackRepo提供今日内容,Navigator和AppRoutes承接跳转,AppBp、AppColors、AppDimens让页面适配不同设备和主题。
这样写出来的首页不是一张静态海报,也不是按钮堆砌,而是一条可继续延展的学习路径:今天读一句诗,理解一件史事,再从诗文、朝代、地理、练习和文脉进入更深的学习模块。下一篇会继续沿着这条主线,拆解诗文内容包如何从 Markdown 转成可检索、可按需读取的本地诗库。
[#HarmonyOS](https://so.csdn.net/so/search/s.do?q=HarmonyOS&t=all&o=vip&s=&l=&f=&viparticle=&from_tracking_code=tag_word&from_code=app_blog_art) [#ArkTS](https://so.csdn.net/so/search/s.do?q=ArkTS&t=all&o=vip&s=&l=&f=&viparticle=&from_tracking_code=tag_word&from_code=app_blog_art) [#ArkUI](https://so.csdn.net/so/search/s.do?q=ArkUI&t=all&o=vip&s=&l=&f=&viparticle=&from_tracking_code=tag_word&from_code=app_blog_art) [#DevEco Studio](https://so.csdn.net/so/search/s.do?q=DevEco+Studio&t=all&o=vip&s=&l=&f=&viparticle=&from_tracking_code=tag_word&from_code=app_blog_art) [#鸿蒙开发](https://so.csdn.net/so/search/s.do?q=%E9%B8%BF%E8%92%99%E5%BC%80%E5%8F%91&t=all&o=vip&s=&l=&f=&viparticle=&from_tracking_code=tag_word&from_code=app_blog_art)
