📖 鸿蒙NEXT开发实战系列 | 第36篇 | 实战篇 🎯 适合人群:完成鸿蒙基础学习的开发者 ⏰ 阅读时间:约20分钟 | 💻 开发环境:DevEco Studio 5.0+

简介:学了这么多知识点,该做个完整项目了!本系列上篇带你搭建一个新闻资讯App的完整架构,实现首页新闻列表(下拉刷新+上拉加载)和新闻详情页,涵盖网络请求、数据解析、页面路由全链路。


导航链接


目录


一、项目概述与功能规划

在学习了鸿蒙开发的基础知识后,我们需要一个完整的实战项目来串联所学内容。本教程将带你从零搭建一个新闻资讯App,实现以下核心功能:

功能模块

模块

功能点

说明

首页

新闻列表

使用List组件展示新闻卡片

首页

下拉刷新

Refresh组件实现下拉刷新

首页

上拉加载

List滑动到底部触发加载更多

详情页

富文本展示

WebView加载新闻详情

网络

请求封装

基于axios封装统一请求模块

技术栈选择

  • UI框架:ArkUI声明式开发

  • 网络请求:@ohos/axios

  • 数据管理:AppStorage + 自定义状态管理

  • 路由导航:Navigation + NavRouter


二、项目架构设计

良好的架构是项目成功的基础。我们采用分层架构设计,让代码结构清晰、易于维护。

目录结构

NewsApp/
├── entry/src/main/ets/
│   ├── entryability/
│   │   └── EntryAbility.ets          # 应用入口
│   ├── pages/
│   │   ├── Index.ets                 # 主页面(使用Tabs)
│   │   ├── HomeTab.ets               # 首页Tab内容
│   │   └── DetailPage.ets            # 新闻详情页
│   ├── components/
│   │   ├── NewsCard.ets              # 新闻卡片组件
│   │   ├── NewsList.ets              # 新闻列表组件
│   │   └── EmptyView.ets             # 空状态视图
│   ├── model/
│   │   └── NewsModel.ets             # 新闻数据模型
│   ├── network/
│   │   ├── HttpUtils.ets             # 网络请求工具类
│   │   └── ApiConfig.ets             # API配置
│   ├── services/
│   │   └── NewsService.ets           # 新闻业务服务
│   ├── viewmodel/
│   │   └── HomeViewModel.ets         # 首页ViewModel
│   └── utils/
│       ├── DateUtils.ets             # 日期工具
│       └── Constants.ets             # 常量定义
└── module.json5

架构图示

┌─────────────────────────────────────────────────────┐
│                    UI层 (Pages)                      │
│   Index.ets → HomeTab.ets → NewsList.ets            │
│                    ↓                                  │
│              NewsCard.ets                            │
└─────────────────────┬───────────────────────────────┘
                      │
┌─────────────────────▼───────────────────────────────┐
│               ViewModel层                            │
│            HomeViewModel.ets                         │
│        (状态管理 + 业务逻辑处理)                        │
└─────────────────────┬───────────────────────────────┘
                      │
┌─────────────────────▼───────────────────────────────┐
│                Service层                             │
│            NewsService.ets                           │
│            (业务服务封装)                              │
└─────────────────────┬───────────────────────────────┘
                      │
┌─────────────────────▼───────────────────────────────┐
│               Network层                              │
│        HttpUtils.ets + ApiConfig.ets                │
│            (网络请求封装)                              │
└─────────────────────────────────────────────────────┘

数据模型定义

首先定义新闻数据模型,对应API返回的数据结构:

// NewsModel.ets

/**
 * 新闻数据模型
 * 对应API返回的新闻数据结构
 */
export interface NewsItem {
  id: string;              // 新闻ID
  title: string;           // 新闻标题
  summary: string;         // 新闻摘要
  imageUrl: string;        // 封面图片URL
  source: string;          // 新闻来源
  publishTime: string;     // 发布时间
  category: string;        // 新闻分类
  url: string;             // 详情页URL
  readCount: number;       // 阅读量
}

/**
 * API响应数据结构
 */
export interface ApiResponse<T> {
  code: number;            // 状态码
  message: string;         // 提示信息
  data: T;                 // 响应数据
}

/**
 * 新闻列表响应
 */
export interface NewsListResponse {
  list: NewsItem[];        // 新闻列表
  total: number;           // 总数
  page: number;            // 当前页码
  pageSize: number;        // 每页数量
}

/**
 * 新闻分类枚举
 */
export enum NewsCategory {
  ALL = 'all',             // 全部
  TECH = 'tech',           // 科技
  FINANCE = 'finance',     // 财经
  SPORTS = 'sports',       // 体育
  ENTERTAINMENT = 'entertainment'  // 娱乐
}

三、网络请求封装

网络请求是App开发的基础能力,我们基于axios封装一个统一的请求工具类。

安装axios

首先在项目根目录执行安装命令:

ohpm install @ohos/axios

API配置

// ApiConfig.ets

/**
 * API配置
 * 集中管理所有接口地址
 */
export class ApiConfig {
  // 基础URL(使用公开的新闻API)
  static readonly BASE_URL: string = 'https://api.example.com/v1';

  // 新闻列表接口
  static readonly NEWS_LIST: string = '/news/list';

  // 新闻详情接口
  static readonly NEWS_DETAIL: string = '/news/detail';

  // 请求超时时间(毫秒)
  static readonly TIMEOUT: number = 15000;

  // 默认每页数量
  static readonly PAGE_SIZE: number = 20;
}

HTTP工具类封装

// HttpUtils.ets

import axios, { AxiosResponse, AxiosError } from '@ohos/axios';
import { ApiConfig } from './ApiConfig';

/**
 * HTTP请求工具类
 * 封装axios,提供统一的请求方法
 */
export class HttpUtils {
  private static instance: axios.AxiosInstance;

  /**
   * 获取axios实例(单例模式)
   */
  private static getInstance(): axios.AxiosInstance {
    if (!HttpUtils.instance) {
      HttpUtils.instance = axios.create({
        baseURL: ApiConfig.BASE_URL,
        timeout: ApiConfig.TIMEOUT,
        headers: {
          'Content-Type': 'application/json'
        }
      });

      // 请求拦截器
      HttpUtils.instance.interceptors.request.use(
        (config) => {
          console.info(`[HTTP] 请求: ${config.method?.toUpperCase()} ${config.url}`);
          return config;
        },
        (error: AxiosError) => {
          console.error(`[HTTP] 请求错误: ${error.message}`);
          return Promise.reject(error);
        }
      );

      // 响应拦截器
      HttpUtils.instance.interceptors.response.use(
        (response: AxiosResponse) => {
          console.info(`[HTTP] 响应: ${response.status} ${response.config.url}`);
          return response;
        },
        (error: AxiosError) => {
          console.error(`[HTTP] 响应错误: ${error.message}`);
          return Promise.reject(error);
        }
      );
    }
    return HttpUtils.instance;
  }

  /**
   * GET请求
   * @param url 请求地址
   * @param params 查询参数
   * @returns Promise<any>
   */
  static async get<T>(url: string, params?: object): Promise<T> {
    try {
      const response = await HttpUtils.getInstance().get<T>(url, { params });
      return response.data;
    } catch (error) {
      console.error(`[HTTP] GET请求失败: ${url}`, error);
      throw error;
    }
  }

  /**
   * POST请求
   * @param url 请求地址
   * @param data 请求体
   * @returns Promise<any>
   */
  static async post<T>(url: string, data?: object): Promise<T> {
    try {
      const response = await HttpUtils.getInstance().post<T>(url, data);
      return response.data;
    } catch (error) {
      console.error(`[HTTP] POST请求失败: ${url}`, error);
      throw error;
    }
  }
}

新闻服务层

// NewsService.ets

import { HttpUtils } from '../network/HttpUtils';
import { ApiConfig } from '../network/ApiConfig';
import { NewsItem, ApiResponse, NewsListResponse } from '../model/NewsModel';

/**
 * 新闻业务服务
 * 封装新闻相关的业务接口
 */
export class NewsService {
  /**
   * 获取新闻列表
   * @param page 页码
   * @param pageSize 每页数量
   * @param category 新闻分类
   * @returns Promise<NewsItem[]>
   */
  static async getNewsList(
    page: number = 1,
    pageSize: number = ApiConfig.PAGE_SIZE,
    category: string = 'all'
  ): Promise<NewsItem[]> {
    try {
      const response = await HttpUtils.get<ApiResponse<NewsListResponse>>(
        ApiConfig.NEWS_LIST,
        { page, pageSize, category }
      );

      if (response.code === 200) {
        return response.data.list;
      }
      console.error(`获取新闻列表失败: ${response.message}`);
      return [];
    } catch (error) {
      console.error('获取新闻列表异常:', error);
      // 返回模拟数据用于开发测试
      return NewsService.getMockNewsList(page, pageSize);
    }
  }

  /**
   * 获取新闻详情
   * @param newsId 新闻ID
   * @returns Promise<NewsItem | null>
   */
  static async getNewsDetail(newsId: string): Promise<NewsItem | null> {
    try {
      const response = await HttpUtils.get<ApiResponse<NewsItem>>(
        `${ApiConfig.NEWS_DETAIL}/${newsId}`
      );

      if (response.code === 200) {
        return response.data;
      }
      return null;
    } catch (error) {
      console.error('获取新闻详情异常:', error);
      return null;
    }
  }

  /**
   * 生成模拟新闻数据(开发测试用)
   */
  static getMockNewsList(page: number, pageSize: number): NewsItem[] {
    const mockList: NewsItem[] = [];
    const startIndex = (page - 1) * pageSize;

    const categories = ['科技', '财经', '体育', '娱乐', '社会'];
    const sources = ['新华社', '人民日报', '央视新闻', '环球时报', '澎湃新闻'];

    for (let i = 0; i < pageSize; i++) {
      const index = startIndex + i;
      mockList.push({
        id: `news_${index}`,
        title: `鸿蒙NEXT生态系统快速发展,开发者数量突破${index * 1000 + 500}万`,
        summary: `随着鸿蒙NEXT的正式发布,越来越多的开发者加入鸿蒙生态,共同构建万物互联的智能世界...`,
        imageUrl: `https://picsum.photos/400/200?random=${index}`,
        source: sources[index % sources.length],
        publishTime: new Date(Date.now() - index * 3600000).toISOString(),
        category: categories[index % categories.length],
        url: `https://example.com/news/${index}`,
        readCount: Math.floor(Math.random() * 100000)
      });
    }
    return mockList;
  }
}

四、首页新闻列表实现

首页是App的门面,我们将实现一个功能完善的新闻列表,支持下拉刷新和上拉加载更多。

主页面布局

// Index.ets

import { HomeTab } from './HomeTab';

/**
 * 主页面
 * 使用Tabs实现底部导航
 */
@Entry
@Component
struct Index {
  @State currentIndex: number = 0;

  @Builder
  tabBuilder(title: string, index: number, icon: string, iconSelected: string) {
    Column() {
      Image(this.currentIndex === index ? iconSelected : icon)
        .width(24)
        .height(24)
        .margin({ bottom: 4 })
      Text(title)
        .fontSize(10)
        .fontColor(this.currentIndex === index ? '#007DFF' : '#999999')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }

  build() {
    Tabs({ barPosition: BarPosition.End }) {
      // 首页Tab
      TabContent() {
        HomeTab()
      }
      .tabBar(this.tabBuilder('首页', 0, $r('app.media.tab_home'), $r('app.media.tab_home_selected')))

      // 视频Tab(占位)
      TabContent() {
        Column() {
          Text('视频').fontSize(20)
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
      }
      .tabBar(this.tabBuilder('视频', 1, $r('app.media.tab_video'), $r('app.media.tab_video_selected')))

      // 我的Tab(占位)
      TabContent() {
        Column() {
          Text('我的').fontSize(20)
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
      }
      .tabBar(this.tabBuilder('我的', 2, $r('app.media.tab_mine'), $r('app.media.tab_mine_selected')))
    }
    .barWidth('100%')
    .barHeight(56)
    .onChange((index: number) => {
      this.currentIndex = index;
    })
  }
}

首页Tab内容

// HomeTab.ets

import { NewsList } from '../components/NewsList';
import { NewsCategory } from '../model/NewsModel';

/**
 * 首页Tab内容
 * 包含分类导航和新闻列表
 */
@Component
export struct HomeTab {
  @State selectedCategory: string = NewsCategory.ALL;

  // 分类列表
  private categories: string[] = [
    NewsCategory.ALL,
    NewsCategory.TECH,
    NewsCategory.FINANCE,
    NewsCategory.SPORTS,
    NewsCategory.ENTERTAINMENT
  ];

  // 分类显示名称映射
  private categoryNameMap: Record<string, string> = {
    [NewsCategory.ALL]: '推荐',
    [NewsCategory.TECH]: '科技',
    [NewsCategory.FINANCE]: '财经',
    [NewsCategory.SPORTS]: '体育',
    [NewsCategory.ENTERTAINMENT]: '娱乐'
  };

  @Builder
  categoryItem(category: string) {
    Column() {
      Text(this.categoryNameMap[category])
        .fontSize(14)
        .fontColor(this.selectedCategory === category ? '#007DFF' : '#333333')
        .fontWeight(this.selectedCategory === category ? FontWeight.Bold : FontWeight.Normal)
    }
    .height(40)
    .padding({ left: 16, right: 16 })
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.selectedCategory = category;
    })
  }

  build() {
    Column() {
      // 顶部标题栏
      Row() {
        Text('新闻资讯')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
      }
      .width('100%')
      .height(48)
      .padding({ left: 16 })
      .justifyContent(FlexAlign.Start)
      .backgroundColor('#FFFFFF')

      // 分类导航
      Scroll() {
        Row() {
          ForEach(this.categories, (category: string) => {
            this.categoryItem(category)
          })
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .width('100%')
      .height(40)
      .backgroundColor('#FFFFFF')
      .scrollBar(BarState.Off)

      // 分割线
      Divider()
        .color('#F0F0F0')
        .height(1)

      // 新闻列表
      NewsList({ category: this.selectedCategory })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

新闻卡片组件

// NewsCard.ets

import { NewsItem } from '../model/NewsModel';
import { DateUtils } from '../utils/DateUtils';

/**
 * 新闻卡片组件
 * 展示单条新闻信息
 */
@Component
export struct NewsCard {
  news: NewsItem = {} as NewsItem;
  onClick?: (news: NewsItem) => void;

  build() {
    Row() {
      // 左侧文字内容
      Column() {
        // 新闻标题
        Text(this.news.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ bottom: 8 })

        // 底部信息栏
        Row() {
          // 新闻来源
          Text(this.news.source)
            .fontSize(12)
            .fontColor('#999999')

          Blank()

          // 发布时间
          Text(DateUtils.getRelativeTime(this.news.publishTime))
            .fontSize(12)
            .fontColor('#999999')

          // 阅读量
          if (this.news.readCount > 0) {
            Text(`${this.formatReadCount(this.news.readCount)}阅读`)
              .fontSize(12)
              .fontColor('#999999')
              .margin({ left: 8 })
          }
        }
        .width('100%')
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .padding({ right: 12 })

      // 右侧封面图片
      Image(this.news.imageUrl)
        .width(120)
        .height(80)
        .borderRadius(4)
        .objectFit(ImageFit.Cover)
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .onClick(() => {
      if (this.onClick) {
        this.onClick(this.news);
      }
    })
  }

  /**
   * 格式化阅读量
   */
  private formatReadCount(count: number): string {
    if (count >= 10000) {
      return (count / 10000).toFixed(1) + 'w';
    }
    return count.toString();
  }
}

新闻列表组件(下拉刷新 + 上拉加载)

// NewsList.ets

import { NewsCard } from './NewsCard';
import { NewsItem } from '../model/NewsModel';
import { NewsService } from '../services/NewsService';
import { router } from '@kit.ArkUI';

/**
 * 新闻列表组件
 * 支持下拉刷新和上拉加载更多
 */
@Component
export struct NewsList {
  @State newsList: NewsItem[] = [];
  @State isRefreshing: boolean = false;
  @State isLoadingMore: boolean = false;
  @State currentPage: number = 1;
  @State hasMore: boolean = true;
  @State isFirstLoad: boolean = true;

  // 当前分类
  category: string = 'all';

  // 每页数量
  private pageSize: number = 20;

  aboutToAppear() {
    this.loadNewsList();
  }

  /**
   * 加载新闻列表
   * @param isRefresh 是否是刷新操作
   */
  async loadNewsList(isRefresh: boolean = false) {
    if (isRefresh) {
      this.currentPage = 1;
      this.hasMore = true;
    }

    try {
      const newsList = await NewsService.getNewsList(
        this.currentPage,
        this.pageSize,
        this.category
      );

      if (isRefresh) {
        this.newsList = newsList;
      } else {
        this.newsList = [...this.newsList, ...newsList];
      }

      // 判断是否还有更多数据
      this.hasMore = newsList.length >= this.pageSize;
      this.currentPage++;
    } catch (error) {
      console.error('加载新闻列表失败:', error);
    } finally {
      this.isRefreshing = false;
      this.isLoadingMore = false;
      this.isFirstLoad = false;
    }
  }

  /**
   * 下拉刷新回调
   */
  onRefresh() {
    this.isRefreshing = true;
    this.loadNewsList(true);
  }

  /**
   * 上拉加载更多回调
   */
  onLoadMore() {
    if (this.isLoadingMore || !this.hasMore) {
      return;
    }
    this.isLoadingMore = true;
    this.loadNewsList(false);
  }

  /**
   * 跳转到详情页
   */
  goToDetail(news: NewsItem) {
    router.pushUrl({
      url: 'pages/DetailPage',
      params: {
        newsId: news.id,
        newsTitle: news.title,
        newsUrl: news.url
      }
    });
  }

  build() {
    Stack() {
      // 新闻列表
      List({ space: 1 }) {
        // 下拉刷新容器
        Refresh({ refreshing: $$this.isRefreshing }) {
          List({ space: 1 }) {
            // 新闻卡片列表
            ForEach(this.newsList, (news: NewsItem) => {
              ListItem() {
                NewsCard({
                  news: news,
                  onClick: (clickedNews: NewsItem) => {
                    this.goToDetail(clickedNews);
                  }
                })
              }
            })

            // 加载更多提示
            if (this.isLoadingMore) {
              ListItem() {
                Row() {
                  LoadingProgress()
                    .width(20)
                    .height(20)
                    .margin({ right: 8 })
                  Text('加载中...')
                    .fontSize(14)
                    .fontColor('#999999')
                }
                .width('100%')
                .height(50)
                .justifyContent(FlexAlign.Center)
              }
            }

            // 没有更多数据提示
            if (!this.hasMore && this.newsList.length > 0) {
              ListItem() {
                Text('—— 已经到底了 ——')
                  .fontSize(14)
                  .fontColor('#999999')
                  .width('100%')
                  .height(50)
                  .textAlign(TextAlign.Center)
              }
            }
          }
          .width('100%')
          .height('100%')
          .divider({ color: '#F0F0F0', height: 1 })
          .onReachEnd(() => {
            this.onLoadMore();
          })
        }
        .onRefreshing(() => {
          this.onRefresh();
        })
        .width('100%')
        .height('100%')
      }
      .width('100%')
      .height('100%')
      .edgeEffect(EdgeEffect.Spring)

      // 空状态视图
      if (this.newsList.length === 0 && !this.isFirstLoad) {
        Column() {
          Image($r('app.media.empty'))
            .width(120)
            .height(120)
            .margin({ bottom: 16 })
          Text('暂无新闻内容')
            .fontSize(16)
            .fontColor('#999999')
            .margin({ bottom: 16 })
          Button('刷新重试')
            .fontSize(14)
            .fontColor('#FFFFFF')
            .backgroundColor('#007DFF')
            .borderRadius(20)
            .height(36)
            .onClick(() => {
              this.loadNewsList(true);
            })
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
      }

      // 首次加载状态
      if (this.isFirstLoad) {
        Column() {
          LoadingProgress()
            .width(40)
            .height(40)
            .margin({ bottom: 16 })
          Text('加载中...')
            .fontSize(14)
            .fontColor('#999999')
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
  }
}

日期工具类

// DateUtils.ets

/**
 * 日期工具类
 */
export class DateUtils {
  /**
   * 获取相对时间描述
   * @param dateString 日期字符串
   * @returns 相对时间描述
   */
  static getRelativeTime(dateString: string): string {
    const date = new Date(dateString);
    const now = new Date();
    const diff = now.getTime() - date.getTime();

    const minutes = Math.floor(diff / 60000);
    const hours = Math.floor(diff / 3600000);
    const days = Math.floor(diff / 86400000);

    if (minutes < 1) {
      return '刚刚';
    } else if (minutes < 60) {
      return `${minutes}分钟前`;
    } else if (hours < 24) {
      return `${hours}小时前`;
    } else if (days < 7) {
      return `${days}天前`;
    } else {
      return `${date.getMonth() + 1}-${date.getDate()}`;
    }
  }

  /**
   * 格式化日期
   * @param date 日期对象
   * @param format 格式字符串
   * @returns 格式化后的日期字符串
   */
  static format(date: Date, format: string = 'YYYY-MM-DD HH:mm:ss'): string {
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const day = String(date.getDate()).padStart(2, '0');
    const hours = String(date.getHours()).padStart(2, '0');
    const minutes = String(date.getMinutes()).padStart(2, '0');
    const seconds = String(date.getSeconds()).padStart(2, '0');

    return format
      .replace('YYYY', String(year))
      .replace('MM', month)
      .replace('DD', day)
      .replace('HH', hours)
      .replace('mm', minutes)
      .replace('ss', seconds);
  }
}

五、新闻详情页实现

新闻详情页使用WebView加载网页内容,实现完整的新闻阅读体验。

详情页代码

// DetailPage.ets

import { webview } from '@kit.ArkWeb';
import { router } from '@kit.ArkUI';

/**
 * 新闻详情页
 * 使用WebView展示新闻内容
 */
@Entry
@Component
struct DetailPage {
  @State newsTitle: string = '';
  @State newsUrl: string = '';
  @State isLoading: boolean = true;
  @State loadProgress: number = 0;

  // WebView控制器
  private webviewController: webview.WebviewController = new webview.WebviewController();

  aboutToAppear() {
    // 获取路由参数
    const params = router.getParams() as Record<string, string>;
    if (params) {
      this.newsTitle = params.newsTitle || '新闻详情';
      this.newsUrl = params.newsUrl || 'https://www.example.com';
    }
  }

  build() {
    Column() {
      // 顶部导航栏
      Row() {
        // 返回按钮
        Image($r('app.media.back'))
          .width(24)
          .height(24)
          .onClick(() => {
            router.back();
          })

        // 标题
        Text(this.newsTitle)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ left: 12 })
          .layoutWeight(1)

        // 分享按钮
        Image($r('app.media.share'))
          .width(24)
          .height(24)
          .onClick(() => {
            // 分享功能(下篇实现)
            console.info('分享功能开发中...');
          })
      }
      .width('100%')
      .height(48)
      .padding({ left: 16, right: 16 })
      .backgroundColor('#FFFFFF')
      .border({ width: { bottom: 1 }, color: '#F0F0F0' })

      // 加载进度条
      if (this.isLoading) {
        Progress({ value: this.loadProgress, total: 100, type: ProgressType.Linear })
          .width('100%')
          .height(2)
          .color('#007DFF')
      }

      // WebView容器
      Web({ src: this.newsUrl, controller: this.webviewController })
        .width('100%')
        .layoutWeight(1)
        .javaScriptAccess(true)
        .onPageBegin((event) => {
          this.isLoading = true;
          console.info('页面开始加载:', event.url);
        })
        .onPageEnd((event) => {
          this.isLoading = false;
          console.info('页面加载完成:', event.url);
        })
        .onProgressChange((event) => {
          this.loadProgress = event.newProgress;
        })
        .onErrorReceive((event) => {
          this.isLoading = false;
          console.error('页面加载错误:', event.errorInfo);
        })

      // 底部操作栏
      Row() {
        // 评论按钮
        Column() {
          Image($r('app.media.comment'))
            .width(24)
            .height(24)
          Text('评论')
            .fontSize(10)
            .fontColor('#666666')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .onClick(() => {
          console.info('评论功能开发中...');
        })

        // 点赞按钮
        Column() {
          Image($r('app.media.like'))
            .width(24)
            .height(24)
          Text('点赞')
            .fontSize(10)
            .fontColor('#666666')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .onClick(() => {
          console.info('点赞功能开发中...');
        })

        // 收藏按钮
        Column() {
          Image($r('app.media.collect'))
            .width(24)
            .height(24)
          Text('收藏')
            .fontSize(10)
            .fontColor('#666666')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .onClick(() => {
          console.info('收藏功能开发中...');
        })

        // 字体大小按钮
        Column() {
          Image($r('app.media.font_size'))
            .width(24)
            .height(24)
          Text('字号')
            .fontSize(10)
            .fontColor('#666666')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .onClick(() => {
          console.info('字号设置功能开发中...');
        })
      }
      .width('100%')
      .height(56)
      .backgroundColor('#FFFFFF')
      .border({ width: { top: 1 }, color: '#F0F0F0' })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }
}

网络安全配置

为了让WebView能够正常加载网页,需要在module.json5中配置网络安全:

// module.json5
{
  "module": {
    "name": "entry",
    "type": "entry",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets"
      }
    ],
    "networkStack": {
      "cleartextTraffic": true,
      "securityConfig": {
        "domain": [
          {
            "name": "api.example.com",
            "cleartextPermitted": true
          }
        ]
      }
    }
  }
}

六、页面路由配置

在鸿蒙中,页面路由需要在配置文件中声明。确保pages配置正确:

// src/main/resources/base/profile/main_pages.json
{
  "src": [
    "pages/Index",
    "pages/DetailPage"
  ]
}

七、总结与预告

本篇总结

本篇我们完成了新闻资讯App的核心功能开发:

  1. 项目架构设计:采用分层架构,代码结构清晰

  2. 网络请求封装:基于axios封装统一的请求工具类

  3. 首页新闻列表

    • 使用List组件实现列表渲染

    • Refresh组件实现下拉刷新

    • onReachEnd事件实现上拉加载更多

    • 新闻卡片组件复用

  4. 新闻详情页

    • 使用WebView加载新闻详情

    • 实现加载进度条

    • 底部操作栏布局

关键知识点回顾

知识点

说明

@ohos/axios

网络请求库,支持拦截器配置

Refresh

下拉刷新容器组件

List.onReachEnd

列表滑动到底部事件

Web

WebView组件,用于加载网页

router.pushUrl

页面路由跳转

下篇预告

在下篇中,我们将继续完善App功能:

  • 搜索功能实现

  • 新闻收藏管理

  • 个人中心页面

  • 本地数据持久化

  • 应用状态管理优化

敬请期待!


系列文章推荐


标签鸿蒙实战 新闻App 完整项目 ArkUI 网络请求 下拉刷新 上拉加载 WebView 分层架构


💡 学习建议:动手实践是掌握开发技能的最佳方式。建议读者跟随教程一步步完成代码编写,遇到问题时查阅官方文档或在评论区交流讨论。


版权声明:本文为原创技术博客,欢迎转载分享,请注明出处。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐