《旅游住宿》二、数据模型_常量_四大服务封装

HarmonyOS 分层架构 --- common 模块实战:数据模型、常量与服务封装

效果


一、common 模块职责

在 HarmonyOS 分层架构中,common 是一个 HAR(Harmony Archive) 静态共享模块,承担整个项目的公共基础设施角色。它为上层业务模块(home、ParkService、MapService、AccountCenter、PersonalCenter)提供统一的数据定义、常量管理和基础服务封装。

能力分类 说明 关键文件
数据模型(Models) 使用 @ObservedV2 + @Trace + @Computed 构建响应式数据类,支持精准 UI 刷新 ScenicSpot、FoodItem、HotelItem、UserInfo、CategoryItem 等
常量管理(Constants) 统一管理路由名称、应用配置、主题样式等全局常量,避免硬编码 RouteConstants、AppConstants、ThemeConstants
服务封装(Services) 封装 RDB 数据库、路由调度、HTTP 请求、定位服务,对外提供简洁 API RdbService、RouterService、HttpService、LocationService
通用组件(Components) 提供 SectionHeader、ScenicCard、FoodCard 等可复用 UI 组件 SectionHeader、LoadingView、EmptyView 等

业务模块只需通过 import { xxx } from 'common' 一行代码即可使用全部公共能力,实现了 "定义在 common,消费在 feature" 的分层设计。


二、模块导出(Index.ets)

common/Index.ets 是模块的统一出口,所有对外暴露的符号在此集中导出:

typescript 复制代码
// Models
export { ScenicSpot } from './src/main/ets/models/ScenicSpot';
export { FoodItem } from './src/main/ets/models/FoodItem';
export { HotelItem } from './src/main/ets/models/HotelItem';
export { UserInfo } from './src/main/ets/models/UserInfo';
export { CategoryItem, BannerItem, SpotMarker } from './src/main/ets/models/CategoryItem';

// Constants
export { AppConstants } from './src/main/ets/constants/AppConstants';
export { RouteConstants } from './src/main/ets/constants/RouteConstants';
export { ThemeConstants } from './src/main/ets/constants/ThemeConstants';

// Services
export { HttpService } from './src/main/ets/services/HttpService';
export { RdbService, FavoriteRecord, HistoryRecord } from './src/main/ets/services/RdbService';
export { LocationService, LocationData } from './src/main/ets/services/LocationService';
export { RouterService } from './src/main/ets/services/RouterService';

// Components
export { SectionHeader } from './src/main/ets/components/SectionHeader';
export { ScenicCard } from './src/main/ets/components/ScenicCard';
export { FoodCard } from './src/main/ets/components/FoodCard';
export { HotelCard } from './src/main/ets/components/HotelCard';
export { LoadingView } from './src/main/ets/components/LoadingView';
export { EmptyView } from './src/main/ets/components/EmptyView';
export { ImageView } from './src/main/ets/components/ImageView';

设计要点 :通过 Index.ets 做统一出口,业务模块无需关心内部目录结构,只依赖 'common' 这一个包名。这是 HarmonyOS HAR 模块的标准实践。


三、数据模型设计(@ObservedV2 + @Trace + @Computed)

HarmonyOS 6.1 引入了全新的状态管理 V2 方案。本项目的数据模型全部使用 @ObservedV2 装饰器标记为可观测类,配合 @Trace 实现字段级精准追踪,配合 @Computed 自动派生计算属性。

3.1 ScenicSpot 景点模型

景点模型是最复杂的数据模型,包含地理坐标、门票、评分等 15 个字段,以及 3 个计算属性:

typescript 复制代码
@ObservedV2
export class ScenicSpot {
  @Trace id: string = '';
  @Trace name: string = '';
  @Trace description: string = '';
  @Trace detail: string = '';
  @Trace coverImage: string = '';
  @Trace images: string[] = [];
  @Trace category: string = '';
  @Trace address: string = '';
  @Trace latitude: number = 0;
  @Trace longitude: number = 0;
  @Trace ticketPrice: string = '';
  @Trace openTime: string = '';
  @Trace rating: number = 0;
  @Trace visitDuration: string = '';
  @Trace isFavorite: boolean = false;
  @Trace detailUrl: string = '';

  @Computed
  get hasTicket(): boolean {
    return this.ticketPrice.length > 0 && this.ticketPrice !== '免费';
  }

  @Computed
  get coordinateValid(): boolean {
    return this.latitude !== 0 && this.longitude !== 0;
  }

  @Computed
  get ratingText(): string {
    if (this.rating >= 4.5) {
      return '极力推荐';
    } else if (this.rating >= 4.0) {
      return '值得一去';
    } else if (this.rating >= 3.5) {
      return '还不错';
    }
    return '一般';
  }
}

@Computed 的价值hasTicket 根据门票价格自动判断是否需要购票,coordinateValid 校验坐标有效性(地图模块据此决定是否显示导航按钮),ratingText 将数字评分转为中文推荐文案。这三个属性无需手动维护 ,当 @Trace 依赖的源字段变化时自动更新,UI 绑定了 @Computed 属性的组件也会自动刷新。

3.2 FoodItem 美食模型

美食模型围绕"吃什么"场景设计,增加了 tags 标签数组和 priceLevel 消费等级计算属性:

typescript 复制代码
@ObservedV2
export class FoodItem {
  @Trace id: string = '';
  @Trace name: string = '';
  @Trace description: string = '';
  @Trace coverImage: string = '';
  @Trace restaurant: string = '';
  @Trace address: string = '';
  @Trace priceRange: string = '';
  @Trace rating: number = 0;
  @Trace category: string = '';
  @Trace isFavorite: boolean = false;
  @Trace tags: string[] = [];

  @Computed
  get priceLevel(): string {
    const price = Number(this.priceRange.replace(/[^\d]/g, ''));
    if (price <= 30) {
      return '平价小吃';
    } else if (price <= 80) {
      return '中等消费';
    }
    return '高端餐饮';
  }
}

priceLevelpriceRange(如 "¥25/人")中正则提取数字,自动划分为三档消费等级,方便列表页用不同颜色标签展示。

3.3 HotelItem 住宿模型

住宿模型与地图强关联,包含经纬度坐标和价格等级计算:

typescript 复制代码
@ObservedV2
export class HotelItem {
  @Trace id: string = '';
  @Trace name: string = '';
  @Trace description: string = '';
  @Trace coverImage: string = '';
  @Trace address: string = '';
  @Trace pricePerNight: string = '';
  @Trace rating: number = 0;
  @Trace latitude: number = 0;
  @Trace longitude: number = 0;
  @Trace tags: string[] = [];
  @Trace isFavorite: boolean = false;
  @Trace phone: string = '';

  @Computed
  get priceLevel(): string {
    const price = Number(this.pricePerNight.replace(/[^\d]/g, ''));
    if (price <= 300) {
      return '经济型';
    } else if (price <= 800) {
      return '舒适型';
    }
    return '豪华型';
  }

  @Computed
  get priceNumber(): number {
    return Number(this.pricePerNight.replace(/[^\d]/g, ''));
  }
}

priceLevel 将住宿按价格分为经济型(≤300)、舒适型(≤800)、豪华型三档;priceNumber 提取纯数字用于排序和筛选逻辑。

3.4 UserInfo 用户信息模型

用户信息模型支持登录态切换场景:

typescript 复制代码
@ObservedV2
export class UserInfo {
  @Trace userId: string = '';
  @Trace nickname: string = '';
  @Trace avatar: string = '';
  @Trace phone: string = '';
  @Trace email: string = '';
  @Trace isLoggedIn: boolean = false;

  @Computed
  get displayNickname(): string {
    return this.isLoggedIn ? this.nickname : '游客用户';
  }

  @Computed
  get maskedPhone(): string {
    if (this.phone.length === 11) {
      return this.phone.substring(0, 3) + '****' + this.phone.substring(7);
    }
    return this.phone;
  }
}

displayNickname 根据登录状态动态显示昵称或"游客用户";maskedPhone 将 11 位手机号中间四位脱敏为 ****,如 138****1234。这些计算属性保证了敏感信息展示的安全性和登录态切换时的 UI 一致性

3.5 CategoryItem / BannerItem / SpotMarker 辅助模型

这三个轻量模型分别服务于分类导航、首页轮播图和地图标记点场景:

typescript 复制代码
@ObservedV2
export class CategoryItem {
  @Trace id: string = '';
  @Trace name: string = '';
  @Trace icon: string = '';
  @Trace color: string = '';
}

@ObservedV2
export class BannerItem {
  @Trace id: string = '';
  @Trace title: string = '';
  @Trace imageUrl: string = '';
  @Trace linkType: string = '';
  @Trace linkId: string = '';
}

@ObservedV2
export class SpotMarker {
  @Trace id: string = '';
  @Trace name: string = '';
  @Trace latitude: number = 0;
  @Trace longitude: number = 0;
  @Trace category: string = '';
  @Trace icon: string = '';
  @Trace isSelected: boolean = false;

  constructor(name: string, latitude: number, longitude: number, category: string) {
    this.id = `${category}_${name}`;
    this.name = name;
    this.latitude = latitude;
    this.longitude = longitude;
    this.category = category;
  }
}

SpotMarker 的构造函数通过 category_name 拼接生成唯一 ID,isSelected 字段支持地图标记点的选中态高亮。


四、常量管理

将全局常量集中管理是避免硬编码、提升可维护性的关键。本项目将常量分为路由、应用配置、主题样式三类。

4.1 RouteConstants 路由名称常量

所有页面的路由名称在 RouteConstants 中统一定义,各 feature 模块通过 RouteConstants.HOME_PAGE 引用,杜绝字符串拼写错误:

typescript 复制代码
export class RouteConstants {
  static readonly HOME_PAGE: string = 'HomePage';
  static readonly PARK_LIST_PAGE: string = 'ParkListPage';
  static readonly PARK_DETAIL_PAGE: string = 'ParkDetailPage';
  static readonly MAP_PAGE: string = 'MapPage';
  static readonly PERSONAL_CENTER_PAGE: string = 'PersonalCenterPage';
  static readonly FAVORITES_PAGE: string = 'FavoritesPage';
  static readonly HISTORY_PAGE: string = 'HistoryPage';
  static readonly LOGIN_PAGE: string = 'LoginPage';
  static readonly REGISTER_PAGE: string = 'RegisterPage';
  static readonly SETTINGS_PAGE: string = 'SettingsPage';
  static readonly WEB_VIEW_PAGE: string = 'WebViewPage';
  static readonly FOOD_DETAIL_PAGE: string = 'FoodDetailPage';
  static readonly HOTEL_DETAIL_PAGE: string = 'HotelDetailPage';
  static readonly PROFILE_EDIT_PAGE: string = 'ProfileEditPage';
}

共定义 14 个页面路由,覆盖了首页、景点列表/详情、地图、个人中心、收藏、历史、登录/注册、设置、WebView、美食详情、住宿详情、资料编辑等全部页面。

4.2 AppConstants 应用配置常量

typescript 复制代码
export class AppConstants {
  static readonly APP_NAME: string = '广州旅游住宿';
  static readonly DEFAULT_PAGE_SIZE: number = 20;
  static readonly ANIMATION_DURATION: number = 300;
  static readonly BANNER_AUTO_PLAY_INTERVAL: number = 4000;

  // 景点分类
  static readonly CATEGORY_NATURE: string = '自然风光';
  static readonly CATEGORY_CULTURE: string = '历史人文';
  static readonly CATEGORY_PARK: string = '公园广场';
  static readonly CATEGORY_MODERN: string = '现代地标';

  // 美食分类
  static readonly FOOD_DIM_SUM: string = '早茶点心';
  static readonly FOOD_CANTONESE: string = '粤菜';
  static readonly FOOD_SNACK: string = '街头小吃';
  static readonly FOOD_DESSERT: string = '甜品糖水';

  // RDB
  static readonly DB_NAME: string = 'gztourism.db';
  static readonly DB_VERSION: number = 1;
  static readonly TABLE_FAVORITES: string = 'favorites';
  static readonly TABLE_HISTORY: string = 'browse_history';

  // ItemType
  static readonly ITEM_TYPE_SCENIC: string = 'scenic';
  static readonly ITEM_TYPE_FOOD: string = 'food';
  static readonly ITEM_TYPE_HOTEL: string = 'hotel';

  // 地图默认中心(广州市中心)
  static readonly DEFAULT_LATITUDE: number = 23.1291;
  static readonly DEFAULT_LONGITUDE: number = 113.2644;
  static readonly DEFAULT_ZOOM: number = 12;
}

关键配置包括:数据库名称 gztourism.db、两张表名(favorites 收藏表、browse_history 浏览历史表)、三种 ItemType(scenic/food/hotel)、广州地图默认中心坐标 (23.1291, 113.2644) 和默认缩放级别 12。

4.3 ThemeConstants 主题样式常量

typescript 复制代码
export class ThemeConstants {
  // 品牌主色
  static readonly PRIMARY: string = '#1A73E8';
  static readonly PRIMARY_LIGHT: string = '#E8F0FE';
  static readonly PRIMARY_DARK: string = '#0D47A1';

  // 强调色
  static readonly ACCENT: string = '#FF6D00';
  static readonly ACCENT_LIGHT: string = '#FFF3E0';

  // 功能色
  static readonly SUCCESS: string = '#34A853';
  static readonly WARNING: string = '#FBBC04';
  static readonly ERROR: string = '#EA4335';
  static readonly FAVORITE: string = '#E91E63';

  // 文字色
  static readonly TEXT_PRIMARY: string = '#1A1A2E';
  static readonly TEXT_SECONDARY: string = '#5F6368';
  static readonly TEXT_HINT: string = '#9AA0A6';
  static readonly TEXT_WHITE: string = '#FFFFFF';

  // 背景色
  static readonly PAGE_BG: string = '#F8F9FA';
  static readonly CARD_BG: string = '#FFFFFF';
  static readonly DIVIDER: string = '#E8EAED';

  // 尺寸
  static readonly CARD_RADIUS: number = 12;
  static readonly CARD_PADDING: number = 16;
  static readonly SECTION_MARGIN: number = 20;

  // 字号
  static readonly FONT_SIZE_TITLE: number = 20;
  static readonly FONT_SIZE_SUBTITLE: number = 16;
  static readonly FONT_SIZE_BODY: number = 14;
  static readonly FONT_SIZE_CAPTION: number = 12;
  static readonly FONT_SIZE_SMALL: number = 10;
}

色彩体系采用 品牌色 + 强调色 + 功能色 三层设计:蓝色系 #1A73E8 作为品牌主色,橙色 #FF6D00 作为强调色(用于价格、CTA 按钮),红/绿/黄用于成功/错误/警告等功能场景。尺寸和字号通过统一常量保证全局视觉一致性。


五、服务封装详解

5.1 RdbService --- 关系型数据库封装

RdbService 采用单例模式 管理 HarmonyOS 关系型数据库(@kit.ArkData),维护 favorites(收藏)和 browse_history(浏览历史)两张表,提供完整的 CRUD 操作。

单例初始化与建表

typescript 复制代码
export class RdbService {
  private static instance: RdbService | null = null;
  private rdbStore: relationalStore.RdbStore | null = null;

  static getInstance(): RdbService {
    if (!RdbService.instance) {
      RdbService.instance = new RdbService();
    }
    return RdbService.instance;
  }

  async initStore(context: Context): Promise<void> {
    try {
      this.rdbStore = await relationalStore.getRdbStore(context, STORE_CONFIG);
      await this.createTables();
      hilog.info(DOMAIN, TAG, 'RDB store initialized successfully');
    } catch (error) {
      hilog.error(DOMAIN, TAG, 'RDB init failed: %{public}s', JSON.stringify(error));
    }
  }
}

表结构设计

字段 favorites 表 browse_history 表
id INTEGER PRIMARY KEY AUTOINCREMENT INTEGER PRIMARY KEY AUTOINCREMENT
item_id TEXT NOT NULL UNIQUE TEXT NOT NULL
item_type TEXT NOT NULL TEXT NOT NULL
item_name TEXT NOT NULL TEXT NOT NULL
item_data TEXT NOT NULL(JSON 序列化) ---
cover_image TEXT TEXT
时间戳 created_at INTEGER visited_at INTEGER

注意 favorites 表中 item_idUNIQUE 约束,防止重复收藏;item_data 存储完整的 JSON 数据,用于离线展示。

收藏 CRUD 操作

typescript 复制代码
// 添加收藏
async addFavorite(itemId: string, itemType: string, itemName: string,
  itemData: string, coverImage: string): Promise<void> {
  const bucket: relationalStore.ValuesBucket = {
    'item_id': itemId, 'item_type': itemType, 'item_name': itemName,
    'item_data': itemData, 'cover_image': coverImage, 'created_at': Date.now()
  };
  await this.rdbStore.insert(AppConstants.TABLE_FAVORITES, bucket);
}

// 移除收藏
async removeFavorite(itemId: string): Promise<void> {
  const predicates = new relationalStore.RdbPredicates(AppConstants.TABLE_FAVORITES);
  predicates.equalTo('item_id', itemId);
  await this.rdbStore.delete(predicates);
}

// 判断是否已收藏
async isFavorite(itemId: string): Promise<boolean> {
  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;
}

// 查询收藏列表(支持按类型筛选)
async getFavorites(itemType?: string): Promise<FavoriteRecord[]> { ... }

浏览历史操作addBrowseHistorygetBrowseHistoryclearHistory 三个方法覆盖了历史记录的新增、查询和清空功能。

5.2 RouterService --- 路由调度服务

RouterService 是模块间导航的核心,通过封装 NavPathStack 实现业务模块完全解耦

typescript 复制代码
export class RouterService {
  private static builderMap: Map<string, WrappedBuilder<[object]>> = new Map();
  private static navPathStack: NavPathStack | null = null;

  static registerNavPathStack(stack: NavPathStack): void {
    RouterService.navPathStack = stack;
  }

  static push(name: string, param?: object): void {
    if (RouterService.navPathStack) {
      RouterService.navPathStack.pushPath({ name: name, param: param ?? {} });
    }
  }

  static pop(): void {
    if (RouterService.navPathStack) {
      RouterService.navPathStack.pop();
    }
  }

  static popToRoot(): void {
    if (RouterService.navPathStack) {
      RouterService.navPathStack.clear();
    }
  }

  static replace(name: string, param?: object): void {
    if (RouterService.navPathStack) {
      RouterService.navPathStack.replacePath({ name: name, param: param ?? {} });
    }
  }

  static getPathName(): string { ... }
}

解耦原理 :各 feature 模块只需调用 RouterService.push(RouteConstants.PARK_DETAIL_PAGE, { id: spotId }) 即可跳转,无需知道目标页面在哪个模块、如何注册。路由 Builder 的注册在 Phone 端入口模块统一完成,实现了页面定义与页面跳转的分离

核心 API 一览:

方法 功能
registerNavPathStack() 注册导航栈(Phone 端初始化时调用)
registerBuilder() 注册页面 Builder
push(name, param?) 压栈跳转
pop() 弹栈返回
replace(name, param?) 替换当前页
popToRoot() 清空导航栈回到根页面
getPathName() 获取当前页面名称

5.3 HttpService --- HTTP 请求封装

HttpService 基于 @kit.NetworkKithttp 模块,封装了 GET、POST 和图片下载三个静态方法:

typescript 复制代码
export class HttpService {
  static async get(url: string, params?: Record<string, string>): Promise<string> {
    const httpRequest = http.createHttp();
    try {
      const response = await httpRequest.request(url, {
        method: http.RequestMethod.GET,
        connectTimeout: TIMEOUT,
        readTimeout: TIMEOUT,
        extraData: params,
        expectDataType: http.HttpDataType.STRING
      });
      return response.result as string;
    } catch (error) {
      hilog.error(DOMAIN, TAG, 'GET request failed: %{public}s', JSON.stringify(error));
      throw error;
    } finally {
      httpRequest.destroy();
    }
  }

  static async post(url: string, data: object): Promise<string> { ... }
  static async downloadImage(url: string): Promise<ArrayBuffer> { ... }
}

设计亮点

  • try-finally 保证资源释放 :每次请求后在 finally 中调用 httpRequest.destroy() 释放连接
  • 统一超时配置:连接超时和读取超时均为 30 秒
  • hilog 日志追踪:失败时通过 hilog 记录错误,便于线上排查

5.4 LocationService --- 定位服务

LocationService 基于 @kit.LocationKit 封装了定位和逆地理编码能力:

typescript 复制代码
@ObservedV2
export class LocationData {
  @Trace latitude: number = 0;
  @Trace longitude: number = 0;
  @Trace address: string = '';
  @Trace isLocated: boolean = false;
}

export class LocationService {
  static async getCurrentLocation(): Promise<geoLocationManager.Location> {
    const request: geoLocationManager.SingleLocationRequest = {
      locatingPriority: geoLocationManager.LocatingPriority.PRIORITY_ACCURACY,
      locatingTimeoutMs: 10000
    };
    const location = await geoLocationManager.getCurrentLocation(request);
    return location;
  }

  static async reverseGeocode(latitude: number, longitude: number): Promise<string> {
    const request: geoLocationManager.ReverseGeoCodeRequest = {
      locale: 'zh', latitude: latitude, longitude: longitude, maxItems: 1
    };
    const results = await geoLocationManager.getAddressesFromLocation(request);
    if (results.length > 0) {
      return results[0].placeName ?? '';
    }
    return '';
  }

  static async isLocationEnabled(): Promise<boolean> {
    return await geoLocationManager.isLocationEnabled();
  }
}

LocationData 本身也是一个 @ObservedV2 响应式模型,可直接绑定到 UI 组件,当位置更新时自动刷新界面。reverseGeocode 将经纬度转换为中文地名(locale: 'zh'),用于地图页显示当前位置文字。


六、设计原则总结

6.1 @ObservedV2 + @Trace 精准响应

HarmonyOS 6.1 的状态管理 V2 方案相比 V1 的 @Observed/@ObjectLink,实现了字段级别的精准追踪 。只有被 @Trace 装饰的属性变化才会触发绑定了该属性的组件刷新,避免了不必要的整树重渲染。

6.2 @Computed 自动计算派生状态

通过 @Computed 装饰器,hasTicketpriceLeveldisplayNickname 等派生属性无需手动维护同步逻辑,源字段变化时自动重算,UI 自动更新。这消除了大量冗余的"手动同步"代码,也让业务逻辑更加内聚。

6.3 单例模式管理数据库

RdbService 通过单例模式保证全局只有一个数据库连接实例,避免多实例导致的数据不一致和资源浪费。调用方通过 RdbService.getInstance() 获取实例后即可执行 CRUD 操作。

6.4 RouterService 实现模块间完全解耦

RouterService 是整个分层架构的关键胶水层。feature 模块之间不直接依赖 ,所有页面跳转都通过 RouterService.push(RouteConstants.XXX) 完成。路由名称在 RouteConstants 中集中定义,路由 Builder 在 Phone 入口模块统一注册------任何模块都可以独立开发、独立测试,只要遵循路由契约即可。