使用WebGL去實現一個拖拽式UI代碼生成App

前言

UI(User Interface),即用戶界面,是軟件和用戶之間進行交互和信息交換的媒介,實現信息的內部形式與人類可接受形式間的轉換。UI開發通常須要通過UI設計、UI實現兩個過程。UI設計是對軟件的交互、操做邏輯、界面的設計,一般由UI設計師和交互設計師按照用戶對軟件的需求完成一套UI界面的設計,並最終以UI設計稿的形式呈現(psd、png、jpeg文件等)。UI實現是對UI設計階段產生的UI設計稿進行編碼實現,這部分是前端工程師的任務。css

​ 隨着互聯網的快速發展,從最先只有簡單的超文本文檔內容,逐漸發展成豐富多彩的靈活動態體驗平臺,各類手機App,PC端應用和網站更是多得迎接不暇。用戶從最先只注重軟件功能的實現,到現在不只須要軟件功能實現,還對軟件總體UI界面很是挑剔。目前軟件爲知足用戶的審美,軟件UI被設計的愈來愈複雜,不管是佈局仍是元素樣式,前端開發起來愈來愈費勁,開發成本愈來愈高,而且對於大量須要快速上線的頁面,沒有足夠的人力物力去開發。html

​ 在字節跳動直播活動中臺-前端的業務中,常常須要開發多個平臺的活動頁面。而活動頁面一般佈局、邏輯類似、需求頻率高且須要快速迭代。若是使用常規的開發方式去開發一個活動頁面,須要產品、前端、服務端、測試等多方參與,而且每個活動頁面上線週期長,沒法快速響應產品的需求。對於活動頁面開發, 較優的流程是使用頁面可視化搭建平臺來實現,即直播活動中臺的魔方平臺。平臺基於DOM實現了一個組件化的UI編輯器,而且提供封裝良好的UI組件供運營同窗使用,以此完成一個活動頁面。從之前須要4人天完成活動頁面的開發,到2小時就能拖拽出一個活動頁面而且上線,極大的提升了頁面開發效率。前端

​ 但魔方平臺也有必定的侷限性,因爲只須要針對活動相關業務,所以平臺只能適用於活動頁面的生成。經過拓展JSON來定義schema的形式描述一個編輯的UI頁面,而基於JSON的schema描述能力有限,只能經過對應的client端去解析schema來還原UI頁面,而且不能適用到其餘平臺。node

​ 所以基於魔方平臺提出了更通用的UI編輯App,將拖拽出來的頁面使用更加通用的DSL來描述,並能將DSL代碼編譯到各平臺代碼。相似於阿里Imgcook,基於WebGL實現UI編輯器,基於DSL編譯到多端代碼,提高UI開發效率。react

運行效果展現&所用技術

運行效果展現

主頁面:左側提供基礎組件,中間則是使用WebGL實現的UI編輯器,右側實現對選中的UI組件的屬性修改 git

代碼編譯:將當前UI頁面生成到目標代碼,並導出相應的代碼文件
DSL編輯頁面:提供DSL代碼的編輯,並生成到UI頁面

所用技術

通常的拖拽式UI生成平臺會作成一個網站,本文則是嘗試將其實現爲一個Electron App。github

  • Electron: Electron是使用Web前端技術(HTML/CSS/JavaScript/React等)來建立原生跨平臺桌面應用程序的框架。可使用electron-react-boilerplate模版快速使用React去開發,但本文則是使用手動搭建React環境,使用Webpack、Electron-builder完成資源打包和App構建,參考文章:使用Webpack/React去打包構建Electron應用web

  • Node.js:Node.js是一個開源、跨平臺、基於Chrome V8引擎的JavaScript運行時,可讓JavaScript運行在服務端環境下。Node.js採用單線程、異步非阻塞IO、事件驅動架構,使得Node.js在處理IO密集型任務時效率極高。正則表達式

  • React:React是一個用於構建Web UI的JavaScript庫,容許開發者以數據驅動、組件化、聲明式的方式編寫UI。算法

  • WebGL:是一種在Web端運行的3D繪圖協議,這種繪圖協議把JavaScript和OpenGL ES2.0結合起來,提供硬件加速3D渲染並藉助顯卡來在瀏覽器裏渲染3D場景和模型。WebGL技術的誕生解決了現有的Web 3D渲染的兩個關鍵問題:1.跨平臺,使用原生的canvas標籤便可實現3D渲染。2.渲染效率高,圖形的渲染基於底層的硬件加速實現。

  • Konva:一個基於Canvas開發的2D JavaScript庫,能夠輕鬆的用於實現桌面應用和移動應用的圖形交互效果,能夠高效實現動畫、變換、節點嵌套、局部操做、濾鏡、緩存、事件等功能。Konva最大的特色是圖形可交互,Konva的全部的圖形均可以監聽事件,實現相似於原生DOM的交互方式。事件監聽是在層(Konva.Layer)的基礎上實現的,每個層有一個用於顯示圖形的前臺渲染器和用於監聽事件的後臺渲染器,經過在後臺渲染器中註冊全局事件來判斷當前觸發事件的圖形,並調用處理事件的回調。Konva很大程度上借鑑了瀏覽器的DOM,好比Konva經過定義舞臺(Konva.Stage)來存儲全部圖形,相似於html標籤,定義層來顯示圖形,相似於body標籤。其中的節點嵌套、事件監聽、節點查找等等也借鑑了DOM操做,這使得前端開發者能夠很快速的上手Konva框架。

應用設計

需求分析

App核心功能包括WebGL UI編輯器和DSL代碼編輯器以及DSL代碼編譯器,系統功能需求以下圖。

  • 基礎功能:系統須要實現基礎的登陸註冊功能、登出功能、全局快捷鍵綁定等功能。

  • UI編輯器:可視化WebGL UI編輯器,提供基礎的通用UI組件庫,容許用戶經過拖拽基礎的通用UI組件庫的組件來繪製一個UI頁面;提供組件工具欄,容許用戶對畫布上的組件進行復制、刪除、粘貼、重作等操做。提供組件的屬性面板,容許用戶對組件的背景、邊框、位置、大小等屬性進行修改;提供DSL代碼構建工具欄,容許用戶將畫布上的UI頁面生成到DSL代碼,進而編譯DSL代碼到目標平臺代碼。

  • DSL代碼編輯器:提供一個編寫DSL代碼的編輯器,支持代碼高亮、複製、粘貼、保存等功能。提供文件系統,容許用戶新建、刪除一個DSL代碼文件;提供代碼運行工具,將DSL代碼生成到UI頁面或者生成到目標代碼。

  • 幫助中心:DSL代碼語法幫助、UI編輯器使用幫助。

總體架構設計

系統採用Client/Server模式進行架構,先後端分離方式開發,Client端爲Electron App,服務端則使用Express實現。 image-20200706093153577

  • Client端,採用Electron、React、Node.js來實現一個跨平臺的PC端App。

  • Server端,基於Node.js Express編寫的服務端,並暴露出相應的API供Client端調用。集成WebSocket服務,獨立運行在Node.js側,共享相應的數據庫鏈接等公共類和函數,提供Socket支持。並基於Niginx搭建一個靜態資源服務器,提供圖片等文件的存儲服務。

  • 數據庫使用MySQL/MongoDB數據庫,MongoDB存儲UI頁面信息,好比UI元素位置、大小、樣式等信息,以及其餘類JSON形式的信息。MySQL存儲用戶信息、組件信息等一些基礎信息。

Client端架構設計

Client端是一個PC端應用,採用Electron技術進行開發。Electron雖然是使用前端技術來建立跨平臺應用的框架,但又與傳統的網站開發方式不同。Electron基於主從進程模型,即由一個主進程和多個渲染進程組成,進程之間使用IPC進行通訊。基於這種進程模型,對系統進程進行功能劃分:

  • 主進程負責進程間通訊、窗口管理、服務端請求和native C++插件加載
  • 渲染進程只負責Web頁面的渲染和具體的業務邏輯

渲染進程使用Typescript/React/Redux開發,藉助React Hooks能夠更好的將通用UI邏輯抽離,提升代碼複用率。主進程使用Typescript/C++開發,其中C++開發Node.js插件並打包成.node文件,主進程加載.node文件從而調用到C++代碼。藉助Webpack編譯工具,將渲染進程全部代碼編譯爲index.htmlrenderer.jsstyle.css並進行代碼壓縮和代碼分割優化,提升代碼運行效率。主進程全部代碼編譯只編譯爲一個main.js,並在main.js中加載渲染進程的index.html完成整個系統的運行。最後再利用electron-builder將編譯後的主進程代碼和渲染進程代碼以及其餘資源文件打包成一個.dmg應用文件,完成整個系統的構建。 image-20200706093244240

主進程設計

Client端主進程可分爲三部分模塊:widget模塊、services模塊、compile模塊。 image-20200706094043998

  • Widget模塊負責窗口建立和管理,好比建立login窗口,實現最小化、關閉login窗口等IPC調用。

  • Services模塊負責提供系統基礎服務,包括IPC調用服務,用於渲染進程與主進程之間的通訊;fetch服務,提供後端接口調用能力;session服務,存儲用戶session,記錄登陸等信息;socket服務,提供後端socket鏈接;fileSave服務,提供文件保存功能。

  • Compile模塊負責執行DSL代碼編譯,經過實現多種編譯器來實現多平臺代碼構建。

渲染進程設計

在渲染進程打包過程當中,採用多頁面打包設計,將部分UI頁面從一個渲染進程中分離,設計成多個獨立的新窗口(渲染進程),開發時在每一個渲染進程中都注入模塊熱更新代碼實現開發環境頁面熱更新。在Webpack的entry字段中添加多個頁面入口實現獨立打包,而且每一個打包頁面使用HtmlWebpackPlugin插件生成對應的HTML文件。主進程實例化一個獨立窗口加載對應頁面打包後的index.html完成一個新窗口的建立。 image-20200706094240656

在多個窗口中,主窗口是系統最核心的窗口,實現的模塊和功能相對複雜,使用React Hooks開發的組件避免不了相互通訊,故使用採用Redux進行全局狀態管理,優化組件間的通訊流程。 image-20200706094512545

在Redux的工做流中,將state提取到Redux狀態樹store中存儲,經過dispatch action進入reducer去更新state,更新完state後觸發一次React render去更新視圖。設計Redux狀態樹的關鍵點在於抽離組件狀態,將多個組件依賴的狀態抽離到Redux狀態樹中,並在組件使用useSelector Hooks訂閱狀態樹中的某個狀態,使用useDispatch獲取dispatch去更新Redux狀態樹中的某個狀態。 image-20200706094417266

在主窗口渲染進程中,包括Redux模塊、Page模塊、Components模塊、WebGL模塊。

image-20200706094610522
  • Page模塊,主窗口頁面相似於單頁應用,每個子頁面就在Page下實現,包括UI編輯器子頁面、DSL代碼編輯器子頁面等等。

  • Redux模塊,實現Redux基本事件流store、action、reducer,用於組件間通訊。

  • Components模塊,通用UI組件實現,好比toast、modal等通用組件。

  • WebGL模塊,基於WebGL原生JavaScript實現UI畫布和UI組件以及一些相關工具函數。

Sever端架構設計

Server端使用Node.js Express框架搭建,在Express的基礎上進行封裝、擴展。 image-20200706094929896

  • Core層是對Express的封裝以及擴展,包括實現App類、Middleware抽象類、Controller抽象類,以及defineRouter路由裝飾器等。

  • Services是對基礎服務的封裝以及第三方服務的調用,如文件上傳、文件下載等。

  • Socket是對Socket服務的抽象,提供Socket類來支持服務端Socket功能,底層基於SocketIO開發。

  • Controller是具體業務邏輯控制器的實現,利用類來抽象一個業務,利用路由裝飾器對類中方法進行裝飾來表達一個業務邏輯。

  • Database提供對MySQL、MongoDB的鏈接和操做的抽象。

  • Model提供數據庫表的基本模型,包括User表、WebGLPage表等。

服務端使用Typescript編程語言實現,在運行時根據tsconfig.json來運行tsc命令來將全部Typescript文件編譯成JavaScript並在Node.js環境下運行。

數據庫設計

MongoDB是鍵值數據庫,存儲結構相似於JSON,具備必定的層級結構,可以很好的表示一個正在編輯中的UI頁面狀態。因此係統利用MongoDB的這種特性來存儲每個正在編輯的UI頁面信息,存儲結構以下。 image-20200706095702748

DSL語法設計

DSL(Domain Specific Language),即特定領域語言,是一種爲特定領域而設計,表達性受限的編程語言,包含內部DSL和外部DSL兩種:

1.外部DSL 與傳統編程語言不通,外部DSL一般採用自定義語法,並利用相應的編程語言去解析DSL代碼。好比正則表達式、SQL和一些配置文件等。

2.內部DSL 是編程語言的一個特定語法表現,用內部DSL寫成的代碼是一段合法的程序,只不過具備特定的風格,並且用到了編程語言的一部分特性,僅用於處理系統的某些特定問題。

系統使用外部DSL定義,用於描述一個UI頁面,並對DSL進行解析生成目標代碼。DSL語法設計參考了SCSS語法,採用一個嵌套結構來表達UI頁面嵌套關係。對UI頁面中的組件進行屬性抽象,獲得瞭如下DSL語法的定義:

1.以Type.name形式表達一個組件的類型和名稱,以「{」開頭,以「}」結尾,將組件的屬性和相關信息進行包裹。

2.組件屬性定義爲兩類,基礎屬性和樣式屬性,基礎屬性關鍵字包括position、size、text、image,樣式屬性以style關鍵字定義,用大括號進行包裹,內層屬性包括background、border、shadow。屬性與屬性之間使用「;」分開。

3.一個屬性的參數使用空格進行分隔,末尾使用「;」號結束一個屬性的定義。

4.使用children關鍵字表達一個組件的全部子組件,使用「[」和「]」對全部的子組件進行包裹,子組件DSL代碼以「,」分開。

一個簡單的DSL組件定義以下。 image-20200706095817348

功能實現

主進程相關服務實現

Client端採用主進程與渲染進程分離模式開發,主進程實現Session管理,Socket鏈接,服務端接口調用,頁面通訊等服務。

1.Session服務的實現 主進程對Session進行全局管理,存儲用戶的登陸信息。在Electron中可使用session API來獲取當前session

export const Session = {
  setCookies(name: string, value: string) {
    const Session = session.defaultSession; // 主進程中獲取默認session
    const Cookies = Session.cookies; // 獲取cookies
    return Cookies.set({
      url: domain,
      name,
      value,
    });
  },
  getCookies(name: string | null = null) {
    const Session = session.defaultSession;
    const Cookies = Session.cookies;
    if (!name) return Cookies.get({ url: domain });
    return Cookies.get({ url: domain, name });
  },
  clearCookies(name: string) {
    const Session = session.defaultSession;
    return Session.cookies.remove(domain, name);
  }
};

複製代碼

2.Socket鏈接實現與封裝 服務端使用SocketIO庫實現一個Socket服務,一樣在主進程使用SocketIO庫來創建一個Socket鏈接

class SocketService {
  static instance: SocketService | null = null;
  static getInstance() {
    return !SocketService.instance ? (SocketService.instance = new SocketService()) : SocketService.instance;
  }
  private socket: SocketIOClient.Socket;
  constructor() {
    this.socket = SocketIO(url);
    this.socket.on('connect', () => { // 鏈接
      console.log('connect !');
    })
  }
  emit(event: string, data: any) {
    if (!this.socket.connected) this.socket.connect();
    this.socket.emit(event, data);
  }
  on(event: string, callback: Function) {
    this.socket.on(event, callback)
  }
}
複製代碼

3.fetch服務端調用實現與封裝 主進程中使用Node.js request模塊來實現服務端接口請求,渲染進程則經過IPC調用來間接使用request模塊,進而實現服務端接口的請求

export const fetch = {
  get(url: string, data: any) {
    return fetch.handle('GET', url, data);
  },
  post(url: string, data: any) {
    return fetch.handle('POST', url, data);
  },
  handle(method: 'GET' | 'POST', url: string, data: any) { // 封裝request模塊
    return new Promise((resolve, reject) => {
      const params = {
        method,
        baseUrl,
        url,
        ...(method === 'GET' ? { qs: data } : { form: data })
      };
      request(params, (err, res, body) => {
        try {
          if (err) {
            reject(err);
            return;
          }
          resolve(JSON.parse(res.body));
        } catch (e) {
          reject(e);
        }
      });
    });
  }
};
複製代碼

4.IPC進程間通訊實現與封裝 渲染進程與主進程的通訊是整個系統的核心,合理的定義通訊接口能提升系統運行效率。在主進程中,Electron提供ipcMain對象來處理渲染進程的消息;在渲染進程中,使用ipcRenderer處理主進程的消息。例如服務端請求邏輯的IPC調用,主進程使用ipcMain.handle註冊IPC調用

export const handleFetch = () => {
  ipcMain.handle(IpcEvent.FETCH, async (event, args: { method: 'GET' | 'POST', url: string, data: any }) => {
    return await fetch.handle(args.method, args.url, args.data); // fetch
  });
};
複製代碼

渲染進程調用

function fetch(method: 'GET' | 'POST', url: string, data: any = null) {
  return ipcRenderer.invoke(IpcEvent.FETCH, {
    method,
    data,
    url
  }).catch(console.error);
}

// fetch('GET', '/user/login', { email, password });
複製代碼

5.編譯邏輯封裝 渲染進程經過IPC調用將DSL代碼發送到主進程,主進程調用編譯服務完成代碼編譯並把結果返回到渲染進。通常DSL代碼的解析都是生成到抽象語法樹,再對抽象語法樹進行節點的修改最後生成到目標代碼。可是考慮到設計的DSL較爲簡單,只須要利用正則表達式解析相應的屬性並拼接到JSON便可

parser.id_index = 0;
export function parser(str: string): any {
  let childrenMatch = str.match(/children\s*:\s*\[(.+)/);
  const childrenToken = childrenMatch ? childrenMatch[1].trim().replace(/\]\s*\}$/, '').trim() : '';
  if (childrenMatch) {
    str = str.substring(0, childrenMatch.index);
  }

  const children = getChildrenToken(childrenToken); // 子組件token

  let nameMatch = str.match(/^[\w\d\.\s]+\s*{/); // 解析組件type, name
  const [type, name] = nameMatch ? nameMatch[0].replace('{', '').trim().split('.') : ['', ''];
  let positionMatch = str.match(/position\s*:([^;]+);/); // 組件position屬性
  const [x = 0, y = 0] = positionMatch ? positionMatch[1].trim().split(' ').map(v => Number.parseInt(v)) : [0, 0];

  let sizeMatch = str.match(/size\s*:([^;]+);/); // 組件size 屬性
  const [width = 0, height = 0] = sizeMatch ? sizeMatch[1].trim().split(' ').map(v => Number.parseInt(v)) : [0, 0];

  let backgroundMatch = str.match(/background\s*:([^;]+);/); // 組件background屬性
  const [fill = 'white', opacity = 0] = backgroundMatch ? backgroundMatch[1].trim().split(' ') : ['', ''];

  let shadowMatch = str.match(/shadow\s*:([^;]+);/); // 組件shadow屬性
  let [offsetX = 0, offsetY = 0, blur = 0, shadowFill = 'white'] = shadowMatch ? shadowMatch[1].trim().split(' ').map((v, i) => {
    if (i === 3) return v;
    return Number.parseInt(v);
  }) : [0, 0, 0, ''];

  let borderMatch = str.match(/border\s*:([^;]+);/); // 組件border屬性
  const [borderWidth = 0, radius = 0, borderFill = 'white'] = borderMatch ? borderMatch[1].trim().split(' ').map((v, i) => {
    if (i === 2) return v;
    return Number.parseInt(v);
  }) : [0, 0, ''];


  let textMatch = str.match(/text\s*:([^;]+);/); // 組件text屬性
  const textMatchRes = textMatch ? textMatch[1].trim() : '';
  let text = textMatchRes.match(/'(.+)'/);
  if (text) {
    text = (text[0] as any).replace(/^'/, '').replace(/'$/, '');
  }
  let textFill = textMatchRes.split(' ');
  textFill = (textFill[textFill.length - 1] as any).trim();

  let imageMatch = str.match(/image\s*:([^;]+);/); // 組件image屬性
  const src = imageMatch ? imageMatch[1].trim().replace(/^'/, '').replace(/'$/, '') : '';
  return { // 拼接JSON
    name,
    type: type.toLocaleUpperCase(),
    id: `${type.toLocaleUpperCase()}-${name}-${parser.id_index++}`,
    props: {
      position: { x , y },
      size: { width, height },
      ...(backgroundMatch ? { background: { fill, opacity: +opacity } } : {}),
      ...(shadowMatch ? {
        shadow: {
          offsetY,
          offsetX,
          blur,
          fill: shadowFill
        }
      } : {}),
      ...(borderMatch ? {
        border: {
          width: borderWidth,
          radius: radius,
          fill: borderFill
        }
      } : {}),
      ...(textMatch ? { text: { text, fill: textFill } } : {}),
      ...(imageMatch ? { image: { src } } : {})
    },
    children: children.map(str => parser(str)) // 遞歸解析子組件token
  };
}
// 計算子組件token
function getChildrenToken(childrenToken: string) {
  let count = 0;
  let child = '';
  const result = [];
  for (let i = 0; i < childrenToken.length; i++) {
    child += childrenToken[i];
    if (childrenToken[i] === '{') {
      count++;
    }
    if (childrenToken[i] === '}') {
      count--;
    }
    if ((childrenToken[i] === ',' && count === 0) || (count === 0 && i === childrenToken.length - 1)) {
      result.push(child.replace(/,$/, '').trim());
      child = '';
    }
  }
  return result;
}
複製代碼

而生成目標代碼的過程則是根據JSON對象的組件類型進行條件判斷

function compileToElementToken(obj: any): any {
  switch (obj.type) {
    case TYPES.WIDGET: { // 
      return (`<div id="${obj.id}">${obj.children.map((v: any) => compileToElementToken(v)).join('\n')}</div>`); } case TYPES.BUTTON: { return (`<button id="${obj.id}">${obj.props.text ? obj.props.text.text : ''}</button>`); } case TYPES.SHAPE: { return (`<div id="${obj.id}">${obj.children.map((v: any) => compileToElementToken(v)).join('\n')}</div>`); } case TYPES.TEXT: { return (`<div id="${obj.id}">${obj.props.text ? obj.props.text.text : ''}</div>`); } case TYPES.INPUT: { return (`<input id="${obj.id}" placeholder="some text"/>`); } case TYPES.IMAGE: { return (`<img id="${obj.id}" src="${obj.props.image ? obj.props.image.src : ''}" alt="none"/>`); } } } 複製代碼

最後拼接成目標代碼

const jsonObject = compileToJson(code);
let style = (` * { box-sizing: border-box; margin: 0; padding: 0 } html, body { height: 100%; width: 100% } ${compileToStyleToken(jsonObject)}`).replace(/\n(\n)*(\s)*(\n)*\n/g, '\n');
let div = compileToElementToken(jsonObject).replace(/\n(\n)*(\s)*(\n)*\n/g, '\n');
const html = (`<!DOCTYPE> <html lang="zh"> <head><title>auto ui</title></head> <style>${style}</style> <body>${div}</body> </html>`);
複製代碼

主進程多窗口管理

Client端App由用戶信息窗口、主窗口、登陸窗口、頭像選擇窗口等若干窗口組成,每個窗口都是一個獨立的渲染進程,主進程負責管理全部的窗口。Electron自己並無提供多窗口的管理,所以須要手動去管理每個窗口的狀態、窗口間的交互邏輯等。

App中將每個窗口抽象成一個Widget類,因爲窗口的特殊性,每個Widget類都基於單例模式去設計。 image-20200706100823770

父類Widget實現IWidget接口,實現一個窗口基本的功能,好比create()建立窗口,close()關閉窗口等。其子類是一個單例類,使用靜態方法getInstance()去獲取。每個窗口都是一個frame窗口,即去除了操做系統的狀態欄裝飾,所以須要手動實現關閉、最小化、最大化窗口以及窗口的拖拽的功能。對於窗口拖拽,在Electron中可使用-webkit-app-region: drag一行CSS屬性去實現。對於關閉、最小化、最大化窗口則是經過在渲染進程中調用註冊的關閉、最小化、最大化窗口的IPC調用實現。

Widget類的create()方法是建立窗口的關鍵方法,使用Electron.BrowserWindow去實例化一個窗口,並用實例對象的loadURL()loadFile()去加載.html文件渲染出頁面,並註冊相應的事件

// DSL代碼預覽窗口
export default class CodeWidget extends Widget {
  static instance: CodeWidget | null = null;
  static getInstance() {
    return CodeWidget.instance ? CodeWidget.instance : (CodeWidget.instance = new CodeWidget());
  }

  constructor() {
    super();
    // 窗口關閉事件
    onCloseWidget((event, args: { name: string }) => {
      if (args.name === WidgetType.CODE) {
        if (this._widget) {
          this._widget.close();
        }
      }
    });
  }

  create(parent?: Electron.BrowserWindow, data?: any): void {
    if (this._widget) return;
    // 實例化窗口
    this._widget = new Electron.BrowserWindow({
      ...CustomWindowConfig,
      parent,
      width: 550,
      height: 600,
      resizable: false,
      minimizable: false,
      maximizable: false
    });
    //加載.html文件
    loadHtmlByName(this._widget, WidgetType.CODE);
    // 初始數據
    if (data) {
      this._widget.webContents.on('did-finish-load', () => {
        this._widget?.webContents.send('code', data);
      });
    }
    parent?.on('close', () => this.reset());
    this._widget.on('close', () => this.reset());
  }
}
複製代碼

多個窗口之間避免不了相互間的通訊,好比頭像選擇窗口和用戶信息窗口的通訊。用戶信息窗口點擊修改頭像打開頭像選擇窗口,頭像選擇窗口選擇完頭像後須要將選擇結果發送到用戶信息窗口。 image-20200706102141623

窗口間的通訊最簡單的方式是使用ipcMain對象和ipcRenderer對象去實現,即在一個窗口的渲染進程中向主進程中發送消息,主進程再向另外一個窗口的渲染進程中發送消息,實現兩個窗口的通訊。 image-20200706102321896

但在這種實現模式下,須要額外定義事件名,並須要利用主進程去實現兩個窗口的通訊。所以Electron提供了更方便的remote模塊,能夠在不發送進程間消息的方式實現通訊。Electron的remote模塊相似於Java的RMI(Remote Method Invoke,遠程方法調用),一種利用遠程對象互相調用來實現雙方通訊的一種通訊機制。對應有父子結構的窗口,通訊時只須要在子窗口中使用remote方法向父窗口中的渲染進程發送消息便可

remote.getCurrentWindow().getParentWindow().webContents.send('avatar-data', { ...avatar });
複製代碼

其中remote的通訊機制大體原理以下圖。

image-20200706102513874

Client端UI畫布實現

UI畫布是系統的核心之一,基於WebGL Konva框架實現。

1.UI畫布的實現 在使用Konva實現畫布時,只須要使用Konva.Stage定義舞臺以及使用Konva.Layer定義繪製層

this.renderer = new Konva.Stage({
  container: container.id,
  width: CANVAS_WIDTH,
  height: CANVAS_HEIGHT
});
// 管理畫布中的全部組件
this.componentsManager = new ComponentManager();
this.layer = new Konva.Layer();
// Redux dispatch,webgl與react通訊的核心
this.dispatch = dispatch;
// 像畫布中添加輔助線
WebGLEditorUtils.addGuidesLineForLayer(this.layer, this.renderer);
this.renderer.add(this.layer);
複製代碼

2.向UI畫布中添加一個組件 向UI畫布中添加UI組件時,首先要爲組件綁定Konva內的事件,包括選中、拖拽、修改大小等事件;而後將組件繪製到Layer層;而後隱藏上一個組件錨點,顯示拖拽過來的組件的錨點;檢測拖拽過來的組件是否位於某個組件內,若是位於某個組件內,則將拖拽的組件添加到該組件內部,造成嵌套結構;通知調用dispatch,通知React側,保存當前組件的狀態;最後重繪畫布。

addComponent(webGLComponent: WebGLComponent) {
  // 爲組件添加事件
  this.addSomeEventForComponent(webGLComponent);
  // 將組件添加到繪製層
  webGLComponent.appendToLayer(this.layer);
  this.componentsManager.pushComponent(webGLComponent);
  // 檢測拖入的組件是否位於某個組件內部
  const id = WebGLEditorUtils.checkInSomeGroup(
    this.layer,
    this.renderer,
    webGLComponent.getGroup()
  );

  if (id) {
    // 若是在則添加到對應的組件內部
    this.componentsManager.appendComponentById(id, webGLComponent);
  }
  // 通知react側
  this.dispatch(selectComponent(
    webGLComponent.getId(),
    webGLComponent.getType(),
    webGLComponent.getName(),
    this.componentsManager.getPathOfComponent(webGLComponent).join('>'),
    getComponentProps(webGLComponent)
  ));
  // 重繪畫布
  this.render();
}
複製代碼

對應的addSomeEventForComponent()函數實現以下,主要添加選中事件、拖拽事件、修改事件

addSomeEventForComponent(component: WebGLComponent) {
  component.onSelected(e => { // 組件選中事件
    this.componentsManager.showCurrentComponentTransformer(
      component.getId()
    );
    component.moveToTop();
    this.dispatch(selectComponent(
      component.getId(),
      component.getType(),
      component.getName(),
      this.componentsManager.getPathOfComponent(component).join('>'),
      getComponentProps(component)
    ));
    this.render();
  });

  component.onDragEnd(e => { // 組件拖拽結束事件
    this.dispatch(dragComponent(e.target.position()));
  });

  component.onTransformEnd(e => { // 組件transform結束事件
    this.dispatch(transformComponent(component.getSize()));
  })

  component.onDragEnd(e => { // 組件拖拽結束事件
    const id = WebGLEditorUtils.checkInSomeGroup(
      this.layer,
      this.renderer,
      component.getGroup()
    );


    if (id) {
      this.componentsManager.appendComponentById(id, component);
    }
    this.render();
  });
}
複製代碼

3.檢測一個組件是否位於畫布中某個組件內部 在拖動組件事件結束時,須要檢測拖動後的組件是否位於某個組件內部,並移動到對應的目標組件中,造成嵌套結構。首先獲取畫布中除拖動組件的全部組件的座標和大小信息,並以{id, w, h, x, y}格式存儲到數組points中;而後獲取拖動組件的座標和大小信息,記爲groupPoint,格式爲{id, w, h, x, y};遍歷points數組,判斷能能包含拖拽組件的項,並添加到includePoints數組中,代碼以下:

const points = getAllGroupPoints();
const groupPoint = getGroupPoint(group);
const includePoints: PointType[] = [];
points.forEach(point => {
  if (
    groupPoint.x >= point.x &&
    groupPoint.y >= point.y &&
    groupPoint.x + groupPoint.w <= point.x + point.w &&
    groupPoint.y + groupPoint.h <= point.y + point.h
  ) {
    includePoints.push(point);
  }
});
複製代碼

遍歷includePoints數組中全部項,按歐式距離選擇出與拖拽組件距離最小的組件做爲父組件。 image-20200706103419916

檢測組件是否位於某個組件內部的算法流程以下

let minDistance = Number.MAX_SAFE_INTEGER;
let id = '';
const distance = (p0: { x: number, y: number }, p1: { x: number, y: number }) => {
  return Math.sqrt(Math.pow(p0.x - p1.x, 2) + Math.pow(p0.y - p1.y, 2));
};
includePoints.forEach(point => {
  const diff =
        distance(
          { x: groupPoint.x, y: groupPoint.y },
          { x: point.x, y: point.y }
        ) +
        distance(
          { x: groupPoint.x + groupPoint.w, y: groupPoint.y },
          { x: point.x + point.w, y: point.y }
        ) +
        distance(
          { x: groupPoint.x, y: groupPoint.y + groupPoint.h },
          { x: point.x, y: point.y + point.h }
        ) +
        distance(
          { x: groupPoint.x + groupPoint.w, y: groupPoint.y + groupPoint.h },
          { x: point.x + point.w, y: point.y + point.h }
        );
  if (diff < minDistance) {
    minDistance = diff;
    id = point.id;
  }
});
複製代碼

4.WebGL與React通訊 經過WebGL繪製的畫布已經脫離了瀏覽器的DOM,裏面的元素都是一條線一條線繪製而成,不一樣與DOM。WebGL與React的通訊,利用Redux提供的全局狀態樹實現。在構造WebGL畫布時傳入dispatch函數,用於觸發全局狀態樹的更改從而通知到React。 image-20200706103808959

5.HTML5拖拽API實現拖入組件到UI畫布 在HTML5中,拖拽被定義爲數據的移動,將一份數據移動到另外一個區域,所以藉助這個思路,能夠實現一個組件拖拽到UI畫布中的操做

// 拖動
export function drag(type: string, name: string, event: DragEvent<any>) {
  event.dataTransfer?.setData('component', JSON.stringify({type, name}));
}
// 放下
export function drop(callback: Function, event: DragEvent<any>) {
  event.preventDefault();
  const { type, name } = JSON.parse(event.dataTransfer?.getData('component'));
  callback({
    type,
    name,
    position: {
      clientX: event.clientX,
      clientY: event.clientY
    }
  });
}
複製代碼

解析出拖拽過來的組件類型和名稱,UI畫布根據類型和名稱實例化一個組件對象並添加到畫布中

export function dropComponentToWebGLEditor(type: string, name: string, position: { x: number, y: number }, editor: CanvasEditorRenderer) {
  const cpn = new (ComponentMap as any)[type][name](position); // 根據type和name實例化對應的組件
  editor.addComponent(cpn);
  return cpn;
}
複製代碼

Client端UI組件實現

UI組件依然使用WebGL Konva框架實現,並將其封裝爲一個Typescript類。 image-20200706122833576

IWebGLComponentProps接口抽象出一個組件的可用屬性以及獲取、設置屬性的方法,好比獲取、設置位置屬性,獲取、設置背景屬性等。IWebGLComponentEvents接口抽象出一個組件須要綁定的事件,好比拖拽事件、選中事件等。WebGLComponent類,對WebGL組件基本結構進行封裝,好比描述組件層級結構的childrenparent屬性,將組件添加到畫布中的appendToLayer()方法等,並實現IWebGLComponentProps()接口,定義一個WebGL組件的屬性,實現IWebGLComponentEvents接口,定義一個組件須要監聽的事件。每個組件都經過繼承WebGLComponent父類來實現,好比WebGLRect類、WebGLText類。

經過定義一個WebGLComponent父類來實現一個組件的通用邏輯,一個組件的基礎就是grouptransformer,分別是渲染到WebGL的畫布的形狀組和能夠自由變換的錨點。

1.繪製UI組件 一個UI組件由若干個Konva圖形組成,好比按鈕組件由矩形(Konva.Rect)和文本(Konva.Text)組成。經過向group中添加若干個形狀,繪製出一個組件。 image-20200706123205939

2.刪除組件 刪除組件是隻須要依次刪除三部分便可,即從父組件中移除當前組件,從畫布中移除組件的group,從畫布中移除組件的transformer

removeFromLayer() {  
  this.parent?.removeChild(this.getId());
  this.getGroup().remove();
  this.getTransformer().remove();
}
複製代碼

3.父組件添加子組件 將一個組件添加到另外一個組件中只須要將該組件的grouptransformer移動到父組件中便可,而且在子組件中使用parent引用父組件,父組件中使用children存儲全部子組件的引用。 image-20200706123505999

所以在添加子組件時須要創建父子組件的層級關係

appendComponent(component: WebGLComponent) {
  if (!this.isRawComponent) {
    const group = component.getGroup();
    const transformer = component.getTransformer();
    group.moveTo(this.getGroup()); // 移動到父組件中
    transformer.moveTo(this.getGroup()); // 移動到父組件中

    if (component.parent) { // 移除子組件原來的父組件
      component.parent.removeChild(component.getId());
    }
    component.parent = this; // 從新指向父組件
    this.appendChild(component);
  }
}
複製代碼

Client端UI頁面與JSON的相互轉化

服務端使用MongoDB來存儲一個編輯的UI頁面,所以須要實現UI頁面到JSON的轉化,以及 JSON對象到UI頁面的轉化。

1.UI頁面與JSON對象的轉化 從根組件開始遍歷,提取出類型、名稱、子組件、樣式等屬性,再遞歸解析子組件

export function webGLComponentToJsonObject(component: WebGLComponent): TRawComponent {
  return {
    id: component.getId(),
    type: component.getType(),
    name: component.getName(),
    props: getComponentProps(component),
    children: component.getChildren().size ?
      [...component.getChildren().values()].map(value => {
        return webGLComponentToJsonObject(value);
      }) : []
  };
}
複製代碼

2.JSON轉化到UI頁面 利用廣度優先搜索,遍歷JSON對象,並依次實例化父組件和對應的子組件,設置組件屬性,並將子組件添加到父組件中,記錄根節點,區分是否以粘貼的形式生成,添加到畫布中

export function drawComponentFromJsonObject(jsonObject: TRawComponent, renderer: CanvasEditorRenderer, isPaste = false): WebGLComponent {
  let root: WebGLComponent | null = null; // 記錄根節點
  const queue = [jsonObject]; // 廣度優先搜索隊列
  const map = new Map<string, WebGLComponent>(); // 記錄當前組件是否實例化

  while (queue.length) { // 廣度優先搜索
    const front = queue.shift() as TRawComponent; // 記錄父節點
    let parent;
    if (map.has(front.id)) { // 若是父組件實例過,則直接拿到實例化的引用
      parent = map.get(front.id);
    } else { // 未實例化,則對組件進行實例化,並記錄到map中
      parent = new (ComponentMap as any)[front.type][front.name](
        front.props.position
      ) as WebGLComponent;
      setComponentProps(parent, front.props); // 設置屬性
      map.set(front.id, parent);
    }

    if (root === null) { // 獲取根節點
      root = parent as WebGLComponent;
      renderer.addRootComponent(root as WebGLComponent); // 將根節點繪製到UI畫布中
    }

    for (let v of front.children) { // 遍歷子組件
      queue.push(v);
      const child = new (ComponentMap as any)[v.type][v.name](v.props.position, v.props.size) as WebGLComponent;
      setComponentProps(child, v.props);
      renderer.addComponentForParent(parent as WebGLComponent, child); // 繪製到父組件中
      map.set(v.id, child);
    }
  }
  const component = root as WebGLComponent;
  // 是否以粘貼的形式
  isPaste && component.setPosition({
    x: component.getPosition().x + 10,
    y: component.getPosition().y + 10
  });
  renderer.getComponentManager().showCurrentComponentTransformer(
    root?.getId() as string
  );
  renderer.render(); // 從新渲染UI畫布
  return component;
}
複製代碼

Client端UI組件編輯功能實現

React與WebGL的通訊是基於Redux狀態樹實現,經過在WebGL側調用dispatch()來通知React渲染,在渲染React Editor組件時使用useEffect Hooks來實現通訊。

對於編輯功能的實現,須要在Redux狀態樹中記錄一個編輯狀態的state,格式爲{id, editType },其中id表示組件id,editType表示編輯類型。

點擊編輯操做時調用dispatch()函數發送編輯的組件id和編輯類型,React Editor組件使用useEffect Hooks接收變化並使用CanvasEditorRenderer類提供的編輯組件方法實現組件的編輯功能

const editToolsDeps = [editToolsState.id, editToolsState.editType];
useEffect(() => {
  if (editToolsState.id) {
    const renderer = (webglEditor.current as CanvasEditorRenderer);
    switch (editToolsState.editType) {
      case 'delete': { // 刪除組件
        // 移除畫布中對應Id的組件
        const rmCpn = removeComponentFromWebGLEditor(editToolsState.id, renderer);
        EventEmitter.emit('auto-save', webGLPageState.pageId); // 自動保存
        // 新增編輯歷史
        dispatch(addEditHistory(editToolsState.id, 'delete', {
          old: '',
          new: webGLComponentToJsonObject(rmCpn as WebGLComponent)
        }));
        return;
      }
      case 'paste': { // 粘貼組件
        const newCpn = pasteComponentToWebGLEditor(editToolsState.id, renderer);
        // 新增編輯歷史
        dispatch(addEditHistory(editToolsState.id, 'paste', { old: '', new: newCpn?.getId() }));
        EventEmitter.emit('auto-save', webGLPageState.pageId);
        return;
      }
      case 'save': { // 保存
        savePage(webGLPageState.pageId, renderer.toJsonObject() as object).then((v: any) => {
          if (!v.err) {
            toast('save!');
            dispatch(resetComponent());
          }
        });
        return;
      }
      case 'undo': { // 重作
        dispatch(removeEditHistory());
        dispatch(resetComponent());
        return;
      }
      default: {
        return;
      }
    }
  }
}, editToolsDeps);
複製代碼

1.從畫布中移除一個組件 當選中UI畫布中的組件時,Redux狀態樹中會存儲選中的組件id,經過組件id,調用CanvasEditorRenderer類移除對應id的方法,其內部實現以下

const cpn = this.componentsManager.getComponentById(id);
this.componentsManager.removeComponentById(id);
this.render();
this.dispatch(resetComponent());
return cpn;
複製代碼

2.複製粘貼一個組件 複製組件時將組件id記錄下來,在粘貼時,查找對應id的組件,將其轉化爲JSON對象,再由JSON對象從新構造出UI組件並添加到UI畫布中,實現粘貼邏輯

if (this.webGLComponentCollection.has(id)) {
  const cpn = this.webGLComponentCollection.get(id) as WebGLComponent;
  const json = webGLComponentToJsonObject(cpn); // 轉化到JSON對象
  return drawComponentFromJsonObject(json, renderer, true); // 再由JSON對象生成新的組件
}
return null;
複製代碼

3.重作組件 經過記錄一個編輯歷史,來實現重作組件邏輯。編輯歷史使用一個數組來存儲,當存在編輯操做時,將該操做存儲到數組中,存儲格式爲{id, operator, data}id表示組件id,operator表示操做名稱,data表示operator操做的逆操做所需的數據。執行重作命令時,取出數組最後一個項,並對該項對應的操做進行一個逆操做,達到重作的效果。

以粘貼組件操做爲例,粘貼一個組件,向數組中添加一個粘貼操做

dispatch(addEditHistory(editToolsState.id, 'paste', { old: '', new: newCpn?.getId() }));
複製代碼

而粘貼組件操做的逆操做就是刪除組件,所以拿到data中粘貼組件的id,並從UI編輯器中刪除,達到重作的效果。

const { id, data } = editHistory.current;
renderer.removeComponent(data.new);
複製代碼

Client端修改UI組件屬性功能實現

經過對WebGL組件樣式屬性進行一個抽象,抽象出background屬性、border屬性、shadow屬性、text屬性、image屬性這5類屬性。當修改一個組件的屬性時,先判斷修改的屬性類型,再對該類型的屬性在UI畫布中進行修改渲染。在修改屬性時,屬性面板Propspanel組件經過dispatch()修改Redux狀態樹的狀態,而後重繪UI。UIEditor組件經過useEffect反作用監聽狀態改變,並調用CanvasEditorRenderer類的modifyComponentProps方法實現組件屬性修改。 image-20200706125031944

總結

本項目是個人畢業設計,在字節跳動實習期間接觸到了魔方平臺,魔方平臺的UI編輯器的實現是基於DOM技術,對比設計軟件Figma使用WebGL實現的UI編輯器,項目也嘗試着使用用WebGL去實現一個UI編輯器,並將其構建爲一個App。

存在的不足
  • WebGL實現UI組件難度大,目前實現的可用UI組件並很少,因此並不能編輯出任意的UI

  • DSL代碼編譯目標代碼出現目標代碼可讀性差

  • 打包後的應用包體積過大等等問題

將來規劃
  • 將研究如何使用計算機視覺、機器學習算法等對UI設計稿進行識別,並轉化到系統的DSL表示,從而編譯到目標代碼。

  • 研究如何解析PSD文件,並將PSD轉化到DSL表示,從而編譯到目標代碼

寫在最後

光陰似箭,大學四年的生活就要落下帷幕。在大學四年裏,有人選擇安逸,有人選擇放棄,而我選擇堅持努力學習,不留遺憾。在四年的不斷學習過程當中,我從一個電腦菜鳥,變成了技術達人,進入到字節跳動實習,並憑藉優秀的成績保送研究生。「天道酬勤」,學習沒有輕鬆可言,在大學四年的學習中,有太多的幸酸和淚水,很是感謝曾經堅持過的本身。「博觀而約取,厚積而薄發」,在四年的學習生涯,知識的堆積,讓我有了今天的成果。人生路還很漫長,畢業並非一個結束,將來在研究生或工做中仍需繼續努力。祝本身畢業快樂!!!

參考

Electron在Taro IDE的開發實踐

分享這半年的Electron應用開發和優化經驗

Konvajs.Konva Tutorials

項目Github地址: github.com/sundial-dre…

相關文章
相關標籤/搜索