一个文件,两种形状 — 不知道自己有多长的列表
5 分钟到目前为止,值变了形状不变。真实画面不是这样 — 没人等和三个人等,得显示不同的东西。
分岔的地方
{ "type": "conditional", "condition": "{{empty}}",
"then": { "type": "text", "text": "Nobody is waiting", ... },
"orElse": { "type": "box", ... } }为真走 then,为假走 orElse。两边都是普通节点,放什么都行。
这里用 {{empty}} 是要点。没有写成 {{waiting == 0}} 这样的表达式。决定条件的一方 是服务器,不是画面 — 「空」的定义一旦改变(比如决定预约不计入),要改的只有服务器。
不知数量的行
{ "type": "list", "items": "{{rows}}",
"itemTemplate": { "type": "linear", "direction": "horizontal", "distribution": "spaceBetween",
"children": [
{ "type": "text", "text": "{{item.no}}", ... },
{ "type": "text", "text": "{{item.since}}", ... } ] } }items 指向一个数组,itemTemplate 是 一行 的形状。有多少行,哪儿都没写。
模板里用 {{item.*}} 取这一行的值,不会和外面的状态串名。
{{rows}} 放三个就三行,放十个就十行。文件仍然是 1,234 字节。
同一个文件渲染两次
这一讲的主张是「一个文件变成两种形状」。一张截图证明不了。
await render('step5', {'empty': true, 'waiting': 0, 'rows': <dynamic>[]},
as: 'step5_empty', ...);
const rows = [
{'no': '44', 'since': 'waiting 2 min'},
{'no': '45', 'since': 'waiting 5 min'},
{'no': '46', 'since': 'waiting 9 min'},
];
await render('step5', {'empty': false, 'waiting': rows.length, 'rows': rows},
as: 'step5_filled', ...);然后数实际搭起来了几行。
final built = [
for (final r in rows)
if (find.text(r['no']!).evaluate().isNotEmpty) r['no']!
];step5_empty 1234 B, 16 lines, type "page" -> step5_empty.png
empty state shows: 1 placeholder, 0 rows
step5_filled 1234 B, 16 lines, type "page" -> step5_filled.png
filled state shows: 0 placeholder, 3 rows (44, 45, 46)要数,是因为数量错了图也看得过去。三行只画两行,卡片照样显得是满的。
grep -qE '"(44|45|46)"' screens/step5.json && die "step5: the rows are baked in"
grep -q '"itemTemplate"' screens/step5.json || die "step5: no row template"
cmp -s captures/step5_empty.png captures/step5_filled.png && \
die "step5: the conditional did not change anything"
grep -q 'empty state shows: 1 placeholder, 0 rows' $LOG || \
die "step5: the empty branch is wrong"
grep -q 'filled state shows: 0 placeholder, 3 rows (44, 45, 46)' $LOG || \
die "step5: the list did not build one row per item"把 {{item.no}} 换成 44 再跑,第一行就抓住了。
五讲
step1 233 B, 4 lines, type "page" -> step1.png
step2 906 B, 12 lines, type "page" -> step2.png
step3 one file, two states, two pictures (27577 B vs 24734 B)
step4 pressed -> the host was asked for: queue.next, queue.take
step5 empty state shows: 1 placeholder, 0 rows
filled state shows: 0 placeholder, 3 rows (44, 45, 46)
live ui://desk — 546 B, 6 lines, type "page", title "Desk"
the server now reports {"waiting":2} after one press
5 steps + the live loop · each checked against its own claim · one host, five screens, no widgets written by hand最后一行就是这条线的要旨。五个画面做五件不同的事,画它们的代码只有一份。第 1 讲之后宿主的渲染部分没变过,多出来的只是 onToolCall 的函数体,和那些把截图看不见的东西记下来的检查行。
三条线接上的地方
course-server 六讲 开工具、拒绝、把画面作为资源送出、变了就通知
course-client 五讲 连上、询问、调用、接收画面、订阅
course-ui 五讲 收到的那个画面变成图三条线讲的是同一间候诊室。服务器的 desk.admit 就是客户端的 callTool,服务器在 ui://desk 送出的 JSON 和这条线的 screens/*.json 是同一套语法。
这句话对不对,由最后一项检查来定。host/test/live_test.dart 真的启动服务器课程的成品服务器,读 ui://desk — 那是一个不在 screens/ 里的画面 — 原样渲染,再按下那个画面自己带着的 Admit one。
ui://desk — 546 B, 6 lines, type "page", title "Desk"
nothing about this screen is on disk here; it arrived just now
opened with 3 waiting
pressed Admit one
the server now reports {"waiting":2}按一次,3 变成了 2。前后两张截图不同,检查也一并核对。把服务器的画面拷进这个文件夹,[ -e screens/desk.json ] 就会抓住 — 渲染一份本地副本,这一讲的主张就不成立了。
客户端那条线不用模拟服务器 — 五步全部真的启动服务器课程做的那台服务器再连上去。所以两条线一旦对不上,检查就会抓住。客户端第 3 讲确实抓住过一次,服务器一侧被错误合并的拒绝文案就是那时找出来的。
自己跑一遍
cd content/sample/course-ui
bash verify.sh
open captures/step5_empty.png captures/step5_filled.png带走的
conditional— 条件用状态接,不要写成表达式list+itemTemplate— 只写一行的形状- 数行数 — 数量错了图也看得过去
接下来
三条线讲的是同一间候诊室。从下个月起,这块画面要下到人真正站着的地方 — 仓库、交接班、断了网的收银台。
运行示例
makemind-academy/course_ui/git clone https://github.com/makemind-academy/course_ui cd course_ui bash verify.sh