1. 项目概述与核心价值

最近在捣鼓OpenHarmony应用开发,想找个能串联起网络请求、数据解析、列表展示和页面跳转这些核心技能的实战项目。正好看到网上有个“坚果食谱”的创意,但资料比较零散,于是决定自己动手,基于ArkUI的eTS(extended TypeScript)语言,从头到尾实现一个功能完整的食谱应用,我把它命名为NutRecipes。这个项目麻雀虽小,五脏俱全,非常适合想从Hello World进阶到实际功能开发的HarmonyOS开发者。

简单来说,NutRecipes就是一个能联网获取海量菜谱、并清晰展示详情的应用。你输入一个食材,比如“白菜”,它就能从云端拉回一堆相关菜谱,点进去还能看到需要什么材料、分几步做,甚至每一步都有配图。整个过程涉及到从零搭建项目结构、配置网络权限、发起HTTP请求、解析复杂的嵌套JSON数据、用可滚动组件展示列表、以及页面间的路由跳转。如果你正愁找不到一个能覆盖HarmonyOS应用开发基础技能链的练手项目,那跟着我把这个NutRecipes做一遍,绝对能让你对ArkUI eTS开发有个扎实的理解。

2. 项目架构与核心思路拆解

在动手写代码之前,我们先得把项目的“骨架”搭好,想清楚数据怎么来、页面怎么摆、代码怎么组织。盲目开干很容易写到一半就陷入混乱。

2.1 技术选型与ArkUI eTS优势

为什么选择ArkUI eTS?对于HarmonyOS应用开发,eTS是当前和未来的主流推荐语言。它基于TypeScript,提供了静态类型检查,能在编码阶段就帮你揪出不少潜在的错误,比如属性名拼写错误、类型不匹配等,这对于构建中大型应用至关重要,能极大提升开发效率和代码的可维护性。相比之前的JS UI框架,ArkUI eTS的声明式UI开发范式也更直观、更高效,你只需要描述UI应该是什么样子,框架会自动帮你处理状态更新和UI渲染。

在这个食谱项目里,我们需要处理异步的网络数据、复杂的UI状态(加载中、成功、失败)、以及列表的滚动渲染。ArkUI eTS的组件化开发模式和状态管理机制(比如 @State , @Prop 装饰器)能让这些需求变得清晰可控。例如,菜谱列表的数据作为一个 @State 变量,当网络请求返回新数据时,只需更新这个状态变量,UI就会自动刷新,我们无需手动操作DOM。

2.2 应用整体架构设计

根据提供的文件结构,我们可以清晰地看到这是一个典型的分层架构,这种结构让代码职责分明,易于维护:

.
├── config.json          // 应用配置文件,声明权限、路由等
├── ets
│   └── MainAbility
│       ├── app.ets              // 应用入口,全局配置
│       ├── data                 // 数据层:负责网络请求和数据获取
│       │   ├── get_cook_data.ets
│       │   └── get_test.ets
│       ├── model                // 模型层:定义数据结构(实体类)
│       │   ├── cookDetailModel.ets
│       │   ├── cookModel.ets
│       │   ├── materialModel.ets
│       │   └── processModel.ets
│       └── pages                // 视图层:所有UI页面
│           ├── Main.ets         // 主页面(搜索/列表)
│           ├── cookbookDetails.ets // 详情页
│           └── index.ets        // 应用首页(可能是一个入口或启动页)
└── resources                    // 资源文件(图片、字符串、颜色等)

各层职责解析:

  1. Model层 ( /model ) : 这是应用的“数据蓝图”。我们根据API返回的JSON结构,预先定义好对应的TypeScript类或接口。比如,一个菜谱( CookDetailData )包含名称、图片、材料数组( MaterialData )、步骤数组( ProcessData )。这样做的好处是,在代码的任何地方,我们都能明确知道正在操作的数据是什么形状,IDE也能提供智能提示和类型检查,避免出现 data.result.list[0].xxx 这种容易出错的链式调用。
  2. Data层 ( /data ) : 这是应用的“数据搬运工”。它不关心UI,只负责一件事:从网络(或本地)获取原始数据,并将其转换成Model层定义好的对象。 get_cook_data.ets 文件里就会封装网络请求的逻辑,对外提供一个如 fetchRecipes(keyword: string): Promise<CookModel> 这样的干净函数。任何页面需要数据,都调用这个函数。
  3. View层 ( /pages ) : 这是用户直接看到的部分。它负责渲染UI,并响应用户交互(如点击搜索、点击菜谱项)。它从Data层获取数据,然后使用ArkUI的组件(如 List , Text , Image )将数据展示出来。页面之间的跳转也在这里通过路由器( router )完成。
  4. 配置文件 ( config.json ) : 这是HarmonyOS应用的“身份证”和“通行证”。它定义了应用的基本信息、所需的系统权限(如网络访问)、以及页面路由规则。没有正确配置,应用可能无法联网或无法跳转页面。

这种“数据-模型-视图”分离的设计,让代码的测试、调试和后续功能扩展(比如增加收藏功能、缓存数据)都变得更容易。

2.3 第三方API接口分析

项目使用了“聚合数据”提供的菜谱查询API。我们仔细看一下这个接口:

  • 请求地址 : https://way.jd.com/jisuapi/search
  • 请求方式 : GET
  • 核心参数 :
    • keyword : 搜索关键词,如“白菜”。
    • num : 返回结果数量,如10。
    • start : 起始索引,用于分页,从0开始。
    • appkey : 你的个人密钥,用于身份验证。

注意:API密钥安全 :示例中的 appkey 是公开的,仅用于演示。在实际开发中, 绝对不要 将真实的API密钥硬编码在客户端代码里。一旦应用被反编译,密钥就会泄露,可能导致被盗用产生费用。正确的做法是搭建一个自己的后端服务,由后端去调用第三方API,或者使用HarmonyOS提供的更安全的密钥管理方式(如果API支持)。本项目为学习目的,暂不涉及后端,但你必须意识到这一点。

接口返回的JSON结构嵌套较深,这是我们设计Model层的依据。成功时, code ”10000” ,真正的数据藏在 result.result.list 路径下。每个菜谱对象包含了完整的详情,这意味我们可以在列表页先展示缩略信息(名称、图片),在详情页再展示全部信息,一次请求即可,无需为详情页再次请求。

3. 环境准备与项目初始化

工欲善其事,必先利其器。在开始编码前,我们需要把开发环境搭建好。

3.1 开发环境搭建

  1. 安装DevEco Studio : 前往HarmonyOS应用开发者官网,下载并安装最新版本的DevEco Studio。这是官方的集成开发环境,提供了项目创建、代码编辑、预览、调试、打包发布的全套工具链。
  2. 配置SDK : 首次打开DevEco Studio,它会引导你下载HarmonyOS SDK。确保SDK中包含你目标设备(如Phone)所需的版本。本项目基于较新的ArkUI eTS,建议使用API Version 9或更高版本。
  3. 准备测试设备/模拟器 : 你可以使用真实的HarmonyOS手机(开启开发者模式并连接电脑),也可以使用DevEco Studio内置的远程模拟器或本地模拟器(需要电脑性能较好)。对于网络请求测试,使用真机或能联网的模拟器更方便。

3.2 创建NutRecipes项目

打开DevEco Studio,点击 Create Project

  • 选择模板 : 选择 Empty Ability 模板,确保 UI Syntax eTS Language eTS 。这个模板会生成一个最干净的项目结构,适合我们从头构建。
  • 项目配置 :
    • Project Name : 输入 NutRecipes
    • Bundle Name : 这是应用包名,通常采用反域名格式,如 com.example.nutrecipes
    • Save Location : 选择你的项目存放路径。
    • Compile API Version : 选择9或更高。
    • 其他保持默认,点击 Finish

项目创建成功后,你会看到初始的文件结构,已经包含了 entry/src/main/ets/MainAbility/pages/index.ets 等基础文件。我们可以在此基础上,按照之前设计的架构,创建对应的文件夹和文件。

3.3 配置网络权限与明文传输

这是HarmonyOS安全机制的要求,应用访问网络必须显式声明权限,且默认禁止不安全的HTTP明文传输。

  1. 打开配置文件 : 找到 entry/src/main/resources/base/profile/config.json
  2. 声明网络权限 : 在 module 字段的 requestPermissions 数组中添加互联网权限声明。注意,文档中提到的 reqPermissions 是老版本写法,新版本已改为 requestPermissions
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      }
    ],
    ...
  }
}
  1. 允许HTTP明文请求 : 我们使用的示例API是HTTPS,但很多测试环境或内部API可能是HTTP。为了兼容性,我们在 deviceConfig 中配置允许明文传输。 请注意,上架到正式应用市场时,出于安全考虑,应尽可能使用HTTPS并移除此配置。
{
  "module": {
    ...
  },
  "deviceConfig": {
    "default": {
      "network": {
        "cleartextTraffic": true // 允许HTTP明文流量
      }
    }
  }
}

配置完成后,记得保存文件。这些配置会在应用安装时被系统读取,没有权限,你的网络请求将无法发出。

4. 数据模型层(Model)设计与实现

模型层是应用的基石,定义得好,后面写代码会事半功倍。我们根据API返回的JSON结构,自底向上地定义模型。

4.1 定义基础实体模型

首先,在 ets/MainAbility/model/ 目录下创建三个文件,分别对应材料、步骤和菜谱详情。

materialModel.ets (材料模型):

/*
 * 材料数据模型
 * 对应JSON中的 `material` 数组里的每个对象
 */
export class MaterialData {
  mname: string = ''; // 材料名称,如“白菜”
  type: string = '';  // 材料类型,示例中“0”可能代表调料,“1”代表主料
  amount: string = ''; // 用量,如“380g”、“适量”
}

这个类很简单,三个字段直接对应JSON的键。 type 字段在UI上可以用来区分主料和调料,用不同样式展示。

processModel.ets (步骤模型):

/*
 * 烹饪步骤数据模型
 * 对应JSON中的 `process` 数组里的每个对象
 */
export class ProcessData {
  pcontent: string = ''; // 步骤文字说明
  pic: string = '';     // 步骤图片URL
}

步骤模型包含描述和图片,在详情页我们会用图文并茂的方式展示。

cookDetailModel.ets (菜谱详情模型):

/*
 * 单个菜谱的完整详情数据模型
 */
import { MaterialData } from './materialModel';
import { ProcessData } from './processModel';

export class CookDetailData {
  id: string = '';           // 菜谱ID
  classid: string = '';      // 分类ID
  name: string = '';         // 菜谱名称
  peoplenum: string = '';    // 适合人数
  preparetime: string = '';  // 准备时间
  cookingtime: string = '';  // 烹饪时间
  content: string = '';      // 菜谱描述
  pic: string = '';          // 封面图URL
  tag: string = '';          // 标签(逗号分隔)
  material: Array<MaterialData> = []; // 材料数组
  process: Array<ProcessData> = [];   // 步骤数组
}

这里我们引入了之前定义的两个模型类,用 Array<MaterialData> 来声明 material process 字段的类型。这样,当我们从JSON解析出一个 CookDetailData 对象后,可以直接通过 cookDetail.material[0].mname 来访问材料名,TypeScript能提供完整的类型提示。

4.2 定义API响应模型

API的返回结构是多层嵌套的,我们需要定义外层模型来匹配它。

cookModel.ets (API响应总模型):

import { CookDetailData } from './cookDetailModel';

// 最内层的数据结构,包含列表和总数
export class CookModelInnerResult {
  num: string = ''; // 本次返回的菜谱数量
  list: Array<CookDetailData> = []; // 菜谱详情列表
}

// 接口返回的result字段内的结构
export class CookModelResult {
  status: string = ''; // 状态码,"0"表示成功
  msg: string = '';    // 状态信息,"ok"
  result: CookModelInnerResult = new CookModelInnerResult(); // 实际数据
}

// 整个API响应的顶层结构
export class CookModel {
  code: string = '';      // 聚合数据API状态码,"10000"表示成功
  charge: boolean = false; // 是否收费
  msg: string = '';       // 消息,如“查询成功”
  result: CookModelResult = new CookModelResult(); // 结果
}

这里做了三层封装,虽然看起来繁琐,但完美映射了JSON结构: CookModel.result.result.list 才是我们需要的菜谱数组。这种严谨的定义能确保在解析数据时不会因为路径错误而崩溃。

实操心得:模型定义的严谨性 在定义模型时,我建议每个字段都赋予一个默认值(如空字符串、空数组、0)。这可以避免在数据未加载或解析出错时出现 undefined 错误。特别是对于数组,初始化为空数组 [] ,这样即使在UI中直接使用 *ForEach 遍历,也不会报错。这是一种防御性编程的思想。

5. 数据层(Data)网络请求封装

数据层是连接网络和模型的桥梁。我们将网络请求的逻辑封装在这里,对外提供简洁的异步函数。

5.1 创建网络请求工具函数

ets/MainAbility/data/ 目录下创建 get_cook_data.ets 文件。

/*
 * 网络请求模块:获取菜谱数据
 */
import http from '@ohos.net.http'; // 导入HarmonyOS网络模块
import { CookModel } from '../model/cookModel'; // 导入响应模型
import prompt from '@ohos.promptAction'; // 导入提示框模块

export class CookDataService {
  // 你的API密钥(此处为示例,实际项目请妥善保管)
  private appKey: string = '7c913be32b690701cd994d804a6d4294';

  /**
   * 根据关键词搜索菜谱
   * @param keyword 搜索关键词,如“白菜”
   * @param num 返回数量,默认10
   * @param start 起始位置,用于分页,默认0
   * @returns 返回一个Promise,成功时解析为CookModel,失败时reject
   */
  async fetchRecipes(keyword: string, num: number = 10, start: number = 0): Promise<CookModel> {
    // 1. 构建请求URL
    const baseUrl = 'https://way.jd.com/jisuapi/search';
    const url = `${baseUrl}?keyword=${encodeURIComponent(keyword)}&num=${num}&start=${start}&appkey=${this.appKey}`;

    // 2. 创建HTTP请求对象
    // 注意:每个httpRequest对象最好只用于一次请求,不建议复用,以避免状态混乱。
    let httpRequest = http.createHttp();

    // 返回一个Promise,便于调用方使用async/await或.then()
    return new Promise((resolve, reject) => {
      // 3. 发起GET请求
      httpRequest.request(
        url,
        {
          method: http.RequestMethod.GET, // 明确指定GET方法
          // 可以在这里添加header,例如:header: { 'Content-Type': 'application/json' }
        },
        (err, data) => {
          // 4. 请求完成回调
          if (err) {
            // 网络层错误,如无网络、超时
            console.error(`网络请求失败: ${JSON.stringify(err)}`);
            prompt.showToast({ message: `网络错误: ${err.message || err.code}` });
            reject(err);
            return;
          }

          // 5. 检查HTTP状态码
          if (data.responseCode !== 200) {
            console.error(`HTTP状态码异常: ${data.responseCode}`);
            prompt.showToast({ message: `服务器异常: ${data.responseCode}` });
            reject(new Error(`HTTP ${data.responseCode}`));
            return;
          }

          // 6. 解析响应数据
          try {
            const resultStr = data.result.toString();
            console.info(`收到原始数据: ${resultStr.substring(0, 200)}...`); // 日志只打印前200字符
            const parsedData: CookModel = JSON.parse(resultStr);

            // 7. 检查业务状态码(聚合数据API的code)
            if (parsedData.code === '10000') {
              // 查询成功
              console.info(`数据解析成功,获取到 ${parsedData.result.result.list?.length || 0} 条菜谱`);
              resolve(parsedData);
            } else {
              // 业务逻辑错误,如appkey无效、参数错误
              console.error(`API业务错误: code=${parsedData.code}, msg=${parsedData.msg}`);
              prompt.showToast({ message: `查询失败: ${parsedData.msg}` });
              reject(new Error(`API Error: ${parsedData.msg}`));
            }
          } catch (parseError) {
            // JSON解析失败
            console.error(`JSON解析失败: ${parseError.message}`);
            prompt.showToast({ message: '数据格式错误' });
            reject(parseError);
          } finally {
            // 8. 释放请求对象资源
            httpRequest.destroy();
          }
        }
      );
    });
  }
}

5.2 关键代码解析与避坑指南

  1. URL编码 : 使用 encodeURIComponent(keyword) 对搜索关键词进行编码非常重要。如果用户输入了中文或特殊字符(如空格、 & ),不编码会导致URL格式错误,请求失败。
  2. Promise封装 : 将基于回调的 http.request 封装成返回 Promise async 函数,是现代JavaScript/TypeScript的通用做法。这样在UI页面中可以使用更清晰的 async/await 语法,避免“回调地狱”。
  3. 错误处理分层 : 错误处理分了三层:
    • 网络层错误 ( err ): 如无网络、DNS解析失败。
    • HTTP层错误 ( responseCode !== 200 ): 如404、500等服务器错误。
    • 业务层错误 ( parsedData.code !== ‘10000’ ): API逻辑错误。 分层处理能让问题定位更精准,给用户的提示也更友好。
  4. 资源释放 : 在 finally 块中调用 httpRequest.destroy() 是一个好习惯。虽然不调用可能也不会立即出错,但显式释放资源能避免潜在的内存泄漏,尤其是在频繁发起请求的场景下。
  5. 日志输出 : 使用 console.info console.error 输出关键日志,在调试时非常有用。注意,打印整个 resultStr 可能很长,可以截取前面一部分。

注意事项:异步操作与UI更新 网络请求是异步操作,意味着它不会阻塞UI线程。在请求过程中,UI应该是可交互的(比如显示一个加载动画)。我们的 fetchRecipes 函数返回 Promise ,在页面中调用时,一定要在适当的时机更新UI状态(如加载中、成功、失败),这部分逻辑我们将在页面层实现。

6. 视图层(View)主页面开发

主页面( Main.ets )承担着搜索入口和菜谱列表展示的核心功能。我们将使用 Column TextInput Button List 等基础组件来构建。

6.1 页面布局与状态定义

首先,在 ets/MainAbility/pages/ 目录下创建 Main.ets ,或者将原有的 index.ets 重命名并改造。

/*
 * 主页面:搜索和菜谱列表
 */
import { CookDataService } from '../data/get_cook_data'; // 导入数据服务
import { CookModel, CookDetailData } from '../model/cookModel'; // 导入模型

@Entry
@Component
struct MainPage {
  // 状态变量:搜索关键词
  @State keyword: string = '白菜'; // 给一个默认值方便测试
  // 状态变量:菜谱列表数据
  @State recipeList: Array<CookDetailData> = [];
  // 状态变量:加载状态
  @State isLoading: boolean = false;
  // 状态变量:错误信息
  @State errorMessage: string = '';

  // 数据服务实例
  private cookService: CookDataService = new CookDataService();

  build() {
    // 主容器,采用垂直布局
    Column() {
      // 1. 搜索区域
      this.buildSearchBar()

      // 2. 内容区域:根据状态显示加载中、错误或列表
      if (this.isLoading) {
        this.buildLoadingView()
      } else if (this.errorMessage) {
        this.buildErrorView()
      } else {
        this.buildRecipeListView()
      }
    }
    .width('100%')
    .height('100%')
    .padding(12) // 给整个页面加一点内边距
    .backgroundColor('#F5F5F5') // 设置一个浅灰色背景
  }
  // ... 后续会定义 buildSearchBar, buildLoadingView 等方法
}

状态管理解析:

  • @State keyword : 使用 @State 装饰器,意味着这个变量是组件的 状态 。当它的值改变时,ArkUI框架会自动重新渲染( build )所有依赖它的UI部分。这里绑定到搜索输入框。
  • @State recipeList : 核心数据状态。当网络请求成功并赋值给它时,列表UI会自动更新。
  • @State isLoading @State errorMessage : 用于控制UI在不同状态(加载中、成功、失败)下的显示。这是一种非常清晰的状态驱动UI的模式。

6.2 构建搜索栏与交互

MainPage 结构体内,继续添加构建搜索栏的方法:

  // 构建搜索栏组件
  @Builder
  buildSearchBar() {
    Row() {
      // 文本输入框
      TextInput({
        placeholder: '请输入食材或菜名,如:白菜、红烧肉',
        text: this.keyword
      })
      .width('80%')
      .height(40)
      .padding(8)
      .borderRadius(20) // 圆角
      .backgroundColor('#FFFFFF')
      .onChange((value: string) => {
        // 输入框内容变化时,同步更新状态变量
        this.keyword = value;
      })

      // 搜索按钮
      Button('搜索')
        .width('18%')
        .height(40)
        .margin({ left: 8 })
        .fontColor('#FFFFFF')
        .backgroundColor('#FF6B81') // 设置一个主题色
        .borderRadius(20)
        .onClick(() => {
          // 点击搜索按钮时,触发搜索操作
          this.performSearch();
        })
    }
    .width('100%')
    .margin({ bottom: 16 }) // 与下方内容间隔
    .justifyContent(FlexAlign.SpaceBetween)
  }

@Builder 装饰器用于定义一个UI构建函数,它返回一个UI描述。这里我们将搜索栏抽离成一个独立的方法,让 build 函数更清晰。 TextInput text 属性通过 this.keyword 实现了双向绑定。 onClick 事件绑定了 performSearch 方法,我们接下来就实现它。

6.3 实现搜索数据获取逻辑

MainPage 结构体内添加执行搜索的方法:

  // 执行搜索
  async performSearch() {
    // 1. 输入验证
    if (!this.keyword.trim()) {
      prompt.showToast({ message: '请输入搜索关键词' });
      return;
    }

    // 2. 更新状态:开始加载,清空旧数据和错误信息
    this.isLoading = true;
    this.errorMessage = '';
    this.recipeList = []; // 清空旧列表,提供更好的用户体验

    try {
      // 3. 调用数据层方法
      const result: CookModel = await this.cookService.fetchRecipes(this.keyword.trim());
      
      // 4. 请求成功,更新数据状态
      // 注意:API返回的数据路径较深,需要逐层访问
      const list = result?.result?.result?.list;
      if (list && list.length > 0) {
        this.recipeList = list;
        prompt.showToast({ message: `找到 ${list.length} 个菜谱` });
      } else {
        this.errorMessage = '未找到相关菜谱,换个关键词试试吧~';
        prompt.showToast({ message: '未找到相关菜谱' });
      }
    } catch (error) {
      // 5. 请求失败,更新错误状态
      console.error('搜索失败:', error);
      this.errorMessage = `搜索失败: ${error.message || '未知错误'}`;
    } finally {
      // 6. 无论成功失败,都结束加载状态
      this.isLoading = false;
    }
  }

这个方法清晰地展示了异步数据处理的完整流程:验证 -> 设置加载状态 -> 发起请求 -> 处理成功/失败 -> 更新UI状态。使用 try...catch...finally 能确保异常被捕获,且 isLoading 状态最终会被重置。

6.4 构建列表与列表项

接下来,我们实现加载中视图、错误视图和最重要的列表视图。

  // 构建加载中视图
  @Builder
  buildLoadingView() {
    Column() {
      LoadingProgress() // HarmonyOS提供的加载进度组件
        .width(50)
        .height(50)
        .color('#FF6B81')
      Text('正在努力搜索菜谱...')
        .fontSize(16)
        .margin({ top: 12 })
        .fontColor('#666666')
    }
    .width('100%')
    .height('60%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

  // 构建错误视图
  @Builder
  buildErrorView() {
    Column() {
      Image($r('app.media.ic_error')) // 假设在resources/base/media添加了一个错误图标
        .width(100)
        .height(100)
        .objectFit(ImageFit.Contain)
      Text(this.errorMessage)
        .fontSize(16)
        .margin({ top: 20 })
        .textAlign(TextAlign.Center)
        .fontColor('#FF3333')
      Button('重试')
        .margin({ top: 20 })
        .onClick(() => {
          this.performSearch();
        })
    }
    .width('100%')
    .height('60%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

  // 构建菜谱列表视图
  @Builder
  buildRecipeListView() {
    if (this.recipeList.length === 0) {
      // 列表为空时的提示(例如首次进入未搜索)
      this.buildEmptyView()
    } else {
      // 使用List组件展示可滚动列表
      List({ space: 12 }) { // space设置列表项之间的间距
        // 使用ForEach循环渲染每个菜谱项
        ForEach(this.recipeList, (item: CookDetailData, index: number) => {
          ListItem() {
            this.buildRecipeItem(item, index)
          }
        }, (item: CookDetailData) => item.id) // 第三个参数是key生成函数,用于列表项高效复用。这里使用菜谱id作为key。
      }
      .width('100%')
      .layoutWeight(1) // 占据剩余所有垂直空间
      .divider({ // 设置列表分割线
        strokeWidth: 1,
        color: '#EEEEEE',
        startMargin: 16,
        endMargin: 16
      })
    }
  }

  // 构建单个菜谱列表项
  @Builder
  buildRecipeItem(item: CookDetailData, index: number) {
    // 使用Row实现横向布局:图片在左,文字在右
    Row() {
      // 菜谱封面图
      Image(item.pic)
        .width(80)
        .height(80)
        .borderRadius(8) // 图片圆角
        .objectFit(ImageFit.Cover) // 覆盖模式,保持比例填满,可能裁剪

      // 文字信息区域,用Column纵向排列
      Column() {
        // 菜谱名称
        Text(item.name)
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')
          .maxLines(1) // 限制一行显示
          .textOverflow({ overflow: TextOverflow.Ellipsis }) // 超出显示省略号
          .margin({ bottom: 4 })

        // 菜谱描述(简介)
        Text(item.content)
          .fontSize(14)
          .fontColor('#666666')
          .maxLines(2) // 限制两行
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ bottom: 4 })

        // 附加信息:人数、时间、标签
        Row() {
          Text(`👥 ${item.peoplenum}`)
            .fontSize(12)
            .fontColor('#888888')
          Text(`  ⏱️ ${item.preparetime}`)
            .fontSize(12)
            .fontColor('#888888')
            .margin({ left: 8 })
          Text(`  🔖 ${item.tag.split(',')[0]}`) // 只显示第一个标签
            .fontSize(12)
            .fontColor('#888888')
            .margin({ left: 8 })
        }
        .width('100%')
        .margin({ top: 4 })
      }
      .margin({ left: 12 })
      .layoutWeight(1) // 让文字区域占据Row的剩余宽度
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(12) // 整个列表项圆角
    .onClick(() => {
      // 点击列表项,跳转到详情页,并传递当前菜谱数据
      this.navigateToDetail(item);
    })
  }

  // 构建空状态视图
  @Builder
  buildEmptyView() {
    Column() {
      Image($r('app.media.ic_empty')) // 空状态图标
        .width(120)
        .height(120)
        .objectFit(ImageFit.Contain)
      Text('暂无菜谱数据\n尝试搜索一下吧!')
        .fontSize(16)
        .margin({ top: 20 })
        .textAlign(TextAlign.Center)
        .fontColor('#999999')
    }
    .width('100%')
    .height('60%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

6.5 实现页面路由跳转

最后,实现点击列表项跳转到详情页的逻辑。我们需要用到HarmonyOS的路由器 router

  // 跳转到菜谱详情页
  navigateToDetail(recipe: CookDetailData) {
    // 使用router.pushUrl进行页面跳转。
    // 第二个参数是路由参数,这里我们将整个菜谱对象序列化成JSON字符串传递过去。
    // 注意:传递大量数据时,需考虑URL长度限制,对于复杂对象,更好的做法是只传递ID,详情页再根据ID请求数据。
    // 本项目为简化,直接传递对象。
    router.pushUrl({
      url: 'pages/cookbookDetails', // 目标页面的路径,在config.json中配置
      params: { recipeData: JSON.stringify(recipe) } // 传递参数
    }).catch((err) => {
      console.error('跳转详情页失败:', err);
      prompt.showToast({ message: '跳转失败' });
    });
  }

实操心得:List组件的性能优化

  1. Key的重要性 ForEach 的第三个参数 (item) => item.id 为每个列表项提供了一个稳定且唯一的标识(key)。当列表数据变化时,ArkUI框架通过key能高效地识别哪些项是新增、移动或删除的,从而最小化UI更新,提升列表滚动性能。如果数据没有唯一id,可以用索引 index ,但这不是最佳实践。
  2. 避免在 build 中执行复杂计算 build 函数会被频繁调用。像 item.tag.split(',')[0] 这样的简单操作可以,但应避免在 build @Builder 函数中进行数据过滤、排序等耗时操作。这些操作应在数据赋值给 @State 变量之前完成。
  3. 图片优化 :网络图片加载需要时间。在实际项目中,应考虑使用图片缓存库,或对图片进行压缩、懒加载(HarmonyOS List本身支持懒加载),以提升列表滚动流畅度。

7. 视图层(View)详情页开发

详情页( cookbookDetails.ets )负责展示一个菜谱的所有信息。我们需要从路由参数中接收数据,并用丰富的组件进行布局。

7.1 详情页结构与数据接收

pages 目录下创建 cookbookDetails.ets

/*
 * 菜谱详情页
 */
import { CookDetailData } from '../model/cookDetailModel';
import { MaterialData } from '../model/materialModel';
import { ProcessData } from '../model/processModel';
import router from '@ohos.router';

@Entry
@Component
struct CookbookDetailsPage {
  // 通过@State接收并管理从主页面传递过来的菜谱数据
  @State recipeDetail: CookDetailData = new CookDetailData();

  onPageShow() {
    // 页面显示时,从路由参数中获取数据
    const params = router.getParams() as Record<string, string>;
    if (params && params['recipeData']) {
      try {
        const parsedData: CookDetailData = JSON.parse(params['recipeData']);
        this.recipeDetail = parsedData;
      } catch (error) {
        console.error('解析菜谱数据失败:', error);
        // 可以在这里显示错误提示,并返回上一页
        prompt.showToast({ message: '数据加载失败' });
        router.back();
      }
    } else {
      // 没有接收到数据,返回
      prompt.showToast({ message: '未获取到菜谱信息' });
      router.back();
    }
  }

  build() {
    // 使用Scroll容器,因为内容可能超出一屏
    Scroll() {
      Column() {
        // 1. 顶部横幅图
        this.buildHeaderImage()

        // 2. 基础信息卡片
        this.buildBasicInfoCard()

        // 3. 材料准备卡片
        this.buildMaterialsCard()

        // 4. 烹饪步骤卡片
        this.buildStepsCard()

        // 5. 小贴士卡片
        this.buildTipsCard()
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
    .scrollBar(BarState.Off) // 隐藏滚动条,视觉更简洁
  }
  // ... 后续定义各个构建方法
}

这里的关键是 onPageShow 生命周期函数。它在页面显示时被调用,我们从路由参数 router.getParams() 中取出之前传递的JSON字符串,并解析成 CookDetailData 对象,赋值给 @State recipeDetail ,从而触发UI更新。

7.2 构建详情页各区域UI

由于详情页内容较多,我们分块构建。首先是顶部横幅图:

  // 构建顶部横幅图
  @Builder
  buildHeaderImage() {
    Stack() { // 使用Stack实现图片上叠加文字的效果
      // 菜谱封面大图
      Image(this.recipeDetail.pic)
        .width('100%')
        .height(250)
        .objectFit(ImageFit.Cover)

      // 半透明遮罩,让白色文字更清晰
      Column()
        .width('100%')
        .height(250)
        .backgroundColor('#000000')
        .opacity(0.3)

      // 菜谱名称,叠加在图片上
      Text(this.recipeDetail.name)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .maxLines(2)
        .margin({ left: 20, right: 20 })
        .alignSelf(ItemAlign.Start) // 对齐到Stack的底部
    }
    .width('100%')
    .height(250)
    .margin({ bottom: 16 })
  }

接着是基础信息卡片,展示人数、时间、标签:

  // 构建基础信息卡片
  @Builder
  buildBasicInfoCard() {
    Column() {
      // 使用Grid网格布局来排列三个信息块
      GridRow() {
        GridCol({ span: { xs: 8, sm: 8, md: 4 } }) { // 响应式布局,在不同屏幕宽度下占不同列数
          Column() {
            Image($r('app.media.ic_people')) // 人数图标
              .width(24)
              .height(24)
              .margin({ bottom: 8 })
            Text('人数')
              .fontSize(12)
              .fontColor('#888888')
              .margin({ bottom: 4 })
            Text(this.recipeDetail.peoplenum)
              .fontSize(16)
              .fontWeight(FontWeight.Medium)
              .fontColor('#333333')
          }
          .alignItems(HorizontalAlign.Center)
        }
        // 准备时间
        GridCol({ span: { xs: 8, sm: 8, md: 4 } }) {
          Column() {
            Image($r('app.media.ic_prepare')) // 准备时间图标
              .width(24)
              .height(24)
              .margin({ bottom: 8 })
            Text('准备')
              .fontSize(12)
              .fontColor('#888888')
              .margin({ bottom: 4 })
            Text(this.recipeDetail.preparetime)
              .fontSize(16)
              .fontWeight(FontWeight.Medium)
              .fontColor('#333333')
          }
          .alignItems(HorizontalAlign.Center)
        }
        // 烹饪时间
        GridCol({ span: { xs: 8, sm: 8, md: 4 } }) {
          Column() {
            Image($r('app.media.ic_cook')) // 烹饪时间图标
              .width(24)
              .height(24)
              .margin({ bottom: 8 })
            Text('烹饪')
              .fontSize(12)
              .fontColor('#888888')
              .margin({ bottom: 4 })
            Text(this.recipeDetail.cookingtime)
              .fontSize(16)
              .fontWeight(FontWeight.Medium)
              .fontColor('#333333')
          }
          .alignItems(HorizontalAlign.Center)
        }
      }
      .padding(20)
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .margin({ bottom: 16, left: 16, right: 16 })
    }
    .width('100%')
  }

然后是材料准备卡片,这里我们区分主料和调料:

  // 构建材料准备卡片
  @Builder
  buildMaterialsCard() {
    Column() {
      // 卡片标题
      Row() {
        Text('🥦 材料准备')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Blank() // 空白填充,将按钮推到右边
        // 可以在这里添加一个“一键加入购物车”的按钮(扩展功能)
      }
      .width('100%')
      .margin({ bottom: 16 })

      // 分割线
      Divider()
        .strokeWidth(1)
        .color('#EEEEEE')
        .margin({ bottom: 16 })

      // 主料区域
      if (this.recipeDetail.material.filter(item => item.type === '1').length > 0) {
        Text('主料')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#FF6B81')
          .margin({ bottom: 8 })
          .alignSelf(ItemAlign.Start)
        this.buildMaterialList(this.recipeDetail.material.filter(item => item.type === '1'))
      }

      // 调料区域
      if (this.recipeDetail.material.filter(item => item.type === '0').length > 0) {
        Text('调料')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#FF6B81')
          .margin({ top: 16, bottom: 8 })
          .alignSelf(ItemAlign.Start)
        this.buildMaterialList(this.recipeDetail.material.filter(item => item.type === '0'))
      }
    }
    .padding(20)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .margin({ bottom: 16, left: 16, right: 16 })
  }

  // 构建材料列表(通用方法)
  @Builder
  buildMaterialList(materials: Array<MaterialData>) {
    Column() {
      ForEach(materials, (item: MaterialData) => {
        Row() {
          Text(item.mname)
            .fontSize(16)
            .fontColor('#555555')
            .layoutWeight(1) // 材料名占据大部分空间
          Text(item.amount)
            .fontSize(16)
            .fontColor('#888888')
            .fontWeight(FontWeight.Medium)
        }
        .width('100%')
        .padding({ top: 8, bottom: 8 })
        .borderRadius(8)
        .backgroundColor('#F9F9F9')
        .margin({ bottom: 6 })
      })
    }
  }

烹饪步骤卡片是最复杂的,因为要图文混排:

  // 构建烹饪步骤卡片
  @Builder
  buildStepsCard() {
    Column() {
      Text('👨‍🍳 烹饪步骤')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ bottom: 16 })
        .alignSelf(ItemAlign.Start)

      ForEach(this.recipeDetail.process, (step: ProcessData, index: number) => {
        Column() {
          // 步骤序号和标题
          Row() {
            Text(`STEP ${index + 1}`)
              .fontSize(14)
              .fontColor('#FFFFFF')
              .backgroundColor('#FF6B81')
              .padding({ left: 8, right: 8, top: 4, bottom: 4 })
              .borderRadius(12)
            Text(` ${step.pcontent}`) // 步骤描述
              .fontSize(16)
              .fontColor('#333333')
              .layoutWeight(1)
              .margin({ left: 12 })
          }
          .width('100%')
          .margin({ bottom: 12 })

          // 步骤图片(如果有)
          if (step.pic && step.pic.length > 0) {
            Image(step.pic)
              .width('100%')
              .height(200)
              .borderRadius(12)
              .objectFit(ImageFit.Cover)
              .margin({ bottom: 20 })
          }

          // 步骤间的分割线(非最后一步)
          if (index < this.recipeDetail.process.length - 1) {
            Divider()
              .strokeWidth(1)
              .color('#EEEEEE')
              .margin({ bottom: 20 })
          }
        }
        .width('100%')
      })
    }
    .padding(20)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .margin({ bottom: 16, left: 16, right: 16 })
  }

最后,可以添加一个展示菜谱描述的小贴士卡片:

  // 构建小贴士卡片(展示菜谱描述)
  @Builder
  buildTipsCard() {
    if (this.recipeDetail.content && this.recipeDetail.content.trim().length > 0) {
      Column() {
        Text('💡 小贴士')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .margin({ bottom: 16 })
          .alignSelf(ItemAlign.Start)

        Text(this.recipeDetail.content)
          .fontSize(15)
          .fontColor('#666666')
          .lineHeight(24) // 设置行高,提升阅读体验
          .textAlign(TextAlign.Start)
      }
      .padding(20)
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .margin({ bottom: 32, left: 16, right: 16 }) // 底部留出更多空间
    }
  }

7.3 配置页面路由

为了让 router.pushUrl 能正确跳转到详情页,我们需要在 config.json 中注册这个页面。

打开 entry/src/main/resources/base/profile/config.json ,找到 pages 节点,它应该已经包含了 ”pages/index” 。我们需要修改它,指向我们实际的主页和详情页。

{
  "module": {
    ...
    "pages": "$profile:main_pages",
    ...
  }
}

然后,在 entry/src/main/resources/base/profile/ 目录下(如果没有则创建),找到或创建 main_pages.json 文件,内容如下:

{
  "src": [
    "pages/Main",        // 主页面(搜索列表页)
    "pages/cookbookDetails" // 菜谱详情页
  ]
}

这样配置后, router.pushUrl({ url: ‘pages/cookbookDetails’ }) 就能找到对应的页面了。

8. 应用入口与全局配置

最后,我们来看一下应用入口文件 app.ets 和首页 index.ets 的简单配置。

app.ets 通常用于应用级别的配置,例如定义全局的UI样式、注册自定义组件等。在本项目中,我们可以保持其简洁:

/*
 * 应用入口文件
 */
import { CookDataService } from './data/get_cook_data';

export default class App {
  // 可以在这里创建全局单例,如数据服务实例(如果需要跨页面共享)
  // private static cookService: CookDataService = new CookDataService();

  // 应用启动时调用
  onCreate() {
    console.info('NutRecipes Application onCreate');
    // 可以在这里进行一些全局初始化操作,例如初始化缓存、配置网络库等。
  }

  // 应用销毁时调用
  onDestroy() {
    console.info('NutRecipes Application onDestroy');
  }
}

index.ets 是应用启动后显示的第一个页面。在我们的架构中,主页面是 Main.ets ,所以 index.ets 可以非常简单,直接跳转到主页面,或者作为一个简单的启动页/导航页。这里我们采用直接跳转的方式:

/*
 * 应用首页/入口页
 */
import router from '@ohos.router';

@Entry
@Component
struct Index {
  onPageShow() {
    // 页面显示时,直接跳转到主页面
    // 设置延迟,可以展示一个启动屏(Splash Screen)效果
    setTimeout(() => {
      router.replaceUrl({
        url: 'pages/Main' // 替换当前页面,这样按返回键不会回到index页
      }).catch(err => {
        console.error('跳转到Main页失败:', err);
      });
    }, 500); // 延迟500毫秒,让启动图停留一会儿
  }

  build() {
    // 可以在这里设计一个简单的启动屏UI
    Column() {
      Image($r('app.media.icon')) // 应用图标
        .width(120)
        .height(120)
        .margin({ bottom: 30 })
      Text('NutRecipes')
        .fontSize(32)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FF6B81')
      Text('发现美味,轻松下厨')
        .fontSize(16)
        .fontColor('#666666')
        .margin({ top: 10 })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .backgroundColor('#FFFFFF')
  }
}

使用 router.replaceUrl 而不是 pushUrl ,可以避免用户按返回键时又回到这个启动页,体验更自然。

9. 项目运行、调试与优化

代码写完了,我们来看看如何运行、调试,并探讨一些优化方向。

9.1 在模拟器或真机上运行

  1. 在DevEco Studio右上角,选择你的目标设备(例如 Phone 类型的模拟器)。
  2. 点击绿色的运行按钮(或按 Shift+F10 )。
  3. DevEco Studio会自动编译项目,安装应用到模拟器/真机,并启动应用。
  4. 首次运行可能会提示你安装必要的签名证书,按照向导操作即可(开发阶段可以使用自动生成的调试证书)。

9.2 核心功能测试点

应用启动后,请重点测试以下流程:

  1. 网络权限 :首次启动,应用是否请求了网络权限?如果没有,检查 config.json 配置。
  2. 搜索功能 :在主页面输入框输入“白菜”、“番茄”等关键词,点击搜索按钮。观察:
    • 是否显示“加载中”状态?
    • 成功后,列表是否正常显示(图片、标题、描述)?
    • 列表项样式是否符合预期?
  3. 列表交互 :点击任意一个菜谱列表项。
    • 是否能成功跳转到详情页?
    • 详情页的图片、基本信息、材料、步骤是否都正确显示?
    • 图文混排的步骤区域,图片加载是否正常?
  4. 错误处理
    • 断开网络,再次搜索,是否显示友好的错误提示和重试按钮?
    • 输入一个不存在的关键词(如“asdfghj”),是否提示“未找到相关菜谱”?
    • 点击重试按钮,功能是否正常?

9.3 常见问题排查(FAQ)

在开发过程中,你可能会遇到以下问题,这里提供排查思路:

问题现象 可能原因 解决方案
应用安装失败 1. 签名证书问题。
2. 设备上已存在相同包名但签名不同的应用。
1. 检查DevEco Studio的签名配置,确保使用正确的调试证书。
2. 卸载设备上已有的同名应用。
搜索无反应,控制台无网络请求日志 1. 网络权限未配置或配置错误。
2. cleartextTraffic 未开启(如果API是HTTP)。
3. 真机未开启网络或应用权限。
1. 仔细核对 config.json requestPermissions cleartextTraffic 的配置。
2. 在真机的“设置-应用管理”中,找到本应用,确保“网络”权限已开启。
能搜索但列表为空,控制台打印了API返回数据 1. 数据模型(Model)定义与API返回结构不匹配。
2. JSON解析路径错误。
1. 在 get_cook_data.ets 的请求成功回调中,用 console.info(JSON.stringify(parsedData)) 打印解析后的完整对象,对比与 CookModel 的定义是否一致。
2. 检查访问数据的路径,例如 parsedData.result.result.list
列表图片不显示或显示很慢 1. 网络图片加载慢。
2. 图片URL无效。
3. 未处理图片加载失败状态。
1. 考虑实现图片缓存(可使用第三方库或 Image 组件的 onComplete / onError 回调设置占位图)。
2. 在 buildRecipeItem 中为 Image 组件添加 .alt(‘加载失败’) 属性。
3. 对于大量图片的列表,确保 List 组件在滚动时,非可视区域的图片加载被合理管理(ArkUI List自带一定优化)。
点击列表项无法跳转,或跳转后详情页空白 1. 路由 url 配置错误。
2. 详情页未在 config.json pages 列表中注册。
3. 传递的参数在详情页解析失败。
1. 确认 router.pushUrl 中的 url main_pages.json 中配置的路径完全一致(不含文件后缀)。
2. 检查 main_pages.json 文件是否存在且格式正确。
3. 在详情页 onPageShow 中打印 router.getParams() ,检查传递的数据格式。确保传递的是字符串,且详情页用 JSON.parse 正确解析。
UI布局在真机上显示错乱 1. 使用了固定的像素尺寸,未适配不同屏幕。
2. 布局组件属性使用不当。
1. 多使用百分比( ‘100%’ )、弹性布局( Flex Row Column layoutWeight )和相对单位( vp )。
2. 利用 GridRow GridCol 进行响应式布局。多在预览器和不同分辨率的模拟器上测试。

9.4 项目优化与扩展思路

这个基础版本已经实现了核心功能,但还有很大的优化和扩展空间:

  1. 数据持久化 :使用HarmonyOS的 Preferences 或关系型数据库,实现搜索历史、收藏菜谱功能。用户收藏的菜谱可以离线查看。
  2. 图片缓存与加载优化 :集成一个轻量级的图片缓存库,避免重复请求网络图片,提升列表流畅度和用户体验。
  3. 状态管理进阶 :随着应用复杂,多个页面可能需要共享状态(如用户信息、主题)。可以考虑引入更专业的状态管理方案,如 AppStorage (应用级状态)或 LocalStorage (页面级状态共享)。
  4. 组件化重构 :将 buildRecipeItem buildMaterialList 等UI片段抽离成独立的 @Component 组件。这样能提高代码复用性,使主文件更清晰,也便于单独维护和测试。
  5. API密钥安全 :如前所述,将API密钥硬编码在客户端是危险的。学习如何搭建一个简单的后端服务(如使用云函数),由后端持有密钥并转发请求。
  6. 用户体验提升
    • 在主页面添加下拉刷新功能( Refresh 组件)。
    • 实现上拉加载更多(监听列表滚动到底部,修改 fetchRecipes start 参数)。
    • 为详情页的步骤图片添加点击放大查看功能。
    • 增加分享菜谱到其他应用的功能。

这个NutRecipes项目就像一块很好的敲门砖,它串联起了HarmonyOS应用开发中最常用、最核心的技术点。当你把它完整实现一遍后,再去看官方文档或其他复杂案例,会发现很多概念都变得亲切和容易理解了。开发的过程就是不断踩坑和填坑的过程,遇到问题多查文档、多调试、多思考,能力自然就上去了。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐