轉(zhuǎn)載自'小專(zhuān)欄'RyRYanZhong'
這個(gè) session 覆蓋了 app 儲(chǔ)存文件的方方面面, 對(duì)于經(jīng)常需要寫(xiě)入沙盒的 app來(lái)說(shuō), 提供了很多好的 guideline, 以及底層原理的分析.
使用HEIC格式圖片
蘋(píng)果建議我們本地的圖片切換成使用 HEIC 格式這種更高效的圖片格式, HEIC格式本身比jpeg小50%, 因此下載和上傳都更快, 在磁盤(pán)中存取也更快, 同時(shí)也支持透明度和無(wú)損壓縮, 在單個(gè) HEIC 圖片容器中可支持儲(chǔ)存多個(gè)圖片.
將圖片放入asset catalog
asset catalog 原生支持了 app slicing, 可以根據(jù)下載機(jī)型的不同, slice 出對(duì)應(yīng)的圖片放入安裝包中, 而不是直接將2x, 3x等倍圖全部打入安裝包, 可以有效減少包體積. 另外image的加載也會(huì)更快, 尤其是啟動(dòng)時(shí), apple 聲稱(chēng)可以比不使用 asset catalog 的情況提升10%的圖片加載速度, 所以使用 heic 加 asset catalog 組合, 會(huì)有奇效
文檔數(shù)據(jù)元數(shù)據(jù) File system metadata
檔 metadata 的數(shù)據(jù)寫(xiě)入經(jīng)常會(huì)發(fā)生, 而且 IO 開(kāi)銷(xiāo)是很大的, 例如你的 app 中有一個(gè) plist 文件記錄上一次的啟動(dòng)時(shí)間, 每次啟動(dòng) app, 都讀取該 plist 獲取上次啟動(dòng)時(shí)間, 然后寫(xiě)入當(dāng)前時(shí)間這個(gè)簡(jiǎn)單的操作會(huì)發(fā)生一次讀取 IO, 三次寫(xiě)入 IO, 還有一次 fsync() 的操作, 并且以下行為都會(huì)造成 File system metadata 寫(xiě)入
- 創(chuàng)建文件
- 刪除文件
- 重命名文件
- 更新文件
而 File system metadata 包括以下元素
- 文件名
- 大小
- 地理位置
- 修改時(shí)間
- 等等....
例如當(dāng)我們寫(xiě)一個(gè)240byte的 NSDictionary 到文件時(shí), 首先是 update file system tree
基于寫(xiě)時(shí)復(fù)制 (copy on write)策略, 不會(huì)馬上更新 file system tree 的結(jié)點(diǎn), 而是創(chuàng)建一個(gè)結(jié)點(diǎn)的拷貝
每一次操作, 都會(huì)有自己的 transaction id, 這個(gè)寫(xiě)操作就會(huì)生成一個(gè)新的結(jié)點(diǎn), ID也會(huì)被更新, 一個(gè)簡(jiǎn)單的寫(xiě)入240byte的數(shù)據(jù)進(jìn)入disk的時(shí)候, 會(huì)同步導(dǎo)致以下數(shù)據(jù)的更新. 包括:
更新file system node (4k),
更新object map (4k),
metadata總大小: 8k
文件本身: 本身是240byte, 但ios的寫(xiě)入文件最小單位是4k
所以總共是12k
因此每次更新數(shù)據(jù)到 disk 里都是有代價(jià)的, 如果我們只是需要?jiǎng)?chuàng)建一些臨時(shí)數(shù)據(jù), 例如字典, 數(shù)組這類(lèi)數(shù)據(jù), 建議不要把這些數(shù)據(jù)直接寫(xiě)入到磁盤(pán)中, 直接在內(nèi)存中使用并銷(xiāo)毀就可以了, 如果這類(lèi)原始數(shù)據(jù)有持久化的需求, 應(yīng)該通過(guò)一個(gè)中間類(lèi)來(lái)統(tǒng)一管理內(nèi)存寫(xiě)入到 disk 的邏輯, 盡量減少你的app需要的文件數(shù)量
syncing to disk
OS cache: 性能最好的一層, 使用 logical I/O, 由于是儲(chǔ)存在內(nèi)存中, 所以 I/O操作很高效 (使用logical I/O)
Disk cache: 磁盤(pán)儲(chǔ)存的物理映射. (使用 physical I/O)
permanent storage: 最終用于持久化數(shù)據(jù)的介質(zhì), 對(duì)于iOS來(lái)說(shuō), 就是閃存 (使用 physical I/O)
緩存有以上幾個(gè)層級(jí), 對(duì)于 app 來(lái)說(shuō), 離 cpu 越近的 cache, 性能就越好, 但同時(shí)我們也希望cache能確實(shí)地落在磁盤(pán)中. 數(shù)據(jù)在內(nèi)存當(dāng)中時(shí)對(duì)于app而言速度是最快的, 也沒(méi)有任何的 IO 開(kāi)銷(xiāo), 但是當(dāng)我們需要將數(shù)據(jù)從內(nèi)存一層一層地注入到閃存時(shí), 就需要注意 IO 開(kāi)銷(xiāo)了.
下面介紹幾個(gè)將數(shù)據(jù)從 OS cache 層逐步 flush 到 Permanent Storage 的函數(shù)
fsync()
該函數(shù)用于將數(shù)據(jù)從 OS cache 層寫(xiě)入到 Disk cache 層, 但數(shù)據(jù)可能不是立即寫(xiě)到 permanent storage 層, 如果沒(méi)有代碼的明確指令, 實(shí)際上是由設(shè)備的固件決定數(shù)據(jù)什么時(shí)候從 disk cache 進(jìn)入 permanent cache, 并且寫(xiě)入的順序是沒(méi)有保障的, 從OS cache寫(xiě)入 Disk cache 的順序并不決定從 disk cache 進(jìn)入 permanent cache的順序 因此fsync()
的過(guò)度使用是昂貴的, 這個(gè)函數(shù)是會(huì)直接導(dǎo)致 IO 的發(fā)生, 其實(shí)我們沒(méi)有必要手動(dòng)顯式地調(diào)用這個(gè)函數(shù), 因?yàn)?OS 本身會(huì)周期性的調(diào)用fsync()
來(lái)寫(xiě)入數(shù)據(jù), 因?yàn)榇蠖鄶?shù)情況下沒(méi)有必要手動(dòng)觸發(fā)fsync()
FULLSYNC
該函數(shù)用于將數(shù)據(jù)從 OS cache 層寫(xiě)入到 permanent cache 層, 并且會(huì)觸發(fā)所有已經(jīng)存在于 disk cache 上的數(shù)據(jù)寫(xiě)入到 permanen cache, 并且OS本身會(huì)周期性的調(diào)用該函數(shù), 因此理由同上, 大多數(shù)情況下沒(méi)有必要手動(dòng)觸發(fā)
文件序列化格式的選擇
開(kāi)發(fā)者一般會(huì)使用 Plist, XML, JSON 這三個(gè)常見(jiàn)的格式, 這些都是常見(jiàn)的數(shù)據(jù)格式, 便于使用而且普適性高, 也易于解析, 適合不是頻繁讀寫(xiě)的數(shù)據(jù), 但是每次改動(dòng)都是全量的讀寫(xiě), 導(dǎo)致整個(gè)文件讀取和重新寫(xiě)入, 就會(huì)引起上面所說(shuō)的從OS cache層到 permanent cache 層的IO操作, 即使你寫(xiě)入一個(gè)很小的數(shù)據(jù), 由于文件本身攜帶的 meta data 操作, 也可能會(huì)產(chǎn)生數(shù)據(jù)量是寫(xiě)入data本身幾倍大的IO開(kāi)銷(xiāo)
舉個(gè)例子
以下是 file activity instruments 監(jiān)控我們創(chuàng)建, 讀取, 和更新上述這個(gè) plist 時(shí), 引起了12個(gè)獨(dú)立的 IO 操作
面是單單的更新plist操作, 調(diào)用了系統(tǒng)的writeToFile
函數(shù), 最后再調(diào)用棧上系統(tǒng)為我們調(diào)用了fsync, 所以數(shù)據(jù)就會(huì)直接由OS cache層一直寫(xiě)入到 Disk cache 層, 并從 OS cache 層被清除, 如果在寫(xiě)入后我們?nèi)匀灰^續(xù)使用數(shù)據(jù), 就會(huì)失去了OS cache這一層的緩存, 而需要重新開(kāi)啟IO去磁盤(pán)中讀取數(shù)據(jù)
因此使用plist這類(lèi)文件來(lái)儲(chǔ)存需要頻繁讀寫(xiě)的數(shù)據(jù), 是非常不合適的
Core Data
由蘋(píng)果推出的 Core Data 其底層其實(shí)是基于sqlite實(shí)現(xiàn)的, 也是蘋(píng)果推薦開(kāi)發(fā)者使用的數(shù)據(jù)緩存系統(tǒng), 因?yàn)樗梢怨芾韺?duì)象關(guān)系, 創(chuàng)建關(guān)系型的數(shù)據(jù)庫(kù), 可以注冊(cè)屬性觀察與通知, 自動(dòng)版本檢測(cè), 自動(dòng)解決寫(xiě)沖突, 并且在內(nèi)部集成了 iCloudKit (iOS 13或以后)
sqlite
關(guān)于直接使用sqlite, 蘋(píng)果特別提出了關(guān)閉與開(kāi)啟連接的開(kāi)銷(xiāo), 每次開(kāi)啟和關(guān)閉DB的連接, 都會(huì)觸發(fā)sqlite的一致性檢測(cè), 日志恢復(fù), 日志標(biāo)志位設(shè)置等等操作, 因此apple建議不要過(guò)多的開(kāi)啟和關(guān)閉連接, 而是在app的生命周期里, 開(kāi)發(fā)者盡可能的保持連接一直開(kāi)啟, 例如可以建立一個(gè)獨(dú)立的子線(xiàn)程來(lái)保持與DB的連接, 然后全局通過(guò)那個(gè)子線(xiàn)程去操作DB
日志
關(guān)于日志, 開(kāi)發(fā)者平時(shí)可能對(duì)于 sqlite 日志的 mode 沒(méi)有過(guò)多的關(guān)注, 但其實(shí)日志 mode 的不同對(duì)性能同樣有很大的影響, Delete Mode
是sqlite的默認(rèn)日志mode, 但WAL Mode
是更推薦的日志mode, 首先是因?yàn)楦俚膶?xiě)操作, 這個(gè)日志模式會(huì)自動(dòng)組合多個(gè)寫(xiě)操作到同一頁(yè), 同時(shí)也使用更少的 barrier, 支持多個(gè)讀操作與寫(xiě)操作并發(fā), 并且支持?jǐn)?shù)據(jù)快照, 例如我們要寫(xiě)入4頁(yè)的DB, WAL Mode
并不會(huì)分別寫(xiě)在這4個(gè)頁(yè)中, 而且統(tǒng)一寫(xiě)在Write Ahead log file中
事務(wù) Transaction
在多個(gè) INSERT, UPDATE, DELETE 操作時(shí), 建議使用 Transaction, 可有效減低 IO 次數(shù)
例如我們有3個(gè) Transaction, 修改 DB 上同一頁(yè)的數(shù)據(jù), 這會(huì)造成 DB 上同一頁(yè)的數(shù)據(jù)被修改3次
但如果將這3個(gè)操作放在一個(gè)Transaction里, 寫(xiě)操作就會(huì)被合并成一次
File Size and Privacy
當(dāng)我們從 DB 中刪除數(shù)據(jù)時(shí), 在 DB 中儲(chǔ)存該數(shù)據(jù)的空間會(huì)被設(shè)置為可用, 但被刪除的數(shù)據(jù)是實(shí)際上有可能仍然在磁盤(pán)上直至有新數(shù)據(jù)寫(xiě)入, 如果是涉及安全和敏感的數(shù)據(jù), 可以使用
PRAGMA schema.auto_vacuume=INCREMENTAL
這種刪除模式, 該模式會(huì)總動(dòng)清理被刪除的數(shù)據(jù), 并且在iOS13 是默認(rèn)模式
不要使用 VACUUM
VACUUM 是性能比較差的清掃空閑頁(yè)的方式, 例如我們要 VACUUM 下面這個(gè)包含6個(gè)空閑頁(yè)(灰
色)的 DB, 這時(shí)會(huì)先將所有有效數(shù)據(jù)寫(xiě)到 journal 里
然后清空DB
最后再將 journal 的數(shù)據(jù)重新全部 insert 到 DB
最后再刪除 journal
所有數(shù)據(jù)都最少執(zhí)行了兩次寫(xiě)操作, 由于數(shù)據(jù)被 copy 了一份, 也會(huì)占用更多的內(nèi)存
因此建議使用 PRAGMA schema.auto_vacuume=INCREMENTAL
, 原理如下
例如我們要清理下面兩個(gè)空閑頁(yè)
write ahead log會(huì)預(yù)先記錄末尾兩個(gè)準(zhǔn)備被移動(dòng)的頁(yè), 以及他們的父結(jié)點(diǎn)
然后將末尾的頁(yè)數(shù)據(jù)更新到空閑頁(yè)上
比起直接使用 VACUUM 的全量刪除和寫(xiě)入, 這個(gè)模式只更新了需要被清理的空閑頁(yè)的數(shù)據(jù), 明顯更高效
關(guān)于sqlite部分的總結(jié)
首先是保持 DB 連接的開(kāi)啟, 尤其是需要頻繁讀寫(xiě) DB 的應(yīng)用, 如果現(xiàn)在的設(shè)計(jì)模式是每次讀寫(xiě)都做成獨(dú)立的一次連接開(kāi)啟與關(guān)閉, 將會(huì)造成不必要的額外開(kāi)銷(xiāo)
在日志模式上使用 WAL mode, 會(huì)自動(dòng)幫你合并日志操作, 一次性執(zhí)行多個(gè) statement 時(shí)優(yōu)先考慮合并成一個(gè) transaction, 并且在需要清理DB的空閑數(shù)據(jù)時(shí), 使用 auto vacuum incremental. 以上都是讓你的sqlite db能更高效運(yùn)行的方法
總結(jié)
這次 wwdc 總結(jié)了儲(chǔ)存策略的方方面面, 磁盤(pán) IO 的開(kāi)銷(xiāo)可能是國(guó)內(nèi)開(kāi)發(fā)者很少注意到的一個(gè)點(diǎn), 估計(jì)將一些小數(shù)據(jù)作為文件存在磁盤(pán)然后運(yùn)行過(guò)程不斷讀寫(xiě)的項(xiàng)目不在少數(shù), 一個(gè)簡(jiǎn)單的writeToFiles
的api就會(huì)導(dǎo)致多層的 cache IO 操作, IO 不但導(dǎo)致發(fā)熱和耗電, 而且發(fā)生在主線(xiàn)程也會(huì)造成卡頓. 如果你的項(xiàng)目暫時(shí)沒(méi)做過(guò)這方面的優(yōu)化, 用 instrument 做一下debug可能會(huì)發(fā)現(xiàn)不少"驚喜".