0%

pywebview —— 桌面开发的一种新尝试

之前做数据处理的时候为了方便用 Python 做过一些基于 Windows 的 GUI 应用,当时仅仅是一个从无到有的过程,而且为了便于打包分发,干脆就用了自带的 Tkinter ,所以界面就有点原始了。然后就一直这么用着过了这么些年,发现给增加新菜单、新页面的时候感觉特别别扭,从页面布局到事件响应。鉴于之前也用过 wxPython , kivy , PyQt 之类的框架,使用的时候基本上不是常用的组件都要查阅文档,而且事件响应各有各的用法(比如 wxPython 需要 Bind 绑定, Qt 习惯使用 slot/emit/signals 等),太久没用复习成本有点高昂了。至于别的选择,比如流行的 electron 等,不好把 Python 的数据处理模块重新用 JS 实现一遍(数据处理还是 Python 香啊)。突然就想有没有一种 Python 的前后端分离的桌面开发方式,让我可以用编写 Web 网页前端,后端使用 Python 做处理,然后还能封装成桌面应用 —— 然后就发现了 pywebview 。

pywebview 是什么

根据官网的介绍介绍,pywebview 是一个轻量级的跨平台 GUI 框架(基于 BSD 协议开源),通过对 webview 组件包装来让 HTML 内容可以在平台对应的 GUI 上展示,可以把它想象成 Python 的 Electron ,不过它的体积要小得多。你可以用现代的 Web 开发技术来开发你的桌面应用,而无须关注原生平台的 GUI 细节。通过 pywebview ,可以选择你喜欢的 Python 轻量级 Web 框架如 Flask 或者 Bottle 来处理实际逻辑,由 pywebview 来负责 Python 和 DOM 之间的交互。

GUI 工具

对于呈现 HTML 内容的 Web 组件的构建,pywebview 使用的都是平台原生的 GUI 技术:比如 Windows 是基于 WinForm ,macOS 使用 Cocoa 而 Linux 使用 QT 或者 GTK —— 在打包应用的时候,pywebview 不会将任何 GUI 工具或者 Web 渲染器打包进去因此它的打包体积会很小!

Web 渲染器

Web engine 一节中列出了 pywebview 在各平台下使用的 web 渲染器,如下所示:

平台 代码 渲染器 提供者 浏览器兼容性
GTK(Linux) gtk WebKit WebKit2
macOS WebKit WebKit.WKWebView (系统捆绑自带)
QT(Linux) qt WebKit QtWebEngine / QtWebKit
Windows edgechromium Chromium .NET Framework 4.6.2 以上以及已安装 Edge Runtime 尚在维护中的 Chromium 均可
Windows edgehtml EdgeHTML .NET Framework 4.6.2 以上以及 Windows 10 build 17110
Windows mshtml MSHTML MSHTML via .NET / System.Windows.Forms.WebBrowser IE11 (Windows 10/8/7)
Windows cef CEF CEF Python Chrome 66

Windows 下有诸多可选的 Web render ,它的选择优先级是 edgechromium > edgehtml > mshtml 。(因为 WebView2 已经集成了 edgechromium ,可以理解的是直接使用的 WebView2 ) 。mshtml 实际上就是 Trident ,也就是一般理解的 IE 内核,他是所有 Windows 系统上都可以使用的 render 。当然你可以通过修改环境变量 PYWEBVIEW_GUI 或者传递参数给 webview.start(gui=code) 来改变 render 的选择。

pywebview example 分析

在官网的 Examples 页提供了几个零碎的 Example ,虽然但是也没有关系,我们可以结合起来一起看!
因为 pywebview 实际上提供了两种使用框架的方式:Serverless (无服务端)和 HTTP server (有服务端)的形式 —— 对于 Serverless 大部分交互只能依靠 pywebview 提供的原生 API 以及 JavaScript API ,虽然容易上手但是能力比较有限,对于小的项目可能比较适合。但是考虑到实际需要,我这边选择的还是 HTTP server 的方式,所以可以结合 React boilerplate with create-react-appFlask-based application 这两个 examples 来看,他们分别是前端和后端的各自的实现例子。

后端代码分析

我们先看后端的 example ,参考他的项目结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
project

├─gui
│ index.html

└─src
├─backend
│ app.py
│ main.py
│ server.py

└─frontend
README

大致上可以理解成,它将后端代码都放在 /src/backend 目录下而前端代码则是 src/frontend ,而前端工程构建出来的项目文件则是 gui ,因此除了前端的构建输出目录要自定义之外,server 里面也要配置相应的 HTML 文件路径地址:

1
2
3
4
5
6
7
8
9
10
# src/backend/server.py
...
gui_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'gui') # development path

if not os.path.exists(gui_dir): # frozen executable path
gui_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'gui')

server = Flask(__name__, static_folder=gui_dir, template_folder=gui_dir)
server.config['SEND_FILE_MAX_AGE_DEFAULT'] = 1 # disable caching
...

src/backend/server.py 剩下的代码里都是非常常规的 Flask 项目代码,而其中所有的接口都加了 @verify_token 装饰器来负责校验每个请求,主要是因为使用本地 web server 的时候可能会有 CSRF 攻击,而 pywebview 自己提供了 API 来生成会话唯一的 token : webview.token 以及 DOM 的引用 window.pywebview.token ,具体详见 Security 一节。

正式的 pywebview 运行代码在 src/main.py 里头:

1
2
3
4
5
6
7
8
# src/backend/main.py
...
if __name__ == '__main__':

stream = StringIO()
with redirect_stdout(stream):
window = webview.create_window('My first pywebview application', server)
webview.start(debug=True)

注意使用了 webview.start(debug=True) 开启了调试模式 —— 这个是为了调试 JavaScript 代码的,在不同的平台上会有不同的表现形式:在 macOS 、 GTK 或者 QT(仅限 QTWebEngine)上会启动 web inspector (审查元素),可以通过右键窗体选择 Inspect 打开;在 Window 上,针对 EdgeHTML ,需要先预装 Microsoft Edge DevTools Preview 工具,然后在通过工具找到我们应用运行的 WebView 进行外部调试,而 MSHTML 没有办法进行外部调试,所以要调试 JavaScript 的代码只能在窗体右键打开审查元素菜单(设置 debug 会允许你使用右键菜单)。

前端代码分析

前端代码看上去内容有点多,排除了 .spec 结尾的 pyinstaller 的描述文件和 py2app 的 build-macos.py 文件之后,剩下的其实是很常规的 create-react-app 自动生成的 React boilerplate 文件。

不过因为这个项目是 Serverless 模式运行的,所以你会发现有 src/index.py 文件中直接编写了 Python 逻辑代码,同时配置使用了 js_api 来让前端代码直接和 Python 逻辑代码交互 —— 因此我们可以把这个文件忽略。因为打包分发我们稍后会再分析,我们先简单的了解一下 package.json 的内容,尤其是 script 部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
...
"scripts": {
"dev": "react-scripts start",
"frontend:dev": "BUILD_PATH='./gui' react-scripts build",
"frontend:prod": "BUILD_PATH='./gui' react-scripts build",
"frontend:test": "react-scripts test",
"frontend:eject": "react-scripts eject",
"build": "yarn run clean && yarn run frontend:prod && run-script-os",
"build:macos": "./venv-pywebview/bin/python build-macos.py py2app",
"build:windows": ".\\venv-pywebview\\Scripts\\pyinstaller build-windows.spec",
"build:linux": "./venv-pywebview/bin/pyinstaller build-linux.spec --onefile",
"clean": "run-script-os",
"clean:default": "rm -rf gui 2>/dev/null; rm -rf build 2>/dev/null; rm -rf dist 2>/dev/null; ",
"clean:windows": "if exist gui rd /S /Q gui & if exist build rd /S /Q build & if exist dist rd /S /Q dist",
"init": "yarn install && run-script-os",
"init:windows": "virtualenv -p python venv-pywebview && .\\venv-pywebview\\Scripts\\pip install -r requirements.txt",
"init:linux": "virtualenv -p python3 venv-pywebview && if [[ -z \"${KDE_FULL_SESSION}\" ]]; then yarn run init:qt5; else yarn run init:gtk; fi",
"init:default": "virtualenv -p python3 venv-pywebview && ./venv-pywebview/bin/pip install -r requirements.txt",
"init:qt5": "./venv-pywebview/bin/pip install pyqt5 pyqtwebengine -r requirements.txt",
"init:gtk": "sudo apt install libgirepository1.0-dev gcc libcairo2-dev pkg-config python3-dev gir1.2-gtk-3.0 && ./venv-pywebview/bin/pip install pycairo pygobject -r requirements.txt",
"start": "yarn run frontend:dev && run-script-os",
"start:windows": ".\\venv-pywebview\\Scripts\\python src\\index.py",
"start:default": "./venv-pywebview/bin/python src/index.py"
},
}

大致可以分为 devbuildcleaninitstart 几个操作和对应各平台细分的指令

  • init 指令基本上是用 yarn 安装前端依赖后,根据不同的平台(即 run-script-os 包的作用)运行不同的 init:xx 命令,都是用 virtualenv 创建虚拟环境之后用 pip 安装对应 python 依赖,除了 Linux 还有一些系统依赖要处理;
  • clean 指令会清除项目下的 gui 目录、build 目录以及 dist 目录;
  • build 命令按照顺序会先执行 clean 命令,再通过 yarn 构建前端工程,在根据不同的平台用 Python 的工具打包成对应的分发体(基本上都是 onefile 模式),在 Windows 和 Linux 上使用的是 PyInstaller 而 macOS 则是 py2app 。
  • startdev 指令其实是紧密关联的(build 实际上和 dev 也有联系),dev 的主要作用是将构建目录设置为 guiBUILD_PATH='./gui'),同时运行 create-react-app 的 build 操作;start 则是在 dev 命令运行完构建操作后运行 src\index.py 文件来启动 pywebview —— 由于我们实际用的是 HTTP Server 模式,应该要运行的是 src\backend\main.py

碎碎念:项目的 debug 和打包

结合两个 Example 之后我们确定的项目结构大致如下 —— 我这里前端框架用的是 AcroDesign React ,后端还是 Flask ,前端包管理是 yarn 而后端则是 poetry :

项目结构

其中:

  • scripts/sql 放置一些常用的系统/数据库脚本
  • server 放置服务端的 Flask 代码
  • src 放置前端的 ArcoDesign 代码
  • main.py 用于单独运行 server 来调试逻辑,window.py 来运行 pywebview 框架,同时也是 PyInstaller 的打包入口文件
  • build.spec 则是 PyInstaller 的描述文件

关于 Debug

正如前文所说,即使给 pywebview 创建的 window 开启了 debug mode ,它实际上能做的也就是 debug 前端的 JavaScript 错误,那么对于实际上后端逻辑的 debug 没有什么直接支持的手段;不过因为用的是 HTTP Server 模式,实在不行我们也可以单独把 server 跑起来当成一个 web 项目来调试,但是代码里头用到的那些 pywebview 的代码就没办法执行了 —— 这里只能折衷一下自己封装一个 mock 来跳过,希望之后能有更好的集成调试模式。

关于项目打包

因为我这边实际上只用到了 Window 平台,所以工具上也只用到了 PyInstaller —— PyInstaller 的描述文件也大致参考前端代码 Example 中的 build-windows.spec 文件即可。

不过有一点要注意的是 PyInstaller 的打包入口文件的命名:因为我之前已经用了 main.py 来作为调试 server 的入口,打包入口文件就命名为 cmd.py 然后死活运行不了,后面遂改成了 window.py ;其次,因为 Windows 打包是基于 .NET 平台的,需要依赖 pythonnet 包,但是这个包对于 Python 有版本要求,目前为止 Python 3.10 以上应该还是不行的。

因为实际上最后还是通过 yarn 命令来统一执行打包操作,这里我根据我的项目实际稍微修改了一下:

1
2
3
4
5
6
7
8
9
10
{
...
"scripts": {
...
"build:default": "poetry run pyinstaller build.spec",
...
"start:default": "poetry run python main.py"
},
...
}

pywebview 实战感受

总体而言,使用 pywebview 的感觉还是蛮不错,至少在开发上的体验不错:毕竟之前做多了 Web 开发,单纯搞一个桌面版的同时没有太高的学习成本(除了 Debug 有点麻烦),目前所提供的一些 API 也能满足需要(实际上大部分的 API 除了刚需的 file dialog 接口之外我感觉都是为了 Serverless 模式提供的)。只要 Web 前端功底扎实,确实能做出比传统桌面程序更加炫酷和灵活的应用(Web 能借鉴的东西也更多,想想以前写 xaml 的痛苦经历,也难怪人们也更倾向于 CEF ……)。而且因为用的 Python ,也不用 node.js 来写 Electron 。因为直接用的 WebView2 (Windows 平台),打包体积也要比其他同类的框架小。

感觉比较深的一点是,在做耗时逻辑交互的时候,无论是 Tkinter 还是 .NET Framework 的桌面应用,都需要考虑界面线程和后台线程的分离和交互。而 pywebview 应用本身天然就是 B/S 的,因此也少了些心智负担,只是耗时逻辑的处理上需要换种思路来实现同样的效果:这里我大部分情况下都是使用了 SSE 的方式(结合 Python 的 generator),就效果而言也算差强人意。

如果有需要的 Pythoner 全栈,欢迎尝试一下 pywebview 😀 。

参考资料