Calling an event handler using RTTI

In the comments to my previous post, Barry Kelly has popped up to explain the reasons behind the limitations of the new RTTI interface I listed. One thing he did correct me on was the issue of event handlers, since while I had been thinking they are put into TValue records as anonymous methods, this is in fact not the case — in reality, they are put in as TMethod records, as you would expect.

This discovery made, I wondered whether it is possible to invoke event handlers using RTTI — and, in short, they indeed can, since while they cannot be invoked in a single call directly (TRttiMethodType not having an Invoke method), it isn’t hard to write a simple-ish wrapper routine to do the deed for you. One caveat, though, is that because you call Invoke upon a TRttiMethod instance, calling a handler whose type you don’t know up front requires its implementing method to be exposed by RTTI, which in practical terms means it must be with public or published scope. For event types you do know up front, however, you can avoid TRttiMethod completely and just call the handler directly, negating the need for its implementing method to be public or published.

Well, putting this and a bit of knowledge about the TMethod type all together, I came up with the following implementation:

uses
  Classes, TypInfo, Rtti, Controls;

resourcestring
  SMissingEvent = '%s does not have an event called %s';
  SPropertyNotAnEvent = '%s.%s is not an event';
  SEventHandlerHasInsufficientRTTI = 'Event handler does not ' +
    'have the required RTTI to be dynamically invoked';

function CallEventHandler(Instance: TObject; Event: TRttiProperty;
  const Args: array of TValue): TValue; overload;
var
  HandlerValue: TValue;
  HandlerObj: TObject;
  MethodRecPtr: ^TMethod;
  RttiContext: TRttiContext;
  RttiMethod: TRttiMethod;
begin
  if Event.PropertyType.TypeKind <> tkMethod then
    raise EInvocationError.CreateFmt(SPropertyNotAnEvent, [Instance.ClassName, Event.Name]);
  Result := nil;
  HandlerValue := Event.GetValue(Instance);
  if HandlerValue.IsEmpty then Exit;
  MethodRecPtr := HandlerValue.GetReferenceToRawData;
  { check for event types we know }
  if HandlerValue.TypeInfo = TypeInfo(TNotifyEvent) then
  begin
    TNotifyEvent(MethodRecPtr^)(Args[0].AsObject);
    Exit;
  end;
  if HandlerValue.TypeInfo = TypeInfo(TMouseEvent) then
  begin
    TMouseEvent(MethodRecPtr^)(Args[0].AsObject, TMouseButton(Args[1].AsOrdinal),
      Args[2].AsType<TShiftState>, Args[3].AsInteger, Args[4].AsInteger);
    Exit;
  end;
  if HandlerValue.TypeInfo = TypeInfo(TMouseMoveEvent) then
  begin
    TMouseMoveEvent(MethodRecPtr^)(Args[0].AsObject,
      Args[1].AsType<TShiftState>, Args[2].AsInteger, Args[3].AsInteger);
    Exit;
  end;
  { still here? well, let's go for the generic approach }
  HandlerObj := MethodRecPtr.Data;
  for RttiMethod in RttiContext.GetType(HandlerObj.ClassType).GetMethods do
    if RttiMethod.CodeAddress = MethodRecPtr.Code then
    begin
      Result := RttiMethod.Invoke(HandlerObj, Args);
      Exit;
    end;
  raise EInsufficientRtti.Create(SEventHandlerHasInsufficientRTTI);
end;

function CallEventHandler(Instance: TObject; const EventName: string;
  const Args: array of TValue): TValue; overload;
var
  RttiContext: TRttiContext;
  Prop: TRttiProperty;
begin
  Prop := RttiContext.GetType(Instance.ClassType).GetProperty(EventName);
  if Prop = nil then
    raise EInvocationError.CreateFmt(SMissingEvent, [Instance.ClassName, EventName]);
  Result := CallEventHandler(Instance, Prop, Args);
end;

If you have any other standard event types you wish to handle directly, you can add them after TNotifyEvent is taken care of, following the pattern given — note that for any argument type that doesn’t have a corresponding AsXXX method on TValue, you should use the angle bracket syntax, like I do for TShiftState.

In use, you can then do the following:

  CallEventHandler(MyButton, 'OnClick', [MyButton]);

This calls the OnClick event handler for MyButton, passing MyButton as the Sender parameter.

Now, OnClick, being of the TNotifyEvent type, was called directly by CallEventHandler. To test the generic fallback approach, add the following as the handler to the form’s OnGesture event:

procedure TForm1.FormGesture(Sender: TObject;
  const EventInfo: TGestureEventInfo; var Handled: Boolean);
begin
  ShowMessageFmt('Distance = %d', [EventInfo.Distance]);
end;

To do the actual testing, handle the OnDblClick event of the form as thus:

procedure TForm1.FormDblClick(Sender: TObject);
var
  EventInfo: TGestureEventInfo;
  Handled: Boolean;
begin
  EventInfo.Distance := 999;
  Handled := False;
  CallEventHandler(Self, 'OnGesture', [Self, TValue.From(EventInfo), Handled]);
end;

Note how because EventInfo is a record, you need to use the TValue.From syntax — a minor incovenience for sure, but no more than that.

Try this out by running the app and double-clicking the form, and you should find that it works. What, though, of the var parameter? For, if you change the Handled parameter in the handler, you’ll find that the Handled variable in the caller is not changed. This makes sense if you recognise that TValue records contain copies of, and not pointers to, their source data. If the ‘var-ness’ of a parameter is important to the caller, though, then you will need to construct an array of TValue records manually:

procedure TForm1.FormDblClick(Sender: TObject);
var
  EventInfo: TGestureEventInfo;
  Handled: Boolean;
  Args: TArray<TValue>;
begin
  EventInfo.Distance := 999;
  Handled := False;
  Args := TArray<TValue>.Create(Self, TValue.From(EventInfo), Handled);
  CallEventHandler(Self, 'OnGesture', Args);
  Handled := Args[2].AsBoolean;
  ShowMessage('After being OnGesture has been called, Handled is now ' +
    BoolToStr(Handled, True));
end;

Or, using a static rather than a dynamic array:

procedure TForm1.FormDblClick(Sender: TObject);
var
  EventInfo: TGestureEventInfo;
  Handled: Boolean;
  Args: array[0..2] of TValue;
begin
  EventInfo.Distance := 999;
  Handled := False;
  Args[0] := Self;
  Args[1] := TValue.From(EventInfo);
  Args[2] := Handled;
  CallEventHandler(Self, 'OnGesture', Args);
  Handled := Args[2].AsBoolean;
  ShowMessage('After being OnGesture has been called, Handled is now ' +
    BoolToStr(Handled, True));
end;

To test, we can alter the OnGesture handler to be as thus:

procedure TfrmMain.FormGesture(Sender: TObject;
  const EventInfo: TGestureEventInfo; var Handled: Boolean);
begin
  ShowMessageFmt('Distance = %d; on input, Handled is %s',
    [EventInfo.Distance, BoolToStr(Handled, True)]);
  Handled := not Handled;
end;

Try it out, and you should find it all works as expected.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s