手把手教你用C#做疫情传播仿真
在上篇文章中,我介绍了用C#
做的疫情传播仿真程序的使用和配置,演示了其运行效果,但没有着重讲其中的代码。
今天我将抽丝剥茧,手把手分析程序的架构,以及妙趣横生的细节。
首先来回顾一下运行效果:
注意看,程序中的信息,包含信息统计、城市居民展示和医院展示三个部分,其中居民按状态的不同,显示为不同的颜色。
本文将先从程序员的角度,说说程序中的实现细节,细节中会聊一聊与与Java
版的不同,最后进行总结。
细节介绍
细节介绍一 · 从“人”说起
居民类如下所示:
struct Person
{
public PersonStatus Status;
public Vector2 Position;
public float EstimateDays;
public float Direction;
public static Person Create(float citySize)
{
// ...
}
public void Draw(DeviceContext ctx, XResource x)
{
// ...
}
public void MoveAroundInCity(float dt, float citySize)
{
// ...
}
}
enum PersonStatus
{
Healthy, // 健康
InfectedInShadow, // 被感染,处于潜伏期
Illness, // 发病
InHospital, // 发病并进入医院
Cured, // 治愈
Dead, //死亡
}
一个城市将会模拟5000
个居民,因此在设计这个类的时候,应该尽可能地考虑性能、节约内存。
所以,状态最好越少越好,在设计这个类的时候,我谨慎地保留了状态Status
、当前位置Position
、用于做状态机的EstimateDays
和移动方向Direction
这四个状态。
细节介绍二 - 居民的状态变更流
居民状态扭转过程如下所示:
(有传染性,传染给健康人)
?? ? ?
?? ? ?
健康 ? 潜伏期 ? 发病 ? 入院隔离 ? 治愈
↘ ↙
↘ ↙
死亡
其中,健康
到被感染
的验证除了状态检测外,还要由居民之间的距离决定。而是否戴口罩,又会影响其判断距离,这些逻辑用代码表示如下:
const float InffectRate = 0.8f; // 靠得够近时,被携带者感染的机率
static bool WearMask = false; // 是否戴口罩
// 要靠多近,才会触发感染验证
static float SafeDistance() => WearMask ? 1.5f : 3.5f;
void StepDay()
{
// ...
// healthy -> infected
List<int> newlyInffectedIds = new List<int>();
newlyInffectedIds = healthyIds
.AsParallel()
.Where(x =>
{
foreach (var infectorId in infectorIds)
{
if (Vector2.DistanceSquared(Persons[x].Position, Persons[infectorId].Position) <= SafeDistance() * SafeDistance())
return true;
}
return false;
})
.ToList();
foreach (int personId in newlyInffectedIds)
{
Infect(personId);
}
}
EstimateDays
字段用于控制潜伏期、发病到去医院的等待时间、治愈时间,这个字段用得较为巧妙。正常可能需要三个字段,但这三种状态之间,不存在状态共享,因此可以使用一个共享的字段来代替。
比如,infected -> illness
状态扭转的代码表述如下:
void StepDay()
{
for (var i = 0; i < Persons.Length; ++i)
{
// ... 其它代码
// infected -> illness
if (Persons[i].Status == PersonStatus.InfectedInShadow)
{
--Persons[i].EstimateDays;
if (Persons[i].EstimateDays <= 0)
{
Persons[i].Status = PersonStatus.Illness;
Persons[i].EstimateDays = GenerateToHospitalDays();
}
continue;
}
}
// ... 其它代码
}
注意,代码中总会使用EstimateDays
,来判断是否要进入下一个状态,而进入下一个状态后,便会重新指定新的EstimateDays
。通过这样的状态共享,便可为Person
类节省许多状态。
细节介绍3 - 性能优化
注意上文中的代码,它原本可能会是一个5000
x5000
的大循环,而每帧的时间仅仅只有1/60=13.33ms
。
经过反复思考,我使用了三种方法来优化。
优化1 · 索引与缓存
首先是在城市类City
中,我使用了一个索引:
class City
{
public Person[] Persons;
private SortedSet<int> infectorIds = new SortedSet<int>();
private SortedSet<int> healthyIds = new SortedSet<int>();
// ... 其它代码
}
该索引维护了两个索引infectorIds
和healthyIds
,保存好这两个索引后,这个双层循环检测性能可以从5000
x5000
降低到0
-2000
x2000
,最优情况是初期和未期,数据规模趋近于0
,最差情况在中期,数据规模趋近于2000
x2000
,总之会比简单的双层循环快很多。
注意:索引是有明显缺点的,索引的本质是缓存,缓存的本质是状态,状态的属性之一,就是
bug
,多一份索引,就需要多加一处维护索引的位置,就多加了一层“写bug
”的风险。另外索引过多,可能会影响性能。我会尽我一切努力,不给程序引入额外状态。除非我有一个无法拒绝的理由。
优化2 · 多线程
这算是.NET
的福利吧。
如代码所示,我使用了PLINQ
,这是从.NET 4.0
推出的新玩意,只需一条简单的AsParallel()
,就可以让代码几乎不变,就能享受多核CPU
带来的性能红利,我完全不需要处理同步等机制。
优化3 · 使用值类型
也如代码所示,我特意为Person
类选择了值类型(struct
),它的优点在本程序中体现在两处:
一是在于创建时,无需分配堆内存,要知道内存分配需要请求操作系统(就像浏览器请求服务器那样)非常缓慢;
二是值类型数据的值,在内存中是连续的。这对CPU
缓存是个天大的好消息。无论是否是现代CPU
,对连续型的内存访问,性能总是最高的,在一性能测试中,连续内存与非连续内存的CPU
访问速度差,高达50
倍之大。
注意:
Java
中没提供类似于struct
这样的关键字,无法自定义值类型。但通过一定技巧,如创建基元类型数组,也能实现高性能的连续内存访问。我之前写过一篇文章《.NET中的值类型与引用类型》,包含了详情说明(包含缺点与优化、使用场景等)和性能测试。
细节介绍四 - 时间控制
我尝试写过很多游戏和动态模拟器,我认为时间控制的优劣,最能体现出一个模拟器/游戏制作者的用心。一般程序员都喜欢将垂直同步事件当作游戏的心脏,这样最简单,用代码表述如下(已简化):
void Render()
{
float dt = RenderTimer.LastFrameTimeInSecond;
Update(dt);
Draw(ctx);
SwapChain.Present(1, 0);
}
这样的好处是逻辑可能比较简单,可以在大脑中脑补每秒60
帧,然后按60
帧设置参数,想事情。
这样一来,更新逻辑Update(dt)
可能就会和垂直同步事件强绑定。要知道有些投影仪可能只有50
帧,而某些显示器,有144
帧;然后就是它也和垂直同步选项强绑定,一旦关闭垂直同步,Update
逻辑可能就会过快而导致程序运行不正常。
我的做法是将这些逻辑稍作封装,代码中的配置,只与真实世界中的时间相关,而与垂直同步选项无关:
const float SecondsPerDay = 0.3f; // 模拟器的秒数,对应真实一天
class City
{
float dayAccumulate = 0;
public void Update(float dt)
{
// step move
for (var i = 0; i < Persons.Length; ++i)
{
Persons[i].MoveAroundInCity(dt, CitySize);
}
// step status
dayAccumulate += dt;
day += (dt / SecondsPerDay);
while (dayAccumulate >= SecondsPerDay)
{
StepDay();
dayAccumulate -= SecondsPerDay;
}
}
}
注意我使用了一个SecondsPerDay
,来控制模拟器的运行速度,将这个值调大或调小,不影响运行的最终结果。
我还使用了一个dayAccumulate
值,用于做按“天”更新判断,这样的话,无论函数调用频率如何,调用StepDay()
时都会确保相隔“一整天”。
细节介绍五 - 缩放管理
和时间管理一样,我认为窗口大小与缩放控制也很重要,否则程序只能以一种固定的分辨率、DPI
来运行。我使用的是我自己写的“准”游戏引擎FlysEngine
,它基于Direct2D
,可以通过矩阵变换轻松地管理好程序缩放:
protected override void OnDraw(DeviceContext ctx)
{
ctx.Clear(Color.DarkGray);
float minEdge = Math.Min(ClientSize.Width / 2, ClientSize.Height / 2);
float scale = minEdge / 540; // relative coordinate
ctx.Transform =
Matrix3x2.Scaling(scale) *
Matrix3x2.Translation(ClientSize.Width / 2, ClientSize.Height / 2);
City.Draw(ctx, XResource);
}
注意我定义了一个“魔法值”——540
,它是FHD 1920x1080
中,短边1080
的一半。
这样一来,有两个好处。
首先,我程序后面所有代码,都可以按照1920x1080
的“相对值”进行设计。无论客户的桌面分辨率是4k UHD
还是1366x768
,都会以相同的比例做缩放。
其次我还将坐标原点设为屏幕的正中心,这样也更加简化了我的后续代码,比如在控制Person
的出生点时,我可以通过极坐标系直接生成:
struct Person
{
public static Person Create(float citySize)
{
float phi = random.NextFloat(0, MathUtil.TwoPi);
float r = random.NextFloat(0, citySize);
var p = new Person { Status = PersonStatus.Healthy };
p.Position.X = MathF.Sin(phi) * r;
p.Position.Y = -MathF.Cos(phi) * r;
p.Direction = random.NextFloat(0, MathF.PI * 2);
return p;
}
// 其它代码
}
总结
本文从五个细节聊了我的【.NET疫情传播程序】的代码,其实这些代码不光应用在这个程序中,也应用到了我写过的许多小游戏和模拟器,都非常重要。
所有这些代码都已经上传到我的Github
:https://github.com/sdcb/2019-ncp-simulation,各位可以*star
/fork
/提issue
/PR
。
喜欢的朋友请关注我的微信公众号【.NET骚操作】: