一个文件夹就是应用 — 包里没有代码

9 分钟
目标我们做了无人店铺店主用的应用。源码是四个 JSON,没有编译。顺着路由走到第二个画面,改一段文字画面就变 — 构建不会运行。只是变的只有画面,商品照旧。那个分岔就是这一篇的主题。

此前的五篇全是代码。C 固件与 Dart 服务器。每次都有编译在跑。

这一篇没有编译。我们要做无人店铺店主看到的应用,而源码是四个 JSON 文件。

unmanned_store.mbd/
  manifest.json            它是谁
  ui/app.json              路由
  ui/pages/main.json       画面一
  ui/pages/restock.json    画面二

而且在这篇的后半,我们会真的跑一遍「改了那些 JSON 应用就变」。构建不会运行。

先看结果

店主看到的第一个画面。今天的营业额和库存,以及有几样需要补货。

Ice cream — Elm Street branch · Shelf。库存列带着 LOW 标记,右上是今天的营收。真实渲染截图

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

补货界面 —— 需要带 3 项,每行都有 on hand · reorder at 与被强调的 `bring N`

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

下单之后列表清空,只剩下 "Ordered 3 lines"

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

改了一个 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 ran

8,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

可以带走的

  1. 包的骨架 —unmanned_store.mbd/ 那四个文件。改个名字就是下一个应用的起点
  2. 40 行托管辅助代码 —serve_bundle.dart。把它放到任何服务器旁边,旁边的文件夹就是应用
  3. 包检查 —verify.sh 里的「非 JSON 为 0」检查与路由解析检查

想改成自己的,该动哪里

要改文案或排布 —ui/pages/*.json。不需要开发者,也没有构建。

要多加一个画面 — 在 ui/pages/ 放一个文件,在 ui/app.json 的 routes 里加一行。跳到那一页的按钮是 {"type":"navigation","action":"push","route":"/新名字"}。

要改数据 —是服务器,不是包。 这就是边界。包改到天荒地老,商品也不会变(§ 可是只变了一半)。

所以这个格式卖的是什么

有份提案里有这么一句 —「领域专家不写代码,只靠点击就能融合出领域专用的应用包。」

这篇文章偿还的是那句话的准确尺寸。不写代码能做到的是画面。画面是 JSON,JSON 是人能改的,改了不用构建就生效。到这里为止都是真的。

而把做不到的也一起写出来,才算诚实。商品是什么、补货点是多少、订单怎么发出去,这些都在包的外面。 那是服务器的活儿,总得有人去写。那个半变成洗衣店的画面把这条线亮了出来 — 与其承诺去抹掉这条线,不如把它在哪儿指给你看。

练习任务

用两句话说明:修改画面文件后,下一次连接时会发生什么变化,以及为什么不需要安装步骤。

相关文章A Folder of JSON Is the App — There Is No Code in a Bundle