HarmonyOS ArkUI eTS实战:从零开发菜谱应用,掌握网络请求与数据解析
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 // 资源文件(图片、字符串、颜色等)
各层职责解析:
-
Model层 (
/model) : 这是应用的“数据蓝图”。我们根据API返回的JSON结构,预先定义好对应的TypeScript类或接口。比如,一个菜谱(CookDetailData)包含名称、图片、材料数组(MaterialData)、步骤数组(ProcessData)。这样做的好处是,在代码的任何地方,我们都能明确知道正在操作的数据是什么形状,IDE也能提供智能提示和类型检查,避免出现data.result.list[0].xxx这种容易出错的链式调用。 -
Data层 (
/data) : 这是应用的“数据搬运工”。它不关心UI,只负责一件事:从网络(或本地)获取原始数据,并将其转换成Model层定义好的对象。get_cook_data.ets文件里就会封装网络请求的逻辑,对外提供一个如fetchRecipes(keyword: string): Promise<CookModel>这样的干净函数。任何页面需要数据,都调用这个函数。 -
View层 (
/pages) : 这是用户直接看到的部分。它负责渲染UI,并响应用户交互(如点击搜索、点击菜谱项)。它从Data层获取数据,然后使用ArkUI的组件(如List,Text,Image)将数据展示出来。页面之间的跳转也在这里通过路由器(router)完成。 -
配置文件 (
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 开发环境搭建
- 安装DevEco Studio : 前往HarmonyOS应用开发者官网,下载并安装最新版本的DevEco Studio。这是官方的集成开发环境,提供了项目创建、代码编辑、预览、调试、打包发布的全套工具链。
- 配置SDK : 首次打开DevEco Studio,它会引导你下载HarmonyOS SDK。确保SDK中包含你目标设备(如Phone)所需的版本。本项目基于较新的ArkUI eTS,建议使用API Version 9或更高版本。
- 准备测试设备/模拟器 : 你可以使用真实的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明文传输。
-
打开配置文件
: 找到
entry/src/main/resources/base/profile/config.json。 -
声明网络权限
: 在
module字段的requestPermissions数组中添加互联网权限声明。注意,文档中提到的reqPermissions是老版本写法,新版本已改为requestPermissions。
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
],
...
}
}
-
允许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 关键代码解析与避坑指南
-
URL编码
: 使用
encodeURIComponent(keyword)对搜索关键词进行编码非常重要。如果用户输入了中文或特殊字符(如空格、&),不编码会导致URL格式错误,请求失败。 -
Promise封装
: 将基于回调的
http.request封装成返回Promise的async函数,是现代JavaScript/TypeScript的通用做法。这样在UI页面中可以使用更清晰的async/await语法,避免“回调地狱”。 -
错误处理分层
: 错误处理分了三层:
-
网络层错误
(
err): 如无网络、DNS解析失败。 -
HTTP层错误
(
responseCode !== 200): 如404、500等服务器错误。 -
业务层错误
(
parsedData.code !== ‘10000’): API逻辑错误。 分层处理能让问题定位更精准,给用户的提示也更友好。
-
网络层错误
(
-
资源释放
: 在
finally块中调用httpRequest.destroy()是一个好习惯。虽然不调用可能也不会立即出错,但显式释放资源能避免潜在的内存泄漏,尤其是在频繁发起请求的场景下。 -
日志输出
: 使用
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组件的性能优化
- Key的重要性 :
ForEach的第三个参数(item) => item.id为每个列表项提供了一个稳定且唯一的标识(key)。当列表数据变化时,ArkUI框架通过key能高效地识别哪些项是新增、移动或删除的,从而最小化UI更新,提升列表滚动性能。如果数据没有唯一id,可以用索引index,但这不是最佳实践。- 避免在
build中执行复杂计算 :build函数会被频繁调用。像item.tag.split(',')[0]这样的简单操作可以,但应避免在build或@Builder函数中进行数据过滤、排序等耗时操作。这些操作应在数据赋值给@State变量之前完成。- 图片优化 :网络图片加载需要时间。在实际项目中,应考虑使用图片缓存库,或对图片进行压缩、懒加载(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 在模拟器或真机上运行
-
在DevEco Studio右上角,选择你的目标设备(例如
Phone类型的模拟器)。 -
点击绿色的运行按钮(或按
Shift+F10)。 - DevEco Studio会自动编译项目,安装应用到模拟器/真机,并启动应用。
- 首次运行可能会提示你安装必要的签名证书,按照向导操作即可(开发阶段可以使用自动生成的调试证书)。
9.2 核心功能测试点
应用启动后,请重点测试以下流程:
-
网络权限
:首次启动,应用是否请求了网络权限?如果没有,检查
config.json配置。 -
搜索功能
:在主页面输入框输入“白菜”、“番茄”等关键词,点击搜索按钮。观察:
- 是否显示“加载中”状态?
- 成功后,列表是否正常显示(图片、标题、描述)?
- 列表项样式是否符合预期?
-
列表交互
:点击任意一个菜谱列表项。
- 是否能成功跳转到详情页?
- 详情页的图片、基本信息、材料、步骤是否都正确显示?
- 图文混排的步骤区域,图片加载是否正常?
-
错误处理
:
- 断开网络,再次搜索,是否显示友好的错误提示和重试按钮?
- 输入一个不存在的关键词(如“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 项目优化与扩展思路
这个基础版本已经实现了核心功能,但还有很大的优化和扩展空间:
-
数据持久化
:使用HarmonyOS的
Preferences或关系型数据库,实现搜索历史、收藏菜谱功能。用户收藏的菜谱可以离线查看。 - 图片缓存与加载优化 :集成一个轻量级的图片缓存库,避免重复请求网络图片,提升列表流畅度和用户体验。
-
状态管理进阶
:随着应用复杂,多个页面可能需要共享状态(如用户信息、主题)。可以考虑引入更专业的状态管理方案,如
AppStorage(应用级状态)或LocalStorage(页面级状态共享)。 -
组件化重构
:将
buildRecipeItem、buildMaterialList等UI片段抽离成独立的@Component组件。这样能提高代码复用性,使主文件更清晰,也便于单独维护和测试。 - API密钥安全 :如前所述,将API密钥硬编码在客户端是危险的。学习如何搭建一个简单的后端服务(如使用云函数),由后端持有密钥并转发请求。
-
用户体验提升
:
-
在主页面添加下拉刷新功能(
Refresh组件)。 -
实现上拉加载更多(监听列表滚动到底部,修改
fetchRecipes的start参数)。 - 为详情页的步骤图片添加点击放大查看功能。
- 增加分享菜谱到其他应用的功能。
-
在主页面添加下拉刷新功能(
这个NutRecipes项目就像一块很好的敲门砖,它串联起了HarmonyOS应用开发中最常用、最核心的技术点。当你把它完整实现一遍后,再去看官方文档或其他复杂案例,会发现很多概念都变得亲切和容易理解了。开发的过程就是不断踩坑和填坑的过程,遇到问题多查文档、多调试、多思考,能力自然就上去了。
更多推荐


所有评论(0)