《旅游住宿》六、列表_详情_RDB收藏

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 被调用:

  1. 更新 currentFilter@Trace 属性变化 → SpotFilterBar 高亮更新)
  2. 更新 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 组件通信的典范:

装饰器参数类型说明
@Paramfiltersstring[]父→子:所有可选项列表
@ParamcurrentFilterstring父→子:当前选中的筛选项
@EventonFilterChange(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 生命周期中依次完成三项任务:

  1. 加载景点数据:创建临时 ViewModel,通过 spotId 查找对应景点
  2. 检查收藏状态:异步查询 RDB 数据库判断当前景点是否已收藏
  3. 记录浏览历史:向 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 计算属性的核心价值在于:

  1. 自动追踪依赖ratingText 自动追踪 rating 的变化,hasTicket 自动追踪 ticketPrice 的变化
  2. 缓存机制:只有当依赖的 @Trace 属性发生变化时,才会重新计算
  3. 消除中间状态:不需要手动维护 ratingTexthasTicket 等派生变量,减少了数据不一致的风险

十、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 装饰器使用清单

装饰器使用位置作用
@ObservedV2ParkListViewModel, ScenicSpot标记类为响应式
@TraceViewModel 属性, ScenicSpot 属性属性级变更追踪
@ComputedScenicSpot.hasTicket/ratingText计算属性自动更新
@LocalParkListPage.viewModel, ParkDetailPage.spot/isFavorite组件私有状态
@ParamSpotFilterBar.filters/currentFilter, ParkDetailPage.spotId父→子单向传递
@EventSpotFilterBar.onFilterChange子→父事件回调

10.3 跨模块协作路径

ParkService 与其他模块的协作全部通过 common 层间接完成:

操作ParkService 侧common 层消费侧
收藏景点RdbService.addFavorite()RDB favorites 表PersonalCenter.FavoritesPage 读取
浏览记录RdbService.addBrowseHistory()RDB browse_history 表PersonalCenter.HistoryPage 读取
跳转详情RouterService.push()NavPathStack + RouterConfigParkDetailPage 接收参数
跳转 WebViewRouterService.push()NavPathStack + RouterConfigPersonalCenter.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 + RouterConfigParkDetailPage 接收参数
跳转 WebViewRouterService.push()NavPathStack + RouterConfigPersonalCenter.WebViewPage 接收参数

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值