一个文件夹就是应用 — 包里没有代码
9 分钟此前的五篇全是代码。C 固件与 Dart 服务器。每次都有编译在跑。
这一篇没有编译。我们要做无人店铺店主看到的应用,而源码是四个 JSON 文件。
unmanned_store.mbd/
manifest.json 它是谁
ui/app.json 路由
ui/pages/main.json 画面一
ui/pages/restock.json 画面二而且在这篇的后半,我们会真的跑一遍「改了那些 JSON 应用就变」。构建不会运行。
先看结果
店主看到的第一个画面。今天的营业额和库存,以及有几样需要补货。

按下「What needs a visit」,就跳到同一个文件夹里的另一个画面。

按下「Order the lot」,列表清空。

现在我们改 JSON 再跑一次。 编译器没有执行。

请仔细看最后这张。标签变成了洗衣店,而商品依然是冰淇淋。这个只变了一半的画面,是这一篇里最重要的一张图。后面还会回来。
包是什么
manifest.json 说明这个应用是谁。不是代码,是身份。
{
"schemaVersion": "1.0.0",
"manifest": {
"id": "com.makemind.sample.unmanned_store",
"name": "Unmanned Store",
"type": "application",
"entryPoint": "ui.app",
"description": "What the owner of an unmanned store sees: stock, takings and what needs a visit. No code, only declarations.",
"category": "business",
"tags": ["retail", "unmanned", "sample"]
}
}ui/app.json 是画面的地图。
{
"type": "application",
"title": "Unmanned Store",
"initialRoute": "/",
"routes": {
"/": "ui://pages/main",
"/restock": "ui://pages/restock"
}
}而每一个页面就是一个画面。按钮做什么也写在这里 — 不是函数名,而是工具名。
{
"type": "button",
"label": "What needs a visit",
"variant": "elevated",
"onTap": { "type": "navigation", "action": "push", "route": "/restock" }
},
{
"type": "button",
"label": "Refresh",
"variant": "outlined",
"onTap": { "type": "tool", "tool": "store.today", "params": {} }
}服务器托管这个文件夹 — 四十行
这个文件夹在 AppPlayer 里的打开方式,和这个系列里其他服务器应用一样:由店铺自己的服务器托管。serve_bundle.dart 就是全部 — 注册 ui://app,再为每个文件注册一个 ui://pages/<名字>,每次请求都重新读文件。之所以短,是因为这个格式不要求更多:播放器本来就会画画面定义,而包不过是给画面定义加了一份目录。
void registerBundleUi(Server server, String bundleDir) {
final manifest = _json('$bundleDir/manifest.json')['manifest'];
void serve(String uri, String name, String description,
Map<String, dynamic> Function() document) {
server.addResource(
uri: uri, name: name, description: description,
mimeType: 'application/json',
handler: (requestedUri, params) async => ReadResourceResult(contents: [
ResourceContentInfo(uri: requestedUri,
mimeType: 'application/json', text: jsonEncode(document())),
]),
);
}
serve('ui://app', manifest['name'], 'The app: routes and theme',
() => _json('$bundleDir/ui/app.json'));
for (final f in Directory('$bundleDir/ui/pages').listSync().whereType<File>()) {
if (!f.path.endsWith('.json')) continue;
final name = f.uri.pathSegments.last.replaceAll('.json', '');
serve('ui://pages/$name', name, 'Screen "$name"', () => _json(f.path));
}
}验证在包发出去之前,先确认文件夹里没有任何需要构建的东西。
# If there is a build step this article's argument collapses, so start by
# checking there is nothing to build
BUILDISH=$(find unmanned_store.mbd -type f ! -name '*.json' | wc -l | tr -d ' ')
[ "$BUILDISH" -eq 0 ] || { echo "bundle contains non-json files"; exit 1; }页面进入时自己把状态填上
第一次跑的时候第二个画面是空的。 服务器说有三行短缺,截图却是「Nothing to bring」。
原因在格式里面。每个页面都带着自己的 initialState。
"initialState": { "low": [], "lowCount": 0, "notice": "" }一换路由,新画面就从自己的初始值开始。上一个画面收到的数据不会跟过来。所以每个页面写明进入时要问什么,运行时把答案并进页面状态。
"onInit": { "type": "tool", "tool": "store.today", "params": {} }也放进了验证。补货画面要等列表上来了才拍,下单必须把列表清空,而不是只换个说法。
ap.tap("What needs a visit")
ap.wait_text("bring") # photographed only once the list is on it
ap.shot("02_restock.png")
ap.tap("Order the lot")
ap.wait_text("Ordered 3 lines")
ap.expect_no_text("bring 13") # the ordered line is gone, not just relabelled改了 JSON,应用就变
这是这一篇的主张。原件保持不动,改的是副本 — ui/pages/main.json 里的三个字符串。
after = (before
.replace("Shelf · open 24 h", "Laundry · open 24 h")
.replace("TAKINGS TODAY", "COIN BOX TODAY")
.replace("{{lowCount}} lines at or below reorder",
"{{lowCount}} machines need a look"))然后重新打开应用并渲染。
edited ui/pages/main.json — 8921 B -> 8919 B, no compiler ran8,921 字节变成 8,919 字节,画面变了。 这中间编译器没有执行。
可是只变了一半
这就是前面说要回来的那张图。标签成了洗衣店,列表却还是 Cone vanilla、Bar mint、Tub 474ml,名字也还是 Ice cream — Elm Street branch,因为 {{storeName}} 是服务器的话,不是文件里的。
这不是 demo 的瑕疵,而是结构露了出来。 包握着的是画面,商品握在服务器手里。
// The bundle in the folder next door has no code. It declares screens and
// says which tool a button calls. The tools live here. That split is the
// argument of this article — the person who edits the screen and the
// person answerable for the stock are not the same person, and should not
// be editing the same file.店主改画面上的文案不需要开发者。作为交换,想改库存规则的话,画面改到天荒地老也没用。 无代码够得着与够不着的界线正是这条,而那个半变成洗衣店的画面,用一张图把这条线画了出来。
验证打印的内容
$ bash verify.sh
[1/3] bundle is only json
[2/3] store_server (dart analyze)
No issues found!
[3/3] open in AppPlayer, walk it, capture
edited ui/pages/main.json — 8921 B -> 8919 B, no compiler ran
store-bundle: 2 routes walked, reorder cleared the list, json edit took effect with no build实测值
| 值 | |
|---|---|
| 包内文件数 | 4(全是 JSON) |
| 包总大小 | 21 KB(21,551 字节) |
| 托管辅助代码 | 40 行(serve_bundle.dart) |
| 路由 | 2 |
| 构建次数 | 0 |
没有时间值。播放器的时钟不是这一篇的主题,每次运行都会变的数字也不是这个格式的实测值。
范围之外
- 有几十个页面的包的加载成本。这个包只有两个。
- 不经服务器、把
.mbd直接装进播放器的路径。这一篇因为页面要调用服务器的工具,所以经由服务器打开文件夹;播放器的安装、签名、分发路径没有涉及。 - 在包内使用
client.mcpStream这类客户端侧通道的情形。已有的包示例(ble_monitor.mbd)用了它,这一篇没有接。
这个样例的范围
这篇跑的是包格式 — 由它自己的服务器托管、由 AppPlayer 绘制,不是播放器的安装流水线。这个区分我们不会含糊。
自己跑一遍
( cd store_server && dart pub get )
bash verify.sh # bundle check + analyze + AppPlayer: two routes, the order, the edit想手动打开,就在 AppPlayer 里加一个服务器应用 — 命令 dart,参数 run bin/server.dart,工作目录 store_server/。包本身不需要任何工具,一个文本编辑器就够。
cat unmanned_store.mbd/ui/app.json
cat unmanned_store.mbd/ui/pages/main.json可以带走的
- 包的骨架 —
unmanned_store.mbd/那四个文件。改个名字就是下一个应用的起点 - 40 行托管辅助代码 —
serve_bundle.dart。把它放到任何服务器旁边,旁边的文件夹就是应用 - 包检查 —
verify.sh里的「非 JSON 为 0」检查与路由解析检查
想改成自己的,该动哪里
要改文案或排布 —ui/pages/*.json。不需要开发者,也没有构建。
要多加一个画面 — 在 ui/pages/ 放一个文件,在 ui/app.json 的 routes 里加一行。跳到那一页的按钮是 {"type":"navigation","action":"push","route":"/新名字"}。
要改数据 —是服务器,不是包。 这就是边界。包改到天荒地老,商品也不会变(§ 可是只变了一半)。
所以这个格式卖的是什么
有份提案里有这么一句 —「领域专家不写代码,只靠点击就能融合出领域专用的应用包。」
这篇文章偿还的是那句话的准确尺寸。不写代码能做到的是画面。画面是 JSON,JSON 是人能改的,改了不用构建就生效。到这里为止都是真的。
而把做不到的也一起写出来,才算诚实。商品是什么、补货点是多少、订单怎么发出去,这些都在包的外面。 那是服务器的活儿,总得有人去写。那个半变成洗衣店的画面把这条线亮了出来 — 与其承诺去抹掉这条线,不如把它在哪儿指给你看。
练习任务
用两句话说明:修改画面文件后,下一次连接时会发生什么变化,以及为什么不需要安装步骤。