
简介这份ArcGIS二次开发出图工具Demo面向使用AEArcObjects进行GIS桌面端开发的初中级开发者。示例演示了如何在地图布局中动态添加图名、比例尺、指北针与图例并封装了导出JPG/PNG/PDF文件及调用打印输出的完整流程适合需要快速搭建自定义出图模块的工程参考。资源包共49个文件以17个C#源码文件为主并包含6个资源文件、6个resx界面资源定义、可运行的exe程序及配置文件还附有一份PDF报告便于对照代码理解实现思路。压缩包整体约829KB结构紧凑、可直接用Visual Studio打开调试。目前已有5633人学习下载。通过阅读源码与报告可掌握文本元素、比例尺接口、图例管理、活动视图打印等关键对象的用法同时能借鉴作者在交互配置与模块封装上的设计减少二次开发中的踩坑成本。 入行做GIS开发这么多年我接到过的二次开发需求里出图工具绝对是最被低估的一类。初次听上去就是加个图名、放个比例尺、导张图片好像没什么技术含量可真要把一套出图工具从零做出来你才知道里面有多少细节能让人崩溃。这篇文章就聊聊我用ArcObjects在ArcGIS Desktop平台做二次开发实现自动出图工具的完整过程如何向地图布局中添加图名、比例尺、指北针、图例如何导出图片文件如何调用打印以及这些环节里最容易翻车的坑。如果你正在做ArcGIS二次开发尤其是辅助制图和批量出图相关需求这篇文章应该能帮你少走不少弯路。我会把代码骨架、设计思路、踩坑记录都放出来有些细节是我反复试错之后才确认的常规文档里真不一定写这么清楚。1. 图纸自动化这件事为什么值得自己动手做1.1 手工出图的真实痛点在很多单位里ArcMap出图仍然是日常工作的一部分。一个专题项目做下来可能要出几十张甚至上百张成果图地类图、规划图、权属图、分布图……每张图都需要在布局视图里摆好图名、图例、指北针、比例尺再导出一张高清图或者打印归档。第一次手工做没什么重复二十次之后你就会发现绝大多数时间都花在反复拖动元素、调整位置、改图名、核对比例尺上而且每张图的图名、图例位置还很难保证完全一致稍微一不留神某张图的比例尺方向错了或者图例遮住了关键地块质检回来又是一轮返工。我接到的需求正是这样一个典型场景用同一套基础数据给几十个不同区域各出一张专题图图名要随区域变化图例、比例尺、指北针要固定在统一位置最后批量导出JPG和PDF并支持单张直接打印。这种需求如果靠人工操作既不现实也不可靠而写一套出图工具把布局、注记、环绕要素的添加和导出打印全部自动化才能从根本上解决效率问题。1.2 技术路线怎么选ArcPy 还是 ArcObjectsArcGIS二次开发里出图相关的技术路线大体分两类一类是ArcPy走的是arcpy.mapping / arcpy.mp 脚本另一类是ArcObjects走的是Desktop或Engine组件库。很多人一上来就问我该学哪个我的选择标准很直接如果只是“批量导出已有mxd”把现有地图文档里的元素统一替换文字、更新图层、导出图片那ArcPy完全够用脚本短、逻辑简单、部署也方便但如果要做“从零组装一张图”要在代码里创建地图框、动态生成图例项、精确控制每个制图元素在页面上的坐标位置还要和ArcMap界面交互、响应打印操作ArcObjects明显更合适。我这次选择的是ArcObjects C#做成一个ArcMap Add-In工具栏按钮。因为需求里有一个很重要的环节操作人员先打开一个底图mxd点按钮后工具自动读取当前环境组装出图元素、预览布局再导出或打印。这一切需要和ArcMap的文档对象、布局对象直接打交道ArcPy虽然也能做但在界面交互和精细控制上会绕很多弯。当然ArcObjects的学习曲线比ArcPy陡但一旦把几个核心接口摸清楚后续扩展会非常灵活。2. 开发环境与项目骨架AoInitializer 和几个引用坑2.1 工程创建与程序集引用开发环境建议用Visual Studio目标框架选.NET Framework 4.x不要选.NET Core更不要选.NET 5以上。原因很简单ArcGIS Desktop 10.x自带的ArcObjects运行时只支持.NET Framework选错了版本编译能过但一运行就各种程序集加载失败。创建项目时可以直接选“ArcGIS Add-In”模板如果没有模板也可以手动建一个类库项目然后让类继承ESRI.ArcGIS.Desktop.AddIns.Button。Add-In方式的好处是不用做COM注册部署时把生成的.esriAddIn文件拷到目标机器双击安装就行这对研发环境不统一的团队非常重要。我早期图省事用过经典DLL注册的方式结果每台机器都要处理注册表维护成本高不少。程序集引用是整个开发环境里最容易被忽略的坑。ArcObjects的程序集很多但做桌面出图工具最核心的就这么几个ESRI.ArcGIS.ArcMapUI拿IMxDocument、ArcMap.Document接口。ESRI.ArcGIS.Carto地图、图层、地图框、MapSurround、图例、比例尺这些都在这里。ESRI.ArcGIS.Display符号、颜色、文本符号、元素样式。ESRI.ArcGIS.GeometryEnvelope、Point等几何类型元素位置离不开它们。ESRI.ArcGIS.Output导出图片、PDF的IExport接口。ESRI.ArcGIS.Framework框架相关比如IApplication。ESRI.ArcGIS.SystemUIICommand、ITool等UI接口。引用的时候要特别注意同名的程序集在ArcGIS里可能同时存在Desktop和Engine版本别引错。桌面开发一般引Desktop版本如果你电脑上装了ArcGIS EngineVisual Studio的引用列表里会出现两套类似名称的DLL选错之后部署到只有Desktop的环境会直接崩溃。2.2 License 初始化的隐藏规则ArcObjects所有功能都建立在License初始化成功的基础上。很多新手第一次跑代码就卡在“You are not licensed for ArcGIS for Desktop Advanced”这类错误上其实不是破解问题而是你初始化的License等级和安装的许可不匹配。出图工具用到的完整制图功能建议初始化ArcInfo也叫esriLicenseProductCodeArcInfo或Advanced许可。只初始化Viewer或Engine的Geodatabase许可后面调用MapFrame.CreateSurroundFrame、ExportPDF的这些接口时会静默失败或抛异常。稳妥的做法是在入口处这样初始化ESRI.ArcGIS.RuntimeManager.Bind(ESRI.ArcGIS.ProductCode.Desktop); AoInitialize aoInit new AoInitializeClass(); ESRI.ArcGIS.esriSystem.esriLicenseProductCode productCode ESRI.ArcGIS.esriSystem.esriLicenseProductCode.esriLicenseProductCodeArcInfo; bool licenseStatus aoInit.IsProductCodeAvailable(productCode) ESRI.ArcGIS.esriSystem.esriLicenseStatus.esriLicenseAvailable; if (licenseStatus) { aoInit.Initialize(productCode); }注意Initialize方法不是一定返回成功但即使返回AlreadyInitialized只要你的产品码可用一般也能继续。实际开发中我会把初始化封装成一个静态方法在工具类第一次使用前调用避免每个按钮都重复写。还有一点ArcGIS 10.2以后初始化线程必须和之后调用ArcObjects的线程一致如果你开了多线程批量出图每个线程都要各自初始化一遍这点后面还会说到。3. 制图元素注入图名、比例尺、指北针、图例的编码顺序与坐标计算3.1 先搞清楚你在往哪里画地图框、布局与页面坐标ArcObjects出图最核心的概念是“布局视图”。数据视图里看到的是地图本身布局视图里看到的是一张“纸”纸上放地图框、图名、图例、比例尺、指北针等元素。必须明确图名这种独立文本是放在PageLayout上的而图例、比例尺、指北针这类MapSurround也就是地图环绕元素是放在地图框内部的。所以动手前先拿到两个关键对象IMxDocument mxDoc ArcMap.Document; IMap focusMap mxDoc.FocusMap; IActiveView layoutView mxDoc.PageLayout as IActiveView; IGraphicsContainer layoutGraphics mxDoc.PageLayout as IGraphicsContainer; IMapFrame mapFrame layoutGraphics.FindFrame(focusMap) as IMapFrame;布局视图才是页面坐标的基准。页面单位由PageLayout的PageSettings确定常见的是毫米和英寸两种。我习惯在工具启动时把页面单位统一set成毫米IPageLayout pageLayout mxDoc.PageLayout as IPageLayout; IPageSettings pageSettings pageLayout.PageSettings; pageSettings.Units ESRI.ArcGIS.Carto.esriPageUnits.esriPageUnitsMillimeters;这一步看起来很基础但非常关键。很多元素位置跑偏的问题最后查下来都是因为页面单位是默认的英寸而代码里却按毫米算坐标结果元素跑到了纸外面。3.2 文本元素与比例尺指北针两种不同的添加方式添加图名相对简单用TextElement几何类型是一个Point。位置一般放在页面顶部中间可以通过页面宽度的中点计算。double pageWidth pageSettings.PageSize.X; double pageHeight pageSettings.PageSize.Y; ITextElement textElement new TextElementClass(); textElement.Text 地块权属专题图; ITextSymbol textSymbol new TextSymbolClass(); textSymbol.Size 20; textSymbol.Color new RgbColorClass() { Red 0, Green 0, Blue 0 }; textSymbol.Font new stdole.StdFont() { Name 微软雅黑, Bold true }; ((ITextElement)textElement).Symbol textSymbol; IPoint anchorPoint new PointClass(); anchorPoint.X pageWidth / 2; anchorPoint.Y pageHeight - 15; IElement element (IElement)textElement; element.Geometry anchorPoint; layoutGraphics.AddElement(element, 0);注意TextElement的Geometry必须是IPoint不是Envelope。如果你给它赋一个Envelope元素位置会变得非常诡异要么偏到角落要么尺寸异常。这是我第一次实现时踩过的坑排查了很久才意识到是Geometry类型的问题。比例尺和指北针就不一样了它们必须通过MapFrame的CreateSurroundFrame方法创建。直接new一个ScaleBar然后往GraphicsContainer里AddElement是看不到效果的因为ArcObjects要求这些MapSurround必须挂在某个Frame上。IMapSurroundFrame scaleBarFrame mapFrame.CreateSurroundFrame( typeof(IScaleBar), null) as IMapSurroundFrame; IScaleBar scaleBar scaleBarFrame.MapSurround as IScaleBar; scaleBar.Map focusMap; scaleBar.UnitLabelText 米; scaleBar.UnitLabelPosition esriScaleBarUnitLabelPosition.esriScaleBarUnitLabelPositionAfterLabel; scaleBar.LabelFormat new NumericFormatClass() { NumberOfDigits 0, UseGroupSeparator true }; IElement scaleBarElement (IElement)scaleBarFrame; IEnvelope scaleBarEnv new EnvelopeClass(); scaleBarEnv.PutCoords(30, 25, 110, 45); scaleBarElement.Geometry scaleBarEnv; ((IGraphicsContainer)mapFrame).AddElement(scaleBarElement, 0);这里有一个很关键的认知比例尺Frame要添加到MapFrame自己的GraphicsContainer里而不是PageLayout的GraphicsContainer。同理指北针也是这种路数只是接口换成INorthArrow并且要给指北针设置具体样式INorthArrow northArrow new NorthArrowClass(); northArrow.Map focusMap; INorthArrowStyle northArrowStyle new NorthArrowStyleClass(); northArrowStyle.Style ESRI North 1; northArrow.Style northArrowStyle; IMapSurroundFrame northArrowFrame mapFrame.CreateSurroundFrame( typeof(INorthArrow), null) as IMapSurroundFrame; northArrowFrame.MapSurround northArrow; IElement arrowElement (IElement)northArrowFrame; arrowElement.Geometry ...; // 右上角坐标 ((IGraphicsContainer)mapFrame).AddElement(arrowElement, 0);指北针的样式字符串必须在当前可用的Style文件中存在。默认的ESRI.ServerStyle里一般有“ESRI North 1”到“ESRI North 9”这几套如果你用自定义Style文件需要先用IStyleGallery加载否则运行时会报找不到样式。很多项目里指北针显示为空白或者一个红叉基本都是样式文件没加载。3.3 图例组装最容易刷不出来的一环图例是四个制图元素里最复杂的因为它不是单个对象而是“图例框架 多个图例项 图层关联”的组合。核心流程是先拿到图例清空默认项遍历地图图层逐个生成LegendItem最后刷新。IMapSurroundFrame legendFrame mapFrame.CreateSurroundFrame( typeof(ILegend), null) as IMapSurroundFrame; ILegend legend legendFrame.MapSurround as ILegend; legend.Map focusMap; legend.ClearItems(); IEnumLayer layers focusMap.Layers; ILayer layer null; while ((layer layers.Next()) ! null) { if (layer.Visible false) continue; ILegendItem legendItem new LegendItemClass(); legendItem.Layer layer; legendItem.AddDefaultPatch(); legend.LegendItems.Add(legendItem); } legend.Update(); IElement legendElement (IElement)legendFrame; IEnvelope legendEnv new EnvelopeClass(); legendEnv.PutCoords(15, 20, 95, 130); legendElement.Geometry legendEnv; ((IGraphicsContainer)mapFrame).AddElement(legendElement, 0);图例不出来或者显示空白九成原因是忘掉了legend.Update()。这个方法和ArcMap界面里右键图例选择“刷新”是同一个动作如果不调用图例会保持刚创建时的空状态。另一个容易忽略的点是图层分组。如果图层是GroupLayer直接遍历只拿到一个组图层对象图例里不会展开子图层。要在递归层面处理遇到IGroupLayer就深入遍历。另外有的图层没有做符号化比如临时图层只有简单渲染器图例默认只显示一个色块这时候要调整LegendItem的Behavior设置为esriLegendBehaviorOnlyShowVisibleClasses让图例只显示当前地图上实际可见的类别。图例的位置和大小也很有讲究一般放在页面左下角。如果你发现图例里的文字堆叠、互相遮挡多半是LegendFrame的Envelope给的太窄。图例项会按照当前样式自动换行但不会主动缩小字号所以宁可把图例框设得大一点导出前再微调。4. 导出与打印DPI、页面尺寸、打印机纸张这些细节决定成败4.1 导出图片和PDF的参数设置制图元素都放好之后导出就是顺理成章的事了。ArcObjects里导出图片通过IExport接口实现最常用的是ExportPictureClass支持JPG、PNG、TIFF等格式。关键参数是Resolution也就是输出DPI一般出成果图至少300DPI如果只是预览用150DPI就够了。IExport export new ExportPictureClass(); export.ExportFileName D:\output\地块专题图.png; export.ExportFilter PNG; export.Resolution 300; IActiveView activeView mxDoc.PageLayout as IActiveView; ITrackCancel trackCancel new CancelTrackerClass(); int hdc 0; int imageWidth 0; int imageHeight 0; bool outputToFile true; activeView.Output(export, trackCancel, ref hdc, ref imageWidth, ref imageHeight, ref outputToFile); export.Cleanup();这里我踩过一个大坑activeView.Output的imageWidth和imageHeight参数如果你传入0它会根据当前视图的ExportFrame大小和Resolution自动计算像素宽高如果你自己手动算一定要用ExportFrame的宽度乘以分辨率再除以页面单位换算系数。我试过想当然地传页面宽高结果是导出的图不是被裁掉就是留大片白边。导出PDF类似用ExportPDFClass但有两个比图片更值得关注的属性一个是EmbedFonts设成true可以避免PDF在别的机器上打开时字体缺失另一个是ImageCompression控制嵌入图片的压缩方式默认质量可能不够高做印刷级别的时候记得调成高质量的压缩算法。4.2 直接打印与页面缩放策略打印的实现相对简单但“页面匹配”这个环节坑最多。ArcMap的布局页面大小和打印机实际纸张往往不一致如果直接Print可能出现打印结果被拉伸、比例尺失真、甚至只打印出页面的一部分。我的做法是先拿到IDocumentPrint再设置PageToPrinterMapping为按比例缩放IDocumentPrint documentPrint mxDoc as IDocumentPrint; IPrinter printer documentPrint.Printer; printer.PaperSize new PaperSizeClass() { Width pageWidth, Height pageHeight }; documentPrint.PageToPrinterMapping esriPageToPrinterMapping.esriPageToPrinterMappingScaleToFit; ITrackCancel printTrackCancel new CancelTrackerClass(); documentPrint.Print(1, ref printTrackCancel);设置打印机PaperSize为页面的宽高再把PageToPrinterMapping设成ScaleToFit这样ArcMap会根据打印机实际可打印区域自动缩放保证输出内容完整且比例基本一致。注意这里有个细节打印机的PaperSize和页面尺寸的单位可能不一致最好统一按毫米设置。如果你需要弹打印预览和打印对话框建议用Windows自带的PrintDialog控件把选中的打印机实例赋值给IDocumentPrint.Printer再调用Print。这样用户可以选择不同打印机、纸张方向、份数而不需要你在代码里写死打印参数。5. 排查路线从“导出的图是黑的”到“图例不见了”5.1 导出黑图、白边和元素丢失的排查顺序导出环节最吓人的问题是导出的PNG打开来全黑。我第一次遇到时第一反应是代码写错了后来排查才发现问题出在导出前没有刷新视图。ArcObjects的Output方法导出的本质是“当前视图的渲染结果”如果界面上元素已经更新但内部渲染缓存没有刷新就会导出黑图或空白图。解决办法很简单导出前先调用activeView.Refresh();如果刷新后还是黑再看是不是直接把ActiveView传成了数据视图。ArcMap的IActiveView可以指向地图也可以指向布局。出图一定要用PageLayout对应的ActiveView如果误用了FocusMap对应的ActiveView导出的是数据视图里的地图矩形很可能只有部分区域、没有制图元素看起来就像“导出失败了”。元素丢失的问题还有一个隐蔽原因是Z轴顺序。AddElement的第二个参数表示索引0代表添加到最上面随着添加次数增多后面的元素可能盖住前面的。如果你把图名放到了最底层地图框、图例这些大面积元素可能把它完全遮住。我的经验是先放图名这种顶层文字再放地图框最后放图例、比例尺、指北针必要时通过IGraphicsContainer.UpdateElement调整顺序。5.2 比例尺数值不对与图例不刷新的根因比例尺显示出来的数字和期望值对不上是出图工具里很经典的“隐性错误”。看起来一切正常1:1000的比例尺也设置了但标注文字变成1:10000或者刻度间距完全不对。根因通常是地图单位和比例尺单位不一致。比如数据是经纬度地图单位是度但比例尺的单位标签写的是米此时ArcObjects按度数去做比例分割结果自然离谱。处理方式是先确认地图单位再做换算。如果地图单位是度建议把数据框的坐标系设置成投影坐标系再出图或者在创建比例尺时明确指定单位换算关系。比例尺的接口本身有MapUnitToPageUnitRatio之类的属性但更简单可靠的做法是直接设置mapFrame.Map.MapScaleArcObjects会让你明确指定1:多少例如focusMap.MapScale 1000; // 1:1000 scaleBar.MapScale focusMap.MapScale;图例不刷新的根因前面已经提到过一次缺legend.Update()。但还有一种情况是Update也调了图例还是旧状态——这是因为你在添加图例项之后又改了图层的符号化状态。比如图例项已经根据旧的渲染器生成了快照你随后关闭了一个图层的可见性或者替换了渲染器没有再次触发图例刷新。我的处理方式是任何图层状态变化之后统一调用一个RefreshLegend方法强制图例重新读取所有关联图层的当前状态。private static void RefreshLegend(ILegend legend) { legend.Update(); legend.Refresh(); }6. 让工具真正落地配置化、批量处理与一点经验6.1 用XML配置模板替代硬编码当出图工具的按钮能跑通之后接下来要解决的是“复用”。不同项目的图名位置、图例大小、比例尺长度可能不一样如果每次都在代码里改坐标再重新编译工具就只服务了当前这一个项目换个业务场景又得重新开发。我最后的做法是把所有可调参数抽到一个XML配置文件里包括页面尺寸、页面单位、图名内容与字体字号、图例位置与尺寸、比例尺位置与样式、指北针位置与样式、导出DPI和格式、是否需要打印预览等。工具启动时先读配置读完再执行布局组装。这样换个专题项目只需要改配置文件不需要动程序。实际操作中这套配置化设计帮我省了大量时间后来甚至有同事直接按配置模板自己调整出图布局不需要我再改代码。6.2 批量出图与COM资源释放批量出图是另一个绕不开的话题。一个工具如果只能处理当前打开的mxd用处还是有限最好能遍历一个文件夹里的多个mxd逐个打开、组装、导出、关闭。ArcObjects里同时只能有一个MxDocument实例被打开所以批量处理必须串行不能用多线程并行处理多个mxd。但是串行也有讲究关键是COM对象的释放。ArcObjects是基于COM的.NET下即使局部变量出了作用域COM引用也不会立即释放长时间循环会导致内存暴涨。我写批量逻辑时会定期调用Marshal.ReleaseComObject或者直接用System.Runtime.InteropServices.Marshal.FinalReleaseComObject把用过的map、layout、graphicsContainer显式释放。ReleaseComObject不能随便对同一个对象调两次否则会抛异常所以工具类里我习惯统一封装一个ReleaseObject方法把引用置空的操作集中处理。private static void ReleaseObject(object obj) { if (obj null) return; try { while (System.Runtime.InteropServices.Marshal.ReleaseComObject(obj) 0) { } } catch { } finally { obj null; } }批量出图的稳定性和单张出图完全是两个量级。单张出图即使有资源泄漏跑一次就结束了看不出来问题批量跑20张以上COM引用累积到一定程度ArcMap会直接卡死甚至崩溃。这个坑我是真实经历过的刚开始批量导出36张图跑到第15张左右程序崩溃后来加了释放逻辑才稳定跑完。最后再分享一条我的个人体会出图工具最难的部分从来不是某一个API不会调而是你对“一张合格图纸”有没有完整的理解。图名放哪里、图例多大、比例尺用哪种样式、指北针方向是否符合规范、导出DPI够不够印刷这些看似“业务”的问题最后都会变成代码里的坐标计算和参数设置。如果你只是照着接口文档把元素加进去出来的图大概率是“能看但没法交付”的。建议动手前先拿几张人工出好的成果图把每个元素的位置、大小、字体、间距量一遍再设计你的配置模板和坐标计算逻辑这样的出图工具才算真正能用。本文还有配套的精品资源点击获取