AutoHotkey 之美 2014-08-16T06:18:57+00:00 rhong.fu@gmail.com 脚本外挂的检测及预防 2014-08-19T06:06:23+00:00 amnesiac http://amnesiac10.github.io/2014/08/19/script-main-window 导言:一些游戏在运行时能检测并屏蔽脚本外挂,原理是什么?有什么办法可以知道当前系统中开了多少脚本?包括使用默认托盘图标、自定义图标甚至隐藏了托盘图标的脚本。还有,能发现已编译成可执行文件的脚本吗?是否能控制这些脚本??

检测在运行的脚本

获取系统中当前运行的所有 AutoHotkey 脚本的信息(下面的脚本获取脚本标题和进程路径):

DetectHiddenWindows, on
WinGet, AHKWinList, List, ahk_class AutoHotkey 
Loop, %AHKWinList%
{
  AHKWinHWND := AHKWinList%A_Index%
  WinGetTitle, AHKWinTitle, ahk_id %AHKWinHWND%
  WinGet, AHKWinProcessPath, ProcessPath, ahk_id %AHKWinHWND%
  MsgBox, % "脚本标题:" AHKWinTitle "`n脚本进程路径:" AHKWinProcessPath
}
return

由于可以获取到窗口句柄,所以可以直接通过窗口命令获取指定脚本的各种信息。其中脚本标题中包含了脚本文件名和执行脚本的 AutoHotkey.exe 版本号,例如:

D:\Software\AutoHotkey\Scripts\test.ahk - AutoHotkey v1.1.15.00

当然,还可以使用其他方法。下面通过消息获取某脚本的进程 ID:

AHKScriptName := "MyScript.ahk"
SetTitleMatchMode, 2
DetectHiddenWindows, on
SendMessage, 0x44, 0x405, 0, , %AHKScriptName% ahk_class AutoHotkey
MsgBox %ErrorLevel% is the process id.

想判断哪些是未编译脚本, 哪些是已编译脚本, 请看下图: 运行的 AutoHotkey 脚本列表 图中显示当前系统运行了两个 AutoHotkey 脚本,我们对比这两个脚本的窗口名称:

  • D:\Software\AutoHotkey\Scripts\test.ahk - AutoHotkey v1.1.15.00
  • D:\Software\AutoHotkey\Scripts\test.exe

可以看出一个是未编译脚本,一个是已编译脚本,因此可以根据窗口名判断(其他信息也有差异)。注:从我使用这种方法开始到现在,窗口名称的规律没有发生变化,不过这里无法保证以后的所有版本都会遵从。

对这些脚本进行控制

在写脚本时,我们都知道可以很方便的对当前脚本进行控制,如:

Pause::Pause
^!s::Suspend
^!r::Reload
^+q::ExitApp
!l::ListLines
!v::ListVars
!k::KeyHistory

提示:我以前写脚本时习惯加上许多这样的热键,包括暂停、重启、列出热键、列出执行行、列出变量、显示按键历史等,调试时很方便。那么,对其他脚本我们可以实现这些功能吗?

AHKScriptName := "MyScript.ahk"
DetectHiddenWindows On  ; 才可以检测到脚本的隐藏主窗口.
SetTitleMatchMode 2  ; 避免为下面的文件指定完整的路径.
WM_COMMAND := 0x111
ID_FILE_PAUSE := 65403
ID_FILE_SUSPEND := 65404
PostMessage, WM_COMMAND, ID_FILE_PAUSE,,, %AHKScriptName% ahk_class AutoHotkey
PostMessage, WM_COMMAND, ID_FILE_SUSPEND,,, %AHKScriptName% ahk_class AutoHotkey
WinClose, %AHKScriptName% ahk_class AutoHotkey ; 关闭脚本,也可以使用消息,不过这里使用窗口命令可能直观一些。
; 下面两个同样是挂起和暂停的功能
PostMessage, 0x111, 65305,,, %AHKScriptName% ahk_class AutoHotkey ; 挂起
PostMessage, 0x111, 65306,,, %AHKScriptName% ahk_class AutoHotkey ; 暂停

注意:当前我不清楚如何判断脚本当前处于哪种状态(当前脚本可使用 A_IsSuspended、A_IsPaused)。

还有哪些可用的消息?我提供几点思路供参考:

  • 执行操作时通过工具截取消息(消息很多,这些属于 WM_COMMAND)
  • 直接到官方论坛询问作者

我比较懒,下面是通过源代码提取的一些消息号(可能不完整,前面部分是消息号分配的说明):

0: unused (possibly special in some contexts)
1: IDOK
2: IDCANCEL
3 to 1002: GUI window control IDs (these IDs must be unique only within their parent, not across all GUI windows)
1003 to 65299: User Defined Menu IDs
65300 to 65399: Standard tray menu items.
65400 to 65534: main menu items

消息号 含义
65300 ID_TRAY_OPEN
65400 ID_FILE_RELOADSCRIPT, ID_TRAY_RELOADSCRIPT
65401 ID_FILE_EDITSCRIPT, ID_TRAY_EDITSCRIPT
65402 ID_FILE_WINDOWSPY, ID_TRAY_WINDOWSPY
65403 ID_FILE_PAUSE, ID_TRAY_PAUSE
65404 ID_FILE_SUSPEND, ID_TRAY_SUSPEND
65405 ID_FILE_EXIT, ID_TRAY_EXIT
65406 ID_VIEW_LINES
65407 ID_VIEW_VARIABLES
65408 ID_VIEW_HOTKEYS
65409 ID_VIEW_KEYHISTORY
65410 ID_VIEW_REFRESH
65411 ID_HELP_USERMANUAL, ID_TRAY_HELP
65412 ID_HELP_WEBSITE

注:一般而言在更新版本时消息号的用途不太可能发生变化,不过为了安全,在使用前最好明确其用途,否则可能发生意外情况。

实现的具体原理

在前面的脚本中我们应该注意到两点:

  1. 开启了对隐藏窗口的检测
  2. 使用 AutoHotkey 类名获取窗口

每个 AutoHotkey 运行时都有类名为 AutoHotkey 的主窗口(这个窗口为什么称为主窗口?请参阅 A_ScriptHwnd),与是否创建自定义 GUI 窗口(自定义窗口的类名为 AutoHotkeyGUI)、是否隐藏托盘图标、是否打开调试窗口(这里的调试窗口即是主窗口,未打开时为隐藏状态)等无关。

说到这里,我想起了一个有趣的事情:为什么 AutoHotkey 被称为模拟多线程呢?从这里可以判断出,每个脚本运行时至少有两个线程:

  • 主线程,运行主窗口,负责接受消息、缓冲热键及伪线程的中断和切换等
    这里的描述可能不太准确和全面,不过大体上可以这么理解,其中的伪线程是指帮助中所说的线程概念(例如 Thread 中所描述的线程,注意帮助中除了 A_ScriptHwnd 外从未涉及到主线程)。
  • 脚本线程,实际执行脚本的线程,这不用多说了,它执行的就是我们写的脚本。

从 Microsoft Spy++ 中可以看到,实际上每个脚本也只有两个线程(你多开几个热键,多用几个计时器并不会出现更多线程)。对于模拟多线程的理解,请参阅什么是 Event Loop?,此文以 JavaScript 为例通俗地说明了这种模型的工作机制,尽管某些术语有所不同,不过不影响理解。

小结

尽管未谈到游戏,不过主要内容前面都说完了。因此,要检测 AutoHotkey 脚本的外挂只需在脚本启动及运行时定期执行:

DetectHiddenWindows, on
While WinExist("ahk_class AutoHotkey")
    WinKill

游戏不大可能用 AutoHotkey 写,这里的演示大家能看懂它的用途吧?不过这种检测方法只是我猜测的,对于如 AutoIt、按键精灵这样的脚本语言应该具有一定的可行性(如果是 C 语言这种估计没那么容易预防了)。正常的脚本如改键工具也无法例外了,有了解游戏开发的朋友能否能爆个真实情况。规避的方法也很简单,自己下载源码修改过类名编译喔。

本文与游戏关系不大,最开始的标题为脚本主窗口的妙用,不过这个标题是否更好呢?

]]>
学习 WMI 从代码转换开始 2014-08-18T06:06:23+00:00 amnesiac http://amnesiac10.github.io/2014/08/18/learn-wmi-from-code-conversion 导言:WMI 功能强大,尤其在系统管理方面,即使你不打算使用它,你也几乎一定会遇到使用它的代码。但由于 WMI 体系结构庞大,因此初学者学习 WMI 的难点在于如何找到适合的命名空间、类和相应的属性、方法或事件来实现我们需要的功能。不过很容易注意到,使用 WMI 的代码结构异常简单且网上(尤其是 MSDN)有大量现成的实现各种各样功能的 WMI 代码,所以如果能找到使用其他脚本语言的 WMI 代码并将其转换为 AutoHotkey,那么就绕过了这个难点。

目前关于 Windows 系统管理的脚本中,以 VBScript(现在似乎升级成 VB.NET,我不甚了解)居多(例如 MSDN 中的 WMI 代码都是使用这种脚本语言,微软 MVP 们写的这些教程也挺生动有趣、通俗易懂的),所以本文以 VBScript 代码的 WMI 脚本为例说明转换为 AutoHotkey 代码的过程,如果看到其他语言的代码也可以参照这个过程,例如对于 VB、JScript、AutoIt 等。本文根据 WMI 代码的不同实现方式把它们分成三类:查看 WMI 属性、执行 WMI 方法和接收 WMI 事件。

注:本文重点说明转换过程中的一些情况,但不会解释 WMI 代码的含义,如果您尚不了解 WMI 基础知识,请先参阅 WMI 脚本第一阶系列教程。

查看属性

分析脚本

下面这个 VBScript 脚本显示操作系统的名称:

strComputer = "." 
Set objWMIService = GetObject("winmgmts:\\" & strComputer & "\root\CIMV2") 
Set colItems = objWMIService.ExecQuery( _
    "SELECT * FROM Win32_OperatingSystem",,48) 
For Each objItem in colItems 
    Wscript.Echo "-----------------------------------"
    Wscript.Echo "Win32_OperatingSystem instance"
    Wscript.Echo "-----------------------------------"
    Wscript.Echo "Caption: " & objItem.Caption
Next

先分析一下这段 VBScript 代码:

  • 第一行赋值,这里的一个句点表示本地计算机。
  • 第二行连接到WMI服务。
  • 第三、四行查询WMI服务。
  • 第五行至代码末尾为For循环,并在循环中显示内容。

转换脚本

现在直接看看转换过来的 AutoHotkey 脚本:

strComputer := "." 
objWMIService := ComObjGet("winmgmts:\\" . strComputer . "\root\CIMV2") 
colItems := objWMIService.ExecQuery(""
    . "SELECT * FROM Win32_OperatingSystem", ComObjMissing(), 48) 
For objItem in colItems {
    MsgBox, %  "-----------------------------------"
    MsgBox, %  "Win32_OperatingSystem instance"
    MsgBox, %  "-----------------------------------"
    MsgBox, %  "Caption: " . objItem.Caption
}
return

对比这两段代码,简单说说基本的转换方法:

  • 第一行,修改赋值语法:AutoHotkey 中的表达式赋值使用 :=,所以这里把 = 修改为 :=。
  • 第二行,修改赋值语法、对应函数和字符串连接符:去除行首的 Set,在 AutoHotkey 中连接 WMI 服务使用 ComObjGet() 函数,并且字符串连接符为句点。
  • 第三、四行,修改续行方式:去除前一行末尾的下划线并在下一行开始处加上一个句点及空格(这里用于连接字符串),由于前一行末尾没有需要连接的变量或字符串,所以另加一对空字符串。
  • 第五行至代码末尾,修改 For 循环语法:去除 For 的 Each 关键字,并在末尾加上左大括号(注意它的前面至少需要一个空格或 TAB),并把循环后面的 Next 替换为右大括号。
  • 在循环中,修改显示命令:把 Wscript.Echo 替换为 MsgBox, %,因为前者直接接表达式,所以 MsgBox 后需加百分号。

调整转换的脚本

这样的转换比较直接,除了个别部分其他都可以用脚本实现转换。这里做些简单的修改, ExecQuery() 的参数不长就不用续行了,循环中的几个消息框相互关联所以放在一起显示(至于字符串连接符——句点,可以保留,也可以去除,这里不管它了):

strComputer := "." 
objWMIService := ComObjGet("winmgmts:\\" . strComputer . "\root\CIMV2") 
colItems := objWMIService.ExecQuery("SELECT * FROM Win32_OperatingSystem", ComObjMissing(), 48) 
For objItem in colItems {
    MsgBox, %  "-----------------------------------"
      . "Win32_OperatingSystem instance"
      .  "-----------------------------------"
      .  "Caption: " . objItem.Caption
}
return

可选参数的默认值

前面代码的差异中,有重要的一点没有提到:在 VBScript 代码中 ExecQuery() 方法省略了第二个参数表示这个参数是可选的并且使用它的默认值,但在 AutoHotkey 中在调用 COM 的方法时若可选参数后还有其他参数,那么这个参数不能省略,可以使用默认值或者用 ComObjMissing() 代替,所以下面两者效果一样(这个方法的这个参数的默认值是字符串 WQL):

colItems := objWMIService.ExecQuery("SELECT * FROM Win32_OperatingSystem", "WQL", 48)
colItems := objWMIService.ExecQuery("SELECT * FROM Win32_OperatingSystem", ComObjMissing(), 48)

这点也适用于转换 VB 家族其他语言的情况。

补充自定义函数

在一些情况下会遇到 AutoHotkey 中没有与 VBScript 中相对应的命令或函数,此时要考虑自定义函数了。在下面这个例子中,由于这个属性的值是数组,所以需要先进行处理才能显示出来(原来的 VBScript 代码中使用 Join(),不过这是它的内置函数)。

strComputer := "." 
objWMIService := ComObjGet("winmgmts:\\" . strComputer . "\root\CIMV2") 
colItems := objWMIService.ExecQuery("SELECT * FROM Win32_BIOS", ComObjMissing(), 48) 
For objItem in colItems {
    MsgBox, % "-----------------------------------"
      . "`nWin32_BIOS instance"
      . "`n-----------------------------------"
      . "`nBiosCharacteristics: " . Join(objItem.BiosCharacteristics, ",")
}
return

Join(arrList, strDelimiter)
{
    For strItem in arrList
    {
        If (A_Index = 1)
          strList := strItem
        strList .= strDelimiter . strItem
    }
    Return, strList
}

在 WMI 中有许多属性的值是数组,很可能需要这个函数。另外,许多 WMI 属性的值是时间:

strComputer := "." 
objWMIService := ComObjGet("winmgmts:\\" . strComputer . "\root\CIMV2") 
colItems := objWMIService.ExecQuery("SELECT * FROM Win32_OperatingSystem", ComObjMissing(), 48) 
For objItem in colItems {
    MsgBox, % "-----------------------------------"
      . "`nWin32_OperatingSystem instance"
      . "`n-----------------------------------"
      . "`nInstallDate: " . objItem.InstallDate
}
return

WMIDateStringToDate(dtmDate)
{
    WMIDateStringToDate := SubStr(dtmDate, 5, 2) . "/"
    . SubStr(dtmDate, 7, 2) . "/" . SubStr(dtmDate, 1, 4) 
    . " " . SubStr(dtmDate, 9, 2) . ":" . SubStr(dtmDate, 11, 2) . ":" . SubStr(dtmDate,13, 2)
    Return, WMIDateStringToDate
}

WMI 中时间格式类似于 20101220164120.000000+480,看起来不太方便,这时一般需要进行适当的处理。

执行方法

下面这个 VBScript 脚本把计算机名称从 MS-201012201636 修改为 NewComputerName:

strComputer = "." 
Set objWMIService = GetObject("winmgmts:\\" & strComputer & "\root\CIMV2") 
' Obtain an instance of the the class 
' using a key property value.
Set objShare = objWMIService.Get("Win32_ComputerSystem.Name='MS-201012201636'")

' Obtain an InParameters object specific
' to the method.
Set objInParam = objShare.Methods_("Rename"). _
    inParameters.SpawnInstance_()

' Add the input parameters.
objInParam.Properties_.Item("Name") =  "NewComputerName"
objInParam.Properties_.Item("Password") =  ""
objInParam.Properties_.Item("UserName") =  "Administrator"

' Execute the method and obtain the return status.
' The OutParameters object in objOutParams
' is created by the provider.
Set objOutParams = objWMIService.ExecMethod("Win32_ComputerSystem.Name='MS-201012201636'", "Rename", objInParam)

' List OutParams
Wscript.Echo "ReturnValue: " & objOutParams.ReturnValue

转换并进行简单调整后的 AutoHotkey 脚本:

strComputer := "." 
objWMIService := ComObjGet("winmgmts:\\" . strComputer . "\root\CIMV2") 
; Obtain an instance of the the class 
; using a key property value.
objShare := objWMIService.Get("Win32_ComputerSystem.Name='MS-201012201636'")

; Obtain an InParameters object specific
; to the method.
objInParam := objShare.Methods_("Rename").inParameters.SpawnInstance_()

; Add the input parameters.
objInParam.Properties_.Item("Name") := "NewComputerName"
objInParam.Properties_.Item("Password") := ""
objInParam.Properties_.Item("UserName") := "Administrator"

; Execute the method and obtain the return status.
; The OutParameters object in objOutParams
; is created by the provider.
objOutParams := objWMIService.ExecMethod("Win32_ComputerSystem.Name='MS-201012201636'", "Rename", objInParam)

; List OutParams
MsgBox, % "ReturnValue: " . objOutParams.ReturnValue
return

除了把行注释符替换为分号,这里不进行更多的解释了。把其中的两处 MS-201012201636 替换为您计算机的名称(请在【系统属性】对话框中查看计算机名),就可以执行这个代码来修改计算机名称了(其中输入参数中的用户名和密码可能需要根据具体情况进行调整)。

接收事件

同步监听

下面这个 VBScript 脚本监听进程创建、关闭事件:

strComputer = "." 
Set objWMIService = GetObject("winmgmts:\\" & strComputer & "\root\CIMV2") 
Set objEvents = objWMIService.ExecNotificationQuery _
("SELECT * FROM Win32_ProcessTrace")

Wscript.Echo "Waiting for events ..."
Do While(True)
    Set objReceivedEvent = objEvents.NextEvent

    'report an event
    Wscript.Echo "Win32_ProcessTrace event has occurred."
Loop

转换并进行简单调整后的 AutoHotkey 脚本:

strComputer := "." 
objWMIService := ComObjGet("winmgmts:\\" . strComputer . "\root\CIMV2") 
objEvents := objWMIService.ExecNotificationQuery("SELECT * FROM Win32_ProcessTrace")

MsgBox, % "Waiting for events ..."
Loop {
    objReceivedEvent := objEvents.NextEvent

    ; report an event
    ToolTip, % "Win32_ProcessTrace event has occurred."
}
return

为什么这里把 Wscript.Echo 替换为 ToolTip 而不是 MsgBox 呢?因为 MsgBox 是阻塞的,在显示对话框时可能会错过其他事件。里面包含的无限循环用来持续进行监视,对于所发生事件的相关信息可以从接收到的事件对象中获取(即 objReceivedEvent)。使用同步方法监听事件这个脚本就没办法做别的事情了,请看后面的解决方法。

异步监听

下面这个 VBScript 脚本与前一个的用途相同,也是监听进程的创建和关闭事件,不过这里使用异步方法:

strComputer = "." 
Set objWMIService = GetObject("winmgmts:\\" & strComputer & "\root\CIMV2") 
Set MySink = WScript.CreateObject( _
    "WbemScripting.SWbemSink","SINK_")

objWMIservice.ExecNotificationQueryAsync MySink, _
    "SELECT * FROM Win32_ProcessTrace"

WScript.Echo "Waiting for events..."

While (True)
    Wscript.Sleep(1000)
Wend

Sub SINK_OnObjectReady(objObject, objAsyncContext)
    Wscript.Echo "Win32_ProcessTrace event has occurred."
End Sub

Sub SINK_OnCompleted(objObject, objAsyncContext)
    WScript.Echo "Event call complete."
End Sub

转换并进行简单调整后的 AutoHotkey 脚本:

#Persistent

strComputer := "." 
objWMIService := ComObjGet("winmgmts:\\" . strComputer . "\root\CIMV2") 
MySink := ComObjCreate("WbemScripting.SWbemSink")
ComObjConnect(MySink, "SINK_")

objWMIservice.ExecNotificationQueryAsync(MySink, "SELECT * FROM Win32_ProcessTrace")

MsgBox, % "Waiting for events..."
return

SINK_OnObjectReady(objObject, objAsyncContext) {
    ToolTip, % "Win32_ProcessTrace event has occurred."
}

SINK_OnCompleted(objObject, objAsyncContext) {
    ToolTip, % "Event call complete."
}

其中需要重点注意的是把

Set MySink = WScript.CreateObject( _
    "WbemScripting.SWbemSink","SINK_")

替换为了:

MySink := ComObjCreate("WbemScripting.SWbemSink")
ComObjConnect(MySink, "SINK_")

还有原来 VBScript 脚本中的无限空循环被替换为 #Persistent 指令,这样可以在脚本中执行其他操作。从这里可以看出,在具体情况中需要进行灵活的替换,不应该拘泥于某种固定的模式。

对于 WMI 事件,建议采用后面这种异步方式,这样一个脚本中可以同时监听多个事件,还可以在监听事件的同时执行其他操作(虽然使用多个脚本或 AutoHotkey_H 的多线程也可以实现,然而会复杂多了)。

小结

从前面的例子可以看出,转换过程中语法的转换比较简单,在实际情况中应根据需要灵活进行转换。此外,WMI 是 COM 的一个部分,所以这样的转换方法也可以扩展到使用 COM 的脚本。 同时,为了方便大家,除了 MSDN,我还发现 List of Windows Management Instrumentation (WMI) classes with examples 中有大量的 WMI 脚本示例,在这里初学者能根据自己需要的功能找到代码,不容错过。

]]>
使用 ADO 操作 Excel 文档 2014-08-17T06:16:23+00:00 amnesiac http://amnesiac10.github.io/2014/08/17/process-spreadsheet-with-ado 注:本文改写自微软知识库文章,待找到源网址后补上。

Office 家族系列软件功能强大,更强大的是通过相应的对象模型可以把这些软件脚本化,唯一的问题可能是每个软件都有自己专用对象模型。例如如果要操作 Word 或 Excel,您必须学习两种对象模型,这样并不是说学习它们很难,意思是例如知道如何添加数据到 Word 文档并不能给您在添加数据到 Excel 文档时带来多少帮助。

所以在一些只需要执行基本操作的情况下,使用 ADO 操作 Excel 文档可能是较好的选择。比起 Excel 对象模型,使用 ADO 具有以下优点:

  • ADO 对象模型简单易学。
  • ADO 且可用于 CSV、TSV、xls、mdb 等多种文件类型。
  • ADO 中通过 SQL 查询可以方便地进行过滤和聚合等。
  • 使用 ADO 是在进程内执行,速度快(请参阅 COM 对象的进程内、外运行)。
  • 无需安装 Excel,这可节省了一笔不小的费用(基于国情,这点放最后)。

测试文件样本

一般而言,除了需要通过脚本演示在 Excel 中进行的操作和使用 Excel 中专有的一些高级功能外,基本上 ADO 都可以满足要求。下面将通过一个简单的电子表格说明如何使用 ADO 访问电子表格。(如果一定还要寻找其他适合使用 Excel 的原因,那么很可能是您对 Excel 对象模型很熟悉,而目前对 ADO 还不大了解,这样看过本文后也许会给您带来较大的收获。)

下面是一个简单的电子表格文件 C:\Scripts\Test.xls: Excel 中的样本数据

具体的内容结构:一个标签为 Name,另一个为 Number。为了确保您能对 Excel 电子表格使用数据库查询,必须让电子表格符合简单的风格:让第一行作为标题行,从第二行开始为数据,并且不要跳过任何行或列。同时为了让代码简单些,在标题中不要包含空格,例如使用 SocialSecurityNumber 作为列标题代替 Social Security Number。

访问样本数据

现在我们看看使用 ADO 来访问这样电子表格数据的代码:

adOpenStatic := 3
adLockOptimistic := 3
adCmdText := 0x0001

objConnection := ComObjCreate("ADODB.Connection")
objRecordSet := ComObjCreate("ADODB.Recordset")

objConnection.Open("Provider=Microsoft.Jet.OLEDB.4.0; Data Source=C:\Scripts\Test.xls; Extended Properties=""Excel 8.0;HDR=Yes;"";")

objRecordset.Open("Select * FROM [Sheet1$]", objConnection, adOpenStatic, adLockOptimistic, adCmdText)

while !objRecordset.EOF
{
  MsgBox, % objRecordset.Fields.Item("Name") objRecordset.Fields.Item("Number")
  objRecordset.MoveNext
}

运行代码后结果像这样:

A 1
B 1
C 2
D 2
E 1
F 1

代码分析

开始部分定义了一些常量和两个对象——ADODB.Connection 和 ADODB.Recordset,这两个对象用来连接数据源并从中获取数据。这几乎是所有 ADO 脚本中的模板了,这里不会对这部分进行详细的说明。

现在来看看接下来这行代码,它实际上建立了到 Excel 电子表格的连接:

objConnection.Open("Provider=Microsoft.Jet.OLEDB.4.0; Data Source=C:\Scripts\Test.xls; Extended Properties=""Excel 8.0;HDR=Yes;"";")

其中 Data Source 部分指定了电子表格的文件路径和名称。如果文件名称中包含空格呢?仍然直接写就行了,例如 Data Source=C:\Scripts\My Spreadsheet.xls。

注意,您可能会把连接字符串中的 Excel 8.0 改为您电脑上安装的 Excel 版本,但会执行错误,因为这里的 Excel 8.0 并非电脑上安装的 Excel 程序的版本,而是指用来访问 Excel 文档提供者(Provider)的版本。

还有,其中的 HDR=Yes 表示电子表格含有标题行,如果没有标题行则设置 HDR 为 No。

连接到数据源后,使用 SQL 查询来获取其中的数据。这是用来返回包含了电子表格中所有行的记录集的代码:

objRecordset.Open("Select * FROM [Sheet1$]", objConnection, adOpenStatic, adLockOptimistic, adCmdText)

这里只需要关心 SQL 查询的参数,Select * FROM [Sheet1$] 是标准的 SQL 查询,选择数据库(工作表)中的所有字段(列)。在查询中指定了工作表的名称,需要注意它的格式:工作表名称后面附加了 $ 符且括在方括号中。

操作这里得到的记录集和操作从 SQL Server 得到的记录集没多大区别,因此可以用下面这些代码简单地显示记录集中每个记录中的 Name 和 Number 字段:

while !objRecordset.EOF
{
  MsgBox, % objRecordset.Fields.Item("Name") objRecordset.Fields.Item("Number")
  objRecordset.MoveNext
}

任务完成了,很漂亮。

进一步思考

您可能会产生这样的疑问:不能使用 Excel 对象模型来获取这样的信息吗?确实可以这么做。我承认,如果只是要显示电子表格中的每行内容,使用 ADO 并没有体现出多少好处(好处还是有的,或许您可以使用 Excel 对象模型实现相同的任务,并比较它们的代码,不过这个不是决定性的好处)。

不过,设想一下如果我们只要显示 Number 等于 2 的那些行呢?使用 Excel 脚本我们需要检查每一行的 Number 是否等于 2,以决定是否显示出来。这样不难,但却很麻烦,尤其当您需要检查多个列的时候(例如您需要寻找在 Finance 部门且头衔为 Administrative Assistant 的所有用户)。比较起来,通过 ADO 我们可以不用检查电子表格中的每行,唯一需要做的只是修改 SQL 查询,即使用类似下面的查询就可以获取 Number 等于 2 的所有行:

objRecordset.Open("Select * FROM [Sheet1$] Where Number = 2", objConnection, adOpenStatic, adLockOptimistic, adCmdText)

这样我们就能得到下面的结果:

C 2
D 2

从这里可以看出,在 ADO 使用 SQL 查询进行过滤很方便,实际上除了过滤,还可以方便地进行排序、聚合等操作。

小结

本文中简单介绍了使用 ADO 从 Excel 电子表格中读取数据,主要说明了在一些情况下使用 ADO 访问 Excel 电子表格比使用 Excel 更好的原因,实际上,使用 ADO 除了能从 Excel 中获取数据外,还可以写入数据。可以把 ADO 先看成一种通用数据访问接口,详细特性请参阅微软的 ADO 手册,简单了解可参考 ADO 教程

对于普通用户而言,本文缺乏背景介绍学习起来有难度,但与其他技术类似,对于这些高级技术的学习不应局限在 AutoHotkey 自身帮助,本身也不属于它的内容。需要自己从其他资料或语言中学习,例如 Windows API、COM、SQL、WMI,这些一般都是语言独立的。

]]>
传统形式与表达式 2014-08-15T22:10:54+00:00 amnesiac http://amnesiac10.github.io/2014/08/15/traditional-and-expression 本篇中主要专注于 AutoHotkey 中容易引起困惑的传统形式和表达式两种风格,进行比较并提供一些建议,以尽可能去除在学习或使用过程中的困惑。

从示例开始

有人在论坛中提问(加不加括号,效果截然不同 • AHKScript),在帮助文档 ListView 的一个示例中(注:这里只是片段,完整代码请参阅帮助 ListView 页面):

GuiContextMenu:
if A_GuiControl <> MyListView
    return

Menu, MyContextMenu, Show, %A_GuiX%, %A_GuiY%
return

然后他做了点小小的修改(把 If 后的部分加在括号中):

GuiContextMenu:
if (A_GuiControl <> MyListView)
    return

Menu, MyContextMenu, Show, %A_GuiX%, %A_GuiY%
return

运行时他发现效果完全不同了:修改前在 ListView 控件上点击才显示右键菜单,而修改后则是在窗口的空白处点击才显示菜单,很不理解。

为什么会这样呢?因为他在不理解 If 传统型和 If(表达式)就加上括号,这是产生问题的根源,加上括号则成了表达式(在修改后的脚本中),同时 MyListView 成了变量,但该脚本中不存在这个变量,实际上换成任何一个脚本中不存在的变量名效果都一样(不过这里的名称有迷惑性,让人误解它有特定意义)。为什么还能运行?这必须提到 AutoHotkey 的变量机制:使用即存在,换句话说,大部分情况下当我们需要变量时直接使用就行了。如果是其他语言,一般会出现变量不存在的提示。

从另一角度看,困惑的来源是为什么会存在容易引起误解的两种风格呢?AutoHotkey 最初参照自典型命令式语言 AutoIt v2,这是传统型存在的历史原因,因为有些情况下传统型无法解决,在开发过程中加强了表达式型,所以目前形成并存的局面。

传统赋值与表达式赋值

str = 这是个字符串。 ; 传统赋值
str := "这是个字符串。" ; 表达式赋值

上面这两个赋值语句是等效的,可以看到传统赋值在变量名后使用等号接着是作为赋值内容的字符串。而表达式赋值使用冒号等号,后面的赋值内容括在引号中。看起来似乎传统赋值简单一些,这里确实如此,然而它只适用于赋值字符串、数字、变量或它们组合的内容,并且在包含变量时变量名称必须括在百分号中。现在在看看赋值内容包含字符串和变量的例子:

str = 这是个字符串,后面跟着 Var 变量的内容:%var%
str := "这是个字符串,后面跟着 Var 变量的内容:" var

这时可以发现传统赋值和表达式赋值复杂度上接近,进一步的,如果只赋值变量,显然表达式方式简单了。它们的区别主要不在这里,请看这个例子:

var = 1 + 1
var := 1 + 1

保存为脚本,运行看看结果。这里说明了它们的主要区别,表达式赋值时可以进行运算,操作数可以为数字、字符串、函数调用等。这种情况时无法用传统形式代替。

如果能明确它们的区别,可以两者都用。如果担心会混淆,那么只能采用表达式赋值了,这是我对初学者建议的方法。

If 传统型与 If 表达式型

这里包含好几个命令及等价的比较结构,除了 If 表达式型外我唯一建议使用的是下面这种形式:

If var
    MsgBox, var 变量既不为零也不为空。

这里 If 语句中的 var 是使用这个变量的值,而 MsgBox 命令中则使用这个变量的名称。下面是其他一些句式:

IfEqual, var, value (等同于: if var = value)
IfNotEqual, var, value (等同于: if var <> value) (可以使用 != 代替 <>)
IfGreater, var, value (等同于: if var > value)
IfGreaterOrEqual, var, value (等同于: if var >= value)
IfLess, var, value (等同于: if var < value)
IfLessOrEqual, var, value (等同于: if var <= value)

这些命令都很简单,名称的含义也显而易见。第一个变量是不需要百分号括住的变量,第二个是原义的值。可以使用两种形式:

IfEqual, var, value
    MsgBox, 变量 var 的值为:value

if var = value
    MsgBox, 变量 var 的值为:value

这两种形式完全相同,后一种形式虽然含有等号,但这里不是表达式。现在看看使用表达式的形式:

if (var = "value")
    MsgBox, 变量 var 的值为:value

传统形式比较简单,但也只能比较最简单的情况。下面这个例子则无法使用传统形式代替了:

if (Mod(intYear, 100) ? Mod(intYear, 400) : Mod(intYear, 4))
    MsgBox, % intYear "不是润年。"

还有很多情况无法使用传统形式代替,同时接受两种很可能产生混淆,一是变量什么时候应括在百分号中,二是值什么时候要加引号。

两种同时使用不行吗?

在只赋值字符串时使用传统形式,而需要运算时则通过表达式,两者都很简单,为什么非要两者选一呢?这样的问题不是“行不行”,既然语言自身提供了,当然不会不行。主要问题在于如果没有理解,那么会给您带来困惑,即让您的脚本增加出错的可能。

请看这个例子:

if Var = ""
    MsgBox, 变量 Var 为空。

请闭上眼睛几秒钟,想想什么时候会显示消息框?

【=====================分隔区域,请暂停往下看=====================】

您刚才是否在想当 Var 变量为空时这个 If 语句结果为真呢?这样就错了,实际是只有在 Var 包含一对双引号时才为真。如果要判断一个变量是否为空,正确的传统形式和表达式形式应该这样:

; 传统形式
if Var =
    MsgBox, 变量 Var 为空。

; 表达式形式
if (Var = "")
    MsgBox, 变量 Var 为空。

所以建议完全使用表达式形式,当然在理解的基础上一起用是没问题的。

小结

最后,从 AutoHotkey Basic 到 AutoHotkey_L 中表达式型一直在加强,个人猜测 AutoHotkey v2 版本可能完全转向表达式型(目前测试版中尚未实现,但在 Thoughts for v2.0 中 Lexikos 有这种倾向),所以不论从目前的理解或以后的学习、过渡而言,建议采用表达式型。

]]>
COM 对象的进程内、外运行 2014-08-14T22:10:53+00:00 amnesiac http://amnesiac10.github.io/2014/08/14/com-in-and-out-of-process 正餐外来甜点,点缀生活。总结性的文章尽管有内容,但一般比较长、看起来也累,而实用脚本可能某些读者用不上(可能是暂时),虽然我写的时候告诉你所以然,所以本文既非总结性内容,也非实用脚本,可无需打开编辑器运行实践,但还是能了解些东西的。

下面我们编写一个脚本,让它创建一个 Internet Explorer 实例,并在屏幕上显示,在暂停 10 秒钟后退出:

objIE := ComObjCreate("InternetExplorer.Application")
objIE.Visible := True
Sleep, 10000
MsgBox, 脚本执行完毕。

该脚本将创建一个 Internet Explorer 实例并显示在屏幕中。 在经过 10 秒钟的暂停之后,会出现一条消息,提示您脚本已执行完成。 单击“确定”后,脚本将立即终止。
您可能已经注意到,脚本终止后 Internet Explorer 仍在运行,也就是说,脚本终止后 Internet Explorer 并未终止。这是什么原因呢?是这样,有些 COM 对象(比如,FileSystemObject)与脚本在同一个进程中运行。也就是说,脚本所在进程终止后,在该进程中运行的 COM 对象也将终止运行(这就是进程内运行的含义)。脚本进程终止后,FileSystemObject 也将终止。
您不相信吗?下面我将为您证实。我们编写一段脚本,在脚本中创建 FileSystemObject 的一个实例,打开任务管理器后运行该脚本。此时您会在任务管理器中观察到只创建了一个新进程,这是因为脚本和 FileSystemObject 在同一个进程中运行。

objFSO := ComObjCreate("Scripting.FileSystemObject")
objFolder := objFSO.GetFolder("C:\")
Sleep, 10000
MsgBox, 脚本已运行完毕。

现在打开任务管理器后再次运行前面的 Internet Explorer 脚本,这时应该能够看到新增了两个进程:AutoHotkey.exe 和 iexplore.exe。这是因为 Internet Explorer 在自己的进程中运行。脚本运行结束后,脚本进程(AutoHotkey.exe)将消失,但 Internet Explorer 进程(iexplore.exe)将继续存在。

这很重要,如果不在 Internet Explorer 对象中执行退出操作,它将持续运行并继续占用内存。因为终止脚本的运行不会自动终止 Internet Explorer 程序。

看样子要束手无策了,是吗?别失望。您要终止一个 Internet Explorer 实例吗?只需要确保在脚本中的某处执行 Quit 命令就可以终止此实例。例如,下面的脚本创建将创建一个 Internet Explorer 实例,暂停 10 秒钟后,使用 Quit 命令关闭它,再暂停 10秒钟后,自动终止脚本。如果在打开任务管理的情况下运行此脚本,您将会看到系统创建了两个新进程,即 AutoHotkey.exe 和 iexplore.exe,经过短时间的暂停之后,将会看到 iexplore.exe 和脚本进程先后消失。

objIE := ComObjCreate("InternetExplorer.Application")
objIE.Visible := True
Sleep, 10000
objIE.Quit
Sleep, 10000

提示:有时您会发现脚本编写者将对象引用设置为空,就象下面这样:

objIE := ComObjCreate("InternetExplorer.Application")
objIE.Visible := True
Sleep, 10000
objIE := ""

该语句用来释放对象引用(即 objIE 将不再指向 Internet Explorer 的实例),但它不会终止 Internet Explorer 的运行,实际上,iexplore.exe 将继续运行,就好像任何事情都没有发生,因为确实没有发生任何事情。如果希望关闭 Internet Explorer(这里指在脚本中),就必须使用 Quit 方法。

注:上面的脚本可正常执行于 Windows XP,我不清楚 FileSystemObject 对象在 Windows 7/8 系统中是否存在(请帮忙确认)。

甜点完了,还可口吗?

]]>
实用 AutoHotkey 脚本推荐 2014-08-13T22:10:53+00:00 amnesiac http://amnesiac10.github.io/2014/08/13/practical-script 下面某些脚本在目前的 AutoHotkey 版本上执行时可能需要调整,这里许多脚本也很有趣。

AutoHotkey 帮助中的脚本展示

这里随意选几个例子:

Screen Magnifier [屏幕放大镜](作者: Holomind)
Mouse Gestures [鼠标手势](作者: deguix)
IntelliSense [智能感应](作者: Rajat)
On-Screen Keyboard [屏幕键盘](作者: Jon)

更多的脚本请到帮助页面,请点击上面的标题直达。

论坛及其他站点收集的脚本

这里的论坛包括官方论坛(新论坛旧论坛)和中文论坛

AHK 版的俄罗斯方块?发送消息到 QQ 网站?

我把 AutoHotkey 学习指南推荐的一些脚本转载过来:

HK4WIN [通过热键执行系统中的大量常用操作](作者: 宋瑞华)
发送消息到QQ网站(作者: ddandyy)
Candy [把内容通过关联程序快捷操作](作者: 万年书妖) Candy改进版使用介绍
Lock Screen Appinn [屏幕密码锁]
GridMove [便捷窗口管理工具](作者: GridMove)
Folder Menu [文件夹快速切换工具](作者: rexx) 使用介绍
AHK俄罗斯方块 (作者: dracula004)
Qliner Hotkeys [使用屏幕键盘设置热键] 使用介绍
Texter [在 GUI 中设置热字串](作者: AdamPash) 简单介绍
验证码识别 (作者: lskxt)
ViATc [使用类 VI 模式操作 TC](作者: linxinhong)
Appifyer [应用程序集成和启动工具](含视频)(作者: sumon)
nDroid [快速启动程序的工具](作者: Rajat)
林可LINK [快捷方式管理及通过热字符启动](作者: megalove)

更多的脚本应用集中站

如果您喜欢 AutoHotkey 就像老鼠爱大米,那么下面这些就是米缸了,其中一些看成粮仓也不算过(官方和中文论坛已在如何学习 AutoHotkey?推荐过,这里不再列举它们的脚本与函数版块)。

小众软件 AutoHotkey 专区:提供了一些贴合国内实际的脚本。
Ahk Standard Library Collection:较适合 AutoHotkey Basic 的函数库。
1 Hour Software by Skrommel:短小精悍的一系列脚本,作者功力不凡。
AHK-Scripts for TotalCMD:定制、扩展及增强 Total Commander 的脚本集。
Rosetta Code AutoHotkey Category:演示了许多独辟蹊径的用法,很有趣。
JGPaiva’s AutoHotkey Coding Snacks:众多简单实用的小工具。

原本我想针对每个作单独详细的介绍,如列举一些典型实用的脚本,不过又想这种看法偏主观,所以还是自己去选吧。

小结

想看看更多有趣的应用?

学习别人使用 AutoHotkey 的方法。

]]>
amnesiac 的 Everything 热键 2014-08-12T22:10:53+00:00 amnesiac http://amnesiac10.github.io/2014/08/12/my-hotkey-for-everything amnesiac 是我的 ID,以后不再赘述。

Everything 是个不错的软件,许多人应该都将它列为开机启动的程序之一。它本身提供了热键功能,包括新建窗口、显示窗口和切换窗口,通过切换窗口用热键控制激活和隐藏 Everything 在实际中足够了(如下图),所以下面这个脚本并不很必要。不过如果用类似的方式控制 Total Commander 效果就出来了,它较小众所以这里不作为例子。 Everything 选项对话框

在启动 Everything 后,用一个热键可激活其搜索窗口(当最小化或隐藏时)、隐藏其窗口(当活跃时)。

脚本

; 环境:WIN_XP; AutoHotkey 1.1.15.00 Unicode; Everything V1.3.3.658b (x86)

EverythingExe := "d:\Software\Everything\Everything-1.3.3.658b.x86.exe"

F1::    ; 打开/最小化/激活 Everything
IfWinActive, ahk_class EVERYTHING ; 窗口当前活跃,关闭(隐藏到后台了)。
{
    WinClose
    return
}
DetectHiddenWindows, On
IfWinNotExist, ahk_class EVERYTHING_TASKBAR_NOTIFICATION ; 未启动。
{
    Run, %EverythingExe%,, Max
    WinWait, ahk_class EVERYTHING_TASKBAR_NOTIFICATION,, 2
    if (ErrorLevel = 1)
    {
        MsgBox, 4112, 错误, Everything启动失败。
        return
    }
}
IfWinNotExist, ahk_class EVERYTHING ; 已启动但不存在窗口,说明在后台。
{
    PostMessage, 0x312, 0, 0x700000,, ahk_class EVERYTHING_TASKBAR_NOTIFICATION
    WinWait, ahk_class EVERYTHING,, 1
}
IfWinNotActive, ahk_class EVERYTHING ; 窗口不活跃,激活。
    WinActivate
return

分析

由于 Everything 可能未启动、启动了未激活(即最小化或隐藏)或激活状态,所以先理顺思路:

  • 若 Everything 窗口活跃,则退出(或隐藏);
  • 若 Everything 窗口不存在,有两种情况;

当 Everything 窗口不存在时,有可能:

  • Everything 未启动,则启动并激活;
  • Everything 已启动,则激活窗口;

这里,不论是否已启动都需要激活窗口,所以合并到一起。接着重点说说 EVERYTHING_TASKBAR_NOTIFICATION 这个窗口,怎么来的呢? Microsoft Spy++ 进程列表

打开 Microsoft Spy++,在 Everything 进程中可以看到这个隐藏窗口(应该在选项中选中后台运行才会存在,若不选则关闭时会退出)。

前面已经设置“切换窗口热键”为 F1,这里监视这个隐藏窗口的消息后按下 F1: Microsoft Spy++ 消息窗口

Everything 的窗口出现了,同时消息窗口中也显示了隐藏窗口处理的消息,很幸运第一条即是我们需要的。其中 P 表示 PostMessage,后面包含了参数(属性窗口中查看更方便,需要注意这些值都是十六进制,作为命令参数时需加“0x”),因此得到这条命令:

PostMessage, 0x312, 0, 0x700000,, ahk_class EVERYTHING_TASKBAR_NOTIFICATION ; 必须开启隐藏窗口检测才有效。

0x312 表示 WM_HOTKEY,相关说明请参考 WM_HOTKEY 资料页

刚才关于消息的来源介绍时只是蜻蜓点水,详细说明需要在另一篇专门文章了,若有兴趣可先参考帮助中的消息指南。此外,后台运行的程序都会有隐藏窗口,有了窗口才能与系统、用户或其他进程交互(可能说法不够严谨,请专业人士斧正)。AutoHotkey 脚本运行时也有(即使不包含图形界面),这个隐藏窗口的妙用有机会再聊。

其他

我的实际脚本

我实际使用的脚本与上面有些差异:

  • 有了 Listary 后,Everything 不经常启动了(所以不随系统启动);
  • 在启动前要设置 Everything.ini 以在 TC 中打开文件/文件夹路径;
  • 定制了一些仅用于 Everything 的热键,用于快速执行常用过滤等;

由于各人习惯有异、实现的方法都比较简单且适用性不大,这里不发了。

Total Commander 热键

尽管通过前面的内容应该能自己写出来,想想还是放在这里吧(这个更简单)。

TotalCommanderExe := "d:\Software\totalcmd\TOTALCMD.EXE"
Return

F1::    ; 打开/最小化/激活TC
IfWinNotExist, ahk_class TTOTAL_CMD
{
    Run, %TotalCommanderExe%,, Max
}
Else
{
    IfWinNotActive
    {
        WinMaximize
        WinActivate
    }
    else
    {
        WinGet, TcWinState, MinMax, A
        If (TcWinState = 1)
            WinMinimize
        Else
            WinMaximize
    }
}
Return
]]>
粘贴网页内容时附上来源 2014-08-12T02:26:23+00:00 amnesiac http://amnesiac10.github.io/2014/08/12/paste-with-source 许多朋友经常摘录一些网页内容到其他地方,供查阅、编辑等,在这时,常常要复制两次,一次是内容,接着一次是内容所在的网址。脚本比较简单,只有一个热键,当我们粘贴从网页中复制的内容时,它会自动附加上网页的地址。

脚本

最初我写了这种功能的脚本,但一些方面处理不太好,下面这个脚本是 Lexikos 重写的,比较完善,不影响其他复制粘贴操作。

原理是,从网页复制内容时其中的内容实际上包含了来源,所以直接从中提取。

~^v:: 
; 最初灵感:http://ahk8.com/thread-4198.html
; 脚本来源(英文):http://www.autohotkey.com/board/topic/82393-auto-attach-its-url-when-copy-from-a-webpage/#entry525258
Sleep 100
CF_HTML := DllCall("RegisterClipboardFormat", "str", "HTML Format")
bin := ClipboardAll
n := 0
while format := NumGet(bin, n, "uint")
{
    size := NumGet(bin, n + 4, "uint")
    if (format = CF_HTML)
    {
        html := StrGet(&bin + n + 8, size, "UTF-8")
        RegExMatch(html, "(*ANYCRLF)SourceURL:\K.*", sourceURL)
        break
    }
    n += 8 + size
}
if !sourceURL
    return
Clipboard := "`nSource: " sourceURL
Send ^v
Sleep 250
Clipboard := bin
return

使用时开启脚本后与平常一样复制, 然后使用 Ctrl + V 粘贴就行(鼠标粘贴无效)。

实际效果

我复制【其他】Copyheart、改版中的部分内容,如下: 复制网页内容

粘贴到 Word 中后(因内容过宽,右边部分被截除) 粘贴到 Word 中

可以看到在原内容后自动增加了文章的网址,以后复制网页内容(包括从浏览器、CHM 文件等复制的情况)时开启这个脚本就方便多了。

小结

可根据需要调整脚本,上面的脚本中没有注释,如果有兴趣进一步了解原理,请参阅:

  • CF_HTML 剪贴板格式的数据结构:HTML Clipboard Format
  • 最初的实现思路及改进过程: 上面脚本中的来源链接
]]>
常见的编码问题 2014-08-11T02:26:23+00:00 amnesiac http://amnesiac10.github.io/2014/08/11/common-encoding-question 编码是每个脚本人、程序员最常见的困惑之一(中文用户尤其常遇),在 AutoHotkey 也不例外。这里一起说说在 AutoHotkey 中可能遇到的编码问题,以及可以避免这些问题的方法。

在 AutoHotkey 中谈到编码时可能在三种情况中:

脚本文件编码

首先请阅读帮助中相关内容:脚本文件代码页。这里说明了解释器(AutoHotkey.exe)加载脚本时选择编码的优先级顺序:

  1. 若脚本文件开头为字节顺序标记(BOM),则据其选择相应的编码(UTF-8 BOM 或 UTF-16 BOM);
  2. 若解释器命令行中包含了 /CPn 选项,则使用 n 指定的编码;
  3. 其他情况下,则使用系统默认代码页(一般简单称为 ANSI 编码)。

一般情况下建议脚本文件使用 ANSI 或 UTF-8 BOM(注意必须加上 BOM)编码,这样一般情况下脚本文件都能正常加载,但下列两种情况中必须使用 UTF-8 BOM 编码,否则脚本加载或执行时会出问题:

  • 脚本文件中包含多种非 ANSI 编码字符集的字符,如同时包含简体中文和俄文;
  • 脚本可能在不同默认代码页的系统中执行,例如在简体中文系统和日文系统;

对于简体中文 Windows 系统,默认代码页为 CP936(也可以认为是 GB2312、GBK 或 GB18030),这是种双字节字符集(缩写 DBCS,是多字节字符集即 MBCS 的一种,与单字节字符集 SBCS 相对;典型的多字节字符集如汉字,而单字节字符集常见于欧洲语言)。对于上面的第二种情况,如果脚本只包含 ANSI 字符,那么应该也能正常执行,不过中文用户脚本中不包含汉字的可能性有多大呢?

注:AutoHotkey Basic 默认脚本编码为 ANSI;对于 AutoHotkey_L,在 1.1.08.00 版本之前,ANSI 构建(build)默认脚本编码为 ANSI,但 Unicode 构建默认编码为 UTF-8,为了减少混乱,该版本之后脚本默认编码都使用 ANSI(所以 UTF-8 编码的脚本必须包含 BOM 头部才能被正确识别)。

SciTE4AutoHotkey 中在工具中设置默认代码页为 UTF-8 时创建的 UTF-8 脚本不含 BOM: SciTE 设置对话框

此时【File】【Encoding】中显示的编码并不会改变,因为上面的默认代码设置是通过脚本实现的,因此若要使用 UTF-8 BOM 编码,则每次创建脚本时都需要手动在该菜单下选择【UTF-8 with BOM】,幸运的是中文论坛已经有人解决了该问题(SciTE4AutoHotkey 新建编码为 UTF-8SciTE4AutoHotkey 新建文件默认编码 UTF-8 with BOM:补充后一方法,待验证)。对于其他编辑器(如 Notepad++),必须注意选择 UTF-8 编码时是否包含了 BOM(不同的编辑器有所区别),例如记事本,另存时编码中的 UTF-8 是包含了 BOM 的。

脚本编码错误一般具体表现为:脚本加载时出现错误或执行时字符串出现问题(如 MsgBox 显示乱码),因为无效的或不存在于原生代码页中的字符会被替换为占位符:ANSI’?‘或 Unicode‘�’。

基于上述的说明,推荐脚本文件编码统一使用 UTF-8 BOM。

文件编码

文件编码是指在读取和写入文件(文件 I/O)时使用的编码。FileEncoding 可以设置默认编码(A_FileEncoding 包含了脚本当前的默认编码设置),它会被用于 FileRead、FileReadLine、FileAppend、FileOpen 和文件读取循环,不过其中一些命令(函数)中可以使用编码参数覆盖该默认设置。

注意下面两点:

  1. 在读取文件时,若文件头部包含 BOM,则优先使用该标记指示的编码;
  2. 如果当前命令中未指定编码参数,之前也未设置默认编码,则使用系统默认编码。

INI 文件编码

IniRead 和 IniWrite 总是使用 UTF-16 或系统默认代码页,即除了 UTF-16 编码(通过 BOM 判断)外,其他所有情况都被视为系统默认编码。

原理: IniReadIniWrite 依靠外部函数 GetPrivateProfileString 和 WritePrivateProfileString 来读取和写入值,这些函数仅支持 UTF-16 编码的 Unicode 文件,其他所有文件都被认为使用系统默认代码页。

对于 IniRead 支持的 UTF-16 编码,需注意下面几点:

  1. 实际仅支持 UTF-16 LE BOM 一种形式,其他可能出问题;
  2. 在 ANSI 构建中也支持 UTF-16 LE BOM 编码的 INI 文件;
  3. 当 IniWrite 的目标文件不存在时,ANSI 构建使用系统默认编码创建文件,而 Unicode 构建使用 UTF-16 LE BOM。 所以,如果希望使用指定编码的 INI 文件,则需先使用 FileAppend 并指定编码创建文件。

下图是 Notepad2 中编码设置界面,INI 文件编码必须选择 ANSI(936)Unicode (UTF-16 LE BOM) 脚本才能正常读取和写入: Notepad2 编码设置对话框

建议脚本的 INI 文件统一使用 UTF-16 LE BOM 编码,以避免脚本在不同系统中运行可能遇到的潜在问题。小头(Little endian)、大头(Big endian)的更多相关知识请参阅:字符编解码的故事(ASCII,ANSI,Unicode,Utf-8区别)字符编码笔记:ASCII,Unicode和UTF-8

字符串编码

这部分属于进阶内容,相对于前面的内容有较高难度,多实践是理解的关键。

字符串编码是指内存中存储字符时使用的编码,这种编码被称为可执行程序的原生编码,相关帮助内容请参阅 Unicode 与 ANSI 两种构建的比较。简而言之,字符串编码与构建有关,Unicode 构建使用 UTF-16 LE 编码,而 ANSI 构建使用系统默认编码,AutoHotkey Basic 与 ANSI 构建相同。各分支比较的相关说明请参阅选择哪个分支?

编码转换

通常我们无需关心字符串编码,例如赋值或显示时如果需要转换都会自动进行, 但通过一些高级方法操作字符串则必须考虑,例如在 DllCall、PostMessage/SendMessage、NumPut/NumGet、Capacity 和 StrPut/StrGet 中处理字符串时。

此时一般操作过程为:首先确定原生编码(通过 A_IsUnicode),然后计算目标字符串的大小。例如:

; 获取汉字的 GBK 编码,适用于 AutoHotkey_L 中两种构建。
SetFormat, integer, H    ; 让最后获取的编码为十六进制格式。
Char := "中"

; 因不同构建原生编码不同,所以需分别处理:
If A_IsUnicode
{
    VarSetCapacity(GBKChar, 3) ; 一个汉字的 GBK 编码占用两个字节,加上字符串截止符。
    StrPut(Char, &GBKChar, "CP936")    ; 对于简体中文系统,编码参数中使用 CP0 亦可;这里的 GBKChar 为二进制变量,无法通过常规赋值。
}
else
{
    GBKChar := Char
}
GBKCode := (NumGet(GBKChar, 0, "UChar") << 8) + NumGet(GBKChar, 1, "UChar")
MsgBox, % GBKCode ; 显示“中”的 GBK 编码为“D6D0”(这里显示为“0xD6D0”)

上面获取编码时为什么需要那么复杂?下面这样不行吗?

GBKCode := NumGet(GBKChar, 0, "UInt") ; 这里获取到的编码将为“0xD0D6”。

可以发现 GBK 编码在内存中存放时使用大头方式(即第一个字节在前,先高位、后低位),在低字节(LChar)与高字节(WChar)(还有低字与高字)的编码问题时经常需要需要注意这种情况。下面这样是可以的(将显示“0xD60xD0”):

GBKCode := NumGet(GBKChar, 0, "UChar") . NumGet(GBKChar, 1, "UChar")

那么使用下面这样呢?

GBKCode := Asc(GBKChar) ; 与上面的示例不同,此处的 GBKChar 仅指中文字符。

Asc() 可以获取首个字符的编码,但在 ANSI 构建中的原生编码为系统默认编码,所以一个字符占用一个字节(双字节字符集编码中两个编码的字符实际上被视为两个独立单元),所以显示“0xD6”;而 Unicode 构建中原生编码为 UTF-16 LE(由于在内存中,所以无需字节顺序标记),所以这里使用该方式获取字符编码即小头方式,所以显示“0xD0D6”。

刚才的例子看了可能困惑多于收获,为了让大家掌握字符串编码转换的要领,接着再看另一个例子(这次是解疑):

SetFormat, integer, H

NativeString := "中"
StrCap := StrPut(NativeString, "CP65001")
VarSetCapacity(UTF8String, StrCap)
StrPut(NativeString, &UTF8String, "CP65001")
Loop, % StrCap - 1 ; StrPut 返回的长度中包含末尾的字符串截止符,因此必须减 1。
{
    UTF8Codes .= SubStr(NumGet(UTF8String, A_Index - 1, "UChar"), 3) ; 逐字节获取,去除开头的“0x”后连接起来。
}
MsgBox, % UTF8Codes ; 显示“E4B8AD”,前面附加“0x”就变成十六进制了。

这里尽管还是转换编码,但许多地方采用适应性更强的方式(如设置变量容量、获取编码和连接字符串的方式),同时还能明白这些具体是怎么来的。不论转换到什么编码,方式还是这一套。所以以后需要转换编码时,无需去记住哪种编码是大头还是小头存储,只需先用个字符测试一次:按顺序逐字节获取并连接起来,和实际比较,不符合时调整顺序就行了。 其中,连接字符串的方式之前使用数值计算(左移),这里则采用字符串连接,个人感觉这种方式更好:

UTF8Codes .= SubStr(NumGet(UTF8String, A_Index - 1, "UChar"), 3)

AutoHotkey 中转换编码可以通过 Transform 的 FromCodePage/ToCodePage 两个子命令或 Windows API 进行,不过目前建议使用 StrPut/StrGet 代替。

字符编码常用在与网络交互时,如提交内容到网页或获取网页返回的内容。为了方便,这里把刚才的操作写成函数:

SetFormat, integer, H

String := "汉字"
MsgBox, % Encode(String, "CP65001")
return

Encode(Str, Encoding, Separator = "")
{
    StrCap := StrPut(Str, Encoding)
    VarSetCapacity(ObjStr, StrCap)
    StrPut(Str, &ObjStr, Encoding)
    Loop, % StrCap - 1
    {
        ObjCodes .= Separator . SubStr(NumGet(ObjStr, A_Index - 1, "UChar"), 3)
    }
    Return, ObjCodes
}

这里加了个 Separator 参数,在谷歌中搜索“汉字”时,从网址里可以看到被编码为:

%E6%B1%89%E5%AD%97

而在百度中,则被编码为(这里为 GBK 编码,调用时编码参数为“CP936”):

%BA%BA%D7%D6

所以,此时分隔符中使用百分号就行了。

最后,转换字符串编码时建议在 StrPut/StrGet 的编码参数中尽量不使用“CP0”,而指明具体的编码。因为“CP0”表示系统默认编码,与系统有关。不使用 Asc 也是因为它依赖于 AutoHotkey_L 构建,尽管在特定构建中它可能比较简单。

小结

多实践、多小结以加深理解,使用推荐的编码方式可以减少实际中可能遇到的编码问题。此外,需注意下面几点:

  • “一个汉字占用两个字节”的说法不严谨,应具体指明字符集(charset)和编码(encoding);
  • 在 Basic 版本中,Asc(char) 总是将一个字节视为一个字符,即完全等同于:
    • NumGet(char, 0, "UChar")
      但 AutoHotkey_L 中则能正确处理字符,Asc(char) 总是能获取一个字符的编码(包括单字节字符集和双字节字符集中的字符),因为它是依据原生编码的方式获取的(不过二进制变量通常必须手工处理才能获取正确编码)。
]]>
让热键动起来 2014-08-10T02:16:23+00:00 amnesiac http://amnesiac10.github.io/2014/08/10/dynamic-hotkey 引子:有些文章只看它的标题,您就看不出它的内容,则很可能错过它的精彩,本文即是其中一例。

使用双冒号语法可以快速创建热键,简单、直接,非常方便。有时我们需要经常修改一些热键或把脚本给别人使用, 通常必须考虑把热键放在专门的配置中以方便修改,此时就需要动态实现热键了。

使用 hotkey 命令

这个命令本身的用法简单,这里结合常见的具体场景介绍:

; 为了简便这里直接赋值变量(实际情况中可从配置文件读取):
MyHotkey := "F1"

; 下面这个热键仅在记事本中有效:
Hotkey, IfWinActive, ahk_class Notepad
Hotkey, %MyHotkey%, MyLabel_1
; 下面这个热键为全局热键:
Hotkey, If
Hotkey, %MyHotkey%, MyLabel_2
; 下面这个热键在记事本或写字板窗口活动时有效:
Hotkey, If, WinActive("ahk_class Notepad") || WinActive("ahk_class WordPadClass")
Hotkey, %MyHotkey%, MyLabel_3
Return

; 这里使用不同的标签,是便于调试具体生效的是哪个变体:
MyLabel_1:
MyLabel_2:
MyLabel_3:
MsgBox, 您按下了 %A_ThisHotkey% 热键(%A_ThisLabel%)。
Return

; 上面的第三个热键需要该指令才起作用(注:这里的“||”替换为“or”无效):
#If, WinActive("ahk_class Notepad") || WinActive("ahk_class WordPadClass")

下面进行简单的分析:

Hotkey, IfWinActive, ahk_class Notepad

设置热键生效的窗口条件,可使用窗口存在/不存在/活动/不活动(IfWinActive/IfWinNotActive/IfWinExist/IfWinNotExist),效果等同于对应的系列指令(不过作用对象有区别,一个是双冒号热键,一个是 hotkey 热键)。

Hotkey, IfWinActive

取消热键条件,其中的 IfWinActive 可替换为 IfWinExist/IfWinNotActive/IfWinNotExist/If(注:这里还包括 If)。

Hotkey, IfWinActive, ahk_class Notepad
Hotkey, IfWinActive, ahk_class WordPadClass
Hotkey, %MyHotkey%, MyLabel_1

这种条件与对应指令类似,效果是互斥的,所以上面这个热键不会在记事本和写字板中都生效。

Hotkey, If, WinActive("ahk_class Notepad") || WinActive("ahk_class WordPadClass")

尽管这里的条件仍与窗口有关,不过实际上可以任意表达式,只需满足存在相应的 #If 指令且它们包含的表达式完全一致。

#If, WinActive("ahk_class Notepad") || WinActive("ahk_class WordPadClass")

原本 #If 指令是与位置有关,但由于这个脚本中没有双冒号热键和热字串,所以这里放在什么位置关系不大。

关于热键优先级:一般而言,钩子热键优先级最高,最近启用的优先级更高,局部变体优先级高于全局变体(多个局部变体都有效时最先启用的优先级更高)。前面的有些结论可能仅适用于同一脚本而言,不同脚本及与其他程序之间实际情况比较复杂。

热键的优先级与启用顺序和作用范围有关,但先创建的并不总是高优先级。

双冒号热键和 Hotkey 创建的热键是分别管理的,后者是通过该命令的选项管理自身创建的热键,具体请参阅帮助。Hotkey 命令不能直接启用或禁用脚本中不是它创建的热键,但在大多数情况下它可以通过创建或启用相同的热键来覆盖它们。

使用 Input 命令

请用 AutoHotkey 实现按任意键继续的功能。

请思考,您会如何实现?很容易想到命令提示符中的 pause 命令,在批处理中执行某些操作前先提示用户,随意按某个键后继续执行,AutoHotkey 中应如何实现相同功能?也许实际中不一定需要,但思考可以锻炼思维。先看看我的实现:

; 建立所有按键列表,尽管可能有些键未包含在其中,这里用于演示。
AllKeyList := "a|b|c|d|e|f|g|h|i|j|k|l|m|n|o|p|q|r|s|t|u|v|w|x|y|z|{LControl}|{RControl}|{LAlt}|{RAlt}|{LShift}|{RShift}|{LWin}|{RWin}|{AppsKey}|{F1}|{F2}|{F3}|{F4}|{F5}|{F6}|{F7}|{F8}|{F9}|{F10}|{F11}|{F12}|{Left}|{Right}|{Up}|{Down}|{Home}|{End}|{PgUp}|{PgDn}|{Del}|{Ins}|{BS}|{Capslock}|{Numlock}|{PrintScreen}|{Pause}"
Loop, Parse, AllKeyList, |
{
    ; 把 Send 参数中发送模拟按键的格式转成热键参数中的按键格式:
    OneKey :=  Trim(A_LoopField, "{}")
    Hotkey, ~%OneKey%, Operation
}
Return

Operation:
MsgBox, 您按下了 %A_ThisHotkey% 键。
Return

按任意键,就建立“任意键”的热键,思路很明确。现在要转换成“提示用户一些信息并按任意键继续”就简单了,加上一对 ToolTip(在该片段前加带参数的 ToolTip 而 Operation 子程序中加不带参数的)或使用类似的方法。

上面虽然实现了功能,不过从编写效率或移植角度看这种实现不好(如果都用双冒号会更糟糕),热键功能是 AutoHotkey 的特色,有更好的实现吗(其他脚本中蹩脚是正常的)?

; 来自帮助(一行命令,干净简洁):
Input, SingleKey, L1, {LControl}{RControl}{LAlt}{RAlt}{LShift}{RShift}{LWin}{RWin}{AppsKey}{F1}{F2}{F3}{F4}{F5}{F6}{F7}{F8}{F9}{F10}{F11}{F12}{Left}{Right}{Up}{Down}{Home}{End}{PgUp}{PgDn}{Del}{Ins}{BS}{Capslock}{Numlock}{PrintScreen}{Pause}
; 需要注意,由于 SingleKey 只记录按键按下后生成的字符,所以产生不可见字符的按键应放在 EndKeys 参数中。

这里实际上创建了一批热键,比较而言,Hotkey 创建单个热键很方便,但创建批量热键时应优先考虑 Input。创建批量热键的一种典型的情况是类似码表式输入法,其中需要批量转译输入为对应的输出,一个码表(保存了输入字符组与输出字符组的对应关系),一个平台(脚本中实现转换功能),该平台中核心功能就是 Input:

Input, Code, C I L4, {Space}{Enter}{Esc} ; 码表式输入法核心示例。

例如五笔输入法的特点:最长四码,按空格键可输入一、二、三级简码。当然,实际情况比较复杂,需要处理全角、半角、标点符号和特殊按键,加上候选框就更复杂了。到这里,您理解我之前曾说热字串是序列键(热键的一种)吗?刚才谈论的功能和热字串本源上都是热键。

说到这个命令,必须和大家分享一段代码,实现很精妙(原本想放到 AutoHotKey 常用函数或小技巧有哪些分享?上的,但可能不容易理解):

; 节选自小众屏幕密码锁(http://www.appinn.com/lock-screen-appinn/)并做了简单调整
Key := "test"
i := 1
Loop ; 锁定后就一直处于循环状态,直到解锁
{
    Input, a, L1
    Temp := SubStr(Key, i, 1) ; 提取 Key 中第 i 位字符
    If (a = Temp)
    {
        i++ ; 准备匹配下一位。
    }
    Else
    {
        i := 1 ; 重头开始匹配。
    }
    If (i= StrLen(Key) + 1)    ; 输入匹配了,退出循环。
    {
        MsgBox, 您输入了正确的通行码。
        Break
    }
}

前面几句可能不好理解,我们考察开始的几次循环:i=1 时接受一位输入并与 Key 中的第一位比较,相同则 i 自增,否则继续从新开始。i=2 时(第一次的输入已经匹配 Key 中的第一位),继续接受输入一位与 Key 中的第二位比较……若连续 i 位都与 Key 中的第 i 位相同,即刚才输入的字符串已经完全匹配 Key。

为什么不按下面这样实现?

Key := "test"
i := 1
Loop
{
    Input, a, % "L" StrLen(Key)
    If (a = Key)
    {
        MsgBox, 您输入了正确的通行码。
        Break
    }
}

乍一看,他们的功能好像没什么区别,自己动动手比较吧(如果不实践,永远不会知道看起来正常的代码却不会按预期运行的原因)。另外,小众屏幕密码锁也是个很实用的功能,输入密码时别人看不到,也不容易猜出来(先输一些错的字符)。

使用 A_PriorKey

上面没有提到,实际上使用 Input 是有局限性的,如不支持鼠标按键,而通过 A_PriorKey 不仅能检测到按键,还能检测到按钮(需要安装钩子):

#InstallKeybdHook
#InstallMouseHook
Loop {
    ToolTip % A_PriorKey
    Sleep 100
}

之前说到任意键,您意识到按钮了吗?

小结

动态热键的用途不限于本文开头介绍的情况, 例如还可以快速创建大量热键。

]]>