ArcMap 10.4 的 .NET(CLR) 加载项:在 ArcMap 里一键加载在线底图、按地名定位,
并且把任意 {z}/{x}/{y} 瓦片源接进来 —— 最后这一项 ArcMap 原生做不到。
当前版本 0.9.0;加载项名「TiandituTools」,工具条「天地图 Tools (C#)」。
Python 版在另一个仓库:
bunkmr/TiandituTools。 本仓库是它的完整 C# 重写,功能一一对齐:五个按钮(添加底图 / 搜索 / 图源管理 / 设置 / 帮助)、内置 5 组 35 条图源、任意{z}/{x}/{y}模板经 WMTS 翻译加载、 天地图 Key 校验、GCJ-02 警示、搜索结果落点。两版可以同时装、同时用(AddInID 与工具条名都不同),而且共用同一份
%APPDATA%\TiandituTools\config.json—— Key、自定义图源、启停状态互通。 想只留一版,在「自定义 → 加载项管理器」里卸掉另一版即可。
![]() 添加底图 |
![]() 搜索 |
![]() 图源管理 |
![]() 新增自定义图源 |
![]() 设置 |
![]() 帮助 |
这是 C# 版存在的最大理由。Python 有 GIL:ArcMap 同步绘制时,主线程阻塞在
原生 ArcObjects 调用里,不释放 GIL,于是中转的 accept 线程拿不到 CPU ——
表现就是"图层加得进去、一放大就白/卡死"。Python 版只能把中转拆出去做成独立
进程,然后配套一整套麻烦事:端口文件、/ping 探活、pid 活着但不应答判"半死"、
taskkill 重建、绝不回退进程内中转……
C# 的托管线程就是真 OS 线程,TcpListener.AcceptTcpClient 阻塞不影响任何人。
所以这里只有一句 TileRelay.Start()(见 Host/LayerManager.EnsureRelay()),
没有端口文件,没有子进程。实测:ArcMap 里一次绘制 174 张瓦片全部成功,
单张 23~70 ms。
⚠ 不要"为了对照 Python 版"把它改回独立进程。那是有 GIL 才需要的绕法, 照抄只会重新引入它带来的全部故障模式。
Python 版造图层时,得先在 ArcMap 里手工存一个 .lyr 当模板,之后按模板一键
添加。那是因为 esriCarto.WMTSLayer 是 in-proc COM 组件:在独立进程里 new
出来交给 ArcMap,ArcMap 拿到的是跨进程代理,图层会进列表但画布空白。
C# 的按钮回调本来就在 ArcMap 进程的 UI 线程上,直接
new WMTSLayerClass() → IMap.AddLayer 就行,拿到的就是本地 COM 对象。
Python 版不能把 Tkinter 塞进 ArcMap 进程(会让内部数据结构损坏、被 CRT 强制
中止),只能起 pythonw.exe 子进程、靠 JSON 回传结果。C# 版省掉这一整套跨进程
协议:Ui/*.cs 里的窗口直接 ShowModal()(owner = ArcMap 主窗)。
为什么是 WPF 而不是 WinForms —— 实测(Entry/WpfProbe.cs):
| 实测项 | 值 |
|---|---|
GetProcessDpiAwareness |
0 = unaware |
GetSystemMetrics(SM_CXSCREEN) |
1365x853(物理屏是 3072x1920) |
| WPF 窗口 700x520 DIP 的实际物理尺寸 | 1543x1154(×2.25,系统代为放大) |
TransformToDevice / VisualTreeHelper.GetDpi |
都报 1 |
也就是说整个 ArcMap 进程的坐标都是被 Windows 虚拟化过的,225% 缩放完全 由系统合成。WinForms 手写代码在这种环境里同时用了两套单位 —— 字号是 pt(会随 DPI 放大),控件尺寸是像素(不会)—— 实测把「新增图源」窗压成 296x1323、 5 条提示文字全被裁掉。WPF 的布局单位是 DIP,而 DIP 在这里恰好就是系统的虚拟 坐标,布局与窗口尺寸天然自洽;字体也是矢量渲染,1:1 看屏幕像素字形边缘锐利。
⚠ 只写代码构建 UI,不用 XAML:加载项 DLL 由 ArcMap 用 LoadFrom 载入,
Application.ResourceAssembly 为 null,pack://application:,,,/... 解析不了。
⚠ 剪贴板用 System.Windows.Forms.Clipboard:System.Windows.Clipboard 在
ArcMap 进程里报 CLIPBRD_E_CANT_OPEN(0x800401D0),实测。
但沿用同一条纪律:窗口里只记录结果,不动地图;关窗之后再由按钮去加图层。
在对话框回调里同步改地图仍然是让 ArcMap 崩掉的一条路径(Ui/SearchDialog.cs
顶部有说明)。
XyzSourceStore 只在 xyz.json / extra.json / config.json 真的变过时才重新
解析,不再每次枚举都读盘。
TiandituTools.CSharp\
├─ Directory.Build.props 输出/中间目录(必须在这儿设,见 §4.7)
├─ TiandituTools.csproj net48 / x86 / Library
├─ Core\ 纯逻辑,不碰 ArcObjects(可被控制台工程直接编)
│ ├─ Json.cs 自写极简 JSON(不用 JavaScriptSerializer:中文会被转成 \uXXXX)
│ ├─ Env.cs 目录 / 日志 / 版本(反射读 BuildInfo)/ NoUi / TempDir
│ ├─ AppConfig.cs 与 Python config.py 逐字段兼容的 config.json
│ ├─ XyzSource.cs 格网常量 + capabilities 合成 + URL 模板
│ ├─ XyzSourceStore.cs 清单枚举 / 分组 / 连通性探测 / mtime 缓存
│ ├─ Upstream.cs 上游 HTTP(DNS 只解析一次 + IP 直连 + 每线程 keep-alive)
│ ├─ TileRelay.cs 进程内 WMTS 中转(本项目的核心)
│ ├─ TiandituApi.cs 天地图 API(Key 校验 / 地名 / 地理编码 / 逆地理编码)
│ └─ BuildInfo.cs 打包时自动重写(版本 / 日期 / AddInID)
├─ Host\ 只在这里碰 ArcObjects
│ ├─ ArcMapBridge.cs AppROT → IApplication → IMap;造/加图层;缩放;提示框
│ ├─ LayerManager.cs 业务编排:加底图、加图源条目、手工指引、参数串
│ ├─ PointLayer.cs 搜索结果落点(用 ArcObjects 造临时 shapefile)
│ └─ BasemapCatalog.cs 底图目录(四条来源合成)
├─ Ui\ WPF 界面(全部代码构建,无 XAML)
│ ├─ Theme.cs 配色 / 字体 / 按钮模板 / 窗口摆放(DIP)
│ ├─ TdtWindow.cs 窗口基类:owner=ArcMap、Esc 关闭、巡检用 AutoClose
│ └─ Basemap / XyzManager / Settings / Search / Help 五个窗口
├─ Entry\
│ ├─ Buttons.cs 五个按钮类(Config.xml 里 class= 指的就是它们)
│ ├─ TiandituExtension.cs autoLoad 扩展,启动时写一行 "[addin] 已加载"
│ ├─ WpfProbe.cs (开发期)WPF 在 ArcMap 进程内的 DPI/可行性探针
│ └─ UiTour.cs (开发期)依次弹出六个窗口,配合 ScreenTool 截图巡检
├─ Dev\ 开发期工具,**不随包发布**
│ ├─ pack_addin.py 编译 → 组装 → 打包 → 清单自检(可选安装)
│ ├─ smoke_arcmap.py 启动 ArcMap 冒烟:稳定性 + 解包 + 扩展加载
│ ├─ check_buttons.py (ArcGIS Py2)连运行中的 ArcMap 枚举命令条
│ ├─ probe_commandbars.py (ArcGIS Py2)CommandBars 取法探针
│ ├─ ScreenTool\ (C#)全屏/指定窗口截图、裁切放大
│ ├─ smoke_arcmap.py 等 启动 ArcMap / 枚举命令条 / 打包
│ └─ RelayCheck\ 独立控制台工程:中转自检 + 类型侦察 + 清单自检
├─ maps\ 随包资源:图源清单 xyz.json + 省份边界 tianditu_province.json
├─ images\ 随包资源:按钮图标 16/24/32/48px、帮助页眉、公众号二维码
├─ lib\ (**不入库**)编译用 ArcObjects 引用;clone 下来先跑
│ `python tools/refresh_lib.py` 从本机 ArcGIS 的 GAC 抽出
└─ build\ 中间产物 / 舞台目录 / 输出(可整个删掉,重跑打包即恢复)
| 需要 | 用途 |
|---|---|
| Windows | ArcMap 只有 Windows 版 |
| ArcGIS Desktop 10.4(10.x 亦可) | 既是运行宿主,也提供两个不在 GAC 里的加载项基类程序集 |
.NET SDK(能编 net48 即可) |
dotnet build |
| Python 2.7 或 3.x | 只给 Dev\ 下的打包 / 冒烟 / 抽取脚本用 |
lib\ 里的 ArcObjects 引用不在仓库里 —— 它们是 Esri 的版权程序集,公开分发
越界了 EULA(完整理由写在 .gitignore 里)。clone 下来第一件事是本地重建:
python tools/refresh_lib.py --check # 先看本机找不找得齐
python tools/refresh_lib.py # 从 GAC / Desktop\bin 抽到 lib\- 关闭 ArcMap;
- 双击
TiandituTools-CSharp.esriaddin→Install Add-In; - 启动 ArcMap;
- 菜单「自定义 → 工具条」里勾选**「天地图 Tools (C#)」**。只需勾一次,
ArcMap 会记在
Normal.mxt里(本包为什么默认不自动创建工具条,见 §4.4); - 要用天地图图源,先在「设置」里填浏览器端类型的天地图 Key(32 位)。 只用 Esri / 高德 / OSM 这类图源可以不填。
# 编译(0 错误 0 警告)
dotnet build TiandituTools.csproj -c Release
# 打包 + 清单自检 + 装到 well-known 目录
python Dev/pack_addin.py --install
# 只打包(用已有产物)
python Dev/pack_addin.py --no-build
# 中转链路自检(真实联网,34 项左右)
Dev/RelayCheck/bin/Release/net48/RelayCheck.exe
# 加载项冒烟(会清缓存后启动 ArcMap,观测 60 秒再关掉)
python Dev/smoke_arcmap.py 60
python Dev/smoke_arcmap.py 60 --keep # 不关 ArcMap
# 侦察 ArcObjects 互操作类型(排查"某成员到底叫啥")
RelayCheck.exe --types lib "IWMTSLayer"pack_addin.py 的步骤是编译 → 注入版本 → 组装 → 打包 → 清单自检,
最后一步失败就返回非零退出码 —— 不许发出清单和 DLL 对不上的包(原因见 §4.1)。
<AddIn language="CLR" library="TiandituTools.dll" namespace="TiandituTools.Entry">
<Button id="TiandituToolsCSharp_AddBasemap" class="AddBasemapButton" ... />
<!-- ^ 真实类型 = namespace + "." + class -->两处独立实证(都是本机磁盘上真实存在的包):
- Esri 官方随包发行的
Desktop10.4\bin\Addins\ESRI.ArcGIS.MapCenter.esriaddin写namespace="ESRI.ArcGIS.MapCenter"+class="MapCenterButton", 而 DLL 里真实类型正是ESRI.ArcGIS.MapCenter.MapCenterButton; - 本机另一个 C# 加载项写
namespace="ArcMapAgent"+class="AgentExtension", DLL 里就有ArcMapAgent.AgentExtension。
library 是 Install/ 下的文件名;Config.xml 的文件名大小写两种写法
(Config.xml / config.xml)本机都验证过可用。
清单错了 ArcMap 不报错:包照样被接受、Install/ 照样解包到 AssemblyCache,
但那个命令就是不存在、点了没反应、日志里什么都没有。所以
RelayCheck --addin 会在打包流程里强制核对每个 class 能不能解析成
public + 公开无参构造 + 基类链里有对应 ESRI 基类的类型。
⚠ 反射加载项 DLL 时必须把 ESRI 的 dll 放在同一个目录再扫。 单独拷出来
GetTypes(),凡继承 ESRI 基类的类型都会因解析不到ESRI.ArcGIS.Desktop.AddIns.dll而变成null被跳过,看上去"这个类不存在"。 这个坑让我把一个好好的加载项误判成"包与程序集不是同一次构建的"。
Commands → Extensions → Toolbars → Menus。
schema 默认 false(just-in-time)。只写 id/class 时 OnStartup() 永远不会在
启动时被调用。本项目的扩展只做一件事:往 csharp.log 写一行
[addin] 已加载 版本=… 资源=… —— 那是"加载项到底有没有被 ArcMap 加载"的
唯一确定性反馈(原因见 §4.4)。
showInitially="false":经实测,这类工具条根本不注册进ICommandBars, 连CommandBars.Find("TiandituToolsCSharp_toolbar")都查不到(同一个 ArcMap 里另一个showInitially="true"的加载项就能查到)。所以不能用它当"加载成功" 的探针。showInitially="true":CLR 通道实测安全 —— 工具条启动即出现, ArcMap 连续运行 71 秒无异常,并且真的用它添加了「天地图-矢量地图(含注记)」 并成功出图。 (Python 版当年不得不用false:showInitially="true"会让 ArcMap 在启动 20~30 秒后被 MSVCR 强制中止。那是 pythonaddins.pyd 这条通道的问题,CLR 不受影响。)
本包默认 false —— Dev/pack_addin.py 里 SHOW_INITIALLY 只在设了
TIANDITU_SHOW_TOOLBAR=1 时才取 true。也就是发出去的包默认不自动建工具条,
用户按 §3.2 第 4 步手工勾一次即可,代价仅此而已。
想改成启动即出现(CLR 通道实测是安全的,见上一条):
TIANDITU_SHOW_TOOLBAR=1 python Dev/pack_addin.py。
SocketException.Message 是本地化的。Python 版留下的过滤逻辑写的是
IndexOf("timed out") < 0,在中文系统上那条消息是
「无法从传输连接中读取数据: 由于连接方在一段时间后没有正确答复或连接的主机
没有反应,连接尝试失败。」—— 里面没有 timed out,于是每次 ArcMap 画完一批
瓦片、一排 keep-alive 连接集体空闲超时,日志里就刷一片假的"连接异常"
(实测 09:25:15 一秒内 8 条)。正确做法是看
SocketException.SocketErrorCode(数字,不受语言影响),见
TileRelay.IsNormalDisconnect()。
- 运行期 ESRI 强名称程序集(
PublicKeyToken=8fc3cc631e44ad86)由宿主 ArcMap 的 GAC 解析 → csproj 里一律<Private>false</Private>,跟着发一份反而危险; - 例外:
ESRI.ArcGIS.Desktop.AddIns.dll与.Factory.dll不在 GAC, 只在Desktop10.4\bin下,加载项基类就在里面 → 这两个<Private>true</Private>; dotnet build用 Core 版 MSBuild,不搜 GAC,按名字引用一律 MSB3245 → 所以引用走工程内lib\*.dll(tools/refresh_lib.py从 GAC 抽)。
写在 .csproj 里会报 warning MSB3539:NuGet 在 Microsoft.Common.props 阶段就
用 BaseIntermediateOutputPath 推算了 MSBuildProjectExtensionsPath,之后再改
两者就对不上了。Directory.Build.props 是唯一能在那之前赋值的地方(里面用
MSBuildProjectName 条件把 Dev\RelayCheck 排除在外)。
- GetTile 不带
tk→ HTTP 418(CloudWAF); - 有
tk但没有User-Agent→ HTTP 403code=301012「Key权限类型为:浏览器端」; ArcMap 自己发不发 UA 控制不了 → 只能中转代发; - capabilities 必须取
/xxx_w/esri/wmts,不是通用/xxx_w/wmts: 通用版TopLeftCorner写的是20037508 -20037508(X/Y 反的,其实是右下角), ArcMap 反算行列号会全错 → 画布全白。且/esri/wmts只服务 GetCapabilities,瓦片仍要走普通/wmts。
另外 ArcMap 拼 KVP 时不加分隔符(?tk=KEYservice=WMTS、TILECOL=843service=WMTS),
中转用 _normalize_tile_query 修粘连;href 结尾绝不能补 &(会让
IWMTSLayer.Connect E_FAIL)。
文件(都在 %APPDATA%\TiandituTools\) |
内容 |
|---|---|
csharp.log |
加载项运行日志:[addin] [ao] [relay] [caps] [tile] [xyz] [dns] [status],启动时轮转(>1 MB 截半) |
layer_manager.log |
图层操作日志,与 Python 版同一格式(两版对照着看) |
config.json |
与 Python 版共用:Key、Key 列表、子域、图源启停、自定义 XYZ 源、代理 |
TIANDITU_NO_UI=1 时不弹任何对话框(全部改成写日志),自动化测试靠它。
PointLayer每次加一个点都新建一个 shapefile,多点检索会留一堆%APPDATA%\TiandituTools\tmp\pts\tdt_pN.shp;该改成复用单个图层 + 追加要素。- 图源管理里的"测试连通性"目前只探一张瓦片(
ProbeLon/ProbeLat= 北京), 境外图源在无代理时会给"失败"——这是对的,但提示可以说得更明确。 - 帮助页的「关于」里那四项优化只写了标题,可以再补实测数字。
底图清单与格网参数参考 liuxspro 的 qgis-plugin-tianditu(GPLv3), 本插件是它在 ArcMap 上的移植与扩展。原项目以 GPLv3 发布,本仓库同样以 GPLv3 提供,除非另行说明。
仓库里不含 Esri 的程序集:lib\ 由 tools\refresh_lib.py 在本机从已安装的
ArcGIS Desktop 中抽取,只作编译期引用 —— 不进发布包,也不入库。ArcGIS /
ArcObjects 是 Esri 的商标与版权产品,使用本插件需自行取得合法的 ArcGIS Desktop
授权。
天地图瓦片与 API 的使用须遵守天地图的服务条款;Key 请自行到 https://console.tianditu.gov.cn/api/key 申请(类型选浏览器端)。





