布局列表框循序渐进:从商品清单到购物车和网络分页

← 返回布局列表框使用指导 · 组件参考 · 全部使用指导

布局列表框循序渐进:从商品清单到购物车和网络分页

从三行商品名称开始,逐步做出有详情、搜索、收藏和购物车的商品目录,再练习两列网格、聊天气泡、横滑栏目和真实接口分页。商品为教学数据,价格以整数“分”保存,显示时转换成元。

难度 建议用时 准备
入门到进阶 每关约 10–20 分钟,可分多次完成 Android 手机、包含布局列表框的最新版 AI 伴侣;网络关额外需要电脑和 Python 3

学习路线

关卡 做出的功能 可用于哪些 App
1. 三行商品 模板和字典自动绑定 联系人、通知、设备目录
2. 卡片与详情 图片、价格和点击详情 商品、课程、新闻、订单
3. 搜索与筛选 关键词、现货开关、空提示 商品检索、通讯录、状态筛选
4. 收藏与复用 收藏保存、长按、行内按钮 收藏夹、关注、标记、待办状态
5. 购物车 加减数量、库存边界、金额、保存 点餐、购物、工单数量
6. 两列网格 相同业务换布局 商品墙、相册、功能入口
7. 多模板聊天 左右气泡、发送、滚到新消息 客服、消息记录、操作日志
8. 嵌套首页 纵向栏目内横滑,点击子商品 商城首页、推荐栏目、设备分组
9. 网络分页 真接口、加载、搜索、重试、超时、去重 商品列表、新闻流、订单分页

开始前的准备

  1. 新建一个 Screen1;屏幕“可滚动”设为假,“尺寸调整”设为响应式。列表本身负责滚动。
  2. 第 1–6、9 关使用同一个列表实例:布局列表框_商品,宽、高均为填充父级,方向纵向,列数 1,空提示文本为“没有匹配的商品”。搜索和操作条放在列表外面。
  3. 在列表里面直接拖入 竖直布局_商品行,宽填充父级、高自动、背景白色,保持可见;这才是行模板。
  4. 第 7、8 关建议另建空项目,按各自的组件树搭界面;它们是新的模板形态。

每关代码都是完整版本。 进入下一关时先增加该关组件,再删除上一关代码,粘贴当前关完整代码;不要把多个 Screen1.Initialize 和重复全局变量叠在一起。每段预览都有复制代码块入口。

用到的组件:布局列表框、竖直布局、水平布局、标签、图像、按钮、文本输入框、复选框、对话框、本地数据库、Web 客户端。

第 2 关起,把这三张本地示意图下载并上传到素材,保留文件名:cup.png、bag.png、lamp.png。本地图像无需运行时网络下载。

第 1 关:先显示三行商品

模板里先只放一个标签,名为 标签_名称,宽填充父级,字体 18。字典键要和模板组件名完全相同。

布局列表框_商品
└─ 竖直布局_商品行
   └─ 标签_名称
when Screen1.Initialize() {
  布局列表框_商品.Data = list(
    { "标签_名称": "保温杯" },
    { "标签_名称": "帆布袋" },
    { "标签_名称": "学习台灯" }
  )
}

中途验证:打开屏幕看到保温杯、帆布袋、学习台灯三行。把第一条名称改成“我的商品”,对应行跟着变化;若三个标签仍显示设计器文本,先检查字典键和模板层级。

第 2 关:做商品卡片,点击看详情

保留上一关列表,给行模板增加以下组件;屏幕外增加非可视的 对话框1。

行模板组件 名称 关键属性
图像 图像_封面 宽 64、高 64、缩放图片适应为真
标签 标签_名称 字体 18
标签 标签_价格 字体 16、颜色红色
标签 标签_分类、标签_库存 字体 12

图像和文字可放进一个水平布局,文字列里放竖直布局。每条数据额外保存原始 商品 字典,详情使用原始编号和字段,而不是从标签文字反向解析。

global 商品数据 = list(
  { "id": "p1", "name": "保温杯", "price": 1590, "stock": 3, "category": "生活", "image": "cup.png" },
  { "id": "p2", "name": "帆布袋", "price": 3900, "stock": 0, "category": "生活", "image": "bag.png" },
  { "id": "p3", "name": "学习台灯", "price": 5900, "stock": 5, "category": "学习", "image": "lamp.png" }
)

function 商品行(商品) {
  return makeDict(pair("商品", 商品), pair("标签_名称", dictLookup(商品, "name", "")), pair("标签_价格", join("¥", decimals(dictLookup(商品, "price", 0) / 100, 2))), pair("标签_分类", dictLookup(商品, "category", "")), pair("标签_库存", join("库存 ", dictLookup(商品, "stock", 0))), pair("图像_封面", dictLookup(商品, "image", "")))
}

procedure 显示详情(行值) {
  var 商品 = dictLookup(行值, "商品", makeDict())
  对话框1.ShowMessageDialog(join("编号:", dictLookup(商品, "id", ""), "\n价格:¥", decimals(dictLookup(商品, "price", 0) / 100, 2), "\n分类:", dictLookup(商品, "category", ""), "\n库存:", dictLookup(商品, "stock", 0)), dictLookup(商品, "name", "商品详情"), "关闭")
}

procedure 显示商品() {
  var 行列表 = []
  foreach 商品 in 商品数据 { listAdd(行列表, 商品行(商品)) }
  布局列表框_商品.Data = 行列表
}

when Screen1.Initialize() { 显示商品() }
when 布局列表框_商品.ItemClick(索引, 值) { 显示详情(值) }

中途验证:三件商品显示不同图片、名称、价格和库存;保温杯显示 ¥15.90,帆布袋库存 0。点台灯这一行,弹窗显示编号 p3 和库存 5。

第 3 关:关键词搜索、现货筛选和空状态

在列表外、上方增加 文本输入框_搜索、复选框_现货(文本“只看现货”)、按钮_重置 和 标签_状态。保留原始商品数据,每次从原数据生成新行列表;不要在当前显示列表上反复删除,否则重置无法恢复。

global 商品数据 = list(
  { "id": "p1", "name": "保温杯", "price": 1590, "stock": 3, "category": "生活", "image": "cup.png" },
  { "id": "p2", "name": "帆布袋", "price": 3900, "stock": 0, "category": "生活", "image": "bag.png" },
  { "id": "p3", "name": "学习台灯", "price": 5900, "stock": 5, "category": "学习", "image": "lamp.png" }
)

function 商品行(商品) {
  return makeDict(pair("商品", 商品), pair("标签_名称", dictLookup(商品, "name", "")), pair("标签_价格", join("¥", decimals(dictLookup(商品, "price", 0) / 100, 2))), pair("标签_分类", dictLookup(商品, "category", "")), pair("标签_库存", join("库存 ", dictLookup(商品, "stock", 0))), pair("图像_封面", dictLookup(商品, "image", "")))
}

procedure 显示详情(行值) {
  var 商品 = dictLookup(行值, "商品", makeDict())
  对话框1.ShowMessageDialog(join("编号:", dictLookup(商品, "id", ""), "\n价格:¥", decimals(dictLookup(商品, "price", 0) / 100, 2), "\n分类:", dictLookup(商品, "category", ""), "\n库存:", dictLookup(商品, "stock", 0)), dictLookup(商品, "name", "商品详情"), "关闭")
}

procedure 筛选商品() {
  var 关键词 = trim(文本输入框_搜索.Text)
  var 行列表 = []
  foreach 商品 in 商品数据 {
    var 命中 = 关键词 == "" or textContains(join(dictLookup(商品, "name", ""), " ", dictLookup(商品, "category", "")), 关键词)
    var 满足库存 = not (复选框_现货.Checked) or dictLookup(商品, "stock", 0) > 0
    if 命中 and 满足库存 { listAdd(行列表, 商品行(商品)) }
  }
  布局列表框_商品.Data = 行列表
  标签_状态.Text = join("找到 ", length(行列表), " 件商品")
}

when Screen1.Initialize() { 筛选商品() }
when 文本输入框_搜索.TextChanged() { 筛选商品() }
when 复选框_现货.Changed() { 筛选商品() }
when 按钮_重置.Click() {
  文本输入框_搜索.Text = ""
  复选框_现货.Checked = false
  筛选商品()
}
when 布局列表框_商品.ItemClick(索引, 值) { 显示详情(值) }

中途验证:输入“生活”得到杯和袋;勾选只看现货后只剩杯。搜索“不存在”显示空提示,状态显示 0 件;点重置重新出现三件。筛选后点台灯仍显示 p3,不会误认为它是第一件商品。

第 4 关:收藏保存,避免回收行串状态

沿用第 3 关全部组件,再增加:

位置 组件名 用途
行模板里的水平布局 按钮_详情、按钮_收藏 文本“详情”“收藏”,两个按钮宽均填充父级
列表外 复选框_收藏 文本“只看收藏”
非可视组件 微数据库1 保存收藏编号

ComponentClick 用返回的标签区分行内按钮。不能用设计器里的 按钮_收藏.Click 处理所有克隆按钮。ForEachData 每次绑定都设置“已收藏”和“未收藏”两种状态;状态存在固定商品 id 中,不存在行号或克隆组件对象中。外部收藏状态改变后主动 Refresh,因为数据相同的行可能不会自动重新绑定。

global 商品数据 = list(
  { "id": "p1", "name": "保温杯", "price": 1590, "stock": 3, "category": "生活", "image": "cup.png" },
  { "id": "p2", "name": "帆布袋", "price": 3900, "stock": 0, "category": "生活", "image": "bag.png" },
  { "id": "p3", "name": "学习台灯", "price": 5900, "stock": 5, "category": "学习", "image": "lamp.png" }
)
global 收藏编号 = []

function 商品行(商品) {
  return makeDict(pair("商品", 商品), pair("标签_名称", dictLookup(商品, "name", "")), pair("标签_价格", join("¥", decimals(dictLookup(商品, "price", 0) / 100, 2))), pair("标签_分类", dictLookup(商品, "category", "")), pair("标签_库存", join("库存 ", dictLookup(商品, "stock", 0))), pair("图像_封面", dictLookup(商品, "image", "")))
}

procedure 显示详情(行值) {
  var 商品 = dictLookup(行值, "商品", makeDict())
  对话框1.ShowMessageDialog(join("编号:", dictLookup(商品, "id", ""), "\n价格:¥", decimals(dictLookup(商品, "price", 0) / 100, 2), "\n分类:", dictLookup(商品, "category", ""), "\n库存:", dictLookup(商品, "stock", 0)), dictLookup(商品, "name", "商品详情"), "关闭")
}

procedure 筛选商品() {
  var 关键词 = trim(文本输入框_搜索.Text)
  var 行列表 = []
  foreach 商品 in 商品数据 {
    var 命中 = 关键词 == "" or textContains(join(dictLookup(商品, "name", ""), " ", dictLookup(商品, "category", "")), 关键词)
    var 满足库存 = not (复选框_现货.Checked) or dictLookup(商品, "stock", 0) > 0
    var 满足收藏 = not (复选框_收藏.Checked) or contains(收藏编号, dictLookup(商品, "id", ""))
    if 命中 and 满足库存 and 满足收藏 { listAdd(行列表, 商品行(商品)) }
  }
  布局列表框_商品.Data = 行列表
  标签_状态.Text = join("找到 ", length(行列表), " 件商品")
}

procedure 切换收藏(行值) {
  var 商品 = dictLookup(行值, "商品", makeDict())
  var 编号 = dictLookup(商品, "id", "")
  if contains(收藏编号, 编号) { listRemove(收藏编号, indexOf(收藏编号, 编号)) }
  else { listAdd(收藏编号, 编号) }
  微数据库1.StoreValue("course-favorites-v1", 收藏编号)
  if 复选框_收藏.Checked { 筛选商品() }
  else { 布局列表框_商品.Refresh() }
}

when Screen1.Initialize() {
  收藏编号 = 微数据库1.GetValue("course-favorites-v1", [])
  筛选商品()
}
when 文本输入框_搜索.TextChanged() { 筛选商品() }
when 复选框_现货.Changed() { 筛选商品() }
when 按钮_重置.Click() {
  文本输入框_搜索.Text = ""
  复选框_现货.Checked = false
  复选框_收藏.Checked = false
  筛选商品()
}
when 布局列表框_商品.ItemClick(索引, 值) { 显示详情(值) }
when 布局列表框_商品.ForEachData(索引, 值) {
  var 商品 = dictLookup(值, "商品", makeDict())
  var 已收藏 = contains(收藏编号, dictLookup(商品, "id", ""))
  var 收藏按钮 = 布局列表框_商品.GetComponent("按钮_收藏")
  any Button(收藏按钮).Text = choose(已收藏, "已收藏", "收藏")
  any Button(收藏按钮).BackgroundColor = choose(已收藏, colorFromHex("#FFE2B4"), colorFromHex("#E8EEF8"))
}
when 布局列表框_商品.ComponentClick(标签, 索引, 值) {
  if 标签 == "按钮_收藏" { 切换收藏(值) }
  else if 标签 == "按钮_详情" { 显示详情(值) }
}
when 布局列表框_商品.ItemLongClick(索引, 值) { 切换收藏(值) }
when 复选框_收藏.Changed() { 筛选商品() }

中途验证:收藏台灯,勾选只看收藏后只剩台灯;长按取消收藏会变为空列表。取消只看收藏、再次收藏并关闭重开 APK,收藏仍保留。滚动后未收藏的商品必须显示“收藏”及普通背景。AI 伴侣的本地数据库可能与其他测试项目共享,持久化以独立 APK 为准。

第 5 关:购物车加减、库存边界和金额

沿用第 4 关,再在模板里增加 按钮_减、标签_数量、按钮_加(文本分别为“−”“数量 0”“+”);列表外增加 复选框_购物车(“只看购物车”)和 标签_总价。

购物车是“商品 id → 数量”的字典。所有金额运算用整数分,展示时才除以 100 并格式化为两位小数。数量改变后保存、重新筛选并刷新行内按钮;库存为 0 的商品仍可看详情,但不能增加数量。这里只有商品展示和购物车,提交订单和支付需要对应的业务接口。

global 商品数据 = list(
  { "id": "p1", "name": "保温杯", "price": 1590, "stock": 3, "category": "生活", "image": "cup.png" },
  { "id": "p2", "name": "帆布袋", "price": 3900, "stock": 0, "category": "生活", "image": "bag.png" },
  { "id": "p3", "name": "学习台灯", "price": 5900, "stock": 5, "category": "学习", "image": "lamp.png" }
)
global 收藏编号 = []
global 购物车 = makeDict()

function 商品行(商品) {
  return makeDict(pair("商品", 商品), pair("标签_名称", dictLookup(商品, "name", "")), pair("标签_价格", join("¥", decimals(dictLookup(商品, "price", 0) / 100, 2))), pair("标签_分类", dictLookup(商品, "category", "")), pair("标签_库存", join("库存 ", dictLookup(商品, "stock", 0))), pair("图像_封面", dictLookup(商品, "image", "")))
}

procedure 显示详情(行值) {
  var 商品 = dictLookup(行值, "商品", makeDict())
  对话框1.ShowMessageDialog(join("编号:", dictLookup(商品, "id", ""), "\n价格:¥", decimals(dictLookup(商品, "price", 0) / 100, 2), "\n分类:", dictLookup(商品, "category", ""), "\n库存:", dictLookup(商品, "stock", 0)), dictLookup(商品, "name", "商品详情"), "关闭")
}

procedure 更新总价() {
  var 总价 = 0
  foreach 商品 in 商品数据 {
    var 数量 = dictLookup(购物车, dictLookup(商品, "id", ""), 0)
    总价 = 总价 + 数量 * dictLookup(商品, "price", 0)
  }
  标签_总价.Text = join("购物车合计:¥", decimals(总价 / 100, 2))
}

procedure 筛选商品() {
  var 关键词 = trim(文本输入框_搜索.Text)
  var 行列表 = []
  foreach 商品 in 商品数据 {
    var 命中 = 关键词 == "" or textContains(join(dictLookup(商品, "name", ""), " ", dictLookup(商品, "category", "")), 关键词)
    var 满足库存 = not (复选框_现货.Checked) or dictLookup(商品, "stock", 0) > 0
    var 满足收藏 = not (复选框_收藏.Checked) or contains(收藏编号, dictLookup(商品, "id", ""))
    var 满足购物车 = not (复选框_购物车.Checked) or dictLookup(购物车, dictLookup(商品, "id", ""), 0) > 0
    if 命中 and 满足库存 and 满足收藏 and 满足购物车 { listAdd(行列表, 商品行(商品)) }
  }
  布局列表框_商品.Data = 行列表
  更新总价()
  标签_状态.Text = join("显示 ", length(行列表), " 件商品")
}

procedure 改变数量(行值, 变化) {
  var 商品 = dictLookup(行值, "商品", makeDict())
  var 编号 = dictLookup(商品, "id", "")
  var 原数量 = dictLookup(购物车, 编号, 0)
  var 新数量 = 原数量 + 变化
  if 新数量 >= 0 and 新数量 <= dictLookup(商品, "stock", 0) {
    dictSet(购物车, 编号, 新数量)
    微数据库1.StoreValue("course-cart-v1", 购物车)
    筛选商品()
    布局列表框_商品.Refresh()
  } else { 对话框1.ShowAlert("数量不能小于 0,也不能超过库存") }
}

procedure 切换收藏(行值) {
  var 商品 = dictLookup(行值, "商品", makeDict())
  var 编号 = dictLookup(商品, "id", "")
  if contains(收藏编号, 编号) { listRemove(收藏编号, indexOf(收藏编号, 编号)) }
  else { listAdd(收藏编号, 编号) }
  微数据库1.StoreValue("course-favorites-v1", 收藏编号)
  if 复选框_收藏.Checked { 筛选商品() }
  else { 布局列表框_商品.Refresh() }
}

when Screen1.Initialize() {
  收藏编号 = 微数据库1.GetValue("course-favorites-v1", [])
  购物车 = 微数据库1.GetValue("course-cart-v1", makeDict())
  筛选商品()
}
when 文本输入框_搜索.TextChanged() { 筛选商品() }
when 复选框_现货.Changed() { 筛选商品() }
when 按钮_重置.Click() {
  文本输入框_搜索.Text = ""
  复选框_现货.Checked = false
  复选框_收藏.Checked = false
  复选框_购物车.Checked = false
  筛选商品()
}
when 布局列表框_商品.ItemClick(索引, 值) { 显示详情(值) }
when 布局列表框_商品.ForEachData(索引, 值) {
  var 商品 = dictLookup(值, "商品", makeDict())
  var 数量 = dictLookup(购物车, dictLookup(商品, "id", ""), 0)
  var 已收藏 = contains(收藏编号, dictLookup(商品, "id", ""))
  var 收藏按钮 = 布局列表框_商品.GetComponent("按钮_收藏")
  any Button(收藏按钮).Text = choose(已收藏, "已收藏", "收藏")
  any Button(收藏按钮).BackgroundColor = choose(已收藏, colorFromHex("#FFE2B4"), colorFromHex("#E8EEF8"))
  var 数量标签 = 布局列表框_商品.GetComponent("标签_数量")
  var 加号 = 布局列表框_商品.GetComponent("按钮_加")
  var 减号 = 布局列表框_商品.GetComponent("按钮_减")
  any Label(数量标签).Text = join("数量 ", 数量)
  any Button(加号).Enabled = 数量 < dictLookup(商品, "stock", 0)
  any Button(减号).Enabled = 数量 > 0
}
when 布局列表框_商品.ComponentClick(标签, 索引, 值) {
  if 标签 == "按钮_收藏" { 切换收藏(值) }
  else if 标签 == "按钮_加" { 改变数量(值, 1) }
  else if 标签 == "按钮_减" { 改变数量(值, -1) }
  else if 标签 == "按钮_详情" { 显示详情(值) }
}
when 布局列表框_商品.ItemLongClick(索引, 值) { 切换收藏(值) }
when 复选框_收藏.Changed() { 筛选商品() }
when 复选框_购物车.Changed() { 筛选商品() }

中途验证:先把杯和台灯的数量减到 0、取消所有筛选开关,再测试:杯加到 3 后加号禁用;帆布袋库存 0,加号从一开始就禁用。杯 2 个加台灯 1 个,合计 ¥90.80;勾选只看购物车只显示这两件。减到 0 后该商品离开购物车视图,减号不会产生负数。关闭重开 APK,数量和金额仍保留。

第 6 关:同一套业务换成两列网格

复制第 5 关工程。在设计器把 布局列表框_商品 的列数改成 2,行间距设为 8;把模板排成“图片、名称、价格、库存、操作条”从上到下的卡片。

列数、排列方向、从底部开始、行间距都是设计期属性,没有对应的运行时属性块。这里直接保留第 5 关完整代码;如果 App 需要运行时切换列表/网格,像大学目录案例一样放两个列表框,共享数据,通过可见性切换。

网格里行内按钮宽使用填充父级,不要用屏幕宽度 50%;后者会超出半屏卡片。

global 商品数据 = list(
  { "id": "p1", "name": "保温杯", "price": 1590, "stock": 3, "category": "生活", "image": "cup.png" },
  { "id": "p2", "name": "帆布袋", "price": 3900, "stock": 0, "category": "生活", "image": "bag.png" },
  { "id": "p3", "name": "学习台灯", "price": 5900, "stock": 5, "category": "学习", "image": "lamp.png" }
)
global 收藏编号 = []
global 购物车 = makeDict()

function 商品行(商品) {
  return makeDict(pair("商品", 商品), pair("标签_名称", dictLookup(商品, "name", "")), pair("标签_价格", join("¥", decimals(dictLookup(商品, "price", 0) / 100, 2))), pair("标签_分类", dictLookup(商品, "category", "")), pair("标签_库存", join("库存 ", dictLookup(商品, "stock", 0))), pair("图像_封面", dictLookup(商品, "image", "")))
}

procedure 显示详情(行值) {
  var 商品 = dictLookup(行值, "商品", makeDict())
  对话框1.ShowMessageDialog(join("编号:", dictLookup(商品, "id", ""), "\n价格:¥", decimals(dictLookup(商品, "price", 0) / 100, 2), "\n分类:", dictLookup(商品, "category", ""), "\n库存:", dictLookup(商品, "stock", 0)), dictLookup(商品, "name", "商品详情"), "关闭")
}

procedure 更新总价() {
  var 总价 = 0
  foreach 商品 in 商品数据 {
    var 数量 = dictLookup(购物车, dictLookup(商品, "id", ""), 0)
    总价 = 总价 + 数量 * dictLookup(商品, "price", 0)
  }
  标签_总价.Text = join("购物车合计:¥", decimals(总价 / 100, 2))
}

procedure 筛选商品() {
  var 关键词 = trim(文本输入框_搜索.Text)
  var 行列表 = []
  foreach 商品 in 商品数据 {
    var 命中 = 关键词 == "" or textContains(join(dictLookup(商品, "name", ""), " ", dictLookup(商品, "category", "")), 关键词)
    var 满足库存 = not (复选框_现货.Checked) or dictLookup(商品, "stock", 0) > 0
    var 满足收藏 = not (复选框_收藏.Checked) or contains(收藏编号, dictLookup(商品, "id", ""))
    var 满足购物车 = not (复选框_购物车.Checked) or dictLookup(购物车, dictLookup(商品, "id", ""), 0) > 0
    if 命中 and 满足库存 and 满足收藏 and 满足购物车 { listAdd(行列表, 商品行(商品)) }
  }
  布局列表框_商品.Data = 行列表
  更新总价()
  标签_状态.Text = join("显示 ", length(行列表), " 件商品")
}

procedure 改变数量(行值, 变化) {
  var 商品 = dictLookup(行值, "商品", makeDict())
  var 编号 = dictLookup(商品, "id", "")
  var 原数量 = dictLookup(购物车, 编号, 0)
  var 新数量 = 原数量 + 变化
  if 新数量 >= 0 and 新数量 <= dictLookup(商品, "stock", 0) {
    dictSet(购物车, 编号, 新数量)
    微数据库1.StoreValue("course-cart-v1", 购物车)
    筛选商品()
    布局列表框_商品.Refresh()
  } else { 对话框1.ShowAlert("数量不能小于 0,也不能超过库存") }
}

procedure 切换收藏(行值) {
  var 商品 = dictLookup(行值, "商品", makeDict())
  var 编号 = dictLookup(商品, "id", "")
  if contains(收藏编号, 编号) { listRemove(收藏编号, indexOf(收藏编号, 编号)) }
  else { listAdd(收藏编号, 编号) }
  微数据库1.StoreValue("course-favorites-v1", 收藏编号)
  if 复选框_收藏.Checked { 筛选商品() }
  else { 布局列表框_商品.Refresh() }
}

when Screen1.Initialize() {
  收藏编号 = 微数据库1.GetValue("course-favorites-v1", [])
  购物车 = 微数据库1.GetValue("course-cart-v1", makeDict())
  筛选商品()
}
when 文本输入框_搜索.TextChanged() { 筛选商品() }
when 复选框_现货.Changed() { 筛选商品() }
when 按钮_重置.Click() {
  文本输入框_搜索.Text = ""
  复选框_现货.Checked = false
  复选框_收藏.Checked = false
  复选框_购物车.Checked = false
  筛选商品()
}
when 布局列表框_商品.ItemClick(索引, 值) { 显示详情(值) }
when 布局列表框_商品.ForEachData(索引, 值) {
  var 商品 = dictLookup(值, "商品", makeDict())
  var 数量 = dictLookup(购物车, dictLookup(商品, "id", ""), 0)
  var 已收藏 = contains(收藏编号, dictLookup(商品, "id", ""))
  var 收藏按钮 = 布局列表框_商品.GetComponent("按钮_收藏")
  any Button(收藏按钮).Text = choose(已收藏, "已收藏", "收藏")
  any Button(收藏按钮).BackgroundColor = choose(已收藏, colorFromHex("#FFE2B4"), colorFromHex("#E8EEF8"))
  var 数量标签 = 布局列表框_商品.GetComponent("标签_数量")
  var 加号 = 布局列表框_商品.GetComponent("按钮_加")
  var 减号 = 布局列表框_商品.GetComponent("按钮_减")
  any Label(数量标签).Text = join("数量 ", 数量)
  any Button(加号).Enabled = 数量 < dictLookup(商品, "stock", 0)
  any Button(减号).Enabled = 数量 > 0
}
when 布局列表框_商品.ComponentClick(标签, 索引, 值) {
  if 标签 == "按钮_收藏" { 切换收藏(值) }
  else if 标签 == "按钮_加" { 改变数量(值, 1) }
  else if 标签 == "按钮_减" { 改变数量(值, -1) }
  else if 标签 == "按钮_详情" { 显示详情(值) }
}
when 布局列表框_商品.ItemLongClick(索引, 值) { 切换收藏(值) }
when 复选框_收藏.Changed() { 筛选商品() }
when 复选框_购物车.Changed() { 筛选商品() }

中途验证:商品显示为两列;收藏、详情和加减仍按同一 id 工作,金额与第 5 关一致。缩小屏幕或旋转后按钮不应溢出卡片。

第 7 关:多模板做左右聊天气泡

新建项目。列表名为 布局列表框_聊天,宽高填充父级;设计器设置“从底部开始”为真。先拖入对方模板,再拖入自己模板,顺序对应类型 1 和 2。

布局列表框_聊天
├─ 竖直布局_对方(宽填充父级,水平对齐左)
│  └─ 标签_对方(宽自动,背景浅灰)
└─ 竖直布局_自己(宽填充父级,水平对齐右)
   └─ 标签_自己(宽自动,背景浅绿)

列表外增加 文本输入框_消息 和 按钮_发送。两个模板里的组件名必须不同;数据用 类型: 1 或 类型: 2 选择直接子模板。追加消息后滚到最后一行。需要更长对话时,应限制客户端保留数量,并从接口分页获取历史记录。

global 消息数 = 1

when Screen1.Initialize() {
  布局列表框_聊天.Data = list({ "类型": 1, "标签_对方": "你好,欢迎咨询商品。" })
}

when 按钮_发送.Click() {
  var 内容 = trim(文本输入框_消息.Text)
  if 内容 != "" {
    布局列表框_聊天.AppendData(list({ "类型": 2, "标签_自己": 内容 }))
    消息数 = 消息数 + 1
    文本输入框_消息.Text = ""
    布局列表框_聊天.ScrollToIndex(消息数)
  }
}

中途验证:初始消息在左边,自己发送的消息在右边;空输入不追加。连续发送后能看到最新消息。此关练习本地消息展示,不连接真实客服;联网收发可接入 WebSocket 指导。

第 8 关:纵向首页里放横滑栏目

新建项目,搭下面的组件树;添加非可视组件 对话框1。

布局列表框_首页(宽高填充父级,方向纵向)
└─ 竖直布局_栏目(宽填充父级,高自动)
   ├─ 标签_栏目
   └─ 布局列表框_横向(宽填充父级,高 140,方向横向)
      └─ 竖直布局_子商品(宽 160,高 120)
         └─ 标签_子商品

内层用固定高度,不能填充外层。外层每次绑定时取出当前行的内层克隆并重新设数据,避免回收后串栏目。内层实例是克隆,不会触发设计器原实例的专用事件;用「任意布局列表框的行点击」接收。先排除外层,仅在“尚未处理”为真时处理内层点击,避免与专用事件重复。

when Screen1.Initialize() {
  布局列表框_首页.Data = list(
    { "标签_栏目": "生活好物", "items": list({ "标签_子商品": "保温杯", "id": "p1" }, { "标签_子商品": "帆布袋", "id": "p2" }) },
    { "标签_栏目": "学习用品", "items": list({ "标签_子商品": "台灯", "id": "p3" }, { "标签_子商品": "笔记本", "id": "p4" }) }
  )
}

when 布局列表框_首页.ForEachData(索引, 值) {
  var 内层列表 = 布局列表框_首页.GetComponent("布局列表框_横向")
  any LayoutListView(内层列表).Data = dictLookup(值, "items", [])
}

when any LayoutListView.ItemClick(component, notAlreadyHandled, index, value) {
  if component != 布局列表框_首页 and notAlreadyHandled {
    对话框1.ShowMessageDialog(join("商品编号:", dictLookup(value, "id", "")), dictLookup(value, "标签_子商品", "商品"), "关闭")
  }
}

中途验证:首页显示两个栏目,每个栏目中有自己的子商品;点击生活栏目“帆布袋”显示 p2,学习栏目“笔记本”显示 p4。滚动外层并回来,各栏目仍显示自己的数据。

第 9 关:真接口分页、失败重试和超时

这一关单独使用第 2 关的商品卡片和对话框,去掉本地收藏/购物车相关组件和代码,先把网络流程验证清楚。需要组合时,把已经验证的 id 状态逻辑接到返回商品数据上。

增加的组件 名称 设置
文本输入框 文本输入框_地址 单行,填电脑实际局域网地址,例如 http://192.168.1.19:8765,末尾不要加斜杠
文本输入框 文本输入框_搜索 商品名称关键词
按钮 按钮_搜索、按钮_刷新、按钮_重试 文本“搜索”“刷新”“重试”
标签 标签_状态 显示加载、完成或失败
Web 客户端 Web1 保存响应为假,响应文本编码 UTF-8;代码设置超时 5000 毫秒

电脑下载并运行本页配套的 course-server.py,无需安装第三方包:

python3 course-server.py --host 0.0.0.0 --port 8765

手机和电脑在同一局域网。先用手机浏览器打开 http://电脑局域网地址:8765/products?page=1&size=10,能看到 JSON 再回 App 填地址。手机里的 localhost 是手机自身;安卓模拟器访问宿主机通常使用 10.0.2.2。接口仅用于本地实验,正式服务使用你自己的 HTTPS 地址。

接口返回对象含 page、items、hasMore、total;每个 item 含 id、name、price、stock、category、image。image 为本地图片名,所以不用等网络图片才能测试。

本关采用单请求加载锁,只有成功接收并校验结果后才推进页码;第一页成功后替换列表,后续页追加。每次请求的流水号进入 URL,回调只接受当前地址,过滤过期结果;按固定 id 去重。搜索要点按钮发起,刷新沿用本次关键词;列表首次 10 行,足够让本例纵向卡片滚动,结果较少时一次显示全部。

global 成功页 = 0
global 请求页 = 1
global 正在加载 = false
global 还有更多 = true
global 关键词 = ""
global 当前请求地址 = ""
global 请求流水 = 0
global 已显示编号 = []

function 商品行(商品) {
  return makeDict(pair("商品", 商品), pair("标签_名称", dictLookup(商品, "name", "")), pair("标签_价格", join("¥", decimals(dictLookup(商品, "price", 0) / 100, 2))), pair("标签_分类", dictLookup(商品, "category", "")), pair("标签_库存", join("库存 ", dictLookup(商品, "stock", 0))), pair("图像_封面", dictLookup(商品, "image", "")))
}

procedure 显示详情(行值) {
  var 商品 = dictLookup(行值, "商品", makeDict())
  对话框1.ShowMessageDialog(join("编号:", dictLookup(商品, "id", ""), "\n价格:¥", decimals(dictLookup(商品, "price", 0) / 100, 2), "\n分类:", dictLookup(商品, "category", ""), "\n库存:", dictLookup(商品, "stock", 0)), dictLookup(商品, "name", "商品详情"), "关闭")
}

procedure 结束请求() {
  正在加载 = false
  按钮_搜索.Enabled = true
  按钮_刷新.Enabled = true
  按钮_重试.Enabled = true
}

procedure 请求一页(页码) {
  if not (正在加载) {
    var 服务地址 = trim(文本输入框_地址.Text)
    if 服务地址 == "" { 标签_状态.Text = "先填写电脑的局域网地址" }
    else {
      正在加载 = true
      还有更多 = false
      请求页 = 页码
      请求流水 = 请求流水 + 1
      当前请求地址 = join(服务地址, "/products?page=", 页码, "&size=10&q=", Web1.UriEncode(关键词), "&request=", 请求流水)
      按钮_搜索.Enabled = false
      按钮_刷新.Enabled = false
      按钮_重试.Enabled = false
      标签_状态.Text = join("正在加载第 ", 页码, " 页…")
      Web1.Url = 当前请求地址
      Web1.Get()
    }
  }
}

when Screen1.Initialize() {
  Web1.Timeout = 5000
  标签_状态.Text = "填写接口地址后点搜索,例如 http://192.168.1.19:8765"
}

when 按钮_搜索.Click() {
  if not (正在加载) {
    关键词 = trim(文本输入框_搜索.Text)
    请求一页(1)
  }
}
when 按钮_刷新.Click() { 请求一页(1) }
when 按钮_重试.Click() { 请求一页(请求页) }
when 布局列表框_商品.ReachEnd() {
  if 还有更多 and 成功页 > 0 { 请求一页(成功页 + 1) }
}
when 布局列表框_商品.ItemClick(索引, 值) { 显示详情(值) }

when Web1.GotText(URL网址, 响应代码, 响应类型, 响应内容) {
  if URL网址 == 当前请求地址 and 正在加载 {
    if 响应代码 == 200 {
      var 返回数据 = Web1.JsonTextDecodeWithDictionaries(响应内容)
      if isDict(返回数据) and isList(dictLookup(返回数据, "items", "")) and dictLookup(返回数据, "page", 0) == 请求页 {
        var 新行 = []
        if 请求页 == 1 { 已显示编号 = [] }
        foreach 商品 in dictLookup(返回数据, "items", []) {
          var 编号 = dictLookup(商品, "id", "")
          if 编号 != "" and not (contains(已显示编号, 编号)) {
            listAdd(已显示编号, 编号)
            listAdd(新行, 商品行(商品))
          }
        }
        if 请求页 == 1 { 布局列表框_商品.Data = 新行 }
        else { 布局列表框_商品.AppendData(新行) }
        成功页 = 请求页
        还有更多 = dictLookup(返回数据, "hasMore", false)
        标签_状态.Text = join("已加载 ", length(已显示编号), " 件 · ", choose(还有更多, "滚到底部继续加载", "已全部加载"))
      } else { 标签_状态.Text = "返回格式不正确;应包含 items 列表、page 页码和 hasMore" }
    } else { 标签_状态.Text = join("HTTP ", 响应代码, ",点重试继续当前页") }
    结束请求()
  }
}

when Web1.TimedOut(URL网址) {
  if URL网址 == 当前请求地址 and 正在加载 {
    标签_状态.Text = "请求超时,已有商品保留;点重试"
    结束请求()
  }
}

when Screen1.ErrorOccurred(组件, 函数名称, 错误编号, 消息) {
  if 组件 == Web1 and 正在加载 {
    标签_状态.Text = join("错误 ", 错误编号, ":", 消息, ";检查地址或 JSON,再重试")
    结束请求()
  }
}

中途验证:正常接口按 10 → 20 → 30 件加载并停止;刷新替换第一页,不能越刷越重复。搜索“杯”结果一次显示 10 件并结束;停止服务器后重试应显示失败,已有行保留;失败后不自动连续请求,修复后点重试继续失败的页。更完整的故障测试见下表。

网络闭环测试表

操作 预期
正常启动服务,App 点搜索,再两次滚到底 10、20、30 件,无重复,提示已全部加载
搜索“杯” 10 件,hasMore 为假;再次滚动不发下一页
搜索“不存在” 0 件,列表空提示,已全部加载
连续点搜索或快速滚动 请求期间按钮禁用,只有一个请求在加载
保持列表已有数据,点刷新 成功后替换第一页;加载期间保留旧内容
服务重启为 python3 course-server.py --fail-page 2 第 1 页成功,第 2 页 HTTP 503,已有 10 件保留,成功页不推进
恢复正常服务,再点重试 仍请求第 2 页,追加到 20 件
服务重启为 python3 course-server.py --delay 6 超过 5 秒触发超时,按钮恢复,可重试
服务重启为 python3 course-server.py --invalid-json 返回格式错误提示,停止加载状态,仍可重试

把这些做法迁移到其他 App

需求 数据与交互怎么改 还需要什么
通讯录/学校/课程目录 id 换成联系人编号、学校编号或课程编号;卡片字段换成对应信息 详情页、电话或报名流程按业务另接
待办/工单 收藏按钮改“已完成”,保存每个 id 的状态;筛选改只看未完成 团队共享状态需保存到服务端
订单 商品 id 换订单号,金额用整数分,状态在每次绑定时设置 查询、取消和支付结果需真实业务接口
蓝牙设备/硬件通道 固定 id 用设备地址或通道编号,字典保存名称、连接状态、最近读数;连接状态变化时刷新行 扫描、连接与通信由对应蓝牙/通信组件完成,列表负责显示及选择
新闻/消息中心 卡片包含文章 id、标题、摘要和已读状态;点击后按 id 打开详情 网络分页复用第 9 关,消息收发接通信组件
相册/商品墙 图像键放本地路径或可访问图片地址,网格按设计器列数布局 大图查看、图片加载失败占位按需要补充

常见问题

现象 处理
标签始终是设计器占位文本 检查字典键是否等于组件名;行布局必须直接放在列表里
图片空白 素材中必须存在 cup.png、bag.png、lamp.png,大小写一致;确认图像的图片键对应图像组件名
点收藏没有反应 接收列表的组件点击事件并比较标签,不用原模板按钮的点击事件
滚动后收藏、颜色或数量跑到别的行 状态按固定 id 保存,遍历数据事件里设置全部分支,不能只设置选中的一侧
改了收藏,但可见按钮没变 行外状态变化后调用刷新;智能数据更新可能跳过内容相同的行
筛选后详情错了 使用事件传回的值及其 id;当前索引只是筛选后的位置,且从 1 开始
内层栏目点击没有触发 克隆内层的事件要用任意组件事件;不要绑定设计器原内层实例
试图在代码块改列数却找不到属性 列数、方向、从底部开始、行间距在设计器设置;运行时切换用多个列表实例
触底不加载 确认还有更多、没有请求进行中,并且内容超过一屏;不要把网络请求放在遍历数据事件中
手机打不开接口 手机不能用电脑的 localhost,检查同一局域网、实际 IP 和电脑防火墙;先用手机浏览器验证
请求失败后一直转圈或页码跳过 每个失败/超时路径都结束加载;只在成功并校验后推进成功页
模板里用了宽度 50%,网格溢出 百分比按整个屏幕宽度算,网格行内改用填充父级或固定尺寸

验收与下一步

本页每关完整代码都进行语法、真实成员和变量绑定检查,并重编文档检查最终 HTML。网络服务也测试分页、搜索、空结果、错误响应和超时配置。AI 伴侣与手机实际操作仍需要按各关“中途验证”逐项确认;这里不把代码校验当作真机测试。

需要完整可导入工程与大学数据,可继续用大学目录 AIA 和 APK;更多组件行为与限制见布局列表框使用指导。网络分页成功后再接入自己的业务服务,并继续做身份认证、服务端权限、更新库存和订单等真实业务流程。

文档反馈