从普通函数到对象方法 ------Windows窗口过程的面向对象封装

原创 2006年12月20日 22:12:00

从普通函数到对象方法

------Windows窗口过程的面向对象封装

 

开始,由VirtualAlloc想起

      我在查看VirtualAlloc这个API的时候,思绪竟然跳到另一个地方去了。那是以前阅读VCL源码时遗留下来的问题,Classes单元的MakeObjectInstance函数调用了VirtualAlloc,我甚是不解,为什么Delphi提供了那么多内存分配函数,而MakeObjectInstance偏偏要用系统提供的API,更令我不解的是,之后再也不见有VirtualFree的调用,也就是说,VCL其实存在内存泄漏?这个问题我在网上也看到相关的讨论,有人认为这的确是VCLBug,有人甚至修改了Classes单元,在单元的结束节处调用VirtualFree以释放以前分配的内存。

      不过我对这个问题始终持保留态度,MakeObjectInstance是一个非常重要的函数,担负着窗口过程到对象方法的转换,Borland没有理由留着这个“Bug”不理。

      于是我重新阅读了MakeObjectInstance这个倍受李维赞誉的函数,我想我这次是读懂了,为什么不调用VirtualFree,因为没有必要,进程在结束的时候会毫无保留的回收所有的内存,而经由VirtualAlloc分配到的内存就保留在由TInstanceBlock记录所组成的链表中,这个链表组成的内存并不是使用一次即弃掉的,它是可重用的,调用一次FreeObjectInstance,那张链表便空余出TObjectInstance大小的内存,以供下一次使用。所以,这其实是一个内存池,提供了更上一层的内存分配机制。而在结束的时候调用VirtualFree就显得没有任何必要了。

      回到上面提出的第一个问题,为什么要调用VirtualAlloc,而不用Delphi提供的内存分配函数,如果没有看到System单元的这两个变量,我想永远也不可能找到答案:

var

  AllocMemCount: Integer; { Number of allocated memory blocks }

  AllocMemSize: Integer;  { Total size of allocated memory blocks }

这两个变量的确是记录内存使用的总量,前提是你调用Delphi提供的内存管理函数,如果调用Windows原生的API,则VCL是没有办法感应到的。写到这里,再看看上面的描述,也许一切都了然了。

      然而,这只是我写这篇文章的导火线,真正原因是我读懂了MakeObjectInstance,以前的许多疑惑已经拨云见日,窗口过程到对象方法的脉络在我的脑中从未有过这么清晰,因此欲罢不能,作此文记之。

使用,将窗口过程转成对象方法的步骤

      SDK的角度来讲,设置窗口过程有两种方法(我所能想到的),一是调用RegisterClass,另一个是调用SetWindowLong,第一种用在创建窗口的时候,另一种用在改变窗口过程的时候。在Delphi中,假设你写了一个自定义窗口类,那么你可以重载WndProc,这个方法就相当于窗口过程。可以确定,VCL在开始时肯定也是用上面所说的方法,设置窗口过程,只是后来经过一些转换,最终使窗口过程调用到对象实例的WndProc,所以WndProc可以当成窗口过程来使用。

      这个转换的步骤从表面上看很简单,现在我们不必去深究其原理,只要知道通过下面的做法,就可以将一个窗口过程转成对象的方法。

      首先,到Controls单元的TWinControl类,这是所有窗口的父类,转换过程就在这里面完成。TWinControl的构造函数中写了这一句:

constructor TWinControl.Create(AOwner: TComponent);

begin

  ... ...

  FObjectInstance := Classes.MakeObjectInstance(MainWndProc);

  ... ...

end;

其中的MainWndProc就是代替窗口过程的对象方法。

      接着,在InitWndProc有如下代码:

function InitWndProc(HWindow: HWnd; Message, WParam,

  LParam: Longint): Longint;

Begin

  ... ...

  SetWindowLong(HWindow, GWL_WNDPROC,

Longint(CreationControl.FObjectInstance));

  ... ...

end;

InitWndProc就是刚开始的窗口过程,而调用了SetWindowLong之后,窗口过程就转成了FobjectInstance了。而实际上最终得到调用是却是MainWndProc

      最后,在TWinControl的析构函数中还写了如下语句:

destructor TWinControl.Destroy;

begin

  ... ...

  if FObjectInstance <> nil then

 Classes.FreeObjectInstance(FObjectInstance);

  ... ...

end;

      这是为了回收由MakeObjectInstance使用的内存,让这块内存可在下一次重用。

      上面就是TWinControl的窗口过程到对象方法的转换步骤,这的确是很神奇的事情,它们在某些情况下是很有用的,比如TComboBox,在这个控件里面有一个用于编辑的Edit和一个用于下拉选择的ListBox,这两个控件是在ComboBox创建的时候一起创建的,VCL没有办法对它们进行封装,但有时候需要处理他们的消息,这时,上面的方法就派上用场了,事实上TComboBox就是运用上面的方法,将EditListBox的窗口过程转换成TcomboBox内部的方法的,有兴趣者请查阅一下VCL

      对上面进行一次总结:

1、             假设你通过原生的API创建了一个窗口,如果你想让这个窗口的窗口过程被指定为一个类的方法,那么可以在类的内部调用MakeInstanceObject,传进类的一个方法(如上面的MainWndProc,当然这个方法必须是TwndMethod类型的),并保留函数返回的指针。

2、             调用SetWindowLong,用类保留的指针替换原来的窗口过程。到这里,窗口过程就被传进MakeInstanceObject的对象方法所代替了。

3、             在消毁这个类的实例时,别忘了调用FreeObjectInstance,并传回保留的指针。如果这时窗口还未消毁,还得用SetWindowLong恢复原来的窗口过程。

      知道如何使用并不是我们的最终目的,我们要更进一步,为什么会是这样,请看下一节。

实现,窗口过程到对象方法的转换技术

      窗口过程实际上是一个回调函数,向API传递函数的地址,Windows保留着这个函数地址,在适当的时候调用这个函数。那么对象方法与普通函数有什么不同呢,对于同一种调用规则来说,不同之处就是对象方法在第一个参数之前有一个隐藏的参数,这个参数就是对象的实例(如果是C++应该叫实例指针,而Delphi的对象实例就是一个指针,只已经为大多数人所共知的事实)。

      另一方面,WindowsAPI使用的是Stdcall的调用规则,从机器指令的角度看,就是在Call某个函数之前,先将函数的参数从右向左地压栈。而Delphi为了提高效率,默认使用了Register调用规则,粗略的讲就是从左向右传递参数,且前三个参数分别放在EAXEDXECX寄存器中,其后则依次入栈。若要知道详细的规则,请查看Delphi的帮助主题:Calling conventions

      现在,如果我们想让窗口过程流入某个对象的方法,要解决两个问题:

1、             在进入对象方法的入口时,先将对象实例作为第一个参数传入,其次再将窗口过程的参数依次传入。对于Register调用规则来说,就是将对象实例赋值给EAX,再将其他参数按照规则赋给相应的寄存器或者压栈。

2、             Stdcall规则到Register规则的转换,这个不是必须的,因为Delphi也支持StdCall规则,但对Register规则来说效率更高,另一方面DelphiRegister规则作了更多的支持,比如Published的属性就只能指定Register规则的方法。 

现在让我们围线着这两个问题开始探索VCL是如何做的。

VCL在开始的时候同样要遵守Win32的做法,首先填充一个窗口类结构然后注册窗口类,注意TWinControl.CreateWnd中的这一句:

WindowClass.lpfnWndProc := @InitWndProc;

它将窗口过程指定为InitWndProc函数。

      接下来就创建窗口类,在TWinControl.CreateWindowHandle中:

FHandle := CreateWindowEx(ExStyle, WinClassName, Caption, Style,

      X, Y, Width, Height, WndParent, 0, WindowClass.hInstance, Param);

      现在来看,一切都似乎正常,但其实在调用CreateWindowEx的时候,事情正在稍稍发生变化。CreateWindowEx的时候系统将发送(请注意是发送而不是投递)一个WM_CREATE消息给窗口,处理这个消息的是谁呢,正是上面看到的InitWndProc

      有必要看一下这个函数的代码,我顺便作了详细的注释:

01 function InitWndProc(HWindow: HWnd; Message, WParam,
02   LParam: Longint): Longint;
03 Begin
04 //CreationControl就是窗口类,TWinControlCreateWnd的时候将Self赋给它
05 //由此可以看到VCL的窗口类是非线程安全的。
06   CreationControl.FHandle := HWindow;
07 //重设窗口过程,从此之后,这个函数再也不会得到调用了
08   SetWindowLong(HWindow, GWL_WNDPROC,
09     Longint(CreationControl.FObjectInstance));
10   if (GetWindowLong(HWindow, GWL_STYLE) and WS_CHILD <> 0and
11     (GetWindowLong(HWindow, GWL_ID) = 0then
12    SetWindowLong(HWindow, GWL_ID, HWindow);
13 //设置该窗的一些属性,与我们讨论的无关,可不去理会它们
14   SetProp(HWindow, MakeIntAtom(ControlAtom), THandle(CreationControl));
15   SetProp(HWindow, MakeIntAtom(WindowAtom), THandle(CreationControl));
16 //主动调用一次FobjectInstance
17   asm
18         PUSH    LParam
19         PUSH    WParam
20         PUSH    Message
21         PUSH    HWindow
22         MOV     EAX,CreationControl
23         MOV     CreationControl,0
24         CALL    [EAX].TWinControl.FObjectInstance
25         MOV     Result,EAX
26   end;
27 end;

6行对窗口类的Fhandle进行赋值,这么做是必要的,因为正常情况下Fhandle只有到CreateWindowsEx返回之后才能得到赋值,在这个函数调用的过程中,系统发送WM_CREATE消息给窗口,在外部,我们可以得到WM_CREATE的处理器进行处理,如果没有第6行的赋值,则那时我们将没有办法得到窗口句柄。我想这也是InitWndProc存在的原因之一。

8行重新设置窗口过程,设置为窗口类的FobjectInstance,从此以后,窗口消息只会流到FobjectStance指向的地方,这个函数也就作废了。

而接下来是一段汇编代码,主要的意思是调用FobjectInstance1821行传递参数(还记得STDCALL规则吗),然后24行调用FobjectInstance。这段汇编就相当于这样的语句:

WinControl := CreationControl;

CreationControl := nil;

Result := TThunkProc(WinControl.FObjectInstance)(HWindow, Message, WParam, LParam);

其实这正是Linux版下面的做法。

在这里我想说一下CALL指令,理解它的行为,对下文是很有帮助的,CALL指令可以分解为两个动作:先将下一条指令的地址(EIP)压栈,然后跳转到操作数指定的地址去。与CALL对应的是RET指令,这个指令其实就是从栈顶弹出一个值,然后跳转到这个值指明的地址去。这就是函数的原理,在函数内部,维持堆栈的平衡是非常重要的,你必须保证在RET的时候弹出来的值正是CALL的时候压入的值,这样才能正确返回到CALL指令的下一条指令的地址,要不然执行点就不知跳到哪里去了?当然使用高级语言不用去关心这些东西,但理解堆栈的知识仍然是非常有用的。 

InitWndProc完成它的历史命令之后,我们可以把目光关注到FobjectInstance这个指针去,现在它就是新的窗口过程,但是它到底指向了什么东西呢,答案就在前面看到的MakeObjectInstance中,我们要去详细的分解这个函数的代码,不过之前我要从总体上说一下这个过程:

FobjectInstance指向一块由MakeObjectInstance分配好的内存,这块内存存放的是一段机器指令,这段机器指令其实也是在MakeObjectInstance写入的,当FobjectInstance得到调用时,就执行了那段指令,这段指令的任务是将对象方法(这个方法就是传入MakeObjectInstance的那个参数,即MainWndProc)存放在ECX,然后跳转到StdWndProc去,StdWndProcECX取出MainWndProc,并从这个方法中得到对象实例(对象方法其实是一个地址和一个对象实例的组合,详情请看TMethod帮助),然后构造出一个Tmessage的结构,最后调用MainWndProc,流程完毕。

为了让读者有一个总体的认知,我画了下面的流程图:

从上面的分析看,至少有这么几个元素对转换过程起着至关重要的作用:

MakeObjectInstance函数

FObjectInstance以及其指向的内存

StdWndProc函数

现在我们就来详细解析它们。

      TWinControl的构造函数中调用了MakeObjectInstance,并传入TWinControl的一个方法:MainWndProcMakeObjectInstance的代码是这样的:

01 function MakeObjectInstance(Method: TWndMethod): Pointer;
02 const
03   //机器指令
04   BlockCode: array[1..2of Byte = (
05     $59,       { POP ECX }
06     $E9);      { JMP StdWndProc }
07   PageSize = 4096;
08 var
09   Block: PInstanceBlock;
10   Instance: PObjectInstance;
11 Begin
12 //InstFreeList指向一个TObjectInstance记录,这个记录是当前可用的
13   if InstFreeList = nil then
14   begin
15 //如果InstFreeList为空,就再创建4K的内存,这个内存格式化为一个
16 //TinstanceBlock结构。
17     Block := VirtualAlloc(nil, PageSize, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
18 Block^.Next := InstBlockList;
19 //对新创建的4K内存进行初始化
20     Move(BlockCode, Block^.Code, SizeOf(BlockCode));
21 Block^.WndProcPtr := Pointer(CalcJmpOffset(@Block^.Code[2], @StdWndProc));
22 //TinstanceBlock里面含有313TobjectInstance记录,对这些记录进行初始化
23     Instance := @Block^.Instances;
24     repeat
25       Instance^.Code := $E8;  { CALL NEAR PTR Offset }
26       Instance^.Offset := CalcJmpOffset(Instance, @Block^.Code);
27       Instance^.Next := InstFreeList;
28       InstFreeList := Instance;
29       Inc(Longint(Instance), SizeOf(TObjectInstance));
30     until Longint(Instance) - Longint(Block) >= SizeOf(TInstanceBlock);
31     InstBlockList := Block;
32   end;
33   //将可用的TobjectInstance块返回,并让InstFreeList指向下一个可用的块
34   Result := InstFreeList;
35   Instance := InstFreeList;
36   InstFreeList := Instance^.Next;
37   //MainWndProc保存在这里
38   Instance^.Method := Method;
39 end;

这个函数一个非常重要的任务就是管理一个链表,这个链表的每一项有4096字节大小,每一项可以认为是一个TinstanceBlock结构(实际上TinStanceBlock只有4092字节,即最后4个字节是没有用的)。这个链表会随着MakeObjectInstance 的调用而增加链表项,但是不会被释放,到进程结束时由操作系统回收。InstBlockList变量指向这个链表头,可以用下图来表示:

每一个TinstanceBlock的结构是这样的:

PInstanceBlock = ^TInstanceBlock;

TInstanceBlock = packed record

  Next: PInstanceBlock;             //下一个块

  Code: array[1..2] of Byte;       //机器码

  WndProcPtr: Pointer;              //指针,相当于操作数

  Instances: array[0..InstanceCount] of TObjectInstance;//314个记录数组

end;

CodeWndProcPtr一起组成了一段机器指令,请回头看看第2021行,最后CodeWndProcPtr成员一起组成了类似下面这样的指令:

POP ECX

      JMP Offset

上面的Offset是另有用意的,它等于WndProcPtr,而Jmp的结果是跳到StdWndProc的入口点去,为什么能够这样呢,请看第21行:

Block^.WndProcPtr := Pointer(CalcJmpOffset(@Block^.Code[2], @StdWndProc));

CalcJmpOffset函数如下
function CalcJmpOffset(Src, Dest: Pointer): Longint;

begin

  Result := Longint(Dest) - (Longint(Src) + 5);

end;

StdWndProc的地址减去Code[2]的地址与5的和,为什么Code[2]还要加上5,才能被StdWndProc的地址减呢,原因是Code[2]等到$E9,后面跟一个地址(可以是绝对地址也可以是相对地址,这里是使用相对地址)就形成了一条JMP指令,$E9占一个字节,地址是一个指针占了4个字节,所以这条指令占用了5个字节,所以Code[2]要和5加后,被StdWndProc的地址减去后才能得到一个正确的相对地址(其实也就是StdWndProc的地址到JMP指令的距离)。

      接下来的Instances是一个数组,共有314个,数组的每一项是一个TobjectInstance记录:

PObjectInstance = ^TObjectInstance;

TObjectInstance = packed record

  Code: Byte;         //机器码

  Offset: Integer;    //偏移,操作数

  case Integer of

    0: (Next: PObjectInstance);  //可能是指向下一个记录

    1: (Method: TWndMethod);   //也可能存放一个方法类型

end;

CodeOffset也组成了一条机器指令,请看第2526行,这条指令相当于:

CALL NEAR PTR Offset

Offset也是通过CalcJmpOffset计算得到的,它指定当前地址到BlockCode处的偏移,也就是调用ObjectInstance所在的InstanceBlockCode处的代码。另外,请注意这里是使用CALL而不是JMP,这是有特殊的含意的,你不妨可以思考一下,稍后我会作解释。

      接下来是一个变体,有可能是Next指向下一个记录,也有可能是一个TwndMethod的变量。看MakeObjectInstance的代码,在初始化Block块的时候,是将数组中所有项都设成Next的。这样看来,当一个InstanceBlock新生成时,这个Instances数组也可以当成一个链表了,从第28行可以看出,有一个变量InstFreeList ,就指向了这个表头。

      但在34行下面的几句代码,返回了InstFreeList,并将这个记录指针的Next变成了Method,将传进来的参数Method赋给它,最后InistFreeList指向下一个ObjectInstance。这样看来,一个InstanceBlock是否已经用完取决于它里面的Instances数组,如果所有ObjectInstance的最后一个成员是Method,那么表示这个块已经用完了,相反如果是Next则表示还有ObjectInstance可用。

      至此,我们可以确定,MakeObjectInstance返回的值(被FObjectInstance所接收),就是一个TobjectInstance的指针,且里面的Method成员的值等于传进函数中的参数的值(即MainWndProc)。

      好了,让我们对MakeObjectInstance的行为作一些总结吧:

如果InstFreeList变量是空,表示InstanceBlock链表没有创建或者已经没有可用的ObjectInstance项了,这时要创建一个新的InstanceBlock记录,并对它进行初始化,接着让这个新的块作为链表头,即InstBlockList指向它,而它的Next成员则指向原来的表头。最后,块里面第一个ObjectInstanceMethod被赋值,并作为函数结果返回。而InstFreeList则指向下一个ObjectInstance

      如果InstanceBlock链表里面还有可用的ObjectInstance项,过程就相对简单一点,对InstFreeList指向的ObjectInstanceMethod成员赋值,并将它作为函数结果返回。InstFreeList则指向下一个可用的ObjectInstance。这个过程可以用下面的图来分解:

那么是不是说明已用的ObjectInstance再也收不回来了呢,其实不是,那些ObjectInstance都保存在各个窗口类当中,如果调用了FreeObjectInstance,则可以将这些内存回收回来,FreeObjectInstance的代码是这样的:

01 procedure FreeObjectInstance(ObjectInstance: Pointer);
02 begin
03   if ObjectInstance <> nil then
04   begin
05     //将回收的ObjectInstanceNext成员指向InstFreeList指向的内存块
06     PObjectInstance(ObjectInstance)^.Next := InstFreeList;
07     //InstFreeList指向被回收的内存
08     InstFreeList := ObjectInstance;
09   end;
10 end;

      讲完了上面的内存块管理,现在可以将普通函数到对象方法的流程走一遍,其实这一个过程经过上面的讲解之后已经顺理成章,只要照着执行流程走下去就是

      假设窗口接收到一个消息,则窗口类中的FObjectInstance得到调用,实际上就是执行ObjectInstance这块内存里面的指令。

      ObjectInstanceCodeOffset成员组成了这样的指令:

CALL NEAR PTR Offset

我们知道这条指令将使执行点跳到Offset处的代码,通过上面的分析知道Offset处的代码就在这个ObjectInstance记录所在的InstanceBlockCode处。另外一个非常重要的信息是Call指令调用时,会将下一条指令地址压栈,那么这里的下一条指令地址是什么呢?不就正是下面的Method成员的地址吗。所以,我们要紧记堆栈的现场,下图是调用上面的Call指令后的堆栈:

栈是向低的地址增长的,我们假设低地址在上面,而往下则地址渐增,因此图示像上面那样,栈顶以下的第二个值是窗口过程的返回地址,想一下Windows在调用我们的窗口过程的时候也是用Call指令的,所以当然要将Call指令的下一条指令地址压栈,这里所谓的“窗口过程的返回地址”指的就是Windows调用窗口过程的下一条指令地址。只要我们的窗口过程最终在Ret的时候,从栈中弹出的是这个值,那么窗口过程就正确地完成它的任务了。

      回过头来,CALL指令之后,执行点已经到InstanceBlockCode数组处了,这个Code数组和它下面的WndProcPtr一起组成下面的指令:

POP ECX

      JMP Offset

第一行,将栈顶弹出的值存入ECX,这个值当然就是ObjectInstance.Method的地址。第二行执行一个JMPJMP指令的一个好处就是不会对堆栈有任何影响,通过上面分析得知这次是跳到StdWndProc的入口点去了,记住现在的堆栈:

      现在执行点到了非常重要的StdWndProc处,从上面的堆栈现场看,可以认为StdWndProc就是由Windows调用的窗口过程,只是这个时候对象方法的地址正保存在ECX中,看下面的代码:

01 标准窗口过程 }
02 { In    ECX = 方法指针的地址 }
03 { Out   EAX = 返回结果 }
04 function StdWndProc(Window: HWND; Message, WParam: Longint;
05   LParam: Longint): Longint; stdcall; assembler;
06 asm
07       XOR     EAX,EAX
08         PUSH    EAX
09         PUSH    LParam
10         PUSH    WParam
11         PUSH    Message
12         MOV     EDX,ESP
13         MOV     EAX,[ECX].Longint[4]
14         CALL    [ECX].Pointer
15         ADD     ESP,12
16         POP     EAX
17 end;

      7到第11行,实际上它是在堆栈上构造一个Tmessage结构,这个结构正是TwndMethod类型的方法所需要的唯一参数,Tmessage可简化为这样:

TMessage = packed record

    Msg: Cardinal;

    WParam: Longint;

    LParam: Longint;

    Result: Longint);

end;

所以第8行推入的Result,第9行推入的Lparam,以此类推。

      12行将栈顶赋值给EDX,记得Register调用规则吗,EDX正是我们看得到的第一个参数,而这个参数被赋给了一个Tmessage记录的地址(其实就是栈顶)。我们看一下现在的堆栈现场:

      之所以会有“上一个EBP”这一项,是在StdWndProcASM处有一个EBP压栈的指令,尽管这是一个非常有用的技术,但对我们的主题没有任何意义,所以就略去不讲了。

接着看第13行,ECX是方法指针的地址,那么[ECX]就得到方法指针本身了,而[ECX].Longint[4]是方法指针首地址偏移4个字节处,正是方法指针对应的对象实例,为了让读者更明白,我画了下面的图揭示方法指针的内存分布:

      现在EDXTmessage的地址,EAX存对象实例,看看Twndmethod的声明

TWndMethod = procedure(var Message: TMessage) of object;

想想Register的调用规则,我们得出结论,参数传递已经完成,接下来当然是调用对象方法,看第14行,做的就是这个事情。也就是这个时候,当初通过MakeObjectInstance传进来的MainWndProc得到调用了,流程终于走到对象的方法去了。

      MainWndProc如何做我们大可不去理会,现在来看在MainWndProc调用完后的第15行,栈顶加12表示栈顶的前3个值出栈(记住栈是向低处增长的),那么现在的栈顶就是Tmessage结构的Result。再看第16行,将Result弹出给EAX,将这作为函数的返回值。看一下堆栈:

      StdWndProcEnd处,先有一个Pop EBP的动作,才有一个Ret指令,所以最终能够正确的返回窗口过程。整个过程到这里结束。

这真是一个激动人心的时刻,尽管李维的Inside VCL对于这一主题有详尽的描述,但只有自己将整个流程走通,才能真正理解这一个转换的过程。

      我们已经走得很远了,不过我们可以走得更远一些,让我们更进一步,来讨论回调函数到对象方法的转换过程吧。

扩展,将对象方法设为回调函数

      Win32API有一些需要回调函数,说白了就是函数指针,比如钩子,列举窗口等等。如果我们要对这些技术进行面向对象的封装,就要遇到一些难题。拿钩子来说,假设我们要封装一个键盘钩子,设计一个TKeyboard Hook类,并提供一个Active属性,如果Active属性为True,就调用SetWindowsHookEx安装一个键盘钩子,如果ActiveFalse,就调用UnhookWindowsHookEx卸载键盘钩子,一切看起来都很好,但是调用SetWindowHookEx时需要提供一个HOOKPROC类型的回调函数,而我们并不能用一个对象的方法去作为回调函数传进去。如果有一种方法,能将普通的回调函数转换成对象的方法,那将是很棒的事情,其实VCLMakeObjectInstance函数已经为我们开了先河,尽管它只是转换了窗口的回调函数,但对于一般的回调函数,我们同样可以仿照着做。

      上文中提到过在同一种调用规则下,Win32API与对象方法之间的差别,仅有的一点就是多了个Self的隐藏参数。由于MakeObjectInstance只是针对窗口的回调函数,参数是确定的,所以可以多做一些功夫,把StdCall转成Register调用规则。但扩展到所有的回调函数,情况就复杂得多了,你不知道这个回调函数的参数个数,因此没法进行调用规则的转换。既然如此,我们退一步,让对象方法必须也是StdCall调用规则,作这一让步并不需要付出多大的代价,你只需要把这个对象方法作为中转站,在方法里面调用Register版的方法即可,而剩下的事情由编译器帮我们做就行了。

      基本的原理与上文的描述是很相似的,即提供一个内存块,内存块中保留着一段机器指令,这段指令最终能够调用到对象的指定方法。声明一个指向这个内存块的指针,将它作为回调函数传进API中。

      在我即将完成这个有趣的事情而感到兴奋时,我看到网上已经有人实现了这样的转换,那就是大富翁的SaveTime,我在他的2004学习笔记中看到了“让类成员函数成为Windows回调函数的方法”,原来在两年多前就有人完成了这样的事情,看来我的此举是有些多余了,我认真看了Savetime的实现方法,基本的思路是差不多的,不过他写到内存块中的机器指令似乎不是很好,他的指令是这样:

MOV EAX, [ESP];          //栈顶的值存到EAX中,此时栈顶的值即是回调函数返回地址

PUSH EAX;           //EAX入栈,

MOV EAX, ObjectAddr;  

MOV [ESP+4], EAX;       //将对象地址作为对象方法的第一个参数

JMP FunctionAddr;        //跳到对象方法去

      这段指令实现的功能与我原来想的一样,我们知道在调用API时,要先将参数从右到左的入栈,然后调用函数。我们假设Windows调用了回调函数,执行点到了上面的代码,此时栈顶是回调函数的返回地址,下面则是回调函数所需要的参数,那么这段指令就是将回调函数的返回地址下移一个栈值,再将对象指针存到函数返回地址原来的位置,先后两种情况的堆栈是这样的:

      如图2所示,此时已经完成了调用对象方法所需要的一切工作,接下来跳到对象方法的入口点去就行了。

      这段代码的思路是正确的,不过我认为有一点值得考虑,就是EAX,如果之前EAX的值是有用的,那么执行这段指令之后,它的值就被破坏了,最好的情况就是不要使用寄存器,我将指令优化了一下,成了下面这样子:

push  [ESP]

mov   [ESP+4], ObjectAddr

jmp   MethodAddr

      现在只需要三条指令就可以完成了,现实的功能是一样,从机器指令的大小来算,Savetime的需要18字节,而我的指令只需要16字节,所以在空间方面也有所减少。由此看来,我所做的并非无用功呀,呵呵!

      至此已经万事具备,应该将代码列出来了,我写了一个CallbackToMethod的单元,这个单元具有一定的通用性,可以应用到你需要的地方去,请看下面的代码:

01 unit CallBackToMethod;
02 
03 {*******************************************
04  * brief: 回调函数转对象方法的实现
05  * autor: linzhenqun
06  * date:  2006-12-18
07  * email: linzhengqun@163.com
08 ********************************************}
09 {
10 说明:本单元的实现方法是一种比较安全的方式,其中不破坏任何寄存器的值,并且
11       指令的大小只有16字节。
12 使用:下面是推荐的使用方法
13       1. 在类中保存一个指针成员 P: Pointer
14       2. 在类的构造函数中创建指令块:
15          var
16            M: TMethod;
17          begin
18            M.Code := @MyMethod;
19            M.Data := Self;
20            P := MakeInstruction(M);
21          end;
22       3. 调用需要回调函数的API时,直接传进P即可,如:
23          HHK := SetWindowsHookEx(WH_KEYBOARD, P, HInstance, 0);
24       4. 在类的析构函数中释放指令块
25          FreeInstruction(P);
26 注意:作为回调函数的对象方法必须是StdCall调用规则
27 }
28 
29 interface
30 
31 (* 创建回调函数转对象方法的指令块 *)
32 function MakeInstruction(Method: TMethod): Pointer;
33 (* 消毁指令块 *)
34 procedure FreeInstruction(P: Pointer);
35 
36 implementation
37 
38 uses SysUtils;
39 
40 type
41   {
42     指令块中的内容相当于下面的汇编代码:
43     ----------------------------------
44     push  [ESP]
45     mov   [ESP+4], ObjectAddr
46     jmp   MethodAddr
47     ----------------------------------
48   }
49   PInstruction = ^TInstruction;
50   TInstruction = packed record
51     Code1: array [0..6of byte;
52     Self: Pointer;
53     Code2: byte;
54     Method: Pointer;
55   end;
56 
57 function MakeInstruction(Method: TMethod): Pointer;
58 const
59   Code: array[0..15of byte =
60    ($FF,$34,$24,$C7,$44,$24,$04,$00,$00,$00,$00,$E9,$00,$00,$00,$00);
61 var
62   P: PInstruction;
63 begin
64   New(P);
65   Move(Code, P^, SizeOf(Code));
66   P^.Self := Method.Data;
67   P^.Method := Pointer(Longint(Method.Code)-(Longint(P)+SizeOf(Code)));
68   Result := P;
69 end;
70 
71 procedure FreeInstruction(P: Pointer);
72 begin
73   Dispose(P);
74 end;
75 
76 end.

      60行是机器指令,实现的功能就是注释中的汇编,请不要被这些数字吓倒,只要先写好汇编,用CPU窗口一查就知道了,至少我就是这么做的。

      在上文中曾说到封装一个键盘钩子,下面就是一个简单的实现版本:

01 unit HookKeyBoard;
02 
03 interface
04 uses
05   Windows, Messages, Classes, Forms, Controls, CallBackToMethod;
06 
07 type
08   TKeyEventEx = procedure(Sender: TObject; IsDown: Boolean;
09     ShiftState: TShiftState; Key: Word) of object;
10 
11   TKeyBoardHook = class
12   private
13     HHK: HHOOK;
14     P: Pointer;
15     FActive: Boolean;
16     FKeyEvent: TKeyEventEx;
17     procedure SetActive(const Value: Boolean);
18     function KeyboardProc(code: Integer;
19       wParam: WPARAM; lParam: LPARAM): LRESULT; stdcall;
20   protected
21     function DoKeyEvent(IsDown: Boolean; ShiftState: TShiftState;
22       Key: Word): Boolean; virtual;
23   public
24     constructor Create;
25     destructor Destroy; override;
26     property Active: Boolean read FActive write SetActive;
27     property OnKeyEvent: TKeyEventEx read FKeyEvent write FKeyEvent;
28   end;
29 
30 implementation
31 
32 uses SysUtils;
33 
34 { TKeyBoardHook }
35 
36 constructor TKeyBoardHook.Create;
37 var
38   M: TMethod;
39 begin
40   M.Code := @TKeyBoardHook.KeyboardProc;
41   M.Data := Self;
42   P := MakeInstruction(M);
43 end;
44 
45 destructor TKeyBoardHook.Destroy;
46 begin
47   SetActive(False);
48   FreeInstruction(P);
49   inherited;
50 end;
51 
52 function TKeyBoardHook.DoKeyEvent(IsDown: Boolean;
53   ShiftState: TShiftState; Key: Word): Boolean;
54 begin
55   if Assigned(FKeyEvent) then
56     FKeyEvent(Self, IsDown, ShiftState, Key);
57   Result := False;
58 end;
59 
60 function TKeyBoardHook.KeyboardProc(code: Integer; wParam: WPARAM;
61   lParam: LPARAM): LRESULT;
62 var
63   IsKeyDown: Boolean;
64   ShiftState: TShiftState;
65   CharCode: Word;
66 begin
67   if code >= 0 then
68   begin
69     ShiftState := KeyDataToShiftState(lParam);
70     CharCode := LOWORD(wParam);
71     IsKeyDown := lParam and $80000000 = 0;
72     if DoKeyEvent(IsKeyDown, ShiftState, CharCode) then
73     begin
74       Result := 1;
75       Exit;
76     end;
77   end;
78   Result := CallNextHookEx(HHK, code, wParam, lParam);
79 end;
80 
81 procedure TKeyBoardHook.SetActive(const Value: Boolean);
82 begin
83   if FActive <> Value then
84   begin
85     if Value then
86     begin
87       HHK := SetWindowsHookEx(WH_KEYBOARD, P, HInstance, 0);
88       if HHK = 0 then
89         raise Exception.Create('can not install a keyboard hook');
90     end
91     else
92       UnhookWindowsHookEx(HHK);
93     FActive := Value;
94   end;
95 end;
96 
97 end.

      代码中没有作什么注释,那不是我们的重点。可以覆盖DoKeyEvent方法,以实现功能更丰富的键盘钩子类。

      请用CallbackToMethod单元多测试一些例子,如果有什么错误,欢迎指正,这个转换的功劳应该归于Savetime,我只是作了一些优化,谈不上什么创造。

 

      我的文章到此就告一段落了,写这篇文章花了我五个晚上的时间,每天晚上都是半夜才睡觉,早上几乎都是很疲惫地去上班。有时候也问自己,花这么大的力气写这些东西有什么用呢,我想,对于自己,能够用文字表达这些东西,说明自己已经很好地掌握这些知识了;而对于别人,看到这些文字,也许可以少走一些弯路,多得到一些知识。这样看来,于己于人都是大有脾益,何乐而不为呢。

      还是那句话,希望对你有用!

 

相关文章推荐

Delphi的学习笔记七——格式化输出函数(总结的很全面)

第一部分: var s: string; begin //指令类型 type s := Format('最大整数是: %d; 最小整数是: %d',[MaxInt,Low(Inte...
  • kimifdw
  • kimifdw
  • 2012年12月18日 11:12
  • 3908

在Delphi中如何输出后台调试信息?

有些时候,我们需要输出调试信息,但又不在界面上,也不希望弹出窗口中断执行,这时,只要用OutputDebugString就可以了,然后在View|Debug Window|Event Log查看结果即...

分别用普通过程,面向对象过程,和继承方法实现拖拽效果

分别用普通过程,面向对象过程,和继承方法实现拖拽效果

07_Java基础_堆栈、面向对象/过程、封装/构造函数

------- android培训、java培训、.net培训、 IOS培训 期待与您交流! ------- 堆栈 任何一个应用程序,在内存中运行之前必须要先进性空间的划分。 比如我们启动了qq,那么...

【OpenGL基础篇】——使用面向对象方法封装OpenGL函数(二)

今天封装了一个Line类,负责在昨天写的窗口上绘制线条。 OpenGL绘图是通过给glBegin函数设置参数达成的,绘制线条有三个不同的参数: GL_LINES : 绘制连接两个点的线段(绘制的端点位...

Java面向对象基础---名词概念的理解:方法的重载、构造函数、封装性

方法的重载:方法名称相同,但参数的类型和个数不同,通过传递参数的个数及类型不同以完成不同功能的方法调用。 例如:System.out.println();属于方法的重载。 方法的重载一定是根据参数类型...
  • waldmer
  • waldmer
  • 2013年09月02日 11:19
  • 1359

【OpenGL基础篇】——使用面向对象方法封装OpenGL函数(三)——绘制矩形

今天实现的是在Window对象上绘制矩形,并且可以定制矩形的坐标、长宽、边框的大小的颜色、是否填充、以及填充时的颜色。 主要的思想就是先用线条绘制出边框,然后在里面绘制出矩形,再根据设定的是否填充的...
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:从普通函数到对象方法 ------Windows窗口过程的面向对象封装
举报原因:
原因补充:

(最多只允许输入30个字)