编程实验:开发商铺列表页
本实验将基于 05_storelist 模板工程,完成鸿蒙点餐 APP 中「点餐」Tab 的商铺列表页开发。最终页面需要从服务端拉取商铺数据,并展示商铺图片、名称、销量、人均价格、商铺描述、营业状态和距离信息。
本实验主要完成以下任务:
- 搭建商铺列表页的 Loading、空数据、异常、内容四种页面状态
- 为商铺列表页增加下拉刷新能力
- 根据服务端接口返回字段设计
StoreData数据类 - 在
ServerApi中封装商铺列表接口请求 - 使用
BasicDataSource、List与LazyForEach渲染商铺列表 - 新建
StoreItemView组件完成商铺列表小项 UI
最终效果
本实验涉及的知识点
| 知识点 | 使用场景 |
|---|---|
Stack | 在同一块内容区域内按页面状态切换 Loading、空数据、异常和内容视图 |
PageStatus | 记录当前页面处于加载中、显示内容、空数据、异常或刷新状态 |
Refresh | 为商铺列表增加下拉刷新能力 |
class-transformer | 将服务端返回的 JSON 数据转换为 ArkTS 数据类对象 |
@Expose | 将服务端字段 img 映射为页面更容易理解的 imageUrl |
BasicDataSource | 作为 LazyForEach 的数据源,通知列表刷新 |
LazyForEach | 根据数据源懒加载列表小项 |
@Builder | 将商铺列表区域封装为可复用的 UI 构建函数 |
页面状态切换策略
| 场景 | 页面状态 | 页面表现 |
|---|---|---|
| 首次进入页面 | PageStatus.Loading | 显示加载中视图 |
| 首次加载失败 | PageStatus.Error | 显示异常视图,点击后重新加载 |
| 首次加载成功但没有数据 | PageStatus.Empty | 显示空数据视图,点击后重新加载 |
| 首次加载成功且有数据 | PageStatus.Content | 显示商铺列表 |
| 下拉刷新中 | PageStatus.Refreshing | 保留当前列表区域,显示刷新动画 |
| 下拉刷新失败 | 保持原内容状态 | 弹出刷新失败提示 |
目标:导入并运行模板工程,确认当前工程状态,明确本实验需要修改和新建的文件。
2.1 获取模板工程
模板工程仓库:https://cnb.cool/sziit-coding/harmony-coding/05_storelist
本实验基于以上模板工程进行开发,请你将其克隆导入DevEco Studio中并运行。
2.2 认识模板工程当前状态
模板工程已经完成启动页、登录页和首页 Tab 框架。PortalView.ets 的第一个 Tab 已经引用 StoreListView(),但当前 StoreListView.ets 只是占位页面。
@Componentexport struct StoreListView { build() { Column() { Text('商铺列表页') .fontSize(50) .fontWeight(FontWeight.Bold) } .size({width: '100%', height: '100%'}) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) }}2.3 本实验涉及的文件
| 文件路径 | 操作 | 作用 |
|---|---|---|
entry/src/main/ets/customer/store/StoreListView.ets | 修改 | 商铺列表页主组件,负责状态流转、下拉刷新和列表渲染 |
entry/src/main/ets/model/StoreData.ets | 新建 | 商铺列表接口返回数据模型 |
entry/src/main/ets/api/ServerApi.ets | 修改 | 增加商铺列表接口请求方法 |
entry/src/main/ets/customer/store/StoreItemView.ets | 新建 | 商铺列表小项 UI 组件 |
✅ 预期效果:运行模板工程并完成登录后,进入首页第一个 Tab,页面中间显示「商铺列表页」文字。
目标:理解本实验会用到的页面状态切换、下拉刷新与列表懒加载机制。
3.1 Stack 容器
Stack 可以让多个子组件叠放在同一个区域中。本实验会在 Stack 中根据 pageStatus 判断当前应该显示哪个视图。
Stack() { if (this.pageStatus == PageStatus.Loading) { // TODO: 显示加载中视图 } if (this.pageStatus == PageStatus.Content) { // TODO: 显示内容视图 }}.width('100%').layoutWeight(1)3.2 PageStatus 页面状态
模板工程已经在 Constants.ets 中定义了 PageStatus 枚举:
export enum PageStatus { Loading, Content, Empty, Error, Refreshing,}3.3 Refresh 组件
Refresh 用于实现下拉刷新,refreshing 控制刷新动画,onRefreshing 响应用户的下拉动作。
refreshing 是一个双向绑定参数:当用户下拉触发刷新时,组件需要把刷新状态写回页面变量;当接口加载完成时,页面也需要把变量改回 false 来关闭刷新动画。因此这里要写成 $$this.isRefreshing。
- 写
$$this.isRefreshing:Refresh组件可以读取并回写isRefreshing,刷新动画能被正确打开和关闭 - 只写
this.isRefreshing:相当于只把当前值传给组件,组件不能把刷新状态同步回页面变量,容易出现刷新动画状态不同步的问题
Refresh({ refreshing: $$this.isRefreshing }) { // TODO: 放置列表内容}.onRefreshing(() => { // TODO: 调用刷新加载函数})3.4 LazyForEach + IDataSource + BasicDataSource
LazyForEach 是 ArkUI 提供的数据懒加载渲染能力,适合用于商铺列表、商品列表、订单列表这类数据量可能较多的页面。它通常放在 List、Grid、Swiper、WaterFlow 等支持懒加载的容器中使用。
与普通循环渲染不同,LazyForEach 不会一次性创建所有子组件。当它用于 List 这类滚动容器时,框架会根据当前可视区域按需创建列表项;当列表项滑出可视区域后,框架也可以销毁或回收对应节点,从而降低内存占用。
LazyForEach 的三个核心参数
LazyForEach 的使用可以理解为三件事:
| 参数 | 作用 |
|---|---|
| 数据源 | 告诉 LazyForEach 当前有多少条数据,以及每个位置的数据是什么 |
| 子组件生成函数 | 告诉 LazyForEach 每条数据应该渲染成什么 UI |
| 键值生成函数 | 告诉 LazyForEach 每条数据的唯一标识是什么 |
先看一个完整的数字列表示例,重点观察 LazyForEach 三个参数的位置和写法。这里的 numberDataSource 可以理解为一个已经放入数字数据的数据源对象。
@BuilderNumberListExample() { List() { LazyForEach( this.numberDataSource, (item: number) => { ListItem() { Text(item.toString()) } }, (item: number) => item.toString() ) }}对照到本实验的商铺列表时,三个参数分别替换为:
| 示例中的写法 | 商铺列表中的写法 |
|---|---|
this.numberDataSource | this.dataSource |
(item: number) => { ... } | (item: StoreData) => { ... } |
(item: number) => item.toString() | (item: StoreData) => item.id.toString() |
为什么需要 IDataSource
官方文档中要求 LazyForEach 的数据源实现 IDataSource 接口。这个接口至少需要解决四个问题:
| 方法 | 作用 |
|---|---|
totalCount() | 告诉框架当前一共有多少条数据 |
getData(index) | 告诉框架指定下标对应的数据是什么 |
registerDataChangeListener(listener) | 让 LazyForEach 把自己的数据变化监听器注册进数据源 |
unregisterDataChangeListener(listener) | 在组件销毁或不再需要监听时移除监听器 |
也就是说,LazyForEach 并不是直接监听一个普通数组,而是通过 IDataSource 与数据源建立联系:
LazyForEach ↓ 调用 totalCount / getDataIDataSource 数据源 ↓ 数据变化时调用 listenerDataChangeListener ↓ 通知框架刷新对应列表项ListItem UI 更新为什么 key 很重要
LazyForEach 依赖 key 判断每一条数据对应哪个列表项。key 应该满足两个要求:
- 唯一性:不同商铺不能生成相同 key
- 稳定性:同一个商铺在数据未变时 key 应保持不变
本实验中,商铺接口返回的 id 天然适合作为 key,因此后续会使用:
// TODO: 在 LazyForEach 的 keyGenerator 中使用 item.id.toString()如果多个数据项生成了相同 key,列表在滚动、刷新或复用时可能出现渲染错乱;如果 key 不稳定,框架可能频繁重建列表项,影响性能。
模板工程中的 BasicDataSource<T>
模板工程已经在 entry/src/main/ets/common/datsource/BasicDataSource.ets 中封装了通用数据源类。它的设计思路是:把 IDataSource 的固定代码封装起来,业务页面只关心“把新数据放进去”。
注意:下面这段代码是模板工程中已经写好的参考代码,本实验不要求学生重新实现 BasicDataSource.ets。后续页面只需要导入它、创建 BasicDataSource<StoreData> 实例,并在接口返回后调用 setDataList()。
export class BasicDataSource<T> implements IDataSource { private listeners: DataChangeListener[] = []; private originDataArray: T[] = [];
public totalCount(): number { return this.originDataArray.length }
public getData(index: number): T { return this.originDataArray[index]; }
public setDataList(dataArray: T[]) { this.originDataArray = [] this.originDataArray = this.originDataArray.concat(dataArray) this.listeners.forEach(listener => { listener.onDataReloaded() }) }
registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); } }
unregisterDataChangeListener(listener: DataChangeListener): void { const pos = this.listeners.indexOf(listener); if (pos >= 0) { this.listeners.splice(pos, 1); } }}这种封装有三个优势:
| 优势 | 说明 |
|---|---|
| 复用性高 | 后续商品列表、订单列表也可以复用同一套数据源封装 |
| 页面代码更简洁 | 页面只需要调用 setDataList(),不用每次手写监听器管理逻辑 |
| 刷新逻辑更清晰 | 数据更新后由数据源统一通知 LazyForEach,避免直接替换普通数组导致列表不刷新的问题 |
在本实验中,接口返回商铺数组后,页面只需要执行类似下面的逻辑:
const storeList: StoreData[] = rsp.datathis.dataSource.setDataList(storeList)this.pageStatus = PageStatus.Content其中 setDataList() 会把新数组写入 BasicDataSource,并在内部调用 listener.onDataReloaded() 通知 LazyForEach 重新渲染列表。
✅ 小结:本实验将先用 Stack 和 PageStatus 完成页面状态迁移,再加入 Refresh 支持下拉刷新,最后使用 LazyForEach 渲染真实商铺数据。
目标:先不接入服务端接口,只用 setTimeout 模拟数据加载过程,完成 Loading、Content、Empty、Error 四种页面状态之间的切换。
4.1 引入页面状态和通用组件
打开 StoreListView.ets,补充本步骤需要用到的 import。
import promptAction from '@ohos.promptAction'import { EmptyView } from '../../common/widget/EmptyView'import { ErrorView } from '../../common/widget/ErrorView'import { LoadingView } from '../../common/widget/LoadingView'import { TitleBarView } from '../../common/widget/TitleBarView'import { PageStatus } from '../../utils/Constants'4.2 定义页面状态变量
@Componentexport struct StoreListView { // TODO: 使用 @State 定义 pageStatus,初始值设置为 PageStatus.Loading
build() { ... }}4.3 编写模拟加载函数
新增 loadStoreList() 方法。此时暂时不访问接口,只使用 setTimeout 模拟 1.5 秒的数据加载过程。
@Componentexport struct StoreListView { ...
loadStoreList() { // TODO: 进入函数后,先将 pageStatus 切换为 PageStatus.Loading // TODO: 使用 setTimeout 模拟 1.5 秒网络请求耗时 // TODO: 在 setTimeout 回调中,先模拟加载成功,将 pageStatus 切换为 PageStatus.Content }
build() { ... }}4.4 页面出现时触发加载
@Componentexport struct StoreListView { ...
aboutToAppear() { // TODO: 调用 loadStoreList,进入页面后立即加载商铺列表 }
build() { ... }}4.5 搭建状态切换页面
将原来的占位布局替换为标题栏 + 状态内容区。内容区使用 Stack,根据 pageStatus 显示不同视图。
@Componentexport struct StoreListView { ...
build() { Column() { // TODO: 使用 TitleBarView 显示页面标题 // · title: $r('app.string.title_store_list') // · backEnabled: false // · rightBtnSrc: $r('app.media.ic_search_black') // · rightBtnClickListener 中调用 promptAction.showToast,message 使用 toast_unimplemented
Stack() { // TODO: Loading 状态显示 LoadingView,message 使用 message_loading_store_list // TODO: Empty 状态显示 EmptyView,message 使用 message_empty_store_list,点击后重新调用 loadStoreList() // TODO: Error 状态显示 ErrorView,message 使用 message_data_error_with_retry_hint,点击后重新调用 loadStoreList() // TODO: Content 状态先显示一个居中的占位文本“商铺列表区域 - 待实现”,fontSize 为 18,fontColor 为 black_60 } // TODO: 设置 Stack.width('100%'),并通过 layoutWeight(1) 占满标题栏下方剩余空间 } // TODO: 设置根 Column 的 size 为 { width: '100%', height: '100%' } }}4.6 验证不同页面状态
默认情况下,页面会先显示加载中视图,1.5 秒后显示「商铺列表区域 - 待实现」。
验证空数据和异常状态时,可以临时修改模拟加载函数中最终赋值的 pageStatus:
setTimeout(() => { // TODO: 临时切换为 PageStatus.Empty,验证空数据页面 // TODO: 临时切换为 PageStatus.Error,验证异常页面 // TODO: 验证完成后改回 PageStatus.Content}, 1500)✅ 预期效果:进入第一个 Tab 后,页面先显示「正在加载商铺列表」,1.5 秒后切换到内容占位区域;临时切换状态时,能看到空数据页或异常页,点击后重新进入加载过程。
目标:在内容区域加入 Refresh 组件,让商铺列表页具备下拉刷新交互,并让刷新状态与首次加载状态区分开。
5.1 新增刷新状态变量
@Componentexport struct StoreListView { // TODO: 保留 pageStatus 状态变量 // TODO: 新增 @State isRefreshing,初始值为 false
...}5.2 改造 loadStoreList 函数
将 loadStoreList() 改成 loadStoreList(isRefresh: boolean)。首次进入页面时传入 false,下拉刷新时传入 true。
@Componentexport struct StoreListView { ...
loadStoreList() { loadStoreList(isRefresh: boolean) { // TODO: 如果 isRefresh 为 true,将 pageStatus 切换为 PageStatus.Refreshing // TODO: 如果 isRefresh 为 false,将 pageStatus 切换为 PageStatus.Loading // TODO: 如果 isRefresh 为 true,将 isRefreshing 设置为 true // TODO: 保留 setTimeout 模拟加载逻辑,延迟时间设置为 1500 毫秒 // TODO: 模拟加载结束后切换到 PageStatus.Content,并在刷新场景下将 isRefreshing 重置为 false }
aboutToAppear() { // TODO: 将原来的 loadStoreList() 调整为 loadStoreList(false) }
...}5.3 修改重试点击逻辑
if (this.pageStatus == PageStatus.Empty) { EmptyView({ message: $r('app.string.message_empty_store_list') }) .onClick(_ => { // TODO: 点击空数据页时调用 loadStoreList(false) 重新加载 })}if (this.pageStatus == PageStatus.Error) { ErrorView({ message: $r('app.string.message_data_error_with_retry_hint') }) .onClick(_ => { // TODO: 点击异常页时调用 loadStoreList(false) 重新加载 })}5.4 用 Refresh 包裹内容区域
将 Content 态中的普通占位容器替换为 Refresh 容器。
这里要同时判断 PageStatus.Content 和 PageStatus.Refreshing。原因是下拉刷新不是“离开内容页重新加载”,而是在原有列表内容上方显示刷新动画:
Content:数据已经加载完成,正常显示列表内容Refreshing:用户正在下拉刷新,页面仍然要显示列表内容,同时Refresh根据isRefreshing显示顶部刷新动画
如果 Refreshing 状态不显示 Refresh 组件,页面会在下拉刷新时离开内容区,用户看不到“保留原列表并刷新”的效果。
if (this.pageStatus == PageStatus.Content || this.pageStatus == PageStatus.Refreshing) { Refresh({ refreshing: $$this.isRefreshing }) { // TODO: 暂时保留“商铺列表区域 - 待实现”占位文本,fontSize 为 18,fontColor 为 black_60 } // TODO: 设置 Refresh 的 size 为 { width: '100%', height: '100%' } // TODO: 在 onRefreshing 中调用 loadStoreList(true)}✅ 预期效果:进入页面后,首次加载仍显示 LoadingView;加载完成后显示占位内容;手指下拉时出现刷新动画,1.5 秒后刷新动画关闭,页面仍停留在内容区域。
目标:根据商铺列表接口返回数据设计 StoreData 数据类,在 ServerApi 中封装接口请求,并将前面模拟的 setTimeout 加载替换为真实网络请求。
6.1 查看商铺列表接口
接口文档地址:https://docs.apipost.net/docs/detail/2c39ebb29c64000?target_id=4ed3cb5
本实验使用的商铺列表接口为:
GET https://api.food2.sziit.top/order-store/list接口成功时返回的数据结构如下:
{ "data": [ { "id": 2, "name": "深井烧鹅", "img": "http://114.132.175.147:8081/file/order/xxx.jpg", "online": 1, "distance": 1230, "salesInMonth": "月售65", "priceAverage": "人均¥92", "storeDesc": "深圳 - 南山" } ], "code": 200, "msg": "成功"}6.2 创建 StoreData 数据类
在 entry/src/main/ets/model/ 目录下新建 StoreData.ets。字段定义属于简单代码,本实验只给出字段设计提示,请根据接口字段自行补全。
import { Expose } from 'class-transformer'
export class StoreData { // TODO: 定义 id 字段,类型为 number,默认值为 0 // TODO: 定义 name 字段,类型为 string,默认值为空字符串 // TODO: 定义 imageUrl 字段,使用 @Expose({ name: 'img' }) 映射服务端 img 字段 // TODO: 定义 online 字段,类型为 number,默认值为 0 // TODO: 定义 distance 字段,类型为 number,默认值为 0,单位为米 // TODO: 定义 salesInMonth、priceAverage、storeDesc 三个 string 字段
isOnline(): boolean { // TODO: 当 online 等于 1 时返回 true,否则返回 false }
getDistanceText(): string { // TODO: 如果 distance 小于 1000,返回“xxm” // TODO: 如果 distance 大于等于 1000,转换为 km 并保留 2 位小数,例如“1.23km” }}6.3 在 ServerApi 中导入 StoreData
import { UploadData } from '../model/UploadData'import { StoreData } from '../model/StoreData'import { AccountManager } from '../account/AccountManager'6.4 封装 getStoreList 接口方法
在 ServerApi 类中新增 getStoreList() 方法。接口请求属于复杂逻辑,这里给出方法框架,请根据注释逐步完成。
模板工程的 entry/src/main/ets/api/ServerApi.ets 中已经封装了一个 private static async execute(...) 方法,新增的 getStoreList() 位于同一个 ServerApi 类内部,因此可以直接调用这个私有方法。
execute() 的作用是统一发起 HTTP 请求,并在用户已登录时自动把 token 写入请求头。它的三个参数含义如下:
| 参数 | 类型 | 含义 |
|---|---|---|
request | http.HttpRequest | 通过 http.createHttp() 创建的请求对象 |
url | string | 完整接口地址,本实验为 ${Constants.SERVER_HOST}/order-store/list |
options | http.HttpRequestOptions | 请求配置,本实验只需要设置请求方法为 GET |
execute() 返回 Promise<http.HttpResponse>。接口返回的原始数据在 data.result 中,本实验需要先把它作为字符串解析成普通对象,再创建 Response<ArrayList<StoreData>> 响应对象,并把 data 数组中的每一项转换成 StoreData 后加入 ArrayList。
export class ServerApi { ...
static async getStoreList(): Promise<Response<ArrayList<StoreData>>> { // TODO: 使用 http.createHttp() 创建 req 对象 // TODO: 创建 HttpRequestOptions,method 设置为 http.RequestMethod.GET try { // TODO: 调用 ServerApi.execute(req, Constants.SERVER_HOST + '/order-store/list', options) 发起 GET 请求 // TODO: 将 data.result 作为 string 使用 JSON.parse 转换为 object // TODO: 创建 Response<ArrayList<StoreData>>(StoreData) 响应对象 // TODO: 将 result['code'] 写入 response.code,将 result['msg'] 写入 response.message // TODO: 创建 response.data,类型为 new ArrayList<StoreData>() // TODO: 将 result['data'] 转为 Array<Object>,并遍历数组中的每一项 // TODO: 遍历时使用 plainToClass(StoreData, JSON.parse(JSON.stringify(item))) 转换为 StoreData // TODO: 将转换后的 storeData 通过 response.data!.add(storeData) 加入列表 // TODO: 返回封装好的 response } catch (e) { // TODO: 创建错误 Response<ArrayList<StoreData>>(StoreData) // TODO: 将 response.code 设置为 -1 // TODO: 返回错误 Response } }
}6.5 将模拟加载替换为真实接口请求
打开 StoreListView.ets,先补充接口 import。
import { TitleBarView } from '../../common/widget/TitleBarView'import { ServerApi } from '../../api/ServerApi'import { PageStatus } from '../../utils/Constants'然后将 loadStoreList 中的 setTimeout 模拟逻辑替换为真实接口调用。
@Componentexport struct StoreListView { ...
async loadStoreList(isRefresh: boolean) { // TODO: 根据 isRefresh 切换状态:true 切到 PageStatus.Refreshing,false 切到 PageStatus.Loading // TODO: 如果 isRefresh 为 true,将 isRefreshing 设置为 true
// TODO: 删除原来的 setTimeout 模拟逻辑 // TODO: 调用 ServerApi.getStoreList() 拉取商铺列表 // TODO: 使用 console.log 打印 count 和第一条数据,格式可为 拉取到XX条商铺数据、first=JSON.stringify(首条数据)
// TODO: 如果接口失败: // · 下拉刷新场景:调用 promptAction.showToast,message 使用 toast_refresh_store_list_failed // · 非刷新场景:将 pageStatus 切换到 PageStatus.Error
// TODO: 如果接口成功但数据为空,切换到 PageStatus.Empty // TODO: 如果接口成功且有数据,切换到 PageStatus.Content // TODO: 如果是下拉刷新,最后将 isRefreshing 重置为 false }
...}✅ 预期效果:运行页面后,DevEco Studio 控制台中能看到类似以下日志:
此时页面仍显示「商铺列表区域 - 待实现」,说明真实数据已经拉取成功,但还没有渲染到列表中。
目标:把服务端返回的商铺数组保存到 BasicDataSource 中,并使用 List + LazyForEach 先渲染出一个简单的商铺名称列表,验证列表数据流已经贯通。
7.1 导入数据源和 StoreData
import { ServerApi } from '../../api/ServerApi'import { BasicDataSource } from '../../common/datsource/BasicDataSource'import { StoreData } from '../../model/StoreData'import { PageStatus } from '../../utils/Constants'7.2 定义列表数据源
@Componentexport struct StoreListView { // TODO: 保留 pageStatus 和 isRefreshing 状态变量 // TODO: 定义 dataSource,类型为 BasicDataSource<StoreData>,初始值为 new BasicDataSource()
...}7.3 接口成功后更新数据源
@Componentexport struct StoreListView {
...
loadStoreList(isRefresh: boolean) { ... ServerApi.getStoreList().then(response => { ... if (storeCount > 0) { ... // TODO: 将 rsp.data 写入 dataSource this.dataSource.setDataList(response.data!.convertToArray())
... } } ... }
}完成数据源接入后,可以移除上一步用于调试的临时 console.log。
7.4 编写商铺列表构建函数
在 StoreListView 中新增 StoreListComponent() 构建函数,先用 Text(item.name) 显示商铺名称。
@Componentexport struct StoreListView { ...
@Builder StoreListComponent() { List({ space: 3 }) { LazyForEach( // TODO: 参数一传入 this.dataSource // TODO: 参数二中 item 类型为 StoreData,在 ListItem 中先使用 Text(item.name) 显示商铺名称 // TODO: Text(item.name) 的 fontSize 设置为 18,padding 设置为 16 // TODO: 参数三使用 item.id.toString() 作为唯一 key ) } // TODO: 添加 divider({ strokeWidth: 0.5, color: $r('app.color.black_10') }) // TODO: 添加 scrollBar(BarState.Auto) // TODO: 设置 size 为 { width: '100%', height: '100%' } }
build() { ... }}7.5 在 Refresh 中显示列表
将 Refresh 中的占位文本替换为 this.StoreListComponent()。
Refresh({ refreshing: $$this.isRefreshing }) { // TODO: 删除“商铺列表区域 - 待实现”占位文本 // TODO: 调用 this.StoreListComponent() 显示商铺列表}✅ 预期效果:第一个 Tab 中能看到一行行商铺名称文本;下拉刷新后,商铺名称列表仍能正常显示。
目标:新建 StoreItemView 组件,将简单的商铺名称文本替换为完整的商铺列表小项,展示图片、名称、月售、人均、描述、营业状态和距离。
8.1 新建 StoreItemView 组件
在 entry/src/main/ets/customer/store/ 目录下新建 StoreItemView.ets。小项布局内容较多,这里给出组件框架和实现提示。
import { StoreData } from '../../model/StoreData'
@Componentexport struct StoreItemView { // TODO: 定义 storeData 属性,类型为 StoreData,默认值为 new StoreData()
build() { Row() { // TODO: 左侧商铺图片 // · 图片来源:this.storeData.imageUrl // · alt: $r('app.media.ic_default_store_image') // · borderRadius: 6 // · size: { width: 80, height: 80 } // · backgroundColor: $r('app.color.black_5')
Column() { // TODO: 第一行显示商铺名称 // · 若 name 为空,使用 label_undefined_store_name 字符串资源兜底 // · fontSize: 23,fontWeight: FontWeight.Bold,maxLines: 1,fontColor: black
// TODO: 第二行显示 salesInMonth + 三个空格 + priceAverage // TODO: 第二行样式:margin({ top: 8 }),fontSize: 15,fontColor: black_80 // TODO: 第三行显示 storeDesc,样式同第二行,并额外设置 maxLines(1) } // TODO: 信息列设置 margin({ left: 10, right: 10 })、alignItems(HorizontalAlign.Start)、layoutWeight(1)
Column() { // TODO: 根据 this.storeData.isOnline() 显示 ic_store_online 或 ic_store_offline // TODO: 营业图标高度:营业中为 32,休息中为 24 // TODO: 使用 this.storeData.getDistanceText() 显示距离文本,fontSize 为 15,margin({ top: 10 }),fontColor 为 black_60 } } // TODO: 设置 Row.width('100%')、padding({ left: 12, right: 12, top: 12, bottom: 12 })、backgroundColor(white) }}8.2 在 StoreListView 中导入 StoreItemView
import { StoreData } from '../../model/StoreData'import { StoreItemView } from './StoreItemView'import { PageStatus } from '../../utils/Constants'8.3 替换列表小项内容
将 Step 7 中 ListItem 内的 Text(item.name) 替换为 StoreItemView。
LazyForEach( this.dataSource, (item: StoreData) => { ListItem() { // TODO: 删除 Text(item.name) 临时文本 // TODO: 使用 StoreItemView({ storeData: item }) 显示完整商铺小项 } }, (item: StoreData) => item.id.toString())✅ 预期效果:第一个 Tab 中显示完整商铺列表,每个商铺小项包含商铺图片、名称、月售、人均、商铺描述、营业状态图标和距离信息;下拉刷新后列表仍能正常显示。
9.1 商铺排序策略
需求:调整商铺列表顺序,满足以下规则:
- 自己账号下的店铺永远排在列表首位
- 营业中的店铺排在休息中的店铺前面
} else { // TODO: 将 rsp.data 保存为 storeList // TODO: 使用 Array.sort() 完成排序 // TODO: 排序规则一:自己的商铺优先 // TODO: 排序规则二:营业中的商铺优先 // TODO: 将排序后的 storeList 写入 dataSource this.pageStatus = PageStatus.Content}✅ 预期效果:重新进入商铺列表页后,自己的店铺显示在列表首位,营业中店铺显示在休息中店铺之前。
9.2 响应式多列列表
需求:根据设备方向和屏幕宽度调整商铺列表列数。
- 手机竖屏:单列显示
- 手机横屏:2 列或 3 列显示
- 平板:2 列或 3 列显示
@BuilderStoreListComponent() { List({ space: 3 }) { ... } // TODO: 根据屏幕宽度设置 lanes:竖屏为 1,横屏或平板可设置为 2 或 3 .divider({ strokeWidth: 0.5, color: $r('app.color.black_10') }) .scrollBar(BarState.Auto) .size({ width: '100%', height: '100%' })}✅ 预期效果:旋转模拟器到横屏后,商铺列表由单列变为多列;在平板设备上也能显示更适合大屏的多列布局。
完成本实验后,请录制一段不超过 3 分钟的演示视频并提交。
10.1 必须演示的内容
| 序号 | 演示内容 | 说明 |
|---|---|---|
| ① | 进入商铺列表页 | 登录后进入首页第一个 Tab |
| ② | 首次加载 | 展示页面从 Loading 状态切换到商铺列表 |
| ③ | 商铺列表展示 | 展示商铺图片、名称、月售、人均、描述、营业状态和距离 |
| ④ | 下拉刷新 | 演示下拉刷新动画和刷新后的列表显示 |
| ⑤ | 异常处理 | 可通过断网方式演示异常页或刷新失败提示 |
10.2 加分项
若完成拓展任务,请额外演示:
- 商铺排序策略生效
- 横屏或平板下多列列表效果
10.3 评分标准
| 评分项 | 要求 |
|---|---|
| 页面功能完成度 | 能拉取并展示真实商铺列表,支持下拉刷新 |
| 页面状态处理 | Loading、空数据、异常、内容、刷新状态处理完整 |
| UI 完成度 | 商铺小项信息完整,布局整齐美观 |
| 代码规范 | 文件结构清晰,命名规范,逻辑分层合理 |
| 拓展任务完成度 | 排序策略和响应式多列效果完成情况 |