写作训练类应用的首页最容易写散:上面放一句口号,中间放几个按钮,下面再堆一串题库卡片,看起来信息很多,用户真正打开时却不知道先点哪里。笔下生辉的首页实现给了一个比较清晰的工程样本:它没有把首页做成纯展示页,而是把“今日概览、快捷行动、题型分类、推荐题库、学习进度”按扫读顺序组织起来,再通过Index.ets的底部导航和侧边导航适配手机、平板和 2in1。
本文只基于本地源码D:\huawei\one17-11可复核内容展开,核心文件包括entry/src/main/ets/views/HomePage.ets、entry/src/main/ets/pages/Index.ets、entry/src/main/ets/common/components/BankCard.ets、entry/src/main/ets/common/components/SectionHeader.ets、entry/src/main/ets/mock/MockBanks.ets与entry/src/main/module.json5。文章不记录 AppGallery 后台状态,不声称公开发布结果,也不虚构源码里不存在的在线推荐、云同步或 AI 写作能力。
本文解决四个具体问题:
- 首页如何把“能做什么”放到用户第一眼能理解的位置。
- 分类、搜索、题库和错题入口如何从同一页自然分流。
- 学习进度如何从
StorageLink与题库卡片联动,而不是写死展示数字。 - 手机、平板、2in1 下首页和导航如何避免互相挤占。
一、先确认首页不是孤立页面,而是 Tab 架构里的首屏
HomePage.ets并不是应用入口本身,它被pages/Index.ets装进 Tab 容器。这个边界很关键:首页只负责首屏信息组织和跳转意图,底部安全区、顶部避让、底部导航、侧边导航属于Index。
// entry/src/main/ets/pages/Index.ets @Builder PageContent() { if (this.currentIndex === 0) { HomePage() } else if (this.currentIndex === 1) { BankListPage() } else if (this.currentIndex === 2) { ExamTab() } else if (this.currentIndex === 3) { FavoritePage() } else { MinePage() } }这段代码说明首页只是第一个 Tab。首页里点击“更多”时可以把currentTabIndex改到题库页,点击错题复习时可以把favoriteTabIndex与currentTabIndex组合起来。这比在首页里直接复制题库页或错题页要稳,因为页面职责不会互相吞掉。
对 HarmonyOS 应用来说,这个拆分还能减少发布前复查难度:如果首页出现滚动、底栏遮挡、侧栏适配问题,优先看Index.ets;如果卡片进度或分类入口不对,再看HomePage.ets和子组件。
二、用 StorageLink 建立首页与全局学习状态的连接
首页真正要展示的不是静态素材,而是用户当前学习状态。源码中HomePage使用多组@StorageLink读取全局状态:当前 Tab、收藏 Tab、题库进度、考试历史、收藏记录、错题记录、当前断点。
@StorageLink('currentTabIndex') currentTabIndex: number = 0 @StorageLink('favoriteTabIndex') favoriteTabIndex: number = 0 @StorageLink('bankProgress') progressList: BankProgress[] = [] @StorageLink('examHistory') examHistory: ExamHistory[] = [] @StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = [] @StorageLink('wrongRecords') wrongRecords: WrongRecord[] = [] @StorageLink('currentBreakpoint') currentBp: string = 'sm' @State pageWidth: number = 360这些状态各自有明确用途:
| 状态 | 首页用途 | 失效后的表现 |
|---|---|---|
currentTabIndex | 首页按钮切换到题库、挑战、错题等 Tab | 点击后仍停留首页 |
favoriteTabIndex | 错题入口指定收藏页内部 Tab | 进入错题页后默认分区不对 |
bankProgress | 推荐题库卡片显示已答数量和进度 | 题库卡片看起来像静态列表 |
examHistory | Hero 统计挑战次数 | 今日概览缺少反馈 |
favoriteRecords | Hero 统计收藏数 | 收藏行为没有被首页感知 |
wrongRecords | 快捷行动和底栏徽标 | 错题复习入口缺少紧迫感 |
currentBreakpoint | 决定导航和题库布局 | 宽屏仍使用窄屏布局 |
首页通过StatService.summarize(...)把这些状态归并成展示指标,这个选择比直接在 UI 中到处写统计表达式更利于维护。
private myStats() { return StatService.summarize( this.progressList, this.examHistory, this.favRecords, this.wrongRecords ) }工程上要注意一点:myStats()在Hero()中会被多次调用,如果后续统计逻辑变重,可以把统计结果提升为 ViewModel 或在状态变化时缓存。当前源码里统计来源是本地数组,首页规模也不大,这种写法仍可接受。
三、Hero 区先回答“今天有什么可做”
首页 Hero 区不是简单 banner。它把总题量、题型覆盖、已答、正确率、挑战次数、收藏数放在一块,让用户打开应用后能立即判断自己处在什么状态。
@Builder Hero() { Column() { Column({ space: 14 }) { Row() { Column({ space: 6 }) { Text('今日挑战') Text(`${TOTAL_QUESTIONS.toLocaleString()} 题`) Text(`覆盖 ${TOTAL_REGIONS} 类语文纠错`) } Blank() Image($r('app.media.img_home_china_map')) } Row({ space: 8 }) { this.HeroStat(`${this.myStats().totalAnswered}`, '已答') this.HeroStat(`${this.myStats().accuracyPercent}%`, '正确率') this.HeroStat(`${this.myStats().examCount}`, '挑战') this.HeroStat(`${this.myStats().favoriteCount}`, '收藏') } } .linearGradient({ angle: 135, colors: [[Colors.PRIMARY, 0], [Colors.PRIMARY_DARK, 1]] }) } }这里有两个可复用点:
第一,TOTAL_QUESTIONS与TOTAL_REGIONS来自MockBanks.ets,不是在页面里硬编码。首页只消费“目录层统计”,数据源负责维护题库规模。
第二,HeroStat把统计块封装成小 Builder,避免四个统计项在布局、背景、字体上逐个复制。
@Builder HeroStat(value: string, label: string) { Column({ space: 3 }) { Text(value) .fontSize(Sizes.TITLE_FONT) .fontWeight(FontWeight.Bold) .fontColor(Color.White) Text(label) .fontSize(Sizes.SMALL_FONT) .fontColor('#E6FFFFFF') } .layoutWeight(1) .height(50) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .borderRadius(14) .backgroundColor('#33000000') }如果要继续优化,可以减少#E6FFFFFF、#33000000这类局部硬编码,把它们沉到主题常量或资源色里。源码已经有Colors和Sizes,首页后续最好继续沿用这条路径。
四、快捷行动不是按钮堆叠,而是首页任务分流
QuickActions()把最重要的开始挑战放成主行动卡,限时挑战和错题复习放成次级行动。这种结构适合训练型应用:用户首次进入先看到主路径,老用户回来能直接处理错题或限时练习。
@Builder QuickActions() { Column({ space: 10 }) { this.PrimaryActionCard() Row({ space: 10 }) { this.ActionCard('限时挑战', '60 秒快速纠错', () => { this.currentTabIndex = 2 }) this.ActionCard('错题复习', `${this.wrongRecords.length} 道错题`, () => { this.favoriteTabIndex = 2 this.currentTabIndex = 3 }) } } }这段源码体现了两个设计判断:
| 行动 | 跳转方式 | 为什么这样写 |
|---|---|---|
| 开始挑战 | currentTabIndex = 1 | 进入题库/训练主路径 |
| 限时挑战 | currentTabIndex = 2 | 切到挑战 Tab,降低路径成本 |
| 错题复习 | favoriteTabIndex = 2+currentTabIndex = 3 | 先定位收藏页内部错题分区,再切外层 Tab |
ActionCard对长文案也做了基本防护:标题和副标题都设置maxLines(1)与textOverflow(Ellipsis)。首页这种卡片在中文环境里很容易遇到“错题复习 123 道错题”长度变化,提前加约束能减少适配时的截断和挤压。
五、题型分类用 Flex 包裹,适合首页短入口
分类区不是完整分类页,只展示前 7 个分类加“全部”。源码用Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween })做短入口宫格。
@Builder CategoryStrip() { Column() { Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { ForEach(CATEGORIES.slice(0, 7), (cat: Category) => { this.CategoryItem(cat) }, (cat: Category) => cat.type) this.CategoryAllItem() } } .padding({ left: 10, right: 10, top: 12, bottom: 4 }) .margin({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE }) .backgroundColor('#EAFBF8F0') .borderRadius(10) }每个分类项点击后进入搜索页,并把分类类型与分类名称作为路由参数传递。
router.pushUrl({ url: 'pages/SearchPage', params: { categoryType: cat.type, categoryName: cat.name } })这个实现适合“写作素材首页”的首屏,因为用户并不需要在首页展开全部题库树。首页只负责让用户快速选择题型方向,例如错别字、病句、标点、词语误用、网络热词、古诗纠错等,详细筛选留给SearchPage或分类页。
需要注意的是,源码当前CategoryItem宽度固定为30.5%,高度76。这在手机三列入口里比较稳定,但宽屏下分类区仍是短入口而非真正自适应分类看板。如果后续要把分类区做成平板强化模块,可以考虑让宽屏使用 4 列或 6 列,而不是简单放大三列卡片。
六、推荐题库卡片承载进度,而不是只展示题库名称
推荐题库区调用BankCard,卡片内部会根据bankProgress计算真实完成数、正确率和进度比例。
private realAccuracy(): number { const p = UserDataManager.getProgress(this.progressList, this.bank.id) if (p && p.finished > 0) return p.correct / p.finished return this.bank.accuracy } private realFinished(): number { const p = UserDataManager.getProgress(this.progressList, this.bank.id) return p ? p.finished : 0 } private progressRatio(): number { if (this.bank.totalCount === 0) return 0 return Math.min(this.realFinished() / this.bank.totalCount, 1) }这三段方法分别负责准确率、已完成数量和进度百分比。它们的好处是:页面不关心进度数据如何查找,只负责把结果显示出来;UserDataManager.getProgress(...)的数据查找逻辑也不会散落到多个 Builder 里。
卡片正文还处理了长标题和小宽度场景:
Text(this.bank.name) .layoutWeight(1) .constraintSize({ minWidth: 0 }) .fontSize(Sizes.TITLE_FONT) .fontWeight(FontWeight.Bold) .fontColor(Colors.TEXT_PRIMARY) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis })constraintSize({ minWidth: 0 })与layoutWeight(1)配合使用,能减少 Row 内文字把右侧按钮挤出的问题。首页卡片里同时存在封面图、题库名称、标签、进度条和“进入”按钮,这类约束比单纯写width('100%')更可靠。
七、推荐题库在宽屏下切换为网格
首页是否使用网格,不只看断点,也看实际页面宽度。
private useGridLayout(): boolean { return this.currentBp === 'lg' && this.pageWidth >= 700 } .onAreaChange((oldArea: Area, newArea: Area) => { const width = Number(newArea.width) if (width > 0) this.pageWidth = width })然后BankSection()按条件切换布局:
@Builder BankSection() { if (this.useGridLayout()) { Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { ForEach(BANKS, (bank: Bank) => { Column() { BankCard({ bank: bank }) } .width('32%') .margin({ bottom: 12 }) }, (bank: Bank) => bank.id) } } else { Column({ space: 12 }) { ForEach(BANKS, (bank: Bank) => { BankCard({ bank: bank }) }, (bank: Bank) => bank.id) } } }这比只用currentBreakpoint更稳。原因是折叠屏、小窗、2in1 可调整窗口下,同一个断点名不一定完全等于内容区可用宽度。源码通过pageWidth >= 700再确认一次,避免内容区窄却强行塞三列。
BankCard自身还会用cardWidth < 280切换CompactCard(),形成两级保护:
| 层级 | 判断点 | 保护目标 |
|---|---|---|
HomePage | 页面宽度是否足够 | 决定题库列表还是三列网格 |
BankCard | 卡片自身宽度是否足够 | 决定横向卡片还是紧凑卡片 |
这种组合很适合 HarmonyOS 多设备布局。页面控制区域密度,组件控制自身可读性。
八、Index 负责安全区与导航形态
首页底部留了Blank().height(92),但真正的底部安全区和导航高度由Index.ets处理。
private bottomSafePadding(): number { return Math.max( Sizes.BOTTOM_NAV_MIN_PADDING, this.getUIContext().px2vp(this.navigationIndicatorHeightPx) ) } private topSafePadding(): number { return Math.max( Sizes.PADDING_SMALL, this.getUIContext().px2vp(this.topAvoidAreaHeightPx) ) } private bottomNavHeight(): number { return Sizes.TAB_BAR_HEIGHT + this.bottomSafePadding() }底部导航区域使用Sizes.TAB_BAR_HEIGHT + bottomSafePadding(),页面根容器再加顶部避让。这说明首页没有直接操控系统窗口,只是在 Tab 内容区滚动。对 AppGallery 适配复查来说,这个边界很重要:如果底部按钮被系统手势条挡住,先检查navigationIndicatorHeightPx的注入和bottomSafePadding();如果首页内容最后一张卡片被底栏遮挡,再检查Blank().height(92)是否和真实底栏高度匹配。
源码还根据currentBp选择底部导航或侧边导航:
if (this.currentBp === 'sm' || this.currentBp === 'md' || this.currentBp === 'lg') { // 底部导航 Column() { Stack() { this.PageContent() } Column() { Row() { this.BottomNavItem(0) this.BottomNavItem(1) this.BottomNavItem(2) this.BottomNavItem(3) this.BottomNavItem(4) } } } } else { // 侧边导航 + 内容区 Row() { Column({ space: 8 }) { this.SideNavItem(0) this.SideNavItem(1) this.SideNavItem(2) this.SideNavItem(3) this.SideNavItem(4) } Stack() { this.PageContent() } } }当前判断把sm/md/lg都归到底部导航,其他断点才走侧边导航。文章不能推断它已经覆盖所有平板策略,只能说源码预留了底部/侧边两种导航形态,实际断点命名与注入逻辑还需要结合项目的断点服务复核。
九、数据目录来自 MockBanks,而不是页面临时拼装
MockBanks.ets是首页题库信息的主要来源。它定义了BANK_SPECS、REGIONS、CATEGORIES、BANKS,并通过syncCatalogCounts()同步题库数量和分类数量。
export const REGIONS: Region[] = BANK_SPECS.map((spec: BankSpec) => { return { id: spec.regionId, name: spec.name, shortName: spec.shortName, cover: spec.cover } as Region }) export const CATEGORIES: Category[] = [ { type: 'typo', name: '错别字', count: 0 }, { type: 'sentence', name: '病句', count: 0 }, { type: 'punctuation', name: '标点', count: 0 }, { type: 'usage', name: '词语误用', count: 0 }, { type: 'internet', name: '网络热词', count: 0 }, { type: 'poetry', name: '古诗词', count: 0 } ]源码中终端读取到的部分中文因控制台编码显示为乱码,但结构可以复核:分类类型包括typo、sentence、punctuation、usage、internet、poetry;题库卡片使用bank.id、bank.cover、bank.totalCount、bank.hot和章节数据。
TOTAL_QUESTIONS和TOTAL_REGIONS也从同一目录数据计算出来:
syncCatalogCounts() export const TOTAL_QUESTIONS: number = BANKS.reduce((sum: number, bank: Bank) => sum + bank.totalCount, 0) export const TOTAL_REGIONS: number = REGIONS.length这让首页 Hero 的数字和题库列表保持同源。后续如果题库扩容,只要目录同步逻辑正确,首页总数和卡片数量不会互相打架。
十、主题常量已经覆盖主要视觉语义
首页引用Colors与Sizes,主题常量集中在ThemeConstants.ets。源码里可以看到主色、背景、文本、分割线、状态色、字体尺寸、卡片圆角、底栏高度等定义。
export class Colors { static readonly PRIMARY: string = '#2F6B5E' static readonly PRIMARY_DARK: string = '#1F5246' static readonly PRIMARY_LIGHT: string = '#E6EFEB' static readonly ACCENT: string = '#8A6814' static readonly BACKGROUND: string = '#F5F1E8' static readonly SURFACE: string = '#FFFFFF' static readonly TEXT_PRIMARY: string = '#1F2A26' static readonly TEXT_SECONDARY: string = '#444444' static readonly TEXT_HINT: string = '#5F6763' static readonly ERROR: string = '#B3261E' } export class Sizes { static readonly H2_FONT: number = 18 static readonly TITLE_FONT: number = 16 static readonly BODY_FONT: number = 14 static readonly CAPTION_FONT: number = 12 static readonly SMALL_FONT: number = 10 static readonly TAB_BAR_HEIGHT: number = 60 static readonly BOTTOM_NAV_MIN_PADDING: number = 28 }这对首页很重要。写作训练类应用的页面容易同时出现标题、说明、标签、进度、按钮、徽标,如果每个组件自己挑颜色,后期调深浅色或对比度会非常困难。当前源码至少把大部分核心语义收口到了Colors和Sizes。
不过源码也存在局部硬编码,例如分类容器的#EAFBF8F0、卡片背景#FFFDF7、边框#E2D4B4,以及 Hero 统计块的半透明色。发布前如果要做更严格的深浅色复查,应把这些局部色也纳入主题表,并确认文本对比度。
十一、发布复查时要检查源码能支撑哪些宣传语
entry/src/main/module.json5声明设备类型为phone、tablet、2in1:
{ "module": { "name": "entry", "type": "entry", "deviceTypes": [ "phone", "tablet", "2in1" ], "pages": "$profile:main_pages", "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "icon": "$media:layered_image", "label": "$string:EntryAbility_label", "startWindowIcon": "$media:layered_image", "startWindowBackground": "$color:start_window_background", "exported": true } ] } }因此,本文可以讨论多设备首页布局和导航适配,但不能把它说成已经完成全部设备实机验收。源码提供的是布局实现依据;真正的上架材料仍要用设备截图、安装启动、旋转/缩放、小窗、深浅色等测试结果来闭环。
同样,首页题库数据来自本地MockBanks.ets,文章可以说它是本地题库目录和学习进度首页,不能说它有云端推荐、在线模型生成素材、跨端同步学习记录。保持宣传语和源码一致,才是 AppGallery 复查时最省成本的做法。
十二、可复核清单与常见问题
发布前可以按下面清单复查首页:
| 检查项 | 证据文件 | 通过标准 |
|---|---|---|
| 首页是否在首个 Tab | pages/Index.ets | currentIndex === 0渲染HomePage() |
| 底部导航是否避让系统手势区 | pages/Index.ets | bottomSafePadding()使用系统导航高度和最小值 |
| 首页是否可滚动 | views/HomePage.ets | 根容器为Scroll(),末尾留底部空白 |
| 题型分类是否可跳转 | views/HomePage.ets | CategoryItem跳转SearchPage并传参 |
| 推荐题库是否展示真实进度 | BankCard.ets | 通过UserDataManager.getProgress读取进度 |
| 宽屏是否改变题库布局 | views/HomePage.ets | currentBp === 'lg' && pageWidth >= 700使用三列 |
| 卡片窄宽是否降级 | BankCard.ets | cardWidth < 280使用紧凑布局 |
| 主题是否集中 | ThemeConstants.ets | 主要颜色和尺寸来自Colors、Sizes |
常见问题也可以直接按源码边界定位:
| 问题 | 先看哪里 | 修复方向 |
|---|---|---|
| 首页最后一张题库卡被底栏挡住 | Index.bottomNavHeight()、HomePage末尾Blank | 确认系统导航高度注入,必要时调整内容底部留白 |
| 平板上题库卡太窄 | HomePage.useGridLayout()、BankCard.useCompactLayout() | 同时检查页面宽度阈值和卡片自身宽度阈值 |
| 错题入口数量不更新 | wrongRecords的StorageLink | 检查错题写入和全局状态同步 |
| 分类点击后没有筛选效果 | CategoryItem路由参数、SearchPage接收逻辑 | 确认categoryType与categoryName被读取 |
| 首页统计和题库列表数字不一致 | MockBanks.syncCatalogCounts() | 确认题库扩容后同步目录数量 |
| 文案在按钮中截断 | ActionCard、BankCard文本约束 | 继续使用maxLines、textOverflow、constraintSize |
总结
笔下生辉首页的工程价值在于:它把写作训练入口做成了一个可扫读的任务面板,而不是堆满入口的展示页。Index管导航和安全区,HomePage管首屏信息架构,MockBanks管本地题库目录,BankCard管题库进度和详情跳转,主题常量管主要视觉语义。这个边界清楚以后,首页既能服务手机用户快速开始,也能在平板和 2in1 上通过网格与侧边导航保留扩展空间。
本文的唯一工程标记是com.jiaweikang.one17。如果后续进入公开发布阶段,应继续以平台真实草稿、公开页和发布时间为准,不用草稿结果替代公开发布结果。