对桌面应用的一些想法
咕咕咕 fishing

为什么想做桌面应用

最近在学习 go 语言。产生了用 go 做点东西的想法。

之前一直在学习 Java。Java 嘛,就是 spring 那套东西,做后端,写接口,操作数据库,连中间件。
最后做出来的东西就是对外暴露的接口,没有界面。或者说大部分都是前端做的界面。
最然我也会写 vue,用些组件也能做些好看的页面。但始终差点意思。

我的意思是,那是个网站。需要服务器部署,需要网络,需要浏览器…
他不是一个我想要的程序,它过于臃肿和专业。(当然,后面才发现还有更臃肿的。

所以,我想做的或许是桌面程序。

技术选型

个人的桌面程序,理想状态肯定是要小而美的。
所以选的标准就是 GUI界面和性能

开发语言

选择 go 作为开发语言。
首先是因为我在学的缘故,想用它做点东西。
另一方面就是界面渲染和后台任务肯定是异步的,go 在这方面有优势。也有助于我学习多线程。

GUI 框架

然后是 GUI 框架的选择。
我不可能使用图形 api 直接去绘制 gui,这工作量是非常巨大,并且十分痛苦的。
之前有使用 C++ 和 OpenGL 的经历,十分的痛苦。所以也十分佩服做 GUI 框架的人。

一开始选择的时候,GUI 框架还是非常多的,不过水平参差不齐。主流的其实并不多
可以参考下面的文章和一个知乎回答

2022年5月,桌面软件开发框架大赏
Go 语言这么强大,为什么没变成开发桌面软件主力语言呢? - shaoyuan的回答 - 知乎

GUI 大致分为两大类。
一类是原生实现。基于自制的绘图引擎或者 SDL、GLW之类的绘图引擎。
另一类就是跨平台的。基于浏览器绘制图形的 Electron 等,或者基于 Java 的 JavaFX、Swing 等。

基本没什么可以选择的余地,因为开发语言是比较新的 go。

首先选择使用的就是 Fyne
fyne github
fyne 是基于 OpenGL 和 GLFW 实现的。
写出来的界面大概是下面这种风格
image

说实话,不是我喜欢风格。并且 Fyne 也不支持自定义!
打包出来的 exe 程序大小在 28MB 往上。
所以劝退了。

然后选择使用的是 (wails)[https://wails.io/]
wails github
wails 可以看作为 Go 的快并且轻量的 Electron 替代品
wails 不嵌入浏览器,因此性能高。它使用平台的原生渲染引擎。在 Windows 上,是基于 Chromium 构建的新 Microsoft Webview2 库。
写出来的界面风格随意,因为前端是使用浏览器的那一套。
下面是无边框,并且半透明,亚克力质感的界面。(页面完全自定义,所以白条也是自己写的,忽略就好
image

wails 打包出来的 exe 程序大小在 8MB 往上,不过得依赖 webview2。处理 WebView2 运行时依赖
到这都很好,下面是缺点。
wails 是比较初级的,功能并没有那么完善。
比如目前最高版本 v2.5.1 只支持单窗口应用、没有托盘图标、缺少一些系统事件(全局热键、聚焦失焦等)。
总之,虽然界面上给了很多自由,但不透明的地方很多,主要是与操作系统交互的部分。
Wails v3 路线这些后续都会有吧,不过 v3 遥遥无期啊。

架构方面

这块是遇到问题请教 布拉 大佬的时候,给我说的一些建议。

首先,布拉大佬是个喜欢函数式编程的人。他认为 go 的设计有问题。建议我换个语言。
啊这,很难受啊。虽然我也觉得有设计不合理的地方,但应该是不会换了。尝试新语言大概是因为没有历史包袱吧(这里点名批评 C++

然后就是架构上的建议。前后端分离,嗯,没错。桌面应用也这样。
后端作为前端的守护进程,前端页面关闭多少次都无所谓,后端关闭就彻底关闭了。
好处就是,可以多机一起用。(当然,我是没这个需求

其实 wails 就是这么搞的,不过封装程度比较高,不透明,基本不能自定义。就那么一个窗口给你玩。
很多应用也是这么搞的。不过对我来说,有点难啊。一点经验没有。
因为前端实在是不想用 Electron ,这玩意打包出来实在是太大,难以接受。(跳佬写的聊天室软件,解压出来 223MB。
也许有很好的减小体积的方法,不过每个 Electron 应用都包含了整个 V8 引擎和 Chromium 内核。我觉得不会小到哪里去。

或许在 Windows 上,C++ 和 C# 才是小而美的最优解?

补充

Windows Api

在使用 windows api 之前,要先了解一个东西,叫 windows 消息机制
基于 Windows 的应用程序是事件驱动的。 它们不会 ((如 C 运行时库调用)进行显式函数调用,) 获取输入。 而是等待系统向其传递输入。
这里简单介绍下,详细参考微软的文档关于消息和消息队列

一个 GUI 线程有一个消息队列,一个线程有多个窗口,所有窗口共享一个消息队列。
比如按下鼠标右键,系统会产生一个消息 WM_RBUTTONDOWN 并将这个消息放到当前窗口所属线程的消息队列中。
应用程序通过一个循环监听这个消息队列,不断从中获取消息,然后处理,做出相对的响应。

消息的结构 msg 结构 (winuser.h)

1
2
3
4
5
6
7
8
typedef struct tagMSG {
HWND hwnd; // 接收消息的窗口句柄
UINT message; // 消息的常量标识符(消息号)
WPARAM wParam; // 32位消息的附加信息
LPARAM lParam; // 32位消息的附加信息
DWORD time; // 消息发布时间
POINT pt; // 发布消息时光标在屏幕坐标系中的位置
} MSG, *PMSG, *NPMSG, *LPMSG;

知道消息的结构和消息的机制之后,就可以调用 windows api 和系统进行一些交互了。
下面用 go 演示注册 windows 热键

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
package main

import (
"fmt"
"syscall"
"unsafe"
)

// MSG 来自线程的消息队列的消息信息
type MSG struct {
HWND uintptr
UINT uintptr
WPARAM int16
LPARAM int64
DWORD int32
POINT struct {
X int32
Y int32
}
}

// 常量值 fsModifiers 参数可以是以下值的组合。
const (
ModAlt = 1 << iota
ModCtrl
ModShift
ModWin
)

// HotKey 热键的结构体
type HotKey struct {
Id int // 唯一 id 标识
Modifiers int // 修饰符的常量值
KeyCode int // 按键的值
}

var user32 *syscall.DLL
var registerHotKey *syscall.Proc
var unregisterHotKey *syscall.Proc
var peekMsg *syscall.Proc
var waitMsg *syscall.Proc

func main() {
// 获取 dll 资源以调用 windows 系统 api
user32 = syscall.MustLoadDLL("user32")
registerHotKey = user32.MustFindProc("RegisterHotKey")
unregisterHotKey = user32.MustFindProc("UnregisterHotKey")
peekMsg = user32.MustFindProc("PeekMessageW")
waitMsg = user32.MustFindProc("WaitMessage")

// 定义热键
keys := []*HotKey{
{1, ModAlt, 'Z'}, // ALT+Z
{2, ModAlt + ModShift, 'X'}, // ALT+SHIFT+X
{3, ModAlt + ModCtrl, 'C'}, // ALT+CTRL+C
}

// 注册热键
for _, hotkey := range keys {
hotkey.registerOneHotKey()
}
defer func() {
for _, hotkey := range keys {
hotkey.unregisterOneHotKey()
}
}()

// 监听消息
for {
var msg = &MSG{}
res, _, _ := peekMsg.Call(uintptr(unsafe.Pointer(msg)), 0, 0, 0, 1)
if res == 0 {
_, _, _ = waitMsg.Call(uintptr(unsafe.Pointer(msg)), 0, 0, 0, 1)
} else {
// 注册 id 在 WPARAM 字段
if id := msg.WPARAM; id != 0 {
// 这里可以对按键进行分别处理
// 这里就简单打印按了什么

hotKey := keys[id-1]
fsModifiers := ""
modifier := hotKey.Modifiers
fmt.Println(hotKey)
if modifier%2 == 1 {
fsModifiers += " alt"
}
modifier = modifier >> 1
if modifier%2 == 1 {
fsModifiers += " ctrl"
}
modifier = modifier >> 1
if modifier%2 == 1 {
fsModifiers += " shift"
}
modifier = modifier >> 1
if modifier%2 == 1 {
fsModifiers += " win"
}
fmt.Printf("热键值:%s+%c\n", fsModifiers, hotKey.KeyCode)
// 退出热键
if id == 1 {
break
}
}
}
}

}

// 注册单个热键
func (h *HotKey) registerOneHotKey() {
res, _, err := registerHotKey.Call(0, uintptr(h.Id), uintptr(h.Modifiers), uintptr(h.KeyCode))
if res == 0 {
fmt.Println("注册热键失败:", h, "error", err)
} else {
fmt.Println("注册热键成功:", h)
}
}

// 注销单个热键
func (h *HotKey) unregisterOneHotKey() {
res, _, err := unregisterHotKey.Call(0, uintptr(h.Id))
if res == 0 {
fmt.Println("注销热键失败:", h, "error", err)
} else {
fmt.Println("注销热键成功:", h)
}
}

运行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
注册热键成功: &{1 1 90}
注册热键成功: &{2 5 88}
注册热键成功: &{3 3 67}
&{2 5 88}
热键值: alt shift+X
&{3 3 67}
热键值: alt ctrl+C
&{1 1 90}
热键值: alt+Z
注销热键成功: &{1 1 90}
注销热键成功: &{2 5 88}
注销热键成功: &{3 3 67}

进程 已完成,退出代码为 0

更多函数(api)参考微软文档 Win32 API 的编程参考

关于 ssh

wails 虽然有缺点,但是也只能用了。毕竟 GUI 框架没上面可以选。
因为只能单窗口,所以想做类似 utools 的工具集就不太可能了。毕竟理想来说每个工具都是一个窗口。
其次就是前后台切换运行时,焦点的处理。wails的窗口没有句柄(wails官网博客-v3路线中说的),不能与窗口进行交互。所以要使用它提供的运行时 api,不过 api 有点少。
最后还有个开机自启动的功能。还没研究过,不过有人在 issue 里提了,估计是没法实现。

所以工具集就算了,只能换个想做的的东西了。(当然也没做成
最后想到的是 ssh 客户端。因为我现在用的是 WinSCP 和 PuTTY 集成使用的方案,上古 UI ,并且不是那么方便。(其实也还好,都是 shell 操作。

没做成是因为它有些难,关于 io 流 和 异步。虽然 go 有优势,但我还不太熟。
异步的问题在 windows api 做热键的时候就有了,不过问题还不是那么大。
到 ssh 这,肯定要多终端连接。代码是一点都写不下去
输出的 io 流我暂时也没办法输出到 wails 的窗口上。
所以就没做成。

不过有示例,输出到控制台倒是没问题。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
package main

import (
"fmt"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
"log"
"os"
)

func main() {

var (
username = "root"
password = "password"
addr = "ip:port"
)

config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
conn, err := ssh.Dial("tcp", addr, config)
if err != nil {
log.Fatal("连接失败: ", err)
}
defer func(conn *ssh.Client) {
err := conn.Close()
if err != nil {
fmt.Println(err)
return
}
}(conn)

// 创建会话
session, err := conn.NewSession()
if err != nil {
log.Fatal("无法创建会话: ", err)
}
defer func(session *ssh.Session) {
err := session.Close()
if err != nil {
fmt.Println(err)
return
}
}(session)

// file, _ := os.OpenFile("./resources/a.txt", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)

// 设置会话的标准输出、错误输出、标准输入
session.Stdout = os.Stdout
session.Stderr = os.Stderr
session.Stdin = os.Stdin

// 设置终端参数
modes := ssh.TerminalModes{
ssh.ECHO: 1, // 启用回显
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kb
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kb
}

// 获取当前标准输出终端窗口尺寸 该操作可能有的平台上不可用,那么下面手动指定终端尺寸即可
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
log.Fatal("无法获取终端大小: ", err)
}

// 设置虚拟终端与远程会话关联
if err := session.RequestPty("xterm", termHeight, termWidth, modes); err != nil {
log.Fatal("请求虚拟终端失败: ", err)
}

// 启动远程Shell
if err := session.Shell(); err != nil {
log.Fatal("启动shell失败: ", err)
}

// 阻塞直至结束会话
if err := session.Wait(); err != nil {
log.Fatal("退出异常: ", err)
}
}

运行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Last login: Thu Aug 10 14:40:10 2023 from 58.221.220.122
[root@VM-16-9-centos ~]# pwd
pwd
/root
[root@VM-16-9-centos ~]#
[root@VM-16-9-centos ~]# cd note
cd note
[root@VM-16-9-centos note]#
[root@VM-16-9-centos note]# ll
ll
total 229992
drwxr-xr-x 4 root root 4096 Jul 20 19:01 dist
drwxr-xr-x 9 root root 4096 Jul 20 16:28 jdk-17.0.8
-rwxr-xr-x 1 root root 182376116 Jun 16 02:47 jdk-17_linux-x64_bin.tar.gz
-rwxr-xr-x 1 root root 26556781 Jul 21 09:27 privateNote-0.0.1-SNAPSHOT.jar
-rw-r--r-- 1 root root 26556825 Jul 22 21:33 privateNote.jar
[root@VM-16-9-centos note]#
[root@VM-16-9-centos note]# exit
exit
logout
EOF

进程 已完成,退出代码为 0

END

得去深入学习下 io 流 和 多线程
桌面应用就这样吧,主要 wails v2 版本功能还是太少了。等 v3 正式版再来用 go 玩玩桌面应用。

最后放一个知乎的问题吧
(现在整个 Web 前端是「屎山」吗?)[https://www.zhihu.com/question/511853234/answer/2324956267]

不能说 web 吧,我觉得几乎所有的都是。