《新闻资讯》九、应用各分层模块实现指南

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 [];
    }
  }
}

流程rawfilegetRawFileContentSyncTextDecoderJSON.parseNewsItem[]

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进行页面级跳转。


本文是整体实现指南的补充,建议配合阅读。

如有问题,欢迎在评论区交流!

相关推荐
小雨下雨的雨2 小时前
HarmonyOS V2状态管理深度解析:列表数据与分页架构
华为·架构·harmonyos·鸿蒙
console.log('npc')12 小时前
AI前端工程与生成式UI学习路线
前端·人工智能·ui
坚果派·白晓明12 小时前
【鸿蒙PC】SDL3 适配:AtomCode + Skills 快速集成 NAPI 测试工具
c++·华为·ai编程·harmonyos·atomcode
YM52e13 小时前
男孩子在外自我保护指南——用鸿蒙 ArkTS 构建交互式安全教育应用
学习·安全·华为·harmonyos·鸿蒙·鸿蒙系统
祭曦念14 小时前
古诗小集开发实战:从零开发一款 HarmonyOS 古诗鉴赏应用
pytorch·深度学习·harmonyos
全栈若城15 小时前
HarmonyOS AppUtil 应用配置控制:颜色模式/灰度/字体/语言/键盘避让详解
华为·harmonyos·arkts·harmonyos6·键盘避让·字体缩放
FrameNotWork15 小时前
HarmonyOS 6.1 Lottie动画集成完全指南:从踩坑到精通
华为·harmonyos