AWTK 實(shí)時(shí)預(yù)覽插件 (vscode) 工作原理
1. 背景
很早就計(jì)劃寫(xiě)一個(gè) vscode 插件來(lái)預(yù)覽 AWTK 的 UI XML 文件赢笨。遲遲沒(méi)有動(dòng)手茧妒,主要是既不太熟悉 vscode 插件的開(kāi)發(fā)左冬,也沒(méi)有想清楚這個(gè)插件的架構(gòu)。如何達(dá)到期望的目標(biāo)梅忌,同時(shí)保證工作量可以接受呢除破?
直到完成了 AWTK 自動(dòng)測(cè)試框架,發(fā)現(xiàn)以服務(wù)的方式來(lái)實(shí)現(xiàn)界面預(yù)覽是最好的選擇:不但預(yù)覽服務(wù)的工作可以重用現(xiàn)有的代碼瑰枫,而且可以保證插件本身的代碼最小光坝,以后為其它 IDE 增加預(yù)覽的功能也相當(dāng)簡(jiǎn)單。
在開(kāi)發(fā) AWTK 自動(dòng)測(cè)試框架 時(shí)逊谋,我們順帶實(shí)現(xiàn)了 AWTK RESTful HTTP 服務(wù)框架 土铺,這使得現(xiàn)實(shí)一個(gè) Restful HTTP 服務(wù)器超級(jí)簡(jiǎn)單。
效果大概如下圖:
2. 工作原理
工作原理是這樣的:在 vscode 中究恤,通過(guò)命令激活 預(yù)覽 后后德,如果當(dāng)前文檔是 AWTK XML UI 文件,就在側(cè)邊打開(kāi)一個(gè) webview理张,webview 讀取文檔的內(nèi)容,并將文檔內(nèi)容通過(guò) HTTP 請(qǐng)求發(fā)往 Preview 服務(wù)悟耘,Preview 服務(wù)在后臺(tái)渲染织狐。然后 webview 在指定的 URL 讀取截圖的 png 文件,并顯示到 webview 中旺嬉。
基本架構(gòu)圖:
3. 插件實(shí)現(xiàn)
3.1 注冊(cè)命令
在插件激活時(shí)厨埋,注冊(cè)一個(gè)命令,它負(fù)責(zé)打開(kāi)預(yù)覽的 webview雨效。
context.subscriptions.push(
vscode.commands.registerCommand('awtk.preview', () => {
if (isAwtkUiFile(vscode.window.activeTextEditor)) {
UIPreviewPanel.createOrShow(context.extensionUri);
}
})
);
如果預(yù)覽的 webview 已經(jīng)存在則更新它亲善,否則創(chuàng)建一個(gè)新的 webview蛹头。始終在側(cè)邊創(chuàng)建戏溺,需要使用參數(shù) vscode.ViewColumn.Two。
3.2 創(chuàng)建 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 文件
這個(gè)很簡(jiǎn)單:一個(gè) image 對(duì)象來(lái)顯示預(yù)覽的效果耕拷,還有一些設(shè)置和提交設(shè)置的按鈕托享。值得一提的是用了一個(gè)隱藏的 input 來(lái)保存 XML 內(nèi)容。
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 文件
它的主要職責(zé)是在 UI 文件內(nèi)容變化時(shí),向 preview 服務(wù)發(fā)送請(qǐng)求碧查。為了保存發(fā)送不會(huì)過(guò)于頻繁,在上一個(gè)請(qǐng)求完成后才發(fā)起新的請(qǐng)求忠售。
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 文件更新事件處理
注冊(cè)文檔變化事件卦方,如果是當(dāng)前預(yù)覽的文檔腐螟,向 webview 發(fā)送 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 里設(shè)置變化的標(biāo)識(shí),在下一次定時(shí)任務(wù)中發(fā)送更新請(qǐng)求衬廷。
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. 服務(wù)實(shí)現(xiàn)
在 preview 服務(wù)中汽绢,實(shí)現(xiàn)了兩個(gè)請(qǐng)求:
- 用于更新設(shè)置和 XML。
- 用于獲取預(yù)覽的效果圖跌宛。
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);
}
詳細(xì)實(shí)現(xiàn)請(qǐng)參考 awtk-previewer
5. 其它問(wèn)題
對(duì)于實(shí)時(shí)預(yù)覽疆拘,比較麻煩的問(wèn)題是:當(dāng)前的 XML 可能是無(wú)效的寂曹,無(wú)效體現(xiàn)在兩個(gè)方面:
- XML 本身不是 Well Formed 的。這個(gè)我們可以通過(guò) DOMParser 進(jìn)行解析漱挚,如果失敗,并不更新 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 的,但是某些值是無(wú)效的白华。比如:
比如輸入 label 時(shí)贩耐,剛輸人 l,這并不是一個(gè)有效的控件名稱鸟赫,它會(huì)觸發(fā) AWTK 的 assert,讓 preview 服務(wù)奔潰台谢。
<l />
為此需要修改 AWTK岁经,去掉 assert,打印一個(gè)警告就好了缀壤,同時(shí)創(chuàng)建一個(gè) view 控件,臨時(shí)代替未知控件筋夏。