フォルダひとつがアプリだ — バンドルにコードはない

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": {} }
}

サーバーがフォルダを配信する — 40行

フォルダは、このシリーズの他のサーバーアプリと同じやり方で 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}} はファイルではなくサーバーの言葉だからだ。

これはデモの傷ではなく構造が露わになったものだ。バンドルが持っているのは画面であり、品目はサーバーが持っている。

// 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