ArcMap Automation: Man the Message Pumps!
The PUG is coming up, so it seems an opportune time to serve a little amuse bouche to get people in the mood. We write a desktop application which wants very much to be an ArcMap plugin. It creates layers, maps, and generally talks to people's real live ArcMap sessions. But we don't want to be an ArcMap plugin, because frankly the world's a lot bigger than ArcMap. We do other stuff, too. So? We automate ArcMap.
We tickle it remotely. You want to create IFeatureLayers in a map? You can do it cross-process, thanks to the beauty of DCOM. We do this in C# where it doesn't blow our minds, and it works. It's actually pretty clever.
ESRI's main applications (like ArcMap) expose an IObjectFactory interface, through which you can create remote objects. We abstract that so that our functions can run either on our server (creating local map documents on the fly) or on a desktop (tickling ArcMap remotely. Like so.
public static object Create(Type type, IObjectFactory factory)
{
string progId = GetProgId(type);
return factory == null
? Activator.CreateInstance(Type.GetTypeFromProgID(progId))
: factory.Create(progId);
}
Cool. So you can use this factory to create layers remotely; it looks like the code you'd write in a plugin, but you don't have to be a plugin. For instance
public static IGeoFeatureLayer CreateFeatureLayer(
bool visible,
string layerName,
string layerDescription,
IFeatureClass featureClass,
IFeatureRenderer renderer,
bool legendVisible,
string legendLabel,
string labelExpression,
bool isLabelled,
string displayField,
bool showTips,
IObjectFactory factory)
{
IGeoFeatureLayer layer = (IGeoFeatureLayer)InteropUtil.Create(typeof(FeatureLayer), factory);
layer.Visible = visible;
layer.Name = layerName;
if (layerDescription != null) ((ILayerGeneralProperties)layer).LayerDescription = layerDescription;
layer.Renderer = renderer;
SetLayerLegendProperties(layer, legendVisible, legendLabel);
SetFeatureLayerLabel(layer, labelExpression, isLabelled, factory);
if (displayField != null) layer.DisplayField = displayField;
layer.ShowTips = showTips;
layer.FeatureClass = featureClass;
return layer;
}
Brilliant. You're in, but you're not a prisoner. The exact same approach can be used for automating MS Office. It's built this way on purpose, and it's cool. Well there's a twist. You can write this code, and you can call it, but it usually doesn't run. It takes minutes or hours to complete, if it completes at all.
You scratch your head. You ponder. Then, absolutely randomly, you notice that when you move your mouse repeatedly over the ArcMap window, it finishes faster. Surely this can't be, you say. I'm being fooled by randomness. But this persists.
Finally you break out the DCOM scriptures and note that DCOM messages between processes arrive as windows messages, just like Alt-Tab requests, minimize requests, and yes, mouse movements! Could the DCOM messages your app is sending be stalled upon arrival to ArcMap?
It turns out the answer is yes. ArcMap does not properly receive these DCOM messages without intervention. You have to force them to pump their Windows message pump. Moving the mouse does that; it's just a nice benefit that while responding to the mouse move messages, they also process the DCOM calls you're making to create layers.
Telling users to scribble over the ArcMap process with their mouse while we were talking to it seemed like it'd get a lot of laughs in our training courses, and not the good kind. So what else could we do? We wrote a function which, while the DCOM call is pending, pelts ArcMap's message pump with messages, forcing it to work. It's like screaming at the top of your lungs "PAY ATTENTION PAY ATTENTION PAY ATTENTION TO ME!!!!". Hey, who are we to argue with a prima donna?
Thusly:
/// example: using(new RemoteArcObjectsPump(app.HWND) { ... }
public class RemoteArcObjectsPump : IDisposable
{
/// Constructs and automatically begins pumping. If HWND is zero, no timer is created.
public RemoteArcObjectsPump(int hWnd)
{
if (hWnd == 0) return;
_hWnd = hWnd;
_thread = new Thread(new ThreadStart(Ping));
_thread.ApartmentState = ApartmentState.MTA;
_thread.Name = "Remote ArcObjects Pump";
_thread.IsBackground = true;
_thread.Start();
}
/// Continuously pings target window with null messages to force message pump to process.
/// By waiting for the message to return (i.e. SendMessage instead of PostMessage) we don't
/// overwhelm the target with messags to process. However we do want to send another message
/// just as soon as we hear the old one is processed, hence no sleeping or timers here.
private void Ping()
{
while(true)
{
try { Win32.SendMessage(new IntPtr(_hWnd), Win32.WM_NULL, 1, 0, true); }
catch {}
}
}
#region Dispose
void IDisposable.Dispose() { Dispose(true); }
~RemoteArcObjectsPump() { Dispose(false); }
private void Dispose( bool disposing )
{
if( disposing && _thread != null )
{
_thread.Abort();
_thread = null;
}
}
#endregion
private Thread _thread;
private int _hWnd;
}
And you call it in a using. When the object is disposed, it stops pelting the remote ArcMap session with messages. (Oh, you have to wrap the native Win32 SendMessage function to call it from .NET, but that's a nice exercise for the reader.) Upon reflection, it might be more efficient to give this task to the ThreadPool, or keep a dedicated thread around, but this is already extremely heavy lifting. Process switches galore, never mind ArcMap's own general pokiness meant we didn't stress too much about a few threads here and there.
Well, it wasn't cranberries in lobster foam, but we found it amusing. I hope you did too. Merry PUG, and see you there next week!

