得物商家客服從Electron遷移到Tauri的技術(shù)實(shí)踐
一、背景
得物商家客服采用的是桌面端應(yīng)用表現(xiàn)形式,而桌面端應(yīng)用主要架構(gòu)形式就是一套和操作系統(tǒng)交互的“后端” + 一套呈現(xiàn)界面的“前端(渲染層)”。而桌面端技術(shù)又可以根據(jù)渲染層的不同核心劃分為以下幾類:
- C語(yǔ)言家族:原生開發(fā)、QT
- Chromium家族:NW、Electron、CEF
- Webview 家族:Tauri、pywebview、webview_java
- 自立山頭:Flutter
在2022年5月份左右,得物商家客服開始投入桌面端應(yīng)用業(yè)務(wù),其目標(biāo)是一個(gè)可以適配多操作系統(tǒng)(MacOS、Windows)、快速迭代、富交互的產(chǎn)品。
考慮到以上前提,我們當(dāng)時(shí)可以選擇的框架是Chromium家族或者Webview家族。但是當(dāng)時(shí)對(duì)于Webview來(lái)說(shuō),Tauri 還并不成熟(在 2022年6月才發(fā)布了1.0版本)生態(tài)也不夠豐富。對(duì)于pywebview和webview_java相對(duì)于前端來(lái)說(shuō),一方面門檻較高,另一方面生態(tài)也非常少。所以,在當(dāng)時(shí),我們選擇了Chromium家族中的Electron框架。這是因?yàn)閷?duì)于CEF、Electron、NW來(lái)說(shuō),Electron有著對(duì)前端開發(fā)非常友好的技術(shù)棧,僅使用JavaScript就可以完成和操作系統(tǒng)的交互以及交互視覺(jué)的編寫,另外,Electron的社區(qū)活躍度和生態(tài)相對(duì)于其他兩者也有非常大的優(yōu)勢(shì)。最重要的是:真的很快!
圖片
但是,隨著時(shí)間的推移,直到2024年的今天,商家客服的入駐量和使用用戶越來(lái)越多,用戶的電腦配置也是參差不齊,Electron的弊端開始顯現(xiàn):
- 性能方面:隨著商家客服入駐數(shù)量的快速增加,現(xiàn)有Electron桌面應(yīng)用在多賬戶+多會(huì)話高并發(fā)場(chǎng)景下,占用內(nèi)存特別大,存在性能瓶頸;
- 安全方面:Electron在內(nèi)存安全性、跨平臺(tái)攻擊、不受限制的上下文和依賴管理等方面存在一些潛在的弱點(diǎn);
- 體驗(yàn)方面:現(xiàn)有Electron桌面應(yīng)用包體積大,下載、更新成本較高;
- 信息集成方面:商家客服目前需要在商家后臺(tái)、商家客服后臺(tái)、商家客服工作臺(tái)3個(gè)系統(tǒng)來(lái)回切換操作,使用成本很高。
我們也發(fā)現(xiàn),之前調(diào)研過(guò)的Tauri作為后起之秀,其生態(tài)和穩(wěn)定性在今天已經(jīng)變得非常出色,我們熟知的以下應(yīng)用都是基于Tauri開發(fā),涵蓋:游戲、工具、聊天、金融等等領(lǐng)域:
- ChatBox:https://github.com/Bin-Huang/chatbox 20k+ star
- ChatGPT 桌面端:https://github.com/lencx/ChatGPT 51k+ star
- Clash Verge:https://github.com/clash-verge-rev/clash-verge-rev 28k+ star
除此之外,因?yàn)門auri是基于操作系統(tǒng)自帶的Webview + Rust的框架。首先,因?yàn)椴挥么虬粋€(gè)Chromium,所以包體積非常的?。?/p>
圖片
其次Rust作為一門系統(tǒng)級(jí)編程語(yǔ)言,具有以下特點(diǎn):
- 內(nèi)存安全:Rust通過(guò)所有權(quán)和借用機(jī)制,在編譯時(shí)檢查內(nèi)存訪問(wèn)的安全性,避免了常見(jiàn)的內(nèi)存安全問(wèn)題,如空指針引用、數(shù)據(jù)競(jìng)爭(zhēng)等;
- 零成本抽象:Rust提供了豐富的抽象機(jī)制,如結(jié)構(gòu)體、枚舉、泛型等,但不引入運(yùn)行時(shí)開銷。這意味著開發(fā)者可以享受高級(jí)語(yǔ)言的便利性,同時(shí)保持接近底層語(yǔ)言的性能;
- 并發(fā)性能:Rust內(nèi)置支持并發(fā)和異步編程,通過(guò)輕量級(jí)的線程(稱為任務(wù))和異步函數(shù)(稱為異步任務(wù))來(lái)實(shí)現(xiàn)高效的并發(fā)處理。Rust的并發(fā)模型保證了線程安全和數(shù)據(jù)競(jìng)爭(zhēng)的檢查,以及高性能的任務(wù)調(diào)度和通信機(jī)制;
- 可靠性和可維護(hù)性:Rust強(qiáng)調(diào)代碼的可讀性、可維護(hù)性和可靠性。它鼓勵(lì)使用清晰的命名和良好的代碼結(jié)構(gòu),以及提供豐富的工具和生態(tài)系統(tǒng)來(lái)支持代碼質(zhì)量和測(cè)試覆蓋率;
Rust的這些額外的特性使其成為改善桌面應(yīng)用程序性能和安全性的理想選擇。
二、技術(shù)調(diào)研
要實(shí)現(xiàn)Electron遷移到Tauri,得先分別了解Electron和Tauri的核心功能和架構(gòu)模型,只有了解了這些,才能對(duì)整體的遷移成本做一個(gè)把控。
Electron的核心模塊
基礎(chǔ)架構(gòu)
首先來(lái)看看Electron的基礎(chǔ)架構(gòu)模型:Electron繼承了來(lái)自Chromium的多進(jìn)程架構(gòu),Chromium始于其主進(jìn)程。從主進(jìn)程可以派生出渲染進(jìn)程。渲染進(jìn)程與瀏覽器窗口是一個(gè)意思。主進(jìn)程保存著對(duì)渲染進(jìn)程的引用,并且可以根據(jù)需要?jiǎng)?chuàng)建/刪除渲染器進(jìn)程。
圖片
每個(gè)Electron的應(yīng)用程序都有一個(gè)主入口文件,它所在的進(jìn)程被稱為 主進(jìn)程(Main Process)。而主進(jìn)程中創(chuàng)建的窗體都有自己運(yùn)行的進(jìn)程,稱為渲染進(jìn)程(Renderer Process)。每個(gè)Electron的應(yīng)用程序有且僅有一個(gè)主進(jìn)程,但可以有多個(gè)渲染進(jìn)程。
圖片
應(yīng)用構(gòu)建打包
打包一個(gè)Electron應(yīng)用程序簡(jiǎn)單來(lái)說(shuō)就是通過(guò)構(gòu)建工具創(chuàng)建一個(gè)桌面安裝程序(.dmg、.exe、.deb 等)。在Electron早期作為 Atom 編輯器的一部分時(shí),應(yīng)用程序開發(fā)者通常通過(guò)手動(dòng)編輯Electron二進(jìn)制文件來(lái)為應(yīng)用程序做分發(fā)準(zhǔn)備。隨著時(shí)間的推移,Electron社區(qū)構(gòu)建了豐富的工具生態(tài)系統(tǒng),用于處理Electron應(yīng)用程序的各種分發(fā)任務(wù),其中包括:
- 應(yīng)用程序打包https://github.com/electron/packager
- 代碼簽名,例如https://github.com/electron/osx-sign
- 創(chuàng)建特定平臺(tái)的安裝程序,例如https://github.com/electron/windows-installer或https://github.com/electron-userland/electron-installer-dmg
- 本地Node.js原生擴(kuò)展模塊重新構(gòu)建https://github.com/electron/rebuild
- 通用MacOS構(gòu)建https://github.com/electron/universal
這樣,應(yīng)用程序開發(fā)者在開發(fā)Electron應(yīng)用時(shí),為了構(gòu)建出跨平臺(tái)的桌面端應(yīng)用,不得不去了解每個(gè)包的功能并需要將這些功能進(jìn)行組合構(gòu)建,這對(duì)新手而言過(guò)于復(fù)雜,無(wú)疑是勸退的。
所以,基于以上背景,目前使用的比較多的是社區(qū)提供的Electron Builder(https://github.com/electron-userland/electron-builder)一體化打包解決方案。得物商家客服也是采用的上述方案。
應(yīng)用簽名&更新
現(xiàn)在絕大多數(shù)的應(yīng)用簽名都采用了簽名狗的應(yīng)用簽名方式,而我們的商家客服桌面端應(yīng)用也是類似,Electron Builder提供了一個(gè)sign的鉤子配置,可以幫助我們來(lái)實(shí)現(xiàn)對(duì)應(yīng)用代碼的簽名:
...
"win": {
"target": "nsis",
"sign": "./sign.js"
},
...
(詳細(xì)的可以直接閱讀electron builder官網(wǎng)介紹,這里只做簡(jiǎn)單說(shuō)明)
對(duì)于應(yīng)用更新而言,我們之前采用的是electron-updater自動(dòng)更新模式:
圖片
如果對(duì)這塊感興趣,可以閱讀我們之前的文章:https://juejin.cn/post/7195447709904404536?searchId=202408131832375B6C2C76DEEE740762EA
Tauri的核心模塊
基礎(chǔ)架構(gòu)
那么,Tauri的基礎(chǔ)架構(gòu)模型是什么樣的?其實(shí)官網(wǎng)對(duì)這塊的介紹比較有限,但是我們可以通過(guò)其源碼倉(cāng)庫(kù)和代碼結(jié)構(gòu)管中窺豹的了解Tauri的核心架構(gòu)模型,為了方便大家理解,我們以得物商家客服桌面端應(yīng)用為模型,簡(jiǎn)單的畫了一個(gè)草圖:
圖片
一些核心模塊的解釋:
WRY
由于Web技術(shù)具有表現(xiàn)力強(qiáng)和開發(fā)成本低的特點(diǎn),與 Electron 和NW等框架類似,Tauri應(yīng)用程序的前端實(shí)現(xiàn)是使用Web技術(shù)棧編寫的。那么Tauri是如何解決Electron/CEF等框架遇到的Chromium內(nèi)核體積過(guò)大的問(wèn)題呢?
也許你會(huì)想,如果每個(gè)應(yīng)用程序都需要打包瀏覽器內(nèi)核以實(shí)現(xiàn)Web頁(yè)面的渲染,那么只要所有應(yīng)用程序共享相同的內(nèi)核,這樣在分發(fā)應(yīng)用程序時(shí)就無(wú)需打包瀏覽器內(nèi)核,只需打包Web頁(yè)面資源。
WRY是Tauri的封裝Webview框架,它在不同的操作系統(tǒng)平臺(tái)上封裝了系統(tǒng)的Webview實(shí)現(xiàn):MacOS上使用WebKit.WKWebview,Windows上使用Webview2,Linux上使用WebKitGTK。這樣,在運(yùn)行Tauri應(yīng)用程序時(shí),直接使用系統(tǒng)的Webview來(lái)渲染應(yīng)用程序的前端展示。
跨平臺(tái)應(yīng)用窗口創(chuàng)建庫(kù),使用Rust編寫,支持Windows、MacOS、Linux、iOS和Android等所有主要平臺(tái)。該庫(kù)是winit的一個(gè)分支,Tauri根據(jù)自己的需求進(jìn)行了擴(kuò)展,如菜單欄和系統(tǒng)托盤功能。
JS API
這個(gè)API是一個(gè)JS庫(kù),提供調(diào)用Tauri Rust后端的一些API能力,利用這個(gè)庫(kù)可以很方便的完成和Tauri Rust后端的交互以及通信。
看起來(lái)有點(diǎn)復(fù)雜,其實(shí)核心也是分成了主進(jìn)程和渲染進(jìn)程兩個(gè)部分。
- Tauri的主進(jìn)程使用Rust編寫,Tauri在主進(jìn)程中提供了一些常用的Rust API比如窗口創(chuàng)建、消息提醒... 如果我們覺(jué)得主進(jìn)程提供的API不夠,那么我們可以通過(guò)Tauri的插件體系自行擴(kuò)展。
- Tauri的渲染進(jìn)程則是運(yùn)行在操作系統(tǒng)的Webview當(dāng)中的,我們可以直接通過(guò)JS + HTML + CSS來(lái)編寫,同時(shí),Tauri會(huì)為渲染進(jìn)程注入一些全局的JS API函數(shù)。比如fs、path、shell等等。
這是將所有組件拼到一起的crate。它將運(yùn)行時(shí)、宏、實(shí)用程序和API集成為一款最終產(chǎn)品
應(yīng)用構(gòu)建打包
Tauri提供了一個(gè)CLI工具:https://v1.tauri.app/zh-cn/v1/api/cli/,通過(guò)這個(gè)CLI工具的一個(gè)命令,我們可以直接將應(yīng)用程序打包成目標(biāo)產(chǎn)物:
yarn tauri build
此命令會(huì)將渲染進(jìn)程的Web資源 與 主進(jìn)程的Rust代碼一起嵌入到一個(gè)單獨(dú)的二進(jìn)制文件中。二進(jìn)制文件本身將位于src-tauri/target/release/[應(yīng)用程序名稱],而安裝程序?qū)⑽挥趕rc-tauri/target/release/bundle/。
第一次運(yùn)行此命令需要一些時(shí)間來(lái)收集Rust包并構(gòu)建所有內(nèi)容,但在隨后的運(yùn)行中,它只需要重新構(gòu)建您的應(yīng)用程序代碼,速度要快得多。
應(yīng)用簽名&更新
Tauri的簽名和Electron類似,如果需要自定義簽名鉤子方法,在Tauri中現(xiàn)在也是支持的:
{
"signCommand": "signtool.exe --host xxxx %1"
}
后面我們會(huì)詳細(xì)介紹該能力的使用方式。
而對(duì)于更新而言,Tauri則有自己的一套體系:Updater | Tauri Apps這里還是和Electron有著一定的區(qū)別。
選型總結(jié)
通過(guò)上面的架構(gòu)模型對(duì)比,我們可以很直觀的感受到如果要將我們的Electron應(yīng)用遷移到Tauri上,整體的遷移改造工作可以總結(jié)成以下圖所示:
圖片
核心內(nèi)容就變成了以下四部分內(nèi)容:
- 主進(jìn)程的遷移:而對(duì)于商家客服來(lái)說(shuō),目前主要用的有:自定義窗口autoUpdater自動(dòng)更新BrowserWindow窗口創(chuàng)建Notification消息通知Tray系統(tǒng)托盤IPC通信
而這些API在Tauri中都有對(duì)應(yīng)的實(shí)現(xiàn),所以整體來(lái)看,遷移成本和技術(shù)可行性都是可控的。
- 渲染進(jìn)程的遷移:渲染進(jìn)程改造相對(duì)而言就少很多了,因?yàn)門auri和Electron都可以直接使用前端框架來(lái)編寫渲染層代碼,所以幾乎可以將之前的前端代碼直接平移過(guò)來(lái)。但是還是有一些小細(xì)節(jié)需要注意,比如IPC通信、JS API的改變、兼容性... 這部分后面也會(huì)詳細(xì)介紹。
- 應(yīng)用構(gòu)建打包:從之前的Electron構(gòu)建模式改成Tauri構(gòu)建模式,并自動(dòng)化整個(gè)構(gòu)建流程和鏈路。
- 應(yīng)用簽名&更新:簽名形式不用改,主要需要調(diào)整簽名的配置,實(shí)現(xiàn)對(duì)Tauri應(yīng)用的自動(dòng)簽名和自動(dòng)更新能力。
最終,我們選擇了Tauri對(duì)現(xiàn)有的商家客服桌面端進(jìn)行架構(gòu)優(yōu)化升級(jí)。
三、技術(shù)實(shí)現(xiàn)
渲染進(jìn)程代碼遷移
目錄結(jié)構(gòu)調(diào)整
在聊如何調(diào)整Tauri目錄結(jié)構(gòu)之前,我們需要先來(lái)了解一下之前的Electron應(yīng)用目錄結(jié)構(gòu)設(shè)置,一個(gè)最簡(jiǎn)單的Electron應(yīng)用的目錄結(jié)構(gòu)大致如下:
.
├── index.html
├── main.js
├── renderer.js
├── preload.js
└── package.json
其中文件說(shuō)明如下:
- index.html:渲染進(jìn)程的入口HTML文件。
- renderer.js:渲染進(jìn)程的入口JS文件。
- main.js:主進(jìn)程入口文件
- preload.js:預(yù)加載腳本文件
- package.json:包的描述信息,依賴信息
有的時(shí)候你可能需要?jiǎng)澐帜夸泚?lái)編寫不同功能的代碼,但是,不管功能目錄怎么改,最終的渲染進(jìn)程和主進(jìn)程的構(gòu)建產(chǎn)物都是期望符合類似于上面的結(jié)構(gòu)。
圖片
所以,之前得物的商家客服也是類似形式的目錄結(jié)構(gòu):
.
├── app // 主進(jìn)程代碼目錄
├── renderer-process // 渲染進(jìn)程代碼目錄
├── ... // 一些其他配置文件,vite 構(gòu)建文件等等
└── package.json
對(duì)于Tauri來(lái)說(shuō),Tauri打包依托于兩個(gè)部分,首先是對(duì)前端頁(yè)面的構(gòu)建,這塊可以根據(jù)業(yè)務(wù)需要和框架選擇(Vue、 React)進(jìn)行構(gòu)建腳本的執(zhí)行。一般前端構(gòu)建的產(chǎn)物都是一個(gè)dist文件包。
然后是Tauri后端程序部分的構(gòu)建,這塊主要是對(duì)Rust代碼進(jìn)行編譯成binary crate。
(Tauri后端的編譯在很大程度上依賴于操作系統(tǒng)原生庫(kù)和工具鏈,因此當(dāng)前無(wú)法進(jìn)行有意義的交叉編譯。所以,在本地編譯我們通常需要準(zhǔn)備一臺(tái)mac和一臺(tái)Windows電腦,以滿足在這兩個(gè)平臺(tái)上的構(gòu)建。)
整體來(lái)看,和Electron是差不多的,這里,我們就直接使用了官方提供的create-tauri-app(https://github.com/tauri-apps/create-tauri-app)腳手架來(lái)創(chuàng)建項(xiàng)目,其目錄結(jié)構(gòu)大致如下:
.
├── src // 渲染進(jìn)程代碼
├── src-tauri // Rust 后端代碼
├── ... // 一些其他配置文件,vite 構(gòu)建文件等等
└── package.json
所以,這里對(duì)渲染進(jìn)程的目錄調(diào)整就很清晰了,直接將我們之前Electron中的renderer-process目錄中的代碼遷移到src目錄中即可。
注意:因?yàn)槲覀儗?duì)渲染進(jìn)程目錄進(jìn)行了調(diào)整,所以對(duì)應(yīng)的打包工具的目錄也需要進(jìn)行調(diào)整。
跨域請(qǐng)求處理
商家客服中會(huì)有一些接口請(qǐng)求,這些接口請(qǐng)求有的是從業(yè)務(wù)中發(fā)起的,有的使用依賴的npm庫(kù)中發(fā)起的請(qǐng)求。但因?yàn)槭强蛻舳艘?,?dāng)從客戶端環(huán)境發(fā)起請(qǐng)求時(shí),請(qǐng)求所攜帶的origin是這樣的:
https://tauri.localhost
那么,就會(huì)遇到一個(gè)我們熟知的一個(gè)前端跨域問(wèn)題。這會(huì)導(dǎo)致如果不在access-ctron-allow-origin中的域名會(huì)被block掉。
圖片
如果有小伙伴對(duì)Electron比較熟悉,可能會(huì)知道在Electron實(shí)現(xiàn)跨域的方案之一是可以關(guān)閉瀏覽器的跨域安全檢測(cè):
const mainWindow = new BrowserWindow({
webPreferences: {
webSecurity: false
}
})
或者在請(qǐng)求返回給瀏覽器之前進(jìn)行攔截,手動(dòng)修改access-ctron-allow-origin讓其支持跨域:
mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
// 通過(guò)請(qǐng)求源校驗(yàn)
'Access-Control-Allow-Origin': ['*'],
...details.responseHeaders,
},
});
});
}
達(dá)到的效果就像這樣:
圖片
那么Tauri中可以這么做嗎?答案是不行的!
雖然Tauri雖然和Electron進(jìn)程模型很類似,但是本質(zhì)上還是有區(qū)別的,最大的區(qū)別就是Electron中的渲染進(jìn)程是基于Chromium魔改的,他可以在Chromium中植入一些控制器來(lái)修改Chromium的一些默認(rèn)行為。但Tauri完全是基于不同平臺(tái)的內(nèi)置Webview封裝,考慮的兼容性問(wèn)題,并沒(méi)有對(duì)Webview進(jìn)行改造(雖然Windows的Webview2支持 --disable-web-security,但是其他平臺(tái)不行)。所以他的跨域策略是Webview默認(rèn)的行為,無(wú)法調(diào)整。
那么在Tauri中,如何發(fā)起一個(gè)跨域請(qǐng)求了?
其實(shí)社區(qū)也有幾種解決方案,接下來(lái)簡(jiǎn)單介紹一下社區(qū)的方案和問(wèn)題。
使用Tauri官方的http
既然瀏覽器會(huì)因?yàn)榭缬騿?wèn)題block掉請(qǐng)求,那么就繞過(guò)瀏覽器唄,沒(méi)錯(cuò),這也是Tauri官方提供的http模塊設(shè)計(jì)的初衷和原理:https://v1.tauri.app/zh-cn/v1/api/js/http/,其設(shè)計(jì)方案就是通過(guò)JavaScript前端調(diào)用Rust后端來(lái)發(fā)請(qǐng)求,當(dāng)請(qǐng)求完成后再返回給前端結(jié)果。
圖片
問(wèn)題:Tauri http有一套自己的API設(shè)計(jì)和請(qǐng)求規(guī)范,我們必須按照他定義的格式進(jìn)行請(qǐng)求的發(fā)送和接收。對(duì)于新項(xiàng)目來(lái)說(shuō)問(wèn)題不是很大,但對(duì)商家客服來(lái)說(shuō),這樣最大的問(wèn)題是之前的所有的接口請(qǐng)求都得改造成Tauri http的格式,我們很多請(qǐng)求是基于Axios的封裝,改造成本非常大,回歸驗(yàn)證也很困難,而且有很多三方npm包也依賴axios發(fā)請(qǐng)求,這就又增加了改造的成本和后期維護(hù)的成本。
使用axios adapter
既然使用axios改造成本大,那么就寫一個(gè)axios的適配器(adapter)在數(shù)據(jù)請(qǐng)求的時(shí)候不使用瀏覽器原生的xhr發(fā)請(qǐng)求而是使用tauri http來(lái)發(fā)請(qǐng)求,順便對(duì)axios的請(qǐng)求參數(shù)進(jìn)行格式化,處理成Tauri http要求的那種各種。在請(qǐng)求響應(yīng)后也進(jìn)行類似的處理。
圖片
這種解決方案社區(qū)也有一個(gè)庫(kù)提供:https://github.com/persiliao/axios-tauri-api-adapter
問(wèn)題:假設(shè)項(xiàng)目中依賴一個(gè)npm庫(kù),這個(gè)庫(kù)中發(fā)起了一個(gè)axios請(qǐng)求,那么也需要對(duì)這個(gè)庫(kù)的axios進(jìn)行適配器改造。這樣還是解決不了三方依賴使用axios的問(wèn)題。我們還是需要侵入npm包進(jìn)行axios改造。另外,如果其他庫(kù)使用的是xhr或者fetch來(lái)直接發(fā)請(qǐng)求或者,那就又無(wú)解了。
最后,不管使用方案1還是2,都有個(gè)通病,那就是請(qǐng)求都是走的Tauri后端來(lái)發(fā)起的,這也意味著我們將在Webview的devtools中的network看不到任何請(qǐng)求的信息和響應(yīng)的結(jié)果,這對(duì)開發(fā)調(diào)試來(lái)說(shuō)無(wú)疑是非常難以接受的。
社區(qū)對(duì)這個(gè)問(wèn)題也有相關(guān)的咨詢:https://github.com/tauri-apps/tauri/issues/7882,但是官方回復(fù)也是實(shí)現(xiàn)不了:
圖片
那我們是怎么做的呢?對(duì)于Axios來(lái)說(shuō),其在瀏覽器端工作的原理是通過(guò)實(shí)例化window.XMLHttpRequest 后的xhr來(lái)發(fā)起請(qǐng)求,同時(shí)監(jiān)聽(tīng)xhr的onreadystatechange事件來(lái)處理請(qǐng)求的響應(yīng)。然后對(duì)于一些請(qǐng)求頭都是通過(guò)xhr.setRequestHeader這樣的方式設(shè)置到了xhr對(duì)象上。因此,對(duì)于axios、原生XmlHttpRequest請(qǐng)求來(lái)說(shuō),我們就可以重寫XmlHttpRequest中的send、onreadystatechange、setRequestHeader等方法,讓其通過(guò)Tauri的http來(lái)發(fā)請(qǐng)求。
但是對(duì)window.fetch這樣底層未使用XHR的請(qǐng)求來(lái)說(shuō),我們就需要重寫window.fetch。讓其在調(diào)用window.fetch的時(shí)候,調(diào)用xhr.send來(lái)發(fā)請(qǐng)求,這樣便實(shí)現(xiàn)了變相調(diào)用Tauri http的功能。
核心代碼:
class AdapterXMLHTTP extends EventTarget{
// ...
// 重寫 send 方法
async send(data: unknown) {
// 通過(guò) TauriFetch 來(lái)發(fā)請(qǐng)求
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 來(lái)發(fā)請(qǐng)求
const xhr = new XMLHttpRequst()
})
}
// 重寫 window.XMLHttpRequest
window.XMLHttpRequest = AdapterXMLHTTP;
// 重寫 window.featch
window.fetch = fetchPollify;
那怎么解決devtools沒(méi)法調(diào)試請(qǐng)求的問(wèn)題呢?
為了讓請(qǐng)求日志能出現(xiàn)在瀏覽器的webview devtools network中,我們可能需要開發(fā)一個(gè)類似于chrome plugin的方式來(lái)支持。但是很可惜,在Tauri中,webview是不支持插件開發(fā)的:https://github.com/tauri-apps/tauri/discussions/2685
所以我們只能采用新的方式來(lái)支持,那就是外接devtools。啥意思呢?就是在操作系統(tǒng)網(wǎng)絡(luò)層代理掉網(wǎng)絡(luò)請(qǐng)求,然后輸出到另一個(gè)控制臺(tái)中進(jìn)行展示,原理類似于Charles。
到這里,我們就完成了對(duì)跨域網(wǎng)絡(luò)請(qǐng)求的處理改造工作。核心架構(gòu)圖如下:
關(guān)鍵性API兼容
這里需要注意的是,Tauri使用的是系統(tǒng)自帶的Webview,而Electron則是直接內(nèi)置了Chromium,這里有個(gè)非常大的誤區(qū)在于想當(dāng)然的把Webview類比Chromium以為瀏覽器的API都可以直接使用。這其實(shí)是不對(duì)的,舉個(gè)例子:我們?cè)诎l(fā)送一些消息通知的時(shí)候,可能會(huì)使用HTML5的 Notification Web API:https://developer.mozilla.org/en-US/docs/Web/API/Notification
但是,這個(gè)API是瀏覽器自行實(shí)現(xiàn)的,也就是說(shuō),你在 Electron 中可以這么用,但是,如果你在Tauri中,你會(huì)發(fā)現(xiàn)一個(gè)bug:https://github.com/tauri-apps/tauri/issues/3698,這個(gè)bug的大概含義就是Tauri中的Notification不會(huì)觸發(fā)click點(diǎn)擊事件。這個(gè)bug至今還未解決。究其原因:
Tauri依賴的操作系統(tǒng)webview并沒(méi)有實(shí)現(xiàn)對(duì)Notification 的支持,webview本身希望宿主應(yīng)用自行實(shí)現(xiàn)對(duì)Notification的實(shí)現(xiàn),所以Tauri就重寫了JS的Notification API,當(dāng)你在調(diào)用window Notification的時(shí)候,實(shí)際上你和Rust進(jìn)程完成了一次通信,調(diào)用的還是tauri::Notification模塊。
在Tauri源碼里面,是這樣實(shí)現(xiàn)的:
// 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 后端通信,調(diào)用 Rust 發(fā)送系統(tǒng)通知
return window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Notification',
message: {
cmd: 'notification',
options:
typeof options === 'string'
? {
title: options
}
: options
}
})
}
// 這里便是對(duì) Notification 的重寫實(shí)現(xiàn)
window.Notification = function (title, options) {
const opts = options || {}
sendNotification(
Object.assign(opts, {
title: title
})
)
}
除此之外,Tauri還分別實(shí)現(xiàn)了:
- DOM上標(biāo)簽的點(diǎn)擊跳轉(zhuǎn)功能,使用內(nèi)置的Tauri API進(jìn)行打開webview。
- 差異化操作系統(tǒng)原生窗口的拖拽和最大化事件:在Windows和Linux上,當(dāng)鼠標(biāo)按下時(shí)拖動(dòng),雙擊時(shí)最大化;而在MacOS上,最大化應(yīng)該在鼠標(biāo)抬起時(shí)發(fā)生,如果雙擊后鼠標(biāo)移動(dòng),應(yīng)該取消最大化。
- window.alert
- window.confirm
- window.print(Macos)
所以,我們?cè)趯?duì)商家客服從Electron遷移到Tauri的過(guò)程中,還需要對(duì)這些關(guān)鍵性API進(jìn)行兼容性測(cè)試和回歸。一旦發(fā)現(xiàn)相關(guān)API不符合預(yù)期,我們需要及時(shí)調(diào)整業(yè)務(wù)策略或者給嘗試進(jìn)行hack。
(這里賣個(gè)關(guān)子,雖然Tauri不支持對(duì)Notification的點(diǎn)擊事件回調(diào),那么我們是怎么讓他支持的呢?在下一節(jié)主進(jìn)程代碼遷移中我們會(huì)詳細(xì)介紹。)
兼容性回歸
對(duì)于樣式兼容性來(lái)說(shuō),因?yàn)镋lectron在不同操作系統(tǒng)內(nèi)都集成了Chromium所以我們完全不用擔(dān)心樣式兼容性的問(wèn)題。但是對(duì)于Tauri來(lái)說(shuō),因?yàn)椴煌僮飨到y(tǒng)使用了不同的Webview,所以在樣式上,我們還是需要注意不同操作系統(tǒng)下的差異性,比如:以下分別是Linux和Windows渲染Element-Plus的界面:
圖片
圖片
可以看到在按鈕大小、文字對(duì)齊等樣式上面還是存在著不小的差距。
除了上述問(wèn)題,如果你需要兼容Linux系統(tǒng),那么還有webkitgtk在非整數(shù)倍縮放下的bug,應(yīng)該是陳年老問(wèn)題了。當(dāng)然,這些問(wèn)題都是上游webkitgtk的“鍋”。
所以,社區(qū)也有關(guān)于討論Tauri是否有可能在不同平臺(tái)上使用同一個(gè)webview的可能性的討論:https://github.com/tauri-apps/tauri/discussions/4591。官方是期待能有Mac版本的Webview發(fā)布,不過(guò)大概率來(lái)看不太現(xiàn)實(shí),一方面是因?yàn)椋何④洓Q定不開源 Webview2的Mac和Linux版本(https://mp.weixin.qq.com/s/p6pdNI3_di7oBkv4ugDIdA),另一方面是如果要使用統(tǒng)一的webview那就又回到了Electron。
除了樣式兼容性外,對(duì)于JS代碼的兼容性也需要留意Tauri在Windows上使用的是Webview2而Webview2本身就是基于Chromium的,所以代碼兼容性倒還好,但是在MacOS 上使用的就是WebKit.WKWebview,Safari就是基于他,所以到這里,我想你也明白了,這就又回到了前端處理不同瀏覽器兼容性的問(wèn)題上來(lái)了。所以這里溫馨提示一下:構(gòu)建時(shí)前端代碼需要進(jìn)行polyfill。
對(duì)于Electron應(yīng)用的用戶來(lái)說(shuō),可能沒(méi)有這樣的煩惱,最新的API只要Chrome支持,那就可以用。
主進(jìn)程代碼遷移
自定義操作欄窗口
默認(rèn)情況,在構(gòu)建窗口的時(shí)候,會(huì)使用系統(tǒng)自帶的原生窗口樣式,比如在MacOS下的樣式:
在有些情況下,操作系統(tǒng)的原生窗口并不能符合我們的一些視覺(jué)和交互需求。所以,在創(chuàng)建桌面應(yīng)用的時(shí)候,有時(shí)候我們希望能完全掌控窗口的樣式,而隱藏掉系統(tǒng)提供的窗口邊框和標(biāo)題欄等。這個(gè)時(shí)候就需要用到自定義操作欄窗口。比如在Windows中,我們希望在右上角有一排自定義的操作欄,就像是這樣:
商家客服桌面端的窗口就是一個(gè)無(wú)邊框的自定義操作欄的窗口,在Electron中,我們可以這樣操作快速創(chuàng)建一個(gè)無(wú)邊框窗口:
const { BrowserWindow } = require('electron')
const win = new BrowserWindow({ frame: false })
然后在渲染進(jìn)程中,自己 “畫一個(gè)標(biāo)題欄”:
<div class="handle-container">
<div class="minimize" @click="minimize"></div>
<div class="maximize" @click="maximize"></div>
<div class="close" @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中,要實(shí)現(xiàn)自定窗口首先需要在窗口創(chuàng)建的時(shí)候設(shè)置decoration無(wú)裝飾樣式,比如這樣:(也可以在tauri.config.json中設(shè)置,道理是一樣的)
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類似,自己畫一個(gè)控制欄,詳細(xì)的代碼可以參考這里:https://v1.tauri.app/v1/guides/features/window-customization/
<div data-tauri-drag-region class="titlebar">
<div class="titlebar-button" id="titlebar-minimize">
<img
src="https://api.iconify.design/mdi:window-minimize.svg"
alt="minimize"
/>
</div>
<div class="titlebar-button" id="titlebar-maximize">
<img
src="https://api.iconify.design/mdi:window-maximize.svg"
alt="maximize"
/>
</div>
<div class="titlebar-button" id="titlebar-close">
<img src="https://api.iconify.design/mdi:close.svg" alt="close" />
</div>
</div>
單例模式
通過(guò)使用窗口單例模式,可以確保應(yīng)用程序在用戶嘗試多次打開時(shí)只會(huì)有一個(gè)主窗口實(shí)例,從而提高用戶體驗(yàn)并避免不必要的資源占用。在Electron中可以很容易做到這一點(diǎn):
app.on('second-instance', (event, commandLine, workingDirectory) => {
// 當(dāng)運(yùn)行第二個(gè)實(shí)例時(shí),將會(huì)聚焦到myWindow這個(gè)窗口
if (myWindow) {
mainWindow.show()
if (myWindow.isMinimized()) myWindow.restore()
myWindow.focus()
}
})
但是,在Tauri中,我需要引入一個(gè)單例插件才可以:
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這個(gè)Crate中的CreateMutexW API來(lái)創(chuàng)建一個(gè)互斥體,確保只有一個(gè)實(shí)例可以運(yùn)行,并在用戶嘗試啟動(dòng)多個(gè)實(shí)例時(shí),聚焦于已經(jīng)存在的實(shí)例并傳遞數(shù)據(jù),簡(jiǎn)化后的代碼大致如下:
pub fn init<R: Runtime>(f: Box<SingleInstanceCallback<R>>) -> TauriPlugin<R> {
plugin::Builder::new("single-instance")
.setup(|app| {
// ...
// 創(chuàng)建互斥體
let hmutex = unsafe {
CreateMutexW(std::ptr::null(), true.into(), mutex_name.as_ptr())
};
// 如果 GetLastError 返回 ERROR_ALREADY_EXISTS,則表示已有實(shí)例在運(yùn)行。
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
unsafe {
// 找到已存在窗口的句柄
let hwnd = FindWindowW(class_name.as_ptr(), window_name.as_ptr());
if hwnd != 0 {
// ...
// 通過(guò) SendMessageW 發(fā)送數(shù)據(jù)給該窗口
SendMessageW(hwnd, WM_COPYDATA, 0, &cds as *const _ as _);
// 最后退出當(dāng)前應(yīng)用
app.exit(0);
}
}
}
// ...
Ok(())
})
.build()
}
(注意:這里有坑,如果你的應(yīng)用需要實(shí)現(xiàn)一個(gè)重新啟動(dòng)功能,那么在單例模式下將不會(huì)生效,核心原因是因?yàn)閼?yīng)用重啟的邏輯是先打開一個(gè)新的實(shí)例再關(guān)閉舊的運(yùn)行實(shí)例。而打開新的實(shí)例在單例模式下就被阻止了,這塊的詳細(xì)原因和解決方案我們已經(jīng)給Tauri提了PR:https://github.com/tauri-apps/tauri/pull/11684)
系統(tǒng)消息通知能力
消息通知是商家客服桌面端應(yīng)用必不可少的能力,消息通知能力一般可以分為以下兩種:
- 觸達(dá)操作系統(tǒng)的消息通知
- 用戶點(diǎn)擊消息后的回調(diào)事件
前面我們有提到,在Electron中,我們需要顯示來(lái)自渲染進(jìn)程的通知,那么可以直接使用HTML5的Web API來(lái)發(fā)送一條系統(tǒng)消息通知:
function notifyMe() {
if (!("Notification" in window)) {
// 檢查瀏覽器是否支持通知
alert("當(dāng)前瀏覽器不支持桌面通知");
} else if (Notification.permission === "granted") {
// 檢查是否已授予通知權(quán)限;如果是的話,創(chuàng)建一個(gè)通知
const notification = new Notification("你好!");
// …
} else if (Notification.permission !== "denied") {
// 我們需要征求用戶的許可
Notification.requestPermission().then((permission) => {
// 如果用戶接受,我們就創(chuàng)建一個(gè)通知
if (permission === "granted") {
const notification = new Notification("你好!");
// …
}
});
}
// 最后,如果用戶拒絕了通知,并且你想尊重用戶的選擇,則無(wú)需再打擾他們
}
如果我們需要為消息通知添加點(diǎn)擊回調(diào)事件,那么我們可以這么寫:
notification.onclick = (event) => {};
當(dāng)然,Electron也提供了主進(jìn)程使用的API,更多的能力可以直接參考Electron的官方文檔:https://www.electronjs.org/zh/docs/latest/api/%E9%80%9A%E7%9F%A5。
然而,對(duì)于Tauri來(lái)說(shuō),只實(shí)現(xiàn)了第1個(gè)能力,也就是消息觸達(dá)。Tauri本身不支持點(diǎn)擊回調(diào)的功能,這就導(dǎo)致了用戶發(fā)來(lái)了一個(gè)消息,但是業(yè)務(wù)無(wú)法感知客服點(diǎn)擊消息的事件。而且原生的Web API也是Tauri自己寫的,原理還是調(diào)用了Rust的通知能力。接下來(lái),我也會(huì)詳細(xì)介紹一下我們是如何擴(kuò)展消息點(diǎn)擊回調(diào)能力的。
Tauri在Rust層,我們可以通過(guò)下面這段代碼來(lái)調(diào)用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 可以調(diào)用
Notification::new(&app.config().tauri.bundle.identifier)
.title("New message")
.body("You've got a new message.")
.show();
// 兼容 win7 的調(diào)用形式
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實(shí)現(xiàn)源碼位置在:https://github.com/tauri-apps/tauri/blob/1.x/core/tauri/src/api/notification.rs這個(gè)文件中,其中看一下show函數(shù)的實(shí)現(xiàn):
pub fn show(self) -> crate::api::Result<()> {
#[cfg(feature = "dox")]
return Ok(());
#[cfg(not(feature = "dox"))]
{
// 使用 notify_rust 構(gòu)造 notification 實(shí)例
let mut notification = notify_rust::Notification::new();
// 設(shè)置消息通知的 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函數(shù)非win7環(huán)境下show函數(shù)調(diào)用的是notify_rust這個(gè)庫(kù),而在win7環(huán)境下調(diào)用的是win7_notifications這個(gè)庫(kù)。而notify_rust這個(gè)庫(kù),本身確實(shí)未完成實(shí)現(xiàn)對(duì)MacOS和Windows點(diǎn)擊回調(diào)事件。
所以我們需要自定義一個(gè)Notification的Tauri插件,實(shí)現(xiàn)對(duì)點(diǎn)擊回調(diào)的能力。(因?yàn)槠颍@里只介紹一些核心的實(shí)現(xiàn)邏輯)
MacOS 支持消息點(diǎn)擊回調(diào)能力
notify_rust在Mac上實(shí)現(xiàn)消息通知是基于Mac_notification_sys這個(gè)庫(kù)的,這個(gè)庫(kù)本身是支持對(duì)點(diǎn)擊action的response,只是notify_rust沒(méi)有處理而已,所以我們可以為notify_rust增加對(duì)Mac上點(diǎn)擊回調(diào)的處理能力:
#[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,
};
// 發(fā)通過(guò) 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()
{
// 響應(yīng)點(diǎn)擊事件,回調(diào)前端的 handle 函數(shù)
Ok(response) => match response {
NotificationResponse::ActionButton(id) => {
if action_name.eq(&id) {
let js = tauri::api::ipc::format_callback(handle, &id)
.expect("點(diǎn)擊 action 報(bào)錯(cuò)");
window_.eval(js.as_str());
};
}
NotificationResponse::Click => {
let data = &sid;
let js = tauri::api::ipc::format_callback(handle, &data)
.expect("消息點(diǎn)擊報(bào)錯(cuò)");
window_.eval(js.as_str());
}
_ => {}
},
Err(err) => println!("Error handling notification {}", err),
}
}
Win 10上支持消息點(diǎn)擊回調(diào)能力
在Windows 10操作系統(tǒng)中,notify_rust則是通過(guò)winrt_notification這個(gè)Crate來(lái)發(fā)送消息通知,winrt_notification 則是調(diào)用的windows這個(gè)crate來(lái)實(shí)現(xiàn)消息通知,windows這個(gè)crate的官方描述是:為Rust開發(fā)人員提供了一種自然和習(xí)慣的方式來(lái)調(diào)用Windows API。這里,主要會(huì)用到以下幾個(gè)方法:
- windows::UI::Notifications::ToastNotification::CreateToastNotification:這個(gè)函數(shù)的作用是根據(jù)指定的參數(shù)創(chuàng)建一個(gè)Toast通知對(duì)象,可以設(shè)置通知的標(biāo)題、文本內(nèi)容、圖標(biāo)、音頻等屬性,并可以指定通知被點(diǎn)擊時(shí)的響應(yīng)行為。通過(guò)調(diào)用這個(gè)函數(shù),可以在Windows應(yīng)用程序中創(chuàng)建并顯示自定義的Toast通知,向用戶展示相關(guān)信息。
- windows::Data::Xml::Dom::XmlDocument:這是一個(gè)用于在Windows應(yīng)用程序中創(chuàng)建和處理XML文檔的類。它主要提供了一種方便的方式來(lái)創(chuàng)建、解析和操作XML數(shù)據(jù)。
- windows::UI::Notifications::ToastNotificationManager::CreateToastNotifierWithId:通過(guò)調(diào)用CreateToastNotifierWithId函數(shù),可以創(chuàng)建一個(gè)Toast通知管理器對(duì)象,并指定一個(gè)唯一的標(biāo)識(shí)符。這個(gè)標(biāo)識(shí)符通常用于標(biāo)識(shí)應(yīng)用程序或者特定的通知渠道,以確保通知的正確分發(fā)和管理。創(chuàng)建了Toast通知管理器之后,就可以使用它來(lái)生成和發(fā)送Toast通知,向用戶展示相關(guān)信息,并且可以根據(jù)標(biāo)識(shí)符進(jìn)行個(gè)性化的通知管理。
- windows::Foundation::TypedEventHandler:這是Windows Runtime API中的一個(gè)委托(delegate)類型。在Windows Runtime中,委托類型用于表示事件處理程序,允許開發(fā)人員編寫事件處理邏輯并將其附加到特定的事件上。
所以,要想在> win7的操作系統(tǒng)中顯示消息同時(shí)的主要流程大致是:
- 通過(guò)XmlDocument來(lái)創(chuàng)建一個(gè)Xml消息通知模板。
- 然后將創(chuàng)建好的Xml消息模板作為CreateToastNotification的入?yún)?lái)創(chuàng)建一個(gè)toast通知。
- 最后調(diào)用CreateToastNotifierWithId來(lái)創(chuàng)建一個(gè)Toast通知管理器對(duì)象,創(chuàng)建成功后顯示toast。
- 通過(guò)TypedEventHandler監(jiān)聽(tīng)用戶點(diǎn)擊事件并完成回調(diào)觸發(fā)
但是winrt_notification這個(gè)庫(kù),只完成了1-3步驟,所以我們需要手動(dòng)實(shí)現(xiàn)步驟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();
// 設(shè)置消息持續(xù)狀態(tài),支持 short 和 long
// short 就是默認(rèn) 6s
// long 是常駐消息
let duration = match notification.timeout {
notify_rust::Timeout::Default => "duratinotallow=\"short\"",
notify_rust::Timeout::Never => "duratinotallow=\"long\"",
notify_rust::Timeout::Milliseconds(t) => {
if t >= 25000 {
"duratinotallow=\"long\""
} else {
"duratinotallow=\"short\""
}
}
};
// 創(chuàng)建消息模版 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;
};
// 根據(jù) xml 創(chuàng)建 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;
}
};
// 創(chuàng)建消息點(diǎn)擊監(jiān)聽(tīng)捕獲
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("消息點(diǎn)擊報(bào)錯(cuò)");
} else {
let data = &sid;
js = tauri::api::ipc::format_callback(handle, &data)
.expect("消息點(diǎn)擊報(bào)錯(cuò)");
}
let _ = window_.eval(js.as_str());
};
Ok(())
},
);
// 通過(guò)消息管理器發(fā)送消息
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上支持消息通知點(diǎn)擊回調(diào)能力
在Windows 7中,Tauri調(diào)用的是win7_notifications這個(gè)庫(kù),這個(gè)庫(kù)本身也沒(méi)有實(shí)現(xiàn)對(duì)消息點(diǎn)擊的回調(diào)處理,我們需要擴(kuò)展win7_notifications的能力來(lái)實(shí)現(xiàn)對(duì)消息通知的回調(diào)事件。我們希望這個(gè)庫(kù)可以這樣調(diào)用:
win7_notify::Notification::new()
.appname(&app_name)
.body(&body)
.summary(&title)
.timeout(duration)
.click_event(move |str| {
// 用戶自定義的參數(shù)
let data = &sid;
// 觸發(fā)前端的回調(diào)能力
let js = tauri::api::ipc::format_callback(handle, &data)
.expect("消息點(diǎn)擊報(bào)錯(cuò)");
let _ = window_.eval(js.as_str());
})
.show();
而我們要做的,就是為win7_notify這個(gè)庫(kù)中的Notification結(jié)構(gòu)體增加一個(gè)click_event函數(shù),這個(gè)函數(shù)支持傳入一個(gè)閉包,這個(gè)閉包在點(diǎn)擊消息通知的時(shí)候執(zhí)行。
pub struct Notification {
// ...
// 添加 click_event 屬性
pub click_event: Option<Arc<dyn Fn(&str) + Send>>,
}
impl Notification {
// ...
// 添加 click_event 事件注冊(cè)
pub fn click_event<F: Fn(&str) + Send + 'static>(&mut self, func: F) -> &mut Notification {
// 將事件綁定到 Notification 中
self.click_event = Some(Arc::new(func));
self
}
// 支持對(duì) click_event 的調(diào)用
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 {
// ....
// 增加對(duì)點(diǎn)擊事件的調(diào)用
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 增加點(diǎn)擊參數(shù)
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)
}
}
}
總結(jié):
圖片
- Tauri本身不支持Notification的點(diǎn)擊事件,需要自行實(shí)現(xiàn)。
- 需要對(duì)不同操作系統(tǒng)分別實(shí)現(xiàn)點(diǎn)擊回調(diào)能力。
- MacOS mac_notification_sys庫(kù)本來(lái)就有點(diǎn)擊回調(diào),只是Tauri沒(méi)有捕獲處理,需要自定義捕獲處理邏輯就好了。
- Windows > 7中,通過(guò)windows這個(gè)crate,來(lái)完成調(diào)用Windows操作系統(tǒng)API的能力,但是winrt_notification這個(gè)庫(kù)并沒(méi)有實(shí)現(xiàn)對(duì)Windows API回調(diào)點(diǎn)擊的捕獲處理,所以需要重寫winrt_notification這個(gè)庫(kù)。
- Windows 7中,消息通知其實(shí)是通過(guò)繪制窗口和監(jiān)聽(tīng)鼠標(biāo)點(diǎn)擊來(lái)觸發(fā)的,但是win7_notify本身也沒(méi)有支持用戶對(duì)點(diǎn)擊回調(diào)的捕獲,也需要擴(kuò)展這個(gè)庫(kù)的點(diǎn)擊捕獲能力。
應(yīng)用構(gòu)建打包
Windows 10
Tauri 1.3版本之前,應(yīng)用程序在Windows上使用的是WiX(Windows Installer)Toolset v3工具進(jìn)行構(gòu)建,構(gòu)建產(chǎn)物是Microsoft安裝程序(.msi文件)。1.3之后,使用的是NSIS來(lái)構(gòu)建應(yīng)用的xxx-setup.exe安裝包。
Tauri CLI默認(rèn)情況下使用當(dāng)前編譯機(jī)器的體系結(jié)構(gòu)來(lái)編譯可執(zhí)行文件。假設(shè)當(dāng)前是在64位計(jì)算機(jī)上開發(fā),CLI將生成64位應(yīng)用程序。如果需要支持32位計(jì)算機(jī),可以使用--target標(biāo)志使用不同的Rust目標(biāo)編譯應(yīng)用程序:
tauri build --target i686-pc-windows-msvc
為了支持不同架構(gòu)的編譯,需要為Rust添加對(duì)應(yīng)的環(huán)境支持,比如:
rustup target add i686-pc-windows-msvc
其次,需要為構(gòu)建增加不同的環(huán)境變量,以便為了在不同的環(huán)境進(jìn)行代碼測(cè)試,對(duì)應(yīng)到package.json中的構(gòu)建代碼:
{
"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參數(shù)指定了構(gòu)建的配置文件路徑,Tauri會(huì)和src-tauri中的tarui.conf.json文件進(jìn)行合并。除此之外,還可以通過(guò)tarui.{{platform}}.conf.json的形式指定不同平臺(tái)的獨(dú)特配置,優(yōu)先級(jí)關(guān)系:
-c path >> tarui.{{platform}}.conf.json >> tarui.conf.json
Windows 7
Webview 2
Tauri在Windows 7上運(yùn)行有兩個(gè)東西需要注意,一個(gè)是Tauri的前端跨平臺(tái)在Windows上依托于Webview2但是Windows 7中并不會(huì)內(nèi)置Webview2因此我們需要在構(gòu)建時(shí)指明引入Webview的方式:
圖片
綜合比較下來(lái),embedBootstrapper目前是比較好的方案,一方面可以減少安裝包體積,一方面減少不必要的靜態(tài)資源下載。
在Tauri中,會(huì)通過(guò)"Windows7-compat"來(lái)構(gòu)建一些Win7特有的環(huán)境代碼,比如:
#[cfg(feature = "windows7-compat")]
{
// todo
}
在Tauri文檔中也有相關(guān)介紹,主要是在使用Notification的時(shí)候,需要加入Windows7-compat特性。不過(guò),因?yàn)?Tauri 對(duì)Notification的點(diǎn)擊事件回調(diào)是不支持,所以我重寫了Tauri的所有Notification模塊,已經(jīng)內(nèi)置了Windows7-compat能力,因此可以不用設(shè)置了。
MacOS
MacOS操作系統(tǒng)也有M1和Intel的區(qū)分,所以為了可以構(gòu)建出兼容兩個(gè)版本的產(chǎn)物,我們需要使用universal-apple-darwin模式來(lái)編譯:
{ "scripts": { "tauri-build:t1": "tauri build -t universal-apple-darwin -c src-tauri/t1.json", "tauri-build:pre": "tauri build -t universal-apple-darwin -c src-tauri/pre.json", "tauri-build:prod": "tauri build -t universal-apple-darwin" }}br
應(yīng)用簽名&更新
應(yīng)用更新
對(duì)于Tauri來(lái)說(shuō),應(yīng)用更新的詳細(xì)配置步驟可以直接看官網(wǎng)的介紹:https://tauri.app/zh-cn/v1/guides/distribution/updater/。這里為了方便大家理解,簡(jiǎn)單畫了個(gè)更新流程圖:
圖片
核心流程如下:
- 對(duì)于需要更新的應(yīng)用,可以在渲染進(jìn)程通過(guò)JS調(diào)用 installUpdate() API
- Tauri內(nèi)部會(huì)發(fā)送一個(gè)更新協(xié)議事件:
pub const EVENT_INSTALL_UPDATE: &str = "tauri://update-install";
br
- Tauri主進(jìn)程Updater模塊會(huì)響應(yīng)這個(gè)事件,執(zhí)行download_and_install函數(shù)通過(guò)tauri.config.json中配置的endpoints來(lái)尋找下載地址下載endpoints服務(wù)器上的zip包內(nèi)容并解壓存儲(chǔ)到一個(gè)臨時(shí)文件夾,Windows中大概位置在C:\Users\admin\AppData\Local\Temp這里。然后通過(guò)PowerShell來(lái)執(zhí)行下載的setup.exe文件:["-NoProfile", "-WindowStyle", "Hidden", "Start-Process"],這些參數(shù)告訴PowerShell在后臺(tái)運(yùn)行,不顯示任何窗口,并啟動(dòng)一個(gè)新的進(jìn)程。
if found_path.extension() == Some(OsStr::new("exe")) {
// 創(chuàng)建一個(gè)新的 OsString,并將 found_path 包裹在引號(hào)中,以便在 PowerShell 中正確處理路徑
let mut installer_path = std::ffi::OsString::new();
installer_path.push("\"");
installer_path.push(&found_path);
installer_path.push("\"");
// 構(gòu)造安裝程序參數(shù)
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();
// 創(chuàng)建一個(gè)新的命令,指向 PowerShell 的路徑。
// 使用 Start-Process 命令來(lái)啟動(dòng)安裝程序,
// 并設(shè)置 -NoProfile 和 -WindowStyle Hidden 選項(xiàng),
// 以確保 PowerShell 不會(huì)加載用戶配置文件,并且窗口保持隱藏
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() 方法啟動(dòng)命令,如果失敗,則輸出錯(cuò)誤信息。
cmd
.spawn()
.expect("Running NSIS installer from powershell has failed to start");
exit(0);
}
- 在通過(guò)PowerShell啟動(dòng)應(yīng)用安裝程序的時(shí)候,就會(huì)使用到tauri.config.json中配置的updater.windows.installMode功能:"basicUi":指定安裝過(guò)程中包括最終對(duì)話框在內(nèi)的基本用戶界面,需要用戶手動(dòng)點(diǎn)擊下一步。"quiet":安靜模式表示無(wú)需用戶交互。如果安裝程序需要管理員權(quán)限(WiX),則需要管理員權(quán)限。"passive":會(huì)顯示一個(gè)只有安裝進(jìn)度條的UI,安裝過(guò)程用戶無(wú)需參與。
需要注意的是:如果以為更新是增量更新,不會(huì)卸載之前已經(jīng)安裝好的應(yīng)用程序只更新需要變更的部分。其實(shí)是不對(duì)的,整個(gè)安裝過(guò)程可以理解為Tauri在后臺(tái)幫你重新下載了一個(gè)最新的安裝包,然后幫你重新安裝了一下。
總結(jié):更新的核心原理就是通過(guò)使用Windows的PowerShell來(lái)對(duì)下載后的安裝包進(jìn)行open。然后由安裝包進(jìn)行安裝。
為什么我要花這么大的篇幅來(lái)介紹 Tauri 的更新原理呢?
這是因?yàn)槲覀冊(cè)诟碌倪^(guò)程中碰到了兩個(gè)比較大的問(wèn)題:
- 通過(guò)cmd調(diào)用PowerShell來(lái)安裝時(shí),會(huì)在安裝過(guò)程中出現(xiàn)一個(gè)藍(lán)色的PowerShell控制臺(tái)一閃而過(guò):
圖片
- 在部分開啟了病毒防護(hù)的Windows電腦上,使用PowerShell來(lái)執(zhí)行對(duì)安裝包的打開,會(huì)報(bào)錯(cuò):Permission Denied,導(dǎo)致安裝更新失?。篽ttps://github.com/rust-lang/rustlings/issues/604
這些都是因?yàn)門auri直接使用 Powershell的問(wèn)題,那需要怎么改呢?很簡(jiǎn)單,那就是使用Windows操作系統(tǒng)提供的ShellExecuteW來(lái)運(yùn)行安裝程序,核心代碼如下:
windows::Win32::UI::Shell::ShellExecuteW(
0,
operation.as_ptr(),
file.as_ptr(),
parameters.as_ptr(),
std::ptr::null(),
SW_SHOW,
)
但是這塊是Tauri的源碼,我們沒(méi)法直接修改,但這個(gè)問(wèn)題的解決方法我們已經(jīng)給Tauri提了PR并已合入到官方的1.6.8正式版本當(dāng)中:https://github.com/tauri-apps/tauri/pull/9818
所以,你要做的就是確保Tauri升級(jí)到v1.6.8及以后版本。
應(yīng)用簽名
Tauri應(yīng)用程序簽名可以分成2個(gè)部分,第一部分是應(yīng)用程序簽名,第二部分是安裝包程序簽名,官網(wǎng)上介紹的簽名方法需要配置tauri.config.json中如下字段:
"windows": {
// 簽名指紋
"certificateThumbprint": "xxx",
// 簽名算法
"digestAlgorithm": "sha256",
// 時(shí)間戳
"timestampUrl": "http://timestamp.comodoca.com"
}
如果你按照官方的步驟來(lái)進(jìn)行簽名:https://v1.tauri.app/zh-cn/v1/guides/distribution/sign-windows/,很快就會(huì)發(fā)現(xiàn)問(wèn)題所在:官網(wǎng)中簽名有一個(gè)重要的步驟就是導(dǎo)出一個(gè).pfx文件,但是現(xiàn)在業(yè)界簽名工具基本上都是采用簽名狗的方式進(jìn)行的,這是一個(gè)類似于U盾簽名工具,需要插入電腦中才可以進(jìn)行簽名,不支持直接導(dǎo)出.pfx格式的文件:
圖片
所以我們需要額外處理一下:
簽名狗支持導(dǎo)出一個(gè).cert證書,可以查看到證書的指紋:
圖片
這里證書的指紋對(duì)應(yīng)的就是certificateThumbprint字段。
然后需要插入我們?cè)诤灻麢C(jī)構(gòu)購(gòu)買的USB key。這樣,在構(gòu)建的時(shí)候,就會(huì)提示讓我們輸入密碼:
圖片
到這里就可以完成對(duì)應(yīng)用程序的簽名。
不過(guò)對(duì)于我們而言,USB key簽名狗是整個(gè)公司共享的,通常不在前端開發(fā)手里(尤其是異地辦公)。一種做法是在Tauri構(gòu)建的過(guò)程中,對(duì)于需要簽名的軟件提供一個(gè)signCommand命令鉤子,并為這個(gè)命令傳入文件的路徑,然后交由開發(fā)者對(duì)文件進(jìn)行自行簽名(比如上傳到擁有簽名工具的電腦,上傳上去后,遠(yuǎn)程進(jìn)行簽名,簽名完成再下載)。所以這就需要讓Tauri將簽名功能暴露出來(lái),讓我們自行進(jìn)行簽名,比如這樣:
{
"signCommand": "signtool.exe --host xxxx %1"
}
該命令中包含一個(gè)%1,它只是二進(jìn)制路徑的占位符,Tauri在構(gòu)建的時(shí)候會(huì)將需要簽名的文件路徑替換掉%1。
圖片
這個(gè)功能官網(wǎng)上還沒(méi)有更新相關(guān)的介紹,所以你可能看不到這塊的使用方式,因?yàn)橐彩俏覀冏罱峤坏腜R:https://github.com/tauri-apps/tauri/pull/9902。不過(guò)目前,這個(gè)PR已經(jīng)被合入Tauri的主版本中,你要做的就是就是升級(jí)Tauri到1.7.0升級(jí)@tauri-apps/cli到1.6.0。
四、收益&總結(jié)
經(jīng)過(guò)我們的不懈努力(不斷地填坑)到目前,得物商家客服Tauri版本終于如期上線,基于Tauri遷移帶來(lái)的收益如下:
整體性能測(cè)試相比之前的Electron應(yīng)用有比較明顯的提升:
- 包體積7M,Electron 80M下降91.25%。
- 平均內(nèi)存占用249M Electron 497M下降49.9%。
- 平均CPU占用百分比20%,Electron 63.5%下降 63.19%。
整體在性能體驗(yàn)上有一個(gè)非常顯著改善。但是,這里也暴露出使用Tauri的一些問(wèn)題。