← 返回布局列表框使用指导 · 组件参考 · 全部使用指导
布局列表框循序渐进:从商品清单到购物车和网络分页
从三行商品名称开始,逐步做出有详情、搜索、收藏和购物车的商品目录,再练习两列网格、聊天气泡、横滑栏目和真实接口分页。商品为教学数据,价格以整数“分”保存,显示时转换成元。
| 难度 | 建议用时 | 准备 |
|---|---|---|
| 入门到进阶 | 每关约 10–20 分钟,可分多次完成 | Android 手机、包含布局列表框的最新版 AI 伴侣;网络关额外需要电脑和 Python 3 |
学习路线
| 关卡 | 做出的功能 | 可用于哪些 App |
|---|---|---|
| 1. 三行商品 | 模板和字典自动绑定 | 联系人、通知、设备目录 |
| 2. 卡片与详情 | 图片、价格和点击详情 | 商品、课程、新闻、订单 |
| 3. 搜索与筛选 | 关键词、现货开关、空提示 | 商品检索、通讯录、状态筛选 |
| 4. 收藏与复用 | 收藏保存、长按、行内按钮 | 收藏夹、关注、标记、待办状态 |
| 5. 购物车 | 加减数量、库存边界、金额、保存 | 点餐、购物、工单数量 |
| 6. 两列网格 | 相同业务换布局 | 商品墙、相册、功能入口 |
| 7. 多模板聊天 | 左右气泡、发送、滚到新消息 | 客服、消息记录、操作日志 |
| 8. 嵌套首页 | 纵向栏目内横滑,点击子商品 | 商城首页、推荐栏目、设备分组 |
| 9. 网络分页 | 真接口、加载、搜索、重试、超时、去重 | 商品列表、新闻流、订单分页 |
开始前的准备
- 新建一个 Screen1;屏幕“可滚动”设为假,“尺寸调整”设为响应式。列表本身负责滚动。
- 第 1–6、9 关使用同一个列表实例:
布局列表框_商品,宽、高均为填充父级,方向纵向,列数 1,空提示文本为“没有匹配的商品”。搜索和操作条放在列表外面。 - 在列表里面直接拖入
竖直布局_商品行,宽填充父级、高自动、背景白色,保持可见;这才是行模板。 - 第 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;更多组件行为与限制见布局列表框使用指导。网络分页成功后再接入自己的业务服务,并继续做身份认证、服务端权限、更新库存和订单等真实业务流程。
扫码添加客服咨询