系列第 3 篇。本文不讲“做一个首页看起来像首页”这种表层问题,而是结合真实项目《三国志鸿蒙 App》,复盘首页如何承担搜索、导航、内容分发和跨页面跳转这四类职责。

首页截图

一、首页为什么最容易做成“大杂烩”

首页通常是最先开始做的页面,但也是最容易失控的页面。

对三国历史知识类 App 来说,首页至少要同时处理这些需求:

  • 用户第一次进入时,要快速理解 App 能做什么。
  • 用户第二次进入时,要能继续上次的阅读和浏览路径。
  • 高频入口必须一眼能看到,不能藏得太深。
  • 内容推荐要有主次,不能让人物、事件、专题和地图互相抢注意力。
  • 搜索和分类筛选要能即时生效,不能把用户强制带去新页面。

如果不先做信息架构,直接开始堆组件,最后往往会变成下面这种状态:

  • 顶部一排按钮什么都想放。
  • Banner 只是装饰图,不承担信息传达。
  • 快捷入口和底部 Tab 职责重叠。
  • 人物、事件、专题都放成一样的卡片,视觉层级混乱。
  • 搜索框只是“摆设”,筛选逻辑散落在多个 Builder 里。

所以这篇的重点不是单个卡片怎么写,而是首页如何先建立结构,再把结构落实到 ArkUI 页面状态和组件拆分上

二、先定首页职责,再定模块顺序

这个项目的首页最终承担四件事:

  1. 让用户知道当前 App 的核心内容主题。
  2. 让用户能在两次点击内进入主要功能。
  3. 让高频内容模块可以顺着页面自然往下浏览。
  4. 让搜索、事件筛选、专题推荐共用一套页面状态,而不是拆成三套页面。

因此首页不是“九宫格入口页”,而是一个内容分发页。模块顺序我定成下面这样:

HomePage
 ├── PageShell
 ├── SearchBox
 ├── SearchFeedback
 ├── HeroBanner
 ├── QuickEntryGrid
 ├── PeopleStrip
 ├── EventCategoryChips
 ├── EventList
 └── ArticleList

对应到真实工程里的主流程也很清晰:当关键词为空时展示首页分发流;当关键词不为空时切到搜索结果流;当 homeMode 被切到 eventsarticles 时,同一页面再切换展示策略。

@State homeMode: string = 'home';
@State selectedCategory: string = '全部';

private homePage() {
  if (this.showEventDetail) {
    this.eventDetailPage();
  } else if (this.showArticleDetail) {
    this.articleDetailPage();
  } else {
    Scroll() {
      Column() {
        this.pageShell('三国志鸿蒙 App', '听风云人物,读乱世将星');
        this.searchBox();
        this.searchFeedback();
        if (this.keyword().length > 0) {
          this.searchResultContent();
        } else if (this.homeMode === 'events') {
          this.sectionHeader('事件索引', '返回首页');
          this.eventChips();
          this.eventList();
        } else if (this.homeMode === 'articles') {
          this.sectionHeader('专题解析', '返回首页');
          this.articleList();
        } else {
          this.heroBanner();
          this.quickEntries();
          this.sectionHeader('热门人物志', '查看更多');
          this.peopleStrip();
          this.sectionHeader('事件索引', '查看全部');
          this.eventChips();
          this.eventList();
          this.sectionHeader('专题解析', '深度阅读');
          this.articleList();
        }
      }
    }
  }
}

这段代码的价值不在“会写 if”,而在于它把首页状态切换集中到了一个地方。后面新增模块时,不需要去多个页面做跳转同步。

三、首页状态不要散,先把切换路径收口

很多 ArkUI 首页后面难维护,本质原因不是 UI 多,而是状态太散。

在这个项目里,我把首页的关键状态控制在几类:

  • activeTab:底部主导航当前选中页。
  • homeMode:首页当前是默认流、事件流还是专题流。
  • selectedCategory:事件筛选当前选中的类别。
  • searchKeyword / searchSubmitted:搜索输入与提交态。
  • showEventDetail / showArticleDetail:是否在首页内部打开详情视图。

其中最关键的是不要把“查看更多”“查看全部”“返回首页”这些点击事件直接散落在每个 Builder 里,而是统一收口到一个动作处理函数。

private handleSectionAction(title: string, action: string): void {
  if (title === '热门人物志' && action === '查看更多') {
    this.activeTab = 1;
    this.showEventDetail = false;
    this.showArticleDetail = false;
    return;
  }
  if (title === '事件索引' && action === '查看全部') {
    this.homeMode = 'events';
    this.selectedCategory = '全部';
    this.clearSearch();
    return;
  }
  if (title === '专题解析' && action === '深度阅读') {
    this.homeMode = 'articles';
    this.clearSearch();
    return;
  }
  if (action === '返回首页') {
    this.homeMode = 'home';
    this.clearSearch();
  }
}

这样做有三个直接好处:

  • Builder 只管渲染,不负责业务分叉。
  • 首页模式切换和搜索清空逻辑始终一致。
  • 后续如果把“专题解析”改成独立 Tab,也只需要改动作层。

对内容型 App 来说,这种“状态收口”比单纯拆组件更重要,因为页面交互经常不是单一路径。

四、Banner 不是背景图,而是首页的语义起点

很多首页喜欢先放一张大图,但如果 Banner 只是占位,就只会增加首屏高度,不能增加信息价值。

这个项目里的 Banner 负责三件事:

  • 定义三国历史内容的视觉氛围。
  • 给用户一个进入首页后的主标题和副标题。
  • 在深浅色主题里维持一致的首屏层级。

实现上没有引入复杂轮播,而是采用一张主视觉图叠加渐变蒙层:

@Builder
private heroBanner() {
  Stack() {
    Image($r('app.media.banner_home'))
      .width('100%')
      .height(132)
      .objectFit(ImageFit.Cover)
    Column()
      .width('100%')
      .height(132)
      .linearGradient({
        angle: 90,
        colors: [['#DD2B1A10', 0], ['#773D2414', 0.62], ['#223D2414', 1]]
      })
    Row() {
      Column() {
        Text('乱世英雄')
          .fontSize(AppFontSize.F28)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFF3DF')
        Text('烽火长江水,雄志三国史')
          .fontSize(AppFontSize.F14)
          .fontColor('#FFE9D1B8')
          .margin({ top: AppSpacing.S8 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .height(132)
    .padding({ left: AppSpacing.S20, right: AppSpacing.S20 })
  }
  .height(132)
  .width('calc(100% - 40vp)')
  .borderRadius(AppRadius.R16)
  .clip(true)
  .margin({ left: AppSpacing.S20, right: AppSpacing.S20, bottom: AppSpacing.S16 })
}

这里最值得复用的不是颜色值,而是这个思路:

  • 图层本身负责情绪。
  • 渐变负责保证文字可读性。
  • 标题负责建立首页主语义。

如果你一开始就用轮播图,后面很容易陷入“看起来更丰富了,但信息更乱了”的陷阱。内容型首页第一屏最重要的是稳定,不是花哨。

五、快捷入口要数据化,别把跳转关系写死在布局里

首页里第二个高频模块是快捷入口。这个位置的职责不是“放尽可能多的功能”,而是承接最常用的二次分流。

这个项目里只保留 4 个入口:

  • 热门人物志
  • 事件索引
  • 势力地图
  • 听书

原因很简单:首页底部已经有 Tab,快捷入口再多就会和主导航打架。真正应该放在这里的,是“从首页切去某个高频内容子流”的动作。

先看数据模型:

class QuickEntryData {
  label: string = '';
  image: ResourceStr = '';
  targetTab: number = 0;

  constructor(label: string, image: ResourceStr, targetTab: number) {
    this.label = label;
    this.image = image;
    this.targetTab = targetTab;
  }
}

private quickEntryItems(): QuickEntryData[] {
  return [
    new QuickEntryData('热门人物志', $r('app.media.module_people'), 1),
    new QuickEntryData('事件索引', $r('app.media.module_events'), 0),
    new QuickEntryData('势力地图', $r('app.media.module_map'), 4),
    new QuickEntryData('听书', $r('app.media.module_audio'), 2)
  ];
}

再看布局层:

@Builder
private quickEntries() {
  Row() {
    ForEach(this.quickEntryItems(), (item: QuickEntryData) => {
      this.quickEntry(item);
    }, (item: QuickEntryData) => item.label)
  }
  .width('calc(100% - 40vp)')
  .justifyContent(FlexAlign.SpaceBetween)
  .padding({ left: AppSpacing.S8, right: AppSpacing.S8, top: AppSpacing.S12, bottom: AppSpacing.S12 })
  .backgroundColor(this.palette().cardBg)
  .borderRadius(AppRadius.R16)
  .margin({ left: AppSpacing.S20, right: AppSpacing.S20, bottom: AppSpacing.S20 })
}

@Builder
private quickEntry(item: QuickEntryData) {
  Column() {
    Image(item.image)
      .width(40)
      .height(40)
      .objectFit(ImageFit.Cover)
      .borderRadius(AppRadius.R12)
    Text(item.label)
      .fontSize(AppFontSize.F10)
      .fontColor(this.palette().textSecondary)
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .margin({ top: AppSpacing.S8 })
  }
  .width('19%')
  .onClick(() => {
    this.activeTab = item.targetTab;
    if (item.label === '事件索引') {
      this.homeMode = 'events';
    }
    this.showEventDetail = false;
    this.showArticleDetail = false;
  })
}

这里我刻意让入口数量少、图标尺寸统一、文字只保留一行。这样做的好处是:

  • 首页不容易被快捷入口抢走全部注意力。
  • 新增入口时只需要扩展数据,不需要重写布局。
  • 点击后的“页面切换 + 状态复位”逻辑是可预测的。

六、人物横滑卡片要解决“扫一眼就想点进去”

历史内容类 App 里,人物往往比事件更容易形成点击动机。所以首页里的人物模块不应该做成普通竖向列表,而更适合横向浏览。

真实实现里,人物条带使用 Scroll + Row + ForEach

@Builder
private peopleStrip() {
  Scroll() {
    Row() {
      ForEach(this.visiblePeople(), (item: Person, index: number) => {
        this.personCard(item, index + 1);
      }, (item: Person) => item.id)
    }
    .padding({ left: AppSpacing.S20, right: AppSpacing.S20 })
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .edgeEffect(EdgeEffect.Spring)
  .margin({ bottom: AppSpacing.S20 })
}

卡片本身承载四类信息:

  • 头像图
  • 排名
  • 姓名
  • 身份标签与热度描述
@Builder
private personCard(item: Person, rank: number) {
  Column() {
    Stack({ alignContent: Alignment.TopStart }) {
      Image(item.image)
        .width('100%')
        .height(96)
        .objectFit(ImageFit.Cover)
        .borderRadius(AppRadius.R12)
      Text(rank.toString())
        .fontSize(AppFontSize.F12)
        .fontColor('#FFFFFFFF')
        .backgroundColor(this.palette().primary)
        .borderRadius(AppRadius.R8)
        .padding({ left: AppSpacing.S8, right: AppSpacing.S8, top: AppSpacing.S4, bottom: AppSpacing.S4 })
        .margin({ left: AppSpacing.S8, top: AppSpacing.S8 })
    }

    Text(item.name)
      .fontSize(AppFontSize.F14)
      .fontWeight(FontWeight.Bold)
      .fontColor(this.palette().textPrimary)
      .margin({ top: AppSpacing.S8 })
    Text(item.title)
      .fontSize(AppFontSize.F12)
      .fontColor(this.palette().textSecondary)
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .margin({ top: AppSpacing.S4 })
    Text(item.heat)
      .fontSize(AppFontSize.F10)
      .fontColor(this.palette().textTertiary)
      .margin({ top: AppSpacing.S4 })
  }
  .alignItems(HorizontalAlign.Start)
  .width(132)
  .padding(AppSpacing.S8)
  .backgroundColor(this.palette().cardBg)
  .borderRadius(AppRadius.R16)
  .margin({ right: AppSpacing.S12 })
  .onClick(() => {
    this.selectedPersonId = item.id;
    this.activeTab = 1;
  })
}

这里的经验很实际:

  • 排名角标可以强化“首页推荐感”。
  • 横滑卡片宽度不能太大,否则一屏容不下 2 张以上,浏览效率会变差。
  • 人物信息只保留一层副标题,避免卡片高度继续膨胀。

如果你的人物卡片放了太多文字,最终用户既不会认真读,也会把横向浏览做得很沉重。

七、事件索引不是单列表,而是“分类 + 列表”的组合

首页里最容易被做乱的模块,其实是事件索引。

因为它同时承载:

  • 分类浏览
  • 搜索命中
  • 列表跳转
  • 与人物内容的交叉关系

这类模块如果只用一个长列表,用户会觉得信息很多,但找不到重点。所以我先把事件分类做成一排轻量 Chip,再把真实列表放在下方。

分类部分:

private eventCategories(): string[] {
  return ['全部', '战役', '政治', '外交', '人物相关'];
}

@Builder
private eventChips() {
  Row() {
    ForEach(this.eventCategories(), (item: string) => {
      EventCategoryChip({ text: item, isDark: this.effectiveIsDark(), selectedCategory: $selectedCategory });
    }, (item: string) => item)
  }
  .width('100%')
  .padding({ left: AppSpacing.S20, right: AppSpacing.S20 })
  .margin({ bottom: AppSpacing.S12 })
}

筛选逻辑部分:

private visibleEvents(): HistoryEvent[] {
  return this.events().filter((item: HistoryEvent) => {
    const categoryMatched: boolean = this.selectedCategory === '全部' || item.category === this.selectedCategory;
    const keywordMatched: boolean = this.keyword().length === 0 || item.title.includes(this.keyword()) ||
      item.summary.includes(this.keyword()) || item.relatedPeople.join('').includes(this.keyword());
    return categoryMatched && keywordMatched;
  });
}

这个实现看起来很朴素,但它解决了两个首页常见问题:

  • 事件分类不再是装饰按钮,而是真实参与筛选。
  • 搜索和分类可以叠加,而不是互相覆盖。

也就是说,用户既可以先点“战役”,再搜“赤壁”,也可以先搜“诸葛亮”,再看有哪些相关事件。对知识类 App 来说,这种组合筛选是很有价值的。

八、搜索框必须接到真实反馈,否则会拉低整页可信度

很多文章会教你写一个 TextInput,但首页搜索真正影响体验的是“输入后有没有反馈”。

在这个项目里,搜索框不是单纯收集字符串,而是有三层反馈:

  • 输入时立即更新 searchKeyword
  • 提交后展示结果摘要
  • 用户可以随时清空,恢复首页原始分发流
@Builder
private searchBox() {
  Row() {
    Text('⌕')
      .fontSize(AppFontSize.F18)
      .fontColor(this.palette().textTertiary)
    TextInput({ text: this.searchKeyword, placeholder: '搜索人物、事件、势力' })
      .layoutWeight(1)
      .height(40)
      .fontSize(AppFontSize.F14)
      .fontColor(this.palette().textPrimary)
      .placeholderColor(this.palette().textTertiary)
      .backgroundColor('#00000000')
      .padding({ left: AppSpacing.S8, right: 0 })
      .onChange((value: string) => {
        this.searchKeyword = value;
        this.searchSubmitted = false;
      })
      .onSubmit(() => {
        this.commitSearch();
      })
  }
  .height(44)
  .width('calc(100% - 40vp)')
  .padding({ left: AppSpacing.S16, right: AppSpacing.S16 })
  .backgroundColor(this.palette().cardBg)
  .borderRadius(AppRadius.R20)
  .margin({ left: AppSpacing.S20, right: AppSpacing.S20, bottom: AppSpacing.S16 })
}

@Builder
private searchFeedback() {
  if (this.searchSubmitted || this.keyword().length > 0) {
    Row() {
      Text(this.searchResultSummary())
        .fontSize(AppFontSize.F12)
        .fontColor(this.palette().textSecondary)
        .layoutWeight(1)
      Text('清除')
        .fontSize(AppFontSize.F12)
        .fontColor(this.palette().primary)
        .onClick(() => {
          this.clearSearch();
        })
    }
    .width('calc(100% - 40vp)')
    .padding({ left: AppSpacing.S4, right: AppSpacing.S4 })
    .margin({ left: AppSpacing.S20, right: AppSpacing.S20, bottom: AppSpacing.S12 })
  }
}

这一步对质量分也很关键,因为真实项目文章一旦能把“输入状态、提交状态、空结果状态”讲清楚,平台更容易识别你不是只在讲概念。

九、主题 Token 决定首页能不能在深色模式下站得住

首页通常是最容易在深色模式下翻车的页面。原因是模块多、层级多、图片多,一旦颜色值硬编码,最后就会出现:

  • 某些文字在深色背景上发灰发脏。
  • 卡片背景和页面背景层级混在一起。
  • Banner 蒙层在浅色好看,深色里却发闷。

这个项目里没有在首页各处直接写颜色,而是统一通过 AppTheme.palette() 取色:

export class AppTheme {
  static readonly LIGHT: AppColors = new AppColors(
    '#8A4F25',
    '#FFF3E7D6',
    '#B8742A',
    '#FFFFF1DC',
    '#FFF8F2E8',
    '#FFFFFBF4',
    '#FFFFFFFF',
    '#FF251A12',
    '#FF6E5A49',
    '#FF9A8777',
    '#FFFFFFFF',
    '#FFE8D7C4',
    '#3329170B',
    '#CC20140C',
    '#0020140C'
  );

  static readonly DARK: AppColors = new AppColors(
    '#D7A15A',
    '#332B2114',
    '#F0BD72',
    '#2AE6B467',
    '#FF0E1110',
    '#FF171A18',
    '#FF20241F',
    '#FFF6EBDD',
    '#FFC8B9A7',
    '#FF8D8072',
    '#FF171A18',
    '#FF2C302C',
    '#66000000',
    '#DD050607',
    '#00050607'
  );

  static palette(isDark: boolean): AppColors {
    return isDark ? AppTheme.DARK : AppTheme.LIGHT;
  }
}

然后首页里所有关键元素都走主题色:

  • 页面背景用 pageBg
  • 卡片背景用 cardBg
  • 主标题用 textPrimary
  • 次级说明用 textSecondary
  • 操作强调色用 primary

这样改动主题方案时,首页不会变成“几十个 Builder 里到处找颜色值”的维护灾难。

十、手机和平板不是单纯拉伸,而是布局关系变化

如果首页只支持手机,很多问题不会暴露出来;一旦支持平板,信息密度马上会不合理。

这个项目的入口层就区分了手机和平板框架:

平板首页截图

private phoneFrame() {
  Column() {
    this.mainContent();
    this.bottomTabs();
  }
  .width('100%')
  .height('100%')
}

@Builder
private tabletFrame() {
  Row() {
    this.tabletSideNav();
    Column() {
      this.mainContent();
    }
    .layoutWeight(1)
    .height('100%')
    .backgroundColor(this.palette().pageBg)
  }
  .width('100%')
  .height('100%')
}

这个思路说明一件事:平板适配不是把手机页面等比放大,而是重新安排导航与内容的关系。

比如在统计信息区域,工程里就用 Grid 根据设备形态改变列数:

private statsGrid() {
  Grid() {
    this.statItem('收藏', this.favorites().length.toString());
    this.statItem('笔记', this.notes().length.toString());
    this.statItem('听书', this.totalListenedMinutes() + '分');
    this.statItem('势力', this.factions().length.toString());
  }
  .columnsTemplate(this.isTabletLayout() ? '1fr 1fr 1fr 1fr' : '1fr 1fr')
  .columnsGap(AppSpacing.S12)
  .rowsGap(AppSpacing.S12)
  .width('calc(100% - 40vp)')
  .margin({ left: AppSpacing.S20, right: AppSpacing.S20, bottom: AppSpacing.S16 })
}

虽然这段代码不在首页模块里,但它代表了同一个设计原则:同一组信息在不同设备上,应该调整布局密度,而不是简单缩放。

十一、工程落地时我重点避免了这 4 类问题

首页做完以后,真正拉开质量差距的不是“看起来像不像”,而是下面这些实现细节。

11.1 避免在 build() 里临时组装数据

快捷入口、事件分类、可见人物、可见事件都提前收敛成函数。这样每个 Builder 只接收可展示的数据,不在布局层现拼逻辑。

11.2 保证 ForEach key 稳定

人物列表用 item.id,事件列表用 item.id,快捷入口用 item.label。一旦 key 不稳定,滚动位置和局部刷新都可能出问题。

11.3 点击动作统一做状态复位

例如快捷入口和 section action 都会把 showEventDetailshowArticleDetailsearchKeyword 等状态收回来,避免用户从一个分支跳到另一个分支后页面还残留旧状态。

11.4 搜索、筛选、详情流共用一套首页容器

这能明显减少页面跳转层级,也让“返回首页”的语义非常稳定。用户不会因为点了一个筛选条件就被带到完全陌生的页面结构里。

十二、调试与验收,我会怎么检查这个首页

如果文章只写到“代码贴完”,质量还是不够。我在实际工程里会按下面这份清单验收首页。

12.1 交互验收清单

检查项 通过标准
Banner 文字可读,首屏能清楚说明首页主题
搜索框 输入、提交、清除三种状态都可回放
快捷入口 每个入口都能稳定跳到对应内容流
人物横滑 卡片滑动顺畅,点击后进入人物页
事件筛选 切换分类后结果稳定,不丢选中态
搜索结果 人物/事件/专题能分别展示命中结果
返回路径 从事件流、专题流返回首页后状态一致
深浅色 主标题、卡片、按钮、Chip 都有足够对比度
平板布局 导航与内容关系清晰,不只是拉伸手机页

12.2 调试时我会重点看什么

  • 搜索关键词变化时,visiblePeople()visibleEvents()visibleArticles() 是否同步更新。
  • homeMode 切换后,是否还有旧的搜索结果残留。
  • selectedCategory 重置为“全部”时,事件列表是否立即恢复。
  • 快捷入口点击后,是否会意外保留详情页状态。
  • 深色模式切换时,Banner 文字和卡片背景是否仍然可读。

如果你在 DevEco Studio 里联调,至少要覆盖手机和 tablet 两种预览/真机场景,首页最容易在宽屏上暴露结构问题。

十三、小结:首页的核心不是卡片,而是内容路径设计

回过头看,这个首页真正做的事情并不是“摆几个漂亮卡片”,而是把三国人物、历史事件、专题推荐和听书入口组织成一条可以自然浏览、也可以快速跳转的路径。

这篇文章里最值得带走的不是某一段 ArkUI 语法,而是这三个落地原则:

  • 先定首页职责,再定模块顺序。
  • 先收口状态切换,再写 Builder。
  • 先保证搜索、筛选、跳转都成立,再谈视觉丰富度。

对内容型 App 来说,首页质量高不高,直接决定用户会不会继续往下看。下一篇我会进入人物详情页,继续拆解传记、时间线、关系人物和听书入口如何组织成一页稳定的信息结构。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐