앞선 다섯 편은 전부 코드였다. C 펌웨어, Dart 서버, Flutter 클라이언트. 매번 컴파일이 돌았다.
이번 편에는 컴파일이 없다. 무인매장 점주가 보는 앱을 만들 건데, 소스는 JSON 파일 네 개다.
unmanned_store.mbd/
manifest.json 누구인지
ui/app.json 라우트
ui/pages/main.json 화면 하나
ui/pages/restock.json 화면 둘
그리고 이 글 후반에는, 그 JSON을 고치면 앱이 바뀌는 것까지 실제로 돌려 본다. 빌드는 돌지 않는다.
결과부터
점주가 보는 첫 화면. 오늘 매출과 재고, 그리고 채워 넣어야 할 게 몇 개인지.

「What needs a visit」를 누르면 같은 폴더의 다른 화면으로 간다.

bring N 이 강조된다「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": {} }
}
로더는 40줄이다
번들을 읽는 코드 전부다. 짧은 이유는 형식이 더 요구하지 않아서다 — 런타임은 이미 화면 정의를 그릴 줄 알고, 번들은 화면 정의에 목차를 붙인 것이다.
factory Bundle.load(String path) {
final root = Directory(path);
if (!root.existsSync()) throw ArgumentError('no bundle at $path');
final manifest = _readJson(File('${root.path}/manifest.json'));
final app = _readJson(File('${root.path}/ui/app.json'));
final pages = <String, Map<String, dynamic>>{};
final pageDir = Directory('${root.path}/ui/pages');
if (pageDir.existsSync()) {
for (final f in pageDir.listSync().whereType<File>()) {
if (!f.path.endsWith('.json')) continue;
final name = f.uri.pathSegments.last.replaceAll('.json', '');
pages['ui://pages/$name'] = _readJson(f);
}
}
return Bundle._(root, manifest, app, pages);
}
라우트를 푸는 곳에서 한 가지를 일부러 한다. 없는 페이지를 가리키는 라우트는 던진다.
/// 라우트 뒤의 화면. null 을 돌려주지 않고 던지는 이유: `app.json` 의 라우트가
/// 아무도 쓰지 않은 페이지를 가리키면 그건 깨진 번들이고, 사용자가 탭했을 때
/// 알게 되는 것보다 로드할 때 알게 되는 편이 낫다.
Map<String, dynamic> screenFor(String route) {
final uri = routes[route];
if (uri == null) throw ArgumentError('no route "$route" in ${app['title']}');
final page = _pages[uri];
if (page == null) throw StateError('route "$route" points at $uri, missing');
return page;
}
검증 스크립트도 같은 걸 본다. 번들이 실려 나가기 전에 라우트가 전부 풀리는지 확인한다.
# 빌드 단계가 있으면 이 글의 논지가 무너지므로, 빌드할 게 없는지부터 본다
BUILDISH=$(find unmanned_store.mbd -type f ! -name '*.json' | wc -l | tr -d ' ')
[ "$BUILDISH" -eq 0 ] || { echo "bundle contains non-json files"; exit 1; }
화면을 옮기면 상태를 다시 채워야 한다
처음 돌렸을 때 두 번째 화면이 비어 있었다. 로그에는 분명히 low=3 이라고 찍혀 있는데 캡처는 "Nothing is low. No trip needed today." 였다.
원인은 형식 안에 있었다. 페이지마다 자기 initialState 를 들고 있다.
"initialState": { "low": [], "lowCount": 0, "notice": "" }
라우트를 옮기면 새 화면이 자기 초기값으로 시작한다. 앞 화면이 받아 둔 데이터가 따라오지 않는다. 실제 앱이라면 페이지의 생명주기가 진입 시 채우고, 여기서는 호스트가 명시적으로 채운다.
await go('/restock');
// 페이지는 자기 initialState 를 선언하므로, 도착하면 누군가 채우기 전까지
// 빈 화면이다. 출시된 앱에서는 페이지 생명주기가 ready 에 이 일을 하고,
// 여기서는 호스트가 명시적으로 한다. 이걸 빼먹으면 세 개가 부족하다는
// 로그 옆에 빈 목록 스크린샷이 남는다.
await callTool('store.today');
await shoot('02_restock');
검증에도 넣었다. 로그와 그림이 어긋나면 실패한다.
# restock 화면은 찍히기 전에 채워져 있어야 한다 — 아니면 로그는 셋이
# 부족하다는데 캡처는 빈 목록인 상태가 남는다
grep -A1 'route "/restock"' captures/run.log | grep -q 'low=3' \
|| { echo "the restock screen was captured without its data"; exit 1; }