《新闻资讯》二、公共能力层模块实现指南

HarmonyOS NEXT 新闻资讯应用 · 公共能力层 common 模块实现指南

开发环境 :DevEco Studio 6.1.0 Release

SDK版本 :HarmonyOS SDK 6.1.0(23) / API 23

开发语言 :ArkTS

状态管理 :V2(@ComponentV2系列装饰器)

前置阅读整体架构指南

本文是 HarmonyOS 新闻资讯应用系列指南的第一篇,聚焦公共能力层 common 模块的实现。common 是整个应用的地基,封装了数据模型、工具类、常量和通用组件,被所有上层 feature 模块依赖。理解 common 模块是掌握整个应用架构的第一步。


效果


一、模块定位与架构角色

1.1 三层架构中的位置

复制代码
┌─────────────────────────────────┐
│  产品定制层 (HAP)                 │  product/phone
├─────────────────────────────────┤
│  基础特性层 (HAR)                 │  features/news, video, live, personal, service
├─────────────────────────────────┤
│  公共能力层 (HAR)  ← 本模块        │  common
└─────────────────────────────────┘

common 是 HAR(Harmony Archive) 包,不能独立运行,编译后作为静态库被上层模块引用。

1.2 依赖方向

  • 上层依赖下层:features/newscommonproduct/phonecommon + 所有 features
  • 同层不互相依赖:features/news 不能依赖 features/video
  • common 无外部依赖:是最底层的模块

1.3 common 的四大职责

职责 说明 涉及文件
数据模型 定义 NewsItem、VideoItem 等可观测数据类 model/ 目录(5个文件)
工具封装 日志、网络请求Mock、JSON解析 utils/ 目录(3个文件)
常量管理 全局配置常量、UI样式常量 constants/ 目录(2个文件)
通用组件 Web组件、偏好存储 components/ + preferences/

二、模块配置

2.1 oh-package.json5

json5 复制代码
{
  "name": "common",
  "version": "1.0.0",
  "description": "Please describe the basic information.",
  "main": "Index.ets",
  "author": "",
  "license": "Apache-2.0",
  "dependencies": {}
}

字段讲解

  • "name": "common" --- 包名,其他模块通过此名称 import(如 import { NewsItem } from 'common'
  • "main": "Index.ets" --- 入口文件,所有对外导出的统一出口
  • "dependencies": {} --- 无外部依赖,是最底层模块

2.2 module.json5

json5 复制代码
{
  "module": {
    "name": "common",
    "type": "har",
    "deviceTypes": [
      "default"
    ]
  }
}

字段讲解

  • "type": "har" --- HAR 模块类型标识,编译为静态库
  • "deviceTypes": ["default"] --- 支持所有设备类型(手机、平板、穿戴等)

三、完整文件结构

复制代码
common/
├── Index.ets                              # 统一导出入口(15个export)
├── oh-package.json5                       # 包配置
├── build-profile.json5                    # 构建配置
└── src/main/
    ├── module.json5                        # 模块配置(type: "har")
    └── ets/
        ├── model/                          # 数据模型层
        │   ├── NewsItem.ets                # 新闻数据(10个@Trace属性)
        │   ├── VideoItem.ets               # 视频数据(8个@Trace属性)
        │   ├── LiveItem.ets                # 直播数据 + LIVE_DATA常量
        │   ├── CommentItem.ets             # 评论数据(6个@Trace属性)
        │   └── UserInfo.ets                # 用户信息(4个@Trace属性)
        ├── datasource/
        │   └── CommonDataSource.ets        # 泛型IDataSource实现(89行)
        ├── constants/
        │   ├── CommonConstants.ets         # 通用常量(含VIDEO_COVER_LIST)
        │   └── StyleConstants.ets          # UI样式常量
        ├── utils/
        │   ├── Logger.ets                  # hilog日志封装
        │   ├── HttpUtil.ets                # Mock网络请求
        │   └── MockDataUtil.ets            # rawfile JSON解析
        ├── preferences/
        │   └── PreferenceModel.ets         # Preferences持久化
        └── components/
            └── CommonWeb.ets               # 通用Web组件

四、数据模型层 ------ @ObservedV2 + @Trace 详解

common 模块定义了 5个数据模型类 ,全部使用状态管理V2的 @ObservedV2 + @Trace 装饰器。这是V2状态管理的核心机制,下面先讲解原理,再逐个模型分析。

4.1 V2装饰器原理

@ObservedV2 (替代V1的 @Observed):

  • 标记整个类为"可观测对象"
  • 框架自动为该类生成代理(Proxy),拦截属性的读写操作
  • 只能在 @ComponentV2 组件中使用

@Trace(V1无对应物,全新装饰器):

  • 标记每个属性为独立的追踪单元
  • 实现属性级细粒度更新 :修改 isGood 时,只有读取 isGood 的UI组件会重渲染
  • 对比V1:@Observed 的属性修改会触发所有 引用该对象的组件重渲染,而 @Trace 只触发读取了该特定属性的组件

4.2 NewsItem.ets --- 新闻数据模型

typescript 复制代码
/**
 * 新闻数据模型
 * 使用状态管理V2的 @ObservedV2 + @Trace 实现细粒度响应式观测
 */
@ObservedV2
export class NewsItem {
  @Trace newsId: string = '';          // 新闻唯一标识
  @Trace newsTitle: string = '';       // 新闻标题
  @Trace newsContent: string = '';     // 新闻正文
  @Trace newsTime: string = '';        // 发布时间
  @Trace newsImage: string = '';       // 封面图片路径(rawfile相对路径)
  @Trace category: string = '头条';    // 新闻分类,默认"头条"
  @Trace isGood: boolean = false;      // 是否点赞(交互状态)
  @Trace isCollect: boolean = false;   // 是否收藏(交互状态)
  @Trace commentCount: number = 0;     // 评论数
  @Trace shareCount: number = 0;       // 分享数

  constructor(id: string, title: string, content: string,
    time: string, image: string, category: string = '头条') {
    this.newsId = id;
    this.newsTitle = title;
    this.newsContent = content;
    this.newsTime = time;
    this.newsImage = image;
    this.category = category;
  }
}

设计要点

  • 10个 @Trace 属性,每个属性变更只触发读取该属性的组件重渲染
  • category 默认值 '头条',构造器中可选传入
  • isGood/isCollect 是交互状态,在 NewsDetail 页面中被切换
  • 构造器:6个必选参数 + 1个可选参数(category)

4.3 VideoItem.ets --- 视频数据模型

typescript 复制代码
/**
 * 视频数据模型
 * 使用 @ObservedV2 + @Trace 实现细粒度响应式
 */
@ObservedV2
export class VideoItem {
  @Trace videoId: string = '';         // 视频唯一标识
  @Trace title: string = '';           // 视频标题
  @Trace content: string = '';         // 视频描述
  @Trace coverImage: Resource = $r('app.media.preview');  // 封面图(Resource类型)
  @Trace videoSrc: string = '';        // 视频文件路径
  @Trace category: string = '推荐';    // 视频分类
  @Trace playCount: number = 0;        // 播放次数
  @Trace duration: string = '';        // 时长(如"03:45")

  constructor(id: string, title: string, cover: Resource,
    src: string = '', category: string = '推荐') {
    this.videoId = id;
    this.title = title;
    this.coverImage = cover;
    this.videoSrc = src;
    this.category = category;
  }
}

设计要点

  • coverImage: Resource 类型 --- 使用 $r('app.media.preview') 作为默认资源引用
  • 构造器参数:id/title/cover 必选,src/category 可选
  • duration 采用字符串格式存储(如 "03:45"),避免格式转换

4.4 LiveItem.ets --- 直播数据模型 + LIVE_DATA 常量

typescript 复制代码
/**
 * 直播数据模型
 * 使用 @ObservedV2 + @Trace 实现细粒度响应式
 */
@ObservedV2
export class LiveItem {
  @Trace liveId: string = '';          // 直播唯一标识
  @Trace title: string = '';           // 直播标题
  @Trace content: string = '';         // 直播描述
  @Trace coverImage: Resource = $r('app.media.live_01');  // 封面图
  @Trace category: string = '关注';    // 分类(用于Tab筛选)
  @Trace isLive: boolean = false;      // 是否正在直播
  @Trace viewerCount: number = 0;      // 观看人数

  constructor(title: string, content: string, img: Resource, category: string = '关注') {
    this.title = title;
    this.content = content;
    this.coverImage = img;
    this.category = category;
  }
}

/**
 * 直播静态数据
 */
export const LIVE_DATA: LiveItem[] = [
  new LiveItem('我的关注', '大理洱海边', $r('app.media.live_01')),
  new LiveItem('我的关注', '可可里西', $r('app.media.live_02')),
  new LiveItem('我的关注', '浪漫土耳其', $r('app.media.live_03')),
  new LiveItem('热门推荐', '大理洱海边', $r('app.media.live_01')),
  new LiveItem('热门推荐', '可可里西', $r('app.media.live_02')),
  new LiveItem('热门推荐', '浪漫土耳其', $r('app.media.live_03')),
  new LiveItem('今日直播', '大理洱海边', $r('app.media.live_01')),
  new LiveItem('今日直播', '可可里西', $r('app.media.live_02')),
  new LiveItem('今日直播', '浪漫土耳其', $r('app.media.live_03')),
  new LiveItem('精彩回放', '大理洱海边', $r('app.media.live_01')),
  new LiveItem('精彩回放', '可可里西', $r('app.media.live_02')),
  new LiveItem('精彩回放', '浪漫土耳其', $r('app.media.live_03')),
]

设计要点

  • LIVE_DATA 常量数组:12条数据,4个分类(我的关注/今日直播/热门推荐/精彩回放),每类3条
  • category 字段用于 LiveHome 中的 filter 筛选,按分类分配到不同Tab
  • 使用 $r('app.media.live_01') 资源引用方式,图片放在 common/src/main/resources/base/media/

⚠️ 重要警告:LIVE_DATA 跨模块导入运行时不可用

虽然 LIVE_DATA 从 common 模块正常导出,但在 feature 模块(如 live)的运行时中,对导入的 LIVE_DATA 执行 .filter() 会返回空数组。这是因为 @ObservedV2 代理对象数组在跨模块传递后,运行时行为异常。

解决方案 :在 feature 模块内本地化定义数据常量(如 LOCAL_LIVE_DATA),结构与 LIVE_DATA 相同,确保运行时 filter 正常工作。common 中的 LIVE_DATA 仍保留导出,作为数据规范参考。

4.5 CommentItem.ets --- 评论数据模型

typescript 复制代码
/**
 * 评论数据模型
 * 使用 @ObservedV2 + @Trace 实现细粒度响应式
 */
@ObservedV2
export class CommentItem {
  @Trace commentId: string = '';       // 评论唯一标识
  @Trace content: string = '';         // 评论内容
  @Trace author: string = '用户';      // 评论作者
  @Trace time: string = '';            // 评论时间
  @Trace newsTitle: string = '';       // 所属新闻标题
  @Trace likeCount: number = 0;        // 点赞数

  constructor(content: string, author: string = '用户',
    time: string = '', newsTitle: string = '') {
    this.content = content;
    this.author = author;
    this.time = time;
    this.newsTitle = newsTitle;
  }
}

设计要点 :简洁模型设计,6个 @Trace 属性,构造器中仅 content 为必选参数。

4.6 UserInfo.ets --- 用户信息模型

typescript 复制代码
/**
 * 用户信息模型
 * 使用 @ObservedV2 + @Trace 实现细粒度响应式
 */
@ObservedV2
export class UserInfo {
  @Trace account: string = '';         // 手机号/账号
  @Trace nickname: string = '';        // 昵称
  @Trace score: number = 0;            // 积分
  @Trace isLoggedIn: boolean = false;  // 是否已登录

  constructor(account: string = '', nickname: string = '') {
    this.account = account;
    this.nickname = nickname;
    this.isLoggedIn = account !== '';  // 根据account自动计算登录状态
  }
}

设计要点

  • isLoggedIn 在构造器中根据 account 自动计算:account !== '' 即为已登录
  • AppStorage 配合使用:LoginPage 写入手机号,PersonalHome 读取并设置 user 属性

五、通用数据源 CommonDataSource

CommonDataSource 是 LazyForEach 的数据源实现,使用泛型设计,可适配任意数据类型。

typescript 复制代码
/**
 * 通用懒加载数据源
 * 实现 IDataSource 接口,供 LazyForEach 使用
 * 支持泛型,可适配任意数据类型
 */
export class CommonDataSource<T> implements IDataSource {
  private dataArray: T[] = [];
  private listeners: DataChangeListener[] = [];

  constructor(elements: T[] = []) {
    this.dataArray = elements;
  }

  totalCount(): number {
    return this.dataArray.length;
  }

  getData(index: number): T {
    return this.dataArray[index];
  }

  pushData(data: T): void {
    this.dataArray.push(data);
    this.notifyDataAdd(this.dataArray.length - 1);
  }

  addData(index: number, data: T[]): void {
    this.dataArray = this.dataArray.concat(data);
    this.notifyDataAdd(index);
  }

  clear(): void {
    this.dataArray = [];
    this.notifyDataReload();
  }

  setData(data: T[]): void {
    this.dataArray = data;
    this.notifyDataReload();
  }

  getDataArray(): T[] {
    return this.dataArray;
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.splice(pos, 1);
    }
  }

  notifyDataReload(): void {
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataReloaded();
    })
  }

  notifyDataAdd(index: number): void {
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataAdd(index);
    })
  }

  notifyDataChange(index: number): void {
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataChange(index);
    })
  }

  notifyDataDelete(index: number): void {
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataDelete(index);
    })
  }

  notifyDataMove(from: number, to: number): void {
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataMove(from, to);
    })
  }
}

5.1 IDataSource 接口

方法 作用 调用时机
totalCount() 返回数据总数 LazyForEach 计算列表高度
getData(index) 按索引返回数据 LazyForEach 渲染可见项
registerDataChangeListener() 注册监听器 框架自动调用
unregisterDataChangeListener() 注销监听器 组件销毁时

5.2 泛型设计

typescript 复制代码
// 新闻数据源
let newsSource = new CommonDataSource<NewsItem>();
// 视频数据源
let videoSource = new CommonDataSource<VideoItem>();

CommonDataSource<T> 中 T 可以是任意类型,getData() 返回 T 类型,编译期即可检查类型安全。

5.3 通知机制

方法 触发场景 LazyForEach 行为
notifyDataReload() setData/clear 重新加载所有数据
notifyDataAdd(index) pushData/addData 在指定位置插入新项
notifyDataChange(index) 手动调用 刷新指定项
notifyDataDelete(index) 手动调用 删除指定项
notifyDataMove(from, to) 排序场景 移动数据位置

5.4 LazyForEach 配合原理

复制代码
框架渲染流程:
1. 调用 totalCount() 获取数据总数
2. 根据可视区域计算需要渲染的索引范围 [start, end]
3. 对范围内每个索引调用 getData(index) 获取数据
4. 使用 itemBuilder 回调渲染每个 ListItem
5. 滚动时动态加载/卸载不可见项

六、常量管理

6.1 CommonConstants.ets --- 通用常量

typescript 复制代码
/**
 * 通用常量定义
 * 集中管理全局使用的常量值
 */
export class CommonConstants {
  /** 提示持续时间(毫秒) */
  static readonly DURATION: number = 3000;
  /** 日志标签 */
  static readonly TAG: string = '[NewsApp]';
  /** 偏好存储键名 */
  static readonly KEY_NAME: string = 'newsData';
  /** 偏好存储文件名 */
  static readonly PREFERENCES_NAME: string = 'newsData.db';
  /** 列表项间距 */
  static readonly LIST_SPACE: number = 3;
  /** 列表项内间距 */
  static readonly LIST_ITEM_SPACE: number = 14;
  /** 最大文本行数 */
  static readonly MAX_LINES: number = 3;
  /** 布局权重 */
  static readonly LAYOUT_WEIGHT: number = 1;
  /** 启动页延迟时间(毫秒) */
  static readonly SPLASH_DELAY: number = 1500;
  /** 搜索按钮文本 */
  static readonly SEARCH_BUTTON_TEXT: string = '搜索';
  /** 百分比:100% */
  static readonly FULL_PERCENT: string = '100%';
  /** Tab栏高度 */
  static readonly TAB_BAR_HEIGHT: number = 56;
  /** 新闻分类列表 */
  static readonly NEWS_CATEGORIES: string[] = ['头条', '体育', '时政', '经济', '文化', '民生', '社会'];
  /** 视频封面图片列表 */
  static readonly VIDEO_COVER_LIST: Resource[] = [
    $r('app.media.ic_picture_01'),
    $r('app.media.ic_picture_02'),
    $r('app.media.ic_picture_03'),
    $r('app.media.ic_picture_04'),
    $r('app.media.ic_picture_05'),
    $r('app.media.ic_picture_06'),
    $r('app.media.ic_picture_07'),
    $r('app.media.ic_picture_08'),
    $r('app.media.ic_picture_09'),
    $r('app.media.ic_picture_10'),
    $r('app.media.ic_picture_01'),
    $r('app.media.ic_picture_02'),
    $r('app.media.ic_picture_03'),
    $r('app.media.ic_picture_04'),
    $r('app.media.ic_picture_05'),
    $r('app.media.ic_picture_06'),
    $r('app.media.ic_picture_07'),
    $r('app.media.ic_picture_08'),
    $r('app.media.ic_picture_09'),
    $r('app.media.ic_picture_10')
  ];
}

设计思路 :集中管理所有常量,避免魔术数字(Magic Number)。修改 NEWS_CATEGORIES 一处,所有使用分类的地方自动更新。

6.2 StyleConstants.ets --- UI样式常量

typescript 复制代码
/**
 * 样式常量定义
 * 集中管理UI样式相关常量
 */
export class StyleConstants {
  /** 宽度100% */
  static readonly FULL_WIDTH: string = '100%';
  /** 高度100% */
  static readonly FULL_HEIGHT: string = '100%';
  /** 图片宽高比 */
  static readonly IMAGE_ASPECT_RATIO: number = 2.25;
  /** 弹性增长 */
  static readonly FLEX_GROW: number = 1;
  /** 字体粗细:700 */
  static readonly FONT_WEIGHT_SEVEN: number = 700;
  /** 字体粗细:500 */
  static readonly FONT_WEIGHT_FIVE: number = 500;
  /** 字体粗细:400 */
  static readonly FONT_WEIGHT_FOUR: number = 400;
  /** 完全不透明 */
  static readonly FULL_OPACITY: number = 1;
  /** 60%透明度 */
  static readonly SIXTY_OPACITY: number = 0.6;
  /** 主题色:红色 */
  static readonly PRIMARY_COLOR: string = '#C7000B';
  /** 主题色:深灰 */
  static readonly TEXT_PRIMARY: string = '#182431';
  /** 文本次要色 */
  static readonly TEXT_SECONDARY: string = '#99182431';
  /** 分割线颜色 */
  static readonly DIVIDER_COLOR: string = '#33182431';
  /** 背景色 */
  static readonly BG_COLOR: string = '#F1F3F5';
  /** 卡片背景色 */
  static readonly CARD_BG: string = '#FFFFFF';
  /** 圆角小 */
  static readonly RADIUS_SMALL: number = 4;
  /** 圆角中 */
  static readonly RADIUS_MEDIUM: number = 8;
  /** 圆角大 */
  static readonly RADIUS_LARGE: number = 16;
}

设计思路 :统一UI风格。修改主题色 PRIMARY_COLOR 一处,全局颜色同步更新。颜色体系分为:主题红、文字深灰、文本次要色、分割线色、背景色、卡片白。


七、工具类

7.1 Logger.ets --- 日志封装

typescript 复制代码
import { hilog } from '@kit.PerformanceAnalysisKit';

/**
 * 通用日志工具类
 * 基于 hilog 封装,统一管理日志输出
 */

const DOMAIN: number = 0xFF00;
const PREFIX: string = 'NewsApp';
const FORMAT: string = `%{public}s, %{public}s`;

export class Logger {
  static debug(...args: string[]) {
    hilog.debug(DOMAIN, PREFIX, FORMAT, args);
  }

  static info(...args: string[]) {
    hilog.info(DOMAIN, PREFIX, FORMAT, args);
  }

  static warn(...args: string[]) {
    hilog.warn(DOMAIN, PREFIX, FORMAT, args);
  }

  static error(...args: string[]) {
    hilog.error(DOMAIN, PREFIX, FORMAT, args);
  }

  static fatal(...args: string[]) {
    hilog.fatal(DOMAIN, PREFIX, FORMAT, args);
  }
}

要点 :基于 @kit.PerformanceAnalysisKit 的 hilog 封装,DOMAIN = 0xFF00(日志域),PREFIX = 'NewsApp'(日志前缀)。5个级别静态方法,使用时无需实例化:Logger.info('加载完成')

7.2 HttpUtil.ets --- Mock网络请求

typescript 复制代码
/**
 * 网络请求工具类(Mock实现)
 * 当前使用模拟数据,后续可替换为真实HTTP请求
 */
export class HttpUtil {
  /**
   * 模拟GET请求,返回延迟后的数据
   * @param data 要返回的数据
   * @param delay 延迟时间(毫秒),默认500ms
   */
  static async mockGet<T>(data: T[], delay: number = 500): Promise<T[]> {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(data);
      }, delay);
    });
  }
}

要点:泛型异步方法,Promise + setTimeout 模拟网络延迟。后续替换为真实HTTP请求只需修改此一个文件。

7.3 MockDataUtil.ets --- rawfile JSON解析

typescript 复制代码
import { util } from '@kit.ArkTS';
import { NewsItem } from '../model/NewsItem';

/**
 * Mock JSON 数据项接口
 */
interface JsonNewsItem {
  newsId: string;
  newsTitle: string;
  newsContent: string;
  newsTime: string;
  newsImage: string;
}

interface JsonNewsList {
  newsList: JsonNewsItem[];
}

/**
 * Mock数据解析工具
 * 从 rawfile 中的 JSON 文件解析新闻数据
 */
export class MockDataUtil {
  /**
   * 从 rawfile 加载新闻数据
   * @param context 应用上下文
   * @param filePath rawfile 中的文件路径
   * @returns NewsItem 数组
   */
  static loadNewsFromRawfile(context: Context, filePath: string): NewsItem[] {
    try {
      let value = context.resourceManager.getRawFileContentSync(filePath);
      let textDecoder = util.TextDecoder.create('utf-8', { ignoreBOM: true });
      let text = textDecoder.decodeToString(new Uint8Array(value.buffer));
      let jsonObj: JsonNewsList = JSON.parse(text) as JsonNewsList;
      return jsonObj.newsList.map((item: JsonNewsItem): NewsItem =>
        new NewsItem(item.newsId, item.newsTitle, item.newsContent,
          item.newsTime, item.newsImage));
    } catch (e) {
      return [];
    }
  }
}

数据流rawfilegetRawFileContentSyncUint8ArrayTextDecoderstringJSON.parseNewsItem[]

容错设计:try-catch 包裹,解析失败返回空数组,不会导致应用崩溃。


八、偏好存储 PreferenceModel

typescript 复制代码
import { preferences } from '@kit.ArkData';
import { Logger } from '../utils/Logger';
import { CommonConstants } from '../constants/CommonConstants';
import { Context } from '@kit.AbilityKit';

let preference: preferences.Preferences;

/**
 * 偏好存储模型
 * 使用 Preferences API 实现数据持久化
 */
export class PreferenceModel {
  /**
   * 从持久化文件读取 Preferences 实例
   */
  async getPreferencesFromStorage(context: Context): Promise<void> {
    try {
      preference = await preferences.getPreferences(context, CommonConstants.PREFERENCES_NAME);
    } catch (err) {
      Logger.error(`Failed to get preferences, Cause: ${err}`);
    }
  }

  /**
   * 保存数据到 Preferences
   */
  async putPreference(context: Context, key: string, value: string): Promise<void> {
    if (!preference) {
      await this.getPreferencesFromStorage(context);
    }
    try {
      await preference.put(key, value);
      await preference.flush();
    } catch (err) {
      Logger.error(`Failed to put value, Cause: ${err}`);
    }
  }

  /**
   * 从 Preferences 读取数据
   */
  async getPreference(context: Context, key: string): Promise<string> {
    if (!preference) {
      await this.getPreferencesFromStorage(context);
    }
    try {
      let data = (await preference.get(key, '')).toString();
      return data;
    } catch (err) {
      Logger.error(`Failed to get value, Cause: ${err}`);
      return '';
    }
  }

  /**
   * 删除 Preferences 持久化文件
   */
  async deletePreferences(context: Context): Promise<void> {
    try {
      await preferences.deletePreferences(context, CommonConstants.PREFERENCES_NAME);
    } catch (err) {
      Logger.error(`Failed to delete preferences, Cause: ${err}`);
    }
  }
}

export const preferenceModel = new PreferenceModel();

8.1 Preferences API 异步读写流程

操作 调用链路
写入 getPreferences()preference.put(key, value)preference.flush()
读取 getPreferences()preference.get(key, defaultValue)
删除 preferences.deletePreferences(context, name)

懒初始化put/get 方法内部检查 if (!preference),首次调用时自动初始化。

单例导出export const preferenceModel = new PreferenceModel() --- 全局共享同一个实例。


九、通用组件 CommonWeb

typescript 复制代码
import { webview } from '@kit.ArkWeb';

/**
 * 通用Web组件
 * 使用 @ComponentV2 + @Param 实现V2状态管理
 * 可嵌入任意页面展示网页内容
 */
@ComponentV2
export struct CommonWeb {
  @Param url: string = '';
  private controller: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      if (this.url !== '') {
        Web({ src: this.url, controller: this.controller })
          .width('100%')
          .height('100%')
          .javaScriptAccess(true)
      } else {
        Text('暂无内容')
          .fontSize(16)
          .fontColor('#999999')
      }
    }
    .width('100%')
    .height('100%')
  }
}

要点

  • @ComponentV2 + @Param url:V2版本的父→子参数传递
  • javaScriptAccess(true):启用JavaScript执行
  • 条件渲染:url为空时显示"暂无内容"占位
  • WebviewController:可用于后续扩展(进度监听、页面跳转拦截等)

十、Index.ets 统一导出规范

typescript 复制代码
// 数据模型
export { NewsItem } from './src/main/ets/model/NewsItem';
export { VideoItem } from './src/main/ets/model/VideoItem';
export { LiveItem, LIVE_DATA } from './src/main/ets/model/LiveItem';  // 注意:LIVE_DATA 跨模块导入运行时不可用,feature 模块应本地化定义
export { CommentItem } from './src/main/ets/model/CommentItem';
export { UserInfo } from './src/main/ets/model/UserInfo';

// 数据源
export { CommonDataSource } from './src/main/ets/datasource/CommonDataSource';

// 常量
export { CommonConstants } from './src/main/ets/constants/CommonConstants';
export { StyleConstants } from './src/main/ets/constants/StyleConstants';

// 工具
export { Logger } from './src/main/ets/utils/Logger';
export { HttpUtil } from './src/main/ets/utils/HttpUtil';
export { MockDataUtil } from './src/main/ets/utils/MockDataUtil';

// 偏好存储
export { PreferenceModel, preferenceModel } from './src/main/ets/preferences/PreferenceModel';

// 组件
export { CommonWeb } from './src/main/ets/components/CommonWeb';

导出规范

  • 所有对外暴露的类、组件、常量都必须在此导出
  • 模块内部类型(如 JsonNewsItem 接口)不需要导出
  • 引用方式:import { NewsItem, CommonDataSource, MockDataUtil } from 'common'
  • 共计 15个导出:数据模型(5) + 数据源(1) + 常量(2) + 工具(3) + 偏好存储(2) + 组件(1)

十一、V1→V2 装饰器映射对照表

V1装饰器 V2装饰器 用途 说明
@Observed @ObservedV2 类观测 标记整个类可观测
--- @Trace 属性追踪 V2新增,属性级细粒度更新
@State @Local 组件本地状态 仅本组件内响应
@Prop @Param 父→子单向传递 语义更明确:"这是参数"
@Provide @Provider 跨层级提供 需指定字符串key
@Consume @Consumer 跨层级消费 key需与Provider匹配
@Watch @Monitor 属性监听 触发时机有变化
@Component @ComponentV2 组件声明 V2组件才能使用V2装饰器

十二、常见问题 Q&A

Q1:为什么数据模型要放在 common 而不是各 feature 模块?

A:因为多个 feature 模块可能共享同一数据模型。例如 NewsItemfeatures/newsfeatures/personal 使用,放在 common 避免重复定义和循环依赖。如果放在 news 模块,personal 模块就需要依赖 news 模块,违反"同层不互相依赖"原则。

Q2:CommonDataSource 的泛型如何保证类型安全?

A:TypeScript 泛型在编译期检查类型。CommonDataSource<NewsItem>.getData() 返回 NewsItem 类型,访问不存在的属性会编译报错。运行时泛型被擦除,但编译期的类型检查已经足够保证安全。

Q3:rawfile 资源放在哪里?

A:rawfile 资源放在使用它的模块中。本项目中:

  • mockDataOne.json 和新闻图片 → features/news/src/main/resources/rawfile/
  • 通用图片(ic_picture_、live_ )→ common/src/main/resources/base/media/
  • 入口资源(splash_bg、back.svg)→ product/phone/src/main/resources/base/media/

Q4:@Trace 和 @ObservedV2 必须同时使用吗?

A:是的。@Trace 只能在 @ObservedV2 标记的类中使用。@ObservedV2 让类可观测,@Trace 让每个属性独立追踪。如果某个属性不需要响应式更新,可以不标记 @Trace(但通常建议全部标记)。

Q5:为什么 LIVE_DATA 从 common 导出后在 feature 模块中 filter 返回空数组?

A:这是 @ObservedV2 代理对象数组跨模块传递的已知问题。common 模块导出的 LIVE_DATA: LiveItem[] 中每个元素都是 @ObservedV2 代理对象,在跨模块传递后,运行时的 .filter() 方法无法正确匹配属性值。解决方案是在 feature 模块内本地化定义数据常量(如 LOCAL_LIVE_DATA),结构与 LIVE_DATA 完全相同。


十三、小结

common 模块作为三层架构的最底层,提供了4大核心能力:

  1. 数据模型:5个 @ObservedV2 + @Trace 响应式数据类,支持属性级细粒度更新
  2. 工具封装:日志(Logger)、网络请求Mock(HttpUtil)、JSON解析(MockDataUtil)
  3. 常量管理:全局配置常量(CommonConstants)+ UI样式常量(StyleConstants)
  4. 通用组件:泛型数据源(CommonDataSource)+ Web组件(CommonWeb)+ 偏好存储(PreferenceModel)

上层所有 feature 模块通过 import { xxx } from 'common' 引用这些能力,实现了代码的高度复用和统一维护。


相关推荐
Ww.xh2 小时前
启用Hypervisor解决模拟器问题
华为·harmonyos
金启攻3 小时前
【鸿蒙原生应用实战】第二篇:装备库页面——分类筛选与数据驱动UI
harmonyos
挂科边缘4 小时前
MonkeyQt组件库,基于 PySide6 搭建的 UI 组件库,68种主题样式
ui·pyside6·monkeyqt
木咺吟5 小时前
鸿蒙原生应用实战(四):愿望单与个人统计 — 数据聚合与可视化
华为·harmonyos
木咺吟6 小时前
鸿蒙原生应用实战(二):游戏库列表与筛选排序 — 卡片式UI设计
harmonyos
互联网散修7 小时前
鸿蒙实战:从零实现自定义相机(下)——填平预览拉伸、比例错乱、缩略图消失的六大坑
数码相机·华为·harmonyos
namexingyun7 小时前
开源前端生态如何成为 AI UI 生成的“燃料“:shadcn/ui、Tailwind CSS、Storybook 技术价值全解剖
java·前端·人工智能·python·ui·开源·ai编程
风华圆舞7 小时前
鸿蒙 + Flutter 下 AI 助手为什么要支持流式输出
人工智能·flutter·harmonyos
LT10157974448 小时前
2026年UI自动化测试平台选型指南:全界面自动化覆盖方案
运维·ui·自动化