10줄로 첫 화면을 띄우다

5 분
목표대기실 번호판을 만든다. 화면 정의는 아홉 줄이고, 그 아홉 줄은 클라이언트 안에 없다. 서버가 파일로 들고 있다가 접속한 쪽에 건네준다. 빌드도 설치도 없다는 말은 그 뜻이다.

대기실 번호판을 만든다. 큰 숫자 하나, 대기 인원 한 줄, 버튼 두 개.

이런 걸 만드는 데 보통 무엇이 필요한가. 프로젝트를 만들고, 위젯 트리를 짜고, 빌드하고, 패널에 설치한다. 번호 글씨를 키우려면 그 네 단계를 다시 돈다.

여기서는 화면이 서버에 있는 파일 하나다. 접속하면 그 파일이 온다.

화면

전부다.

{ "type": "page", "title": "Front Desk",
  "content": { "type": "center",
    "child": { "type": "linear", "direction": "vertical", "spacing": 16, "alignment": "center", "children": [
      { "type": "text", "text": "NOW SERVING", "style": { "fontSize": 18, "letterSpacing": 4, "color": "#6b7280" } },
      { "type": "text", "text": "{{now}}", "style": { "fontSize": 96, "fontWeight": "bold", "color": "#111827" } },
      { "type": "text", "text": "{{waiting}} waiting · issued {{issued}}", "style": { "fontSize": 20, "color": "#6b7280" } },
      { "type": "linear", "direction": "horizontal", "spacing": 12, "children": [
        { "type": "button", "label": "Call next", "onTap": { "type": "tool", "tool": "queue.next" } },
        { "type": "button", "label": "Take a ticket", "onTap": { "type": "tool", "tool": "queue.take" } } ] } ] } } }

제목을 열 줄이라고 달았는데 실제로는 아홉 줄, 901바이트로 끝났다.

{{now}} 자리에 번호가 들어간다. 버튼의 onTap 은 도구 이름을 부른다 — 이번 글에서는 눌러도 아직 아무 일이 안 일어난다. 그건 다음 글이다.

그 아홉 줄은 서버에 있다

서버 쪽 코드는 이게 전부다.

server.addResource(
  uri: 'ui://app',
  name: 'Front Desk screen',
  description: 'The screen definition, served as a file',
  mimeType: 'application/json',
  handler: (uri, params) async => ReadResourceResult(contents: [
    ResourceContentInfo(
      uri: 'ui://app',
      mimeType: 'application/json',
      text: File(_screenPath).readAsStringSync(),
    )
  ]),
);

매번 파일을 새로 읽는다. 부팅할 때 한 번 읽어서 메모리에 들고 있으면 빠르긴 한데, 그러면 화면을 고칠 때마다 서버를 재시작해야 하고 그 순간 이 샘플이 주장하는 것 — 파일을 고치면 다른 앱이 된다 — 이 거짓이 된다.

/// Read fresh on every request, deliberately. A screen cached at boot is a
/// screen you have to restart the server to change, and then the thing this
/// sample claims — edit the file, get a new app — stops being true.

받아서 그린다

클라이언트 쪽은 세 줄이다.

final read = await client.readResource('ui://app');
final screen = jsonDecode(read.contents.first.text!) as Map<String, dynamic>;
await rt.initialize(screen);

rt 는 런타임이다. JSON을 넣으면 화면이 된다.

the server offers: ui://app
ui://app — 901 B, 9 lines, type "page", title "Front Desk"
nothing about this screen is on disk in the player; it arrived just now
opened at 41, 0 waiting

NOW SERVING 41 — 0 waiting · issued 41, Call next / Take a ticket

검증이 붙잡는 것

이 편에서 제일 쉽게 거짓말이 되는 자리가 있다. 샘플이 화면을 이미 갖고 있으면서 "서버에서 왔다"고 쓰는 것. 캡처만 보면 구별이 안 된다.

그래서 검증이 서버가 건네주는 것을 본다.

screen = s.read("ui://app")
assert screen["type"] == "page", "the screen must arrive as a resource"

화면은 리소스로 도착해야 하고, 샘플이 이미 갖고 있던 것이면 실패한다. 그리고 줄 수도 검사한다 — 제목이 열 줄인데 화면이 서른 줄이 되어 있으면 그것도 실패다.

LINES=$(grep -c '' ui/screen.json)
[ "$LINES" -le 10 ] || { echo "   ui/screen.json is $LINES lines, the title says ten"; exit 1; }
$ bash verify.sh
   [1/3] the screen is short enough to be the claim
   ui/screen.json — 9 lines, 901 bytes
   [2/3] screen_server (dart analyze)
No issues found!
   [3/3] open in AppPlayer, press the buttons, reconnect
first-screen: 10 lines of screen served not compiled, 41 -> 43, refused at the last ticket, reconnect reset it to 41

아홉 줄이 산 것과 안 산 것

산 것 — 화면 글씨 크기를 96에서 120으로 바꾸고 싶으면 서버의 파일 한 줄을 고치고 패널을 다시 접속시킨다. 빌드 없음, 설치 없음, 심사 없음.

안 산 것 — 이 화면은 아직 아무것도 못 한다. 버튼이 도구 이름을 부르지만 그 도구가 없다. 번호가 41에서 안 움직인다.

숫자를 움직이게 하는 일, 그리고 그 숫자가 누구 것인지 — 화면 것인지 카운터 것인지 — 는 다음 글이다.

직접 돌려보기

( cd screen_server && dart pub get )

# In AppPlayer: add a server app — command `dart`, arguments `run bin/server.dart`,
# working directory `screen_server/`.

bash verify.sh

ui/screen.json 을 고치고 다시 돌리면 바뀐 화면이 캡처로 나온다. 그게 이 편의 전부다.

가져갈 것

  1. 파일을 매 요청 읽는 리소스 핸들러 — 캐시하는 순간 무설치가 아니게 된다
  2. readResource → jsonDecode → initialize 세 줄
  3. 화면은 리소스로 도착해야 한다 — 이 주장이 거짓이 될 유일한 자리를 검증이 막는다

확인 과제

화면 파일을 고치면 다음 접속에서 무엇이 바뀌는지, 왜 설치 단계가 없는지 두 문장으로 설명해 보세요.

관련 글Putting Up Your First Screen in Ten Lines