本文首發(fā)公眾號洪流學堂。洪流學堂枪芒,讓你快人幾步筒狠。
本文主要是Unity官方川哥的視頻《淺談Unity內(nèi)存管理》的筆記及相關(guān)知識點補充,如果有時間強烈建議學習原視頻:
https://www.bilibili.com/video/BV1aJ411t7N6
正文
1废恋、什么是內(nèi)存
內(nèi)存分為物理內(nèi)存、虛擬內(nèi)存扒寄。
關(guān)于物理內(nèi)存:
CPU訪問內(nèi)存是一個慢速過程鱼鼓。
CPU在需要訪問內(nèi)存時,先是訪問自己的緩存(L1Cache该编、L2Cache……)迄本,當全部Miss之后,CPU會去主內(nèi)存拿一段完整的指令到CPU的緩存中课竣。因此嘉赎,我們需要盡可能保證CPU的指令是連續(xù)的,防止CPU過多地與主內(nèi)存之間的內(nèi)存交換產(chǎn)生IO于樟。
Unity為了處理上述問題公条,減少Cache Miss ,推出了ECS和DOTS迂曲,把分散的內(nèi)存數(shù)據(jù)變成整塊靶橱、連續(xù)的數(shù)據(jù)。(但DOTS目前20210513
還不穩(wěn)定,版本之間的API可能有很大變化且不兼容关霸,產(chǎn)品級項目使用有風險)
關(guān)于虛擬內(nèi)存:
電腦在物理內(nèi)存不夠的時候传黄,操作系統(tǒng)會把一些不用的數(shù)據(jù)(DeadMemory)交換到硬盤上,稱之為內(nèi)存交換队寇。
但是手機是不做內(nèi)存交換的膘掰,一是因為移動設(shè)備的硬盤IO速度比PC慢很多,二是因為移動設(shè)備的硬盤可擦寫次數(shù)更少佳遣;因此手機如果做內(nèi)存交換一是慢识埋,而是減少設(shè)備壽命看,所以Android機上沒有做內(nèi)存交換苍日。IOS可以把不活躍的內(nèi)存進行壓縮,使得實際可用的內(nèi)存更多窗声,而安卓沒有這個能力相恃。
關(guān)于移動設(shè)備和PC:
移動設(shè)備(手機)與PC的區(qū)別在于,手機沒有獨立顯卡笨觅、獨立顯存拦耐。手機上無論是CPU還是GPU都是共用一個緩存,而且手機的內(nèi)存更小见剩、緩存級數(shù)更少杀糯、大小更小。臺式機的三級緩存大約8~16M苍苞,而手機只有2M固翰。
綜上,手機上的內(nèi)存羹呵,不論從哪個角度看骂际,都是比PC要小很多的。所以冈欢,手機上更容易出現(xiàn)內(nèi)存不夠的問題歉铝。
2、Android 內(nèi)存管理
Android是基于Linux開發(fā)的凑耻,所以Android的內(nèi)存管理和Linux很相似太示。
Android的內(nèi)存管理基本單位是Page(頁),一般是4k 一個Page香浩。內(nèi)存的回收和分配都是以 Page為單位進行操作类缤,也就是4k。Android內(nèi)存分用戶態(tài)和內(nèi)核態(tài)兩個部分邻吭,內(nèi)核態(tài)的內(nèi)存是用戶嚴格不能訪問的呀非。
關(guān)于內(nèi)存殺手:Low Memory Killer (LMK)
當手機的內(nèi)存使用量過多時,就會出現(xiàn)LMK,對當前手機的各種App岸裙、服務(wù)進行關(guān)停猖败。安卓的各種應(yīng)用、服務(wù)分為以下一些類別:
0降允、Native:系統(tǒng)內(nèi)核
1恩闻、System:系統(tǒng)服務(wù)
2、Persistent: 用戶服務(wù)剧董,比如電話幢尚、藍牙、Wifi等翅楼。
3尉剩、Foreground:前臺應(yīng)用,當前正在使用的Activity
4毅臊、Perceptible:輔助應(yīng)用理茎,音樂、搜索管嬉、鍵盤等皂林;
5、Service:駐后臺線程的服務(wù)蚯撩,云同步础倍、垃圾回收等;
6胎挎、Home鍵沟启;
7、Previous:上一個使用的應(yīng)用犹菇;
8美浦、Cached:后臺,之前使用過的各種應(yīng)用项栏。
這個也是Android系統(tǒng)的應(yīng)用優(yōu)先度排序浦辨,編號越小優(yōu)先級越高。當LMK開始工作的時候沼沈,會從優(yōu)先度最低的應(yīng)用開始Kill流酬。即最先中斷各種Cached,最后才會到Native列另。
例如當Cached被殺掉之后芽腾,現(xiàn)象就是當你切換到后臺的那些應(yīng)用時,你會發(fā)現(xiàn)那些應(yīng)用重啟了页衙。
當Home被殺死的時候摊滔,你發(fā)現(xiàn)當你回到桌面時阴绢,桌面會重啟艰躺,你的桌面圖標會重建,或者壁紙沒了腺兴。
到Perceptible的時候,可能你的音樂页响、鍵盤不見了篓足。
再往上進行栈拖,到Foreground時,當前前臺應(yīng)用就會被殺死没陡,這個時候就會出現(xiàn)應(yīng)用閃退涩哟。
在往上手機就開始重啟了诗鸭。
3参滴、Android內(nèi)存指標
RSS:Resident Set Size
你當前的APP所應(yīng)用到的所有內(nèi)存砾赔。除了你自己的APP所使用的內(nèi)存之外,你調(diào)用的各種服務(wù)暴心、共用庫所產(chǎn)生的內(nèi)存都會統(tǒng)計到RSS之中专普。
PSS:Proportional Set Size
與RSS不同的是檀夹,PSS會把公共庫所使用的內(nèi)存平攤到所有調(diào)用這個庫的APP上。(可能你自己的應(yīng)用沒有申請很多內(nèi)存娜亿,但是你的調(diào)用的某個公共庫已經(jīng)有了很大的內(nèi)存分配蚌堵,平攤下來就會導(dǎo)致你自己的APP的PSS虛高。)
USS:Unique Set Size
只有此APP所使用的內(nèi)存嘁灯,剔除掉公共庫的內(nèi)存分配够挂。
我們在實際工作中更多要做的是對USS的優(yōu)化孽糖,有時也會注意一下PSS。
4尘奏、關(guān)于Unity內(nèi)存
Unity內(nèi)存的分類
Unity內(nèi)存分為 Native Memory和 Managed Memory (托管內(nèi)存)炫加。值得注意的是铺然,在Editor下和在Runtime下Unity的內(nèi)存分配是完全不同的魄健。不但分配內(nèi)存的大小會有不同,統(tǒng)計看到的內(nèi)存大小不同革骨,甚至是內(nèi)存分配時機和方式也不同良哲。
比如一個AssetBundle助隧,在編輯器下是你一打開Unity就開始加載進內(nèi)存并村,而在Runtime下則是你使用時才會加載颜及,如果不讀取函匕,是不會進內(nèi)存的潘悼。(Unity2019之后做了一些Asset導(dǎo)入優(yōu)化,不使用的資源就不會導(dǎo)入)挠唆。 因為 Editor 不注重 Runtime 的表現(xiàn)嘱吗,更注重編輯器中編輯時的流暢谒麦。
但如果游戲龐大到幾十個 G绕德,如果第一次打開項目,會消耗很多時間踪蹬,有的大的會幾天跃捣,甚至到一周夺蛇。
Unity的內(nèi)存還可以分為引擎管理的內(nèi)存和用戶管理器的內(nèi)存兩類蚊惯。引擎管理的內(nèi)存一般開發(fā)者是訪問不到的截型,而用戶管理的內(nèi)存才是使用者需要關(guān)系和優(yōu)先考慮的儒溉。
還有一個Unity監(jiān)測不到的內(nèi)存:用戶分配的 Native 內(nèi)存內(nèi)存是Unity的Profile工具監(jiān)測不到顿涣。例如:
- 自己寫的 Native 插件(C++ 插件)涛碑, Unity 無法分析已經(jīng)編譯過的 C++ 是如何去分配和使用內(nèi)存的。
- Lua 完全由自己管理內(nèi)存歹篓,Unity 無法統(tǒng)計到內(nèi)部的使用情況庄撮。
5、最佳實踐 Native 內(nèi)存
Unity 重載了所有分配內(nèi)存的操作符(C++ alloc毡庆、new)么抗,使用這些重載的時候乖坠,會需要一個額外的 memory label (Profiler-shaderlab-object-memory-detail-snapshot刀闷,里面的名字就是 label:指當前內(nèi)存要分配到哪一個類型池里面)
- 使用重載過的分配符去分配內(nèi)存時甸昏,Allocator 會根據(jù)你的 memory label 分配到不同 Allocator 池里面施蜜,每個 Allocator 池 單獨做自己的跟蹤。因此當我們?nèi)?Runtime get memory label 下面的池時就可以問 Allocator缸沃,里面有多少東西 多少兆趾牧。
- Allocator 在 NewAsRoot (Memory “island”(沒聽清)) 中生成翘单。在這個 Memory Root 下面會有很多子內(nèi)存:shader:當我們加載一個 Shader 進內(nèi)存的時候蹦渣,會生成一個 Shader 的 root柬唯。Shader 底下有很多數(shù)據(jù):sub shader锄奢、Pass 等會作為 memory “island” (root) 的成員去依次分配。因此當我們最后統(tǒng)計 Runtime 的時候师坎,我們會統(tǒng)計 Root胯陋,而不會統(tǒng)計成員遏乔,因為太多了沒法統(tǒng)計。
- 因為是 C++ 的凉翻,因此當我們 delete制轰、free 一個內(nèi)存的時候會立刻返回內(nèi)存給系統(tǒng)垃杖,與托管內(nèi)存堆不一樣调俘。
Scene
- Unity 是一個 C++ 引擎旺垒,所有實體最終都會反映在 C++ 上先蒋,而不是托管堆里面鞭达。因此當我們實例化一個 GameObject 的時候皇忿,在 Unity 底層會構(gòu)建一個或多個 Object 來存儲這個 GameObject 的信息鳍烁,例如很多 Components幔荒。因此當 Scene 有過多 GameObject 的時候,Native 內(nèi)存就會顯著上升提澎。
- 當我們看 Profiler念链,發(fā)現(xiàn) Native 內(nèi)存大量上升的時候掂墓,應(yīng)先去檢查 Scene君编。
Audio(音頻):
DSP Buffer :相當于音頻的緩沖吃嘿。
當一個聲音要播放的時候兑燥,它需要向 CPU 去發(fā)送指令——我要播放聲音。但如果聲音的數(shù)據(jù)量非常小寺庄,就會造成頻繁地向 CPU 發(fā)送指令斗塘,會造成 I\O馍盟。
當 Unity 用到 FMOD 聲音引擎時(Unity 底層也用到 FMOD)贞岭,會有一個 Buffer搓侄,當 Buffer 填充滿了讶踪,才會向 CPU 發(fā)送“我要播放聲音”的指令。
-
DSP buffer 會導(dǎo)致兩種問題:
- 如果(設(shè)置的) buffer 過大廓俭,會導(dǎo)致聲音的延遲研乒。要填充滿 buffer 是要很多聲音數(shù)據(jù)的告嘲,但聲音數(shù)據(jù)又沒這么大橄唬,因此會導(dǎo)致一定的聲音延遲参歹。
- 如果 DSP buffer 太小犬庇,會導(dǎo)致 CPU 負擔上升臭挽,滿了就發(fā)欢峰,消耗增加纽帖。
Foce to Mono : 強制單聲道
當兩個聲道完全相同時可以Force To Mono懊直,可以節(jié)省一半的內(nèi)存。
在導(dǎo)入聲音的時候有一個設(shè)置雕崩,很多音效師為了聲音質(zhì)量盼铁,會把聲音設(shè)為雙聲道捉貌。但 95% 的聲音趁窃,左右聲道放的是完全一樣的數(shù)據(jù)醒陆。這導(dǎo)致了 1M 的聲音會變成 2M刨摩,體現(xiàn)在包體里和內(nèi)存里澡刹。因此一般對于聲音不是很敏感的游戲罢浇,會建議改成 Force to mono嚷闭,強制單聲道赖临。
Format
例如IOS對MP3有硬解支持的兢榨,所以MP3的解析會快很多(Android 沒有)吵聪。
Compressiont Format
聲音文件在內(nèi)存的存在形態(tài)(解壓的暖璧、壓縮的等)澎办。
Code Size
代碼也是需要加載進內(nèi)存的局蚀,使用時要注意減少模板泛型的濫用琅绅。因為模板泛型在編譯成C++時,會把同樣的代碼排列組合都編譯一邊骆捧,導(dǎo)致Code Size 大幅上升敛苇。
可以參考 Memory Management in Unity:https://learn.unity.com/tutorial/memory-management-in-unity 3.IL2CPP & Mono 的 Generic Sharing 部分枫攀。
AssetBundle
TypeTree
- Unity 的每一種類型都有很多數(shù)據(jù)結(jié)構(gòu)的改變来涨,為了對此做兼容蹦掐,Unity 會在生成數(shù)據(jù)類型序列化的時候笤闯,順便會生成 TypeTree:當前我這一個版本里用到了哪些變量颗味,對應(yīng)的數(shù)據(jù)類型是什么浦马。在反序列化的時候,會根據(jù) TypeTree 來進行反序列化张漂。
- 如果上一個版本的類型在這個版本中沒有晶默,TypeTree 就沒有它,因此不會碰到它航攒。
- 如果要用一個新的類型磺陡,但在這個版本中不存在,會用一個默認值來序列化漠畜,從而保證了不會在不同的版本序列化中出錯币他,這個就是 TypeTree 的作用憔狞。
- Build AssetBundle 中有開關(guān)可以關(guān)掉 TypeTree蝴悉。當你確認當前 AssetBundle 的使用和 Build Unity 的版本一模一樣,這時候可以把 TypeTree 關(guān)掉瘾敢。
- 例如如果用同樣的 Unity 打出來的 AssetBundle 和 APP拍冠,TypeTree 則完全可以關(guān)掉尿这。
- TypeTree 好處:
- 內(nèi)存減少。TypeTree 本身是數(shù)據(jù)庆杜,也要占內(nèi)存射众。
- 包大小會減少,因為 TypeTree 會序列化到 AssetBundle 包中欣福,以便讀取责球。
- Build 和運行時會變快焦履。源代碼中可以看到拓劝,因為每一次 Serialize 東西的時候,如果發(fā)現(xiàn)需要 Serialize TypeTree嘉裤,則會 Serialize 兩次:
- 第一次先把 TypeTree Serialize 出來
- 第二次把實際的東西 Serialize 出來
- 反序列化也會做同樣的事情郑临,1. TypeTree 反序列化,2. 實際的東西反序列化屑宠。
- 當你確定你當前的AssetBundle和你的Unity是同一個版本的時候厢洞,就可以關(guān)掉TypeTree。關(guān)掉TypeTree之后可以減少內(nèi)存大小典奉、包大小躺翻、加快運行速度。
壓縮方式:使用Lz4卫玖,而不是Lzma
- Lz4 (https://docs.unity3d.com/Documentation/ScriptReference/BuildCompression.LZ4.html)
- LZ4HC "Chunk Based" Compression. 非彻悖快
- 和 Lzma 相比,平均壓縮比率差 30%假瞬。也就是說會導(dǎo)致包體大一點陕靠,但是(作者說)速度能快 10 倍以上。
- Lzma (https://docs.unity3d.com/2019.3/Documentation/ScriptReference/BuildCompression.LZMA.html)
- Lzma 基本上就不要用了脱茉,因為解壓和讀取速度上都會比較慢剪芥。
- 還會占大量內(nèi)存
- 因為是 Steam based 而不是 Chunk Based 的,因此需要一次全解壓
- Chunk Based 可以一塊一塊解壓
- 如果發(fā)現(xiàn)一個文件在第 5-10 塊琴许,那么 LZ4 會依次將 第 5 6 7 8 9 10 塊分別解壓出來税肪,每次(chunk 的)解壓會重用之前的內(nèi)存,來減少內(nèi)存的峰值榜田。
- 中國版 Unity 中有基于 LZ4 的Addressables( AssetBundle) 加密益兄,只支持 LZ4。https://mp.weixin.qq.com/s/s9lQyunpRPJZnnaLSb9qOQ
Size & Count
- AssetBundle 包打多大是很玄學的問題串慰,但每一個 Asset 打一個 Bundle 這樣不太好偏塞。
- 有一種減圖片大小的方式,把 png 的頭都剔除出來邦鲫。因為頭的色板是通用的灸叼,而數(shù)據(jù)不通用神汹。AssetBundle 也一樣,一部分是它的頭古今,一部分是實際打包的部分屁魏。因此如果每個 Asset 都打 Bundle 會導(dǎo)致 AssetBundle 的頭比數(shù)據(jù)還要大。
- 官方的建議是每個 AssetBundle 包大概 1M~2M 左右大小捉腥,考慮的是網(wǎng)絡(luò)帶寬氓拼。但現(xiàn)在 5G 的時候,可以考慮適當把包體加大抵碟。還是要看實際用戶的情況桃漾。
Resources文件夾
不要使用,除非在 debug 的時候
- Resource 和 AssetBundle 一樣拟逮,也有頭來索引撬统。Resource 在打進包的時候會做一個紅黑樹,來幫助 Resource 來檢索資源在什么位置敦迄,
- 如果 Resource 非常大恋追,那么紅黑樹也會非常大。
- 紅黑樹是不可卸載的罚屋。在剛開始游戲的時候就會加載進內(nèi)存中苦囱,會持續(xù)對游戲造成內(nèi)存壓力。
- 會極大拖慢游戲的啟動時間脾猛。因為紅黑樹沒加載完撕彤,游戲不能啟動。
Texture
Upload Buffer:和聲音的DSP Buffer很像尖滚,設(shè)置填充滿多大之后再推向CPU/GPU喉刘。
Read/Write : 不使用就關(guān)閉它。
- Texture 沒必要就不要開 read and write漆弄。正常 Texture 讀進內(nèi)存睦裳,解析完了,放到 upload buffer 里后撼唾,內(nèi)存里的就會 delete 掉廉邑。
- 但如果檢測到你開了 r/w 就不會 delete 了,就會在顯存和內(nèi)存中各一份倒谷。
Mip Map : 像UI這些不需要的就關(guān)閉它蛛蒙,可以省大量內(nèi)存。渤愁。
Mesh:
Read/Write :同上Texture
Compression:雖然寫的是壓縮牵祟,但實際效果并不一定有用,有些版本 Compression 開了不如不開抖格,內(nèi)存占用可能更嚴重诺苹,具體需要自己試咕晋。
6、Unity Managed Memory (托管內(nèi)存):
VM內(nèi)存池
Mono虛擬機的內(nèi)存池收奔,實際上VM是會返回給操作系統(tǒng)掌呜。
- 返還條件是什么?
- GC 不會把內(nèi)存直接返還給系統(tǒng)
- 內(nèi)存也是以 Block 來管理的坪哄。當一個 Block 連續(xù)六次 GC 沒有被訪問到质蕉,這塊內(nèi)存才會被返還到系統(tǒng)。(mono runtime 基本看不到翩肌,IL2cpp runtime 可能會看到多一點)
- 不會頻繁地分配內(nèi)存模暗,而是一次分配一大塊。
GC機制
Unity的GC機制是Boehm內(nèi)存回收摧阅,是不分代的汰蓉,非壓縮式的绷蹲。(之所以是使用Boehm是因為Unity和Mono的一些歷史原因棒卷,以及目前Unity主要精力放在IL2CPP上面)
GC機制考量
- Throughput((回收能力)
- 一次回收,會回收多少內(nèi)存
- Pause times(暫停時長)
- 進行回收的時候祝钢,對主線程的影響有多大
- Fragmentation(碎片化)
- 回收內(nèi)存后比规,會對整體回收內(nèi)存池的貢獻有多少
- Mutator overhead(額外消耗)
- 回收本身有 overhead,要做很多統(tǒng)計拦英、標記的工作
- Scalability(可擴展性)
- 擴展到多核蜒什、多線程會不會有 bug
- Protability(可移植性)
- 不同平臺是否可以使用
BOEHM
- Non-generational(不分代的)
分代是指:大塊內(nèi)存、小內(nèi)存疤估、超小內(nèi)存是分在不同內(nèi)存區(qū)域來進行管理的灾常。還有長久內(nèi)存,當有一個內(nèi)存很久沒動的時候會移到長久內(nèi)存區(qū)域中铃拇,從而省出內(nèi)存給更頻繁分配的內(nèi)存钞瀑。
- Non-compacting(非壓縮式)
- 當有內(nèi)存被回收的時候,壓縮內(nèi)存會把上圖空的地方重新排布慷荔。
- 但 Unity 的 BOEHM 不會雕什!它是非壓縮式的∠跃В空著就空著贷岸,下次要用了再填進去。
- 歷史原因:Unity 和 Mono 合作上磷雇,Mono 并不是一直開源免費的偿警,因此 Unity 選擇不升級 Mono,與實際 Mono 版本有差距唯笙。
- 下一代 GC
- Incremental GC(漸進式 GC)
- 現(xiàn)在如果我們要進行一次 GC螟蒸,主線程被迫要停下來落剪,遍歷所有 GC Memory “island”(沒聽清),來決定哪些 GC 可以回收尿庐。
- Incremental GC 把暫停主線程的事分幀做了忠怖。一點一點分析,主線程不會有峰值抄瑟》财總體 GC 時間不變,但會改善 GC 對主線程的卡頓影響皮假。
- SGen 或者升級 Boehm鞋拟?
- SGen 是分代的,能避免內(nèi)存碎片化問題惹资,調(diào)動策略贺纲,速度較快
- IL2CPP
- 現(xiàn)在 IL2CPP 的 GC 機制是 Unity 自己重新寫的,是升級版的 Boehm
- Incremental GC(漸進式 GC)
Memory fragmentation 內(nèi)存碎片化
為了防止內(nèi)存碎片化(Memory Fragmentation)褪测,在做加載的時候猴誊,應(yīng)先加載大內(nèi)存的資源,再加載小內(nèi)存的資源(因為Bohem沒有內(nèi)存壓縮)侮措,這樣可以保證最大限度地利用內(nèi)存懈叹。
- 為什么內(nèi)存下降了,但總體內(nèi)存池還是上升了分扎?
- 因為內(nèi)存太大了澄成,內(nèi)存池沒地方放它,雖然有很多內(nèi)存可用畏吓。(內(nèi)存已被嚴重碎片化)
- 當開發(fā)者大量加載小內(nèi)存墨状,使用釋放*N,例如配置表菲饼、巨大數(shù)組肾砂,GC 會漲一大截。
- 建議先操作大內(nèi)存巴粪,再操作小內(nèi)存通今,以保證內(nèi)存以最大效率被重復(fù)利用。
Zombie Memory(僵尸內(nèi)存)
- 內(nèi)存泄露說法是不對的肛根,內(nèi)存只是沒有任何人能夠管理到辫塌,但實際上內(nèi)存沒有被泄露,一直在內(nèi)存池中派哲,被 zombie 掉了臼氨,這種叫 Zombie 內(nèi)存。
- 無用內(nèi)容
- Coding 時候或者團隊配合的時候有問題芭届,加載了一個東西進來储矩,結(jié)果從頭到尾只用了一次感耙。
- 有些開發(fā)者寫了隊列調(diào)度策略,但是策略寫的不好持隧,導(dǎo)致一些他覺得會被釋放的東西即硼,沒有被釋放掉。
- 找是否有活躍度實際上并不高的內(nèi)存屡拨。
- 沒有釋放
- 通過代碼管理和性能工具分析只酥,查看各個資源的引用
最佳實踐
1、用Destory而不是NULL 呀狼。
2裂允、多使用Struct。
3哥艇、使用內(nèi)存池:VM 本身有內(nèi)存池绝编,但建議開發(fā)者對高頻使用的小部件,自己建一個內(nèi)存池貌踏。例如UI十饥、粒子系統(tǒng)、子彈等哩俭。
4绷跑、閉包和匿名函數(shù):減少使用。所有的閉包和匿名函數(shù)最后都會變成一個Class凡资。
5、協(xié)程:只要不被釋放谬运,里面所有引用的所有內(nèi)存都會存在隙赁。(用的時候生產(chǎn)一個,不用的時候扔掉)梆暖。
6伞访、配置表:減少一次性使用的配置表數(shù)量;不要把整個配置表都扔進去轰驳,是否能切分下配置表厚掷。
7、單例:慎用级解,游戲一開始到游戲死掉冒黑,一直在內(nèi)存中。
8勤哗、Unity性能分析工具UPR:https://mp.weixin.qq.com/s/n0ERE93QQZ499Xz79eTqKA
后記:
建議各位Unity開發(fā)者去看看原視頻抡爹,會收獲更多更詳細~
https://www.bilibili.com/video/BV1aJ411t7N6