【观止·诗史汇 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 Studio6.x 系列HarmonyOS SDKtargetSdkVersion: 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整页加载态首次进入时显示LoadingViewdaily一日一史包含今日日期、诗、史事dailyPoem每日一文今日诗文轻量信息dailyPoemLine每日一文摘要从正文里截取一句适合展示的内容heroPoemhero 区当前 hero 诗句来源heroQuotehero 区实际展示的完整短句heroSlothero 轮换五分钟一个时间片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 区也不会立即空掉页面观感更稳定。完整的数据来源可以拆成四条线数据来源用途今日 bundleDailyService.getToday()取今日日期和今日史事今日诗文PoemPackRepo.pickBriefBySeed()每日一文卡片诗文详情PoemPackRepo.getDetail()从正文中截取展示句朝代名DynastyService.getById()一日一史卡片展示这就是首页和第一篇、第二篇的关系第一篇定义学习闭环第二篇定义分层边界第三篇把这些边界落成一个可见的数据流。每日诗句同一天稳定而不是每次随机首页没有直接Math.random()随机选诗而是用日期作为 seedconst dailyPoem: PoemBrief | null await this.poemRepo.pickBriefBySeed(this.hashString(daily:${this.todayKey()}));这个选择非常适合学习类 App。每日内容如果每次打开都变用户很难形成“今天我读了这首”的记忆。用日期 seed 后同一天打开多次得到的是同一首诗第二天再自然切换。PoemPackRepo.pickBriefBySeed()的底层逻辑也很克制async pickBriefBySeed(seed: number): PromisePoemBrief | 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负责每日诗文与史事的基础 bundleexport interface DailyBundle { date: string; poem: Poem | null; event: HistoryEvent | null; } export class DailyService { async getToday(): PromiseDailyBundle { 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 区使用StackStack({ 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 诗句不是每天只变一次而是五分钟一个 slotconst 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(): Promisevoid { 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/GridColGridRow({ 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 响应式写法断点columnsspan实际效果sm44单列md84双列lg124三列五个入口在手机上纵向排列在中屏上变成两列在大屏上自然变成三列。不需要写if phone then... else tablet...也不需要维护两套首页。ForEach key入口卡需要稳定 id入口区的ForEach使用it.id作为 keyForEach(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)这个细节很重要。入口卡后续可能增加、调整顺序或替换图标如果用数组索引做 keyArkUI 复用组件时更容易出现状态错位。e_poem、e_dynasty、e_geo这种稳定 id 能让 UI 更新更可靠。每日一文卡片只展示诗文不负责选诗DailyPoemCard接收PoemBrief和previewLineComponent 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 中先判断 loadingif (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/uiComponent 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背后绑定的是资源 tokenexport 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 才左右并排每日一文和一日一史使用第二个GridRowGridRow({ 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 不直接调用 NavigatorEntryCard是局部展示组件不应该知道页面路由体系。它只把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今日诗文和史事 bundlefeatures/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?qHarmonyOStallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#ArkTS](https://so.csdn.net/so/search/s.do?qArkTStallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#ArkUI](https://so.csdn.net/so/search/s.do?qArkUItallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#DevEco Studio](https://so.csdn.net/so/search/s.do?qDevEcoStudiotallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art) [#鸿蒙开发](https://so.csdn.net/so/search/s.do?q%E9%B8%BF%E8%92%99%E5%BC%80%E5%8F%91tallovipslfviparticlefrom_tracking_codetag_wordfrom_codeapp_blog_art)