手把手教你用C#做疫情传播仿真

手把手教你用C#做疫情传播仿真

在上篇文章中,我介绍了用 C# 做的疫情传播仿真程序的使用和配置,演示了其运行效果,但没有着重讲其中的代码。

今天我将抽丝剥茧,手把手分析程序的架构,以及妙趣横生的细节。

首先来回顾一下运行效果:

注意看,程序中的信息,包含信息统计、城市居民展示和医院展示三个部分,其中居民按状态的不同,显示为不同的颜色。

本文将先从程序员的角度,说说程序中的实现细节,细节中会聊一聊与与 Java 版的不同,最后进行总结。

细节介绍

细节介绍一 · 从“人”说起

居民类如下所示:

一个城市将会模拟 5000 个居民,因此在设计这个类的时候,应该尽可能地考虑性能、节约内存。

所以,状态最好越少越好,在设计这个类的时候,我谨慎地保留了状态 Status 、当前位置  Position 、用于做状态机的  EstimateDays 和移动方向  Direction 这四个状态。

细节介绍二 - 居民的状态变更流

居民状态扭转过程如下所示:

其中, 健康 到  被感染 的验证除了状态检测外,还要由 居民 之间的距离决定。而是否 戴口罩 ,又会影响其判断距离,这些逻辑用代码表示如下:

EstimateDays 字段用于控制 潜伏期发病到去医院的等待时间治愈时间 ,这个字段用得较为巧妙。正常可能需要三个字段,但这三种状态之间,不存在状态共享,因此可以使用一个共享的字段来代替。

比如, infected->illness 状态扭转的代码表述如下:

注意,代码中总会使用 EstimateDays ,来判断是否要进入下一个状态,而进入下一个状态后,便会重新指定新的  EstimateDays 。通过这样的状态共享,便可为  Person 类节省许多状态。

细节介绍3 - 性能优化

注意上文中的代码,它原本可能会是一个 50005000 的大循环,而每帧的时间仅仅只有  1/60=13.33ms

经过反复思考,我使用了三种方法来优化。

优化1 · 索引与缓存

首先是在城市类 City 中,我使用了一个索引:

该索引维护了两个索引 infectorIds 和  healthyIds ,保存好这两个索引后,这个双层循环检测性能可以从  50005000 降低到  020002000 ,最优情况是初期和未期,数据规模趋近于  0 ,最差情况在中期,数据规模趋近于  20002000 ,总之会比简单的双层循环快很多。

注意:索引是有明显缺点的,索引的本质是缓存,缓存的本质是状态,状态的属性之一,就是  bug ,多一份索引,就需要多加一处维护索引的位置,就多加了一层“写  bug ”的风险。另外索引过多,可能会影响性能。

我会尽我一切努力,不给程序引入额外状态。除非我有一个无法拒绝的理由。

优化2 · 多线程

这算是 .NET 的福利吧。

如代码所示,我使用了 PLINQ ,这是从  .NET4.0 推出的新玩意,只需一条简单的  AsParallel() ,就可以让代码几乎不变,就能享受多核  CPU 带来的性能红利,我完全不需要处理同步等机制。

优化3 · 使用值类型

也如代码所示,我特意为 Person 类选择了值类型(  struct ),它的优点在本程序中体现在两处:

一是在于创建时,无需分配堆内存,要知道内存分配需要请求操作系统(就像浏览器请求服务器那样)非常缓慢;

二是值类型数据的值,在内存中是连续的。这对 CPU 缓存是个天大的好消息。无论是否是现代  CPU ,对连续型的内存访问,性能总是最高的,在一性能测试中,连续内存与非连续内存的  CPU 访问速度差,高达  50 倍之大。

注意: Java 中没提供类似于  struct 这样的关键字,无法自定义值类型。但通过一定技巧,如创建基元类型数组,也能实现高性能的连续内存访问。

我之前写过一篇文章《.NET中的值类型与引用类型》,包含了详情说明(包含缺点与优化、使用场景等)和性能测试。

细节介绍四 - 时间控制

我尝试写过很多游戏和动态模拟器,我认为时间控制的优劣,最能体现出一个 模拟器/游戏 制作者的用心。一般程序员都喜欢将 垂直同步事件 当作游戏的心脏,这样最简单,用代码表述如下(已简化):

这样的好处是逻辑可能比较简单,可以在大脑中脑补每秒 60 帧,然后按  60 帧设置参数,想事情。

这样一来,更新逻辑 Update(dt) 可能就会和 垂直同步事件 强绑定。要知道有些投影仪可能只有  50 帧,而某些显示器,有  144 帧;然后就是它也和 垂直同步选项 强绑定,一旦关闭垂直同步,  Update 逻辑可能就会过快而导致程序运行不正常。

我的做法是将这些逻辑稍作封装,代码中的配置,只与真实世界中的时间相关,而与垂直同步选项无关:

注意我使用了一个 SecondsPerDay ,来控制模拟器的运行速度,将这个值调大或调小,不影响运行的最终结果。

我还使用了一个 dayAccumulate 值,用于做按“ ”更新判断,这样的话,无论函数调用频率如何,调用  StepDay() 时都会确保相隔“一整天”。

细节介绍五 - 缩放管理

和时间管理一样,我认为窗口大小与缩放控制也很重要,否则程序只能以一种固定的分辨率、 DPI 来运行。我使用的是我自己写的“准”游戏引擎  FlysEngine ,它基于  Direct2D ,可以通过矩阵变换轻松地管理好程序缩放:

注意我定义了一个“魔法值”—— 540 ,它是  FHD1920x1080 中,短边  1080 的一半。

这样一来,有两个好处。

首先,我程序后面所有代码,都可以按照 1920x1080 的“相对值”进行设计。无论客户的桌面分辨率是  4kUHD 还是  1366x768 ,都会以相同的比例做缩放。

其次我还将坐标原点设为屏幕的正中心,这样也更加简化了我的后续代码,比如在控制 Person 的出生点时,我可以通过极坐标系直接生成:

总结

本文从五个细节聊了我的【.NET疫情传播程序】的代码,其实这些代码不光应用在这个程序中,也应用到了我写过的许多小游戏和模拟器,都非常重要。

所有这些代码都已经上传到我的 Github :https://github.com/sdcb/2019-ncp-simulation,各位可以自由  starfork /提  issuePR

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章