AWTK 实时预览插件 (vscode) 工作原理

AWTK 实时预览插件 (vscode) 工作原理

1. 背景

很早就计划写一个 vscode 插件来预览 AWTK 的 UI XML 文件。迟迟没有动手,主要是既不太熟悉 vscode 插件的开发,也没有想清楚这个插件的架构。如何达到期望的目标,同时保证工作量可以接受呢?

直到完成了 AWTK 自动测试框架,发现以服务的方式来实现界面预览是最好的选择:不但预览服务的工作可以重用现有的代码,而且可以保证插件本身的代码最小,以后为其它 IDE 增加预览的功能也相当简单。

在开发 AWTK 自动测试框架 时,我们顺带实现了 AWTK RESTful HTTP 服务框架 ,这使得现实一个 Restful HTTP 服务器超级简单。

效果大概如下图:

demo.png

2. 工作原理

工作原理是这样的:在 vscode 中,通过命令激活 预览 后,如果当前文档是 AWTK XML UI 文件,就在侧边打开一个 webview,webview 读取文档的内容,并将文档内容通过 HTTP 请求发往 Preview 服务,Preview 服务在后台渲染。然后 webview 在指定的 URL 读取截图的 png 文件,并显示到 webview 中。

基本架构图:

vscode-plugin.jpg

3. 插件实现

3.1 注册命令

在插件激活时,注册一个命令,它负责打开预览的 webview。

    context.subscriptions.push(
        vscode.commands.registerCommand('awtk.preview', () => {
            if (isAwtkUiFile(vscode.window.activeTextEditor)) {
                UIPreviewPanel.createOrShow(context.extensionUri);
            }
        })
    );

如果预览的 webview 已经存在则更新它,否则创建一个新的 webview。始终在侧边创建,需要使用参数 vscode.ViewColumn.Two。

3.2 创建 webview

    public static createOrShow(extensionUri: vscode.Uri) {
        const column = vscode.ViewColumn.Two;

        if (UIPreviewPanel.currentPanel) {
            UIPreviewPanel.currentPanel._panel.reveal(column);
            UIPreviewPanel.currentPanel.update();
            return;
        } else {
            const panel = vscode.window.createwebviewPanel(
                UIPreviewPanel.viewType, 'AWTK UI Previewer', column,
                {
                    enableScripts: true,
                }
            );
            UIPreviewPanel.currentPanel = new UIPreviewPanel(panel, extensionUri);
        }
    }

3.3 webview 的 HTML 文件

这个很简单:一个 image 对象来显示预览的效果,还有一些设置和提交设置的按钮。值得一提的是用了一个隐藏的 input 来保存 XML 内容。

    private _getHtmlForwebview(webview: vscode.webview, source: string, appRoot: string) {
        const scriptPathOnDisk = vscode.Uri.joinPath(this._extensionUri, 'media', 'main.js');
        const scriptUri = webview.aswebviewUri(scriptPathOnDisk);
        const escapeSource = escape(source);
        const nonce = getNonce();

        return `<!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>UI Preview</title>
              <script nonce="${nonce}" src="${scriptUri}"></script>
            </head>
            <body>
            <image id="screenshot"/>
            <table style="width: 100%;">
                <tr><td>Width:</td><td><input type="text" id="width" value="320"></td></tr>
                <tr><td>Height:</td><td><input type="text" id="height" value="480"></td></tr>
                <tr><td>Language:</td><td><input type="text" id="language" value="en"></td></tr>
                <tr><td>Country:</td><td><input type="text" id="country" value="US"></td></tr>
                <tr><td>Theme:</td><td><input type="text" id="theme" value="default"></td></tr
                <tr><td>App Root:</td><td><input type="text" id="app_root" value="${appRoot}"></td></tr>
            </table>
      <input id="source" type="hidden" value="${escape(source)}" >
      <input id="apply" type="submit" value="Apply">
            </body>
            </html>`;
    }

3.4 webview 的 JS 文件

它的主要职责是在 UI 文件内容变化时,向 preview 服务发送请求。为了保存发送不会过于频繁,在上一个请求完成后才发起新的请求。

function clientUpdateUI(escapedXml, app_root, width, height, language, country, theme) {
  const xml = unescape(escapedXml);

  const reqJson = {
    xml: xml,
    app_root: app_root,
    width: width,
    height: height,
    language: language,
    country: country,
    theme: theme
  }

  console.log(reqJson);

  try {
    const parser = new DOMParser();
    const xmlDoc = parser.parseFromString(xml, "text/xml");
    reqJson.xml = xmlDoc.documentElement.outerHTML;

    if (reqJson.xml.indexOf('parsererror') > 0) {
      console.log("invalid ui xml:", reqJson.xml);
      screenshot.width = width;
      screenshot.height = height;

      screenshot.src = "http://localhost:8000/screenshot?timestamp=" + Date.now();

      return;
    }
  } catch (e) {
    console.log("invalid ui xml", e);
    return;
  }

  const req = JSON.stringify(reqJson, null, '\t');

  function onRequestResult() {
    const screenshot = document.getElementById('screenshot');
    const width = document.getElementById('width').value;
    const height = document.getElementById('height').value;

    screenshot.width = width;
    screenshot.height = height;
    screenshot.onload = function () {
      s_pendingLoad = false;
      console.log("screen shot loaded");
      return;
    }
    s_pendingLoad = true;
    screenshot.src = "http://localhost:8000/screenshot?timestamp=" + Date.now();

    console.log(this.responseText);
  }

  let oReq = new XMLHttpRequest();
  oReq.addEventListener("load", onRequestResult);
  oReq.open("POST", "http://localhost:8000/ui");
  oReq.send(req);

  return;
}

3.5 XML UI 文件更新事件处理

注册文档变化事件,如果是当前预览的文档,向 webview 发送 updateSource 事件:

    context.subscriptions.push(vscode.workspace.onDidChangeTextDocument(e => {
        let uri = e.document.uri.toString();
        if (UIPreviewPanel.currentPanel) {
            const doc = e.document;
            let source = doc.getText();

            if (UIPreviewPanel.currentPanel.uri == uri) {
                UIPreviewPanel.currentPanel.updateSource(source);
            }
        }
    })
    );

...
    public updateSource(source: string) {
        this._panel.webview.postMessage({ type: 'updateSource', source: source });
    }

webview 里设置变化的标识,在下一次定时任务中发送更新请求。

  window.addEventListener('message', event => {
    const message = event.data; // The json data that the extension sent
    switch (message.type) {
      case 'updateSource':
        {
          console.log('updateSource:', message);
          const source = document.getElementById('source');
          if (source.value != message.source) {
            source.value = message.source;
            s_pendingUpdate++;
          } else {
            console.log('not changed')
          }
          break;
        }
      default: break;
    }
    });

4. 服务实现

在 preview 服务中,实现了两个请求:

    1. 用于更新设置和 XML。
    1. 用于获取预览的效果图。
static const http_route_entry_t s_ui_preview_routes[] = {
    {HTTP_POST, "/ui", ui_preview_on_update_ui},
    {HTTP_GET, "/screenshot", ui_preview_on_get_screenshot}};

ret_t ui_preview_start(int port) {
  httpd_t* httpd = httpd_create(port, 1);
  return_value_if_fail(httpd != NULL, RET_BAD_PARAMS);

  httpd_set_routes(httpd, s_ui_preview_routes, ARRAY_SIZE(s_ui_preview_routes));
  httpd->user_data = app_info_create();

  s_httpd = httpd;
  return httpd_start(httpd);
}

详细实现请参考 awtk-previewer

5. 其它问题

对于实时预览,比较麻烦的问题是:当前的 XML 可能是无效的,无效体现在两个方面:

  • XML 本身不是 Well Formed 的。这个我们可以通过 DOMParser 进行解析,如果失败,并不更新 XML。
    const parser = new DOMParser();
    const xmlDoc = parser.parseFromString(xml, "text/xml");
    reqJson.xml = xmlDoc.documentElement.outerHTML;

    if (reqJson.xml.indexOf('parsererror') > 0) {
      console.log("invalid ui xml:", reqJson.xml);
      screenshot.width = width;
      screenshot.height = height;

      screenshot.src = "http://localhost:8000/screenshot?timestamp=" + Date.now();

      return;
    }
  • XML 是 Well Formed 的,但是某些值是无效的。比如:

比如输入 label 时,刚输人 l,这并不是一个有效的控件名称,它会触发 AWTK 的 assert,让 preview 服务奔溃。

<l />

为此需要修改 AWTK,去掉 assert,打印一个警告就好了,同时创建一个 view 控件,临时代替未知控件。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335