鸿蒙开发实战:网络请求与新闻阅读应用开发完全指南

专栏说明:本文是《鸿蒙HarmonyOS新手入门系列》第四篇,建议先学习前三篇基础教程。本文将带你开发一个完整的新闻阅读应用,掌握HTTP网络请求、JSON数据解析、图片加载、下拉刷新、页面路由等实用技能。所有代码严格遵循华为官方开发文档规范。


文章目录


一、项目概述

1.1 为什么选择新闻应用?

在完成前三篇的学习后,你已经掌握了:

  • ✅ 第一篇:ArkTS基础语法、组件使用
  • ✅ 第二篇:列表渲染、数据持久化
  • ✅ 第三篇:状态管理、组件通信

但这些都是本地应用,真实的移动应用往往需要从服务器获取数据。新闻阅读应用是学习网络请求的最佳项目:

为什么适合新手?

  • 涉及网络请求:学习HTTP协议、异步编程
  • 数据展示丰富:文字、图片、列表、详情页
  • 交互完整:下拉刷新、加载状态、错误处理
  • 实用性强:类似今日头条、网易新闻等主流应用

1.2 应用功能预览

核心功能

  • 新闻列表展示(标题、封面图、来源、时间)
  • 下拉刷新获取最新数据
  • 加载状态提示(加载中、失败、空数据)
  • 点击查看新闻详情
  • 网络图片懒加载
  • 相对时间显示(10分钟前、2小时前)

技术亮点

  • 使用官方@ohos.net.http模块发起网络请求
  • JSON数据解析与TypeScript类型安全
  • Refresh组件实现下拉刷新
  • Router路由实现页面跳转传参
  • 枚举类型管理加载状态
  • 优雅的错误处理机制

1.3 最终效果展示

首页效果
待办应用初始界面
详情页效果
待办应用初始界面

二、数据模型设计

2.1 新闻数据接口定义

根据TypeScript最佳实践,我们首先定义数据模型:

// 新闻文章数据模型
export interface NewsArticle {
  id: number              // 文章唯一标识
  title: string           // 文章标题
  content: string         // 文章正文内容
  author: string          // 作者名称
  source: string          // 新闻来源(如:新华社、人民网)
  imageUrl: string        // 封面图片URL
  publishTime: string     // 发布时间(格式:2025-01-15 10:30:00)
  category: string        // 文章分类(科技、财经、娱乐等)
  viewCount: number       // 浏览次数
}

设计说明

  • 使用interface定义数据结构,提供类型安全
  • 所有字段都有明确的类型约束
  • export关键字表示可以在其他文件中导入使用

2.2 API响应数据结构

真实的API接口通常会返回统一的响应格式:

// 通用API响应格式
interface ApiResponse<T> {
  code: number           // 状态码(0表示成功)
  message: string        // 提示信息
  data: T                // 实际数据(泛型)
}

// 新闻列表接口返回的data结构
interface NewsListData {
  articles: NewsArticle[]  // 文章数组
}

知识点

  • 泛型<T>:让ApiResponse可以适配不同的数据类型
  • 嵌套结构:真实API通常有多层数据结构

2.3 加载状态枚举

使用枚举类型管理页面的不同状态:

// 加载状态枚举
enum LoadingState {
  IDLE,      // 初始状态(闲置)
  LOADING,   // 加载中
  SUCCESS,   // 加载成功
  ERROR      // 加载失败
}

为什么用枚举?

  • 代码更易读:LoadingState.LOADING'loading'更清晰
  • 类型安全:编译时可以检查拼写错误
  • 便于维护:修改状态值时IDE会自动提示所有使用位置

三、网络请求服务封装

3.1 HTTP请求基础知识

在HarmonyOS中,使用官方提供的@ohos.net.http模块发起网络请求。

核心API

import http from '@ohos.net.http'

// 1. 创建HTTP请求对象
let httpRequest = http.createHttp()

// 2. 发起请求
httpRequest.request(url, options)

// 3. 销毁请求对象(防止内存泄漏)
httpRequest.destroy()

3.2 创建新闻服务类

根据面向对象原则,我们创建一个专门的服务类来管理网络请求:

import http from '@ohos.net.http'

class NewsServiceClass {
  // 基础URL(实际开发中应配置为真实API地址)
  private BASE_URL: string = 'https://api.example.com'
  
  // 请求超时时间(毫秒)
  private TIMEOUT: number = 10000
  
  /**
   * 获取新闻列表
   * @param category 新闻分类
   * @param page 页码
   * @param pageSize 每页数量
   * @returns 新闻文章数组
   */
  async getNewsList(
    category: string = '全部',
    page: number = 1,
    pageSize: number = 10
  ): Promise<NewsArticle[]> {
    // 1. 创建HTTP请求对象
    let httpRequest = http.createHttp()
    
    // 2. 构建请求URL(带查询参数)
    const url = `${this.BASE_URL}/news?category=${category}&page=${page}&pageSize=${pageSize}`
    
    try {
      // 3. 发起GET请求
      const response = await httpRequest.request(url, {
        method: http.RequestMethod.GET,
        header: {
          'Content-Type': 'application/json'
        },
        expectDataType: http.HttpDataType.STRING,
        connectTimeout: this.TIMEOUT,
        readTimeout: this.TIMEOUT
      })
      
      // 4. 检查响应状态码
      if (response.responseCode === 200) {
        // 5. 解析JSON数据
        const apiResponse: ApiResponse<NewsListData> = 
          JSON.parse(response.result as string)
        
        // 6. 检查业务状态码
        if (apiResponse.code === 0) {
          return apiResponse.data.articles
        }
      }
      
      // 失败时返回空数组
      return []
      
    } catch (error) {
      console.error('网络请求失败:', JSON.stringify(error))
      return []
      
    } finally {
      // 7. 销毁请求对象(重要!)
      httpRequest.destroy()
    }
  }
}

// 导出单例对象
export const NewsService = new NewsServiceClass()

代码详解

步骤 说明 注意事项
1. 创建HTTP对象 createHttp()创建请求实例 每次请求都要创建新对象
2. 构建URL 拼接基础URL和查询参数 参数需要URL编码
3. 发起请求 request()方法支持GET/POST等 使用async/await处理异步
4. 检查状态码 200表示HTTP请求成功 不等于200要做错误处理
5. 解析JSON JSON.parse()解析响应体 要使用类型断言
6. 检查业务码 服务端自定义的状态码 约定0表示成功
7. 销毁对象 防止内存泄漏 一定要在finally中执行

3.3 Mock数据实现(用于测试)

在没有真实后端API时,我们可以先用Mock数据测试:

class NewsServiceClass {
  // ... 其他代码
  
  /**
   * 获取模拟数据
   * 用于开发测试,模拟网络延迟
   */
  async getMockData(): Promise<NewsArticle[]> {
    return new Promise<NewsArticle[]>((resolve) => {
      // 模拟网络延迟1秒
      setTimeout(() => {
        const articles: NewsArticle[] = [
          {
            id: 1,
            title: 'HarmonyOS NEXT正式发布',
            content: 'HarmonyOS NEXT正式版发布,开启纯血鸿蒙时代。这是一个完全独立的操作系统,不再兼容Android应用,标志着鸿蒙生态的全面成熟。系统采用全新的微内核架构,在性能、安全性和流畅度方面都有显著提升。',
            author: '张记者',
            source: '科技日报',
            imageUrl: 'https://picsum.photos/400/250',
            publishTime: '2025-01-15 10:30:00',
            category: '科技',
            viewCount: 15200
          },
          {
            id: 2,
            title: '鸿蒙开发者突破100万',
            content: '华为宣布鸿蒙应用开发者数量突破100万大关,生态建设进入快车道。越来越多的企业和个人开发者加入鸿蒙生态,为用户带来丰富多样的应用体验。华为承诺将继续加大对开发者的支持力度。',
            author: '李编辑',
            source: '人民网',
            imageUrl: 'https://picsum.photos/400/250',
            publishTime: '2025-01-15 09:15:00',
            category: '科技',
            viewCount: 8900
          },
          {
            id: 3,
            title: 'ArkTS成为最受欢迎语言',
            content: 'Stack Overflow最新调查显示,ArkTS语言受欢迎程度大幅提升,成为最受开发者喜爱的移动开发语言之一。ArkTS基于TypeScript扩展,提供了更强大的类型系统和更好的开发工具支持。',
            author: '王主编',
            source: '开发者头条',
            imageUrl: 'https://picsum.photos/400/250',
            publishTime: '2025-01-15 08:00:00',
            category: '编程',
            viewCount: 12500
          }
          // ... 更多数据
        ]
        resolve(articles)
      }, 1000)
    })
  }
}

Mock数据的作用

  • 前端独立开发:不依赖后端接口
  • 测试UI逻辑:验证加载状态、错误处理
  • 学习最佳实践:先用Mock,后期替换为真实API

四、新闻列表页面开发

4.1 页面状态管理

首先定义页面需要的状态变量:

@Entry
@Component
struct Index {
  // 新闻列表数据
  @State newsList: NewsArticle[] = []
  
  // 加载状态(使用枚举)
  @State loadingState: LoadingState = LoadingState.IDLE
  
  // 错误信息
  @State errorMessage: string = ''
  
  // 是否正在刷新
  @State isRefreshing: boolean = false
  
  // 组件即将出现时调用
  aboutToAppear(): void {
    this.loadNews()
  }
  
  build() {
    // UI代码(后续实现)
  }
}

状态说明

状态变量 类型 作用
newsList 数组 存储新闻数据
loadingState 枚举 管理页面状态(加载中/成功/失败)
errorMessage 字符串 存储错误提示信息
isRefreshing 布尔值 控制下拉刷新动画

4.2 加载新闻数据

实现数据加载方法:

/**
 * 加载新闻数据
 */
async loadNews(): Promise<void> {
  // 1. 设置加载状态
  this.loadingState = LoadingState.LOADING
  this.errorMessage = ''
  
  try {
    // 2. 调用服务类获取数据
    const articles = await NewsService.getMockData()
    
    // 3. 更新数据和状态
    this.newsList = articles
    this.loadingState = LoadingState.SUCCESS
    
  } catch (err) {
    // 4. 错误处理
    console.error('加载新闻失败:', JSON.stringify(err))
    this.loadingState = LoadingState.ERROR
    this.errorMessage = '网络请求失败,请稍后重试'
  }
}

流程说明

  1. 设置加载中状态:显示loading动画
  2. 发起异步请求:使用await等待结果
  3. 成功处理:更新数据,设置成功状态
  4. 失败处理:捕获异常,设置错误状态

4.3 下拉刷新实现

实现下拉刷新功能:

/**
 * 下拉刷新
 */
async onRefresh(): Promise<void> {
  // 1. 开启刷新动画
  this.isRefreshing = true
  
  try {
    // 2. 重新获取数据
    const articles = await NewsService.getMockData()
    
    // 3. 更新列表
    this.newsList = articles
    this.loadingState = LoadingState.SUCCESS
    
  } catch (err) {
    console.error('刷新失败:', JSON.stringify(err))
    
  } finally {
    // 4. 关闭刷新动画(无论成功失败都要关闭)
    this.isRefreshing = false
  }
}

关键点

  • 使用finally确保刷新动画一定会关闭
  • 刷新失败时不改变已有数据,避免列表清空

4.4 时间格式化

实现相对时间显示(“10分钟前”、“2小时前”):

/**
 * 格式化时间为相对时间
 * @param timeStr 时间字符串(2025-01-15 10:30:00)
 * @returns 相对时间(10分钟前)
 */
formatTime(timeStr: string): string {
  const now = new Date()
  const time = new Date(timeStr)
  const diff = now.getTime() - time.getTime()  // 时间差(毫秒)
  
  const minutes = Math.floor(diff / 60000)      // 分钟数
  const hours = Math.floor(diff / 3600000)      // 小时数
  const days = Math.floor(diff / 86400000)      // 天数
  
  if (minutes < 60) {
    return `${minutes}分钟前`
  } else if (hours < 24) {
    return `${hours}小时前`
  } else if (days < 7) {
    return `${days}天前`
  } else {
    // 超过7天显示具体日期
    return timeStr.substring(0, 10)
  }
}

时间计算

  • 1分钟 = 60,000毫秒
  • 1小时 = 3,600,000毫秒
  • 1天 = 86,400,000毫秒

4.5 页面跳转

实现点击新闻跳转到详情页:

import router from '@ohos.router'

/**
 * 跳转到新闻详情页
 * @param article 新闻文章数据
 */
navigateToDetail(article: NewsArticle): void {
  router.pushUrl({
    url: 'pages/NewsDetailPage',  // 目标页面路径
    params: article                // 传递参数
  })
}

路由说明

  • pushUrl:跳转到新页面,可以返回
  • params:传递任意对象给目标页面
  • url:不需要加.ets后缀

五、UI界面构建

5.1 主页面结构

build() {
  Column() {
    // 1. 顶部导航栏
    Row() {
      Text('📰 新闻头条')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
    }
    .width('100%')
    .height(56)
    .padding({ left: 20, right: 20 })
    .backgroundColor('#1890FF')
    
    // 2. 根据加载状态显示不同内容
    if (this.loadingState === LoadingState.LOADING && this.newsList.length === 0) {
      this.LoadingView()  // 加载中视图
    } else if (this.loadingState === LoadingState.ERROR) {
      this.ErrorView()    // 错误视图
    } else if (this.newsList.length === 0) {
      this.EmptyView()    // 空数据视图
    } else {
      this.NewsListView() // 新闻列表视图
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F5F5F5')
}

条件渲染逻辑

  1. 首次加载且列表为空 → 显示loading
  2. 加载失败 → 显示错误页
  3. 加载成功但无数据 → 显示空状态
  4. 有数据 → 显示列表

5.2 加载中视图

@Builder
LoadingView() {
  Column() {
    // 系统提供的加载动画组件
    LoadingProgress()
      .width(60)
      .height(60)
      .color('#1890FF')
    
    Text('加载中...')
      .fontSize(16)
      .fontColor('#999999')
      .margin({ top: 15 })
  }
  .width('100%')
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
}

5.3 错误视图

@Builder
ErrorView() {
  Column({ space: 15 }) {
    Text('😢')
      .fontSize(60)
    
    Text(this.errorMessage)
      .fontSize(16)
      .fontColor('#999999')
    
    // 重新加载按钮
    Button('重新加载')
      .fontSize(16)
      .backgroundColor('#1890FF')
      .onClick(() => {
        this.loadNews()
      })
  }
  .width('100%')
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
}

5.4 空数据视图

@Builder
EmptyView() {
  Column({ space: 15 }) {
    Text('📭')
      .fontSize(60)
    
    Text('暂无新闻')
      .fontSize(16)
      .fontColor('#999999')
  }
  .width('100%')
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
}

5.5 新闻列表视图

@Builder
NewsListView() {
  // Refresh组件实现下拉刷新
  Refresh({ refreshing: $$this.isRefreshing }) {
    List({ space: 0 }) {
      ForEach(this.newsList, (article: NewsArticle) => {
        ListItem() {
          this.NewsItemView(article)
        }
        .onClick(() => {
          this.navigateToDetail(article)
        })
      }, (article: NewsArticle) => article.id.toString())
    }
    .width('100%')
    .height('100%')
  }
  .onRefreshing(() => {
    this.onRefresh()
  })
  .width('100%')
  .layoutWeight(1)
}

Refresh组件说明

  • refreshing:控制刷新动画的显示
  • $$:双向绑定语法,Refresh组件会自动修改isRefreshing的值
  • onRefreshing:下拉刷新触发的回调

5.6 新闻项视图

@Builder
NewsItemView(article: NewsArticle) {
  Column() {
    Row({ space: 12 }) {
      // 封面图片
      Image(article.imageUrl)
        .width(120)
        .height(90)
        .borderRadius(8)
        .objectFit(ImageFit.Cover)
        .syncLoad(false)  // 异步加载图片
      
      // 右侧文字信息
      Column({ space: 8 }) {
        // 标题
        Text(article.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')
          .maxLines(2)                          // 最多显示2行
          .textOverflow({ overflow: TextOverflow.Ellipsis })  // 超出显示省略号
        
        // 来源和时间
        Row({ space: 15 }) {
          Text(article.source)
            .fontSize(12)
            .fontColor('#999999')
          
          Text(this.formatTime(article.publishTime))
            .fontSize(12)
            .fontColor('#999999')
        }
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .height(90)
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .width('100%')
    .padding(15)
    
    // 分隔线
    Divider()
      .strokeWidth(1)
      .color('#F0F0F0')
  }
  .width('100%')
  .backgroundColor(Color.White)
}

布局说明

  • 左侧:120x90的封面图
  • 右侧:垂直布局,标题在上、信息在下
  • 分隔线:用Divider组件实现

六、新闻详情页开发

6.1 创建详情页文件

pages目录下创建NewsDetailPage.ets

import router from '@ohos.router'
import { NewsArticle } from './Index'

@Entry
@Component
struct NewsDetailPage {
  // 文章数据
  @State article: NewsArticle = {
    id: 0,
    title: '',
    content: '',
    author: '',
    source: '',
    imageUrl: '',
    publishTime: '',
    category: '',
    viewCount: 0
  }
  
  // 组件即将出现时获取传递的参数
  aboutToAppear(): void {
    const params = router.getParams() as NewsArticle
    if (params && params.id) {
      this.article = params
    }
  }
  
  build() {
    // UI代码(后续实现)
  }
}

接收路由参数

  • router.getParams():获取跳转时传递的参数
  • 使用类型断言as NewsArticle确保类型安全
  • 检查params.id确认数据有效

6.2 详情页UI实现

build() {
  Column() {
    // 1. 顶部导航栏
    Row() {
      // 返回按钮
      Button({ type: ButtonType.Normal }) {
        Text('←')
          .fontSize(24)
          .fontColor(Color.White)
      }
      .width(40)
      .height(40)
      .backgroundColor('transparent')
      .onClick(() => {
        router.back()  // 返回上一页
      })
      
      Text('新闻详情')
        .fontSize(18)
        .fontColor(Color.White)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
      
      // 占位元素(保持标题居中)
      Row().width(40).height(40)
    }
    .width('100%')
    .height(56)
    .padding({ left: 10, right: 10 })
    .backgroundColor('#1890FF')
    
    // 2. 文章内容区
    if (this.article.id > 0) {
      Scroll() {
        Column({ space: 0 }) {
          // 封面大图
          Image(this.article.imageUrl)
            .width('100%')
            .height(250)
            .objectFit(ImageFit.Cover)
          
          // 文章信息
          Column({ space: 15 }) {
            // 标题
            Text(this.article.title)
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
              .lineHeight(32)
            
            // 元信息(来源、作者、阅读量)
            Row({ space: 20 }) {
              Row({ space: 5 }) {
                Text('📰').fontSize(14)
                Text(this.article.source)
                  .fontSize(14)
                  .fontColor('#666666')
              }
              
              Row({ space: 5 }) {
                Text('✍️').fontSize(14)
                Text(this.article.author)
                  .fontSize(14)
                  .fontColor('#666666')
              }
              
              Row({ space: 5 }) {
                Text('👁').fontSize(14)
                Text(this.article.viewCount.toString())
                  .fontSize(14)
                  .fontColor('#666666')
              }
            }
            .width('100%')
            
            // 发布时间
            Text(this.article.publishTime)
              .fontSize(12)
              .fontColor('#999999')
            
            // 分隔线
            Divider()
              .strokeWidth(1)
              .color('#E0E0E0')
              .margin({ top: 15, bottom: 15 })
            
            // 正文内容
            Text(this.article.content)
              .fontSize(16)
              .fontColor('#333333')
              .lineHeight(28)
            
            // 底部留白
            Row().height(30)
          }
          .width('100%')
          .padding(20)
          .backgroundColor(Color.White)
        }
      }
      .width('100%')
      .layoutWeight(1)
      .backgroundColor('#F5F5F5')
      
    } else {
      // 无数据提示
      Column() {
        Text('未找到文章')
          .fontSize(16)
          .fontColor('#999999')
      }
      .width('100%')
      .layoutWeight(1)
      .justifyContent(FlexAlign.Center)
    }
  }
  .width('100%')
  .height('100%')
}

UI结构

  1. 顶部导航栏:返回按钮 + 标题
  2. 封面图片:全宽展示
  3. 文章信息:标题、来源、作者、阅读量、时间
  4. 正文内容:可滚动的长文本

七、网络请求进阶

7.1 请求方法对比

HarmonyOS HTTP模块支持多种请求方法:

// GET请求(获取数据)
httpRequest.request(url, {
  method: http.RequestMethod.GET,
  header: {
    'Content-Type': 'application/json'
  }
})

// POST请求(提交数据)
httpRequest.request(url, {
  method: http.RequestMethod.POST,
  header: {
    'Content-Type': 'application/json'
  },
  extraData: JSON.stringify({
    username: 'zhangsan',
    password: '123456'
  })
})

// PUT请求(更新数据)
httpRequest.request(url, {
  method: http.RequestMethod.PUT,
  extraData: JSON.stringify({ title: '新标题' })
})

// DELETE请求(删除数据)
httpRequest.request(url, {
  method: http.RequestMethod.DELETE
})

7.2 请求头配置

常用的HTTP请求头:

header: {
  'Content-Type': 'application/json',        // JSON格式
  'Authorization': 'Bearer token123',        // 身份认证
  'User-Agent': 'HarmonyOS App/1.0',        // 客户端标识
  'Accept-Language': 'zh-CN',               // 语言偏好
}

7.3 错误处理最佳实践

完善的错误处理:

async getNewsList(): Promise<NewsArticle[]> {
  let httpRequest = http.createHttp()
  
  try {
    const response = await httpRequest.request(url, options)
    
    // 1. 检查HTTP状态码
    if (response.responseCode !== 200) {
      console.error(`HTTP错误: ${response.responseCode}`)
      return []
    }
    
    // 2. 解析JSON
    let apiResponse: ApiResponse<NewsListData>
    try {
      apiResponse = JSON.parse(response.result as string)
    } catch (parseError) {
      console.error('JSON解析失败:', parseError)
      return []
    }
    
    // 3. 检查业务状态码
    if (apiResponse.code !== 0) {
      console.error(`业务错误: ${apiResponse.message}`)
      return []
    }
    
    // 4. 返回数据
    return apiResponse.data.articles
    
  } catch (error) {
    // 网络异常、超时等
    console.error('请求异常:', JSON.stringify(error))
    return []
    
  } finally {
    // 5. 确保销毁请求对象
    httpRequest.destroy()
  }
}

错误处理层级

  1. 网络层:连接失败、超时(try-catch捕获)
  2. HTTP层:状态码404、500等(检查responseCode)
  3. 业务层:服务端返回的错误码(检查apiResponse.code)
  4. 数据层:JSON解析失败(try-catch捕获)

7.4 请求超时配置

const response = await httpRequest.request(url, {
  method: http.RequestMethod.GET,
  connectTimeout: 10000,  // 连接超时:10秒
  readTimeout: 10000      // 读取超时:10秒
})

超时时间建议

  • 🏠 WiFi环境:5-10秒
  • 📱 4G/5G网络:10-15秒
  • 🐌 弱网环境:15-30秒

八、图片加载优化

8.1 Image组件核心属性

Image(url)
  .width(120)
  .height(90)
  .objectFit(ImageFit.Cover)      // 图片填充模式
  .syncLoad(false)                // 异步加载(默认)
  .interpolation(ImageInterpolation.High)  // 高质量插值
  .alt($r('app.media.placeholder'))  // 加载失败时的占位图

objectFit参数说明

参数 说明 使用场景
Cover 裁剪填充,保持比例 封面图、头像
Contain 完整显示,留白 Logo、图标
Fill 拉伸填充 背景图
None 原始尺寸 表情、小图标
ScaleDown 缩小但不放大 详情图

8.2 懒加载实现

Image组件默认支持懒加载,只需设置syncLoad(false)

List() {
  ForEach(this.newsList, (article: NewsArticle) => {
    ListItem() {
      Image(article.imageUrl)
        .syncLoad(false)  // 滚动到可见区域才加载
    }
  })
}

8.3 加载失败处理

设置占位图和加载失败回调:

Image(article.imageUrl)
  .alt($r('app.media.image_placeholder'))  // 占位图
  .onError(() => {
    console.error('图片加载失败:', article.imageUrl)
  })
  .onComplete(() => {
    console.info('图片加载成功')
  })

8.4 缓存策略

HarmonyOS会自动缓存网络图片,无需额外配置。缓存位置:

/data/storage/el2/base/haps/<bundleName>/cache/web/Cache/

清理缓存

  • 应用卸载时自动清理
  • 可以手动调用清理API(需要权限)

九、下拉刷新进阶

9.1 Refresh组件详解

Refresh({ refreshing: $$this.isRefreshing }) {
  // 需要刷新的内容
  List() {
    // ...
  }
}
.onRefreshing(() => {
  // 刷新触发时的回调
  this.loadData()
})
.refreshOffset(64)        // 触发刷新的偏移量
.pullToRefresh(true)      // 是否支持下拉刷新

9.2 自定义刷新动画

Refresh({ 
  refreshing: $$this.isRefreshing,
  builder: this.customRefreshBuilder  // 自定义刷新视图
}) {
  List() { /* ... */ }
}

@Builder
customRefreshBuilder() {
  Row() {
    LoadingProgress()
      .width(30)
      .height(30)
      .color('#1890FF')
    
    Text('正在刷新...')
      .fontSize(14)
      .fontColor('#999999')
      .margin({ left: 10 })
  }
}

9.3 上拉加载更多

实现分页加载:

@State page: number = 1
@State hasMore: boolean = true

// 列表滚动到底部触发
onReachEnd() {
  if (this.hasMore && !this.isLoading) {
    this.loadMore()
  }
}

async loadMore(): Promise<void> {
  this.page++
  const newArticles = await NewsService.getNewsList('全部', this.page)
  
  if (newArticles.length > 0) {
    this.newsList = [...this.newsList, ...newArticles]
  } else {
    this.hasMore = false  // 没有更多数据了
  }
}

十、性能优化技巧

10.1 列表性能优化

ForEach键生成函数优化

// ❌ 不推荐:使用索引作为key
ForEach(this.newsList, (article: NewsArticle, index: number) => {
  ListItem() { /* ... */ }
}, (article: NewsArticle, index: number) => index.toString())

// ✅ 推荐:使用唯一标识作为key
ForEach(this.newsList, (article: NewsArticle) => {
  ListItem() { /* ... */ }
}, (article: NewsArticle) => article.id.toString())

为什么key很重要?

  • 帮助框架识别哪些元素发生了变化
  • 避免不必要的DOM重建
  • 提升列表滚动流畅度

10.2 图片加载优化

Image(article.imageUrl)
  .syncLoad(false)              // 异步加载
  .renderMode(ImageRenderMode.Original)  // 渲染模式
  .sourceSize({ width: 400, height: 250 })  // 指定解码尺寸

优化建议

  • 使用CDN加速图片加载
  • 服务端提供多种尺寸的图片
  • 列表缩略图使用小尺寸,详情页使用大尺寸

10.3 网络请求优化

请求合并

// 批量获取多个分类的新闻
async getMultiCategoryNews(categories: string[]): Promise<Map<string, NewsArticle[]>> {
  const promises = categories.map(cat => this.getNewsList(cat))
  const results = await Promise.all(promises)
  
  const map = new Map<string, NewsArticle[]>()
  categories.forEach((cat, index) => {
    map.set(cat, results[index])
  })
  return map
}

请求去重

private requestCache = new Map<string, Promise<NewsArticle[]>>()

async getNewsList(category: string): Promise<NewsArticle[]> {
  const key = `news_${category}`
  
  // 检查是否有正在进行的相同请求
  if (this.requestCache.has(key)) {
    return this.requestCache.get(key)!
  }
  
  // 发起新请求
  const promise = this.fetchNews(category)
  this.requestCache.set(key, promise)
  
  try {
    const result = await promise
    return result
  } finally {
    // 请求完成后清除缓存
    this.requestCache.delete(key)
  }
}

10.4 数据缓存策略

简单的内存缓存实现:

class CacheManager {
  private cache = new Map<string, { data: any, expireTime: number }>()
  
  // 设置缓存(5分钟过期)
  set(key: string, data: any, ttl: number = 5 * 60 * 1000): void {
    const expireTime = Date.now() + ttl
    this.cache.set(key, { data, expireTime })
  }
  
  // 获取缓存
  get(key: string): any | null {
    const item = this.cache.get(key)
    if (!item) return null
    
    // 检查是否过期
    if (Date.now() > item.expireTime) {
      this.cache.delete(key)
      return null
    }
    
    return item.data
  }
}

十一、常见问题与解决方案

11.1 网络权限配置

问题:网络请求报错"Permission denied"

解决方案:在module.json5中添加网络权限:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET",
        "reason": "$string:internet_permission_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

11.2 图片无法显示

常见原因和解决方法

问题 原因 解决方案
图片不显示 URL地址错误 检查控制台错误日志
显示占位图 网络权限未配置 添加INTERNET权限
加载很慢 图片太大 使用压缩后的图片
显示模糊 插值算法问题 设置.interpolation(ImageInterpolation.High)

11.3 数据不刷新

问题:修改数组元素后UI不更新

// ❌ 错误写法:直接修改数组元素
this.newsList[0].title = '新标题'

// ✅ 正确写法:创建新数组
this.newsList = this.newsList.map((article, index) => {
  if (index === 0) {
    return { ...article, title: '新标题' }
  }
  return article
})

11.4 路由跳转失败

问题:点击新闻无反应

检查清单

  1. ✅ 目标页面路径是否正确(pages/NewsDetailPage
  2. ✅ 目标页面是否在main_pages.json中注册
  3. ✅ 是否使用了@Entry装饰器
  4. ✅ 查看控制台是否有错误日志

main_pages.json配置

{
  "src": [
    "pages/Index",
    "pages/NewsDetailPage"  // 添加新页面
  ]
}

11.5 下拉刷新无效

问题:下拉没有触发刷新

解决方案

// 确保使用双向绑定 $$
Refresh({ refreshing: $$this.isRefreshing }) {
  // 不是 $this.isRefreshing,而是 $$this.isRefreshing
}

11.6 JSON解析报错

问题JSON.parse()抛出异常

原因:服务端返回的不是标准JSON格式

解决方案

try {
  const data = JSON.parse(response.result as string)
  return data
} catch (error) {
  console.error('JSON解析失败,原始数据:', response.result)
  return []
}

十二、完整代码汇总

12.1 主页面代码(Index.ets)

import http from '@ohos.net.http'
import router from '@ohos.router'

// ========== 数据模型定义 ==========
export interface NewsArticle {
  id: number
  title: string
  content: string
  author: string
  source: string
  imageUrl: string
  publishTime: string
  category: string
  viewCount: number
}

interface ApiResponse<T> {
  code: number
  message: string
  data: T
}

interface NewsListData {
  articles: NewsArticle[]
}

// ========== 新闻服务类 ==========
class NewsServiceClass {
  private BASE_URL: string = 'https://api.example.com'
  private TIMEOUT: number = 10000

  async getMockData(): Promise<NewsArticle[]> {
    return new Promise<NewsArticle[]>((resolve) => {
      setTimeout(() => {
        const articles: NewsArticle[] = [
          {
            id: 1,
            title: 'HarmonyOS NEXT正式发布',
            content: 'HarmonyOS NEXT正式版发布,开启纯血鸿蒙时代...',
            author: '张记者',
            source: '科技日报',
            imageUrl: 'https://picsum.photos/400/250',
            publishTime: '2025-01-15 10:30:00',
            category: '科技',
            viewCount: 15200
          },
          // ... 更多数据
        ]
        resolve(articles)
      }, 1000)
    })
  }
}

export const NewsService = new NewsServiceClass()

// ========== 加载状态枚举 ==========
enum LoadingState {
  IDLE,
  LOADING,
  SUCCESS,
  ERROR
}

// ========== 主页面组件 ==========
@Entry
@Component
struct Index {
  @State newsList: NewsArticle[] = []
  @State loadingState: LoadingState = LoadingState.IDLE
  @State errorMessage: string = ''
  @State isRefreshing: boolean = false

  aboutToAppear(): void {
    this.loadNews()
  }

  async loadNews(): Promise<void> {
    this.loadingState = LoadingState.LOADING
    this.errorMessage = ''

    try {
      const articles = await NewsService.getMockData()
      this.newsList = articles
      this.loadingState = LoadingState.SUCCESS
    } catch (err) {
      console.error('加载新闻失败:', JSON.stringify(err))
      this.loadingState = LoadingState.ERROR
      this.errorMessage = '网络请求失败,请稍后重试'
    }
  }

  async onRefresh(): Promise<void> {
    this.isRefreshing = true
    try {
      const articles = await NewsService.getMockData()
      this.newsList = articles
      this.loadingState = LoadingState.SUCCESS
    } catch (err) {
      console.error('刷新失败:', JSON.stringify(err))
    }
    this.isRefreshing = false
  }

  formatTime(timeStr: string): string {
    const now = new Date()
    const time = new Date(timeStr)
    const diff = now.getTime() - time.getTime()

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

    if (minutes < 60) {
      return `${minutes}分钟前`
    } else if (hours < 24) {
      return `${hours}小时前`
    } else if (days < 7) {
      return `${days}天前`
    } else {
      return timeStr.substring(0, 10)
    }
  }

  navigateToDetail(article: NewsArticle): void {
    router.pushUrl({
      url: 'pages/NewsDetailPage',
      params: article
    })
  }

  build() {
    Column() {
      // 顶部导航栏
      Row() {
        Text('📰 新闻头条')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
      }
      .width('100%')
      .height(56)
      .padding({ left: 20, right: 20 })
      .backgroundColor('#1890FF')

      // 根据状态显示不同视图
      if (this.loadingState === LoadingState.LOADING && this.newsList.length === 0) {
        this.LoadingView()
      } else if (this.loadingState === LoadingState.ERROR) {
        this.ErrorView()
      } else if (this.newsList.length === 0) {
        this.EmptyView()
      } else {
        this.NewsListView()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  @Builder
  LoadingView() {
    Column() {
      LoadingProgress()
        .width(60)
        .height(60)
        .color('#1890FF')
      Text('加载中...')
        .fontSize(16)
        .fontColor('#999999')
        .margin({ top: 15 })
    }
    .width('100%')
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
  }

  @Builder
  ErrorView() {
    Column({ space: 15 }) {
      Text('😢')
        .fontSize(60)
      Text(this.errorMessage)
        .fontSize(16)
        .fontColor('#999999')
      Button('重新加载')
        .fontSize(16)
        .backgroundColor('#1890FF')
        .onClick(() => {
          this.loadNews()
        })
    }
    .width('100%')
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
  }

  @Builder
  EmptyView() {
    Column({ space: 15 }) {
      Text('📭')
        .fontSize(60)
      Text('暂无新闻')
        .fontSize(16)
        .fontColor('#999999')
    }
    .width('100%')
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
  }

  @Builder
  NewsListView() {
    Refresh({ refreshing: $$this.isRefreshing }) {
      List({ space: 0 }) {
        ForEach(this.newsList, (article: NewsArticle) => {
          ListItem() {
            this.NewsItemView(article)
          }
          .onClick(() => {
            this.navigateToDetail(article)
          })
        }, (article: NewsArticle) => article.id.toString())
      }
      .width('100%')
      .height('100%')
    }
    .onRefreshing(() => {
      this.onRefresh()
    })
    .width('100%')
    .layoutWeight(1)
  }

  @Builder
  NewsItemView(article: NewsArticle) {
    Column() {
      Row({ space: 12 }) {
        Image(article.imageUrl)
          .width(120)
          .height(90)
          .borderRadius(8)
          .objectFit(ImageFit.Cover)
          .syncLoad(false)

        Column({ space: 8 }) {
          Text(article.title)
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .fontColor('#333333')
            .maxLines(2)
            .textOverflow({ overflow: TextOverflow.Ellipsis })

          Row({ space: 15 }) {
            Text(article.source)
              .fontSize(12)
              .fontColor('#999999')
            Text(this.formatTime(article.publishTime))
              .fontSize(12)
              .fontColor('#999999')
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .height(90)
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')
      .padding(15)

      Divider()
        .strokeWidth(1)
        .color('#F0F0F0')
    }
    .width('100%')
    .backgroundColor(Color.White)
  }
}

12.2 详情页代码(NewsDetailPage.ets)

import router from '@ohos.router'
import { NewsArticle } from './Index'

@Entry
@Component
struct NewsDetailPage {
  @State article: NewsArticle = {
    id: 0,
    title: '',
    content: '',
    author: '',
    source: '',
    imageUrl: '',
    publishTime: '',
    category: '',
    viewCount: 0
  }

  aboutToAppear(): void {
    const params = router.getParams() as NewsArticle
    if (params && params.id) {
      this.article = params
    }
  }

  build() {
    Column() {
      // 顶部导航栏
      Row() {
        Button({ type: ButtonType.Normal }) {
          Text('←')
            .fontSize(24)
            .fontColor(Color.White)
        }
        .width(40)
        .height(40)
        .backgroundColor('transparent')
        .onClick(() => {
          router.back()
        })

        Text('新闻详情')
          .fontSize(18)
          .fontColor(Color.White)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)

        Row().width(40).height(40)
      }
      .width('100%')
      .height(56)
      .padding({ left: 10, right: 10 })
      .backgroundColor('#1890FF')

      if (this.article.id > 0) {
        Scroll() {
          Column({ space: 0 }) {
            Image(this.article.imageUrl)
              .width('100%')
              .height(250)
              .objectFit(ImageFit.Cover)

            Column({ space: 15 }) {
              Text(this.article.title)
                .fontSize(22)
                .fontWeight(FontWeight.Bold)
                .fontColor('#333333')
                .lineHeight(32)

              Row({ space: 20 }) {
                Row({ space: 5 }) {
                  Text('📰').fontSize(14)
                  Text(this.article.source).fontSize(14).fontColor('#666666')
                }
                Row({ space: 5 }) {
                  Text('✍️').fontSize(14)
                  Text(this.article.author).fontSize(14).fontColor('#666666')
                }
                Row({ space: 5 }) {
                  Text('👁').fontSize(14)
                  Text(this.article.viewCount.toString()).fontSize(14).fontColor('#666666')
                }
              }
              .width('100%')

              Text(this.article.publishTime)
                .fontSize(12)
                .fontColor('#999999')

              Divider()
                .strokeWidth(1)
                .color('#E0E0E0')
                .margin({ top: 15, bottom: 15 })

              Text(this.article.content)
                .fontSize(16)
                .fontColor('#333333')
                .lineHeight(28)

              Row().height(30)
            }
            .width('100%')
            .padding(20)
            .backgroundColor(Color.White)
          }
        }
        .width('100%')
        .layoutWeight(1)
        .backgroundColor('#F5F5F5')
      } else {
        Column() {
          Text('未找到文章')
            .fontSize(16)
            .fontColor('#999999')
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
  }
}

🔗 参考资料

本文所有代码严格遵循华为官方文档规范:


💡 结语

恭喜你!通过本教程,你已经:

✅ 掌握了HTTP网络请求的完整流程
✅ 学会了JSON数据解析与类型安全处理
✅ 实现了一个完整的新闻阅读应用
✅ 理解了异步编程和错误处理机制
✅ 学会了图片懒加载和下拉刷新
✅ 掌握了页面路由和参数传递

作者寄语:网络请求是移动应用开发的必备技能,掌握本文知识后,你已经能开发大部分常见的应用了。不要停下学习的脚步,继续探索鸿蒙生态的更多可能性。记住:实践是最好的老师,多动手才能真正掌握!

如果本文对你有帮助,欢迎点赞👍、收藏⭐、关注➕!有问题欢迎在评论区讨论!


标签#HarmonyOS #鸿蒙开发 #ArkTS #网络请求 #HTTP #JSON解析 #新闻应用 #实战教程

版权声明:本文为作者原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐