这是我的一篇 BMG (公共汽车线路图生成器,Bus Map Generator) 的开发笔记,记录了开发过程中认为值得记录的知识点。组织结构如下:

  • 前端部分
    • 使用了 Fluent.RibbonMainWindow.xaml 的组织方式
  • C# 部分
    • JSON 数据在 C# 的加载和保存
    • Svg.SkiaSkiaSharp 的使用
  • Python 工具部分
    • Python 工具的组织
    • Python 工具转可执行程序
  • 杂项部分
    • 资源的复制到输出目录

前端部分

使用了 Fluent.RibbonMainWindow.xaml 的组织方式

最外层容器当然是窗体对象。标签内定义的属性通常包括:

  • 一般属性:如 TitleWidthHeight 这些传入数字或者直接表示属性的字符串的;
  • 命名空间前缀声明:如 xmlnsxdmc 这些传入一个 URI 的;
  • 类型声明:如 x:Class 这些在字符串字面量里面传入类名的。

: 冒号是访问命名空间的符号。

一些命名空间前缀(可以是作为库、包)的主要用途(并不是因为它这个前缀名字而有用途,是因为传入了 URI 给它):

  • xmlns:默认命名空间
  • x: XAML 语言扩展
  • d: 设计器扩展
  • mc: 标记兼容性

下面是 BMG 的窗体标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Fluent:RibbonWindow x:Class="BusMapGenerator.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:Fluent="urn:fluent-ribbon"
xmlns:AvalonDock="https://github.com/Dirkster99/AvalonDock"
xmlns:skia="clr-namespace:SkiaSharp.Views.WPF;assembly=SkiaSharp.Views.WPF"
mc:Ignorable="d"
Title="BMG - 公共汽车线路图生成器"
Width="800"
Height="600"
xmlns:ui="http://schemas.modernwpf.com/2019"
ui:WindowHelper.UseModernWindowStyle="False">
...
</Fluent:RibbonWindow>

可见,窗体 <Fluent:RibbonWindow> 在 C# 的类完整名称是 BusMapGenerator.MainWindow,继承自 Fluent:RibbonWindow。它除了引入了最基础的命名空间,还引入了 AvalonDockskiaui 这些来自 NuGet 包的。它也定义了 TitleWidthHeight 等一般属性。

XAML 实际上是对前端后台 C# 的补充。就比如对窗体类型的声明:x:Class="BusMapGenerator.MainWindow"。本来在前端后台只用写:

1
2
3
4
5
6
7
8
namespace BusMapGenerator
{
public partial class MainWindow
{
/// 内容
}
/// 其他内容
}

前端和后台会被编译器合成:

1
2
3
4
5
6
7
8
namespace BusMapGenerator
{
public partial class MainWindow : RibbonWindow
{
/// 内容
}
/// 其他内容
}

C# 部分

资源相对于可执行文件路径的调用

如果使用一个字符串直接表示相对于可执行文件的路径,可能会导致如果工作目录不为可执行文件所在目录时,程序无法找到资源。

System 命名空间提供了 AppDomain.CurrentDomain.BaseDirectory 属性,可以获取可执行文件所在目录。

因此,如果要安全标识资源相对于可执行文件的路径,就要像这样写:

1
string yourResourcePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "<相对于可执行文件的资源路径>");

JSON 数据在 C# 的加载和保存

这里说的数据是指以 [{对象1}, {对象2},...] 且这些对象共用字段(字段包含数字类型的 id)的 JSON 数据。

在 C# 中,可以用 NuGet 包 Newtonsoft.Json 来加载 JSON 数据。

比如我这有个 nodes.json

1
2
3
4
5
6
7
8
[
{"id": 1, "name": "412总站", "coord": [0, 0]},
{"id": 2, "name": "融通路华翠北路口", "coord": [-70, 0]},
{"id": 3, "name": "海八路华翠北路口", "coord": [-70, -20]},
{"id": 4, "name": "龙溪大道东漖北路口", "coord": [200, -20]},
{"id": 5, "name": "浣南东街南", "coord": [210, -10]},
{"id": 6, "name": "浣南东街北", "coord": [210, 15]}
]

在 C# 中,我需要创建三个类:NodeDataLoaderDataSaver

Node 类

Node 类显然表示这个数据各对象共属的类名,我们只要再定义如何把 JSON 字段映射至 C# 类属性即可:在 C# 每个字段前面都加个 [JsonProperty("JSON 字段名")] 特性声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Node.cs
using Newtonsoft.Json;

namespace BusMapGenerator
{
public class Node
{
[JsonProperty("id")]
public int Id { get; set; }

[JsonProperty("name")]
public string? Name { get; set; }

private decimal[] _coord = new decimal[2]; // 用来保护 Coord

[JsonProperty("coord")]
public decimal[] Coord
{
get => _coord;
set
{
if (value == null || value.Length != 2)
_coord = new decimal[2];
else
_coord = value;
}
}
}
}

DataLoader 类

DataLoader 类用来储存各个加载 JSON 数据的静态方法。为方便在 C# 调用数据,这里用 Dictionary<int, Node> 来储存道路节点数据。

nodes.json 加载到 Dictionary<int, Node> 字典过程是:

  1. 先用 File.ReadAllText 方法把 JSON 文本转为字符串;
  2. 再用 JSONConvert.DeserializeObject<List<Node>> 方法把 JSON 字符串转为 List<Node> 列表;
  3. 最后用列表的 ToDictionary 方法把 List<Node> 列表转为 Dictionary<int, Node> 字典,其中 Id 的值作为元素的键。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// DataLoader.cs
using System.IO;
using Newtonsoft.Json;

namespace BusMapGenerator
{
internal class DataLoader // 输入 <地图名称> ,输出 <数据字典>
{
public static Dictionary<int, Node> LoadNodes(string mapName)
{
string json = File.ReadAllText(Path.Combine("data", mapName, "nodes.json"));
List<Node> nodesList = JsonConvert.DeserializeObject<List<Node>>(json) ?? [];
Dictionary<int, Node> nodesDict = nodesList.ToDictionary(node => node.Id, node => node);
return nodesDict;
}
}
}

DataSaver 类

DataSaver 类用来储存各个保存 JSON 数据的静态方法。为方便在 C# 调用数据,这里用 List<Node> 来储存道路节点数据。

Dictionary<int, Node> 字典保存为 nodes.json 过程是:

  1. 先用 List<Node> 列表把 Dictionary<int, Node> 字典的元素转为 List<Node> 列表;
  2. 再用 JSONConvert.SerializeObject 方法把 List<Node> 列表转为 JSON 字符串;
  3. 最后用 File.WriteAllText 方法把 JSON 字符串写入 nodes.json 文件。

(相当于反向操作 DataLoader 类中相应的方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// DataSaver.cs
using System.IO;
using Newtonsoft.Json;

namespace BusMapGenerator
{
class DataSaver // 输入 ( <数据字典> , <地图名称> ) ,保存数据到文件
{
public static void SaveNodes(Dictionary<int, Node> nodesDict, string mapName)
{
var nodesList = nodesDict.Values.ToList();
string json = JsonConvert.SerializeObject(nodesList, Formatting.Indented);
File.WriteAllText(Path.Combine("data", mapName, "nodes.json"), json);
}
}
}

非主程序的窗体的弹出

除了主程序窗体外,有时候我们会在项目弄一些辅助窗体,在点击某个按钮之后弹出。比如在 BMG 中,我点击“打开地图”按钮,就会弹出一个窗体让我选择地图。

首先,在 Visual Studio 的解决方案资源管理器中,给项目新建一个 WPF 窗体。这里我新建的是 SelectMapWindow.xaml。它同时配套一个后台文件 SelectMapWindow.xaml.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SelectMapWindow.xaml.cs
using System.IO;
using System.Windows;
using System.Windows.Controls;

namespace BusMapGenerator
{
public partial class SelectMapWindow : Window
{
public SelectMapWindow()
{
InitializeComponent();
}
}
}

新建窗体后可以发现,SelectMapWindow.xaml.cs 里面定义了一个继承自 Window 的类 SelectMapWindow。其构造函数中,调用了一个 InitializeComponent 方法,它表示弹出此窗体。这说明,我们点击按钮弹出这个窗体的方法中,创建一个 SelectMapWindow 类的实例,就能弹出这个窗体。

1
2
3
4
5
6
7
8
9
// class MainWindow
private void OpenMap(object sender, RoutedEventArgs e)
{
var selectWindow = new SelectMapWindow // 创建一个窗口的实例
{
Owner = Application.Current.MainWindow // 这个实例的拥有者为主窗体
};
// 后续内容
}

创建的 SelectMapWindow 的对象初始化器,要通过 Owner 属性指定它的拥有者。这里它属于主窗体。

窗体弹出后,我们希望阻塞当前线程,直到那个窗体执行了 Close 方法。我们可以用 ShowDialog 方法:

1
2
3
4
5
6
// 在刚才的 OpenMap 方法体继续添加
bool? result = selectWindow.ShowDialog(); // 阻塞线程,ShowDialog() 会被赋值为 selectWindow 的 DialogResult 属性
if (result == true)
{
// 当 selectWindow 的 DialogResult 属性为 true 时执行的内容
}

用伪代码模拟 ShowDialog 方法的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 伪代码
class Window
{
public bool? DialogResult { get; set; };
private bool _isClosed = false;
public bool IsClosed => _isClosed;
private void wait() {};
public void Close() { _isClosed = true; }

public bool? ShowDialog()
{
while (!this.IsClosed) // 阻塞(实际上 ShowDialog() 没有这个循环结构,这里只是做个类似的模拟)
{
wait();
}
return DialogResult; // 返回 DialogResult 属性
}
}

Svg.SkiaSkiaSharp 的使用

这两个都是不同但有联系的 NuGet 包。

SkiaSharp 是绘图引擎。

Svg.Skia 把 SVG 文件解析为 SkiaSharp 可绘制的内容。

所以,加载 SVG 到 WPF 窗体的流程可表示为映射:

graph LR
    A(SVG 文件) -- 加载 --> B(SKSvg 对象)
    B -- 绘制 --> C(SkiaSharp Canvas 的图层)

使用 Svg.Skia 加载 SVG 的前期认识和操作

Svg.Skia 提供了一个 SKsvg 类,其实例表示一个被 SVG 格式映射在 C# 代码中的对象(我把它称作一个 SkiaSVG)。

1
2
// class Program
public static SKSvg? CurrentSkiaSVG { get; set; } = null;

上述代码在 Program 类创建了一个可为 SKsvg 的实例或 null 值的 CurrentSkiaSVG,默认初始化值为 null

使用 Svg.Skia 加载 SVG

可以回到主线程,使用以下操作 加载 SVG

1
2
3
// 主线程需要加载 SVG 文件的地方
Program.CurrentSkiaSVG = new SKSvg(); // 创建一个新的 SkiaSVG 实例
Program.CurrentSkiaSVG.Load("<SVG文件路径>"); // 加载 SVG

可见,调用 SKSvg 类实例的 Load 方法是 SVG 文件加载到 C# 的关键。

使用 SkiaSharp 绘制 SVG 的前期认识和操作

SkiaSharp 提供了 SKCanvas 类,其实例表示一个绘图画布。

但 BMG 并没有直接用 SKCanvas ,而是在窗体中创建一个 SKElement 画布容器,这个容器就自带画布功能。

1
2
3
4
5
6
<!-- MainWindow.xaml 部分-->
<skia:SKElement x:Name="SkiaCanvas"
PaintSurface="OnPaintSurface"
MouseDown="SkiaCanvas_MouseDown"
MouseMove="SkiaCanvas_MouseMove"
MouseUp="SkiaCanvas_MouseUp" />

于是这个画布容器被实例化为 SkiaCanvas 变量,并显式绑定了四个事件的处理方法。

PaintSurface 表示初次显示、刷新请求、内容变化、尺寸变化时等,执行 OnPaintSurface 方法,其方法签名如下:

1
2
// class MainWindow
private void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e)

这里的参数 e 的属性表示绘图的相关信息,包含马上要用的 Surface

OnPaintSurface 方法首先就要用一个变量储存画布对象:

1
2
// void OnPaintSurface()
var canvas = e.Surface.Canvas;

然后就可以在 canvas 上绘制内容了。比如 canvas.DrawCircle(x, y, r, paint) 可以绘制一个圆。canvas.Clear() 可以清空画布。

使用 SkiaSharp 绘制 SVG

SkiaSVG 提供实例属性 Picture,它是一个 SKPicture 对象,可以用 DrawPicture 方法绘制。

1
2
// void OnPaintSurface()
canvas.DrawPicture(Program.CurrentSkiaSVG.Picture);

Python 工具部分

Python 工具的组织

在 BMG ,Python 工具只有一个数据驱动绘图工具 run.py。但是为了代码的模块化,BMG 的 Python 工具采用了如下的方式组织:

1
2
3
4
5
6
7
8
9
10
工程
├── PythonScripts/ # Python 工具主目录
│ ├── src # 包
│ │ ├── generator.py # 定义生成器函数的模块
│ │ ├── loader.py # 定义数据加载函数的模块
│ │ ├── model.py # 定义类(建模)的模块
│ │ └── utils.py # 定义工具函数的模块
│ ├── run.py # Python 工具主程序
│ └── run.exe # Python 工具可执行文件
···

可见 BMG 采取了分包的形式模块化。如果想在 run.py 里面如果想使用生成器函数,只需

1
2
from src.generator import (generate_road_previewer, get_papersize, shift_nodes_coord,
draw_preview_roads, draw_preview_stations)

即可。

另外,分包这个过程实际上不用太刻意。并不是说我要事先想搞些什么模块出来再想怎么写主程序,而是我先假设我准备了什么变量要放在主程序,然后想怎么用这些变量去推导下一轮变量,以及这个变量是不是可以作为某个类的数据成员,从而我再把这个推到的过程封装为函数,或者把这个可能是类的数据成员放在建模模块定义了,最后放进主程序调用。

Python 工具转可执行程序

使用工具 auto-py-to-exe 可以把 Python 工具转为可执行程序。

工具的安装:

1
pip install auto-py-to-exe

运行:

1
$ auto-py-to-exe

JSON 数据在 Python 的加载

以加载道路节点数据 nodes.json 为例。

先在 model.py 定义 Node 类:

1
2
3
4
5
6
7
8
9
10
from dataclasses import dataclass, field

@dataclass
class Node:
# 从 JSON 加载
id: int
name: str
coord: tuple[float, float]
# 后期计算几何属性
geo_coord: tuple[float, float] = field(init=False)

可以看出 dataclass 装饰器可以免去手动定义 __init__ 方法的麻烦。我们只要写成员变量即可。成员变量除了从 JSON 传入的 idnamecoord 之外,还定义了一个后期计算的 geo_coord= field(init=False) 用于表示这个成员变量不会在 __init__ 方法中初始化。

然后在 loader.py 定义 load_nodes 函数:

1
2
3
4
5
6
7
8
9
10
11
12
import json
from pathlib import Path
from src.model import Node, Road, Station, Route

# 道路节点包含:ID、名称、坐标,其中坐标是包含横坐标和纵坐标的元组
def load_nodes(path: Path) -> dict[int, Node]:
with path.open(encoding='utf-8') as f:
data = json.load(f)
return {
d['id']: Node(id=d['id'], name=d['name'], coord=(d['coord'][0], d['coord'][1]))
for d in data
}

这里用了一个上下文管理器打开 JSON 文件,以字符串的形式储存在变量 f ,随后使用 json.load 方法把 JSON 字符串转为 Python 表达式(映射规则:JSON 数组 => Python 列表,JSON 对象 => Python 字典)。显然,这里的 data 变量储存的是一个列表。最后在 return 语句中,把这个列表映射到 id : Node 对象 的字典。

最后在 run.py 调用 load_nodes 函数:

1
2
3
4
5
6
7
import os
import sys
from pathlib import Path

map_name = sys.argv[1] # 通过命令函参数传入地图名称
data_dir = Path(os.path.join("..", "data", map_name)) # 用 Path 类构造数据目录路径
nodes = load_nodes(data_dir / "nodes.json") # 调用 load_nodes 函数

此时 nodes 变量储存的是一个 id : Node 对象 的字典,可以以 id 索引到对应的 Node 对象。

杂项部分

资源的复制到输出目录

引用的资源一定要在属性窗格把“复制到输出目录”设置为“如果较新则复制”(默认是不复制的)。

设置后,编译器会把资源文件复制到输出目录,复制地址 => 粘贴地址 的相对位置映射规则为

1
工程目录/ => 工程目录/bin/Debug或Release/net8.0-windows/