手把手教你用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 - 性能優化
注意上文中的代碼,它原本可能會是一個5000x5000的大循環,而每幀的時間僅僅只有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,保存好這兩個索引后,這個雙層循環檢測性能可以從5000x5000降低到0-2000x2000,最優情況是初期和未期,數據規模趨近於0,最差情況在中期,數據規模趨近於2000x2000,總之會比簡單的雙層循環快很多。
注意:索引是有明顯缺點的,索引的本質是緩存,緩存的本質是狀態,狀態的屬性之一,就是
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騷操作】:

