AutoHotkey 脚本重构小记

上篇博文我提到了最近我进行了 AutoHotkey 的重构。本篇博文就来介绍一下。

PR

21 号我创建了一个名为「v1_to_v2」的分支,顾名思义是要完成 v1 语法向 v2 语法的转换。并创建了一个名为「Convert convertible scripts to v2」的 Pull Request,这个 PR 最后被更名为「Refactor to v2 & Format codes」,即除重构外,也进行了部分格式化以提高可读性。

这个 PR 最终是有 28 个 commits,仅次于 OCRC 的 30 个 commits。但增加代码 1600+ 行,删减代码 1700+ 行,远胜 OCRC,这也是我完成的最大的一个 PR。

好了不扯淡了,来讲讲 PR 的部分内容。

工具

「工欲善其事,必先利其器」。这是我第二篇博文的介绍。同样的这次转换也使用了一些工具。

首先就是 VSCode,真香。

然后装了两个插件,「AutoHotkey Plus Plus」及「AutoHotkey v2 Language Support」。这两个插件在我改代码、Debug 时起到了不可替代的作用,AutoHotkey 维护者用了都说好!

期间尝试过一个工具 AHK-v2-script-converter,试用了一点,但效果不尽人意,因此大部分还是人工转换的,同时加深我的记忆。

过程

大部分的转换还是比较简单的,就不用说什么了。

MouseHand

第一个谈的应该是「MouseHand」这个脚本,这个脚本虽然并没有被废弃,但由于没有使用过,实质上已经是被废弃了。但在重构过程中,我不仅将代码由 v1 转为 v2,还重写了一遍,现在它的效果应该是胜于一开始的。当然我没进行深入测试,只是把参数改小了一点进行简单测试。

由于手疾,不应该长时间使用鼠标。因此我写了个脚本,一段时间高强度使用鼠标后锁定鼠标,必须休息指定时间后才能继续使用。

原 MouseHand
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
#Requires AutoHotkey v1.1.36.02+
#NoTrayIcon

Global LBNum := 300, CheckMins := 30, BreakTime := 60, ForceTime := 30

~LButton::
If (LButtonKeyNum > 0) {
LButtonKeyNum ++
Return
}
LButtonKeyNum := 1
SetTimer MouseHand, % - 60000 * CheckMins
Return

MouseHand:
If (LButtonKeyNum >= LBNum) {
MsgBox 4144, 休息锁定模式, 已经高强度使用鼠标 %CheckMins% 分钟了 ,活动一下手吧!可在休息至少 %ForceTime%s 后按 Esc 键退出锁定模式。, 10
CoordMode Mouse
MouseGetPos XPos, YPos
Loop % ForceTime {
Sleep 1000
MouseMove XPos, YPos
}
Loop % BreakTime - ForceTime {
KeyWait Esc, DT1
MouseMove XPos, YPos
} Until !ErrorLevel
}
LButtonKeyNum := 0
Return

原设置是每 30 分钟检查一次,期间左键单击使用鼠标超过 300 次就算高强度使用。强制休息 30 秒,然后可以按 Esc 解除锁定。60 秒后自动解除。

然而我当时不知道 BlockInput 这个命令(其实搜索一下就有了,当时不知道为什么不知道),于是采用了一个笨方法:首先获取最后鼠标位置,然后每秒移动鼠标到该位置。

现 MosueHand
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#NoTrayIcon

global LBKeyNum := 600, CheckMins := 30, BreakSeconds := 60, ForceSeconds := 30

~LButton::
KeyLeftButton(ThisHotkey) {
static LButton_key_num := 0
if LButton_key_num > 0
return LButton_key_num++
LButton_key_num := 1
SetTimer(LockMouse, -60000 * CheckMins)

LockMouse() {
if LButton_key_num >= LBKeyNum {
BlockInput("MouseMove")
MsgBox("已经高强度使用鼠标 " CheckMins " 分钟了,活动一下手吧!可在休息至少 " ForceSeconds "s 后按 Esc 键退出锁定模式。", "休息锁定模式", "Icon! 0x1000 T5")
UnlockMouse(*) => BlockInput("MouseMoveOff")
SetTimer(() => Hotkey("~Esc", UnlockMouse, "On"), -1000 * ForceSeconds)
SetTimer(() => Hotkey("~Esc", UnlockMouse, "Off"), -1000 * BreakSeconds)
SetTimer(UnlockMouse, -1000 * BreakSeconds)
}
LButton_key_num := 0
}
}

这是重构与重写后的代码。使用了 BlockInput,是彻底禁止了鼠标的移动(但仍然可以使用按键,即只禁止鼠标的移动)。

不仅如此,还使用了「胖箭头函数」,减小了代码的体积。

至此,我完成了第一个比较大的重构,心满意足地关闭了。

SlideWindows

然后应该就是「SlideWindows」。这是一个在窗口组循环的脚本。应该是网课期间我企图记电子笔记,为方便多窗口激活而写的一个脚本。很显然这也是一个没使用过的脚本。

原 SlideWindows
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
Show() {
text := "Windows IDs:"
for index, value in windows
text .= "`r" index ": " value
return text
}

IndexOf(item, list) {
for index, value in list
if (value = item)
return index
}

Global windows := []

#q::
win := WinActive("A")
index := IndexOf(win, windows)
if (index = windows.Length())
actwin := windows[1]
else
actwin := windows[index + 1]
WinActivate ahk_id %actwin%
return

#w::
win := WinActive("A")
if !IndexOf(win, windows)
windows.Push(win)
return

#e::MsgBox % Show()

#r::
InputBox order, Delete Window ID, % Show(), , , , , , , 10, 1
if !ErrorLevel
windows.RemoveAt(order)
return

这是原脚本。比较粗糙,大体就是 Win + Q 在窗口组循环,Win + W 将当前活跃窗口加入窗口组,Win + E 显示窗口组 id,Win + R 显示列表、输入数字以移除某个窗口。这四个按键也是测试脚本中最常用的了。

重构后遇到了几个问题。首先是显示 id,显示一个 id 有啥用,我咋知道 id 对应哪个窗口,这个显示了跟没显示毫无区别。然后就是,假如窗口组中某个窗口关闭了,那么将要激活这个窗口时 v2 会报错,因为 v2 找不到这个窗口,而 v1 虽然找不到,但它也不说。

于是针对这两点我改进了一下:现在将显示标题而非 id,也许标题很乱也不明所以,但是清晰度确确实实是提升了。还有就是,抛出异常时捕获,并删除掉空项。

不过相当于浪费了一次按键,因为删除后我没有尝试激活下一个。但如果要考虑下一个激活,那还得考虑下一个是否存在。加上我现在没啥动力做这件事,就搁置了吧。

现 SlideWindows
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
Show() {
text := "Windows IDs:"
for index, value in windows
text .= "`r" index ": " WinGetTitle("ahk_id " value)
return text
}

IndexOf(item, list) {
for index, value in list
if value == item
return index
return 0
}

global windows := []

#q::{
index := IndexOf(win := WinActive("A"), windows)
try WinActivate("ahk_id " windows[(index == windows.Length) ? 1 : index + 1])
catch TargetError
windows.RemoveAt((index == windows.Length) ? 1 : index + 1)
}

#w::{
if !IndexOf(win := WinActive("A"), windows)
windows.Push(win)
}

#e::MsgBox(Show())

#r::{
order := InputBox(Show(), "Delete Window ID", "T10")
if order.Result == "OK"
try windows.RemoveAt(order.Value)
}

这是重构后的代码。

重构过程中也发现好几处利用默认以空字串返回的方式偷懒的写法,在 v2 里报错,排查了好一会才找出问题所在。

Vark

Vark 也算我一个大项目了,虽然比 OCRC 小得多,但是这是我第一个完全自主创作,包括库都是自己写的项目。OCRC 引用了外部 JSON 库。不过那还能说我用的函数还都是标准库里的呢,所以 OCRC 仍然是我的第一个比较大的项目。

但是呢 Vark 的重构比较中规中矩,我看了下好像没啥可以谈的。除了我移除了一个参数——Vim 路径。因为我已经加入 PATH 了,就没必要了。

比较大的改动应该就是同步了 vimrc 的修改。不过最近又改动了好多,估计 Vark 的 vimrc 还得跟进。

Vark 的重构并没有进行严格的测试,所以会出什么 bug 也说不定。

Vark 目前还是弱了点,已经一年多没更新过了,希望以后有时间能更新一下吧。

OCRC

基本库

OCRC 的重构就累得多了。首先有 GUI 的就它一个(Focus 也有,但这是一个已经废弃的项目了,所以不参与此次重构),其次里面用到了好几个外部函数:UrlDownloadToVar(我更名为 Request)一个,UrlEncode 一个,StrPutVar 一个,Gdip 有几个,然后还有 JSON 库有两个 LoadDump

JSON 我没有动过,是直接引入的库。仓库在。很遗憾的是,这个库最后更新时间是 7 年前,虽然说支持 v2-alpha,但显然正式版是用不了了,我也搜到了相关帖子。

然后我找到了 JXON_ahk2,期间还找到了个蛮新的库 jsongo_AHKv2。但我最终选择了 thqby 的 JSON 库,为此我还需要额外引入一个库 Native。thqby 的库调用了一个 C++ 编写的 ahk-json.dll,据称能极大提高解析速度。因此重构后的 OCRC 会在初始时在同路径放一个 ahk-json.dllLoadDump 分别变成 parsestringify

Request 函数呢,我找了一下 v2 版本的,但我不想下个库,毕竟 v1 时我就只是单个函数完成的。没发现能满足我需求的,于是就手动重构了 Request 函数,目前来看没出什么问题。

StrPutVar 是一个用在 UrlEncode 的函数。之前精简代码时我把它第三个参数删了,但可笑的是调用时还是有三个参数,然而没报错,仍然能用。

至于 UrlEncode,我在论坛找到了一个编码解码的函数,由于我只需要编码,就改了一点。这个函数用不到 StrPutVar 了,就移除了。

Gdip 的几个函数则是找到了 v2 版本的库 AHKv2-Gdip,并根据名字替换了,还把单行的函数移除了,直接替换以精简代码。

Common 库里还有几个自己编写的函数。

ReadIni 是一个读取 ini 文件的函数,v1 时 IniRead 是命令,无法写在表达式。而 v2 中一切命令皆函数,因此这个就弃用了。但在重构过程中我发现这两个函数 Section 和 Key 位置是反的。这个 ReadIni 一开始是从别的 OCR 继承下来的,因此保留了这个。我觉得 ReadIni 这个先 Key 再 Section 填参数的操作很离谱,只是我现在才意识到。

Img2Base 除了把一个单行函数移除了外,没有变动。

GetScreenshot 这个函数就是要获取截图。除了重构外还修改了部分内容(也会讲 PR 后修改的部分内容)。

v1 时在函数内清空剪贴板,而这个操作在 v2 被我移到主函数里了。同时进行了剪贴板内容的保存和恢复。但仔细一想最终结果出来还是要进剪贴板的,这样做好像没啥意义哎。

同时比较离谱的是函数没有引入任何参数,而是直接 global 了四个设置变量。这个操作简直逆天,传个参都不愿意,我写下这行 global 时脑袋是给驴踢了吗?于是在重构时果断拨乱反正。

截图的逻辑大概是这样的:如果开启了外部截图支持,同时提供了外部截图的命令,就调用这个命令,如果未开启、未提供命令或调用失败,就会抛出一个错误,捕获这个错误后使用自带截图操作。

接下来是 PR 后的修改。

一开始自带截图使用 Win + Shift + S 调用截图。然而这有个隐患:要是用户覆盖了这个截图键,那就不行了。为此我在网上冲浪,在 Stack Overflow 查找到了一个方式,使用命令 snippingtool /clip 进行截图。然后我就兴冲冲地提交了。然后我想着在 Windows11 测试一下,结果我惊恐地发现,不行!它会直接打开那个截图工具!然后我就只好继续找,终于在 ElevenForum 找到了通用的解决方案:explorer ms-screenclip:。这下总没问题了。

然后我通过正则获取外部截图的 exe,以作为 ahk_exe,检测截图是否完成。我将 SnipPath 更名为 SnipEXE,而且稍微改了一点正则,把文件名禁止的字符都加进去了,可以更精准。

Mathpix 库

接下来是 Mathpix 库的内容。

首先的更改就是不再为 Mathpix 提供为空的默认参数,直接不提供默认参数,Baidu 库也是。因为外部调用都是有传参的,而且默认参数就算了,默认个空字符串算什么。

同时修改了参数名称,微调了设置所在参数位置,使设置更清晰。

Mathpix 类精简得只剩下 __New__Show 两个方法,移除了 __FocusSelect __ClipGuiEscape。但并不是说删掉了功能。

__Clip 被一个一行的胖箭头函数 Clip 取代。

Clip(CtrlObj, *) => A_Clipboard := CtrlObj.Value

并使用 OnEvent 绑定在 Edit 控件,在 Focus 事件时触发。更清晰明了,也更简洁。以更为优雅的方式实现了 Click2Clip 功能。而这个功能在 v1 是通过 OnMessage 实现的。

1
2
this.UpdateClip := ObjBindMethod(this, "__Clip")
OnMessage(0x201, this.UpdateClip)

首先将 __Clip 绑定到 this,然后将 0x201 事件关联绑定函数对象(不可以直接用 this.__Clip,后面会讲讲我的推测)。根据Windows 消息列表,0x201 是「按下鼠标左键」的事件,也就是说它监听了全部鼠标左键的消息,然后让我们看看关联到的函数干了什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__Clip(wParam, lParam, msg, hwnd) {
if !WinExist("ahk_group Mathpix") {
OnMessage(0x201, this.UpdateClip, 0)
return
}
GuiControlGet focusvar, FocusV
if focusvar in result,latex_result,inline_result,display_result
{
GuiControlGet clipvar, , %hwnd%
if clipvar {
Clipboard := ""
Clipboard := clipvar
}
}
}

四个参数是必需的,但只用到了一个 Hwnd,是窗口的唯一标识。

之前创建 GUI 时有行命令是 GroupAdd Mathpix, ahk_id %MRW%,将窗口加入了 Mathpix 组里。现在检测如果不存在这个组,就结束监听并返回。

然后获取当前控件关联变量名,假如在目标四个里就获取内容,保存到剪贴板里。

也许其实有更好的方案只是我当时没发现。但我的这个实现实在是烂透了,极其不优雅。不仅监听了无意义的左键消息,还要靠关联变量名判断是不是在控件上。而靠 OnEvent 实现则清晰,优雅多了。

__FocusSelect 也被一行胖箭头函数 FocusSelect 取代。

FocusSelect(CtrlObj) => (CtrlObj.Focus(), Clip(CtrlObj))

而原本的实现也比较烂。

1
2
3
4
5
6
__FocusSelect(control) {
GuiControlGet hwndvar, Hwnd, %control%
GuiControlGet clipvar, , %control%
ControlFocus , , ahk_id %hwndvar%
Clipboard := clipvar
}

最后一个 GuiEscape 也是用 OnEvent 实现的,甚至不需要再定义一个函数。

this.__Results[this.id].OnEvent("Escape", (GuiObj) => GuiObj.Destroy())

其中 this.__Results[this.id] 就是 GUI 对象,后面也会解释。

原来则是这样,虽然也比较简洁,但肯定不如上面好。

1
2
3
GuiEscape() {
Gui Destroy
}

Baidu 库

然后是 Baidu 库的内容。

加了 Token 函数的结果的判断,防止 Token 获取失败后继续执行,造成两次报错。

同时还修改了一点 __Token 函数的内容,由于 v1 不严谨的机制,v2 报错了才发现不对。

Baidu 中一些函数进行了更名,使其用途更明显和清晰。

跟 Mathpix 类似,Baidu 库也在不影响功能的前提下移除了两个方法:__UpdateGuiEscape。以下都以 Format 为例。

1
2
3
4
5
6
7
8
Gui %id%:Add,  DropDownList, x+5 w90 hwndformathwnd AltSubmit Choose%formatstyle%, 智能段落|合并多行|拆分多行
this.formathwnd := formathwnd
this.__Update(formathwnd, "__Format")
...
__Update(hwnd, func) {
bindfunc := ObjBindMethod(this, func)
GuiControl +g, %hwnd%, %bindfunc%
}

根据我浅薄的知识,使用 g-标签可以使控件在更新时触发函数。然而我的处理函数,例如 __Format 都在类里,直接 g__Format 无法传递 this,因此我就只能先在外部绑定,再把 g-标签通过控件 Hwnd 添加到控件上。这样在 Format 对应控件更新时,结果能及时作出反馈。也许还有更好的办法,但我确实不知道。

当然这样做也确实有点抽象,在 v2 中仍然是使用 OnEvent 解决。

1
2
this.__Results[this.id].AddDropDownList("x+5 w90 vFormatStyle AltSubmit Choose" this.config["format_style"], ["智能段落", "合并多行", "拆分多行"]).SetFont("s12")
this.__Results[this.id]["FormatStyle"].OnEvent("Change", ObjBindMethod(this, "__Format"))

this.__Results[this.id]["FormatStyle"] 是 Format 对应控件对象。用 OnEvent 绑定,在 Change 事件,即更改时触发。优雅得多喔。

__Clip 方法保留了,因为处理的方法也在用。但是也变成一个胖箭头函数了。

原 __Clip
1
2
3
4
5
6
7
8
__Clip(hwnd := "") {
Clipboard := ""
if hwnd {
GuiControlGet result, , %hwnd%
this.result := result
}
Clipboard := this.result
}
现 __Clip
__Clip(CtrlObj, *) => (this.result := CtrlObj.Value, A_Clipboard := this.result)

GuiEscape 则是跟 Mathpix 库里一样的处理。

文本处理的方法都没进行变动,一部分原因是我看不懂了。以后有机会还是要处理一下,比如那个「智能空格」会把 OCR 的 URL 弄得一团糟。

主程序

更改了部分变量名及 ini 配置文件的键名,使程序更加清晰。

令人瞠目结舌的是主程序没有(定义)任何一个函数,全是标签。(不过也不至于会去用 Gosub 什么的)

现在命令皆函数就舒服得多了。拿开头的 Menu 作为示范。

原 Menu(Setting 标签略)
1
2
3
4
5
6
7
8
9
10
11
Menu Tray, Add, 设置, Setting
Menu Tray, Add, 重启, ReloadSub
Menu Tray, Add, 退出, ExitSub

ReloadSub:
Reload
return

ExitSub:
ExitApp
return
现 Menu
1
2
3
A_TrayMenu.Add("设置", (*) => SettingGUI())
A_TrayMenu.Add("重启", (*) => Reload())
A_TrayMenu.Add("退出", (*) => ExitApp())

然后不知道为啥我要试图以管理员权限启动,这个应该也是从之前继承下来的,现在移除了。

读取 ini 配置我也从 loop 换成了 for,这个 loop 及里面诡异的变量名设置都是继承下来的。现在终于彻底剔除掉这些痕迹了。

发现创建 ini 配置文件用的就是 Gosub,打脸了。不过我也意识到一个问题:如果打开 OCRC 后删掉配置文件,然后打开随便一个 OCR,就会报错,看来得在 OCR 前额外加个判断。

然后还有两个额外标签(实际上是三个) GETV GBaidu_HotkeyGMathpix_Hotkey,后两个是一类的就只放前两个了。这些标签通过控件 g-标签进行关联。与上面提到的类似。

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
GETV:
if !WinActive("ahk_id " SettingHwnd)
return
GuiControlGet TabVar, , SysTabControl321
GuiControlGet tVa, , %A_GuiControl%
%A_GuiControl% := tVa
IniWrite %tVa%, %ConfigFile%, %TabVar%, %A_GuiControl%
if A_GuiControl in Basic_BaiduOCROnOff,Basic_MathpixOCROnOff
{
if !Basic_AutoReloadOnOff
MsgBox 4132, OCRC, 是否要重启以使设置生效?, 10
IfMsgBox No
Return
Reload
}
return

GBaidu_Hotkey:
if !WinActive("ahk_id " SettingHwnd)
return
GuiControlGet, tVa, , %A_GuiControl%
%A_GuiControl% := tVa
IniWrite %tVa%, %ConfigFile%, BaiduOCR, %A_GuiControl%
Hotkey %Baidu_HotkeyTemp%, BaiduOCR, Off
if (Basic_BaiduOCROnOff and Baidu_Hotkey) {
Hotkey %Baidu_Hotkey%, BaiduOCR, On
Baidu_HotkeyTemp := Baidu_Hotkey
}
return

GETV 这个也是继承的好像,应该是 "Get Variable" 的意思。首先获取了当前的 Tab 控件名作为 Section 名,然后写入 ini 对应 Section 的键值。特别地,如果是开关 OCR 则问要不要重启。

插嘴

重看文章找错时发现「是」和「G」左边的引号之间有空格。结果我一看源码发现用的是「"」,看来是之前设置的 typographer 的功效。只不过对我来说好像有点碍事。

其实完全可以多做一个(两个)标签,专门关联开关的两个 CheckBox,不知道我当时为啥没做,宁可重启。

GBaidu_Hotkey 则是专门关联 Hotkey 控件的,同时加了一个 Baidu_HotkeyTemp 作为临时热键,在热键更换时禁用废弃热键(没找到删除热键的方法,论坛上找到的也说无法删除,只能禁用)。

在 v2 里就简洁一点了。同时为 OCR 开关的 CheckBox 单独关联了函数,所以现在已经不需要重启了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
UpdateVar(CtrlObj, *) => IniWrite(OCRC_Configs[CtrlObj.Name] := CtrlObj.Value, OCRC_ConfigFilePath, CtrlObj.Gui["Tabs"].Text, CtrlObj.Name)

UpdateHotkey(CtrlObj, *) {
UpdateVar(CtrlObj)
global Baidu_HotkeyTemp, Mathpix_HotkeyTemp
if !CtrlObj.Value
return
if CtrlObj.Name == "Baidu_Hotkey" {
Hotkey(Baidu_HotkeyTemp, OCRC_BaiduOCR, "Off")
Hotkey(CtrlObj.Value, OCRC_BaiduOCR, "On")
Baidu_HotkeyTemp := CtrlObj.Value
}
else {
Hotkey(Mathpix_HotkeyTemp, OCRC_MathpixOCR, "Off")
Hotkey(CtrlObj.Value, OCRC_MathpixOCR, "On")
Mathpix_HotkeyTemp := CtrlObj.Value
}
}

SwitchHotkey(CtrlObj, *) => (UpdateVar(CtrlObj), Hotkey(OCRC_Configs["Baidu_Hotkey"], OCRC_BaiduOCR, CtrlObj.Value ? "On" : "Off"))

UpdateVarUpdateHotkey 起的分别就是 GETVGBaidu_Hotkey 的作用。然后跟上面一样 OnEvent 关联 Click 或 Change 事件。

踩坑

未初始化变量不可使用

在 v1 中未初始化变量是能直接用的,值视为空,但在 v2 中会报错。这让我一些 v1 中没注意到的犄角旮旯出了问题。

动态变量不再可用

v1 里有个很骚的操作就是动态变量,下面是一个例子。

1
2
a   := "b"
%a% := "c"

这个例子中,会产生一个变量 b,它的值是字符串 c。但这在 v2 中不可用,原因其实就是上面那一点,未初始化变量不能直接用。

OCRC 中引入配置文件的内容我用的就是动态变量,OCRC 中结果 GUI 我用的也是名为 id 动态变量。因此为了间接实现动态变量,我创建了 OCRC_Configsthis.__Results 的 Map,并将原动态变量的名作为键,原动态变量的值作为值。不过我想,结果 GUI 似乎用不到动态变量,找个机会删掉吧。

FileInstall

我的 OCRC 主程序最上方有两行:

1
2
if !FileExist("ahk-json.dll")
FileInstall("lib\ahk-json.dll", "ahk-json.dll", 1)

这两行是让编译时将 lib 文件夹下的 ahk-json.dll 打包进 exe,并在使用时如果当前目录不存在,就拉出来。

一开始我用的是 FileInstall("ahk-json.dll", "ahk-json.dll", 1),但死活不成功。最后才搜到两个路径不能相同。

话说回来第三个选覆盖好像没啥用,毕竟我都检测了文件不存在。

GuiObj & CtrlObj

即 GUI 和控件对象。

下面是 OCRC 的 Baidu 库里的一段:

1
2
3
4
5
this.__Results[this.id] := Gui(, this.id)
this.__Results[this.id].OnEvent("Escape", (GuiObj) => GuiObj.Destroy())
...
this.__Results[this.id].AddDropDownList("x+5 w90 vFormatStyle AltSubmit Choose" this.config["format_style"], ["智能段落", "合并多行", "拆分多行"]).SetFont("s12")
this.__Results[this.id]["FormatStyle"].OnEvent("Change", ObjBindMethod(this, "__Format"))

一开始我是这样写的:

1
2
3
this.__Results[this.id] := Gui(, this.id).OnEvent("Escape", (GuiObj) => GuiObj.Destroy())
...
this.__Results[this.id].AddDropDownList("x+5 w90 vFormatStyle AltSubmit Choose" this.config["format_style"], ["智能段落", "合并多行", "拆分多行"]).SetFont("s12").OnEvent("Change", ObjBindMethod(this, "__Format"))

看起来很不错,多好哇,还挺省空间的。

然而这会报错。因为 GUI 和控件对象经过 SetFont OnEvent 等处理后返回的是个空字符串(或者是别的?我不记得了),并不是对象。

ObjBindMethod

这其实不只是 v2 的坑,甚至说这其实应该不算坑,只是我学艺不精罢了。

this.__Results[this.id]["FormatStyle"].OnEvent("Change", ObjBindMethod(this, "__Format"))

这个我原本的写法是

this.__Results[this.id]["FormatStyle"].OnEvent("Change", this.__Format)

然后这是 __Format

1
2
3
4
__Format(CtrlObj, *) {
format_style := CtrlObj.Value, result := ""
...
}

然后它除了刚触发时能用外,其余时候都会报错,大意是整数没有 Value 这个值。我去 Debug 也发现弄下来的 CtrlObj 居然是个整数,真是令人百思不得其解。

于是我就按照 v1 的方法用 ObjBindMethod 去弄就成功了。

然后我无意间发现 OnEvent 传的参数一般有两个,CtrlObj 及 Info,由于 Info 没用,我就用 * 代替了,想传啥传啥,想传几个传几个,然后呢 Info 的类型正好是整数。但这也并没有引起我的关注。

直到昨天深夜里我躺在床上,猛地才想通了:__Format 作为方法,隐式地包含了一个参数 this,而唯一能成功运行的那行代码长下面那样。

this.__Format(this.__Results[this.id]["FormatStyle"])

这行代码其实就包含了参数 this。

然而 OnEvent 的函数对象不能包含参数,因此实际上我的 __Format 是要至少两个参数 this 和 CtrlObj,最后传入的是 CtrlObj 与 Info。也就是说,参数错位了。这样也就解释了为何 CtrlObj 是个整数了,因为传入的 Info 本身就是整数。

而 ObjBindMethod 则解决了这个问题。它应该是将 this 与 __Format 绑起来作为一个新的函数对象了。同样的,如果 OnEvent 里的函数对象要传参,也必须用 Bind 而不能加参数,这个我在搜索过程中也发现了相关帖子。

v1 时我也遇到了这个问题。当时也是苦想很久未果,搜索一大片后终于发现了 ObjBindMethod 这个解法,虽然不知其原理,但也就用下去了。

不过现在我大概是知道原理了。希望我理解的没错。

Map

v2 里没有下面的写法了

a := {"m": 1, "n": 2}

必须这样写

a := Map("m", 1, "n", 2)

这其实让我百思不得其解。不过 H 版是支持的,H 版还支持 JSON 和 YAML 解析呢。(这里的 H 版指的不是 HotkeyIt 的 AutoHotkey_H,而是 thqby 的合并了 H 版内容的 AutoHotkey_L 的分支)

同时也不可以用 a.m 读取了,必须用 a["m"]。还是有点不习惯的。

比大小

这个 v1 也是,我只是发现 v2 也有这个现象罢了。看来连续比大小是无法实现的,必须用 &&

1 < 3 < 2 的值是 1。原理就是 1 < 3 是 1,1 < 2 也是 1,因此结果是 1。

ThisHotkey

例如在 MouseHand 里有一段是这样的:

1
2
3
4
~LButton::
KeyLeftButton(ThisHotkey) {
...
}

可见 KeyLeftButtonUnlockMouse 这两个都有 ThisHotkey 这个参数,但都没有显式使用。

我直接引用文档内容:

当热键被触发时, 热键的名称作为其第一个参数传递, 参数名为 ThisHotkey(不包括尾部的冒号).

同样的例子也出现在 OCRC 中。

1
2
3
4
5
6
OCRC_BaiduOCR(ThisHotkey) {
...
}
OCRC_MathpixOCR(ThisHotkey) {
...
}

GUI 控件

首先是 Text,不设定 h 时文字有时会显示不完整。在 v1 没出现这个问题(但印象中确实遇到过,不过 v1 代码里确实是没有设置 h)。懒得特意去截图了。

然后是 Edit 及 UpDown,这个纯粹是我眼瞎。UpDown 选项中有个「0x80」,v1 的脚本里我是用了这个选项的。它用来省略千位分隔符。然后文档说「然而, 通常不使用此样式, 因为当脚本从 UpDown 控件(而不是其伙伴控件) 获取的数字中不包含分隔符」,我想也是,有个千位分隔符也不碍事,方便看数字(虽然我的数字都不大)。于是就没加这个选项了。

于是在测试时,报错了,说你怎么要数字传个字符串过来啊。我一看配置文件,1,000。哎你怎么不讲信用呐,不是说不包含分隔符吗?于是我一气之下又加了这个选项。

后面再一看,「从 UpDown 控件(而不是其伙伴控件)」,原来如此,我就是从 Edit 控件获取数字的,误会它了。不过也懒得改了,得改关联内容,好麻烦,算了算了,这千位分隔符不要也罢,反正本来平时也就没用过。

冒号

v1 的冒号要双写转义,比如要让 a 等于字符串 Hello "World"!b 等于字符串 "",应该这么写:

1
2
a := "Hello ""World""!"
b := """"""

有时候冒号多得我都绕晕了。v2 改为 `"

1
2
a := "Hello `"World`"!"
b := "`"`""

写个小抄,在内联代码中用 ` 需要双写,同时要有空格间隔,即这样写:`` ` ``

特点

我累了,写了快六个小时了还没写完。以后有时间再说吧。

延续行

在 v1 时想要延续行得把 , 放行首。比如这是 v1 的 Start

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Scripts := [ ;"Basic\Shutdown\Shutdown"
, "Basic\Window\WinDrag\main"
, "Basic\Remap\Fn"
, "Basic\Remap\NumLock"
, "Basic\Remap\Others"

, "General\Tips\Run"
, "General\Correction\Pinyin"
, "General\Abbreviation\Common"
, "General\AHKMapCheatSheet\Mappings"

, "Specific\Vim\Vim"
; , "Specific\Anki\Must"

, "Tool\Vark\main"]

跟别的语言格格不入。虽然实现了,但是太丑陋了。

而 v2 就可以使用别的语言的延续行方式了,看起来就美观得多了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Scripts := [
; "Basic/Shutdown/Shutdown.ahk1",
"Basic/Window/WinDrag/main.ahk1",
"Basic/Remap/Fn.ahk",
"Basic/Remap/NumLock.ahk",
"Basic/Remap/Others.ahk",

"General/Tips/Run.ahk",
"General/Correction/Pinyin.ahk",
"General/Abbreviation/Common.ahk",
"General/AHKMapCheatSheet/Mappings.ahk",

"Specific/Vim/Vim.ahk",
; "Specific/Anki/Must.ahk1",

"Tool/Vark/main.ahk",
]

命令

援引自v1.1 到 v2.0 的更改

删除 "命令语法". 已经没有了 "命令", 只有 函数调用语句, 它们只是没有括号的函数或方法调用.

这真是太好了,随便找几个例子。

比如上面提到的 IniRead 就是一个很好的例子。

RegRead ProxyStatus, HKEY_CURRENT_USER, Software\Microsoft\Windows\CurrentVersion\Internet Settings, ProxyEnable

这是 Tips/Run 中用来切换系统代理开关的热键中获取代理状态的一行。它的意思是讲注册表的值赋给 ProxyStatus 这个变量。而在 v2 是这样写:

ProxyStatus := RegRead("HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings", "ProxyEnable")

更加清晰明了。

MouseGetPos xcursor, ycursor
MouseGetPos(&xcursor, &ycursor)

然后是 Vark 中获取鼠标位置的命令。在 v2 中是传递了变量的引用。

ErrorLevel

在网上看到过将 ErrorLevel 形容为「公共厕所」,让我直接笑出声。因为十分贴切。

在 v1,只要是出错就设置 ErrorLevel,要返回信息就设置 ErrorLevel。这是 v1 中 ErrorLevel 的文档,好多个函数命令都设置 ErrorLevel。

1
2
3
4
5
6
7
8
IsChineseMode() {
DetectHiddenWindows On
WinGet winid, ID, A
wintitle := "ahk_id " DllCall("imm32\ImmGetDefaultIMEWnd", "Uint", winid, "Uint")
SendMessage 0x283, 0x001, 0, , %wintitle%
DetectHiddenWindows Off
return ErrorLevel = 1025
}

这是 v1 中检测是不是中文输入法的代码。SendMessage 设置了 ErrorLevel。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
IsChineseMode() {
DetectHiddenWindows True
hWnd := winGetID("A")
result := SendMessage(
0x283, ; Message: WM_IME_CONTROL
0x001, ; wParam : IMC_GETCONVERSIONMODE
0 , ; lParam : (NoArgs)
, ; Control : (Window)
; Retrieves the default window handle to the IME class.
"ahk_id " DllCall("imm32\ImmGetDefaultIMEWnd", "Uint", hWnd, "Uint")
)
DetectHiddenWindows False
return result == 1025
}

而这是 v2。

在 v2 中很多以前设置 ErrorLevel 的现在会抛出错误。虽然处理错误挺麻烦的,但比起弄 ErrorLevel,还是处理错误比较好。

总之这个公共变量 ErrorLevel 这是让人痛恨。写起来爽,但是维护时累的要死。

胖箭头函数

原文是「Fat arrow function」,文档直译为「胖箭头函数」。跟 JavaScript 的箭头函数形式上很像。

这个写法节省了很多空间,上面也有很多例子。不过最好的例子我觉得应该是下面这个。

1
2
3
4
5
6
7
8
9
10
^Space::
RegRead ProxyStatus, HKEY_CURRENT_USER, Software\Microsoft\Windows\CurrentVersion\Internet Settings, ProxyEnable
RegWrite REG_DWORD, HKEY_CURRENT_USER, Software\Microsoft\Windows\CurrentVersion\Internet Settings, ProxyEnable, % ProxyStatus := !ProxyStatus
ToolTip % "Proxy has been switched " (ProxyStatus ? "On" : "Off") "."
SetTimer RemoveToolTip, -1000
return

RemoveToolTip:
ToolTip
return

这是 v1 中切换代理的快捷键。切换后它会显示一个 ToolTip 显示当前代理状态。当然这不能一直显示,于是用 SetTimer 设置 1s 后移除。这也是官方文档给的典型范例。

而在 v2 中使用胖箭头函数 + 命令皆函数,可以砍掉一个标签。

1
2
3
4
5
6
^Space::{
ProxyStatus := RegRead("HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings", "ProxyEnable")
RegWrite(ProxyStatus := !ProxyStatus, "REG_DWORD", "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings", "ProxyEnable")
ToolTip("Proxy has been switched " (ProxyStatus ? "On." : "Off."))
SetTimer(() => ToolTip(), -1000)
}

赋值

v1 中有两种赋值方法,除了 := 外还有一个我从没用过的 =,下面两个是等价的。

1
2
a  = b
a := "b"

我本来就不喜第一种方法,因此没用过,全用的是第二种。v2 中移除了第一种赋值方法。

相等

v1 与 v2 都能用 = 判断相等,不过 v2 加入了一个 ==,可以要求大小写也相同,而 = 是忽略大小写的。重构过程中我将 = 全改为 == 了。

百分号

v1 中很多要用百分号的地方终于解脱了,这让 AutoHotkey 语法更像通俗的编程语言了。目前我的 v2 代码还没出现用到百分号的场景了。

OTB

v2 完全支持了 OTB,即下面这种写法。

1
2
3
4
5
6
if x < y {
...
}
else {
...
}

v1 也是支持的,但是有例外。这是 OCRC__Space 的一段 loop:

1
2
3
4
5
6
7
loop parse, result, "
{
if Mod(A_Index, 2)
PTR .= A_LoopField """"
else
PTR .= Trim(A_LoopField) """"
}

{ 移到前一行末尾会出错,因为它把分隔符当 "{ 了。而 v2 则没事(还用三元表达式简化了代码):

1
2
loop parse result, "`""
PTR .= (Mod(A_Index, 2) ? A_LoopField : Trim(A_LoopField)) "`""

先这样吧。以后想得到再来补充。