HarmonyOS宠物邻里实战第1篇:工程结构、首页看板与一多主导航

摘要

本文基于 2026-07-01 本机源码,拆解一个宠物邻里 HarmonyOS 应用的前端主框架。项目不是单页 Demo,而是围绕宠物档案、社区、寄养、提醒和个人中心组织的中等复杂度 ArkTS 应用。第一篇先不急着写具体业务表单,而是看工程分层、登录态切换、Navigation + Tabs 主壳、首页看板和一多适配。

文章会结合真实源码说明:

  • entrylibrary1library2 为什么要拆开;
  • Index.ets 如何用 Navigation 管理详情页,用 Tabs 管理主页面;
  • 登录态为空时为什么直接进入 LoginPage
  • 首页 HomeTab 如何用 @StorageLink 监听宠物档案变化;
  • Responsive.ets 如何把手机、平板、PC 的内容宽度和边距收敛成几个方法;
  • 首页 Hero、统计卡、照护建议、宠物健康卡如何拆成 Builder;
  • 交付前如何用后端命令验证项目不是“纯静态 UI”。

前言

这组文章会围绕一个名为“宠物邻里”的 HarmonyOS 项目展开。它不是只有静态页面的界面稿,而是一个带宠物档案、寄养互助、社区提醒、地图定位、用户中心和后端联调基础的生活服务 App。

第一篇先处理主框架,是因为这类项目后期最容易失控的地方不是某一个按钮样式,而是页面边界一开始没有划清:登录态散落在多个页面里、详情页和 Tab 页面互相耦合、手机布局写死、大屏适配靠临时判断补丁、模型定义在页面里重复出现。等业务页面变多以后,这些问题会迅速放大。

本篇会重点解决三个问题:

  • 工程目录如何拆分,才能让页面、组件、模型和服务各司其职;
  • Navigation + Tabs 如何组合,才能同时支持主页面常驻和详情页进栈;
  • 一多适配如何从第一版就放进首页,而不是等平板和 2in1 设备适配时再重写。

环境与实测结果

工程当前的 SDK 配置使用 targetSdkVersion: 6.0.2(22)compatibleSdkVersion: 6.0.2(22),工程模型为 modelVersion: 6.0.2。入口模块声明支持 phonetablet2in1 三类设备,说明本文的布局分析不是只面向手机竖屏。

本机验证环境如下:

项目 用途
HarmonyOS 工程模型 modelVersion: 6.0.2 确认工程结构和构建配置
target SDK 6.0.2(22) 确认当前构建目标
compatible SDK 6.0.2(22) 确认兼容边界
Node.js v24.14.1 后端脚本和集成测试运行环境
npm 11.11.0 后端依赖和脚本执行环境
Express ~4.16.1 本项目后端服务框架
MongoDB Driver ^4.17.2 后端数据访问依赖

后端目录已经执行过基础验证:

cd D:\APP\chong_wu_guan_li\houduan\test
npm run check
npm run test:integration

集成测试输出:

Integration test passed: foster messages, notices, coordinates, status timeline and review.

这说明本文拆解的首页、宠物档案入口和寄养业务不是孤立页面,而是可以接入远端数据、消息、坐标和状态流转的应用框架。

对应源码路径:

D:\APP\chong_wu_guan_li\MyApp\entry\src\main\ets\pages\Index.ets
D:\APP\chong_wu_guan_li\MyApp\entry\src\main\ets\pages\home\HomeTab.ets
D:\APP\chong_wu_guan_li\MyApp\entry\src\main\ets\common\Responsive.ets
D:\APP\chong_wu_guan_li\MyApp\library2\src\main\ets\models\Models.ets

效果预览

项目内准备了宠物档案相关视觉稿,可以作为本文的效果预览:

宠物邻里首页与主导航效果预览

D:\APP\chong_wu_guan_li\redesign-pet2.jpeg

这张图对应宠物档案和首页照护中心的视觉方向。正文后面的代码会围绕这个页面骨架展开:主壳、首页看板、宠物卡片和一多适配。

一、先看工程结构:不要让页面承担所有职责

项目根目录里前端和后端分开:

chong_wu_guan_li
├── MyApp
│   ├── entry
│   ├── library1
│   ├── library2
│   ├── oh-package.json5
│   └── build-profile.json5
└── houduan
    └── test

MyApp 下不是只有一个 entry,还拆了两个库:

模块 职责 典型内容
entry App 入口、页面、业务服务、状态层 Index.etsHomeTab.etsPetTab.etsApiClient.etsMockStore.ets
library1 UI 组件和视觉令牌 AppBarSectionHeaderInfoRowPrimaryButtonFontSizeSpacingRadius
library2 领域模型和路由能力 PetFosterRequestNoticeRouteNameRouterService

这种拆法最大的好处是:页面层不需要自己定义一堆散落的类型,也不需要重复写基础组件。新增一个页面时,应该优先复用 library1 的组件和 library2 的模型,而不是在页面里临时拼对象。

二、模块配置:一开始就声明设备形态

前端入口模块位于:

MyApp/entry/src/main/module.json5

这里声明了设备类型:

{
  "deviceTypes": [
    "phone",
    "tablet",
    "2in1"
  ],
  "requestPermissions": [
    {
      "name": "ohos.permission.INTERNET"
    }
  ]
}

这两个配置会直接影响前端设计。

第一,phone/tablet/2in1 意味着页面不能只按手机竖屏写死。内容宽度、左右边距、网格列数、地图高度都要能跟随屏幕宽度变化。

第二,网络权限是前后端联调的基础。项目中 ApiClient.ets 会请求 Express 后端,如果漏掉 ohos.permission.INTERNET,页面逻辑写得再完整也会请求失败。

三、版本边界:写文章时要给读者可复现信息

当前项目配置里能确认的版本信息如下:

项目 来源
HarmonyOS 工程模型 modelVersion: 6.0.2 MyApp/oh-package.json5
target SDK 6.0.2(22) MyApp/build-profile.json5
compatible SDK 6.0.2(22) MyApp/build-profile.json5
测试依赖 @ohos/hypium: 1.0.25 MyApp/oh-package.json5
Mock 依赖 @ohos/hamock: 1.0.0 MyApp/oh-package.json5
后端 Express ~4.16.1 houduan/test/package.json
MongoDB Driver ^4.17.2 houduan/test/package.json

我没有把本地签名文件、证书路径和密码写进文章。build-profile.json5 中确实有签名材料配置,但那不是技术文章应该暴露的内容。写工程复盘时,版本边界要写,敏感配置要避开。

四、主入口 Index:登录态决定进入 Login 还是主壳

主入口文件:

MyApp/entry/src/main/ets/pages/Index.ets

它用 @StorageProp 读取当前用户:

@StorageProp(AppKeys.CURRENT_USER) @Watch('onAuthChanged') currentUser: string = '';
@StorageLink('syncErrorMessage') syncErrorMessage: string = '';
@State currentTabIndex: number = 0;
private pathStack: NavPathStack = new NavPathStack();

构建逻辑很直接:

build() {
  if (this.currentUser.length === 0) {
    LoginPage()
  } else {
    this.MainShell()
  }
}

这个判断很重要。它把“是否登录”的分支放在最外层,而不是让每个 Tab 自己判断登录态。未登录时只渲染登录页;登录后再进入 Navigation + Tabs 主框架。

当用户登出或账号状态清空时:

onAuthChanged(): void {
  if (this.currentUser.length === 0) {
    this.pathStack.clear();
    this.currentTabIndex = 0;
  }
}

这里有两个细节:

  • 清空 pathStack,防止退出登录后返回到某个详情页;
  • 重置 currentTabIndex,下一次登录回到首页。

这比只把 currentUser 置空更完整。

五、Navigation + Tabs:主页面常驻,详情页进栈

Index.ets 的主框架是:

Navigation(this.pathStack) {
  Tabs({ barPosition: BarPosition.End, index: this.currentTabIndex }) {
    TabContent() { HomeTab() }
      .tabBar(this.TabBarItem('首页', $r('app.media.icon_tab_pet'), $r('app.media.icon_tab_pet_active'), 0))
    TabContent() { PetTab() }
      .tabBar(this.TabBarItem('宠物', $r('app.media.icon_tab_pet'), $r('app.media.icon_tab_pet_active'), 1))
    TabContent() { ReminderTab() }
      .tabBar(this.TabBarItem('提醒', $r('app.media.icon_tab_notice'), $r('app.media.icon_tab_notice_active'), 2))
    TabContent() { MeTab() }
      .tabBar(this.TabBarItem('我的', $r('app.media.icon_tab_me'), $r('app.media.icon_tab_me_active'), 3))
  }
}
.navDestination(this.PageMap)
.mode(NavigationMode.Stack)
.onAppear(() => {
  RouterService.bind(this.pathStack);
})

主 Tab 常驻,详情页走 Navigation 栈。这样有几个好处:

场景 处理方式
首页、宠物、提醒、我的 Tab 切换,不进入详情栈
宠物详情、用户主页、设置页 RouterService.push() 进入 NavPathStack
返回 详情页调用 RouterService.pop()
退出登录 清空 pathStack

这比每个页面自己维护一个“当前子页面枚举”更适合多模块应用。Tab 是主壳,详情是栈,这个边界很清楚。

六、PageMap:让路由名集中落到页面组件

详情页映射集中在 PageMap

@Builder
PageMap(name: string) {
  if (name === RouteName.PetDetail) {
    PetDetailPage()
  } else if (name === RouteName.UserHome) {
    UserHomePage()
  } else if (name === RouteName.Settings) {
    SettingsPage()
  } else if (name === RouteName.AccountSecurity) {
    AccountSecurityPage()
  } else if (name === RouteName.AccountDeletion) {
    AccountDeletionPage()
  } else if (name === RouteName.PrivacyCenter) {
    PrivacyCenterPage()
  } else if (name === RouteName.ProfileEdit) {
    ProfileEditPage()
  } else if (name === RouteName.XiaoyiAgent) {
    XiaoyiAgentPage()
  }
}

实际项目里,路由名在 library2 中集中定义。页面不应该到处写 'pet/detail' 这类字符串。把路由名集中起来,后续改路径、加参数、做深链都更容易。

七、TabBarItem:选中态不是只改文字颜色

底部 Tab 的 Builder:

@Builder
TabBarItem(label: string, icon: ResourceStr, activeIcon: ResourceStr, tabIndex: number) {
  Column() {
    Stack({ alignContent: Alignment.Center }) {
      if (this.currentTabIndex === tabIndex) {
        Circle()
          .width(42)
          .height(42)
          .fill($r('app.color.brand_tint'))
      }
      Image(this.currentTabIndex === tabIndex ? activeIcon : icon)
        .width(this.currentTabIndex === tabIndex ? 27 : 25)
        .height(this.currentTabIndex === tabIndex ? 27 : 25)
        .objectFit(ImageFit.Contain)
    }
    Text(label)
      .fontSize(FontSize.xs)
      .margin({ top: 2 })
      .fontWeight(this.currentTabIndex === tabIndex ? FontWeight.Medium : FontWeight.Regular)
      .fontColor(this.currentTabIndex === tabIndex ? $r('app.color.brand_primary') : $r('app.color.text_muted'))
  }
}

选中态做了三层变化:

  • 背后加一个品牌色浅色圆;
  • icon 使用 active 资源并略微放大;
  • 文字加粗并改成主色。

这类细节不复杂,但能让 Tab 的状态更明确。移动端底部导航不应该只靠文字颜色区分状态。

八、全局同步错误:放在主壳层展示

Index.ets 还监听了:

@StorageLink('syncErrorMessage') syncErrorMessage: string = '';

当同步失败时,主壳顶部显示一条错误提示:

if (this.syncErrorMessage.length > 0) {
  Row() {
    Text(this.syncErrorMessage)
      .fontSize(FontSize.sm)
      .fontColor(Color.White)
      .layoutWeight(1)
      .maxLines(2)
    Text('关闭')
      .fontSize(FontSize.sm)
      .fontColor(Color.White)
      .margin({ left: Spacing.md })
  }
  .width('92%')
  .padding(Spacing.md)
  .backgroundColor('#E05252')
  .borderRadius(Radius.md)
  .margin({ top: 42 })
  .shadow({ radius: 10, color: '#30000000', offsetY: 4 })
  .onClick(() => { this.syncErrorMessage = ''; })
}

这个提示放在主壳层,而不是某一个 Tab 内。原因是同步失败可能来自点赞、宠物更新、寄养申请等任意模块,放在全局主壳层更合适。

九、Responsive 工具:把一多规则收敛起来

响应式工具文件:

MyApp/entry/src/main/ets/common/Responsive.ets

代码很短:

export class Responsive {
  static readonly tabletWidth: number = 600;
  static readonly pcWidth: number = 960;
  static readonly maxContentWidth: number = 1080;

  static isTablet(width: number): boolean {
    return width >= Responsive.tabletWidth;
  }

  static isPc(width: number): boolean {
    return width >= Responsive.pcWidth;
  }

  static pagePadding(width: number): number {
    if (Responsive.isPc(width)) {
      return 32;
    }
    if (Responsive.isTablet(width)) {
      return 24;
    }
    return 16;
  }

  static contentWidth(width: number): Length {
    if (Responsive.isPc(width)) {
      return Responsive.maxContentWidth;
    }
    return '100%';
  }
}

不要小看这个工具。它把一多适配中最常见的几个决策统一了:

屏宽 判定 页面边距 内容宽度
手机 < 600 16vp 100%
平板 >= 600 24vp 100%
PC/2in1 >= 960 32vp 最大 1080vp

这样页面里不用反复写 if width > 960。每个页面只需要维护自己的 pageWidth,然后调用 Responsive.pagePadding()Responsive.contentWidth()

十、HomeTab:首页不是静态宣传页,而是照护看板

首页文件:

MyApp/entry/src/main/ets/pages/home/HomeTab.ets

它的状态定义很克制:

@StorageLink('petsVersion') @Watch('onDataChanged') petsVersion: number = 0;
@State myPets: Pet[] = [];
@State pageWidth: number = 360;

petsVersion 是状态层发出的刷新信号。宠物档案新增、编辑或删除后,MockStore.bumpPetsVersion() 会触发首页重新读取宠物列表。

private refresh(): void {
  this.myPets = MockStore.petsOf(MockStore.meId);
}

首页不直接持有全部业务状态,只保存当前页面需要展示的宠物列表。

十一、健康信息完整度:从字段计算页面指标

首页会计算每只宠物缺少多少健康信息:

private missingHealthCount(pet: Pet): number {
  let count: number = 0;
  if (pet.vaccineRecord.trim().length === 0) count++;
  if (pet.dewormRecord.trim().length === 0) count++;
  if (pet.dietHabit.trim().length === 0) count++;
  if (pet.notes.trim().length === 0) count++;
  return count;
}

再聚合成总数:

private totalMissingHealthCount(): number {
  let count: number = 0;
  for (let i = 0; i < this.myPets.length; i++) {
    count += this.missingHealthCount(this.myPets[i]);
  }
  return count;
}

这就是首页看板的核心:不是堆几个静态数字,而是从真实宠物档案字段推导出“待完善项”。用户进入首页时能立即知道哪些信息还没补。

十二、Hero:用真实业务数字增强首屏

首页 Hero:

@Builder Hero() {
  Stack({ alignContent: Alignment.BottomStart }) {
    Image($r('app.media.illu_pet_home'))
      .width('100%')
      .height(Responsive.isPc(this.pageWidth) ? 260 : 202)
      .objectFit(ImageFit.Cover)
    Column() {
      Text('宠物照护中心')
        .fontSize(26)
        .fontWeight(FontWeight.Bold)
      Text('集中管理宠物档案、健康记录和日常提醒')
        .fontSize(FontSize.sm)
        .margin({ top: 6 })
      Row() {
        Text(`${this.myPets.length} 只宠物`)
        Text(`${this.totalMissingHealthCount()} 项待完善`)
      }
    }
  }
}

这里的重点是:Hero 不是营销页大图,而是业务首屏。它展示当前宠物数量和待完善项,用户能立刻进入照护上下文。

十三、SummaryCard:复用小统计卡

首页统计卡用 Builder 抽出:

@Builder SummaryCard(value: string, label: string, desc: string) {
  Column() {
    Text(value)
      .fontSize(24)
      .fontWeight(FontWeight.Bold)
    Text(label)
      .fontSize(FontSize.md)
      .fontWeight(FontWeight.Medium)
      .margin({ top: 4 })
    Text(desc)
      .fontSize(FontSize.xs)
      .maxLines(2)
  }
  .layoutWeight(1)
  .padding(Spacing.lg)
  .backgroundColor($r('app.color.surface_card'))
  .borderRadius(Radius.lg)
}

调用时:

Row() {
  this.SummaryCard(`${this.myPets.length}`, '宠物档案', '基础信息云端同步')
  Blank().width(Spacing.md)
  this.SummaryCard(`${this.totalMissingHealthCount()}`, '待完善', '健康照护信息')
}

这类 Builder 拆分的价值是:页面结构读起来像内容编排,而不是一大坨重复样式。

十四、PetHealthCard:空头像和图片头像都要兼容

首页宠物健康卡需要兼容两种头像:

if (typeof pet.avatar === 'string' && (pet.avatar as string).length === 0) {
  Column() {
    Text(pet.name.slice(0, 1))
  }
  .width(54)
  .height(54)
  .backgroundColor($r('app.color.surface_warm'))
  .borderRadius(27)
} else {
  Image(pet.avatar)
    .width(54)
    .height(54)
    .borderRadius(27)
    .objectFit(ImageFit.Cover)
}

移动端项目里,图片字段经常为空、失效或还没上传。组件必须有兜底视觉,否则列表里会出现空白块。

卡片点击后进入宠物详情:

const p: RouteIdParam = { id: pet.id };
RouterService.push(RouteName.PetDetail, p);

这里呼应了前面的 Navigation + PageMap:首页不直接渲染详情,而是通过路由进栈。

十五、照护建议和小艺助手入口

首页还有两个内容块:

  • CareTips():今日照护建议;
  • XiaoyiAgentCard():小艺照护助手入口。

小艺入口点击后:

.onClick(() => { RouterService.push(RouteName.XiaoyiAgent); })

这类入口不应该写死在 AppBar 里。放在首页内容流中,用户能在看宠物健康状态时自然进入智能建议页面。

十六、onAreaChange:页面自己感知宽度变化

首页底部监听区域变化:

.onAreaChange((_oldValue: Area, newValue: Area) => {
  this.updateWidth(newValue.width);
})

updateWidth() 会把 Length 转成 number:

private updateWidth(width: Length): void {
  this.pageWidth = Responsive.widthOf(width, this.pageWidth);
}

后续 Hero 高度、内容宽度、页面边距都依赖 pageWidth。这种方式比在每个组件里直接读屏幕宽度更可控。

十七、首页页面结构总览

HomeTab.build() 的结构可以概括为:

Column
├── AppBar
└── Scroll
    └── Column
        ├── Hero
        └── Content Column
            ├── Summary Cards
            ├── CareTips
            ├── XiaoyiAgentCard
            ├── Pet Section Title
            ├── Empty State
            └── PetHealthCard List

这个结构没有把所有 UI 都塞进 build()。真正的 build() 负责组织页面,具体块由 Builder 负责。

十八、为什么第一篇先写前端主框架

这个项目有 Express + MongoDB 后端,但第一篇我更建议讲前端主框架。原因很现实:

  • 主框架决定后续所有页面的接入方式;
  • 登录态、Tab、Navigation、全局错误提示都是 App 骨架;
  • 后端接口可以在后续文章作为联调部分出现,不应该抢走第一篇主题。

技术文章也要有阅读路径。第一篇讲主框架,第二篇讲宠物档案页面,第三篇讲寄养地图或状态同步,会比一篇文章里同时讲前端、后端、数据库更容易被识别为系列实战。

十九、实测验证:后端不是摆设

虽然本文主讲前端,但这个项目不是纯静态 UI。后端目录执行过:

cd D:\APP\chong_wu_guan_li\houduan\test
npm run check
npm run test:integration

实测输出:

Integration test passed: foster messages, notices, coordinates, status timeline and review.

这说明项目背后有可验证的寄养业务链路。前端首页、宠物档案和寄养页面不是孤立页面,而是可以接入远端数据的应用框架。

二十、交付前工程检查清单

把主框架交给下一位同学继续开发前,我会按下面这张表检查:

检查项 本文处理
主入口边界 Index.ets 负责登录态、主壳和路由栈
页面主壳 Navigation + Tabs 分离主页面和详情页
路由集中管理 RouteNameRouterService 统一跳转
全局错误提示 syncErrorMessage 在主壳层展示
响应式规则 Responsive.ets 收敛边距、内容宽度和断点
首页数据来源 HomeTabMockStore.petsOf() 读取当前用户宠物
状态刷新 @StorageLink('petsVersion') 响应宠物档案变化
设备边界 module.json5 声明 phone/tablet/2in1
网络权限 module.json5 声明 ohos.permission.INTERNET
基础验证 后端 npm run check 和集成测试已通过

总结

这一篇拆解了宠物邻里 HarmonyOS 应用的前端主框架:

  • entry/library1/library2 分层让页面、组件、模型职责清楚;
  • Index.ets 用登录态决定进入 LoginPage 还是主壳;
  • Navigation + Tabs 把主页面和详情页分开;
  • PageMap 集中管理详情页路由映射;
  • syncErrorMessage 在主壳层展示全局同步错误;
  • Responsive.ets 收敛一多适配规则;
  • HomeTab@StorageLink('petsVersion') 响应宠物档案变化;
  • 首页 Hero、统计卡、照护建议和健康卡都来自真实业务状态。

后续第二篇可以继续进入宠物档案模块,重点看 PetTab 的响应式卡片网格、AddPetDialog 的动态表单、PetDetailPage 的详情展示和 AI 照护建议。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐