HarmonyOS NEXT 新闻资讯应用各分层模块实现指南:状态管理V2逐模块实战详解
📚 文档索引:本指南已拆分为以下独立模块指南,每个指南包含完整源码和详细讲解,可独立阅读:
序号 模块 指南链接 核心内容 01 common 公共能力层 查看指南 @ObservedV2+@Trace数据模型、CommonDataSource泛型、工具类 02 features/news 新闻模块 查看指南 LazyForEach+Refresh下拉刷新、5个@Builder、NavPathStack路由 03 features/video 视频模块 查看指南 Grid双列布局、Video组件播放、Tabs分类 04 features/live 直播模块 查看指南 @Builder复用模式、LIVE_DATA.filter筛选 05 features/personal 个人中心 查看指南 AppStorage跨页面状态、登录验证码倒计时 06 features/service 服务模块 查看指南 shadow卡片阴影、Tabs+Scroll布局 07 product/phone 产品定制层 查看指南 HdsTabs沉浸光感悬浮页签、@Provider+@Param显式传参、Navigation路由
环境 :DevEco Studio 6.1 | HarmonyOS SDK 6.1.0(23) | ArkTS
前置阅读 :整体实现指南
本文重点:逐模块讲解每个分层的具体实现流程、关键代码和V2装饰器用法
效果
---
一、公共能力层:common 模块
1.1 模块定位
common是最底层的基础模块,封装了数据模型 、工具类 、常量 和通用组件,被所有features模块依赖。
1.2 文件结构
common/
├── Index.ets # 统一导出入口
└── src/main/ets/
├── model/ # 数据模型(@ObservedV2 + @Trace)
│ ├── NewsItem.ets # 新闻数据
│ ├── VideoItem.ets # 视频数据
│ ├── LiveItem.ets # 直播数据
│ ├── CommentItem.ets # 评论数据
│ └── UserInfo.ets # 用户信息
├── datasource/
│ └── CommonDataSource.ets # 通用懒加载数据源
├── constants/
│ ├── CommonConstants.ets # 通用常量
│ └── StyleConstants.ets # 样式常量
├── utils/
│ ├── Logger.ets # 日志工具
│ ├── HttpUtil.ets # 网络请求Mock
│ └── MockDataUtil.ets # Mock数据解析
├── preferences/
│ └── PreferenceModel.ets # 偏好存储
└── components/
└── CommonWeb.ets # 通用Web组件
1.3 关键代码讲解
1.3.1 数据模型:@ObservedV2 + @Trace
typescript
// NewsItem.ets - 新闻数据模型
@ObservedV2
export class NewsItem {
@Trace newsId: string = '';
@Trace newsTitle: string = '';
@Trace newsContent: string = '';
@Trace newsTime: string = '';
@Trace newsImage: string = '';
@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;
// ...
}
}
V2要点:
@ObservedV2替代V1的@Observed,标记整个类可观测@Trace标记每个属性为独立追踪单元,实现属性级细粒度更新- 当修改
isGood时,只有读取isGood的组件会重渲染
1.3.2 通用数据源:CommonDataSource
typescript
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]; }
setData(data: T[]): void {
this.dataArray = data;
this.notifyDataReload(); // 通知LazyForEach重新加载所有数据
}
pushData(data: T): void {
this.dataArray.push(data);
this.notifyDataAdd(this.dataArray.length - 1); // 通知新增一项
}
// ... registerDataChangeListener / unregisterDataChangeListener 等
}
使用泛型 :CommonDataSource<NewsItem>、CommonDataSource<VideoItem> 适配不同数据类型。
1.3.3 Mock数据解析:MockDataUtil
typescript
export class MockDataUtil {
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 [];
}
}
}
流程 :rawfile → getRawFileContentSync → TextDecoder → JSON.parse → NewsItem[]
1.3.4 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';
// ...
// 组件
export { CommonWeb } from './src/main/ets/components/CommonWeb';
注意 :所有对外暴露的类、组件、常量都必须在此导出,其他模块通过 import { xxx } from 'common' 引用。
二、基础特性层:features/news 模块
2.1 模块定位
新闻模块是应用的核心功能模块,包含新闻列表、详情、分类三个页面。
2.2 文件结构
features/news/
├── Index.ets # 导出 NewsHome, NewsDetail, NewsCategory
└── src/main/
├── ets/
│ ├── components/
│ │ ├── NewsHome.ets # 新闻主页
│ │ └── NewsListItem.ets # 新闻列表项
│ └── pages/
│ ├── NewsDetail.ets # 新闻详情(NavDestination)
│ └── NewsCategory.ets # 新闻分类(NavDestination)
└── resources/rawfile/
├── mockDataOne.json # Mock数据1
├── mockDataTwo.json # Mock数据2
└── news*.png/jpg # 新闻图片
2.3 关键代码讲解
2.3.1 NewsHome:新闻主页(@ComponentV2 + @Local)
typescript
@ComponentV2
export struct NewsHome {
// @Local 替代 V1 的 @State,管理组件内部状态
@Local newsDataSource: CommonDataSource<NewsItem> = new CommonDataSource<NewsItem>();
@Local titleIndex: number = 0;
@Local isRefreshing: boolean = false;
// @Consumer 替代 V1 的 @Consume,获取祖先组件的 @Provider
@Consumer('pageStack') pageStack!: NavPathStack;
aboutToAppear() {
let context = this.getUIContext().getHostContext() as Context;
let newsList = MockDataUtil.loadNewsFromRawfile(context, 'mockDataOne.json');
this.newsDataSource.setData(newsList);
}
build() {
Column() {
this.TitleBar() // 标题栏
this.CategoryBar() // 分类Tab
Refresh({ refreshing: $$this.isRefreshing }) {
List() {
this.BannerSwiper() // 轮播图
this.FastNewsBar() // 快讯
LazyForEach(this.newsDataSource, (item: NewsItem) => {
ListItem() {
NewsListItem({ item: item }) // @Param 传递数据
.onClick(() => {
this.pageStack.pushPathByName('NewsDetail', item);
})
}
}, (item: NewsItem) => item.newsId)
}
}
.onRefreshing(() => {
// 下拉刷新:切换Mock数据文件
let file = this.mockFlag ? 'mockDataTwo.json' : 'mockDataOne.json';
setTimeout(() => {
this.newsDataSource.setData(
MockDataUtil.loadNewsFromRawfile(context, file));
this.isRefreshing = false;
}, 1000);
})
}
}
}
V2装饰器映射:
| 功能 | V1写法 | V2写法 |
|---|---|---|
| 列表数据 | @State newsData |
@Local newsDataSource |
| Tab索引 | @State titleIndex |
@Local titleIndex |
| 下拉状态 | @State isRefreshing |
@Local isRefreshing |
| 导航栈 | @Consume('pageStack') |
@Consumer('pageStack') |
| 刷新数据绑定 | $newsData |
$$this.isRefreshing |
2.3.2 NewsListItem:列表项(@ComponentV2 + @Param)
typescript
@ComponentV2
export struct NewsListItem {
// @Param 替代 V1 的 @Prop,父→子单向传递
@Param item: NewsItem = new NewsItem('', '', '', '', '');
build() {
Column() {
Row() {
Column() {
// 标签 + 标题
Row() {
Text(this.item.category)
.backgroundColor('#E84026')
.fontColor(Color.White)
Text(this.item.newsTitle)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
// 摘要
Text(this.item.newsContent).maxLines(2)
// 时间
Text(this.item.newsTime)
}
// 图片
if (this.item.newsImage !== '') {
Image($rawfile(`${this.item.newsImage}`))
}
}
Divider()
}
}
}
@Param vs @Prop :@Param是V2版本的@Prop,语义更明确------"这是一个参数"。
2.3.3 NewsDetail:新闻详情(NavDestination + @Consumer)
typescript
@ComponentV2
export struct NewsDetail {
@Consumer('pageStack') pageStack!: NavPathStack;
@Local newsItem: NewsItem = new NewsItem('', '新闻详情', '', '', '');
@Local heartFlag: boolean = false;
@Local collectFlag: boolean = false;
aboutToAppear() {
// 通过 getParamByName 获取路由参数
let param = this.pageStack.getParamByName('NewsDetail');
if (param) {
this.newsItem = param as NewsItem;
}
}
build() {
NavDestination() {
Column() {
// 顶部:返回按钮
// 中部:新闻标题 + 正文 + 图片(Scroll包裹)
// 底部:评论输入 + 点赞 + 收藏
}
}
.hideTitleBar(true) // 隐藏系统标题栏,使用自定义标题
}
}
路由参数获取 :pageStack.getParamByName('NewsDetail') 获取跳转时传入的 NewsItem 对象。
三、基础特性层:features/video 模块
3.1 关键代码:VideoHome(Grid + LazyForEach)
typescript
@ComponentV2
export struct VideoHome {
@Local videoList: CommonDataSource<VideoItem> = new CommonDataSource<VideoItem>();
@Consumer('pageStack') pageStack!: NavPathStack;
aboutToAppear() {
// 用 VIDEO_COVER_LIST 初始化20个视频
let items: VideoItem[] = [];
let covers = CommonConstants.VIDEO_COVER_LIST;
for (let i = 0; i < covers.length; i++) {
items.push(new VideoItem(`video_${i}`, `精彩视频 ${i+1}`, covers[i]));
}
this.videoList.setData(items);
}
build() {
Column() {
Tabs() {
ForEach(this.categories, (item: string) => {
TabContent() {
Grid() {
LazyForEach(this.videoList, (video: VideoItem) => {
GridItem() {
Column() {
Image(video.coverImage)
.height(100).borderRadius(8)
Text(video.title).maxLines(1)
}
.onClick(() => {
this.pageStack.pushPathByName('VideoPlayer', video);
})
}
}, (item: VideoItem) => item.videoId)
}
.columnsTemplate('1fr 1fr') // 两列Grid
}
.tabBar(item)
})
}
}
}
}
Grid双列布局 :columnsTemplate('1fr 1fr') 实现等宽双列,LazyForEach 保证大量视频时的滚动性能。
四、基础特性层:features/live 模块
4.1 关键代码:LiveHome(三Tab直播列表)
typescript
@ComponentV2
export struct LiveHome {
@Local followList: LiveItem[] = [];
@Local liveList: LiveItem[] = [];
@Local hotList: LiveItem[] = [];
aboutToAppear() {
// 按分类筛选LIVE_DATA
this.followList = LIVE_DATA.filter(
(item: LiveItem) => item.category === '我的关注');
this.liveList = LIVE_DATA.filter(
(item: LiveItem) => item.category === '今日直播');
this.hotList = LIVE_DATA.filter(
(item: LiveItem) => item.category === '热门推荐');
}
@Builder
LiveCard(item: LiveItem) {
Row() {
Image(item.coverImage).width(80).height(80).borderRadius(8)
Column() {
Text(item.title).fontWeight(FontWeight.Medium)
Text(item.content).fontColor(StyleConstants.TEXT_SECONDARY)
Text('直播中') // 直播状态标签
.backgroundColor('#E84026').fontColor(Color.White)
}
}
}
build() {
Column() {
Tabs() {
TabContent() { this.FollowTab() }.tabBar('关注')
TabContent() { this.LiveTab() }.tabBar('直播')
TabContent() { this.HotTab() }.tabBar('热门')
}
}
}
}
@Builder复用 :LiveCard作为@Builder函数,在三个Tab中复用相同的卡片样式。
五、基础特性层:features/personal 模块
5.1 关键代码:PersonalHome + LoginPage
5.1.1 PersonalHome:个人中心
typescript
@ComponentV2
export struct PersonalHome {
@Local user: UserInfo = new UserInfo();
@Consumer('pageStack') pageStack!: NavPathStack;
aboutToAppear() {
// 从AppStorage读取登录状态
let account = AppStorage.get<string>('userAccount') ?? '';
if (account !== '') {
this.user.account = account;
this.user.nickname = `用户${account.slice(-4)}`;
this.user.isLoggedIn = true;
}
}
build() {
Scroll() {
Column() {
// 红色背景 + 头像 + 用户名
Column() {
Text(this.user.isLoggedIn ? this.user.nickname : '登录/注册')
.fontColor(Color.White)
.onClick(() => {
if (!this.user.isLoggedIn) {
this.pageStack.pushPathByName('LoginPage', null);
}
})
}
.backgroundColor('#E84026')
// 功能Grid(订阅/历史/评论/收藏)
// 更多功能列表
// 退出登录按钮
}
}
}
}
5.1.2 LoginPage:手机号+验证码登录
typescript
@ComponentV2
export struct LoginPage {
@Consumer('pageStack') pageStack!: NavPathStack;
@Local phone: string = '';
@Local verifyCode: string = '';
@Local countdown: number = 0;
build() {
NavDestination() {
Column() {
// 手机号输入
TextInput({ placeholder: '请输入手机号' })
.type(InputType.Number)
.onChange((v: string) => { this.phone = v; })
// 验证码输入 + 获取按钮(带倒计时)
Button(this.countdown > 0 ? `${this.countdown}s` : '获取验证码')
.onClick(() => {
this.countdown = 60;
let timer = setInterval(() => {
this.countdown--;
if (this.countdown <= 0) clearInterval(timer);
}, 1000);
})
// 登录按钮
Button('登录')
.enabled(this.phone.length === 11 && this.verifyCode.length >= 4)
.onClick(() => {
AppStorage.setOrCreate('userAccount', this.phone);
this.pageStack.pop();
})
}
}
.hideTitleBar(true)
}
}
AppStorage全局状态 :登录成功后将手机号存入AppStorage,PersonalHome在aboutToAppear中读取。
六、基础特性层:features/service 模块
typescript
@ComponentV2
export struct ServiceHome {
@Local currentIndex: number = 0;
@Builder
ServiceCard(title: string, description: string) {
Column() {
Text(title).fontWeight(FontWeight.Bold)
Text(description).maxLines(2)
}
.backgroundColor(Color.White)
.borderRadius(8)
.shadow({ radius: 2, color: '#0D000000' })
}
build() {
Column() {
Tabs() {
ForEach(['党媒云', '社区融', '法律服务', '健康服务'], (item: string) => {
TabContent() {
Scroll() {
Column({ space: 12 }) {
this.ServiceCard(`${item}平台`, '提供优质内容和服务...')
this.ServiceCard(`${item}矩阵`, '整合多渠道资源...')
}
}
}.tabBar(item)
})
}
}
}
}
七、产品定制层:product/phone 模块
7.1 模块定位
手机端入口模块(HAP),包含启动页、主页、路由配置。
7.2 关键代码
7.2.1 启动页 Index.ets
typescript
@Entry
@ComponentV2
struct Index {
aboutToAppear() {
setTimeout(() => {
this.getUIContext().getRouter().replaceUrl({ url: 'pages/MainPage' });
}, 1500);
}
build() {
Stack() {
Image($r('app.media.splash_bg')).objectFit(ImageFit.Cover)
Column() {
Text('华为日报').fontSize(32).fontColor(Color.White)
Text('每日精选,为你呈现').fontSize(14)
}
}
}
}
7.2.2 主页 MainPage.ets(HdsTabs核心)
typescript
import { HdsTabs } from '@kit.UIDesignKit';
import { SymbolGlyphModifier } from '@kit.ArkUI';
// BottomTabBarStyle 是全局类,不需要导入
@Entry
@ComponentV2
struct MainPage {
@Local currentTabIndex: number = 0;
@Provider('pageStack') pageStack: NavPathStack = new NavPathStack();
private buildTabIcon(symbol: Resource, selected: boolean): SymbolGlyphModifier {
return new SymbolGlyphModifier(symbol)
.fontColor([selected ? $r('app.color.focus_color') : $r('app.color.placeholder_color')]);
}
private buildTabBar(symbol: Resource, label: string): BottomTabBarStyle {
return new BottomTabBarStyle({
normal: this.buildTabIcon(symbol, false),
selected: this.buildTabIcon(symbol, true)
}, label).labelStyle({
unselectedColor: $r('app.color.placeholder_color'),
selectedColor: $r('app.color.focus_color')
});
}
build() {
Navigation(this.pageStack) {
Column() {
HdsTabs({ index: this.currentTabIndex }) {
TabContent() { NewsHome() }.tabBar(this.buildTabBar($r('sys.symbol.house'), '首页'))
TabContent() { VideoHome() }.tabBar(this.buildTabBar($r('sys.symbol.video'), '视频'))
TabContent() { LiveHome() }.tabBar(this.buildTabBar($r('sys.symbol.video_badge_adiowaves'), '直播'))
TabContent() { PersonalHome() }.tabBar(this.buildTabBar($r('sys.symbol.person'), '我的'))
}
.barOverlap(true)
.barPosition(BarPosition.End)
.vertical(false)
.barFloatingStyle({ barBottomMargin: 16 })
.onChange((index: number) => { this.currentTabIndex = index; })
.layoutWeight(1)
}
}
.hideTitleBar(true)
.navDestination(this.pageMap)
.mode(NavigationMode.Stack)
}
@Builder
pageMap(name: string, param: Object) {
if (name === 'NewsDetail') { NewsDetail() }
else if (name === 'NewsCategory') { NewsCategory() }
else if (name === 'VideoPlayer') { VideoPlayer() }
else if (name === 'LoginPage') { LoginPage() }
else if (name === 'MyComments') { MyComments() }
}
}
架构核心:
@Provider('pageStack')将导航栈注入整棵组件树HdsTabs+BottomTabBarStyle(全局类)+SymbolGlyphModifier系统图标,实现带图标的悬浮底部导航buildTabBar()封装图标 + 文本 +.labelStyle()颜色配置,确保图标和文本选中颜色一致navDestination统一处理所有子页面路由
八、V2装饰器速查表
| 装饰器 | 用途 | 代码位置 |
|---|---|---|
@ComponentV2 |
组件声明 | 所有组件 |
@Local |
组件本地状态 | NewsHome.titleIndex, PersonalHome.user |
@Param |
父→子参数 | NewsListItem.item |
@Event |
子→父事件 | (预留,当前用onClick替代) |
@Provider |
跨层级提供 | MainPage.pageStack |
@Consumer |
跨层级消费 | 所有子页面的pageStack |
@ObservedV2 |
类观测 | NewsItem, VideoItem, LiveItem, UserInfo |
@Trace |
属性追踪 | 所有模型类的每个属性 |
九、常见问题
Q1: @ComponentV2 和 @Component 能混用吗?
可以,但不推荐在同一组件树中混用V1和V2装饰器。@Param等V2装饰器只能 在@ComponentV2组件中使用。
Q2: HdsTabs 与标准 Tabs 有什么区别?
HdsTabs 来自 @kit.UIDesignKit,支持悬浮页签(.barOverlap(true) + .barFloatingStyle())和沉浸光感材质效果。本项目已使用 HdsTabs,关键要点:BottomTabBarStyle 是全局类 不需要导入,.barOverlap(true) 使页签栏悬浮覆盖内容。
Q3: @Provider/@Consumer 的key如何匹配?
@Provider('pageStack') 和 @Consumer('pageStack') 的字符串key必须完全一致。
Q4: 为什么启动页用router而不是NavPathStack?
启动页Index和主页MainPage是同级的@Entry页面,不在Navigation容器内,所以使用router进行页面级跳转。
本文是整体实现指南的补充,建议配合阅读。
如有问题,欢迎在评论区交流!