- 标题:
平台调用详解 - 标签:
AutoHotkey | AHK | DllCall | P/Invoke | 平台调用 | .dll | 动态链接库 | 应用程序扩展 | WindowsAPI | Win32API | WindowsAPP | 系统API | Windows开发 | 外部函数 | 外部调用 - 标注:
https://www.jianshu.com/p/301ce9e25f5a
https://www.jianshu.com/u/1275d25b625e
在编写具有平台特性的功能时,我们经常需要借由外部实现来增强脚本能力;为此,本文将为您介绍AutoHotkey中的平台调用机制,以及它在实际封装时的设计细节。
基本概念
AutoHotkey中平台调用的复杂性并不总是来自语言本身,而更多是由Windows设计及历史原因导致的种种理解阻碍。广厦亦需深基,厚积薄发方能驾驭凌云;切勿忽视基本概念的重要性。
平台调用(P/Invoke)
平台调用是Windows中程序互相调用的流行方式之一,多以具体进程调用动态链接库的方式完成。
Windows中的动态链接库多以*.dll
文件出现,它在系统中被称作“应用程序扩展”;您可以把它理解为一个个无法启动的小仓库,其中包含诸多有用的功能实现;它将实现逻辑封装为函数签名,供以其它程序在有需要的时候调用。而与之相对的则是可执行文件*.exe
,它在系统中被称作“应用程序”;这种典型的可启动文件可创建进程,访问其它应用程序或应用程序扩展。
这时问题产生了——Autohotkey脚本不是*.exe
而是*.ahk
,它是如何完成平台调用的呢?实际上,与*.ahk
文件相关联的目标是一个脚本解释器;在任务管理器中,您能够清晰地看到是AutoHotkey.exe
在执行您的脚本(此名称仅供教程示例);它是一个分析和执行您脚本的应用程序,并依据您操作系统或脚本内部设置采用不同的二进制实现。
应用程序调用应用程序扩展,前者获得能力,听起来非常完美。而我要告诉您的是,平台调用之所以被视为“脏活儿”,是因为它有着相当多的规矩与限制。
首先,应用程序与应用程序扩展之间应保持一致的运行目标。譬如说,32位应用程序与64位应用程序严格来说并不是面向同一种系统的应用程序,只是因为Windows(64位)兼容了Windows(32位),32位应用程序才得以正常运行。在平台调用时,要谨记这一点——AutoHotkey脚本在执行时会自动选择最佳的解释器(除非您手动指定),也就是说您在64位的Windows中调试的脚本未必可在32位Windows中使用,稍后我会为您详细介绍。
其次,应用程序应当了解应用程序扩展所需及返回的数据类型,以及解析它们的方式,这一点至关重要。在Windows中存在多种不同的调用协议,其中最常见的是标准调用“stdcall”,此处不过多详谈;在其中诸多无法被AutoHotkey所使用的外部类型中,成功平台调用所依赖的实际上是数据类型的宽度,这一点也同样在稍后为您介绍。
您应当已对Windows平台调用有了大致的概念;正如您所想的,平台调用可以汇集各种底层语言的能力供以上层调用,这是组成Windows这一多姿多彩软件生态的重要基石——AutoHotkey解释器中的平台能力也是藉由数量庞大的复杂平台调用完成的。
数据类型(DataTypes)
在如今形形色色的诸多语言中,您很难找到两个具有相同标准的自有类型;纵使它们的名称一致,内部的实现结构也可能大相径庭。好在平台调用有标准存在,这使得数据交换有了最基本的参考依据。Windows中多数的平台调用使用了C语言中的基本类型;也就是我们耳熟能详的“int”、“struct”等。
在认识这些数据类型之前,请允许我穿插一段生活常识。
日常中,我们所说的下载速度*KB/s
和网络速率*Kbps
所表达的含义真的相同吗?实际上它们是两个不同的单位——哦,我不是在说/s
和ps
,也不是在说K
;它们最主要的区别在于B
与b
的大小写。没错,它们是不同的;B
表示字节Byte
,b
表示位bit
,它们的关系是1Byte
=8bit
——1字节等于8位。看来我们还是错怪了运营商,200Mbps
的宽带所表达的最大速率实际上是200÷8=25MB/s
。
这样一来,您再看到类似于“32位整型”的描述时,便能立刻反应过来这种类型的固定宽度是4字节。
Autohotkey所支持的平台调用类型已在此处完整列出,而Windows数据类型也已在该页面中完整展示。参照这些内容,您便能知道花样繁多的Windows数据类型究竟对应着何种基本类型。
除了基本类型,Windows平台调用中还有一种不可忽视的数据结构,名为结构体“struct”;这是一种包含了固定数据类型的复合结构,它常作为一个传递内容的封包流动与各种进程的调用中,这部分将在稍后为您介绍。
调用实现
看完了大段枯燥乏味的概念,是时候小试牛刀了。我将以几个具体的WindowsAPI为例,为您详细展示平台调用的设计过程与结果。
基本语法
AutoHotkey中的平台调用多是通过内置函数“DllCall”进行的;这是它推荐的调用模板:
Result := DllCall("[DllFile\]Function" [, Type1, Arg1, Type2, Arg2, "Cdecl ReturnType"])
这段冗长难懂的语法就像您所见过的所有DllCall一样不明不白,我很清楚这个感受;不过既然都看到了这里,我想您应当还保有着一鼓作气的信心。为此,我对其进行了如下分解:
调用结果 := DllCall(函数路径
, 类型1, 参数1, 类型2, 参数2, ...
, 调用协议和返回类型)
- 首先,DllCall具有(但不总是具有)返回值,所以它可以像普通函数一样被放到赋值符号的右侧。
- 其次是它的第一个必要参数
"[DllFile\]Function"
,它要求您提供一个应用程序扩展的具体路径以及其中的函数名称;譬如“D:\Osmosis.dll”中的“AUX”函数应当被写成字符串D:\Osmosis.dll\AUX
。
这里应注意几点——当省略应用程序扩展的文件名后缀时,函数将默认它为*.dll
应用程序扩展,所以上述情况写成D:\Osmosis\AUX
是可以的;当目标函数位于 “User32.dll”、“Kernel32.dll”、“ComCtl32.dll”或“Gdi32.dll”中时可以省略[DllFile\]
部分,因此调用“%WinDir%\System32\user32.dll”中的“GetDoubleClickTime”函数时,直接填入字符串"GetDoubleClickTime"
是可以的;而当您仅提供应用程序扩展文件名及目标函数名时,DllCall会在系统PATH或A_WorkingDir
中搜索目标,假如目标存在,填入字符串"Osmosis\AUX"
是可以的。 - 中间是它的可变可选参数
Type, Arg
,前者要求您提供参数的类型,后者要求您填入参数的具体;这似乎有些奇怪——AutoHotkey从未要求(或者说并不支持)编辑者显式注明变量的类型,为什么却要在DllCall中多此一举呢?AutoHotkey与外部所实现的类型不尽相同;事实上,不同编程语言之间几乎没有普遍一致的数据类型。DllCall为适应平台调用的通用准则而指定了许多类型标记,这不仅能使外部函数理解我们的调用,也能使AutoHotkey理解外部函数的返回;总的来说,这是一种内部的理解方式与外部的交互协定。
此段参数不限制数目;比方说您想传输3个Int类型的数据,填入"Int", 8, "Int", 3, "Int", 6
是可以的。 - 最后是可选参数
"Cdecl ReturnType"
,前者可选择注明使用C调用“cdecl”,后者可选择注明函数返回值的类型;Cdecl
部分存在时,说明您要求DllCall使用C调用,省略时使用标准调用“stdcall”;ReturnType
与Type
部分类似,DllCall需要此种注明来理解来自外部的返回结果,当调用返回值类型为32位有符号整型或Windows类型“BOOL”时可以省略。
接下来开始正式的编写演示,请留意上文提及中可能会频繁使用的几则参考:
- MSDN - Win32Apps - Windows 数据类型
- MSDN - Win32Apps - Windows 编码约定(相较上文新增)
- AutoHotkey - DllCall - 参数与返回值类型
- AutoHotkey - 二进制兼容性 - DllCall(相较上文新增)
获取鼠标双击间隔(GetDoubleClickTime)
先来看一个简单的。在编写AutoHotkey逻辑时,我们有时需要判断用户的双击(不论是键盘还是鼠标)动作;可您是否有注意到,不同用户的双击间隔可能是不同的。类似的情况还有用户保持按下与系统进行重复触发之间的等待,不过这不在我们今天的讨论范围内。
“GetDoubleClickTime”是一个Win32API,位于“User32.dll”。它的函数签名如下:
UINT GetDoubleClickTime();
该函数不需要参数,那么唯一需要注意的地方就是返回值了。
它所要求的返回值类型为Windows类型“UINT”;根据Windows数据类型可知,“UINT”由C基本类型“int”定义;对应到AutoHotkey中,最符合描述的类型为32位整数“Int”。
开始编写DllCall。由于符合条件,可省略应用程序扩展的路径及名称;由于无需参数,可省略传入类型与具体;那么只需在函数名称后紧跟返回值类型即可。
GetDoubleClickTime() {
return DllCall("GetDoubleClickTime", "Int")
}
在我的设备上,使用MsgBox(GetDoubleClickTime())
所显示的信息为550
。
获取指定窗口的DPI(GetDpiForWindow)
接下来看一个具有参数的调用。在编写AutoHotkey逻辑时,如何判断某个窗口是否已应用系统缩放呢?内置变量“A_ScreenDPI”可获取系统DPI的具体数值,为96
时意为100%缩放倍数。
“GetDpiForWindow”是一个Win32API,位于“User32.dll”。它的函数签名如下:
UINT GetDpiForWindow(
[in] HWND hwnd
);
该函数具有返回值和1个只读参数。
hwnd
的类型为Windows类型“HWND”;根据Windows数据类型可知,它由Windows类型“HANDLE”定义,而“HANDLE”又由Windows类型“PVOID”定义,最终“PVOID”由C基本类型“void*”定义;对应到AutoHotkey中,最符合描述的类型为指针“Ptr”——您可能觉得这样的推断有些不明所以;因为“HANDLE”是句柄而“void*”为指针,不论如何它都符合AutoHotkey中”Ptr“类型的描述。
返回值部分与上一演示相同,此处结论为“Int”。
开始编写DllCall。由于符合条件,可省略应用程序扩展的路径及名称。
GetDpiForWindow(hwnd) {
return DllCall("GetDpiForWindow", "Ptr", hwnd, "Int")
}
在我的设备上,使用MsgBox(GetDpiForWindow(WinGetID("ahk_exe Code.exe")))
获取“Visual Studio Code”窗口缩放的结果为120
,正好与我的系统缩放A_ScreenDPI
相同——120÷96=125%。
显示消息对话框(MessageBox)
这次来看一个具有字符串参数的调用。在编写AutoHotkey逻辑时,我们有时需要通过消息对话框与用户取得互动;此功能已由内置函数“MsgBox”实现。
“MessageBox”是一个Win32API,位于“User32.dll”。它的函数签名如下:
int MessageBox(
[in, optional] HWND hWnd,
[in, optional] LPCTSTR lpText,
[in, optional] LPCTSTR lpCaption,
[in] UINT uType
);
该函数具有返回值和4个只读参数。
hWnd
部分与先前演示相同,此处结论为“Ptr”。
lpText
与lpCaption
的类型为Windows“LPCTSTR”;根据Windows数据类型可知,它根据构建目标是否为“UNICODE”环境而分别被定义为Windows类型“LPCWSTR”与“LPCSTR”,前者为16位宽字符,而后者为8位字符;它们分别对应于AutoHotkey类型“WStr”与“AStr”;由于在AutoHotkeyV2中默认使用“UNICODE”环境,此处可直接使用“Str”。
uType
与返回值的类型与先前演示类似,此处结论为“Int”。
开始编写DllCall。由于符合条件,可省略应用程序扩展的路径及名称。
MessageBox(hWnd, lpText, lpCaption, uType) {
return DllCall("MessageBox", "Ptr", hWnd, "Str", lpText, "Str", lpCaption, "Int", uType, "Int")
}
在我的设备上,使用MessageBox(0, "文本。","标题", 0)
正确显示了标题为“标题”、正文为“文本。”的最简消息对话框。
获取鼠标光标位置(GetCursorPos)
这次的调用有些不同,它将包含结构体“struct”。在编写AutoHotkey逻辑时,我们有时需要获取鼠标光标的即时位置;此功能已由内置函数“MouseGetPos”实现,配合使用CoordMode("Mouse", "Screen")
后的调用即是本次平台调用的结果。
“GetCursorPos”是一个Win32API,位于“User32.dll”。它的函数签名如下:
BOOL GetCursorPos(
[out] LPPOINT lpPoint
);
该函数具有返回值和1个只写参数。
lpPoint
的类型为“POINT”结构体,其内部具有2个Windows类型“LONG”;根据Windows数据类型可知,它由C基本类型“long”定义,宽度固定为32位;对应到AutoHotkey中,最符合“long”描述的类型为32位整数“Int”,而最符合“POINT”描述的类型为32×2=64位指针“Ptr”。
它所要求的返回值类型为Windows类型“BOOL”;根据Windows数据类型可知,“BOOL”由C基本类型“int”定义;对应到AutoHotkey中,最符合描述的类型为32位整数“Int”。
开始编写DllCall。由于符合条件,可省略应用程序扩展的路径及名称。
-
确定逻辑结构。AutoHotkey中没有结构体“struct”类型,因此您应当在封装上设计参数分解。
GetCursorPos(&lpPoint_X, &lpPoint_Y) { return DllCall("GetCursorPos", "Ptr", ???, "Int") }
-
构造参数具体。可以看到,此时没有合适的内置变量供以平台调用,需要借由缓冲对象“Buffer”来构造自定宽度的对象。
缓冲对象“Buffer”的首个构造参数为缓冲对象的总宽度;Windows类型“POINT”共计64位,则此处缓冲对象的长度也应当为64÷8=8字节。由于此处的调用参数为只写类型,所以无需进行初始化填充。GetCursorPos(&lpPoint_X, &lpPoint_Y) { return DllCall("GetCursorPos", "Ptr", Buffer(8), "Int") }
-
设计赋值返回。DllCall虽然成功了,但是您无法获得此平台调用的有效结果;该函数通过修改
Buffer(8)
处的对象内容来返回具体结果,因此需要借由内置函数“NumGet”从缓冲对象中分解出具体内容。
缓冲对象lpPoint
的内容在DllCall成功执行后发生了改变,这是由外部函数“GetCursorPos”产生的。根据定义,该只写参数被修改成了Windows类型“POINT”结构体,其中依次储存着2个Windows类型“LONG”。
内置函数“NumGet”可根据字节位置读取指定内容为预期类型,譬如NumGet(lpPoint, 4, "Int")
表示函数将从lpPoint
第4字节开始解析宽度及内容均为为“Int”类型的内容;根据上下文定义,Windows类型“LONG”宽度为4字节;那么lpPoint
中0~3字节为第1个“LONG”,4~7字节为第2个“LONG”;又因为“Int”类型的宽度也为4字节且数据结构基本相通,故此处使用以下偏移量和预期类型。GetCursorPos(&lpPoint_X, &lpPoint_Y) { Result := DllCall("GetCursorPos", "Ptr", lpPoint := Buffer(8, 0), "Int") lpPoint_X := NumGet(lpPoint, 0, "Int"), lpPoint_Y := NumGet(lpPoint, 4, "Int") return Result }
-
规整化返回值。目前的封装设计看起来已经完美无缺了,但在实际调用时可能会出现因语言标准而异的布尔判别错误;在Windows类型“BOOL”中,除真假值
1
和0
外,还有许多非零值用以表示某种执行状态;而在AutoHotkey中,并不存在严格意义上的布尔类型,条件表达式与C++类型“bool”均为非零即为真,这将导致函数返回值可能会产生不确定的处于真假值以外的数字。
为此,以下修改将通过三元表达式剔除多余状态。请注意,并非所有的非零返回值都可以被统一地视为真值,部分WindowsAPI可能会以负值表示某种错误或警告状态。GetCursorPos(&lpPoint_X, &lpPoint_Y) { Result := DllCall("GetCursorPos", "Ptr", lpPoint := Buffer(8, 0), "Int") lpPoint_X := NumGet(lpPoint, 0, "Int"), lpPoint_Y := NumGet(lpPoint, 4, "Int") return Result == 0 ? false : true ; 严格约束;宽松约束请使用 <= 0,具体视情况而定。 }
在我的设备上,使用以下代码正确显示了当前鼠标的光标在屏幕上的位置:
if GetCursorPos(&CPX, &CPY)
MsgBox(CPX ", " CPY)
重设文件大小(std::filesystem::resize_file)
最后来看一个稍显复杂的调用,它将包含自订内容的结构体“struct”以及嵌套DllCall。本次平台调用将实现C++标准库函数“std::filesystem::resize_file”,它可重设任意可访问文件的尺寸;当重设尺寸小于文件的原始尺寸时,函数将直接丢弃文件的剩余部分;当重设尺寸大于文件的原始尺寸时,函数将以零值扩充文件内容。
-
“_lopen”是一个Win32API,位于“Kernel32.dll”。该函数可通过使用指定访问权限打开文件来获取文件句柄;它的函数签名如下:
HFILE _lopen( LPCSTR lpPathName, int iReadWrite );
该函数具有返回值和2个只读参数。
lpPathName
部分与先前演示类似,此处结论为“AStr”。
iReadWrite
类型为不明枚举,可选值分别为OF_READ
、OF_WRITE
、OF_READWRITE
;根据C枚举“enum”语法可知,枚举可选成员的类型固定为C基本类型“int”;类型推导过程与先前演示相同,此处结论为“Int”。C枚举“enum”以0开始依次向后排列,除非手动指定了具体值;此处使用OF_WRITE
,即为1
。
它所要求的返回值类型为Windows类型“HFILE”,根据Windows数据类型可知,“HFILE”由C基本类型“int”定义;对应到AutoHotkey中,最符合描述的类型为32位整数“Int”。
-
“SetFileInformationByHandle”是一个Win32API,位于“Kernel32.dll”。该函数可通过文件句柄修改指定文件的信息;它的函数签名如下:
BOOL SetFileInformationByHandle( [in] HANDLE hFile, [in] FILE_INFO_BY_HANDLE_CLASS FileInformationClass, [in] LPVOID lpFileInformation, [in] DWORD dwBufferSize );
该函数具有返回值和4个只读参数。
hFile
部分与先前演示相同,此处结论为“Ptr”。
FileInformationClass
的类型为“FILE_INFO_BY_HANDLE_CLASS”枚举;根据C枚举“enum”语法可知,枚举可选成员的类型固定为C基本类型“int”;类型推导过程与先前演示相同,此处结论为“Int”。C枚举“enum”以0开始依次向后排列,除非手动指定了具体值;此处使用FileEndOfFileInfo
,即为6
。
lpFileInformation
部分与先前演示类似,此处结论为“Ptr”;具体细节将在稍后详细展开。
dwBufferSize
的类型为Windows类型“DWORD”;根据Windows数据类型可知,它由C基本类型“long”定义,宽度固定为32位;对应到AutoHotkey中,最符合“long”描述的类型为32位整数“Int”。
返回值部分与先前演示相同,此处结论为“Int”。
开始编写DllCall。由于符合条件,可省略应用程序扩展的路径及名称。
-
确定逻辑结构。对于函数封装,此处应尽量参考“resize_file”的调用模板;而对于内部实现,该函数应先使用平台调用“_lopen”获取目标文件的文件句柄,然后再使用平台调用“SetFileInformationByHandle”修改文件的信息。
“_lopen”函数相比内置函数“FileOpen”和文件对象“File”的“Handle”属性相比具有更快的速度和更保守的行为,它不会在访问失败时创建新文件。resize_file(p, new_size) { DllCall("SetFileInformationByHandle" , "Ptr", DllCall("_lopen", "AStr", p, "Int", 1, "Int") , "Int", 6, "Ptr", ???, "Int", ??? , "Int") }
-
构造参数具体。在上一步骤中缺失的
lpFileInformation
部分为FileInformationClass
所表示的类型为“FILE_END_OF_FILE_INFO”的结构体,其内部具有1个Windows类型“LARGE_INTEGER”;根据“Windows数据类型 - Large Integers”可知,它被定义为64位整数;对应到AutoHotkey中,最符合的类型为64位整数“Int64”,而最符合“lpFileInformation”描述的类型为64×1=64位指针“Ptr”。dwBufferSize
部分的参数为预期结构体的宽度,即8
字节。
在创建缓冲对象“Buffer”后,还需要内置函数“NumPut”来正确填充其中的内容;与内置函数“NumGet”类似,它能以指定偏移以此设置缓冲对象“Buffer”的具体数据。比方说您试图将2个“Int”类型的数据写入到指定缓冲对象中,但又明确知道其首位已存有1个64位宽的数据,那么NumPut("Int", 20, "Int", 40, 目标缓冲对象, 8)
是可以的;它跳过0~7字节并从8字节开始将8~11设置为“Int”类型的20
、12~15设置为“Int”类型的40
。resize_file(p, new_size) { lpFileInformation := Buffer(8) NumPut("Int64", new_size, lpFileInformation) DllCall("SetFileInformationByHandle" , "Ptr", DllCall("_lopen", "AStr", p, "Int", 1, "Int") , "Int", 6, "Ptr", lpFileInformation, "Int", 8 , "Int") }
-
改进封装逻辑。此时平台调用"_lopen"所产生的的错误被交由平台调用"SetFileInformationByHandle"判别,这会在前者明确发生错误时产生诸多不必要的譬如构建缓冲对象等开销;因此应将平台调用分离。另外,函数还应当返回标准的理想布尔值。
resize_file(p, new_size) { if ((hFile := DllCall("_lopen", "AStr", p, "Int", 1, "Int")) < 0) return false ; 宽松约束,由具体返回值而定。 lpFileInformation := Buffer(8) NumPut("Int64", new_size, lpFileInformation) return DllCall("SetFileInformationByHandle" , "Ptr", hFile, "Int", 6, "Ptr", lpFileInformation, "Int", 8 , "Int") == 0 ? false : true ; 严格约束。 }
在我的设备上,使用resize_file("D:\UserData\Desktop\TER.txt", 1024*1024*2)
成功地将空文件“TER.txt”的尺寸设置到了2MB。
设计细节
平台适配
之前的演示似乎让我们产生了一种错觉——这些平台调用并没有受到平台差异的影响——确实如此,因为系统API是特殊的。根据“MSDN - Win32Apps - 运行32位应用程序 - 文件系统重定向程序”一文中的描述,在32位程序试图访问多数系统API时,系统会自动为其重定向至特定的兼容体系中——系统为我们抹除了平台间的差异,而不是平台调用本就如此。
AutoHotkey提供了一些用于解决平台差异的内置能力,可大致归纳为以下两类:
通过指定脚本解释器版本来限制预期之外的平台差异。
“#Requires”指令可指定解释器的具体版本;例如#Requires AutoHotkey v2.0.0+ 64-bit
将要求用户解释器的版本至少为v2.0.0
且必须为64-bit
版本,这样便可以在脚本后续的平台调用中忽略对于32位的适配需求。-
根据运行环境分别执行不同的平台调用。
内置变量“A_Is64bitOS”可判断系统的具体平台(AutoHotkey尚未支持更多平台,因此其它判断是不必要的),而内置变量“A_PtrSize”可判断脚本的运行环境(即解释器的平台版本);前者返回值为0
或1
,分别表示32位系统和64位系统;后者返回值为4
或8
,分别表示32位运行环境和64位运行环境。-
对于一些可在兼容环境(32位)下执行的应用程序扩展,可以脚本运行环境为准:
演示函数1() { if A_PtrSize == 4 return DllCall("Osmosis32\AUX", "Int") else return DllCall("Osmosis64\AUX", "Int") } 演示函数2() { return A_PtrSize == 4 ? DllCall("Osmosis32\AUX", "Int") : DllCall("Osmosis64\AUX", "Int") }
-
而对于一些更为严格的、不可跨平台执行的应用程序扩展,须以双方环境为准:
演示函数() { if A_Is64bitOS == 0 AND A_PtrSize == 4 return DllCall("Osmosis32\AUX", "Int") if A_Is64bitOS == 1 AND A_PtrSize == 8 return DllCall("Osmosis64\AUX", "Int") MsgBox("ERROR") }
32位进程无法调用64位应用程序扩展,64位进程也无法调用32位应用程序扩展,请谨记这一点。
-
性能优化
通常来说,DllCall所产生的的调用开销基本可以忽略不计;但在一些对性能要求严苛的、平台调用极其频繁的场景下,此直接调用的开销还是会产生不小的累积。“AutoHotkey - DllCall - 性能”一文通过Win32API“LoadLibrary”和“GetProcAddress”简要阐明了预装载技术;此外,命令“#DllLoad”提供了更适宜全局生命周期的预装载方式。
此部分暂无示例。
至此,AutoHotkey中的平台调用技术已基本介绍完毕。
2023/11/03 ~ 2023/11/05 | XPERZ