得物商家客服從 Electron 遷移到 Tauri 的技術實踐

背景

得物商家客服採用的是桌面端應用表現形式,而桌面端應用主要架構形式就是一套和操作系統交互的 “後端” + 一套呈現界面的 “前端(渲染層)”。而桌面端技術又可以根據渲染層的不同核心劃分爲以下幾類:

在 2022 年 5 月份左右,得物商家客服開始投入桌面端應用業務,其目標是一個可以適配多操作系統(MacOS、Windows)、快速迭代、富交互的產品。

考慮到以上前提,我們當時可以選擇的框架是 Chromium 家族或者 Webview 家族。但是當時對於 Webview 來說,Tauri 還並不成熟(在 2022 年 6 月才發佈了 1.0 版本)生態也不夠豐富。對於 pywebview 和 webview_java 相對於前端來說,一方面門檻較高,另一方面生態也非常少。所以,在當時,我們選擇了 Chromium 家族中的 Electron 框架。這是因爲對於 CEF、Electron、NW 來說,Electron 有着對前端開發非常友好的技術棧,僅使用 JavaScript 就可以完成和操作系統的交互以及交互視覺的編寫,另外,Electron 的社區活躍度和生態相對於其他兩者也有非常大的優勢。最重要的是:真的很快!

但是,隨着時間的推移,直到 2024 年的今天,商家客服的入駐量和使用用戶越來越多,用戶的電腦配置也是參差不齊,Electron 的弊端開始顯現:

我們也發現,之前調研過的 Tauri 作爲後起之秀,其生態和穩定性在今天已經變得非常出色,我們熟知的以下應用都是基於 Tauri 開發,涵蓋:遊戲、工具、聊天、金融等等領域:

除此之外,因爲 Tauri 是基於操作系統自帶的 Webview + Rust 的框架。首先,因爲不用打包一個 Chromium,所以包體積非常的小:

其次 Rust 作爲一門系統級編程語言,具有以下特點:

Rust 的這些額外的特性使其成爲改善桌面應用程序性能和安全性的理想選擇。

技術調研

要實現 Electron 遷移到 Tauri,得先分別瞭解 Electron 和 Tauri 的核心功能和架構模型,只有瞭解了這些,才能對整體的遷移成本做一個把控。

Electron 的核心模塊

基礎架構

首先來看看 Electron 的基礎架構模型:Electron 繼承了來自 Chromium 的多進程架構,Chromium 始於其主進程。從主進程可以派生出渲染進程。渲染進程與瀏覽器窗口是一個意思。主進程保存着對渲染進程的引用,並且可以根據需要創建 / 刪除渲染器進程。

每個 Electron 的應用程序都有一個主入口文件,它所在的進程被稱爲 主進程(Main Process)。而主進程中創建的窗體都有自己運行的進程,稱爲渲染進程(Renderer Process)。每個 Electron 的應用程序有且僅有一個主進程,但可以有多個渲染進程。

應用構建打包

打包一個 Electron 應用程序簡單來說就是通過構建工具創建一個桌面安裝程序(.dmg、.exe、.deb 等)。在 Electron 早期作爲 Atom 編輯器的一部分時,應用程序開發者通常通過手動編輯 Electron 二進制文件來爲應用程序做分發準備。隨着時間的推移,Electron 社區構建了豐富的工具生態系統,用於處理 Electron 應用程序的各種分發任務,其中包括:

這樣,應用程序開發者在開發 Electron 應用時,爲了構建出跨平臺的桌面端應用,不得不去了解每個包的功能並需要將這些功能進行組合構建,這對新手而言過於複雜,無疑是勸退的。

所以,基於以上背景,目前使用的比較多的是社區提供的 Electron Builder(https://github.com/electron-userland/electron-builder)一體化打包解決方案。得物商家客服也是採用的上述方案。

應用簽名 & 更新

現在絕大多數的應用簽名都採用了簽名狗的應用簽名方式,而我們的商家客服桌面端應用也是類似,Electron Builder 提供了一個sign的鉤子配置,可以幫助我們來實現對應用代碼的簽名:

...
    "win": {
      "target": "nsis",
      "sign": "./sign.js"
    },
...

(詳細的可以直接閱讀 electron builder 官網介紹,這裏只做簡單說明)

對於應用更新而言,我們之前採用的是 electron-updater 自動更新模式:

如果對這塊感興趣,可以閱讀我們之前的文章:https://juejin.cn/post/7195447709904404536?searchId=202408131832375B6C2C76DEEE740762EA

Tauri 的核心模塊

基礎架構

那麼,Tauri 的基礎架構模型是什麼樣的?其實官網對這塊的介紹比較有限,但是我們可以通過其源碼倉庫和代碼結構管中窺豹的瞭解 Tauri 的核心架構模型,爲了方便大家理解,我們以得物商家客服桌面端應用爲模型,簡單的畫了一個草圖:

一些核心模塊的解釋:

WRY

由於 Web 技術具有表現力強和開發成本低的特點,與 Electron 和 NW 等框架類似,Tauri 應用程序的前端實現是使用 Web 技術棧編寫的。那麼 Tauri 是如何解決 Electron/CEF 等框架遇到的 Chromium 內核體積過大的問題呢?

也許你會想,如果每個應用程序都需要打包瀏覽器內核以實現 Web 頁面的渲染,那麼只要所有應用程序共享相同的內核,這樣在分發應用程序時就無需打包瀏覽器內核,只需打包 Web 頁面資源。

WRY 是 Tauri 的封裝 Webview 框架,它在不同的操作系統平臺上封裝了系統的 Webview 實現:MacOS 上使用 WebKit.WKWebview,Windows 上使用 Webview2,Linux 上使用 WebKitGTK。這樣,在運行 Tauri 應用程序時,直接使用系統的 Webview 來渲染應用程序的前端展示。

TAO

跨平臺應用窗口創建庫,使用 Rust 編寫,支持 Windows、MacOS、Linux、iOS 和 Android 等所有主要平臺。該庫是 winit 的一個分支,Tauri 根據自己的需求進行了擴展,如菜單欄和系統托盤功能。

JS API

這個 API 是一個 JS 庫,提供調用 Tauri Rust 後端的一些 API 能力,利用這個庫可以很方便的完成和 Tauri Rust 後端的交互以及通信。

看起來有點複雜,其實核心也是分成了主進程和渲染進程兩個部分。

Tauri

這是將所有組件拼到一起的 crate。它將運行時、宏、實用程序和 API 集成爲一款最終產品

應用構建打包

Tauri 提供了一個 CLI 工具:https://v1.tauri.app/zh-cn/v1/api/cli/,通過這個 CLI 工具的一個命令,我們可以直接將應用程序打包成目標產物:

yarn tauri build

此命令會將渲染進程的 Web 資源 與 主進程的 Rust 代碼一起嵌入到一個單獨的二進制文件中。二進制文件本身將位於 src-tauri/target/release/[應用程序名稱],而安裝程序將位於 src-tauri/target/release/bundle/。

第一次運行此命令需要一些時間來收集 Rust 包並構建所有內容,但在隨後的運行中,它只需要重新構建您的應用程序代碼,速度要快得多。

應用簽名 & 更新

Tauri 的簽名和 Electron 類似,如果需要自定義簽名鉤子方法,在 Tauri 中現在也是支持的:

{
   "signCommand": "signtool.exe --host xxxx %1"
}

後面我們會詳細介紹該能力的使用方式。

而對於更新而言,Tauri 則有自己的一套體系:Updater | Tauri Apps 這裏還是和 Electron 有着一定的區別。

選型總結

通過上面的架構模型對比,我們可以很直觀的感受到如果要將我們的 Electron 應用遷移到 Tauri 上,整體的遷移改造工作可以總結成以下圖所示:

核心內容就變成了以下四部分內容:

而這些 API 在 Tauri 中都有對應的實現,所以整體來看,遷移成本和技術可行性都是可控的。

最終,我們選擇了 Tauri 對現有的商家客服桌面端進行架構優化升級。

技術實現

渲染進程代碼遷移

目錄結構調整

在聊如何調整 Tauri 目錄結構之前,我們需要先來了解一下之前的 Electron 應用目錄結構設置,一個最簡單的 Electron 應用的目錄結構大致如下:

.
├── index.html
├── main.js
├── renderer.js
├── preload.js
└── package.json

其中文件說明如下:

有的時候你可能需要劃分目錄來編寫不同功能的代碼,但是,不管功能目錄怎麼改,最終的渲染進程和主進程的構建產物都是期望符合類似於上面的結構。

所以,之前得物的商家客服也是類似形式的目錄結構:

.
├── app              // 主進程代碼目錄
├── renderer-process // 渲染進程代碼目錄
├── ...              // 一些其他配置文件,vite 構建文件等等
└── package.json

對於 Tauri 來說,Tauri 打包依託於兩個部分,首先是對前端頁面的構建,這塊可以根據業務需要和框架選擇(Vue、 React)進行構建腳本的執行。一般前端構建的產物都是一個 dist 文件包。

然後是 Tauri 後端程序部分的構建,這塊主要是對 Rust 代碼進行編譯成 binary crate。

(Tauri 後端的編譯在很大程度上依賴於操作系統原生庫和工具鏈,因此當前無法進行有意義的交叉編譯。所以,在本地編譯我們通常需要準備一臺 mac 和一臺 Windows 電腦,以滿足在這兩個平臺上的構建。)

整體來看,和 Electron 是差不多的,這裏,我們就直接使用了官方提供的 create-tauri-app(https://github.com/tauri-apps/create-tauri-app)腳手架來創建項目,其目錄結構大致如下:

.
├── src              // 渲染進程代碼
├── src-tauri        // Rust 後端代碼
├── ...              // 一些其他配置文件,vite 構建文件等等
└── package.json

所以,這裏對渲染進程的目錄調整就很清晰了,直接將我們之前 Electron 中的 renderer-process 目錄中的代碼遷移到 src 目錄中即可。

注意:因爲我們對渲染進程目錄進行了調整,所以對應的打包工具的目錄也需要進行調整。

跨域請求處理

商家客服中會有一些接口請求,這些接口請求有的是從業務中發起的,有的使用依賴的 npm 庫中發起的請求。但因爲是客戶端引用,當從客戶端環境發起請求時,請求所攜帶的 origin 是這樣的:

https://tauri.localhost

那麼,就會遇到一個我們熟知的一個前端跨域問題。這會導致如果不在 access-ctron-allow-origin 中的域名會被 block 掉。

如果有小夥伴對 Electron 比較熟悉,可能會知道在 Electron 實現跨域的方案之一是可以關閉瀏覽器的跨域安全檢測:

const mainWindow = new BrowserWindow({
  webPreferences: {
    webSecurity: false
  }
})

或者在請求返回給瀏覽器之前進行攔截,手動修改access-ctron-allow-origin讓其支持跨域:

mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
    callback({
      responseHeaders: {
        // 通過請求源校驗
        'Access-Control-Allow-Origin': ['*'],
        ...details.responseHeaders,
      },
    });
  });
}

達到的效果就像這樣:

那麼 Tauri 中可以這麼做嗎?答案是不行的!

雖然 Tauri 雖然和 Electron 進程模型很類似,但是本質上還是有區別的,最大的區別就是 Electron 中的渲染進程是基於 Chromium 魔改的,他可以在 Chromium 中植入一些控制器來修改 Chromium 的一些默認行爲。但 Tauri 完全是基於不同平臺的內置 Webview 封裝,考慮的兼容性問題,並沒有對 Webview 進行改造(雖然 Windows 的 Webview2 支持 --disable-web-security,但是其他平臺不行)。所以他的跨域策略是 Webview 默認的行爲,無法調整。

那麼在 Tauri 中,如何發起一個跨域請求了?

其實社區也有幾種解決方案,接下來簡單介紹一下社區的方案和問題。

使用 Tauri 官方的 http

既然瀏覽器會因爲跨域問題 block 掉請求,那麼就繞過瀏覽器唄,沒錯,這也是 Tauri 官方提供的 http 模塊設計的初衷和原理:https://v1.tauri.app/zh-cn/v1/api/js/http/,其設計方案就是通過 JavaScript 前端調用 Rust 後端來發請求,當請求完成後再返回給前端結果。

問題:Tauri http 有一套自己的 API 設計和請求規範,我們必須按照他定義的格式進行請求的發送和接收。對於新項目來說問題不是很大,但對商家客服來說,這樣最大的問題是之前的所有的接口請求都得改造成 Tauri http 的格式,我們很多請求是基於 Axios 的封裝,改造成本非常大,迴歸驗證也很困難,而且有很多三方 npm 包也依賴 axios 發請求,這就又增加了改造的成本和後期維護的成本。

使用 axios adapter

既然使用 axios 改造成本大,那麼就寫一個 axios 的適配器(adapter)在數據請求的時候不使用瀏覽器原生的 xhr 發請求而是使用 tauri http 來發請求,順便對 axios 的請求參數進行格式化,處理成 Tauri http 要求的那種各種。在請求響應後也進行類似的處理。

這種解決方案社區也有一個庫提供:https://github.com/persiliao/axios-tauri-api-adapter

問題:假設項目中依賴一個 npm 庫,這個庫中發起了一個 axios 請求,那麼也需要對這個庫的 axios 進行適配器改造。這樣還是解決不了三方依賴使用 axios 的問題。我們還是需要侵入 npm 包進行 axios 改造。另外,如果其他庫使用的是 xhr 或者 fetch 來直接發請求或者,那就又無解了。

最後,不管使用方案 1 還是 2,都有個通病,那就是請求都是走的 Tauri 後端來發起的,這也意味着我們將在 Webview 的 devtools 中的 network 看不到任何請求的信息和響應的結果,這對開發調試來說無疑是非常難以接受的。

社區對這個問題也有相關的諮詢:https://github.com/tauri-apps/tauri/issues/7882,但是官方回覆也是實現不了:

那我們是怎麼做的呢?對於 Axios 來說,其在瀏覽器端工作的原理是通過實例化 window.XMLHttpRequest  後的 xhr 來發起請求,同時監聽 xhr 的 onreadystatechange 事件來處理請求的響應。然後對於一些請求頭都是通過 xhr.setRequestHeader 這樣的方式設置到了 xhr 對象上。因此,對於 axios、原生 XmlHttpRequest 請求來說,我們就可以重寫 XmlHttpRequest 中的 send、onreadystatechange、setRequestHeader 等方法,讓其通過 Tauri 的 http 來發請求。

但是對 window.fetch 這樣底層未使用 XHR 的請求來說,我們就需要重寫 window.fetch。讓其在調用 window.fetch 的時候,調用 xhr.send 來發請求,這樣便實現了變相調用 Tauri http 的功能。

核心代碼:

class AdapterXMLHTTP extends EventTarget{
    // ...
    // 重寫 send 方法
    async send(data: unknown) {
        // 通過 TauriFetch 來發請求
        TauriFetch(this.url, {
          body: buildTauriRequestData(config.data),
          headers: config.headers,
          responseType: getTauriResponseType(config.responseType),
          timeout: timeout,
          method: <HttpVerb>this.method?.toUpperCase()
        }).then((response: any) => {
           // todo
        }
    }
}
function fetchPollify (input, init) {
    return new Promise((resolve, reject) => {
      // ...
      //  使用 xhr 來發請求
      const xhr = new XMLHttpRequst()
    })
}
// 重寫 window.XMLHttpRequest
window.XMLHttpRequest = AdapterXMLHTTP;
// 重寫 window.featch
window.fetch = fetchPollify;

那怎麼解決 devtools 沒法調試請求的問題呢?

爲了讓請求日誌能出現在瀏覽器的 webview devtools network 中,我們可能需要開發一個類似於 chrome plugin 的方式來支持。但是很可惜,在 Tauri 中,webview 是不支持插件開發的:https://github.com/tauri-apps/tauri/discussions/2685

所以我們只能採用新的方式來支持,那就是外接 devtools。啥意思呢?就是在操作系統網絡層代理掉網絡請求,然後輸出到另一個控制檯中進行展示,原理類似於 Charles。

到這裏,我們就完成了對跨域網絡請求的處理改造工作。核心架構圖如下:

關鍵性 API 兼容

這裏需要注意的是,Tauri 使用的是系統自帶的 Webview,而 Electron 則是直接內置了 Chromium,這裏有個非常大的誤區在於想當然的把 Webview 類比 Chromium 以爲瀏覽器的 API 都可以直接使用。這其實是不對的,舉個例子:我們在發送一些消息通知的時候,可能會使用 HTML5 的 Notification Web API:https://developer.mozilla.org/en-US/docs/Web/API/Notification

但是,這個 API 是瀏覽器自行實現的,也就是說,你在 Electron 中可以這麼用,但是,如果你在 Tauri 中,你會發現一個 bug:https://github.com/tauri-apps/tauri/issues/3698,這個 bug 的大概含義就是 Tauri 中的 Notification 不會觸發 click 點擊事件。這個 bug 至今還未解決。究其原因:

Tauri 依賴的操作系統 webview 並沒有實現對 Notification 的支持,webview 本身希望宿主應用自行實現對 Notification 的實現,所以 Tauri 就重寫了 JS 的 Notification API,當你在調用 window  Notification 的時候,實際上你和 Rust 進程完成了一次通信,調用的還是 tauri::Notification 模塊。

在 Tauri 源碼裏面,是這樣實現的:

  // https://github.com/tauri-apps/tauri/blob/1.x/core/tauri/scripts/core.js#L256-L282
  function sendNotification(options) {
    if (typeof options === 'object') {
      Object.freeze(options)
    }
    // 和 Rust 後端通信,調用 Rust 發送系統通知
    return window.__TAURI_INVOKE__('tauri', {
      __tauriModule: 'Notification',
      message: {
        cmd: 'notification',
        options:
          typeof options === 'string'
            ? {
              title: options
            }
            : options
      }
    })
  }
  //  這裏便是對 Notification 的重寫實現
  window.Notification = function (title, options) {
    const opts = options || {}
    sendNotification(
      Object.assign(opts, {
        title: title
      })
    )
  }

除此之外,Tauri 還分別實現了:

所以,我們在對商家客服從 Electron 遷移到 Tauri 的過程中,還需要對這些關鍵性 API 進行兼容性測試和迴歸。一旦發現相關 API 不符合預期,我們需要及時調整業務策略或者給嘗試進行 hack。

(這裏賣個關子,雖然 Tauri 不支持對 Notification 的點擊事件回調,那麼我們是怎麼讓他支持的呢?在下一節主進程代碼遷移中我們會詳細介紹。)

兼容性迴歸

對於樣式兼容性來說,因爲 Electron 在不同操作系統內都集成了 Chromium 所以我們完全不用擔心樣式兼容性的問題。但是對於 Tauri 來說,因爲不同操作系統使用了不同的 Webview,所以在樣式上,我們還是需要注意不同操作系統下的差異性,比如:以下分別是 Linux 和 Windows 渲染 Element-Plus 的界面:

可以看到在按鈕大小、文字對齊等樣式上面還是存在着不小的差距。

除了上述問題,如果你需要兼容 Linux 系統,那麼還有 webkitgtk 在非整數倍縮放下的 bug,應該是陳年老問題了。當然,這些問題都是上游 webkitgtk 的 “鍋”。

所以,社區也有關於討論 Tauri 是否有可能在不同平臺上使用同一個 webview 的可能性的討論:https://github.com/tauri-apps/tauri/discussions/4591。官方是期待能有 Mac 版本的 Webview 發佈,不過大概率來看不太現實,一方面是因爲:微軟決定不開源 Webview2 的 Mac 和 Linux 版本(https://mp.weixin.qq.com/s/p6pdNI3_di7oBkv4ugDIdA),另一方面是如果要使用統一的 webview 那就又回到了 Electron。

除了樣式兼容性外,對於 JS 代碼的兼容性也需要留意 Tauri 在 Windows 上使用的是 Webview2 而 Webview2 本身就是基於 Chromium 的,所以代碼兼容性倒還好,但是在 MacOS 上使用的就是 WebKit.WKWebview,Safari 就是基於他,所以到這裏,我想你也明白了,這就又回到了前端處理不同瀏覽器兼容性的問題上來了。所以這裏溫馨提示一下:構建時前端代碼需要進行 polyfill。

對於 Electron 應用的用戶來說,可能沒有這樣的煩惱,最新的 API 只要 Chrome 支持,那就可以用。

主進程代碼遷移

自定義操作欄窗口

默認情況,在構建窗口的時候,會使用系統自帶的原生窗口樣式,比如在MacOS下的樣式:

在有些情況下,操作系統的原生窗口並不能符合我們的一些視覺和交互需求。所以,在創建桌面應用的時候,有時候我們希望能完全掌控窗口的樣式,而隱藏掉系統提供的窗口邊框和標題欄等。這個時候就需要用到自定義操作欄窗口。比如在 Windows 中,我們希望在右上角有一排自定義的操作欄,就像是這樣:

商家客服桌面端的窗口就是一個無邊框的自定義操作欄的窗口,在 Electron 中,我們可以這樣操作快速創建一個無邊框窗口:

const { BrowserWindow } = require('electron')
const win = new BrowserWindow({ frame: false })

然後在渲染進程中,自己 “畫一個標題欄”:

<div>
  <div @click="minimize"></div>
  <div @click="maximize"></div>
  <div @click="close"></div>
</div>

然後定義一下 icon 的樣式:

.minimize {
  background: center / 20px no-repeat url("./assets/minimize.svg");
}
.maximize {
  background: center / 20px no-repeat url("./assets/maximize.svg");
}
.unmaximize {
  background: center / 20px no-repeat url("./assets/unmaximize.svg");
}
.close {
  background: center / 20px no-repeat url("./assets/close.svg");
}
.close:hover {
  background-color: #e53935;
  background-image: url("./assets/close-hover.svg");
}

但是在 Tauri 中,要實現自定窗口首先需要在窗口創建的時候設置 decoration 無裝飾樣式,比如這樣:(也可以在 tauri.config.json 中設置,道理是一樣的)

let window = WindowBuilder::new(
  &app,
  "main",
  WindowUrl::App("/src/index.html".into()),
)
  .inner_size(400., 300.)
  .visible(true)
  .resizable(false)
  .decorations(false)
  .build()
  .unwrap();

然後就是和 Electron 類似,自己畫一個控制欄,詳細的代碼可以參考這裏:https://v1.tauri.app/v1/guides/features/window-customization/

<div data-tauri-drag-region>
  <div>
    <img
      src="https://api.iconify.design/mdi:window-minimize.svg"
      alt="minimize"
    />
  </div>
  <div>
    <img
      src="https://api.iconify.design/mdi:window-maximize.svg"
      alt="maximize"
    />
  </div>
  <div>
    <img src="https://api.iconify.design/mdi:close.svg" alt="close" />
  </div>
</div>

單例模式

通過使用窗口單例模式,可以確保應用程序在用戶嘗試多次打開時只會有一個主窗口實例,從而提高用戶體驗並避免不必要的資源佔用。在 Electron 中可以很容易做到這一點:

app.on('second-instance', (event, commandLine, workingDirectory) => {
  // 當運行第二個實例時,將會聚焦到myWindow這個窗口
  if (myWindow) {
    mainWindow.show()
    if (myWindow.isMinimized()) myWindow.restore()
    myWindow.focus()
  }
})

但是,在 Tauri 中,我需要引入一個單例插件纔可以:

use tauri::{Manager};
#[derive(Clone, serde::Serialize)]
struct Payload {
  args: Vec<String>,
  cwd: String,
}
fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
            app.emit("single-instance", Payload { args: argv, cwd }).unwrap();
        }))
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

其在 Windows 下判斷單例的核心原理是藉助了 windows_sys 這個 Crate 中的 CreateMutexW API 來創建一個互斥體,確保只有一個實例可以運行,並在用戶嘗試啓動多個實例時,聚焦於已經存在的實例並傳遞數據,簡化後的代碼大致如下:

pub fn init<R: Runtime>(f: Box<SingleInstanceCallback<R>>) -> TauriPlugin<R> {
    plugin::Builder::new("single-instance")
        .setup(|app| {
            // ...
            // 創建互斥體
            let hmutex = unsafe { 
                    CreateMutexW(std::ptr::null(), true.into(), mutex_name.as_ptr())
                };
            // 如果 GetLastError 返回 ERROR_ALREADY_EXISTS,則表示已有實例在運行。
            if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
                unsafe {
                    // 找到已存在窗口的句柄
                    let hwnd = FindWindowW(class_name.as_ptr(), window_name.as_ptr());
                    if hwnd != 0 {
                        // ...
                        // 通過 SendMessageW 發送數據給該窗口
                        SendMessageW(hwnd, WM_COPYDATA, 0, &cds as *const _ as _);
                        // 最後退出當前應用
                        app.exit(0);
                    }
                }
            }
            // ...
            Ok(())
        })
        .build()
}

(注意:這裏有坑,如果你的應用需要實現一個重新啓動功能,那麼在單例模式下將不會生效,核心原因是因爲應用重啓的邏輯是先打開一個新的實例再關閉舊的運行實例。而打開新的實例在單例模式下就被阻止了,這塊的詳細原因和解決方案我們已經給 Tauri 提了 PR:https://github.com/tauri-apps/tauri/pull/11684)

系統消息通知能力

消息通知是商家客服桌面端應用必不可少的能力,消息通知能力一般可以分爲以下兩種:

前面我們有提到,在 Electron 中,我們需要顯示來自渲染進程的通知,那麼可以直接使用 HTML5 的 Web API 來發送一條系統消息通知:

function notifyMe() {
  if (!("Notification" in window)) {
    // 檢查瀏覽器是否支持通知
    alert("當前瀏覽器不支持桌面通知");
  } else if (Notification.permission === "granted") {
    // 檢查是否已授予通知權限;如果是的話,創建一個通知
    const notification = new Notification("你好!");
    // …
  } else if (Notification.permission !== "denied") {
    // 我們需要徵求用戶的許可
    Notification.requestPermission().then((permission) => {
      // 如果用戶接受,我們就創建一個通知
      if (permission === "granted") {
        const notification = new Notification("你好!");
        // …
      }
    });
  }
  // 最後,如果用戶拒絕了通知,並且你想尊重用戶的選擇,則無需再打擾他們
}

如果我們需要爲消息通知添加點擊回調事件,那麼我們可以這麼寫:

 notification.onclick = (event) => {};

當然,Electron 也提供了主進程使用的 API,更多的能力可以直接參考 Electron 的官方文檔:https://www.electronjs.org/zh/docs/latest/api/%E9%80%9A%E7%9F%A5。

然而,對於 Tauri 來說,只實現了第 1 個能力,也就是消息觸達。Tauri 本身不支持點擊回調的功能,這就導致了用戶發來了一個消息,但是業務無法感知客服點擊消息的事件。而且原生的 Web API 也是 Tauri 自己寫的,原理還是調用了 Rust 的通知能力。接下來,我也會詳細介紹一下我們是如何擴展消息點擊回調能力的。

Tauri 在 Rust 層,我們可以通過下面這段代碼來調用 Notification:

use tauri::api::notification::Notification;
let app = tauri::Builder::default()
  .build(tauri::generate_context!("test/fixture/src-tauri/tauri.conf.json"))
  .expect("error while building tauri application");
// 非 win7 可以調用
Notification::new(&app.config().tauri.bundle.identifier)
  .title("New message")
  .body("You've got a new message.")
  .show();
// 兼容 win7 的調用形式
Notification::new(&app.config().tauri.bundle.identifier)
  .title("Tauri")
  .body("Tauri is awesome!")
  .notify(&app.handle())
  .unwrap();
// run the app
app.run(|_app_handle, _event| {});

Tauri 的 Notification Rust 實現源碼位置在:https://github.com/tauri-apps/tauri/blob/1.x/core/tauri/src/api/notification.rs 這個文件中,其中看一下 show 函數的實現:

pub fn show(self) -> crate::api::Result<()> {
    #[cfg(feature = "dox")]
    return Ok(());
    #[cfg(not(feature = "dox"))]
    {
      // 使用 notify_rust 構造 notification 實例
      let mut notification = notify_rust::Notification::new();
      // 設置消息通知的 body\title\icon 等等
      if let Some(body) = self.body {
        notification.body(&body);
      }
      if let Some(title) = self.title {
        notification.summary(&title);
      }
      if let Some(icon) = self.icon {
        notification.icon(&icon);
      } else {
        notification.auto_icon();
      }
      // ... 省略部分代碼
      crate::async_runtime::spawn(async move {
        let _ = notification.show();
      });
      Ok(())
    }
  }
  #[cfg(feature = "windows7-compat")]
  #[cfg_attr(doc_cfg, doc(cfg(feature = "windows7-compat")))]
  #[allow(unused_variables)]
  pub fn notify<R: crate::Runtime>(self, app: &crate::AppHandle<R>) -> crate::api::Result<()> {
    #[cfg(windows)]
    {
      if crate::utils::platform::is_windows_7() {
        self.notify_win7(app)
      } else {
        #[allow(deprecated)]
        self.show()
      }
    }
    #[cfg(not(windows))]
    {
      #[allow(deprecated)]
      self.show()
    }
  }
  #[cfg(all(windows, feature = "windows7-compat"))]
  fn notify_win7<R: crate::Runtime>(self, app: &crate::AppHandle<R>) -> crate::api::Result<()> {
    let app = app.clone();
    let default_window_icon = app.manager.inner.default_window_icon.clone();
    let _ = app.run_on_main_thread(move || {
      let mut notification = win7_notifications::Notification::new();
      if let Some(body) = self.body {
        notification.body(&body);
      }
      if let Some(title) = self.title {
        notification.summary(&title);
      }
      notification.silent(self.sound.is_none());
      if let Some(crate::Icon::Rgba {
        rgba,
        width,
        height,
      }) = default_window_icon
      {
        notification.icon(rgba, width, height);
      }
      let _ = notification.show();
    });
    Ok(())
  }
}

這裏,我們可以看到notify函數非 win7 環境下show函數調用的是 notify_rust 這個庫,而在 win7 環境下調用的是 win7_notifications 這個庫。而notify_rust這個庫,本身確實未完成實現對 MacOS 和 Windows 點擊回調事件。

所以我們需要自定義一個Notification的 Tauri 插件,實現對點擊回調的能力。(因爲篇幅原因,這裏只介紹一些核心的實現邏輯)

MacOS 支持消息點擊回調能力

notify_rust在 Mac 上實現消息通知是基於Mac_notification_sys這個庫的,這個庫本身是支持對點擊action的 response,只是notify_rust沒有處理而已,所以我們可以爲notify_rust增加對Mac上點擊回調的處理能力:

#[cfg(target_os = "macos")]
fn show_mac_action(
  window: tauri::Window,
  app_id: String,
  notification: Notification,
  action_id: String,
  action_name: String,
  handle: CallbackFn,
  sid: String,
) {
  let window_ = window.clone();
  // Notify-rust 不支持 macos actions 但是 mac_notification 是支持的
  use mac_notification_sys::{
    Notification as MacNotification,
    MainButton,
    Sound,
    NotificationResponse,
  };
 // 發通過 mac_notification_sys 送消息通知
  match MacNotification::default()
      .title(notification.summary.as_str())
      .message(¬ification.body)
      .sound(Sound::Default)
      .maybe_subtitle(notification.subtitle.as_deref())
      .main_button(MainButton::SingleAction(&action_name))
      .send()
  {
    // 響應點擊事件,回調前端的 handle 函數
    Ok(response) => match response {
      NotificationResponse::ActionButton(id) => {
        if action_name.eq(&id) {
          let js = tauri::api::ipc::format_callback(handle, &id)
              .expect("點擊 action 報錯");
           window_.eval(js.as_str());
        };
      }
      NotificationResponse::Click => {
        let data = &sid;
        let js = tauri::api::ipc::format_callback(handle, &data)
            .expect("消息點擊報錯");
         window_.eval(js.as_str());
      }
      _ => {}
    },
    Err(err) => println!("Error handling notification {}", err),
  }
}

Win 10 上支持消息點擊回調能力

在 Windows 10 操作系統中,notify_rust則是通過winrt_notification這個 Crate 來發送消息通知,winrt_notification 則是調用的windows這個 crate 來實現消息通知,windows 這個 crate 的官方描述是:爲 Rust 開發人員提供了一種自然和習慣的方式來調用 Windows API。這裏,主要會用到以下幾個方法:

所以,要想在 > win7 的操作系統中顯示消息同時的主要流程大致是:

但是winrt_notification這個庫,只完成了 1-3 步驟,所以我們需要手動實現步驟 4。核心代碼如下:

fn show_win_action(
  window: tauri::Window,
  app_id: String,
  notification: Notification,
  action_id: String,
  action_name: String,
  handle: CallbackFn,
  sid: String,
) {
  let window_ = window.clone();
  // 設置消息持續狀態,支持 short 和 long
  // short 就是默認 6s
  // long 是常駐消息
  let duration = match notification.timeout {
    notify_rust::Timeout::Default => "duration=\"short\"",
    notify_rust::Timeout::Never => "duration=\"long\"",
    notify_rust::Timeout::Milliseconds(t) => {
      if t >= 25000 {
        "duration=\"long\""
      } else {
        "duration=\"short\""
      }
    }
  };
  // 創建消息模版 xml
  let template_binding = "ToastGeneric";
  let toast_xml = windows::Data::Xml::Dom::XmlDocument::new().unwrap();
  if let Err(err) = toast_xml.LoadXml(&windows::core::HSTRING::from(format!(
    "<toast {} {}>
        <visual>
          <binding template=\"{}\">
            {}
            <text>{}</text>
            <text>{}{}</text>
          </binding>
        </visual>
        <audio src='ms-winsoundevent:Notification.SMS' />
      </toast>",
    duration,
    String::new(),
    template_binding,
    ¬ification.icon,
    ¬ification.summary,
    notification.subtitle.as_ref().map_or("", AsRef::as_ref),
    ¬ification.body,
  ))) {
    println!("Error creating windows toast xml {}", err);
    return;
  };
  // 根據 xml 創建 toast
  let toast_notification =
      match windows::UI::Notifications::ToastNotification::CreateToastNotification(&toast_xml)
      {
        Ok(toast_notification) => toast_notification,
        Err(err) => {
          println!("Error creating windows toast {}", err);
          return;
        }
      };
  // 創建消息點擊監聽捕獲
  let handler = windows::Foundation::TypedEventHandler::new(
    move |_sender: &Option<windows::UI::Notifications::ToastNotification>,
          result: &Option<windows::core::IInspectable>| {
      let event: Option<
        windows::core::Result<windows::UI::Notifications::ToastActivatedEventArgs>,
      > = result.as_ref().map(windows::core::Interface::cast);
      let arguments = event
          .and_then(|val| val.ok())
          .and_then(|args| args.Arguments().ok());
      if let Some(val) = arguments {
        let mut js;
        if val.to_string_lossy().eq(&action_id) {
          js = tauri::api::ipc::format_callback(handle, &action_id)
              .expect("消息點擊報錯");
        } else {
          let data = &sid;
          js = tauri::api::ipc::format_callback(handle, &data)
              .expect("消息點擊報錯");
        }
        let _ = window_.eval(js.as_str());
      };
      Ok(())
    },
  );
  // 通過消息管理器發送消息
  match windows::UI::Notifications::ToastNotificationManager::CreateToastNotifierWithId(
    &windows::core::HSTRING::from(&app_id),
  ) {
    Ok(toast_notifier) => {
      if let Err(err) = toast_notifier.Show(&toast_notification) {
        println!("Error showing windows toast {}", err);
      }
    }
    Err(err) => println!("Error handling notification {}", err),
  }
}

Win 7 上支持消息通知點擊回調能力

在 Windows 7 中,Tauri 調用的是win7_notifications這個庫,這個庫本身也沒有實現對消息點擊的回調處理,我們需要擴展win7_notifications的能力來實現對消息通知的回調事件。我們希望這個庫可以這樣調用:

win7_notify::Notification::new()
    .appname(&app_name)
    .body(&body)
    .summary(&title)
    .timeout(duration)
    .click_event(move |str| {
      // 用戶自定義的參數
      let data = &sid;
      // 觸發前端的回調能力
      let js = tauri::api::ipc::format_callback(handle, &data)
          .expect("消息點擊報錯");
      let _ = window_.eval(js.as_str());
    })
    .show();

而我們要做的,就是爲win7_notify這個庫中的Notification結構體增加一個click_event函數,這個函數支持傳入一個閉包,這個閉包在點擊消息通知的時候執行。

pub struct Notification {
    // ...
    // 添加 click_event 屬性
    pub click_event: Option<Arc<dyn Fn(&str) + Send>>,
}
impl Notification {
    // ...
    // 添加 click_event 事件註冊
    pub fn click_event<F: Fn(&str) + Send + 'static>(&mut self, func: F) -> &mut Notification {
        // 將事件綁定到 Notification 中
        self.click_event = Some(Arc::new(func));
        self
    }
    // 支持對 click_event 的調用
    fn perform_click_event(&self, message: &str) {
        if let Some(ref click_event) = self.click_event {
            click_event(message);
        }
    }
}
pub unsafe extern "system" fn window_proc(
    hwnd: HWND,
    msg: u32,
    wparam: WPARAM,
    lparam: LPARAM,
) -> LRESULT {
    let mut userdata = GetWindowLongPtrW(hwnd, GWL_USERDATA);
    match msg {
       // ....
       // 增加對點擊事件的調用
       w32wm::WM_LBUTTONDOWN => {
            let (x, y) = (GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam));
            let userdata = userdata as *mut WindowData;
            let notification = &(*userdata).notification;
            // todo 增加點擊參數
            let data = "default"; 
            notification.perform_click_event(&data);
            if util::rect_contains(CLOSE_BTN_RECT_EXTRA, x as i32, y as i32) {
                println!("close");
                close_notification(hwnd)
            }
            DefWindowProcW(hwnd, msg, wparam, lparam)
        }
    }
}

總結:

應用構建打包

Windows 10

Tauri 1.3 版本之前,應用程序在 Windows 上使用的是 WiX(Windows Installer)Toolset v3 工具進行構建,構建產物是 Microsoft 安裝程序(.msi 文件)。1.3 之後,使用的是 NSIS 來構建應用的xxx-setup.exe安裝包。

Tauri CLI 默認情況下使用當前編譯機器的體系結構來編譯可執行文件。假設當前是在 64 位計算機上開發,CLI 將生成 64 位應用程序。如果需要支持 32 位計算機,可以使用--target標誌使用不同的 Rust 目標編譯應用程序:

tauri build --target i686-pc-windows-msvc

爲了支持不同架構的編譯,需要爲 Rust 添加對應的環境支持,比如:

rustup target add i686-pc-windows-msvc

其次,需要爲構建增加不同的環境變量,以便爲了在不同的環境進行代碼測試,對應到package.json中的構建代碼:

{
  "scripts": {
    "tauri-build-win:t1": "tauri build -t i686-pc-windows-msvc -c src-tauri/t1.json",
    "tauri-build-win:pre": "tauri build -t i686-pc-windows-msvc -c src-tauri/pre.json",
    "tauri-build-win:prod": "tauri build -t i686-pc-windows-msvc",
  }
}

-c參數指定了構建的配置文件路徑,Tauri 會和src-tauri中的tarui.conf.json文件進行合併。除此之外,還可以通過tarui.{{platform}}.conf.json的形式指定不同平臺的獨特配置,優先級關係:

-c path >> tarui.{{platform}}.conf.json >> tarui.conf.json

Windows 7

Webview 2

Tauri 在Windows 7上運行有兩個東西需要注意,一個是 Tauri 的前端跨平臺在Windows上依託於Webview2但是Windows 7中並不會內置Webview2因此我們需要在構建時指明引入Webview的方式:

綜合比較下來,embedBootstrapper目前是比較好的方案,一方面可以減少安裝包體積,一方面減少不必要的靜態資源下載。

Windows 7 一些特性

在 Tauri 中,會通過 "Windows7-compat" 來構建一些Win7特有的環境代碼,比如:

#[cfg(feature = "windows7-compat")]
{
 // todo
}

在 Tauri 文檔中也有相關介紹,主要是在使用Notification的時候,需要加入Windows7-compat特性。不過,因爲 Tauri 對Notification的點擊事件回調是不支持,所以我重寫了 Tauri 的所有Notification模塊,已經內置了Windows7-compat能力,因此可以不用設置了。

MacOS

MacOS 操作系統也有 M1 和 Intel 的區分,所以爲了可以構建出兼容兩個版本的產物,我們需要使用universal-apple-darwin模式來編譯:

br

應用簽名 & 更新

應用更新

對於 Tauri 來說,應用更新的詳細配置步驟可以直接看官網的介紹:https://tauri.app/zh-cn/v1/guides/distribution/updater/。這裏爲了方便大家理解,簡單畫了個更新流程圖:

核心流程如下:

br
if found_path.extension() == Some(OsStr::new("exe")) {
      // 創建一個新的 OsString,並將 found_path 包裹在引號中,以便在 PowerShell 中正確處理路徑
      let mut installer_path = std::ffi::OsString::new();
      installer_path.push("\"");
      installer_path.push(&found_path);
      installer_path.push("\"");
      // 構造安裝程序參數
      let installer_args = [
        config
          .tauri
          .updater
          .windows
          .install_mode
          .nsis_args()
          .iter()
          .map(ToString::to_string)
          .collect(),
        vec!["/ARGS".to_string()],
        current_exe_args,
        config
          .tauri
          .updater
          .windows
          .installer_args
          .iter()
          .map(ToString::to_string)
          .collect::<Vec<_>>(),
      ]
      .concat();
      // 創建一個新的命令,指向 PowerShell 的路徑。
      // 使用 Start-Process 命令來啓動安裝程序,
      // 並設置 -NoProfile 和 -WindowStyle Hidden 選項,
      // 以確保 PowerShell 不會加載用戶配置文件,並且窗口保持隱藏
      let mut cmd = Command::new(powershell_path);
      cmd
        .args(["-NoProfile", "-WindowStyle", "Hidden", "Start-Process"])
        .arg(installer_path);
      if !installer_args.is_empty() {
        cmd.arg("-ArgumentList").arg(installer_args.join(", "));
      }
      // 使用 spawn() 方法啓動命令,如果失敗,則輸出錯誤信息。
      cmd
        .spawn()
        .expect("Running NSIS installer from powershell has failed to start");
      exit(0);
    }

需要注意的是:如果以爲更新是增量更新,不會卸載之前已經安裝好的應用程序只更新需要變更的部分。其實是不對的,整個安裝過程可以理解爲 Tauri 在後臺幫你重新下載了一個最新的安裝包,然後幫你重新安裝了一下。

總結:更新的核心原理就是通過使用 Windows 的 PowerShell 來對下載後的安裝包進行open。然後由安裝包進行安裝。

爲什麼我要花這麼大的篇幅來介紹 Tauri 的更新原理呢?

這是因爲我們在更新的過程中碰到了兩個比較大的問題:

這些都是因爲Tauri直接使用 Powershell的問題,那需要怎麼改呢?很簡單,那就是使用Windows操作系統提供的ShellExecuteW來運行安裝程序,核心代碼如下:

windows::Win32::UI::Shell::ShellExecuteW(
  0,
  operation.as_ptr(),
  file.as_ptr(),
  parameters.as_ptr(),
  std::ptr::null(),
  SW_SHOW,
)

但是這塊是Tauri的源碼,我們沒法直接修改,但這個問題的解決方法我們已經給Tauri提了PR並已合入到官方的1.6.8正式版本當中:https://github.com/tauri-apps/tauri/pull/9818

所以,你要做的就是確保 Tauri 升級到v1.6.8及以後版本。

應用簽名

Tauri 應用程序簽名可以分成 2 個部分,第一部分是應用程序簽名,第二部分是安裝包程序簽名,官網上介紹的簽名方法需要配置tauri.config.json中如下字段:

"windows": {
    // 簽名指紋
    "certificateThumbprint": "xxx",
    // 簽名算法
    "digestAlgorithm": "sha256",
    // 時間戳
    "timestampUrl": "http://timestamp.comodoca.com"
}

如果你按照官方的步驟來進行簽名:https://v1.tauri.app/zh-cn/v1/guides/distribution/sign-windows/,很快就會發現問題所在:官網中籤名有一個重要的步驟就是導出一個.pfx文件,但是現在業界簽名工具基本上都是採用簽名狗的方式進行的,這是一個類似於U盾簽名工具,需要插入電腦中才可以進行簽名,不支持直接導出.pfx格式的文件:

所以我們需要額外處理一下:

簽名狗支持導出一個.ce``rt證書,可以查看到證書的指紋:

這裏證書的指紋對應的就是certificateThumbprint字段。

然後需要插入我們在簽名機構購買的 USB key。這樣,在構建的時候,就會提示讓我們輸入密碼:

到這裏就可以完成對應用程序的簽名。

不過對於我們而言,USB key 簽名狗是整個公司共享的,通常不在前端開發手裏(尤其是異地辦公)。一種做法是在Tauri構建的過程中,對於需要簽名的軟件提供一個signCommand命令鉤子,併爲這個命令傳入文件的路徑,然後交由開發者對文件進行自行簽名(比如上傳到擁有簽名工具的電腦,上傳上去後,遠程進行簽名,簽名完成再下載)。所以這就需要讓Tauri將簽名功能暴露出來,讓我們自行進行簽名,比如這樣:

{
   "signCommand": "signtool.exe --host xxxx %1"
}

該命令中包含一個%1,它只是二進制路徑的佔位符,Tauri在構建的時候會將需要簽名的文件路徑替換掉%1

這個功能官網上還沒有更新相關的介紹,所以你可能看不到這塊的使用方式,因爲也是我們最近提交的 PR:https://github.com/tauri-apps/tauri/pull/9902。不過目前,這個 PR 已經被合入Tauri的主版本中,你要做的就是就是升級Tauri1.7.0升級@tauri-apps/cli1.6.0

收益 & 總結

經過我們的不懈努力(不斷地填坑)到目前,得物商家客服Tauri版本終於如期上線,基於Tauri遷移帶來的收益如下:

整體性能測試相比之前的Electron應用有比較明顯的提升:

整體在性能體驗上有一個非常顯著改善。但是,這裏也暴露出使用Tauri的一些問題。

社區活躍度還需要提升

直到 2024 年的今天,Tauri依然還不是特別完美,目前官方主要精力是放在了2.0的開發上,對於1.x的版本維護顯得力不從心,主要原因也是因爲官方人少。

比如,Tauri: dev分支上,主要貢獻者(> 30 commit)只有 4 個人;相對於 Electron:主要貢獻者有 10 人。

除此之外,Electron 實現了對Chromium的高級定製,因此在 Electron 中,我們可以使用BrowserView這樣的功能,相對於 Electron 來說,Tauri 目前所做的僅僅是對 Webview 的封裝,Webview 不支持的功能暫時都不行。另外,系統性的 API 確實少的可憐。如果要實現一些其他的功能,比如自動更新顯示進度條等能力,就不得不使用 Rust 來擴展 API。然後Rust語言學習成本是有一點的,所以,也給我們日常開發帶來了不少挑戰。

Webview2 的問題

因爲 Tauri 在 Windows 系統上比較依託於 Webview2 作爲渲染的容器,雖然 Tauri 提供了檢測本地電腦是否有安裝 Webview2 以及提供聯網下載的能力,但是因爲 Windows 電腦千奇百怪,經常會出現未內置 Webview2 的 Windows 電腦下載不成功而導致程序無法啓動的情況:

對於這種情況,我們雖然可以將 Webview2 內置到安裝包裏面,在用戶安裝的時候進行內置解壓安裝,但是這樣包體積就跟 Electron 相差不大。

成熟度和穩定性還不夠

我們在將得物商家客服遷移到 Tauri 的過程中,就遇到了非常多的問題,有些問題是Tauribug。有些問題是Tauri的 feature 不夠,有的是Rust社區的問題。單純這一個遷移工作,我們就爲Tauri社區共享了 7 個左右的 PR:

在遇到這些問題時,真的特別讓人頭大,因爲社區幾乎沒有這些問題的答案,需要我們自己去翻 Tauri 的源碼實現,有些是涉及到操作系統底層的一些 API,因此我們必須要去看一些操作系統的 API 介紹和出入參說明,才能更好的理解 Tauri 的代碼實現意圖,才能解決我們碰到的這些問題。另外,Tauri 和操作系統系統相關的源碼都是基於 Rust 來編寫的,也爲我們的排查和解決增加了不少難度。最後一句名言和讀者共勉:紙上得來終覺淺,絕知此事要躬行。

文 / muwoo

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UxmJxU4-fv9GeRxl2fzOGw