BMG 开发笔记
这是我的一篇 BMG (公共汽车线路图生成器,Bus Map Generator) 的开发笔记,记录了开发过程中认为值得记录的知识点。组织结构如下:
- 前端部分
- 使用了
Fluent.Ribbon
的MainWindow.xaml
的组织方式
- 使用了
- C# 部分
- JSON 数据在 C# 的加载和保存
Svg.Skia
和SkiaSharp
的使用
- Python 工具部分
- Python 工具的组织
- Python 工具转可执行程序
- 杂项部分
- 资源的复制到输出目录
前端部分
使用了 Fluent.Ribbon
的 MainWindow.xaml
的组织方式
最外层容器当然是窗体对象。标签内定义的属性通常包括:
- 一般属性:如
Title
、Width
、Height
这些传入数字或者直接表示属性的字符串的; - 命名空间前缀声明:如
xmlns
、x
、d
、mc
这些传入一个 URI 的; - 类型声明:如
x:Class
这些在字符串字面量里面传入类名的。
:
冒号是访问命名空间的符号。
一些命名空间前缀(可以是作为库、包)的主要用途(并不是因为它这个前缀名字而有用途,是因为传入了 URI 给它):
xmlns
:默认命名空间x
: XAML 语言扩展d
: 设计器扩展mc
: 标记兼容性
下面是 BMG 的窗体标签:
1 | <Fluent:RibbonWindow x:Class="BusMapGenerator.MainWindow" |
可见,窗体 <Fluent:RibbonWindow>
在 C# 的类完整名称是 BusMapGenerator.MainWindow
,继承自 Fluent:RibbonWindow
。它除了引入了最基础的命名空间,还引入了 AvalonDock
、skia
、ui
这些来自 NuGet 包的。它也定义了 Title
、Width
、Height
等一般属性。
XAML 实际上是对前端后台 C# 的补充。就比如对窗体类型的声明:x:Class="BusMapGenerator.MainWindow"
。本来在前端后台只用写:
1 | namespace BusMapGenerator |
前端和后台会被编译器合成:
1 | namespace BusMapGenerator |
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 | [ |
在 C# 中,我需要创建三个类:Node
、DataLoader
和 DataSaver
。
Node 类
Node
类显然表示这个数据各对象共属的类名,我们只要再定义如何把 JSON 字段映射至 C# 类属性即可:在 C# 每个字段前面都加个 [JsonProperty("JSON 字段名")]
特性声明。
1 | // Node.cs |
DataLoader 类
DataLoader
类用来储存各个加载 JSON 数据的静态方法。为方便在 C# 调用数据,这里用 Dictionary<int, Node>
来储存道路节点数据。
将 nodes.json
加载到 Dictionary<int, Node>
字典过程是:
- 先用
File.ReadAllText
方法把 JSON 文本转为字符串; - 再用
JSONConvert.DeserializeObject<List<Node>>
方法把 JSON 字符串转为List<Node>
列表; - 最后用列表的
ToDictionary
方法把List<Node>
列表转为Dictionary<int, Node>
字典,其中Id
的值作为元素的键。
1 | // DataLoader.cs |
DataSaver 类
DataSaver
类用来储存各个保存 JSON 数据的静态方法。为方便在 C# 调用数据,这里用 List<Node>
来储存道路节点数据。
将 Dictionary<int, Node>
字典保存为 nodes.json
过程是:
- 先用
List<Node>
列表把Dictionary<int, Node>
字典的元素转为List<Node>
列表; - 再用
JSONConvert.SerializeObject
方法把List<Node>
列表转为 JSON 字符串; - 最后用
File.WriteAllText
方法把 JSON 字符串写入nodes.json
文件。
(相当于反向操作 DataLoader
类中相应的方法)
1 | // DataSaver.cs |
非主程序的窗体的弹出
除了主程序窗体外,有时候我们会在项目弄一些辅助窗体,在点击某个按钮之后弹出。比如在 BMG 中,我点击“打开地图”按钮,就会弹出一个窗体让我选择地图。
首先,在 Visual Studio 的解决方案资源管理器中,给项目新建一个 WPF 窗体。这里我新建的是 SelectMapWindow.xaml
。它同时配套一个后台文件 SelectMapWindow.xaml.cs
。
1 | // SelectMapWindow.xaml.cs |
新建窗体后可以发现,SelectMapWindow.xaml.cs
里面定义了一个继承自 Window
的类 SelectMapWindow
。其构造函数中,调用了一个 InitializeComponent
方法,它表示弹出此窗体。这说明,我们点击按钮弹出这个窗体的方法中,创建一个 SelectMapWindow
类的实例,就能弹出这个窗体。
1 | // class MainWindow |
创建的 SelectMapWindow
的对象初始化器,要通过 Owner
属性指定它的拥有者。这里它属于主窗体。
窗体弹出后,我们希望阻塞当前线程,直到那个窗体执行了 Close
方法。我们可以用 ShowDialog
方法:
1 | // 在刚才的 OpenMap 方法体继续添加 |
用伪代码模拟 ShowDialog 方法的过程:
1 | // 伪代码 |
Svg.Skia
和 SkiaSharp
的使用
这两个都是不同但有联系的 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 | // class Program |
上述代码在 Program
类创建了一个可为 SKsvg
的实例或 null
值的 CurrentSkiaSVG
,默认初始化值为 null
。
使用 Svg.Skia
加载 SVG
可以回到主线程,使用以下操作 加载 SVG :
1 | // 主线程需要加载 SVG 文件的地方 |
可见,调用 SKSvg
类实例的 Load
方法是 SVG 文件加载到 C# 的关键。
使用 SkiaSharp
绘制 SVG 的前期认识和操作
SkiaSharp
提供了 SKCanvas
类,其实例表示一个绘图画布。
但 BMG 并没有直接用 SKCanvas
,而是在窗体中创建一个 SKElement
画布容器,这个容器就自带画布功能。
1 | <!-- MainWindow.xaml 部分--> |
于是这个画布容器被实例化为 SkiaCanvas
变量,并显式绑定了四个事件的处理方法。
PaintSurface
表示初次显示、刷新请求、内容变化、尺寸变化时等,执行 OnPaintSurface
方法,其方法签名如下:
1 | // class MainWindow |
这里的参数 e
的属性表示绘图的相关信息,包含马上要用的 Surface
。
OnPaintSurface
方法首先就要用一个变量储存画布对象:
1 | // void OnPaintSurface() |
然后就可以在 canvas
上绘制内容了。比如 canvas.DrawCircle(x, y, r, paint)
可以绘制一个圆。canvas.Clear()
可以清空画布。
使用 SkiaSharp
绘制 SVG
SkiaSVG
提供实例属性 Picture
,它是一个 SKPicture
对象,可以用 DrawPicture
方法绘制。
1 | // void OnPaintSurface() |
Python 工具部分
Python 工具的组织
在 BMG ,Python 工具只有一个数据驱动绘图工具 run.py。但是为了代码的模块化,BMG 的 Python 工具采用了如下的方式组织:
1 | 工程 |
可见 BMG 采取了分包的形式模块化。如果想在 run.py
里面如果想使用生成器函数,只需
1 | from src.generator import (generate_road_previewer, get_papersize, shift_nodes_coord, |
即可。
另外,分包这个过程实际上不用太刻意。并不是说我要事先想搞些什么模块出来再想怎么写主程序,而是我先假设我准备了什么变量要放在主程序,然后想怎么用这些变量去推导下一轮变量,以及这个变量是不是可以作为某个类的数据成员,从而我再把这个推到的过程封装为函数,或者把这个可能是类的数据成员放在建模模块定义了,最后放进主程序调用。
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 | from dataclasses import dataclass, field |
可以看出 dataclass 装饰器可以免去手动定义 __init__
方法的麻烦。我们只要写成员变量即可。成员变量除了从 JSON 传入的 id
、name
、coord
之外,还定义了一个后期计算的 geo_coord
。= field(init=False)
用于表示这个成员变量不会在 __init__
方法中初始化。
然后在 loader.py
定义 load_nodes
函数:
1 | import json |
这里用了一个上下文管理器打开 JSON 文件,以字符串的形式储存在变量 f
,随后使用 json.load
方法把 JSON 字符串转为 Python 表达式(映射规则:JSON 数组 => Python 列表,JSON 对象 => Python 字典)。显然,这里的 data
变量储存的是一个列表。最后在 return
语句中,把这个列表映射到 id : Node 对象
的字典。
最后在 run.py
调用 load_nodes
函数:
1 | import os |
此时 nodes
变量储存的是一个 id : Node 对象
的字典,可以以 id
索引到对应的 Node
对象。
杂项部分
资源的复制到输出目录
引用的资源一定要在属性窗格把“复制到输出目录”设置为“如果较新则复制”(默认是不复制的)。
设置后,编译器会把资源文件复制到输出目录,复制地址 => 粘贴地址
的相对位置映射规则为
1 | 工程目录/ => 工程目录/bin/Debug或Release/net8.0-windows/ |