Skip to content

About

ArcMap 10.4 的 .NET(CLR) 加载项:一键加载天地图底图、地名检索,并把任意 XYZ 瓦片源接入 ArcMap(v0.9.0)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

ArcMap_TiandituTools

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、自定义图源、启停状态互通。 想只留一版,在「自定义 → 加载项管理器」里卸掉另一版即可。

界面


添加底图

搜索

图源管理

新增自定义图源

设置

帮助

1. 相对 Python 版做的四项优化

1.1 瓦片中转直接跑在 ArcMap 进程内 —— 不需要子进程 / 端口文件 / 半死进程清理

这是 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 才需要的绕法, 照抄只会重新引入它带来的全部故障模式。

1.2 不需要 .lyr 模板中转

Python 版造图层时,得先在 ArcMap 里手工存一个 .lyr 当模板,之后按模板一键 添加。那是因为 esriCarto.WMTSLayer 是 in-proc COM 组件:在独立进程里 new 出来交给 ArcMap,ArcMap 拿到的是跨进程代理,图层会进列表但画布空白。 C# 的按钮回调本来就在 ArcMap 进程的 UI 线程上,直接 new WMTSLayerClass() → IMap.AddLayer 就行,拿到的就是本地 COM 对象。

1.3 界面在 ArcMap 进程内(WPF)

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 顶部有说明)。

1.4 图源清单带 mtime 签名缓存

XyzSourceStore 只在 xyz.json / extra.json / config.json 真的变过时才重新 解析,不再每次枚举都读盘。


2. 目录结构

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\                     中间产物 / 舞台目录 / 输出(可整个删掉,重跑打包即恢复)

3. 构建与安装

3.1 前置条件

需要 用途
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\

3.2 装(使用者)

  1. 关闭 ArcMap;
  2. 双击 TiandituTools-CSharp.esriaddin → Install Add-In;
  3. 启动 ArcMap;
  4. 菜单「自定义 → 工具条」里勾选**「天地图 Tools (C#)」**。只需勾一次, ArcMap 会记在 Normal.mxt 里(本包为什么默认不自动创建工具条,见 §4.4);
  5. 要用天地图图源,先在「设置」里填浏览器端类型的天地图 Key(32 位)。 只用 Esri / 高德 / OSM 这类图源可以不填。

3.3 编(开发者)

# 编译(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)。


4. 已被实测钉死的硬约束(改动前必读)

4.1 Config.xml:language="CLR",class 是相对 namespace 的短类名

<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 被跳过,看上去"这个类不存在"。 这个坑让我把一个好好的加载项误判成"包与程序集不是同一次构建的"。

4.2 <ArcMap> 子元素顺序是 XSD 的 xs:sequence

Commands → Extensions → Toolbars → Menus。

4.3 <Extension> 必须显式写 autoLoad="true"

schema 默认 false(just-in-time)。只写 id/class 时 OnStartup() 永远不会在 启动时被调用。本项目的扩展只做一件事:往 csharp.log 写一行 [addin] 已加载 版本=… 资源=… —— 那是"加载项到底有没有被 ArcMap 加载"的 唯一确定性反馈(原因见 §4.4)。

4.4 工具条 showInitially 的取舍

  • 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。

4.5 绝不用异常消息文本判断错误类别

SocketException.Message 是本地化的。Python 版留下的过滤逻辑写的是 IndexOf("timed out") < 0,在中文系统上那条消息是 「无法从传输连接中读取数据: 由于连接方在一段时间后没有正确答复或连接的主机 没有反应,连接尝试失败。」—— 里面没有 timed out,于是每次 ArcMap 画完一批 瓦片、一排 keep-alive 连接集体空闲超时,日志里就刷一片假的"连接异常" (实测 09:25:15 一秒内 8 条)。正确做法是看 SocketException.SocketErrorCode(数字,不受语言影响),见 TileRelay.IsNormalDisconnect()。

4.6 ESRI 程序集引用:只有两个需要随包

  • 运行期 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 抽)。

4.7 BaseOutputPath / BaseIntermediateOutputPath 只能写在 Directory.Build.props

写在 .csproj 里会报 warning MSB3539:NuGet 在 Microsoft.Common.props 阶段就 用 BaseIntermediateOutputPath 推算了 MSBuildProjectExtensionsPath,之后再改 两者就对不上了。Directory.Build.props 是唯一能在那之前赋值的地方(里面用 MSBuildProjectName 条件把 Dev\RelayCheck 排除在外)。

4.8 天地图那三条与语言无关的约束(与 Python 版逐条一致)

  1. GetTile 不带 tk → HTTP 418(CloudWAF);
  2. 有 tk 但没有 User-Agent → HTTP 403 code=301012「Key权限类型为:浏览器端」; ArcMap 自己发不发 UA 控制不了 → 只能中转代发;
  3. 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)。


5. 日志

文件(都在 %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 时不弹任何对话框(全部改成写日志),自动化测试靠它。


6. 还没做的

  • PointLayer 每次加一个点都新建一个 shapefile,多点检索会留一堆 %APPDATA%\TiandituTools\tmp\pts\tdt_pN.shp;该改成复用单个图层 + 追加要素。
  • 图源管理里的"测试连通性"目前只探一张瓦片(ProbeLon/ProbeLat = 北京), 境外图源在无代理时会给"失败"——这是对的,但提示可以说得更明确。
  • 帮助页的「关于」里那四项优化只写了标题,可以再补实测数字。

7. 授权与致谢

底图清单与格网参数参考 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 申请(类型选浏览器端)。

About

ArcMap 10.4 的 .NET(CLR) 加载项:一键加载天地图底图、地名检索,并把任意 XYZ 瓦片源接入 ArcMap(v0.9.0)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages