手把手教你用C#做疫情传播仿真
手把手教你用C#做疫情傳播仿真
在上篇文章中,我介紹了用?C#做的疫情傳播仿真程序的使用和配置,演示了其運(yùn)行效果,但沒(méi)有著重講其中的代碼。
今天我將抽絲剝繭,手把手分析程序的架構(gòu),以及妙趣橫生的細(xì)節(jié)。
首先來(lái)回顧一下運(yùn)行效果:?
注意看,程序中的信息,包含信息統(tǒng)計(jì)、城市居民展示和醫(yī)院展示三個(gè)部分,其中居民按狀態(tài)的不同,顯示為不同的顏色。
本文將先從程序員的角度,說(shuō)說(shuō)程序中的實(shí)現(xiàn)細(xì)節(jié),細(xì)節(jié)中會(huì)聊一聊與與?Java版的不同,最后進(jìn)行總結(jié)。
細(xì)節(jié)介紹
細(xì)節(jié)介紹一 · 從“人”說(shuō)起
居民類(lèi)如下所示:
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, // 發(fā)病InHospital, // 發(fā)病并進(jìn)入醫(yī)院Cured, // 治愈Dead, //死亡 }一個(gè)城市將會(huì)模擬?5000個(gè)居民,因此在設(shè)計(jì)這個(gè)類(lèi)的時(shí)候,應(yīng)該盡可能地考慮性能、節(jié)約內(nèi)存。
所以,狀態(tài)最好越少越好,在設(shè)計(jì)這個(gè)類(lèi)的時(shí)候,我謹(jǐn)慎地保留了狀態(tài)?Status、當(dāng)前位置?Position、用于做狀態(tài)機(jī)的?EstimateDays和移動(dòng)方向?Direction這四個(gè)狀態(tài)。
細(xì)節(jié)介紹二 - 居民的狀態(tài)變更流
居民狀態(tài)扭轉(zhuǎn)過(guò)程如下所示:
(有傳染性,傳染給健康人)???? ? ????? ? ? 健康 ? 潛伏期 ? 發(fā)病 ? 入院隔離 ? 治愈↘ ↙↘ ↙死亡其中,?健康到?被感染的驗(yàn)證除了狀態(tài)檢測(cè)外,還要由居民之間的距離決定。而是否戴口罩,又會(huì)影響其判斷距離,這些邏輯用代碼表示如下:
const float InffectRate = 0.8f; // 靠得夠近時(shí),被攜帶者感染的機(jī)率 static bool WearMask = false; // 是否戴口罩 // 要靠多近,才會(huì)觸發(fā)感染驗(yàn)證 static float SafeDistance() => WearMask ? 1.5f : 3.5f; void StepDay() {// ...// healthy -> infectedList<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字段用于控制潛伏期、發(fā)病到去醫(yī)院的等待時(shí)間、治愈時(shí)間,這個(gè)字段用得較為巧妙。正常可能需要三個(gè)字段,但這三種狀態(tài)之間,不存在狀態(tài)共享,因此可以使用一個(gè)共享的字段來(lái)代替。
比如,?infected->illness狀態(tài)扭轉(zhuǎn)的代碼表述如下:
void StepDay() {for (var i = 0; i < Persons.Length; ++i){// ... 其它代碼// infected -> illnessif (Persons[i].Status == PersonStatus.InfectedInShadow){--Persons[i].EstimateDays;if (Persons[i].EstimateDays <= 0){Persons[i].Status = PersonStatus.Illness;Persons[i].EstimateDays = GenerateToHospitalDays();}continue;}}// ... 其它代碼 }注意,代碼中總會(huì)使用?EstimateDays,來(lái)判斷是否要進(jìn)入下一個(gè)狀態(tài),而進(jìn)入下一個(gè)狀態(tài)后,便會(huì)重新指定新的?EstimateDays。通過(guò)這樣的狀態(tài)共享,便可為?Person類(lèi)節(jié)省許多狀態(tài)。
細(xì)節(jié)介紹3 - 性能優(yōu)化
注意上文中的代碼,它原本可能會(huì)是一個(gè)?5000x?5000的大循環(huán),而每幀的時(shí)間僅僅只有?1/60=13.33ms。
經(jīng)過(guò)反復(fù)思考,我使用了三種方法來(lái)優(yōu)化。
優(yōu)化1 · 索引與緩存
首先是在城市類(lèi)?City中,我使用了一個(gè)索引:
class City {public Person[] Persons;private SortedSet<int> infectorIds = new SortedSet<int>();private SortedSet<int> healthyIds = new SortedSet<int>();// ... 其它代碼 }該索引維護(hù)了兩個(gè)索引?infectorIds和?healthyIds,保存好這兩個(gè)索引后,這個(gè)雙層循環(huán)檢測(cè)性能可以從?5000x?5000降低到?0-?2000x?2000,最優(yōu)情況是初期和未期,數(shù)據(jù)規(guī)模趨近于?0,最差情況在中期,數(shù)據(jù)規(guī)模趨近于?2000x?2000,總之會(huì)比簡(jiǎn)單的雙層循環(huán)快很多。
注意:索引是有明顯缺點(diǎn)的,索引的本質(zhì)是緩存,緩存的本質(zhì)是狀態(tài),狀態(tài)的屬性之一,就是?bug,多一份索引,就需要多加一處維護(hù)索引的位置,就多加了一層“寫(xiě)?bug”的風(fēng)險(xiǎn)。另外索引過(guò)多,可能會(huì)影響性能。
我會(huì)盡我一切努力,不給程序引入額外狀態(tài)。除非我有一個(gè)無(wú)法拒絕的理由。
優(yōu)化2 · 多線(xiàn)程
這算是?.NET的福利吧。
如代碼所示,我使用了?PLINQ,這是從?.NET4.0推出的新玩意,只需一條簡(jiǎn)單的?AsParallel(),就可以讓代碼幾乎不變,就能享受多核?CPU帶來(lái)的性能紅利,我完全不需要處理同步等機(jī)制。
優(yōu)化3 · 使用值類(lèi)型
也如代碼所示,我特意為?Person類(lèi)選擇了值類(lèi)型(?struct),它的優(yōu)點(diǎn)在本程序中體現(xiàn)在兩處:
一是在于創(chuàng)建時(shí),無(wú)需分配堆內(nèi)存,要知道內(nèi)存分配需要請(qǐng)求操作系統(tǒng)(就像瀏覽器請(qǐng)求服務(wù)器那樣)非常緩慢;
二是值類(lèi)型數(shù)據(jù)的值,在內(nèi)存中是連續(xù)的。這對(duì)?CPU緩存是個(gè)天大的好消息。無(wú)論是否是現(xiàn)代?CPU,對(duì)連續(xù)型的內(nèi)存訪問(wèn),性能總是最高的,在一性能測(cè)試中,連續(xù)內(nèi)存與非連續(xù)內(nèi)存的?CPU訪問(wèn)速度差,高達(dá)?50倍之大。
注意:?Java中沒(méi)提供類(lèi)似于?struct這樣的關(guān)鍵字,無(wú)法自定義值類(lèi)型。但通過(guò)一定技巧,如創(chuàng)建基元類(lèi)型數(shù)組,也能實(shí)現(xiàn)高性能的連續(xù)內(nèi)存訪問(wèn)。
我之前寫(xiě)過(guò)一篇文章《.NET中的值類(lèi)型與引用類(lèi)型》,包含了詳情說(shuō)明(包含缺點(diǎn)與優(yōu)化、使用場(chǎng)景等)和性能測(cè)試。
細(xì)節(jié)介紹四 - 時(shí)間控制
我嘗試寫(xiě)過(guò)很多游戲和動(dòng)態(tài)模擬器,我認(rèn)為時(shí)間控制的優(yōu)劣,最能體現(xiàn)出一個(gè)模擬器/游戲制作者的用心。一般程序員都喜歡將垂直同步事件當(dāng)作游戲的心臟,這樣最簡(jiǎn)單,用代碼表述如下(已簡(jiǎn)化):
void Render() {float dt = RenderTimer.LastFrameTimeInSecond;Update(dt);Draw(ctx);SwapChain.Present(1, 0); }這樣的好處是邏輯可能比較簡(jiǎn)單,可以在大腦中腦補(bǔ)每秒?60幀,然后按?60幀設(shè)置參數(shù),想事情。
這樣一來(lái),更新邏輯?Update(dt)可能就會(huì)和垂直同步事件強(qiáng)綁定。要知道有些投影儀可能只有?50幀,而某些顯示器,有?144幀;然后就是它也和垂直同步選項(xiàng)強(qiáng)綁定,一旦關(guān)閉垂直同步,?Update邏輯可能就會(huì)過(guò)快而導(dǎo)致程序運(yùn)行不正常。
我的做法是將這些邏輯稍作封裝,代碼中的配置,只與真實(shí)世界中的時(shí)間相關(guān),而與垂直同步選項(xiàng)無(wú)關(guān):
const float SecondsPerDay = 0.3f; // 模擬器的秒數(shù),對(duì)應(yīng)真實(shí)一天 class City {float dayAccumulate = 0;public void Update(float dt){// step movefor (var i = 0; i < Persons.Length; ++i){Persons[i].MoveAroundInCity(dt, CitySize);}// step statusdayAccumulate += dt;day += (dt / SecondsPerDay);while (dayAccumulate >= SecondsPerDay){StepDay();dayAccumulate -= SecondsPerDay;}} }注意我使用了一個(gè)?SecondsPerDay,來(lái)控制模擬器的運(yùn)行速度,將這個(gè)值調(diào)大或調(diào)小,不影響運(yùn)行的最終結(jié)果。
我還使用了一個(gè)?dayAccumulate值,用于做按“天”更新判斷,這樣的話(huà),無(wú)論函數(shù)調(diào)用頻率如何,調(diào)用?StepDay()時(shí)都會(huì)確保相隔“一整天”。
細(xì)節(jié)介紹五 - 縮放管理
和時(shí)間管理一樣,我認(rèn)為窗口大小與縮放控制也很重要,否則程序只能以一種固定的分辨率、?DPI來(lái)運(yùn)行。我使用的是我自己寫(xiě)的“準(zhǔn)”游戲引擎?FlysEngine,它基于?Direct2D,可以通過(guò)矩陣變換輕松地管理好程序縮放:
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 coordinatectx.Transform =Matrix3x2.Scaling(scale) *Matrix3x2.Translation(ClientSize.Width / 2, ClientSize.Height / 2);City.Draw(ctx, XResource); }注意我定義了一個(gè)“魔法值”——?540,它是?FHD1920x1080中,短邊?1080的一半。
這樣一來(lái),有兩個(gè)好處。
首先,我程序后面所有代碼,都可以按照?1920x1080的“相對(duì)值”進(jìn)行設(shè)計(jì)。無(wú)論客戶(hù)的桌面分辨率是?4kUHD還是?1366x768,都會(huì)以相同的比例做縮放。
其次我還將坐標(biāo)原點(diǎn)設(shè)為屏幕的正中心,這樣也更加簡(jiǎn)化了我的后續(xù)代碼,比如在控制?Person的出生點(diǎn)時(shí),我可以通過(guò)極坐標(biāo)系直接生成:
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;}// 其它代碼 }總結(jié)
本文從五個(gè)細(xì)節(jié)聊了我的【.NET疫情傳播程序】的代碼,其實(shí)這些代碼不光應(yīng)用在這個(gè)程序中,也應(yīng)用到了我寫(xiě)過(guò)的許多小游戲和模擬器,都非常重要。
所有這些代碼都已經(jīng)上傳到我的?Github:https://github.com/sdcb/2019-ncp-simulation,各位可以自由?star/?fork/提?issue/?PR。
總結(jié)
以上是生活随笔為你收集整理的手把手教你用C#做疫情传播仿真的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 前端 JS/TS 调用 ASP.NET
- 下一篇: 中小企业团队敏捷产品开发流程最佳实践