跳转到内容

编程实验:开发商铺列表页

本实验将基于 05_storelist 模板工程,完成鸿蒙点餐 APP 中「点餐」Tab 的商铺列表页开发。最终页面需要从服务端拉取商铺数据,并展示商铺图片、名称、销量、人均价格、商铺描述、营业状态和距离信息。

本实验主要完成以下任务:

  • 搭建商铺列表页的 Loading、空数据、异常、内容四种页面状态
  • 为商铺列表页增加下拉刷新能力
  • 根据服务端接口返回字段设计 StoreData 数据类
  • ServerApi 中封装商铺列表接口请求
  • 使用 BasicDataSourceListLazyForEach 渲染商铺列表
  • 新建 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 只是占位页面。

entry/src/main/ets/customer/store/StoreListView.ets
@Component
export 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 判断当前应该显示哪个视图。

entry/src/main/ets/customer/store/StoreListView.ets
Stack() {
if (this.pageStatus == PageStatus.Loading) {
// TODO: 显示加载中视图
}
if (this.pageStatus == PageStatus.Content) {
// TODO: 显示内容视图
}
}
.width('100%')
.layoutWeight(1)

3.2 PageStatus 页面状态

模板工程已经在 Constants.ets 中定义了 PageStatus 枚举:

entry/src/main/ets/utils/Constants.ets
export enum PageStatus {
Loading,
Content,
Empty,
Error,
Refreshing,
}

3.3 Refresh 组件

Refresh 用于实现下拉刷新,refreshing 控制刷新动画,onRefreshing 响应用户的下拉动作。

refreshing 是一个双向绑定参数:当用户下拉触发刷新时,组件需要把刷新状态写回页面变量;当接口加载完成时,页面也需要把变量改回 false 来关闭刷新动画。因此这里要写成 $$this.isRefreshing

  • $$this.isRefreshingRefresh 组件可以读取并回写 isRefreshing,刷新动画能被正确打开和关闭
  • 只写 this.isRefreshing:相当于只把当前值传给组件,组件不能把刷新状态同步回页面变量,容易出现刷新动画状态不同步的问题
entry/src/main/ets/customer/store/StoreListView.ets
Refresh({ refreshing: $$this.isRefreshing }) {
// TODO: 放置列表内容
}
.onRefreshing(() => {
// TODO: 调用刷新加载函数
})

3.4 LazyForEach + IDataSource + BasicDataSource

LazyForEach 是 ArkUI 提供的数据懒加载渲染能力,适合用于商铺列表、商品列表、订单列表这类数据量可能较多的页面。它通常放在 ListGridSwiperWaterFlow 等支持懒加载的容器中使用。

与普通循环渲染不同,LazyForEach 不会一次性创建所有子组件。当它用于 List 这类滚动容器时,框架会根据当前可视区域按需创建列表项;当列表项滑出可视区域后,框架也可以销毁或回收对应节点,从而降低内存占用。

LazyForEach 的三个核心参数

LazyForEach 的使用可以理解为三件事:

参数作用
数据源告诉 LazyForEach 当前有多少条数据,以及每个位置的数据是什么
子组件生成函数告诉 LazyForEach 每条数据应该渲染成什么 UI
键值生成函数告诉 LazyForEach 每条数据的唯一标识是什么

先看一个完整的数字列表示例,重点观察 LazyForEach 三个参数的位置和写法。这里的 numberDataSource 可以理解为一个已经放入数字数据的数据源对象。

entry/src/main/ets/customer/store/StoreListView.ets
@Builder
NumberListExample() {
List() {
LazyForEach(
this.numberDataSource,
(item: number) => {
ListItem() {
Text(item.toString())
}
},
(item: number) => item.toString()
)
}
}

对照到本实验的商铺列表时,三个参数分别替换为:

示例中的写法商铺列表中的写法
this.numberDataSourcethis.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 与 IDataSource 的协作关系
LazyForEach
↓ 调用 totalCount / getData
IDataSource 数据源
↓ 数据变化时调用 listener
DataChangeListener
↓ 通知框架刷新对应列表项
ListItem UI 更新

为什么 key 很重要

LazyForEach 依赖 key 判断每一条数据对应哪个列表项。key 应该满足两个要求:

  • 唯一性:不同商铺不能生成相同 key
  • 稳定性:同一个商铺在数据未变时 key 应保持不变

本实验中,商铺接口返回的 id 天然适合作为 key,因此后续会使用:

entry/src/main/ets/customer/store/StoreListView.ets
// TODO: 在 LazyForEach 的 keyGenerator 中使用 item.id.toString()

如果多个数据项生成了相同 key,列表在滚动、刷新或复用时可能出现渲染错乱;如果 key 不稳定,框架可能频繁重建列表项,影响性能。

模板工程中的 BasicDataSource<T>

模板工程已经在 entry/src/main/ets/common/datsource/BasicDataSource.ets 中封装了通用数据源类。它的设计思路是:把 IDataSource 的固定代码封装起来,业务页面只关心“把新数据放进去”。

注意:下面这段代码是模板工程中已经写好的参考代码,本实验不要求学生重新实现 BasicDataSource.ets。后续页面只需要导入它、创建 BasicDataSource<StoreData> 实例,并在接口返回后调用 setDataList()

entry/src/main/ets/common/datsource/BasicDataSource.ets
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,避免直接替换普通数组导致列表不刷新的问题

在本实验中,接口返回商铺数组后,页面只需要执行类似下面的逻辑:

entry/src/main/ets/customer/store/StoreListView.ets
const storeList: StoreData[] = rsp.data
this.dataSource.setDataList(storeList)
this.pageStatus = PageStatus.Content

其中 setDataList() 会把新数组写入 BasicDataSource,并在内部调用 listener.onDataReloaded() 通知 LazyForEach 重新渲染列表。

✅ 小结:本实验将先用 StackPageStatus 完成页面状态迁移,再加入 Refresh 支持下拉刷新,最后使用 LazyForEach 渲染真实商铺数据。

目标:先不接入服务端接口,只用 setTimeout 模拟数据加载过程,完成 Loading、Content、Empty、Error 四种页面状态之间的切换。

4.1 引入页面状态和通用组件

打开 StoreListView.ets,补充本步骤需要用到的 import。

entry/src/main/ets/customer/store/StoreListView.ets
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 定义页面状态变量

entry/src/main/ets/customer/store/StoreListView.ets
@Component
export struct StoreListView {
// TODO: 使用 @State 定义 pageStatus,初始值设置为 PageStatus.Loading
build() {
...
}
}

4.3 编写模拟加载函数

新增 loadStoreList() 方法。此时暂时不访问接口,只使用 setTimeout 模拟 1.5 秒的数据加载过程。

entry/src/main/ets/customer/store/StoreListView.ets
@Component
export struct StoreListView {
...
loadStoreList() {
// TODO: 进入函数后,先将 pageStatus 切换为 PageStatus.Loading
// TODO: 使用 setTimeout 模拟 1.5 秒网络请求耗时
// TODO: 在 setTimeout 回调中,先模拟加载成功,将 pageStatus 切换为 PageStatus.Content
}
build() {
...
}
}

4.4 页面出现时触发加载

entry/src/main/ets/customer/store/StoreListView.ets
@Component
export struct StoreListView {
...
aboutToAppear() {
// TODO: 调用 loadStoreList,进入页面后立即加载商铺列表
}
build() {
...
}
}

4.5 搭建状态切换页面

将原来的占位布局替换为标题栏 + 状态内容区。内容区使用 Stack,根据 pageStatus 显示不同视图。

entry/src/main/ets/customer/store/StoreListView.ets
@Component
export 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

entry/src/main/ets/customer/store/StoreListView.ets
setTimeout(() => {
// TODO: 临时切换为 PageStatus.Empty,验证空数据页面
// TODO: 临时切换为 PageStatus.Error,验证异常页面
// TODO: 验证完成后改回 PageStatus.Content
}, 1500)

✅ 预期效果:进入第一个 Tab 后,页面先显示「正在加载商铺列表」,1.5 秒后切换到内容占位区域;临时切换状态时,能看到空数据页或异常页,点击后重新进入加载过程。

Loading 状态
Content 状态
Empty 状态
Error 状态

目标:在内容区域加入 Refresh 组件,让商铺列表页具备下拉刷新交互,并让刷新状态与首次加载状态区分开。

5.1 新增刷新状态变量

entry/src/main/ets/customer/store/StoreListView.ets
@Component
export struct StoreListView {
// TODO: 保留 pageStatus 状态变量
// TODO: 新增 @State isRefreshing,初始值为 false
...
}

5.2 改造 loadStoreList 函数

loadStoreList() 改成 loadStoreList(isRefresh: boolean)。首次进入页面时传入 false,下拉刷新时传入 true

entry/src/main/ets/customer/store/StoreListView.ets
@Component
export 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 修改重试点击逻辑

entry/src/main/ets/customer/store/StoreListView.ets
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.ContentPageStatus.Refreshing。原因是下拉刷新不是“离开内容页重新加载”,而是在原有列表内容上方显示刷新动画:

  • Content:数据已经加载完成,正常显示列表内容
  • Refreshing:用户正在下拉刷新,页面仍然要显示列表内容,同时 Refresh 根据 isRefreshing 显示顶部刷新动画

如果 Refreshing 状态不显示 Refresh 组件,页面会在下拉刷新时离开内容区,用户看不到“保留原列表并刷新”的效果。

entry/src/main/ets/customer/store/StoreListView.ets
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。字段定义属于简单代码,本实验只给出字段设计提示,请根据接口字段自行补全。

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

entry/src/main/ets/api/ServerApi.ets
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 写入请求头。它的三个参数含义如下:

参数类型含义
requesthttp.HttpRequest通过 http.createHttp() 创建的请求对象
urlstring完整接口地址,本实验为 ${Constants.SERVER_HOST}/order-store/list
optionshttp.HttpRequestOptions请求配置,本实验只需要设置请求方法为 GET

execute() 返回 Promise<http.HttpResponse>。接口返回的原始数据在 data.result 中,本实验需要先把它作为字符串解析成普通对象,再创建 Response<ArrayList<StoreData>> 响应对象,并把 data 数组中的每一项转换成 StoreData 后加入 ArrayList

entry/src/main/ets/api/ServerApi.ets
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。

entry/src/main/ets/customer/store/StoreListView.ets
import { TitleBarView } from '../../common/widget/TitleBarView'
import { ServerApi } from '../../api/ServerApi'
import { PageStatus } from '../../utils/Constants'

然后将 loadStoreList 中的 setTimeout 模拟逻辑替换为真实接口调用。

entry/src/main/ets/customer/store/StoreListView.ets
@Component
export 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

entry/src/main/ets/customer/store/StoreListView.ets
import { ServerApi } from '../../api/ServerApi'
import { BasicDataSource } from '../../common/datsource/BasicDataSource'
import { StoreData } from '../../model/StoreData'
import { PageStatus } from '../../utils/Constants'

7.2 定义列表数据源

entry/src/main/ets/customer/store/StoreListView.ets
@Component
export struct StoreListView {
// TODO: 保留 pageStatus 和 isRefreshing 状态变量
// TODO: 定义 dataSource,类型为 BasicDataSource<StoreData>,初始值为 new BasicDataSource()
...
}

7.3 接口成功后更新数据源

entry/src/main/ets/customer/store/StoreListView.ets
@Component
export 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) 显示商铺名称。

entry/src/main/ets/customer/store/StoreListView.ets
@Component
export 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()

entry/src/main/ets/customer/store/StoreListView.ets
Refresh({ refreshing: $$this.isRefreshing }) {
// TODO: 删除“商铺列表区域 - 待实现”占位文本
// TODO: 调用 this.StoreListComponent() 显示商铺列表
}

✅ 预期效果:第一个 Tab 中能看到一行行商铺名称文本;下拉刷新后,商铺名称列表仍能正常显示。

商铺名称列表渲染效果

目标:新建 StoreItemView 组件,将简单的商铺名称文本替换为完整的商铺列表小项,展示图片、名称、月售、人均、描述、营业状态和距离。

8.1 新建 StoreItemView 组件

entry/src/main/ets/customer/store/ 目录下新建 StoreItemView.ets。小项布局内容较多,这里给出组件框架和实现提示。

entry/src/main/ets/customer/store/StoreItemView.ets
import { StoreData } from '../../model/StoreData'
@Component
export 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

entry/src/main/ets/customer/store/StoreListView.ets
import { StoreData } from '../../model/StoreData'
import { StoreItemView } from './StoreItemView'
import { PageStatus } from '../../utils/Constants'

8.3 替换列表小项内容

将 Step 7 中 ListItem 内的 Text(item.name) 替换为 StoreItemView

entry/src/main/ets/customer/store/StoreListView.ets
LazyForEach(
this.dataSource,
(item: StoreData) => {
ListItem() {
// TODO: 删除 Text(item.name) 临时文本
// TODO: 使用 StoreItemView({ storeData: item }) 显示完整商铺小项
}
},
(item: StoreData) => item.id.toString()
)

✅ 预期效果:第一个 Tab 中显示完整商铺列表,每个商铺小项包含商铺图片、名称、月售、人均、商铺描述、营业状态图标和距离信息;下拉刷新后列表仍能正常显示。

商铺列表小项效果

9.1 商铺排序策略

需求:调整商铺列表顺序,满足以下规则:

  1. 自己账号下的店铺永远排在列表首位
  2. 营业中的店铺排在休息中的店铺前面
entry/src/main/ets/customer/store/StoreListView.ets
} else {
// TODO: 将 rsp.data 保存为 storeList
// TODO: 使用 Array.sort() 完成排序
// TODO: 排序规则一:自己的商铺优先
// TODO: 排序规则二:营业中的商铺优先
// TODO: 将排序后的 storeList 写入 dataSource
this.pageStatus = PageStatus.Content
}

✅ 预期效果:重新进入商铺列表页后,自己的店铺显示在列表首位,营业中店铺显示在休息中店铺之前。

9.2 响应式多列列表

需求:根据设备方向和屏幕宽度调整商铺列表列数。

  • 手机竖屏:单列显示
  • 手机横屏:2 列或 3 列显示
  • 平板:2 列或 3 列显示
entry/src/main/ets/customer/store/StoreListView.ets
@Builder
StoreListComponent() {
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 完成度商铺小项信息完整,布局整齐美观
代码规范文件结构清晰,命名规范,逻辑分层合理
拓展任务完成度排序策略和响应式多列效果完成情况