You are on page 1of 69

序言

原版载于 http://speedfirst.mysmth.net

speedfirst
speedfirst@sina.com
2008-12-4
目录

目录
0. 序言 ................................................................................................................................... 1

1. 基本编程元素 ................................................................................................................... 6

2. 对象 ................................................................................................................................. 12

3. 开始编写完整的程序 ..................................................................................................... 19

4. 操作文件 ......................................................................................................................... 24

5. 事件 ................................................................................................................................. 34

6. 用户窗体 ......................................................................................................................... 38

7. 根据 VBA 制作展示 PPT ................................................................................................. 42

8. 提取 Word 中未样式化的标题 ...................................................................................... 51

9. 操作数据库 ..................................................................................................................... 55

10. 操作 Windows API ....................................................................................................... 61


序言

0. 序言
某圣贤说过,人和动物的最大之不同在于人知道怎么利用工具。VBA 就是一种工
具,一种可以创造工具的工具。VBA 提供了给你充分的自由,做几乎任何其他编
程语言或者环境能做的事情,避免重复的手动劳动。当然,有太多人对编程充满
了敬畏,认为编程是类似周小川做金融决策,或者爱因斯坦在思考问题那样很玄
幻、难以理解的事情,更不用说运用了。但其实,这更多的是误解(我觉得这些
误解源自于国内的编程教科书上那些高深莫测的概念和诘屈聱牙的讲解)。事实
上,20 多年来编程正朝着越来越人性化,越来越容易学的方向发展。VBA,确切
的说是 VB(我在下面会解释这个微妙的差别)是众多语言中最容易学的,正如
其名字一样,又”Visual”,又”Basic”。所以大可不必担心诸如“我没有编
程基础”之类的心理障碍。

VBA 的全称是 Visual Basic for Applications 的简称。其意思是开发环境被整


合到了某个应用程序的 Visual Basic 语言。在本教程中特指整合在 MS Office
中的 VBA。(其余的还有比如 VBA for AutoCAD, VBA for CorelDraw)我简单的
将一门编程语言分为两个大的部分,第一部分是其语法。语法规定了编程指令执
行的顺序和内存的使用方式。尽管如此,语法本身不能解决任何实际的问题,这
就好比你知道了英语语法,但是不懂单词和短语,就不能正确沟通一样;另一个
部分我称之为“库”,就是其他人写好了的,可以完成一定功能的东西。你可以
调用库的某一个部分来完成自己想做的事情。语法和库在一起合作,最终可以完
成复杂的任务。对于 VBA 来讲,使用的是 VB 的语法(确切的说是 VB6 的语法,
与目前的 VB.Net 完全不同,如果你不知道我在说什么,那么就忽略这句话好了),
采用的是 COM 组件这种库,库的功能是让你能操作 Office 的各种功能(VB 也包
含一个基本的库,以完成一些常见的操作,比如访问文件,字符串操作,数学运
算等。这个库的形式是 Func 和 Sub,而不是 COM)。比如代码:

For i = 1 To 100
ActiveWindow.Captain = i
Next

完成的工作是让当前 Office 组件的窗口标题在一瞬间从 1 变成 2,3,…,100。


FOR…NEXT 是 VB 规定的语法,用于循环。这些词语被称为“关键字”,你不能
将关键字用于其他用途。而 ActiveWindow 是指当前活动的 Windows 窗口,Captain
指窗口标题,这些就是提供 Office 功能的 COM 组件。关于 COM 组件到底是怎么
工作的我会在第 1 章进行更详细的介绍。你可以类似的写出如下的代码:

sum = 0
For i = 1 To 100
sum = sum + i
Next

1
序言

就变成了求 1+2+3+…+100 的和。而:

For i = 1 To 100
MsgBox i
Next

可以显示 100 次对话框,依次显示 1,2,…,100。可见语法并不关心你到底用了


哪些库在做什么,而仅仅是完成其任务而已(在这里是循环)。在今后的教程中,
我会将重点放在介绍 Office 库提供的各种功能上,而不会花很多时间介绍语法。
仅仅是在第一次用某种语法举例子时,我会说明一下。很快你就会发现这是很简
单的一件事情。

在正式介绍编程之间,我简单说一下 VBA 的开发环境,也就是你写代码的地方。


如果你用的是 Office 2003,在“工具”菜单找“宏”,在其子菜单中找”Visual
Basic”,就可以打开一个新的窗口。如果是在 Office 2007 中,若你没看到“开
发工具”选项卡的话,先到选项中打开它。

然后在“开发工具”选项卡的“代码”区域里按”Visual Basic”按键,就可以
打开同样的界面。(我用 Excel 举例)

2
序言

2003 和 2007 的界面完全一样。因为 Office2007 添加了若干新的功能,所以只


有库比起 2003 来多了些东西,表面上看不出来。 在“工程”视图中,右键单
击”ThisWorkbook”,然后 在菜单中选“插入”->“模块”,就会在主界面出现
一片空白。键入我在图中写的代码:

Sub test()
MsgBox "Hello, VBA"
End Sub

然后点击上面的绿色箭头,就可以运行它了。看看出了什么?:)这就是你的第
一个 VBA 程序了。记住这个写代码的过程,以后我们会反复用到它。并且我不会
再重复。如果你有兴趣,在桌面上新建一个文本文件,将 MsgBox "Hello, VBA"
这句放到里面(不要第一句和第三句),然后保存为 test.vbs。(注意不是
test.vbs.txt,一定要把默认的扩展名去掉)然后双击这个文件,你又看到了什
么?(如果有杀毒软件或者防火墙问你是否执行脚本,请确认允许。这段代码不
会带来任何伤害)。

以下是对一些常见问题的回答:

1. 宏和 VBA 是什么关系?广义上来讲,宏就是 VBA。我说“一段宏代码”和


“一段 VBA 代码”是等价的。Office 的自动录制功能可以帮你录制一个
宏,实际上就是把你的动作用 VBA 代码的形式记录下来。狭义上,宏是指
一个无参数的非 Private 的 Sub(我会在后边解释什么是 Sub),如果手
写代码的话,只有这种 Sub 才会出现在“宏”那个对话框的列表里。

3
序言

2. 既然可以录制宏,为何还要手写代码?首先,宏录制的局限性很大,很多
动作(比如鼠标行为)是录不下来的。另外宏只能录制顺序的多个指令,
如上面的比较复杂一点的循环就不可能录制。而且宏录制不能覆盖 VBA
的强大功能。例如 VBA 可以直接读写文件系统中的任何文件。这些动作是
录不下来的。最后,录制宏得到的代码非常死板,不灵活,而且很冗余。
如果你知道了怎么写,就会立刻抛掉这个宏录制功能。事实上
PowerPoint2007 已经取消了宏录制。
3. VBA 能干什么?如果你想避免重复劳动,或者需要多个文档协作,VBA 是
最好的选择。虽然默认下,VBA 只可以操作 VB 自带的库和 Office 的库,
但可以通过添加引用的方式来为其扩充功能。理论上 VBA 可以操作任何
Windows API(代表你的 Windows 能做的任意事情),和任意用 COM 封装
的组件。比如像 Acrobat,EndNote 等都是这么做的。它们可以使自身的
功能和办公紧密结合。如果你高兴,大可以写一个 Word 用的聊天插件:)
4. VBA 不能干什么?什么时候不适合使用 VBA?VBA 的功能取决于其库。如
果没有库提供一个功能(比如让 Excel 一个单元格显示一副图片),那么
VBA 也不能帮你。此外,你还可以用 VBA 编写插件来完成普通 Office 不
能完成的功能。此外,如果你开发的项目过于庞大,则应该换一门更专业
的编程平台(比如.Net),以便更好的组织代码和查错。VBA 比较适合短
小的,与工作任务很直接的问题。
5. VBA 能访问某个文本文件么?当然可以。VBA 直接提供 OPEN 关键字来访问
文件。
6. VBA 能模拟键盘输入么?当然可以。VBA 直接提供 SendKeys 函数来做到这
一点。
7. VBA 会被保存在哪里?VBA 是保存在 Office 文档中的,具体可以是某一个
文档,或者某一个文档的模板中。具体的位置需要在工程视图中选择(如
上图)。如果你希望一批文档都能用到同一个功能,最好先写个模板,然
后将 VBA 写到模板里。然后利用此模板来生成多个文档。当然,VBA 代码
也可以单独导出保存。在工程视图中右键单击某一个模块然后点“导出文
件”即可。
8. 不同 Office 组件可以互操作么?当然可以。VBA 的功能是由 COM 库来添
加的。默认情况下某一个 Office 组件只引用了当前这个组件的库。但是
你也可以手动添加其他库的引用。在 VBA 开发环境的“工具”菜单里点击
“引用”,就可以添加 Windows 上安装的任意 COM 组件。这样,不光不同
Office 组件可以互操作,你可以操作比如 Media Player,IE 等程序。
9. VBA 可以写图形界面么?当然可以。不然就说不上”Visual”了。VB 一向
支持一种被称为 UserForm 的模块。你可以在上面添加各种控件。
10.VBA 还提供哪些其他的功能?VBA 可以捕捉 Office 的事件,于是可以编写
如“当打开一个文档,要做某事”;“当 Excel 计算发生了错误,则做某
事”这样的程序。VBA 还有定时器,让你可以做周期性的工作。
11.除了 VBA,还有哪些开发平台可以操作 Office?实际上,任何可以使用
COM 组件的语言都可以访问 Office 的功能。在微软平台上的各种语言基
本上都满足这个条件。不过有几个平台是专门针对 Office 的。如.Net 上
的 VSTO(Visual Studio Tools for Office Developers)。这是一个利
用 Visual Studio 进行 Office 开发的集合环境,适合大型项目。不过为

4
序言

了使用它,你必须得安装 Visual Studio 2005 或者 2008。这对于办公族


来说负担过重了。只有开发复杂的插件,或者开发企业办公管理系统的人
才应该使用它。此外 Apache 基金会有一个 POI 项目,可以让 Java 程序访
问 Office 文档。这样用 Java 开发,同时又想自动生成 Office 文档的人
(比如用 Java 写实验,但想用 Excel 文档统计实验结果)可以利用 POI
来达到他们的目标。不过 POI 目前不支持 Office 2007 的文件格式。

5
基本编程元素

1. 基本编程元素
这一期就是要介绍在 VBA 环境下你能使用什么东西。一般来讲,新手往往对到底
写什么会引起什么结果感到比较混乱。比如写 MsgBox 可以出现对话框,但写
MessageBox 为什么就不可以。在 VBA 中,直接写代码,而不是在 Sub 里写就会
报错?为何是 Sub,而不是 Suc,不是 Sud?等等。我会将最常见的做概要性的
讲解。但是我不会深入去介绍每个细节的语法。精简够用就好。如果有必要,你
可以查任何的关于 VB 的参考手册。

 Sub 与 Function

最基本的问题是:如果你写了很多模块,每个模块有很多代码,那么你的程序代
码是从哪一句开始执行?答案是,VB 将代码组成一段一段的,每段从头到尾执
行(当然,这是粗略的说,因为代码还可能有跳转和循环)。每一段叫做一个
Sub 或着一个 Function。Sub 是子过程“Subroutine”的简称。比如像如下的代
码就定义了一个 Sub。

Sub Test()
MsgBox "Hello" 'Show Hello with a messagebox
End Sub

其中”Sub”这个关键字标志着 Sub 的开始,End Sub 则表示其结束。其中的 Sub


和 End Sub 是关键字。所谓关键字就是 VB 定义好的,有特殊意义的词汇。在写
代码时不可以写错一个字母,否则就不被程序解释器接受(但是你可以不区分大
小写,VBA 开发环境会自动帮你调整)。如果你在开发环境中写了这段代码就会
发现关键字都被表为蓝色。其他的,如 For,Next,If,Then,While 等都是关
键字。以上代码中的 Test 是一个名称,即这个 Sub 的名称。你可以随意定义这
个名称,只要其不包含一些特别的符号,并且不和关键字重名(注意:绝对不能
包含空白字符,如空格或制表符)。写完这段代码后,在 Office 的“宏”对话
框列表里就能看到一项“Test”了。代码中的括号是用于定义参数的。如果想在
Sub/Function 上加输入参数,都是在括号中写一个列表,即

参数名 1 As 类型 1,参数名 2 As 类型 2,...

参数和类型在下面的“变量与类型”一节更详细的介绍。即使没有参数也要写个
空括号来占位置。这是 VB 的要求。之后,当你将键盘光标放在这个 Sub 里,并
点击上面的绿色箭头;或者到“宏”对话框执行 Test,这个 Sub 就被执行了。

另外值得注意的是单引号后边的文字,称为注释。VB 中所有写在单引号后边的
文字都会被解释器忽略掉,它们仅仅是给人看的。所以你可以在写代码时加上注
释,方便其他人看懂你的程序。

6
基本编程元素

Function 和 Sub 是很类似的东西。比如:

Function Add(a1 As Integer, a2 As Integer) As Integer


sum = a1 + a2
Add = sum
End Function

这个例子实现了一个 Function,含义是将两个整数加起来。这次我添加了两个
参数 a1 和 a2 用于输入,其类型均为 Integer。参数的语法 Sub 和 Function 是
一样的。形式上,Function 和 Sub 仅仅有关键字的差别。Function 与 Sub 最大
的不同就在于 Function 需要返回一个值,而 Sub 不需要。第一行最后那个“As
Integer”是指返回值的类型。返回的语法是:

Function 名称=想要返回的值

那么什么叫“返回”呢?这是因为程序段之间是可以互相调用的。Sub 和
Function 可以调用其他的 Sub 和 Function。整个程序的执行顺序大致如下图所
示:

图中的矩形相当于一个 Sub 或者 Function,箭头代表程序的执行顺序(注意矩


形的长短不代表代码的长短,而仅仅是排版需要;并且调用层数不一定是 3 层)。
每个 Sub/Function 都可以调用其他的 Sub/Function,这种形式造成了一种层数
无限定的树状结构。在调用时,上层可以以参数的形式给予下层输入,而下层将
控制权返回给上层的时候,也可以给上层一个返回值(只有 Function 可以)。
7
基本编程元素

比如上面的 Sub “Test” 调用了 VB 已经定义好的 MsgBox 这个 Function 来完


成工作(是不是觉得 MsgBox 应该是一个 Sub?实际上 MsgBox 会返回一个整数值
表示用户是按哪个键关闭对话框的,比如“确定”,“取消”,“重试”等。只
不过在最简单的情况下我们不关心这个返回值罢了)。另外,我们也可以写一个
程序让 Test 调用 Add。

Sub Test()
sum = Add(3,4)
MsgBox sum
End Sub

这样就能用对话框显示 3 与 4 的和了。在实践当中,Sub 一般用于执行某个动作;


而 Function 则在 Excel 自定义函数中特别有用。VB 为开发者提供了一个很大的
库,即你可以直接使用许许多多的 Sub 和 Function 来完成常见的任务(这和
Office 库无关),比如字符串操作的 Substr,Trim,Len;文件操作的 Dir,Write;
数学运算的 Log,Sqr 等。完整的清单可以在任何一本 VB 的参考手册上找到。

 变量与类型

我们在算数的时候需要草稿纸,将计算的中间结果记录下来。计算机算数也不例
外,也得需要在内存中开辟一些空间来记录这些内容。定义一块空间的方法的代
码是:

Dim var

或者

Dim var As Type

或者就像上文 Test 中的 sum 那样不经定义直接使用。其中 Dim 和 As 是关键字,


而 var 就是变量名。你可以随意定义变量名称,只要其不包含一些特别的符号,
并且不和关键字重名。既然是分配一块内存空间,那么分配空间有多大呢,又是
怎么使用的呢?这就由类型来定义了。一个类型规定了要分配的空间的大小和内
部结构,比如 Integer 整数就分配 2 个 Byte(所以其范围是-32768 ~ 32767);
而 Long 长整型数分配 4Byte;Double 双精浮点数(小数)就分配 8 个 Byte;String
字符串的长度就是其存储字符串长度+一块用于存放字符串的长度的额外空间。
以上这些类型由 VB 定义,被称为基本类型(还有其它几个基本类型可以使用)。
VB 会尽可能在你混用这些基本类型时进行自动转换。比如:

Dim str As String


Dim n As Integer
str = 12 'now str is "12"
n = "34" 'now n is 34
n = 23.45 'now n is 23
8
基本编程元素

可见,在类型转换中,可能会有数据损失。此外对于对象类型(下文将介绍)来
讲,将一个类型的值赋给另一个类型的变量是会报错的。因此建议大家尽量减少
混用的情况。

一种特别的类型是 Variant。这种变量可以放置任何类型的数据。比如上文中的
Dim var 就等价于 Dim var As Variant。但是要注意的是 Variant 并不意味着变
量内容的类型可以变化。它仅仅是能放置任何类型的数据而已,同一时间只有一
个”实际类型”。为此,其内部必须维护当前到底是什么类型,还需要在被访问
时进行内部的二次解释。所以它消耗的空间更大,并且性能更差。另外如果用
Variant 变量表示一个对象,在写变量名后输入一个点,就不会有自动提示出现。
因此,建议大家少用 Variant 类型。

最后说一下作用域的问题。每一个变量都有一个作用域。如果在一个
Sub/Function 内定义变量,那么这个变量的作用域就仅仅限于这个
Sub/Function,称为局部变量。也就是说,在其他 Sub/Function 中是访问不到
这个变量的;或者说,如果在其他 Sub/Function 中定义了同名的变量,二者完
全是两码事,互相不会有任何影响。

在 Sub/Function 中的参数也是一种局部变量,其定义的方式就是比常规方法省
略了 Dim。其形式正如上文所写的那样。当然,你也可以在参数列表里不写类型,
这就意味着参数类型是 Varaint。同样的,Function 第一行的最后的 As Type
表明 Function 返回值的类型。不写这个返回值就意味返回 Variant 类型。

如果将 Dim 语句写在任意 Sub/Function 之外,就定义了全局变量。此时变量对


于当前模块文件的所有 Sub/Function 都有效。这时,不同的 Sub/Function 之间
就能通过全局变量交流(尽管这不是个好习惯,因为这样就破坏了 Sub/Function
的独立性,使得复用难以进行)。当然,还有可以在不同模块之间都有效的变量
的定义方法,即将全局变量定义中的“Dim”改为“Public”。不过这种变量在
小型程序中非常少见。

也许你会问,如果某个局部变量恰好和全局变量同名会怎么样?这时局部变量会
“遮蔽”全局变量,使你只能访问到局部变量。

下面是一个例子,大家可以来复习一下本次的内容。

Sub ShowSum()
Range("A1").Value = CalSum(200)
End Sub

Function CalSum(n As Integer) As Integer


Dim sum As Integer
sum = 0
For i = 1 To n Step 1
If i Mod 7 = 0 Then
sum = sum + i
9
基本编程元素

End If
Next
CalSum = sum
End Function

最近代码计算了 1~200 之间所有可以被 7 整除的数字之和,并在 A1 单元格内显


示结果(所以,请在 Excel 的 VBA 环境里运行此段代码)。里面展示了如何定义
和使用 Sub,Function 以及如利用变量来存储数据。当然,我也用了一些没有讲
过的关键字,如 For,If 等。但我觉得它们的意思都是非常容易懂的。比如 For
i=1 To n Step 1 就定义了一个循环,让 i 从 1 逐渐增长为 n,每次步长是 1。
所以 i 依次变为 1,2,3,……,n。当运行到 Next 那句时就会返回到 For 那一
行,将 i 增量,重新执行循环体代码。而 If 是判断语句,判断 i 与 7 取模是否
是 0(整除),Mod 是关键字表示取模。而 Range(”A1″)则是 Excel 提供的一
个对象,意为得到名称为 A1 的那个单元格区域。之后的代码就是将其值设为想
要的结果。关于对象将是下次的主题。最后值得注意的是那个 n 不要设的过大,
否则很容易造成 sum 不足以存储过大的数据而越界(记得么,整数的最大值是
32767)。

 总结

本期我们讲了最基本的 VB 构造元素。讲解了 Sub、Function、关键字、变量定


义和类型等概念。如果你能读到这里,想想看你还能记得它们是怎么回事不?当
然关于这个主题,有很庞大的内容我没有讲。但往往就是这 20%的内容在 100%
代码里被用到,它们是最核心的内容。对于一些高级话题,如果你有兴趣的话,
可以看 Programming Microsoft Visual Basic 6.0 by Francesco Balena
(Microsoft Press)。下一期讲带大家进入对象的世界:)

 问答

1.我是菜鸟,一般一个 sub 连 dim 都不用,想起什么变量就上去用了。 这是不


是相当于 dim as variant ?

比如

sub a()
aa=1
msgbox aa
end sub

真实感觉:速度上,损失似乎不大;内存,似乎一个 sub 一次执行之后,变量全


清空了,内存里的 aa 也被释放了。那么对于一般的小程序来说,是不是就无所
谓了呢?
—————————————————
10
基本编程元素

是的,小程序无所谓,方便就好。但是如果程序稍微大了些,对象多了些,类型
检查是一个很好的防止错误的功能。那个时候,写的对比写的少更重要。其实我
觉得在目前的硬件平台上,性能可以不做最优先考虑。反倒是编程的便利性(写
正确的类型后 VBA 编辑器有自动提示),可读性和类型错误检测让我不喜欢用
Variant。

2.能返回数值的 function,以及带变量的 sub。 感觉很好用,但是他们自己若


返回数值就不能单独执行了 ?? 比较困扰
—————————————————
是的,一般情况下,Function 不能像 Sub 直接被绑定到某个快捷键,某个按钮
那样执行。必须被某个 Sub 或者 Function 调用才行。但是有一种情况,就是 Excel
的自定义函数,Function 的地位无可替代。写一个 Function 后,可以像 Excel
自带函数那样直接在单元格里使用。

3.VBA 的变量必须先声明再使用么?
—————————————————
当然不是,VBA 允许你直接使用变量而不经任何声明。但是这样做可能会引起难
以调试的错误。比如你在第一行直接使用了变量 abc,结果到了后边由于笔误写
成了 acb。这一定会出逻辑错误,但是解释器因为语法没问题而不会报错。你自
己也很难在密密麻麻的代码中将这个错误挑出来。为了强迫变量必须先声明,再
使用,在程序的第一行加上这句:

Option Explicit

这样,如果不经声明直接使用变量就会报语法错误。这对于比较大的程序十分必
要。

11
对象

2. 对象
当你叫一个人做什么事情的时候,一般来讲你会如何做呢?你会说,比如,“周
正龙,拍老虎去”。如果你想知道一个人的信息时,你大概会这么问,“姚明,
你有多高”。这是我们平时的直观的交流方式。对象就提供了这样一种机制,使
得我们的编程更加符合人的思维习惯。这样,做基于对象的编程就很方便了。比
如你可以用程序指令写 Application.Workbooks.Add 的代码让 Application 这个
对象新建一个 Workbook。

对象在物理上是一段内存的区域,维护着一组数据,这些数据管理着实际的各种
看得见,摸得着的实体。比如应用程序 Application 是一个对象,窗口 Window
是一个对象,单元格是一个对象,段落是一个对象……在 Office 中,几乎任何
实体都可以找到对应的对象。如果你希望操作某个实体,那么你就操作它的对象
就好了。这样,编程的任务被转变成了找到合适的对象,并让这个对象做一些事
情,或者从对象上获得一些信息。

在 Office 中使用的对象模型是 COM(不同于比如 CORBA,Java 或者.Net 的对象)。


COM 是 Component Object Model 的简称,是微软开发的组件(Component)标准。
如果你不理解什么是组件,可以把软件看作是一个机器,而组件就是机器的一个
个零件。组件之间相互协作,共同完成任务。COM 组件的好处是可以跨语言。也
就是说,你可以用不同的编程语言来开发组件,并用不同的编程语言来使用组件。
在 Windows 下,你可以用 C++,Visual Basic,J++等语言开来发和使用组件。
当然,我们在这里并不关心一个 COM 组件是怎么开发的。你仅仅要记住如果要使
用一个 COM 组件的主要步骤是什么就可以。首先要使用 COM 组件,就必须先注册
它。如果你足够细心就能留意到很多 Windows 程序在安装的时候都有一步叫做
“注册组件”。这就是为什么如果有的程序不安装就用不了的原因——它们的组
件没安装,因此没法用,程序自然就运行不起来。Office 当然也不例外,它会
注册几个组件,这些组件分别对应不同的 Office 程序,比如 Word,Excel,
PowerPoint,Outlook 等。“注册”的结果可以在你的注册表里找到。打开
HKEY_CLASSES_ROOT,就能看到一长串的注册的组件,如下图所示。图中你能看
到 Excel 的一些组件。

12
对象

每个组件定义了若干“对象类型”。回想上一期讲解的类型就能知道任何一个变
量都有一个类型。而对象也有类型。每个类型可以生成不同的“对象实例”。这
就是说,如果有一个 Excel.Application 的对象类型,我可以生成很多个此类型
的对象实例,即开启多个 Excel(其实类似的,如果有 Integer 这个类型,就可
以定义很多个不一样的 Integer 变量)。在下文中我会简称“对象类型”为“类
型”,而“对象实例”为“对象”。

为了在 VBA 中使用某个类型,需要添加对包含这个类型定义的组件的引用(有点


拗口是吧,多念几遍)。在 VBA 编辑器的工具->引用就能打开一个对话框来更改
引用,如下图所示:

13
对象

默认情况下,VBA 已经为我们自动添加了一些必要组件的引用,所以大多是时候
我们可以完全忽略以上的步骤,直接写代码。但如果想用一些额外的组件,就需
要手动添加了。

每个对象会有如下几种成员:

• 方法:表示一种动作。这和上次讲的一般的 Sub 是没什么区别的,只不过


它代表针对当前对象的动作。比如上面提到的 Workbooks.Add 中的
Workbooks 是一个对象(更确切的说,是一个集合对象,下面再详述),
而 Add 就是这个对象的方法,用于执行“新建一个 Workbook”这个动作。
• 函数:表示一种具有返回值的功能。这和上次讲的一般的 Function 是没
什么区别的,只不过它代表针对当前对象的功能。
• 属性:表示对象所带有的某种信息。比如 Window 对象具有 Captain 这样
的属性,表示其标题栏的标题;Height 和 Width 属性表示 Window 对象的
宽和高等等。通过改变这些值就可以改变对象所代表的实体,比如改变其
外观。每个属性即可能是一个对象,也可能是一个基本类型的变量。每个
属性都属于一个类型。

值得注意的是有两种特别的属性。第一种是“只读”的属性。就是说你不能改变
它,而仅仅能读取它。比如单元格的 Text 属性就是只读的。Excel 根据单元格
的 Value 和应用于此单元格的 Format 来决定 Text 是什么。所以如果非得要改的
话,需要改 Value 或者 Format。另一种是所谓“集合”的属性。表示当前对象
包含一组子对象。直观上,一个 Application 包含多个 Workbook,一个 Workbook
包含多个 Worksheet,一个 Worksheet 包含若干的 Range。对象也正是这么嵌套
的。比如你想获得第一个 Workbook 的第一个 Worksheet 的 A10 单元格需要这么
写:

Application.Workbooks(1).Worksheets(1).Range("A10")
14
对象

可以看到大多数集合对象比常规对象的名称多一个 s,表示复数(这是一种习惯,
而不是语法要求的。比如 Range 就没有 s)。另外取得集合中的某一个对象即可
以用对象索引值来获得(记得索引值是从 1 开始算的,而不是 0),也可以用过
对象的名称,比如如果你的第一个工作表又叫“MySheet”,那么上面的语句可
以等价的写为:

Application.Workbooks(1).Worksheets("MySheet").Range("A10")

你可以注意到访问对象成员的语法就是在对象名成后边加”.”。确切的语法是:

[对象名].[成员名]

下图是 Excel 对象结构的一个概览。虽然总共有一百多个对象,但是实际上经常


使用的仅仅只有 Application, Workbook, Worksheet, Range, Window, Chart,
Shape 这么几个。(图是从帮助里截下来的,所以那个箭头点了不会有效果:)

15
对象

16
对象

我们可以定义一个变量来指向某个对象。这称为“对象的引用”(注意要和组件
引用的概念区分开)或者引用变量。之所以用引用的方式访问对象的原因是对象
比起常规变量都大的多。如果像普通变量那样每 Dim 一次就分配整个对象的内存,
非常浪费空间。所以,对象的引用只包含对象在内存地址而已。当然,我们通过
对象的引用还是能够直接操作对象。比如下面的代码:

Dim a As Range, b As Range


Set a = Range("A10")
Set b = Range("A10")

就定义了两个对象引用 a 和 b,它们指向同一个对象 Range("A10")。逻辑上如下


图所示:

所以,如果用 a.Value = xxx 改变了对象的值,b.Value 也能反映出变化。这和


定义两个整数变量,各自独立的语义是不一样的。

另外需要注意的是,对对象引用进行赋值的时候需要加 Set 关键字,表示传递的


是引用。如果不加 Set,就等价于对对象的默认属性赋值。如果 a 指向 Range("A1")
对象,那么写

a = "abc"

等价于

a.Value = "abc"

因为 Value 是 Range 对象的默认属性。

但是如果你写

a = Range("A2")

就会出错。所以不要忘记 Set 关键字。

那么什么时候对象会释放掉内存呢?每个对象有一个内部的引用计数。每添加一
个引用就能使引用计数增加 1。如果你确认某个引用不再使用了,就写

a = Nothing

这会让引用计数减 1。当对象的引用计数为 0 的时候就会自动销毁释放内存。

17
对象

有人会问,100 多个对象怎么找啊。答案是帮助文档。Office 自带了庞大的开发


文档。不过需要进入 VBA 编辑器后打开的帮助才能看得到。普通的帮助文档是找
不到这些对象的。帮助中记录了每一个对象的每个成员的详细信息,比如是做什
么的,是否是只读的,参数信息等。如下图所示:

所以,如果想找一个对象,完全没必要去录制宏那样去找。你仅仅把需要的对象
的名字翻译成英文(工作簿->Workbook,工作表->Worksheet,……)然后去查
文档。如果你对常用对象不熟悉的话,那么请等待下一期教程:)

18
开始编写完整的程序

3. 开始编写完整的程序
前几期介绍了一些基本概念。是该拿真东西练练手的时候了。本期的主题就是拿
OfficeSoft 一个真实的问题作为例子,讲解如何开始编写一个程序。当然这个
例子也很简单:)这个问题是:如何将一组单元格内红色的数字求和。Excel 自
己提供的函数 Sum 不能对待求和数据进行条件判断,而 Sumif 只允许对待求和数
值进行数值比较上的判断,也无法处理“字体是红色”这种格式条件。这时正是
VBA 发挥作用的时候,它可以将一些基本的功能组织到一起,然后完成自定义的
任务。

开始编程之前,应该先想清楚几个关键的问题,也就是一个设计的过程。编程可
不是上来就开写。对于本问题,首先想明白如何表达问题中的几个关键点:

• 如何表示一个单元格范围,并以此作为问题的输入?即到底要对哪些单元
格应用这个自定义的求和?
• 如何遍历一个个的单元格?
• 如何获取一个单元格的值?
• 对于一个单元格如何判断一个单元格的文字是红色的?
• 如何求和?
• 如何输出?

对于第一个问题。我们一开始可以用最简单的输入方法来做这个事情,即选择。
用户在单元格上拖动一下,选中了一组单元格。我们在 VBA 中可以用 Selection
对象来取得所有选中的单元格。Selection 对象的类型是 Range,以后我会用“对
象名{类型名}”的方法来表示对象和其类型的关系,如 Selection{Range}。顾
名思意,Range 就表示一个单元格的范围。值得注意的是,之前可以看到有这种
写法 Range("A1")。这里的 Range 是一个对象名。具体来讲,它等价于
ActiveSheet.Range("A1")——(这里 Excel 为我们提供了很好的快捷写法)。
也就是说类型名可以和对象名重名。所以需要你来区分什么时候是类型,什么时
候是对象。回到我们的问题上,Selection{Range}可以表示选中的所有单元格,
并为我所用。第一个问题解决。

第二个问题。关于遍历一个像 Selection 这样的集合,VBA 提供了一个很方便的


语法,叫做 For Each … Next。其语法为:

For Each <obj> In <Collection>


'Do Something with <obj>
Next

使用的时候,将<obj>和<Collection>替换成实际的变量和对象即可。每次<obj>
这个变量都会指向 Collection 的一个元素。对于 Selection,其类型是 Range,
那么其包含的元素是什么类型呢?答案是,也是 Range,只是这些 Range 只代表

19
开始编写完整的程序

一个单元格。Range 类型的对象包含一组 Range 类型的对象,这没什么好奇怪的。


文件系统中,文件夹不是还能包含文件夹了么?第二个问题解决。

第三个问题。一个对象 r{Range}会有一个属性,叫做 Value,表示单元格的值。


当 r 是表示多个单元格的范围时,r.Value 实际上是一个数组,包含所有单元格
的值;如果 r 是一个单元格,r.Value 就是那个单元格的值。也就是说 Value 的
内容随着 r 的改变而改变,并不是总是具有相同的类型,所以 Value 属性一定是
Variant 类型的。

第四个问题。如何判断单元格是红色的?回想一下,我们是怎么在 Excel 里设定


单元格是红色的?是利用字体对话框(Font)来设定的。在 VBA 中,是一样的,
对象 r{Range}有一个 Font{Font}属性,表示字体的所有信息,比如边框,粗体,
斜体等,当然,还有文字颜色 Color{Double}。那么什么是红色?如果你学过三
原色的话就知道颜色是由 RGB 组成的,红色对应的是 R=255,G=0,B=0。在 VBA
中写作 RGB(255,0,0)。也就是说如果 r.Font.Color=RGB(255,0,0)就表示这就是
我们想要的红色文字单元格。当然 VBA 还提供了一些颜色常量来方便我们的书写,
比如红色就是 vbRed。所以上面的代码等价于 r.Font.Color=vbRed。注意在本期
教程中,我忽略了一种特殊情况,即一个单元格内的不同字符可能具有不同颜色。
这个今后用到再说。

第五个问题,求和。可以用最基础的方法——累加:设定一个变量 sum,初始值
为 0。然后遍历所有单元格,如果单元格符合条件,就将其值累加到 sum 上。需
要注意,求和之前应该根据要求和数据的总量估算求和结果,考虑 sum 应该用什
么类型。Integer 的范围是-32768~32767,而 Long 是-2,147,483,648 到
2,147,483,647。这里为了简单,就直接用 Integer 了。

最后一个问题,输出。这里就暂且用最基本的 MsgBox 输出吧。

好了,所有问题都解决。你可以根据这些知识尝试自己编写一下,然后在看以下
的完整代码:

Sub SumIfRed()
Dim sum As Integer
Dim r As Range
sum = 0
For Each r In Selection
If r.Font.Color = vbRed Then
sum = sum + r.Value
End If
Next
MsgBox sum
End Sub

也许你对整个运算过程的细节非常感兴趣,也许你写代码后发现除了错误。这时
就可以对代码进行调试。在 VBA 编辑器写代码区域的左边空白单击一下,就可以
20
开始编写完整的程序

增加一个“断点”。你也可以显示“调试”工具栏,并点击上面的小手做到这一
点。

增加完断点之后运行程序,当程序执行到断点时就会停下来。你可以借此机会查
看当前各个变量的值。使用“监视”窗口(Watch View),或者“本地”窗口
(Local View),可以帮你做到这一点。调试工具栏上点击对应的按钮就可以让
它们显示出来。本地窗口自动显示当前 Sub/Function 的所有局部变量(本地、
局部、Local 是等价的,这是翻译问题)。如果一个变量指向对象的话,可以点
击其左边的展开符号查看其所有成员。你还可以利用“类型”那列的信息显示出
一个 Variant 变量当前的实际类型是什么。监视窗口与本地窗口类似,只不过你
需要指定让它显示哪些属性或者变量的信息。你可以直接在代码上选中一段代码,
然后拖拽到监视窗口,添加对那些数据的监视。

21
开始编写完整的程序

“立即”窗口(Immediate View)可以让你输入一段 VBA 代码,查看某些内容。


比如你可以输入 Print sum 一类的代码显示某些变量的值。还可以执行一段完整
的代码段,查看或者改变其他任意对象的状态。比如,你想在程序被断点中断时
Activate 某个单元格,就可以在立即窗口书写相应代码,这时没有 Sub/Function
的概念,直接写功能代码即可。此外,“调用堆栈”窗口显示第 1 期教程中介绍
的 Sub/Function 互相调用的情况。

让代码停下来之后,你还可以一步一步的执行你的代码。用调试工具栏上的“逐
语句”(Step In),“逐过程”(Step)和“跳出”(Step Out)可以单步执
行代码。它们的区别体现在如果执行到了一个你自己写的 Sub/Function,是否
单步进入那个 Sub/Function 代码。

OK,完成上边那个 Sub,你可能会发现,希望想要一个长的更像 Sumif 那样的自


定义函数。输入应该用参数,而输出直接放到单元格里。这时 Sub 就不能满足需
要了,而应当用 Function 来自定义一个函数。核心代码其实没什么变化,仅仅
是输入输出有所区别。代码如下:

Function SumIfRed(range As Range) As Integer


Dim sum As Integer
Dim r As Range
sum = 0
For Each r In range
If r.Font.Color = vbRed Then
sum = sum + r.Value
End If
Next
SumIfRed = sum
End Sub

可以看到,函数接受一个 range{Range}作为操作范围(因此 For Each 的


Collection 也从 Selection 改为 range),并将结果作为函数的返回值返回。这
样,就能在 Excel 直接使用(甚至有自动提示哦)。比如你在 A1:A10 范围内输
入一些整数,然后在 A11 输入

=SumIfRed(A1:A10)

就能得到想要的结果了。

22
开始编写完整的程序

在单元格作为自定义函数使用的 Function 有一点需要注意,就是 Function 在执


行过程中不能改变任何单元格的状态(格式,值等等),否则 Excel 会报错。不
过如果 Function 是被 Sub 调用的,就没有这个限制。

到此,你应该可以用 VBA 完成一些初步的工作了。起码,应该先像本文一开始那


样将问题分解为若干小问题,然后勤查文档,逐个解决,最后组合成完整的代码。
多熟悉几遍这个过程就会很容易上手。如果出了错误,就仔细调试,找出问题。
这样一定可以慢慢成为 VBA 高手。本期留个作业,就是编写一个自定义函数,求
所有斜体数据的平均值。下一期会给出另外一个更复杂的例子,讲解更多 Excel
的对象。

23
操作文件

4. 操作文件
在使用 Excel 的工作中,很常见的就是收集磁盘上的文件,并整理成 Excel 文件。
被收集的文件可能是 Excel 文件,XML 文件,数据库文件等特种文件,也可能是
普通的文本文件,或者自己定义的文件。VBA 提供了相当丰富的文件操作工具。
当然,本教程是不会流水般的列举所有操作的,如有这个需要请看 VBA 的文档或
者 OfficeSoft 的精华区。本期教程还是集中精力解决一个实际问题,用以说明
各种文件的操作方法。在以下的代码中需要注意的地方是,对于文件的基本操作,
如打开、关闭、进入目录、查找、列举等操作是 VBA 直接支持的,它们是 VBA
库提供的 Sub/Function,甚至是关键字。针对 Excel 格式文件的操作自然由
Office 库来提供,比如 Workbook 对象。
我们的问题是:每个月公司都会收集大量的日志数据,它们是以文本的方式记录
的,格式如下:

交易时间 商品名称 成交价格


14:41 牙膏 4.5
14:43 牙刷 2.1
……

当然,文本文件中并不存在那行列头,仅包含数据。记录中间是以 1 个空格分开
的(上面为了排版的需要夸大了空格的长度)。已知我们的系统有 5 个服务器,
每个服务器每天都会记录一份日志。每天所有服务器的日志都被保存在一个以日
期为名称的目录里,每份日志以服务器名称作为文件名。其目录结构是:

2008-4-1\
server1.txt
server2.txt
server3.txt
server4.txt
server5.txt
2008-4-2\
server1.txt
server2.txt
server3.txt
server4.txt
server5.txt
….

假设这些数据的根路径在 C:\log 下。我们希望将所有的数据汇总到一个


Workbook 中,为每一个 server 建立一个 Worksheet,sheet 名称就是 server 名
称,里面记录了这个 server 的所有交易日期,交易时间和成交价格(不管交易

24
操作文件

的什么东西)。然后新建一个 Worksheet,求出所有 server 的成交总额,并为


此建立一个柱状图。最后再清理掉所有原始日志。

就像上期那样,编程前应该先弄清楚有哪些问题要解决:

1. 如何新建一个 Workbook,并在里面添加 Worksheet?


2. 如何打开文本文件?
3. 如何遍历一个目录下的所有文件夹?
4. 如何一行一行的读取文本文件的内容?
5. 如何拆分字符串?
6. 如何将数据放到数据表恰当的位置?
7. 如何在工作表里插入公式,计算总额?
8. 如何在工作表里插入柱状图?
9. 如何删除原始的文本文件?

1. 如何新建一个 Workbook,并在里面添加 Worksheet?

在 Excel 中,Workbooks 这个集合有一个方法叫做 Add,即 Workbooks.Add 意为


添加一个 Workbook。可以看到,这非常的直观。类似的你可以 Workbooks.Open
来打开一个现成的 Excel 文件;Workbooks.OpenDatabase 打开一个数据库(比
如 Access 的 mdb 文件);Workbooks.OpenText 可以打开一个文本文件,然后自
动做分列处理;Workbooks.OpenXML 可以打开 XML 文件等等。

2. 如何打开文本文件?

对于问题 2,需要使用 Open 这个 VBA 的关键字来打开一个文本文件。其格式为:

Open <file-path> For <open-mode> As #<file-number>

这条语句用指定的打开模式打开指定路径上的文件,并连接到指定的一个号码。
比如代码

Open "C:\log\2008-4-1\server1.txt" For Input As #1

可以以只读的方式打开指定的 server1.txt 文件,并将文件号设为 1。指定的文


件号在后续的读写操作中代表打开的文件内容(详情见下文)。文件打开的模式
常用的有:Input 只读;Output 写入;Append 追加写入;Binary 二进制方式
打开;Random 随机读写方式打开这五种。在本文的例子中只用到了 Input 模式。

对于打开的文件要时刻记住一定要在不用文件的时候关闭它。不然就会出现别的
程序无法打开,或者无法删除的情况(因为你在占着嘛)。利用关键字 Close
就可以关闭文件,如:

Close #1
25
操作文件

就可以关闭上边打开的文件。

3. 如何遍历一个目录下的所有文件夹?

如果是在命令行下,就可以用 dir 命令。VBA 也提供这样一个具有同样功能的


Function——Dir。其格式为:

fileName = Dir([path],[attribute])

其中 path 指定要 Dir 的目录,就像在命令行一样,path 中可以使用像*和?这样


的通配符。attribute 可以用一些常量来指定要遍历的文件类型。这样就能够只
遍历目录,或者只读文件等等。不过这个 Dir 奇怪的很,一次只能返回一个文件
名称(目录也算是文件的一种)。VBA 规定第一次使用 Dir 时需要指定一个路径。
这时将返回指定路径的第一个文件名。之后不写任何参数调用 Dir,就可以依次
遍历出所有的文件名。当最后一个文件名得到之后,Dir 会返回一个空字符串”"。
这样我们就知道了遍历结束了。所以以下代码可以遍历 C:\log 下的所有目录,
并依次用对话框输出。

Dim Str As String


Str = Dir("C:\log\", vbDirectory)
While Str <> ""
If Str <> "." And Str <> ".." Then MsgBox Str
Str = Dir
Wend

这里有两个需要注意的地方,一是如果想遍历 C:\log 目录中的所有目录需要将


path 写作”C:\log\”,而不是”C:\log”。第二是,任何目录下都会有两个特
别的“目录”:”.”和”..”,分别表示当前目录和上一层目录。对于我们来
说它们没有任何用处,所以可以用 If 忽略掉它们。

此外,这是本教程第一次使用 While…Wend 循环,这段代码不断循环,直到条件


Str <> “”不再满足。此后 Wend 后边的语句开始执行。由于没法提前直到知道
要循环多少次,所以不能用 For 循环。

4. 如何一行一行的读取文本文件的内容,并按照规则将其拆分?

对于问题 4,VBA 提供了了关键字 Input,Write,Print 等关键字来读写文本文


件,还提供了 Put 和 Get 来读写二进制文件(以后再说)。不过对于每次读一行
这样的需要还是使用 Line Input 关键字最合适(注意 Line Input 是一个整体)。
比如代码:

Line Input #1, str

26
操作文件

就可以从文件号为 1 的文件中读出一行,并放到 str 中。那行字符串末尾的回车


会被自动删掉。这样就可以利用循环将文件中所有的行都读出来。下面的代码读
出了文件的所有行,并用对话框依次输出:

While Not EOF(1)


Line Input #1, str
MsgBox str
Wend

注意这里用了 Function EOF(filenumber)来判断是不是到了文件尾。有人也需


要问什么时候需要#,什么时候不需要?答案是#是 VBA 关键字的需要,也就是说
在用关键字的时候需要带#,比如 Open,Close,Input 等;而对于 VBA Function,
则不需要#。

5. 如何拆分字符串?

对于字符串拆分这个问题,VBA 提供了很便利的函数 Split。你需要指定要拆分


的字符串和分隔符,split 就能将结果以数组的形式返回(要是 Dir 也返回数组
就好了)。比如:

strs = Split("abc,def,ghi", ",")

就可以获得一个三个成员的数组,其中 strs(0)内容为 abc,strs(1)内容 def,


strs(2)内容为 ghi。

在这里引入了数组的概念。简单来讲,一个数组就是可以用“变量名(下标)”访
问的变量。数组的下标具有下界(Lower Bound,简称 LBound)和上界 (Upper Bound,
简称 UBound)。默认情况下数组下界从 0 开始,但也可以采用一些方法来改变
这个,例如在文件头写 Option Base 1,可以让默认下界变为 1。或者明确的用
Dim 声明一个数组,如 Dim arr(3 to 9) As String,就定义了一个上界为 9,
下界为 3 的字符串数组。不过就我的经验,修改上下界没有太大的意义,只会引
起混乱。只有一个下标的数组被称为“一维数组”,同理就会有“二维”、“三
维”、……等数组。但多维数组一般并不常用。数组必须在定义时就指定下标范
围才能使用,即其大小需要在使用前确定。

与数组对应的就是 Collection,读者可以将其理解为一个可变长的数组,不需
要提前制定大小。通过 Add 方法来添加元素,通过 Count 来取得其包含元素的个
数。比如

Dim col As New Collection


col.Add "abc"
col.Add "def"
MsgBox col.Count

27
操作文件

注意因为 Collection 是对象类型,所以声明时要用 New 来创建对象。Collection


使得开发方便了很多,比如在下文的代码中就有用到。

6. 如何将数据放到数据表恰当的位置?

其实这已经不是个问题了。看过前面教程的都知道用 Range(”XX”).Value=XXX
的形式来做到这一点。

7. 如何在工作表里插入公式,计算总额?

对于问题 6,可以使用 Range.Formula 属性。比如可以用


Range("A1").Formula="=Sum(Sheet2!B1:B10)"来将一个公式赋给单元格 A1。对
于上面的问题,其实更关键的是如何确定待求和的单元格范围。在下文的完整代
码再说明这个问题。

8. 如何在工作表里插入柱状图?

与 Workbooks 这种集合对象类似,Excel 中也有 Charts 集合,表示当前 Workbook


中所有的图表。所以可以利用 Charts.Add 来添加一个图表,比如:

Range("A1:A10").Select
Charts.Add

和直接使用 Excel 一样,在添加图表之前应该先选择一些数据,然后 Excel 就会


自动建立一个最合适类型的图表。如果不满意,可以利用产生的 Chart 对象来调
节。比如 Chart.ChartTitle 属性调节名称,Chart.Location 属性调节位置,
Chart.ChartType 属性来调节图表类型,Chart.SetSourceData 方法来改变数据
源等等。

9. 如何删除原始的文本文件?

VBA 提供了 Kill 关键字和 RmDir 关键字,前者可以删掉一个文件,而后者可以


删除一个目录。Kill 的特点是可以使用通配符,这样一次性可以删掉一批文件。
RmDir 的特点是只能删除空目录。所以 Kill 和 RmDir 需要配合使用。

OK,所有的关键问题到目前为止都已经解决。是时候将他们组合起来了。不过由
于问题比较多,还是先画个流程图,进一步明确要做的事情。除非你能一次想明
所有问题,写代码之前最好先规划一下。对于我们的程序,大致应该是这样的:

28
操作文件

以下给出了这个程序的源代码 。代码的长度比之前的程序都要长。因此我将程
序拆分成了若干 Sub 和 Function:

Sub CollectMain()
'Create a workbook
Workbooks.Add
'the result is automatically "activated", so we can get it through
ActiveWorkbook
'now start to read files
Dim root As String, folder As String, filename As String, servername As String,
fullfilename As String
Dim curDate As Date
Dim curSheet As Worksheet 'current worksheet
Dim folders As New Collection 'hold all folders under C:\log\
'Find all folders In C:\log\
root = "C:\log\"
folder = Dir(root, vbDirectory)
While folder <> ""
If folder <> "." And folder <> ".." Then
folders.Add folder
End If
folder = Dir
Wend

29
操作文件

'Get each file In each folder


Dim f
For Each f In folders
curDate = f 'convert folder name to "Date" type
filename = Dir(root & f & "\*.txt", vbNormal)
servername = Left(filename, Len(filename) - 4) 'remove ".txt" to get server
name
'create a worksheet If necessary
Set curSheet = GetWorksheet(servername)
'now open and read file one by one
While filename <> ""
fullfilename = root & f & "\" & filename
servername = Left(filename, Len(filename) - 4) 'remove ".txt" to get
server name
'create a worksheet If necessary
Set curSheet = GetWorksheet(servername)
ReadFile curDate, fullfilename, curSheet
'read Next file
filename = Dir
Wend
Next

'Now all files are read, create a new worksheet to take statistics and add chart
TakeStatistics

'All jobs are done, clear source files, and other temp resource
ClearAll

'Finally, save the workbook and tell user all jobs are done
ActiveWorkbook.SaveAs filename:="C:\log\ServerLogStatistics_" & Date 'put
current date As suffix of result file name
MsgBox "All jobs are done, enjoy"
End Sub

'Get a WorkSheet Object according to sheet name. If there is no sheet with


such name, create a new one.
'The header is also created for the new sheet
Function GetWorksheet(sheetname As String) As Worksheet
Dim resultSheet As Worksheet
If ContainSheet(sheetname) = True Then
Set resultSheet = ActiveWorkbook.Sheets(sheetname)
Else 'If not found, create one, and name it after server name
Set resultSheet =
ActiveWorkbook.Sheets.Add(After:=Sheets(ActiveWorkbook.Sheets.Count))

30
操作文件

resultSheet.Name = sheetname
'write header
resultSheet.Range("A1").Value = "交易日期"
resultSheet.Range("B1").Value = "交易时间"
resultSheet.Range("C1").Value = "交易额"
End If
Set GetWorksheet = resultSheet
End Function

'Read each line of files and put the content to the sheet
Sub ReadFile(d As Date, fullfilename As String, sheet As Worksheet)
sheet.Activate
Dim record As String, dealtime As Date, dealamount As Currency
Dim startRange As Range
curRow = sheet.UsedRange.Rows.Count + 1 'get the first empty line
Open fullfilename For Input As #1
While Not EOF(1)
Line Input #1, record ' read one line
items = Split(record, " ") 'split record
dealtime = items(0) 'the first item in a record is deal time
dealamount = items(2) 'the third itme in a record is deal amount
'Write them to the last row of worksheet
Range("A" & curRow).Value = d
Range("B" & curRow).Value = dealtime
Range("C" & curRow).Value = dealamount
curRow = curRow + 1
Wend
'Caution: you must close the opened file
Close #1
End Sub

'Create a new sheet to do statistics job


Sub TakeStatistics()
Dim stasheet As Worksheet
Set stasheet = ActiveWorkbook.Sheets.Add(After:=Sheets(Sheets.Count))
stasheet.Name = "Statistics"
stasheet.Range("A1").Value = "服务器名"
stasheet.Range("B1").Value = "交易总额"
'Retrieve each worksheet for servers
Dim curRow As Long, lastRow As Long
curRow = 2 ' since we have header, row starts from 2
Dim sheet As Worksheet
For Each sheet In Sheets
If (Left(sheet.Name, 6) = "server") Then 'we Assume all valid sheet names

31
操作文件

are in the format of "serverN"


'find how many data rows In current server sheet
lastRow = sheet.Range("A1").End(xlDown).row
stasheet.Range("A" & curRow).Value = sheet.Name
stasheet.Range("B" & curRow).Formula = "=Sum(" & sheet.Name &
"!C2:C" & lastRow & ")" 'create a formula, such as "=Sum(server1!C2:C100)"
curRow = curRow + 1
End If
Next
curRow = curRow - 1 'extra 1 is added, so minus 1
'Add a chart
Range("A1", "B" & curRow).Select 'Select all statistics data
Charts.Add
ActiveChart.Name = "服务器交易额统计图"
ActiveChart.ChartType = xlColumnStacked
ActiveChart.Location xlLocationAsObject, "Statistics" 'put the chart to
statisitcs worksheet
End Sub

'Clear all the source files and sheet1, sheet2, sheet3


Sub ClearAll()
'Clear all the source files
Dim foldername As String
Dim root As String
root = "C:\log\"
folder = Dir(root, vbDirectory)
While folder <> ""
If folder <> "." And folder <> ".." Then
Kill root & folder & "\*.*" 'delete all files
RmDir root & folder 'delete this folder
End If
folder = Dir
Wend
'Clear sheet1, sheet2, sheet3
Application.DisplayAlerts = False 'Disable the boring warnings
ActiveWorkbook.Sheets("Sheet1").Delete
ActiveWorkbook.Sheets("Sheet2").Delete
ActiveWorkbook.Sheets("Sheet3").Delete
End Sub

Function ContainSheet(sheetname As String) As Boolean


For Each sheet In Sheets
If sheet.Name = sheetname Then
ContainSheet = True

32
操作文件

Exit Function
End If
Next
ContainSheet = False
End Function

其中,CollectMain 是整个程序的入口。GetWorksheet 尝试从已有的工作表中查


找有没有指定名字的工作表,如果没有就新建一个。ReadFile 将每个文件的内
容读取,拆分,再填写进相应的单元格。TakeStatistics 新建一个专门的工作
表,并在工作表中执行了求和统计,并创建了一个图表。ClearAll 删除了所有
原始文件。ContainSheet 用以查找目前的工作表中是否有指定名字的工作表。
程序中的注释已经能够充分说明代码的功能.值得注意的几个地方有:

• 插入新的工作表时,最好将新表插到当前所有工作表的最后边。否者最后统计的时
候,会遍历前面的工作表,从而使得统计结果是倒序的(这样就还得排序) 。为此在
Sheets.Add 时要加入 After 参数,给出要 After 的那个工作表 Sheets(Sheets.Count),
即最后一个工作表对象。
• 在遍历目录的时候,无法使用嵌套的方式遍历。即没法在 Dir 目录的时候,同时 Dir
一个目录中的文件。这是由 Dir 的使用方式造成的——第一次使用时指定路径,之后
每次使用就不需要参数。如果嵌套使用,无参的 Dir 就不知道去遍历哪个路径了
(C:\log 的?还是 C:\log\2008-4-1 的?)
。所以在代码中使用了一个 Collection 来缓
存目录项,然后再遍历每个目录项下面的文件。
• 代码中很多时候采用&来拼接字符串,这十分方便,而且如果被拼接的不是字符串
(比如一个整数,一个日期),也能正确的变成相应的字符串。
• 在最后删除sheet1,sheet2,sheet3 的时候,比如Sheets("sheet1").Delete,
会让Excel为每个sheet的删除弹出一个对话框提示,让我们确认是否要删除这个shet。
为了让提示不出现,可以用Application.DisplayAlerts=False来暂时关闭这个删除提示 1。
但是不用再写一个Application.DisplayAlerts=True恢复,因为VBA执行结束后会自动将
这个状态恢复。也许可以在GetWorksheet中重用sheet1~sheet3,但因为会让代码更
复杂就没有做。读者可以自己尝试。
• 在代码中,很多地方需要获得一个 sheet 的最后一行在哪里。这里使用了
Sheet.UsedRange.Rows.Count。UsedRange 返回用户使用区域的外接矩形形成的 Range。
其 Rows 属性返回所有行,而 Rows.Count 就代表行数了。这也就是最后一行的行号。
本期教程讲解了如何使用 VBA 解决一个复杂的实际问题的全过程。从局部技术,再
到流程设计,最后到实现。本文希望开发者在做程序的时候可以遵从这个过程,这
样可以保证程序十分清晰流畅。程序的规模稍大时,应该利用 Sub/Function 将任务
分解,这样非常利于代码的理解,重用和调试。本文把调试中发现的问题作为注意
点放在了代码的最后,提示了一些程序中细节的地方。在下期中,将重点介绍 VBA
的事件模型和事件处理。

1
感谢水木网友 iam0 的细心指正
33
事件

5. 事件
在前几期教程中,我们的程序都有一个共性——它们都是从头执行到尾的。在正
常执行时,你无法中断它们。这对于完成某个具体的任务是足够了,但是对于具
有交互性的程序就没有办法了。为此 Office 提供了另外一种编程模型——基于
事件的程序。其实说起来很简单,就是 Office 有什么事情发生了就直接通知你
(比如鼠标单击,键盘被按,某个 Workbook 被打开……),如果你有兴趣,就
可以处理这个事件。事件和事件处理的关系如下图所示:

作为开发者只需要编写绿色的部分就可以了,完全不需要其余的部分。

那么如何编写一个事件处理代码呢?在处理之前,首先要明确到底要处理哪一个
层面的事件。例如,在 Excel 中,有三大类事件——Worksheet、Workbook 和
Application。在 Word 中,可以处理 Document 和 Application 两大类事件。以
Workbook 事件为例。不同于之间的“插入模块”,想编辑 Workbook 的事件,需
要在 VBA 开发界面的 Project 视图中的“This Workbook”中右键单击鼠标,点
击“查看代码”。在编辑界面中最上边的左边的下拉框选中“Workbook”,右边
就可以选择处理哪一种事件了。对于 worksheet 的事件是类似的,只不过需要查
看某个 Sheet 的代码。

34
事件

可以看到生成的代码其实就是一个 Sub,只不过 Sub 的名称是固定好的,而不像


之前那样可以随便定义。事件处理 Sub 的形式是:

Private Sub 谁_什么事件(参数列表)


….
End Sub

比如 Workbook_Open()就是对当前 Workbook 被打开的事件进行处理的 Sub。有没


有参数都是自动生成的。比如利用这段代码就可以向这个 Workbook 的用户打个
招呼。

Private Sub Workbook_Open()


Msg "Welcome to use this workbook"
End Sub

如果某个 Worksheet 被 Activate,也可以处理 SheetActivate 事件。

Private Sub Workbook_SheetActivate(ByVal Sh As Object)


MsgBox Sh.Name & " is activated"
End Sub

这时参数 Sh 就就是被 Activate 的 Worksheet 对象的引用。

35
事件

通过查找帮助可以找到所有可以处理的事件的列表。

Application 的事件与 Workbook、Worksheet 的事件有些不同,不是采用编写具


有特别名称的 Sub 名称的方式来编写的,而是采用注册的方式。Application 的
事件是用其种类也不多,只有 OnKey,OnTime,OnUndo,OnRepeat 四种 。 其中
特别有用的是前两种。OnKey 可以注册当某个按键被按下时要做的事情,比如下
面的代码可以在按下 Alt+Shift+Enter 后给出一个对话框提示:

Private Sub Workbook_Open()


Application.OnKey "^%{Enter}", "DoSomething"
End Sub

Sub DoSomething()
MsgBox "DoSomething"
End Sub

其中,OnKey 方法规定^表示 Shift,+表示 Ctrl,%表示 Alt,其他特殊的按键用


{}括起来,第二个参数则用字符串的方式指定要在按下按键后执行哪个 Sub。而
OnTime 事件则可以指定在某个时刻后调用一个 Sub。例如:

Application.OnTime TimeValue("17:00:00"), "DoSomething"

可以让 Office 在下午 5 点来执行 Sub。而

Application.OnTime Now + TimeValue("00:00:30"), "DoSomething"

36
事件

可以让 Office 在 30 秒中后执行 Sub。请注意 TimeValue 函数可以将一个表示时


间的字符串转换为 Date 类型,而 Now 函数可以返回当前的时间。

在下一期教程中,将会介绍事件在 VBA 中的最重要的应用——用户自定义表单。

37
用户窗体

6. 用户窗体
大多数人更加偏爱图形界面的程序,因为它看起来很直观,用起来很方便。不过
之前讲述的编程内容无法做到这一点。但是,“Visual Basic”的本意就是可以
快速的编写可视化的程序。VBA 当然可以快速制作图形界面。VBA 称这种图形界
面为“用户窗体”(User Form)。

为了添加一个窗体,要在工程视图中右击鼠标,并新建一个“用户窗体”。结果
如图所示:

这时可以看到工程视图中多出了一个“UserForm1”,这和之前我们书写的“模
块”、“ThisWorkbook”和“Sheet”等是平级的。在主编辑窗口显示出了我们
的窗体显示出来的样子,被称为“对象窗口”(窗体上面的若干点是用来定位控
件的,并不会真的显示在实际的窗体里)。通过这个视图,就可以使得编程“所
见即所得”。工具箱上有若干可以使用的控件。如果希望在窗体上添加控件,可
以直接用拖拽的方式将工具箱的控件拉到窗体上。不过可以用的工具并不是像显
示的那么少,右击工具箱,选择“附加工具…”可以添加相当多的工具。这些工
具来自于诸多的 COM 控件。

左下侧是“属性”视图。这个属性和之间讲述的一个对象的属性是同一个东西
(用户窗体本身就是一个对象),只不过这里明确的列了出来,而不需要像以前
那样使用“对象名.属性名=值”的形式来访问它们。当然,利用编写代码访问窗
体属性仍然是可以的,两种方式等价。只不过利用属性视图修改的属性并不灵活,
只适合那种设好就一直不变的属性,比如窗体的标题、大小、背景色等;而利用
编写代码的方式更具有动态性,比如实现单击窗体上某一按键就改变某个属性这
样的功能。
38
用户窗体

那么如何显示一个窗体呢?很简单,可以使用 UserForm.Show 方法。比如对于上


面的例子,就是:

UserForm1.Show

对于特别复杂的窗体,显示可能需要耗费一些时间。这时,可以在用户不知情的
时候使用 Load UserForm 的方法预先将窗体加载到内存中。等到用户想看这个窗
体的时候再 Show,因为窗体已经加载好了,仅仅是显示出来,就会显得快很多。
如果希望暂时隐藏窗体,可以使用 UserForm.Hide 方法。如果希望彻底将
UserForm 从内存中清除掉,则需要用 Unload UseForm。

UserForm 和用户是通过事件来进行的。为了处理事件,首先将“对象窗口”切
换到“代码窗口”,做法是右键单击工程视图中的 UserForm1,并选择“查看代
码”。或者右键单击窗体本身,然后选择“查看代码”。甚至直接双击窗体也可
以直接进入代码编辑状态,并自动生成 UserForm_Click 事件的处理代码框架。
切换到代码视图后,编写如下代码:

Private Sub UserForm_Click()


MsgBox "User Form is Clicked"
End Sub

就可以处理 UserForm 被单击后的事件——弹出一个对话框。写完后直接单击绿


色箭头运行,并单击出现的窗体,就能看到如下的结果。

有的 UserForm 事件特别有用,比如 QueryClose 就是用户在尝试关闭窗体,但还


未关闭时的事件。编写以下代码

Private Sub UserForm_QueryClose(Cancel As Integer, CloseMode As Integer)


IF UserForm.Height < 130 Then
Cancel = True
Me.Caption = "Increase the height to close me"
End If
End Sub

39
用户窗体

可以在用户尝试关闭窗体时检测窗体的高度是否达到要求,如果没有达到,就取
消掉关闭这个动作,并且将窗体的标题改为提示信息。更现实一点,你可以将条
件判断改为对窗体内容的合法性校验,并指定如果窗体内容不合法,就禁止关闭
窗体。

当然,空空如也的窗体没什么用。所以需要加一些控件才能完成实际的工作。一
些常见的控件如 CommandButton 是按键;Lable 是静态文本;TextBox 是文本框;
ListBox 是列表框。通过从工具箱中拖拽,可以将一些控件添加到窗体上。比如,
在下面的例子中添加了一个 ListBox,一个 Lable,一个 TextBox 和两个
CommandButton。添加时,可以用“格式”工具栏来对齐控件,这样界面就不会
显得凌乱了。

默认情况下,VBA 会生成比如 TextBox1 这样的名字。为了使得程序更好懂,我


一般采用匈牙利命名法,即将控件的名字设为一个前缀和一个有实际含义的组合。
比如 btnAdd 的意思是用作“Add”这个动作的 CommandButton;frmTest 就表示
用作 Test 的用户窗体。利用属性视图中的第一个属性 Name,就可以改变控件的
名称。下图是添加好的效果,同时,我也标出了控件的名称:

在这个窗体中,我通过修改控件的 Captain 属性,可以改变其外观文字,比如“添


加”“提交”“值”和“Test From by speedfirst”。

这个例子希望一点击 btnAdd 按键,就将 txtValue 中的字符串添加到 lstValues


这个列表框里。双击对象窗口中的 btnAdd,编写如下代码就能完成这样的工作。

Private Sub btnAdd_Click()


If txtValue.Text <> "" Then
lstValues.AddItem txtValue.Text
End If
txtValue.Text = ""
txtValue.SetFocus
End Sub

其中 ListBox.AddItem 可以将一个字符串加到列表框当中,并显示出来。利用条
件判断,可以指定只有当 txtValue 非空才会添加列表框项目。添加后,将
txtValue 清空,并重新将输入焦点返还给它,以便于下次的输入。
40
用户窗体

这个例子还要求单击 btnSubmit 后,就将列表框中字符串写到当前活动工作表从


A1 单元格开始的范围中,每行一个。双击 btnSubmit,书写下面的代码:

Private Sub btnSubmit_Click()


Dim i As Integer
i=1
While (i <= lstValues.ListCount)
ActiveSheet.Range("A" & i).Value = lstValues.List(i - 1)
i=i+1
Wend
Unload Me
End Sub

这段代码利用 While 遍历了 lstValue 的每一个项目,并将其添加到单元格中。


ListBox 的 List 是一个集合,包含了所有 ListBox 的项目;ListBox.ListCount
保存了 ListBox 中的项目的数量。值得注意的地方是单元格的行数是从 1 开始计
数,而 List 是从 0 开始的,所以 List 的索引值要减 1。最后,全部提交完毕,
就关掉当前的窗体,这里用了 Unload Me 这个语句。“Me”是一个关键字,是指
当前对象,在这里就是指当前窗体。

为了使得我们的程序可以在“宏”中出现,还是需要添加一个常规的“模块”,
然后在里面写一个 Sub 即可:

Sub StartTestForm()
frmTest.Show
End Sub

这样就能利用宏对话框来启动窗体了。

41
根据 VBA 制作展示 PPT

7. 根据 VBA 制作展示 PPT


在本期中,会尝试完成一个比较复杂的案例。这里例子来自于版面上一位网友
aulian 的问题:在一个 Excel 表中,包含了一系列图像文件在本机的路径,记
录数量上千,那么如何快速制作一个展示用的 PPT,使得 PPT 包含所有的图像,
并且每页有四张图?这个案例涉及到了 Excel 和 PowerPoint 的联合编程。

首先假设用于输入的 Excel 文件格式是这样的。它只包含一列数据 path,即图


像文件的相对路径。如下图所示:

因为大部分的工作都是由 PowerPoint 来做,所以本程序将书写在 PowerPoint


中。在 PowerPoint 的 VBA 工程中创建一个模块,并编写 CreateAutoPPT。在这
个例子中,此 Sub 仅仅是个启动用户界面的入口。

Sub CreateShowPPT()
AutoShowForm.Show
End Sub

接下来的工作是制作一个用户窗体 AutoShowForm,使其可以提供比较丰富的配
置功能,使得程序更加灵活。窗体的效果如下图所示(我标出了控件的名称,和
下文代码对照):

42
根据 VBA 制作展示 PPT

相关代码为:

Option Explicit
Private Sub btnBrowseDict_Click()
Dim dd As FileDialog
Set dd = Application.FileDialog(msoFileDialogFolderPicker)
If dd.Show = -1 Then
txtImagePath.Text = dd.SelectedItems(1)
End If
End Sub

Private Sub btnExit_Click()


Unload Me
End Sub

Private Sub btnBrowseFile_Click()


Dim fd As FileDialog
Set fd = Application.FileDialog(msoFileDialogFilePicker)
fd.Filters.Clear
fd.Filters.Add "Excel 文档", "*.xls; *.xlsx; *.xlsm; *.xlsb"
fd.Filters.Add "所有文件", "*.*"
If fd.Show = -1 Then
txtXlsPath.Text = fd.SelectedItems(1)
End If
End Sub

Private Sub btnRun_Click()


'Parameters check
If txtXlsPath.Text = "" Then
MsgBox "请输入 Excel 文件的路径"
Exit Sub
43
根据 VBA 制作展示 PPT

End If
If txtColumn.Text = "" Then
MsgBox "请输入图像文件路径所在的列名"
Exit Sub
End If
Dim bAbsoluteImgPath As Boolean, bHeader As Boolean
bAbsoluteImgPath = ckbAbsolutePath.Value
bHeader = ckbHeader.Value
Insert txtXlsPath.Text, txtImagePath.Text, txtColumn.Text,
bAbsoluteImgPath, bHeader
MsgBox "任务完成"
End Sub

Private Sub ckbAbsolutePath_Change()


If ckbAbsolutePath.Value = True Then
txtImagePath.Enabled = False
btnBrowseDict.Enabled = False
Else
txtImagePath.Enabled = True
btnBrowseDict.Enabled = True
End If
End Sub

这部分代码定义了当几个按键被按下后要进行的工作。其中,btnBrowseFile 按
键即为图中“Excel 文件路径”后边的“浏览”按键。它被按下后就会启动一个
“Open File”对话框。这个对话框是由 Application.FileDialog 来直接提供的,
不需要我们自己编写。你只要告诉它打开“msoFileDialogFilePicker”这种对
话框即可——即用于选取文件的文件对话框。在显示之前,添加一些文件类型过
滤器,使得只有 Excel 文件会被显示出来,如下图所示。这是由
FileDialog.Filters.Add 来添加的。

FileDialog.Show 函数就会使得文件对话框被显示出来。当用户选好文件,按
“确定”退出文件对话框时,Show 函数就会返回一个-1(如果是按“取消”退
出,则会返回 0)。所以就会有 If fd.Show = -1 Then …这样的语句,将用户
44
根据 VBA 制作展示 PPT

确定选中的文件路径放到文本框 txtXlsPath 中。这样浏览和选择 Excel 文件路


径的工作就结束了。

选择图像文件根路径的工作与选择 Excel 文件非常类似,只是将文件对话框的类


型改为了 msoFileDialogFolderPicker——选择文件夹的文件对话框。如果输入
的 Excel 文件中的图像文件路径均为相对路径,这个“根路径”就是拼接在每个
相对路径之前的路径。通过综合使用图像文件夹根路径和复选框“Excel 中的图
像路径使用绝对路径”,就可以允许用户在输入的 Excel 中使用绝对路径或者相
对路径。

在将所有的配置填好之后,点击 btnRun,程序就开始进行简单的输入检查,防
止用户输入的一些漏洞。当一切没有问题之后,所有的配置会以参数的形式传递
给 Insert 这个 Sub。这是本程序的核心 Sub。下面是这个 Sub 的代码:

Sub Insert(xlsPath As String, imageRoot As String, column As String,


bAbsolulteImgPath As Boolean, bHeader As Boolean)
ClearAllSlides
Dim excelApp As New Excel.Application
Set workbook = excelApp.Workbooks.Open(FileName:=xlsPath)
workbook.Activate
Dim imagePath As String
Dim layout As CustomLayout
Set layout = ActivePresentation.SlideMaster.CustomLayouts(7) '7 means a
blank layout
Dim i As Integer
Dim curr As slide 'Current slide
If (bHeader = True) Then
i=2
Else
i=1
End If
'Insert slides
Do
Set Cell = workbook.Sheets(1).Range(column & i)
If Cell.Value = "" Then workbook.Close: excelApp.Quit: Exit Sub
With ActivePresentation
.Slides.AddSlide .Slides.Count + 1, layout 'Create a new slide
Set curr = .Slides(.Slides.Count)
If bAbsoluteImgPath = False Then
imagePath = imageRoot & "\" & Cell.Value
Else
imagePath = Cell.Value
End If
i=i+1
PutImage imagePath, 1, curr 'Insert the first picture
45
根据 VBA 制作展示 PPT

'Insert the second to fourth pictures


Dim j As Integer
For j = 2 To 4
Set Cell = Workbook.Sheets(1).Range(column & i)
If Cell.Value = "" Then Workbook.Close: excelApp.Quit: Exit Sub
If bAbsoluteImgPath = False Then
imagePath = imageRoot & "\" & Cell.Value
Else
imagePath = Cell.Value
End If
i=i+1
PutImage imagePath, j, curr
Next
End With
Loop While True
End Sub

这个 Sub 的第一步就是利用 ClearAllSlides 删除 PPT 文件中所有已有的幻灯片。


其代码为:

Private Sub ClearAllSlides()


'Clear the existig slides
Dim i As Integer
Dim n As Integer
n = ActivePresentation.Slides.Count
For i = 1 To n
ActivePresentation.Slides(1).Delete
Next
End Sub

这个 Sub 依次遍历 ActivePresentation


(即当前活动的 PPT 文件)的每个 Slide,
并将其删除。注意这里每次都应用了 Slides(1),而不是 Slides(i)。这是因为,
每次删掉一个幻灯片,原来编号为 2 的幻灯片的编号就会变成 1,依次类推,直
到最后一个幻灯片被删干净。

删除完了之后,就要打开 Excel 文件读取其内容:

...
Dim excelApp As New Excel.Application
Set Workbook = excelApp.Workbooks.Open(FileName:=xlsPath)
Workbook.Activate
...

由于代码是写在 PowerPoint 中,所以为了让上面代码正确执行,必须首先添加


对 Microsoft Excel 12.0 Object Library 的引用(这里用的 Excel 2007 的版
46
根据 VBA 制作展示 PPT

本对应库的版本号是 12.0,所以如果你安装的是 2003,就只能看到 11.0 这样的


版本号)。这段代码新建了一个 Excel.Application 对象。实际上就是启动了一
个 Excel 程序,只不过没有显示罢了。在 Windows 任务管理器中可以看到在这句
执行后确实有个 Excel.exe 在执行。如果希望其显示出来,执行
excelApp.Visible=True 即可。不过在本例子中,没有必要让其显示。启动 Excel
后,利用其 Workbooks.Open 函数打开一个 Excel 文件。这个文件的路径正是上
面在 txtXlsPath 中输入的那个路径。最后将此 Workbook 激活,以备后用。

之后,程序定义了一个用于幻灯片母版(SlideMaster)的 Layout:

...
Set layout = ActivePresentation.SlideMaster.CustomLayouts(7) '7 means a
blank layout
...

所谓 Layout 就是 PowerPoint 中的“版式”,每个版式都有一个内部编号:

可以看到 7 正是“空白”。因为我们不需要任何“占位符”(如果你不清楚什么
是 PowerPoint 的母版和占位符,请查阅相关帮助——它们对于日常的
PowerPoint 应用很重要)。

接下来,要确定该从 Excel 的哪行开始读取。如果考虑标题行,就从第二行开始


读;否则就从第一行开始读。这就是下面这段代码做的事情:
47
根据 VBA 制作展示 PPT

...
If (bHeader = True) Then
i=2
Else
i=1
End If
...

终于,可以进行实际的数据读取工作了。因为每个幻灯片要放四幅图,所以如果
当前的图片刚好是一个幻灯片里的第一幅图片,就需要新建一个幻灯片,然后把
图插进去:

...
Set Cell = workbook.Sheets(1).Range(column & i)
If Cell.Value = "" Then workbook.Close: excelApp.Quit: Exit Sub
With ActivePresentation
.Slides.AddSlide .Slides.Count + 1, layout 'Create a new slide
Set curr = .Slides(.Slides.Count)
...

可以看到,这里对 Excel 的数据做了一些假设,即数据总是在 Sheets(1)里,并


且如果一旦某一行的数据为空,就认为后边没有数据了,于是就退出 Insert;
否则,就新建一个幻灯片。这是由 Slides.Add 方法做到的。它需要两个参数,
一是要插入的位置,二是新幻灯片的版式。上面的语句中就是插入一个具有“空
白”版式的幻灯片到所有已有幻灯片的最后边。然后让 curr 指向新被插入的幻
灯片。

此外,这里用了 With 语法,它可以使得书写代码更加简便一些,不用每次都写


一长串对象的引用。在上面这个例子的 With...End With 内,每个以.开头的语
句都等价于以 ActivePresentation.,这就省略了每次都要写一遍
ActivePresentation 的麻烦。

新建幻灯片之后,需要构造图片的实际路径:

If bAbsoluteImgPath = False Then


imagePath = imageRoot & "\" & Cell.Value
Else
imagePath = Cell.Value
End If

如果是相对路径,就要将用户窗体中的图片根路径和 Excel 文件中记录的相对路


径做拼接。否则,直接用就好了。

得到图片路径之后,就要进行实际的在幻灯片中加入图片的工作了。这是由
PutImage 来实现的,其代码为:
48
根据 VBA 制作展示 PPT

'Pos 1, 2, 3, 4 means left top, right top, lef bottom, right bottom
Private Sub PutImage(imgPath As String, pos As Integer, slide As Slide)
'Set postion
Dim left As Integer, top As Integer, width As Integer, height As Integer
Select Case pos
Case 1: left = 5: top = 5
Case 2: left = ActivePresentation.PageSetup.SlideWidth / 2 + 5: top = 5
Case 3: left = 30: top = ActivePresentation.PageSetup.SlideHeight / 2 + 5
Case 4: left = ActivePresentation.PageSetup.SlideWidth / 2 + 5: top =
ActivePresentation.PageSetup.SlideHeight / 2 + 5
End Select
width = ActivePresentation.PageSetup.SlideWidth / 2 - 10
height = ActivePresentation.PageSetup.SlideHeight / 2 - 10
slide.Shapes.AddPicture imgPath, msoFalse, msoTrue, left, top, width, height
End Sub

这个 Sub 接受三个参数,文件的绝对路径 imgPath{String},位置号 pos{Integer}


和要插入的幻灯片对象 slide{Slide}。其中位置号可以是 1、2、3、4,分别对
应左上、右上、左下、右下。根据位置号,可以计算出图片应该插入的位置,即
左上角的坐标 left, top 和尺寸 width, height。这里用了
ActivePresentation.PageSetup 对象来获得幻灯片的实际尺寸,以应付幻灯片
尺寸不确定的问题。当位置和尺寸都确定好了,就利用
Slide.Shapes.AddPicture 将图片插入到幻灯片中。因此调用:

PutImage imgPath, 1, curr

可以将一幅图片放到幻灯片 curr 的左上角。

最后,当 Excel 的数据全部被读完,一定要记得关闭打开的 workbook{Workbook}


和 excelApp{Excel.Application}。正如上面的代码显示的那样,需要调用:

workbook.Close

excelApp.Quit

如果 workbook 不关闭,它就无法被其他的程序打开;excelApp 如果不关闭,就


会一直占用内存,等价于一直开着 Excel 一样(只是你看不见罢了)。如果同时
运行数次这个程序而不关闭 excelApp,就相当于打开数个 Excel 而不关闭。

程序运行完之后,会弹出对话框提示:

49
根据 VBA 制作展示 PPT

...
MsgBox "任务完成"
...

好啦,可以看一下最后的运行结果。

在本期教程中,讲解了如何使用 VBA 联合 Excel,PowerPoint 和用户窗体一起制


作一个完整的程序。如果 Excel 中记录上千的话,使用这个程序来制作 PPT 的时
间不会超过 10 秒钟。而编写这样一个程序,包括测试在内不会超过 1 小时。这
可以说明 VBA 对提高大量重复性工作的效率是极为有效的。本程序也许比较长和
繁杂,但是这是假设程序需要一定灵活的配置的前提下才会如此。如果可以假设
输入 Excel 的数据格式(比如使用相对路径还是绝对路径,有没有列头,路径所
在列的名称等)是确定的,则可以对程序进行相当多的简化。

在下一期中,将会讲解利用 VBA 如何使得 Excel 和 Word 进行合作的。

50
提取 Word 中未样式化的标题

8. 提取 Word 中未样式化的标题
在本期中,我会将注意力放在 Word 上。网友 dinosaurhxe 曾经提出了一个问题:
他们单位的老干部不是太会用 Word,写标题的时候完全没理会标题样式,全部
用了正文。每个标题分为三部分:标号、中文标题和英文标题。dinosaurhxe 很
苦恼,因为领导要求它产生三份列表,分别以标号、中文标题和英文标题排序,
同时结果还要包含标题对应的页码。这样的话,就必须把这些标题全提取出来
——那可是好几百页啊。不过,尽管老干部们不会用样式,还是留下了一些线索
——标题都很工整的被分为以上三个部分,均以空格隔开,并且全被设为了粗体。
问题的数据规模比较大,问题没有通用的解法,并且很有规律。这刚好符合 VBA
施展功力的条件。

我将这个问题的解决方案定为:如果碰到了一段的段首是粗体的数字,就认为这
个东西是标题。当然,这里可以加很多条件来使得结果更不容易错,比如最大长
度不能超过某某值等。将标题拆解后放到 Excel 中,每个部分一列,这样想怎么
排序都可以了。VBA 的代码写在 Word 中。依照老规矩,还是先看看有哪些子问
题需要解决。

1. 如何操作 Word 文档,并且一段段的遍历这个文档?


2. 如何获得每段的第一个字符?
3. 如何判断字符是粗体?
4. 如何判断字符是数字?
5. 如何拆解一段文字(比如用空格作为分隔符)?
6. 怎么获得标题对应的页码?
7. 如何填写 Excel 数据表?

对于问题 1,非常类似于 Excel 的 ActiveWorkbook,Word 中也有 ActiveDocument


可以获得当前的文档。在 Document 类型中有一个 Paragraphs 集合,包含了 Word
文档中所有的段落。这样,很轻松的可以用

Dim p As Paragraph
For Each p In ActiveDocument.Paragraphs
'do something...
Next

来遍历段落了。

对于问题 2,每个段落对象中都有一个 Range 对象。在 Excel 中 Range 表示一个


或者多个单元格;但在 Word 中,Range 代表了一段文字。Paragraph.Range 就代
表了整段文字。而 Word 的 Range 对象中有一个 Characters 集合,表示了每个字
符。那么 Characters(1)就是 Range 的第一个字符。有趣的是 Charaters 集合中

51
提取 Word 中未样式化的标题

每个对象也都是 Range 类型的,也就是很多代表一个字符的 Range。段落 p 的第


一个字符就是这么取得的:

Dim firstChar As Range


Set firstChar = p.Range.Characters(1)

对于问题 3,Range 对象有一组布尔属性来表示 Range 的格式,比如粗体就是


Range.Bold;斜体是 Range.Italic;下划线就是 Range.Underline。那么判断
firstChar 是不是粗体的语句就是:

If firstChar.Bold = True Then


'do something...
End If

对于问题 4,VBA 本身提供了 IsNumeric 函数来判断一个字符串是否是数字。如


果 IsNumeric(firstChar.Text)为真,则就是数字。

对于问题 5,可以使用 VBA 本身提供的 Split 函数。这个函数在前面几期多次提


到了,就不赘述了。

对于问题 6,Word 本身提供的访问方式并不是那么直观。因为只有 Selection


对象的 Information 函数才能获得一个段落所在的页码。在 Word 当中 Selection
对象就是 Selection 类型的,不像 Excel 中的 Selection 对象那样是 Range 类型
的。在 Word 中 Selection 和 Range 是两种不同的对象,尽管它们很多地方很相
似,但是还是有一些不同,比如 Range 就没有 Information 函数。这样,为了获
得一个段落的页码,必须首先选中这个段落,然后利用
wdActiveEndAdjustedPageNumber 参数来调用 Information 函数,就像是这样:

p.Range.Select
pageNum = Selection.Information(wdActiveEndAdjustedPageNumber)

所谓 ActiveEndAdjused 是指获取选中区域结尾所在的(因为一个段落可能跨页),
经过调整过的页码。所谓调整是指,如果对文档分了节,并做了页码设置(如首
页页码不是 1,或者加了分页符),就返回设置后得到的页码。如果只想要那种
文档第一页为 1,忽略任何页码设置的页码,可以改用 wdActiveEndPageNumber。
更多的枚举值可以看这里:
ms-help://MS.WINWORD.DEV.12.2052/WINWORD.DEV/content/HV10076103.htm

对于问题 7,相信经过那么多次的教程,这个就没有必要再讲了。详细的程序如
下所示:

Sub ExtractTitle()
Dim p As Paragraph
Dim firstChar As Range
Dim num As String, chTitle As String, enTitle As String 'title number, Chinese
52
提取 Word 中未样式化的标题

title, English Title


Dim pageNum As Integer ' page number of a title
'Start excel
Dim excelApp As New Excel.Application
excelApp.Visible = True
Dim sheet As Worksheet
Set sheet = excelApp.Workbooks.Add.Worksheets(1)
'Write header
sheet.Range("A1").Value = "序号"
sheet.Range("B1").Value = "中文标题"
sheet.Range("C1").Value = "英文标题"
sheet.Range("D1").Value = "页码"
i=2
For Each p In ActiveDocument.Paragraphs
Set firstChar = p.Range.Characters(1) 'Get the first character
If firstChar.Bold = True And IsNumeric(firstChar.Text) Then 'If it is bold
and a number
'Split with white space
items = Split(p.Range.Text, " ")
num = items(0)
chTitle = items(1)
enTitle = Right(p.Range.Text, Len(p.Range.Text) - Len(num) -
Len(chTitle) - 2)
p.Range.Select
pageNum = Selection.Information(wdActiveEndAdjustedPageNumber)
'put those information to excel
sheet.Range("A" & i).Value = num
sheet.Range("B" & i).Value = chTitle
sheet.Range("C" & i).Value = enTitle
sheet.Range("D" & i).Value = pageNum
End If
Next
End Sub

其中,首先新建了一个 Excel 文档(这部分的正常运行需要你在 Word 的 VBA 项


目中添加对 Microsoft Excel Object Library 的引用),把 Excel 工作表中的
列头写上。然后开始遍历每个段落,如果一个段落的第一个字符是粗体,并且是
数字,我们就认为它是标题。然后,利用 Split 拆分了这个段落的问童子。第一
部分显然是标题号;第二部分是中文标题;余下的所有部分都是英文标题,这是
因为英文本身就会有很多空格。这段代码利用 Right 函数完成了对英文标题的截
取:

enTitle = Right(p.Range.Text, Len(p.Range.Text) - Len(num) - Len(chTitle) -


2)

53
提取 Word 中未样式化的标题

最后,利用 Selection.Information 得到了标题的页码。所有这些信息都写入到


Excel 工作表的 ABCD 四列中。当遍历完所有的段落,就大功告成了。

54
操作数据库

9. 操作数据库
Excel 一个重要的数据分析平台,自然就少不了与数据库连接。在 Excel 上就有
直接打开数据源的选项,如下图所示:

尽管这种方式用起来很方便,这种连接数据源的方式和其他功能一样,不是很灵
活。查到的数据会被放到一个工作表里,并做自动筛选。如果我们有特别的要求,
比如希望将数据表的左左上角定位在 B10 单元格,这种方式就不能满足要求。另
外,鉴于 SQL 的强大,这些“好用”的功能到了高级用户手里就会一无是处。最
后,这种方式不能把 Excel 的数据 Insert 到数据库中。所以,利用 VBA 连接并
操作数据库就显得很必要了。

要相操纵数据库,首先添加对 Microsoft ActiveX Data Objects X.X Library


的引用。其中的 X.X 是版本号。对于本教程使用的功能来讲,版本号是无所谓的
——根本用不到那些新功能。这个库是一个把数据库操作对象化的库,使用起来
和其他的库很类似。

然后就可以写一个 Sub 来连接数据库了。首先,需要新建一个连接对象。

55
操作数据库

Dim con As New ADODB.Connection

注意其中的 New 关键字用于创建一个新的对象。这也可以写成

Dim con As ADODB.Connection


Set con = New ADODB.Connection

然后,就要设置连接字符串了。在这个例子中,我建了一个 Access 2007 的数据


库 mydb 来做实验。这样,连接字符串就是:

con.ConnectionString = "Provider=Microsoft.ACE.OLEDB.12.0;Data
Source=D:\MyDoc\Documents\mydb.accdb"

当然,如果你连接其他的数据库,连接字符串是不一样的,比如连接 SQL Server


就可能是

"Provider=SQLOLEDB;Data Source=[db source];DATABASE=[db


name];UID=[user]; pwd=[password]"

每种数据库都有自己的连接字符串格式,你需要查阅文档才能知道到底该怎么写
这个连接字符串。不过连接字符串的一般格式是一致的,即一组被分号;分开的
key=value 的项目。Provider 是指数据库的驱动类型;Data Source 是指数据库
文件;Database 是指数据库名;UID 是用户名;pwd 是密码;有的时候,需要用
Windows 集成帐户校验,这时还要加上 Integrated Security=sspi。其外还有很
多其他的项目,例如通讯方式,永续化方式,字符编码等。这些项目大多可选,
如果不写也可以连上数据库,只有在希望对连接方式进行精细调整的情况下才需
要写它们。

设置好了连接字符串后,就可以用以下代码打开数据库

con.Open

最常见的数据库使用方式就是查询了。为了存储查询结果,必须首先新建一个记
录集对象。

Dim rs As New ADODB.RecordSet

然后可以定义一条 SQL 查询语句

queryText = "select * from Person"

你可以书写任意复杂的 SQL 语句,并将其拼成一个字符串。然后,就可以执行查


询了。

rs.Open queryText, con

56
操作数据库

可以看到 RecordSet 的 Open 方法在这里接收两个参数,第一个是查询语句字符


串,第二个是打开的数据库连接。当执行完这句后,查询结果就已经存储在 rs
之中了。当然,如果数据量巨大,就会花一些时间。

接下来的工作就是将查询结果从 rs 中读出,并显示在 Excel 工作表里。这里首


先书写列头。

For i = 1 To rs.Fields.Count
ActiveSheet.Cells(1, i).Value = rs(i - 1).Name
Next

rs 内部会有一个游标(Cursor)。刚查询完后,游标指向查询结果的第一条记
录。每条记录有若干列,每一列被称为一个域(Field)。rs(index)就是指当前
指向记录的第 index 个域。上面这段代码遍历每一个域,并将其 Name 显示出来,
放到工作表第一行的对应列中。需要注意,Office 的库中的索引一般从 1 开始
计数,而数据库的索引都是从 0 开始计数的(因为它们都是用 C/C++开发的)。
所以 rs 的索引要用 i-1。执行效果如下:

接下来就是遍历每条记录,将所有的数据显示出来。

r=2
While (Not rs.EOF)
For i = 1 To rs.Fields.Count
ActiveSheet.Cells(r, i).Value = rs(i - 1).Value
Next
rs.MoveNext
r=r+1
Wend

遍历以 rs.EOF=false 为条件。当 EOF=true 时,就说明游标已经到了所有查询结


果的末尾。对于每一条记录,可以用 rs(index).Value 获取它的值。处理完一条
记录后,用 rs.MoveNext 来将游标定位在下一条记录。执行后,可以看到效果:

57
操作数据库

当然,如果你想直接把 rs 的所有数据显示在单元格里有更简单的做法。比如希
望显示位置的左上角在 A2 单元格,直接写一句 Range.copyFromRecordSet 就可
以了。

Range("A2").CopyFromRecordSet rs

这个和上面显示数据的效果完全相同。不过它不会帮你把列头显示出来。

好了,说完了读取数据库,再说说修改数据库。修改数据库有 Insert,Update
和 Delete 三种。可以书写 SQL 语言直接进行这种操作。这里以 Insert 为例。在
数据库连接打开后,直接用 Connection.Execute 来执行一个修改数据库的 SQL
命令,如下所示。

con.Execute "Insert Into Person(PName, Gender) Values('Linda', 'F')"

就能将数据插入到数据表中。因为 PID 在这个例子中是自动生成的,所以可以不


用为它赋值。执行这条指令后,就会在数据库中看到下面的效果

然而 RecordSet 为我们提供了更方便的方式。在查询完成之后,我们可以直接对
RecordSet 进行修改,比如:

rs("PName").Value = "New Name" 'modify the current record


...
rs.AddNew 'add a new record
rs("PName").Value = "Ivy"
rs("Gender").Value = "M"
58
操作数据库

...
rs.Delete 'delete the current record

等等。最后执行

rs.Update

RecordSet 就会根据我们的修改,自动将插入,删除,和更新应用到数据库里。
所以如果希望将工作表数据导入到数据库,就可以直接将数据放到一个空白的数
据集中,然后再 Update 即可。当然 SQL Server 的 T-SQL 有更加方便的语句 Open
Source 可以直接导入 Excel 数据。不过因为这是 VBA 教程,就不介绍它了。

不过为了使得这种修改可以执行,必须在 rs 被打开时指定可进行读写操作。就
是说必须书写 Record.Open 的第四个参数。下面具体说一下其第三个和第四个参
数的含义。

rs.Open queryText, connection,cursorMode, lockMode

其中 cursorMode 可以是:

0 = adOpenForwardOnly 打开仅向前类型游标。
1 = adOpenKeyset 打开键集类型游标。
2 = adOpenDynamic 打开动态类型游标。
3 = adOpenStatic 打开静态类型游标。

而 lockMode 可以是:

1 = adLockReadOnly (默认值)只读 — 不能改变数据。


2 = adLockPessimistic 保守式锁定(逐个) — 在编辑时立即锁定数据源的记
录。
3 = adLockOptimistic 开放式锁定(逐个) — 只在调用 Update 方法时才锁
定记录。
4 = adLockBatchOptimistic 开放式批更新 — 用于批更新模式(与立即更新模
式相对)。

所以 lockMode 的默认值不允许 rs.Update,需要手动设置一个可以修改的


lockMode。

程序的最后请务必记住要关闭数据库连接。

conn.Close

另外,最好将两个对象的引用取消掉,使其可以释放空间:

conn=Nothing
rs=Nothing
59
操作数据库

这样做可以避免资源的浪费。

60
操作 Windows API

10. 操作 Windows API


尽管 VBA 十分的强大,依靠众多库,可以提供相当丰富的功能。然而,仍然有大
量的操作系统提供的功能无法实现。实际上,操作系统的一切都是由操作系统
API (Application Programming Interface)来提供的。Windows API 是一组 C/C++
风格的函数、结构和常量的集合。在必要的情况下,VBA 可以操作 Windows API
来完成一些特定的,使用常规库无法做到的工作。

不过在使用这个功能之前,一定要搞清楚是否真的没有别的办法了。因为调用
Windows API 函数可能会影响应用程序的可靠性。当你从自己的代码中直接调用
API 函数时,会跳过 VBA 在正常情况下提供的一些安全机制。如果您错误地定
义或调用 API 函数(任何程序员都难免犯这类错误),就可能会产生应用程序
错误。如果使用了 API,那么在运行代码前一定要保存项目,并确保已理解调用
API 函数的原理。因此,你可能需要实现了解一下 C/C++的初步知识,还要稍微
的学习一下 Windows API 和 Windows 的内部机制。比如,可以阅读
MarkE.Russinovish 和 DavidSolomon 合著的 Microsoft Windows Internals。

不同与之前的 COM 组件,想使用 Windows API 的函数不能采用“添加引用”的方


式。这些 API 函数被声明在多个 DLL 文件里(本质上,COM 组件的物理载体也是
DLL 文件,不过与 Windows API 的不同),必须采用一种特殊的 Declare 语句来
声明要使用某个 API 函数。例如:

Private Declare Function GetTempPath Lib "kernel32" Alias "GetTempPathA"


(ByVal nBufferLength As Long, ByVal lpBuffer As String) As Long

这句话的意思是从 kernel32.dll 这个文件中找到一个名称为 GetTempPathA 的函


数,并将以名字“GetTempPath”在后边的代码中使用。当然,如果两个名字是
一样的,就可以不写 Alias 那个部分。这个函数有两个参数,一个是 BufferLength,
另一个是 Buffer 本身(lpBuffer 是 long pointer to buffer 的意思,关于 pointer,
也就是指针的概念可以在 C/C++教程里找到)。返回值是一个长整数。在这个语
句中 Private, Declare, Function, Lib, Alias, ByVal 都是关键字,即它们都
是不会变的。而函数的名称、参数列表和 DLL 文件的名称则根据你的需要来指定。
DLL 文件名不需要写.dll 扩展名,就像是上面 kernel32 那样。以上 GetTempPath
的用途是获得 Windows 临时目录的路径,例如 C:\Documents and
Settings\User\Temp。

Windows API 函数一般都会用 A 或者 W 结尾,分别表示 ANSI 编码版本和 Unicode


编码版本。尽管 VBA 内部使用 Unicode,但在调用 DLL 函数前,它会将所有字
符串都转换成 ANSI 字符串。因此从 VBA 中调用 Windows API 函数时,通常还
是使用 ANSI 版。此外,在 VB 中传递参数的类型可以是 ByRef 和 ByVal,默认是
前者。但因为是在调用 API,默认是 ByVal (C/C++处理参数的方式是 ByVal 的),
所以需要在上面的声明中明确的将 ByVal 写出来。
61
操作 Windows API

微软的 Win32API.txt 文档里涵盖了所有你可以使用的 Windows API 清单。当你


安装了 Windows Office Developer、Windows SDK 或者 Visual Studio 时就自动
获得了它。当然,你也可以在网上单独下载这个文件。绝大部分的 API 函数的声
明都被放在 3 个 dll 里(但并不实现在那里……不过,本教程并不关系 API 是怎
么实现的),分别是:

• Kernel32.dll :低级操作系统函数,例如内存管理和资源处理函数
• User32.dll :Windows 管理函数,例如用于信息处理、计时器、菜单和
通信的函数
• GDI32.dll:图形设备接口 (GDI) 库(包含设备输出函数),例如用于绘
图、显示环境和字体管理的函数

下面通过讲解一个例子来说明 VBA 是怎么使用 API 的。这个例子是改变某一个程


序窗口的标题。先看一下修改的效果:

我将一个桌面词典的窗口标题修改了。而这个软件与 Office 没有任何关系,它


也没有提供任何 COM 对象接口。

为了将问题解释清楚,我先简单的说明一下 Windows 窗口的概念。在 Windows


中,几乎任何可以看到的东西,如看到的窗口、标签、文本框、按键等,都是“窗
口”,只不过外观和对外界的响应(比如鼠标点击)不同罢了。他们构成一个具
有树状层次的“窗口结构”,子窗口的绘制不能超出父窗口的区域。所以一个程
序里任何控件都无法画在程序窗口的外边。当然,如果非得要做到(像 Photoshop
那样),还是可以办的到的,比如将“桌面”设为程序的父窗口。 任何一个窗
口都有一个唯一的标记,被称为“窗口句柄”(Window Handler)。这是一个
32 位的整数,刚好对应 VBA 中的 Long。至于长整数的数值是没有什么意义的,
只不过依靠这个句柄,就能告诉 Windows 将哪个窗口进行某个操作。这与前面的
文件有文件号,对象有引用的道理是一样的。

如果想改变一个窗口的文字,必须首先找到要改变的是哪个窗口。这就必须得用
到 Windows API 中的 FindWindowA、GetWindow 和 GetWindowTextA 函数。前者根
62
操作 Windows API

据一定的条件来查找一个窗口,并返回其句柄值。在这里,我们不给其任何参数,
就让它将一个任意一个窗口句柄,然后再用 GetWindow 来进行窗口遍历。而
GetWindowTextA 则可以获得一个窗口的文字。为了使用它们,首先用上面的语
法声明它们。

Private Declare Function FindWindow Lib "user32" Alias "FindWindowA" (ByVal


lpClAssName As String, ByVal lpWindowName As String) As Long
Private Declare Function GetWindow Lib "user32" (ByVal hwnd As Long, ByVal
wCmd As Long) As Long
Private Declare Function GetWindowText Lib "user32" Alias "GetWindowTextA"
(ByVal hwnd As Long, ByVal lpString As String, ByVal cch As Long) As Long

然后简单书写一下大致的流程。

获得第一个窗口句柄
While 句柄不为空(如果为空就意味着遍历完了)
如果句柄代表的窗口文字中包含“有道”,就将其改名,并退出
否则,获得下一个窗口的句柄
Wend

然后,再用 API 来书写实际的程序

Dim WinHandler As Long, captain As String * 26


WinHandler = FindWindow(vbNullString, vbNullString)
While WinHandler <> 0 ' 0 means there is no window to retrasverse
GetWindowText WinHandler, captain, 26
If InStr(captain, "有道") Then
'Change this window's text
MsgBox "Found!!!"
Exit Sub
End If
WinHandler = GetWindow(WinHandler, 2)
Wend
MsgBox "Not Found"

其中 FindWindow 帮我们获得了一个任意窗口的句柄。其实 FindWindow 的第二个


参数可以填写要查找的窗口名称,不过这种查找只支持精确的匹配,比如写

FindWindow(vbNullString, "abc")

是找不到窗口文字为”abcdef”的窗口的。所以才需要进行遍历。这对于找到那
些窗口标题是动态变化生成的(例如 QQ 之类的)是必须的。

之后进入了循环,条件是句柄不为 0。一旦为 0 就说明,所有的窗口都均遍历到


了。而窗口的遍历是由 GetWindow 函数来完成。其第一个参数当前的窗口句柄,
63
操作 Windows API

第二个则是相对位置。2 表示获得当前窗口的下一个窗口(自然也能获得上一个,
第一个,或者最后一个窗口,详情可以看 WinAPI.txt 或者 MSDN)。你可以先定
一个常量,使这段代码语义更清晰。

Const HWNDNEXT As Long = 2


...
WinHandler = GetWindow(WinHandle, HWNDNEXT)

每次获得一个窗口句柄,就获取其窗口文字,并将其放到 captain 变量中

GetWindowText WinHandler, captain, 26

请注意 captain 是一个定长字符串,最大长度为 26 个字符。在 VBA 中很少见到


这种字符串。在这里是由于 Windows API 要求使用定长字符串的缘故。在 C/C++
中,对内存的控制十分严格,像变长字符串这种数据结构,C/C++的基本数据类
型是不支持的。

当找到了期望的窗口后,就需要改变其文字。这里,需要再声明一个 API 函数
SetWindowTextA:

Private Declare Function SetWindowText Lib "user32" Alias "SetWindowTextA"


(ByVal hwnd As Long, ByVal lpString As String) As Long

然后,在上面代码中,将相应的注释改为:

SetWindowText WinHandler, "This is a Text new Caption"

这就完成了所有的工作。以下是完整的代码:

Private Declare Function FindWindow Lib "user32" Alias "FindWindowA" (ByVal


lpClAssName As String, ByVal lpWindowName As String) As Long
Private Declare Function GetWindow Lib "user32" (ByVal hwnd As Long, ByVal
wCmd As Long) As Long
Private Declare Function GetWindowText Lib "user32" Alias "GetWindowTextA"
(ByVal hwnd As Long, ByVal lpString As String, ByVal cch As Long) As Long
Private Declare Function SetWindowText Lib "user32" Alias "SetWindowTextA"
(ByVal hwnd As Long, ByVal lpString As String) As Long

Const HWNDNEXT As Long = 2

Sub ReplaceCaptain()
Dim WinHandler As Long, captain As String * 26
WinHandler = FindWindow(vbNullString, vbNullString)
While WinHandler <> 0 ' 0 means there is no window to retrasverse
GetWindowText WinHandler, captain, 26

64
操作 Windows API

If InStr(captain, "有道") Then


SetWindowText WinHandler, "This is a Text new Caption"
MsgBox "Found!!!"
Exit Sub
End If
WinHandler = GetWindow(WinHandler, HWNDNEXT)
Wend
MsgBox "Not Found"
End Sub

当然,因为 API 是一切的底层,所以不止可以完成上面这么简单的任务。比如要


进行明确的操作系统资源分配(进程、线程、内存),复杂的图形界面(比如不
规则的窗体),网络通讯等,都是可以做得到的。不过,相信这些应当成为 VBA
的补充。如果主要的工作精力全部在 Windows API 的使用上,就应该直接使用
C/C++。

本期教程是最后一期教程,只要你收到了一点点启发,就达到了本教程的目的。
最后感谢大家的支持,希望大家都能用 VBA 来提高自己的工作效率,让 Office
的高级功能真正的为自己服务。

65
END

66
END

67

You might also like