HarmonyOS宠物邻里实战第1篇:工程结构、首页看板与一多主导航
HarmonyOS宠物邻里实战第1篇:工程结构、首页看板与一多主导航
摘要
本文基于 2026-07-01 本机源码,拆解一个宠物邻里 HarmonyOS 应用的前端主框架。项目不是单页 Demo,而是围绕宠物档案、社区、寄养、提醒和个人中心组织的中等复杂度 ArkTS 应用。第一篇先不急着写具体业务表单,而是看工程分层、登录态切换、Navigation + Tabs 主壳、首页看板和一多适配。
文章会结合真实源码说明:
entry、library1、library2为什么要拆开;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。入口模块声明支持 phone、tablet、2in1 三类设备,说明本文的布局分析不是只面向手机竖屏。
本机验证环境如下:
| 项目 | 值 | 用途 |
|---|---|---|
| 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.ets、HomeTab.ets、PetTab.ets、ApiClient.ets、MockStore.ets |
library1 |
UI 组件和视觉令牌 | AppBar、SectionHeader、InfoRow、PrimaryButton、FontSize、Spacing、Radius |
library2 |
领域模型和路由能力 | Pet、FosterRequest、Notice、RouteName、RouterService |
这种拆法最大的好处是:页面层不需要自己定义一堆散落的类型,也不需要重复写基础组件。新增一个页面时,应该优先复用 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 分离主页面和详情页 |
| 路由集中管理 | RouteName 和 RouterService 统一跳转 |
| 全局错误提示 | syncErrorMessage 在主壳层展示 |
| 响应式规则 | Responsive.ets 收敛边距、内容宽度和断点 |
| 首页数据来源 | HomeTab 从 MockStore.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 照护建议。
更多推荐



所有评论(0)