HarmonyOS 分层架构 — ParkService 游园模块列表与详情实战(RDB 收藏)
系列第 6 篇:本文深入解析广州旅游住宿 App 的 ParkService 游园模块,展示如何基于 HarmonyOS 6.1 状态管理 V2 实现景点列表筛选、详情页展示、RDB 收藏与浏览历史记录功能。所有代码均来自项目真实源码,可直接参考。
图标资源说明:HarmonyOS 6.1 中
sys.media.ohos_ic_public_*系统图标资源已大量废弃。本项目实际采用 Unicode 文本字符替代(如♥/♡替代收藏图标),图片资源采用渐变色 + 首字文字占位符。下文代码示例中部分保留原始写法以便理解 API 用法,实际运行请以项目源码为准。
效果

一、ParkService 模块职责
ParkService(景区服务)是应用的第二大 Tab 页面,也是功能最丰富的业务模块之一。它承担着景点浏览全流程的职责:
| 功能 | 说明 |
|---|---|
| 景点列表展示 | 以图文卡片列表展示广州五大知名景点,包含封面、名称、描述、评分、门票等信息 |
| 分类筛选 | 顶部筛选栏支持按"全部/自然风光/历史人文/公园广场/现代地标"五个分类过滤 |
| 景点详情 | 点击列表卡片进入详情页,展示完整介绍、地址、门票、开放时间、游览时长等 |
| 收藏功能 | 详情页可收藏/取消收藏景点,数据持久化到 RDB 关系型数据库 |
| 浏览历史 | 进入详情页自动记录浏览历史,支持在个人中心查看 |
ParkService 模块内部包含三个页面级组件和一个 ViewModel:
ParkService/
├── viewmodel/
│ └── ParkListViewModel.ets # 数据管理(景点数据 + 筛选逻辑)
└── views/
├── ParkListPage.ets # 列表页(筛选栏 + 列表 + 卡片)
├── ParkDetailPage.ets # 详情页(完整信息 + 收藏 + 浏览记录)
└── SpotFilterBar.ets # 筛选栏组件(分类标签横向滚动)
二、模块配置与导出
2.1 oh-package.json5
{
"name": "ParkService",
"version": "1.0.0",
"description": "景区服务模块",
"main": "Index.ets",
"author": "",
"license": "Apache-2.0",
"dependencies": {
"common": "file:../../common"
}
}
与 Home 模块一样,ParkService 仅依赖 common 公共层。RDB 数据库操作、路由服务、通用组件等能力全部由 common 层提供。
2.2 Index.ets — 双页面导出
export { ParkListPage } from './src/main/ets/views/ParkListPage';
export { ParkDetailPage } from './src/main/ets/views/ParkDetailPage';
ParkService 对外暴露两个页面组件:ParkListPage(列表页)和 ParkDetailPage(详情页)。phone 壳工程将 ParkListPage 嵌入 Tab 页面,ParkDetailPage 则通过路由注册作为二级页面。
三、ParkListViewModel 数据管理
ParkListViewModel 是景点列表的数据中枢,同样采用 @ObservedV2 + @Trace 构建响应式数据模型:
import { ScenicSpot, AppConstants } from 'common';
@ObservedV2
export class ParkListViewModel {
@Trace allSpots: ScenicSpot[] = [];
@Trace filteredSpots: ScenicSpot[] = [];
@Trace currentFilter: string = '全部';
@Trace isLoading: boolean = false;
get filterOptions(): string[] {
return ['全部', AppConstants.CATEGORY_NATURE, AppConstants.CATEGORY_CULTURE,
AppConstants.CATEGORY_PARK, AppConstants.CATEGORY_MODERN];
}
loadSpots(): void {
this.isLoading = true;
this.allSpots = this.buildSpotData();
this.filterSpots(this.currentFilter);
this.isLoading = false;
}
filterSpots(category: string): void {
this.currentFilter = category;
if (category === '全部') {
this.filteredSpots = this.allSpots;
} else {
this.filteredSpots = this.allSpots.filter(s => s.category === category);
}
}
private buildSpotData(): ScenicSpot[] {
// ... 构建 5 条景点数据
}
}
3.1 双列表设计:allSpots + filteredSpots
ViewModel 维护两个列表:
- allSpots:完整的全量景点数据(数据源,不可被筛选修改)
- filteredSpots:当前筛选条件下展示的数据(UI 直接绑定此列表)
这种设计的好处是:筛选操作不会丢失原始数据,切换回"全部"时无需重新加载。
3.2 filterSpots 筛选方法
filterSpots(category: string): void {
this.currentFilter = category;
if (category === '全部') {
this.filteredSpots = this.allSpots;
} else {
this.filteredSpots = this.allSpots.filter(s => s.category === category);
}
}
当用户点击筛选栏的某个分类标签时,filterSpots 被调用:
- 更新
currentFilter(@Trace属性变化 → SpotFilterBar 高亮更新) - 更新
filteredSpots(@Trace属性变化 → ParkListPage 列表刷新)
两个属性的变更会各自独立触发对应组件的重新渲染,互不干扰。
3.3 filterOptions 筛选选项
filterOptions 是一个普通 getter,返回五个分类选项。它使用 AppConstants 中预定义的常量而非硬编码字符串:
// AppConstants 中的分类常量
static readonly CATEGORY_NATURE: string = '自然风光';
static readonly CATEGORY_CULTURE: string = '历史人文';
static readonly CATEGORY_PARK: string = '公园广场';
static readonly CATEGORY_MODERN: string = '现代地标';
3.4 景点数据构建
buildSpotData 方法构建了 5 条广州知名景点的完整数据,每条包含名称、描述、详细介绍、分类、地址、经纬度、门票、开放时间、评分和游览时长:
private buildSpotData(): ScenicSpot[] {
const spots: ScenicSpot[] = [];
const s1 = new ScenicSpot();
s1.id = 's1'; s1.name = '白云山风景名胜区';
s1.description = '广州最高峰,南粤名山之一,四季花开云海缭绕';
s1.detail = '白云山是南粤名山之一,坐落在广州市北部,主峰摩星岭海拔382米...';
s1.category = '自然风光'; s1.address = '广州市白云区广园中路801号';
s1.latitude = 23.1895; s1.longitude = 113.2644;
s1.ticketPrice = '5元'; s1.openTime = '全天开放';
s1.rating = 4.8; s1.visitDuration = '3-5小时';
spots.push(s1);
// s2: 沙面岛(历史人文, 免费, 4.6分)
// s3: 陈家祠(历史人文, 10元, 4.7分)
// s4: 越秀公园(公园广场, 免费, 4.5分)
// s5: 广州塔(现代地标, 150元, 4.6分)
return spots;
}
四、ParkListPage 列表页
ParkListPage 是游园模块的主页面,由筛选栏和景点列表两部分组成:
import {
ScenicSpot, ThemeConstants, RouterService,
RouteConstants, RdbService, AppConstants
} from 'common';
import { ParkListViewModel } from '../viewmodel/ParkListViewModel';
import { SpotFilterBar } from './SpotFilterBar';
@ComponentV2
export struct ParkListPage {
@Local viewModel: ParkListViewModel = new ParkListViewModel();
aboutToAppear(): void {
this.viewModel.loadSpots();
}
build() {
Column() {
SpotFilterBar({
filters: this.viewModel.filterOptions,
currentFilter: this.viewModel.currentFilter,
onFilterChange: (filter: string) => {
this.viewModel.filterSpots(filter);
}
})
List({ space: 12 }) {
ForEach(this.viewModel.filteredSpots, (spot: ScenicSpot) => {
ListItem() {
Row() {
Column()
.width(120)
.height(90)
.backgroundColor(ThemeConstants.PRIMARY_LIGHT)
.borderRadius({
topLeft: ThemeConstants.CARD_RADIUS,
bottomLeft: ThemeConstants.CARD_RADIUS
})
Column() {
Text(spot.name)
.fontSize(ThemeConstants.FONT_SIZE_SUBTITLE)
.fontWeight(ThemeConstants.FONT_WEIGHT_MEDIUM)
.fontColor(ThemeConstants.TEXT_PRIMARY)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(spot.description)
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.TEXT_SECONDARY)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
Row() {
Text(spot.rating.toFixed(1) + '分')
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.WARNING)
Text(' | ' + spot.category)
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(ThemeConstants.TEXT_HINT)
Blank()
Text(spot.ticketPrice.length > 0 ? spot.ticketPrice : '免费')
.fontSize(ThemeConstants.FONT_SIZE_CAPTION)
.fontColor(spot.hasTicket ? ThemeConstants.ACCENT : ThemeConstants.SUCCESS)
}
.width('100%')
.margin({ top: 8 })
}
.layoutWeight(1)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
}
.width('100%')
.backgroundColor(ThemeConstants.CARD_BG)
.borderRadius(ThemeConstants.CARD_RADIUS)
.shadow({ radius: 6, color: '#14000000', offsetY: 2 })
.onClick(() => {
RouterService.push(RouteConstants.PARK_DETAIL_PAGE, {
'spotId': spot.id
});
})
}
})
}
.width('100%')
.layoutWeight(1)
.padding({
left: ThemeConstants.CARD_PADDING,
right: ThemeConstants.CARD_PADDING,
top: 12,
bottom: 80
})
}
.width('100%')
.height('100%')
.backgroundColor(ThemeConstants.PAGE_BG)
}
}
4.1 整体布局
页面采用纵向 Column 布局:
- SpotFilterBar:顶部筛选栏,固定高度
- List:景点列表,
layoutWeight(1)占据剩余空间
4.2 列表卡片设计
每张景点卡片采用左图右文的 Row 布局:
- 左侧:120×90 的封面占位区(左侧圆角)
- 右侧:三行信息——景点名(单行截断)、描述(两行截断)、底部信息行
底部信息行巧妙地使用了 Blank() 组件实现两端对齐:
Row() {
Text(spot.rating.toFixed(1) + '分') // 左:评分
Text(' | ' + spot.category) // 左:分类
Blank() // 弹性空间
Text(spot.ticketPrice) // 右:门票价格
}
4.3 hasTicket 计算属性在 UI 中的应用
门票价格的颜色使用了 ScenicSpot 模型的 @Computed 计算属性 hasTicket:
Text(spot.ticketPrice.length > 0 ? spot.ticketPrice : '免费')
.fontColor(spot.hasTicket ? ThemeConstants.ACCENT : ThemeConstants.SUCCESS)
- 需要购票(如广州塔 150 元)→ 使用
ACCENT(橙色#FF6D00)突出价格 - 免费景点(如沙面岛、越秀公园)→ 使用
SUCCESS(绿色#34A853)标记免费
hasTicket 的定义来自 ScenicSpot 模型:
@Computed
get hasTicket(): boolean {
return this.ticketPrice.length > 0 && this.ticketPrice !== '免费';
}
4.4 路由跳转详情
点击卡片时携带 spotId 跳转到详情页:
.onClick(() => {
RouterService.push(RouteConstants.PARK_DETAIL_PAGE, {
'spotId': spot.id
});
})
五、SpotFilterBar 筛选栏
SpotFilterBar 是独立的筛选栏组件,支持横向滚动和动态高亮:
import { ThemeConstants } from 'common';
@ComponentV2
export struct SpotFilterBar {
@Param filters: string[] = [];
@Param currentFilter: string = '全部';
@Event onFilterChange: (filter: string) => void = () => {};
build() {
Scroll() {
Row({ space: 8 }) {
ForEach(this.filters, (filter: string) => {
Text(filter)
.fontSize(ThemeConstants.FONT_SIZE_BODY)
.fontColor(this.currentFilter === filter
? ThemeConstants.TEXT_WHITE
: ThemeConstants.TEXT_SECONDARY)
.backgroundColor(this.currentFilter === filter
? ThemeConstants.PRIMARY
: ThemeConstants.CARD_BG)
.borderRadius(20)
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.shadow(this.currentFilter === filter ? {
radius: 4, color: '#301A73E8', offsetY: 2
} : { radius: 0, color: '#00000000', offsetY: 0 })
.onClick(() => {
this.onFilterChange(filter);
})
})
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ left: ThemeConstants.CARD_PADDING, right: ThemeConstants.CARD_PADDING })
}
}
5.1 三参数设计
SpotFilterBar 的接口设计是 V2 组件通信的典范:
| 装饰器 | 参数 | 类型 | 说明 |
|---|---|---|---|
@Param | filters | string[] | 父→子:所有可选项列表 |
@Param | currentFilter | string | 父→子:当前选中的筛选项 |
@Event | onFilterChange | (filter: string) => void | 子→父:用户点击某个筛选项时的回调 |
这种 @Param + @Event 的组合实现了双向通信——数据通过 @Param 向下传递,事件通过 @Event 向上传递。
5.2 动态样式
选中状态和未选中状态通过三元表达式实现动态样式:
.fontColor(this.currentFilter === filter
? ThemeConstants.TEXT_WHITE // 选中:白色文字
: ThemeConstants.TEXT_SECONDARY) // 未选中:次要文字色
.backgroundColor(this.currentFilter === filter
? ThemeConstants.PRIMARY // 选中:品牌蓝底
: ThemeConstants.CARD_BG) // 未选中:白色底
选中项还会添加蓝色阴影(#301A73E8),增强视觉层次感。borderRadius(20) 使每个标签呈现胶囊形状。
5.3 事件驱动筛选流程
完整的筛选流程如下:
用户点击标签 → SpotFilterBar.onFilterChange(filter)
→ ParkListPage 的回调:viewModel.filterSpots(filter)
→ currentFilter 更新 → SpotFilterBar 高亮刷新
→ filteredSpots 更新 → List 列表刷新
六、ParkDetailPage 详情页
ParkDetailPage 是景点详情页面,展示完整的景点信息并提供收藏功能:
import {
ScenicSpot, ThemeConstants, RouterService,
RouteConstants, RdbService, AppConstants
} from 'common';
import { ParkListViewModel } from '../viewmodel/ParkListViewModel';
@ComponentV2
export struct ParkDetailPage {
@Param spotId: string = '';
@Local spot: ScenicSpot = new ScenicSpot();
@Local isFavorite: boolean = false;
aboutToAppear(): void {
const vm = new ParkListViewModel();
vm.loadSpots();
const found = vm.allSpots.find(s => s.id === this.spotId);
if (found) {
this.spot = found;
}
this.checkFavorite();
RdbService.getInstance().addBrowseHistory(
this.spot.id, AppConstants.ITEM_TYPE_SCENIC,
this.spot.name, this.spot.coverImage
);
}
private async checkFavorite(): Promise<void> {
this.isFavorite = await RdbService.getInstance().isFavorite(this.spot.id);
}
private async toggleFavorite(): Promise<void> {
if (this.isFavorite) {
await RdbService.getInstance().removeFavorite(this.spot.id);
this.isFavorite = false;
} else {
await RdbService.getInstance().addFavorite(
this.spot.id, AppConstants.ITEM_TYPE_SCENIC,
this.spot.name, JSON.stringify(this.spot), this.spot.coverImage
);
this.isFavorite = true;
}
}
// ... build 方法见下文
}
6.1 @Param 接收路由参数
@Param spotId: string = '';
spotId 由路由跳转时传入。在 phone 壳工程的 RouterConfig 中:
@Builder
static buildParkDetail(param: object) {
ParkDetailPage({ spotId: (param as Record<string, string>)['spotId'] ?? '' })
}
6.2 aboutToAppear 三件事
详情页的 aboutToAppear 生命周期中依次完成三项任务:
- 加载景点数据:创建临时 ViewModel,通过
spotId查找对应景点 - 检查收藏状态:异步查询 RDB 数据库判断当前景点是否已收藏
- 记录浏览历史:向 RDB 数据库写入一条浏览记录
aboutToAppear(): void {
// 1. 加载数据
const vm = new ParkListViewModel();
vm.loadSpots();
const found = vm.allSpots.find(s => s.id === this.spotId);
if (found) { this.spot = found; }
// 2. 检查收藏
this.checkFavorite();
// 3. 记录历史
RdbService.getInstance().addBrowseHistory(
this.spot.id, AppConstants.ITEM_TYPE_SCENIC,
this.spot.name, this.spot.coverImage
);
}
6.3 详情页 UI 结构
详情页的 build 方法使用 Scroll 作为外层容器,内部包含四个区块:
build() {
Scroll() {
Column() {
// 1. 顶部封面区(Stack 叠层)
Stack() {
Column().width('100%').height(240).backgroundColor(ThemeConstants.PRIMARY_LIGHT)
// 景点名 + 评分标签
Column() {
Text(this.spot.name)
Text(this.spot.ratingText) // @Computed 计算属性
}
// 收藏按钮(右上角)
Image(this.isFavorite ? 实心图标 : 空心图标)
.onClick(() => { this.toggleFavorite(); })
}
// 2. 信息卡片(地址、门票、时间、建议游览时长)
Column() { /* 四行信息 + Divider 分隔 */ }
// 3. 景点介绍卡片
Column() {
Text('景点介绍')
Text(this.spot.detail)
}
// 4. 底部按钮
Button('查看景区详情网页')
.onClick(() => {
RouterService.push(RouteConstants.WEB_VIEW_PAGE, {
'url': this.spot.detailUrl, 'title': this.spot.name
});
})
}
}
}
七、收藏功能实现
收藏功能是 ParkService 模块的亮点之一,基于 common 层的 RdbService 实现数据持久化。
7.1 RdbService 数据库架构
RdbService 使用 HarmonyOS 的 @kit.ArkData 关系型数据库(RDB),包含两张表:
-- 收藏表
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL UNIQUE,
item_type TEXT NOT NULL,
item_name TEXT NOT NULL,
item_data TEXT NOT NULL,
cover_image TEXT,
created_at INTEGER NOT NULL
);
-- 浏览历史表
CREATE TABLE IF NOT EXISTS browse_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
item_type TEXT NOT NULL,
item_name TEXT NOT NULL,
cover_image TEXT,
visited_at INTEGER NOT NULL
);
7.2 收藏状态检查
页面加载时异步查询收藏状态:
private async checkFavorite(): Promise<void> {
this.isFavorite = await RdbService.getInstance().isFavorite(this.spot.id);
}
isFavorite 方法通过 RDB 条件查询判断记录是否存在:
// RdbService 中的实现
async isFavorite(itemId: string): Promise<boolean> {
if (!this.rdbStore) return false;
const predicates = new relationalStore.RdbPredicates(AppConstants.TABLE_FAVORITES);
predicates.equalTo('item_id', itemId);
const resultSet = await this.rdbStore.query(predicates);
const exists = resultSet.rowCount > 0;
resultSet.close();
return exists;
}
7.3 切换收藏状态
toggleFavorite 方法根据当前状态执行添加或删除操作:
private async toggleFavorite(): Promise<void> {
if (this.isFavorite) {
await RdbService.getInstance().removeFavorite(this.spot.id);
this.isFavorite = false;
} else {
await RdbService.getInstance().addFavorite(
this.spot.id, AppConstants.ITEM_TYPE_SCENIC,
this.spot.name, JSON.stringify(this.spot), this.spot.coverImage
);
this.isFavorite = true;
}
}
关键细节:
- 添加收藏时,使用
JSON.stringify(this.spot)将完整的景点对象序列化为 JSON 字符串存入item_data字段,这样在个人中心查看收藏列表时可以直接反序列化使用,无需再次查询详情 isFavorite是@Local状态,修改后会立即触发 UI 更新——收藏图标在实心和空心之间切换
7.4 收藏图标动态切换
// 收藏图标已改用 Unicode 爱心字符替代系统图标
Text(this.isFavorite ? '\u2665' : '\u2661') // ♥ 已收藏 / ♡ 未收藏
.fontSize(16)
.fontColor(ThemeConstants.FAVORITE)
.position({ right: 8, top: 8 })
.onClick(() => { this.toggleFavorite(); })
收藏图标定位在封面区右上角,使用 Unicode 爱心字符(♥ / ♡)替代已废弃的 ohos_ic_public_favour 系列系统图标。
7.5 RDB 初始化时机
RdbService 的初始化在 EntryAbility 的 onWindowStageCreate 中完成:
// EntryAbility.ets
RdbService.getInstance().initStore(this.context);
initStore 获取 RDB 存储实例并创建两张表,确保后续所有页面都能正常使用数据库。
八、浏览历史记录
浏览历史是另一个重要的数据持久化功能。每次进入详情页,都会自动记录一条浏览历史:
// ParkDetailPage.aboutToAppear 中
RdbService.getInstance().addBrowseHistory(
this.spot.id, AppConstants.ITEM_TYPE_SCENIC,
this.spot.name, this.spot.coverImage
);
8.1 RdbService.addBrowseHistory 实现
async addBrowseHistory(itemId: string, itemType: string,
itemName: string, coverImage: string): Promise<void> {
if (!this.rdbStore) return;
const bucket: relationalStore.ValuesBucket = {
'item_id': itemId,
'item_type': itemType,
'item_name': itemName,
'cover_image': coverImage,
'visited_at': Date.now()
};
await this.rdbStore.insert(AppConstants.TABLE_HISTORY, bucket);
}
8.2 与收藏表的区别
| 特性 | 收藏表 (favorites) | 浏览历史表 (browse_history) |
|---|---|---|
| item_id 约束 | UNIQUE(去重,一个景点只能收藏一次) | 无约束(同一景点可多次浏览) |
| 额外字段 | item_data(完整 JSON 数据) | 无(仅基本信息) |
| 时间字段 | created_at(收藏时间) | visited_at(浏览时间) |
| 操作方式 | 手动收藏/取消 | 自动记录 |
| 查询排序 | orderByDesc('created_at') | orderByDesc('visited_at') |
浏览历史的设计采用了追加式记录——每次访问都插入一条新记录而非更新。这样在个人中心可以展示完整的浏览时间线,用户能看到自己何时浏览过哪些景点。
8.3 在个人中心复用
PersonalCenter 模块的 HistoryPage 通过相同的 RdbService 接口查询浏览历史:
// 获取全部浏览历史
const records = await RdbService.getInstance().getBrowseHistory();
// 按类型筛选(只看景点/只看美食/只看酒店)
const scenicRecords = await RdbService.getInstance().getBrowseHistory('scenic');
这种设计体现了分层架构的优势——ParkService 负责写入数据,PersonalCenter 负责读取展示,两者通过 common 层的 RdbService 间接协作,无需直接依赖。
九、@Computed 计算属性应用
ScenicSpot 模型中定义了多个 @Computed 计算属性,在详情页 UI 中得到了充分应用:
9.1 ratingText — 评分文案
// ScenicSpot 模型中的定义
@Computed
get ratingText(): string {
if (this.rating >= 4.5) return '极力推荐';
if (this.rating >= 4.0) return '值得一去';
if (this.rating >= 3.5) return '还不错';
return '一般';
}
在详情页封面区直接使用:
Text(this.spot.ratingText)
.fontSize(ThemeConstants.FONT_SIZE_BODY)
.fontColor(ThemeConstants.TEXT_WHITE)
.backgroundColor('#40FFFFFF')
.borderRadius(4)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
白云山评分 4.8 → 显示"极力推荐",广州塔评分 4.6 → 同样"极力推荐",越秀公园评分 4.5 → 刚好踩线"极力推荐"。
9.2 hasTicket — 门票判断
@Computed
get hasTicket(): boolean {
return this.ticketPrice.length > 0 && this.ticketPrice !== '免费';
}
在详情页信息卡片中使用:
Text(this.spot.ticketPrice.length > 0 ? this.spot.ticketPrice : '免费')
.fontColor(this.spot.hasTicket ? ThemeConstants.ACCENT : ThemeConstants.SUCCESS)
- 白云山(5 元)→
hasTicket = true→ 橙色 - 沙面岛(免费)→
hasTicket = false→ 绿色 - 广州塔(150 元)→
hasTicket = true→ 橙色
9.3 coordinateValid — 坐标有效性
@Computed
get coordinateValid(): boolean {
return this.latitude !== 0 && this.longitude !== 0;
}
此属性主要在 MapService 模块中使用,判断景点是否可以标记在地图上。
9.4 @Computed 的优势
@Computed 计算属性的核心价值在于:
- 自动追踪依赖:
ratingText自动追踪rating的变化,hasTicket自动追踪ticketPrice的变化 - 缓存机制:只有当依赖的
@Trace属性发生变化时,才会重新计算 - 消除中间状态:不需要手动维护
ratingText、hasTicket等派生变量,减少了数据不一致的风险
十、V2 跨组件数据流总结
ParkService 模块涉及多层级、多方向的数据流动,是 V2 状态管理体系的典型应用场景:
10.1 完整数据流图
ParkListViewModel (@ObservedV2)
├─ @Trace allSpots ──────────┐
├─ @Trace filteredSpots ─────┤
├─ @Trace currentFilter ─────┤
└─ filterSpots() / loadSpots()│
↓
ParkListPage (@Local viewModel)
├─ SpotFilterBar
│ ├─ @Param filters ← viewModel.filterOptions
│ ├─ @Param currentFilter ← viewModel.currentFilter
│ └─ @Event onFilterChange → viewModel.filterSpots(filter)
│
└─ List → ForEach(filteredSpots)
└─ ListItem (卡片)
└─ onClick → RouterService.push(PARK_DETAIL_PAGE, {spotId})
ParkDetailPage (新页面)
├─ @Param spotId ← 路由参数
├─ @Local spot ← ViewModel 查找结果
├─ @Local isFavorite ← RDB 查询结果
└─ toggleFavorite() → RdbService.addFavorite / removeFavorite
10.2 装饰器使用清单
| 装饰器 | 使用位置 | 作用 |
|---|---|---|
@ObservedV2 | ParkListViewModel, ScenicSpot | 标记类为响应式 |
@Trace | ViewModel 属性, ScenicSpot 属性 | 属性级变更追踪 |
@Computed | ScenicSpot.hasTicket/ratingText | 计算属性自动更新 |
@Local | ParkListPage.viewModel, ParkDetailPage.spot/isFavorite | 组件私有状态 |
@Param | SpotFilterBar.filters/currentFilter, ParkDetailPage.spotId | 父→子单向传递 |
@Event | SpotFilterBar.onFilterChange | 子→父事件回调 |
10.3 跨模块协作路径
ParkService 与其他模块的协作全部通过 common 层间接完成:
| 操作 | ParkService 侧 | common 层 | 消费侧 |
|---|---|---|---|
| 收藏景点 | RdbService.addFavorite() | RDB favorites 表 | PersonalCenter.FavoritesPage 读取 |
| 浏览记录 | RdbService.addBrowseHistory() | RDB browse_history 表 | PersonalCenter.HistoryPage 读取 |
| 跳转详情 | RouterService.push() | NavPathStack + RouterConfig | ParkDetailPage 接收参数 |
| 跳转 WebView | RouterService.push() | NavPathStack + RouterConfig | PersonalCenter.WebViewPage 接收参数 |
响应式 |
| @Trace | ViewModel 属性, ScenicSpot 属性 | 属性级变更追踪 |
| @Computed | ScenicSpot.hasTicket/ratingText | 计算属性自动更新 |
| @Local | ParkListPage.viewModel, ParkDetailPage.spot/isFavorite | 组件私有状态 |
| @Param | SpotFilterBar.filters/currentFilter, ParkDetailPage.spotId | 父→子单向传递 |
| @Event | SpotFilterBar.onFilterChange | 子→父事件回调 |
10.3 跨模块协作路径
ParkService 与其他模块的协作全部通过 common 层间接完成:
| 操作 | ParkService 侧 | common 层 | 消费侧 |
|---|---|---|---|
| 收藏景点 | RdbService.addFavorite() | RDB favorites 表 | PersonalCenter.FavoritesPage 读取 |
| 浏览记录 | RdbService.addBrowseHistory() | RDB browse_history 表 | PersonalCenter.HistoryPage 读取 |
| 跳转详情 | RouterService.push() | NavPathStack + RouterConfig | ParkDetailPage 接收参数 |
| 跳转 WebView | RouterService.push() | NavPathStack + RouterConfig | PersonalCenter.WebViewPage 接收参数 |
282

被折叠的 条评论
为什么被折叠?



