憑良心講,我不能告訴你不去使用 Core Data。它不錯,而且也在變得更好,并且它被很多其他 Cocoa 開發(fā)者所理解,當(dāng)有新人加入你的團隊或者需要別人接手你的 app 的時候,這點很重要。
更重要的是,不值得花時間和精力去寫自己的系統(tǒng)去代替它。使用 Core Data 吧。真的。
為什么我不使用Core Data
就個人而言,我不是個狂熱粉絲。我發(fā)現(xiàn) (Core Data 的) API 是笨拙的,并且框架本身對于超過一定數(shù)量級的數(shù)據(jù)的處理是極其緩慢的。
一個實際的例子:10,000 個條目
想象一個 RSS 閱讀器,一個用戶可以在一個 feed 上點擊右鍵,并且選擇標(biāo)記所有為已讀。
實際實現(xiàn)上,我們有一個帶有read屬性的 Article 實體。把所有條目標(biāo)記為已讀,app 需要加載這個 feed 的所有文章 (可能通過一對多的關(guān)系),然后設(shè)置 read 屬性為 YES。
大部分時候這樣是沒問題的。但是設(shè)想那個 feed 有 200 篇文章,為了避免阻塞主線程,你可能考慮在后臺線程里做這個工作 (尤其是如果這個 app 是一個 iPhone app)。一旦你開始使用 Core Data 多線程的時候,事情就開始變的不好處理了。
這可能還沒這么糟糕,至少不值得拋棄使用 Core Data。
但是,再添加同步。
我用過兩個不同的 RSS 同步 API,它們返回已讀文章的 uniqueID 數(shù)組。其中一個返回近 10,000 個 ID。
你不會打算在主線程中加載 10,000 篇文章,然后設(shè)置read為 NO。你大概也不會想在后臺線程里加載 10,000 篇文章,即使很小心地管理內(nèi)存。這里有太多的工作(如果你頻繁的這么做,想一下對電池壽命的影響)。
概念上來說,你真正想要做的是,讓數(shù)據(jù)庫將 uniqueID 列表里的每一篇文章的read設(shè)置為 YES。
SQLite 可以做到這個,只用一次調(diào)用。如果uniqueID上有索引,這會很快。而且你可以在后臺線程執(zhí)行,這和在主線程執(zhí)行一樣容易。
另一個例子:快速啟動
我的另一個 app,我想減少啟動時間 — 不只是 app 的啟動時間,還有數(shù)據(jù)顯示之前所需要的時間。
這是個類似 Twitter 的 app (雖然它不是):它顯示消息的時間軸。顯示時間軸意味著獲取消息,并加載相關(guān)用戶。它很快,但是在啟動的時候,會填充 UI,然后填充數(shù)據(jù)。
關(guān)于 iPhone app(或者所有應(yīng)用),我的理論是,啟動時間比其他大部分開發(fā)者想的都要重要。啟動時間很慢的 app 是不太可能被啟動的,因為人們潛意識里會記住,并且在啟動那個應(yīng)用這件事情上形成一種抵抗心理。減少啟動時間可以減少這種阻力,用戶也會更愿意使用你的應(yīng)用,并且把它推薦給其他人。這是你讓你的 app 成功的一部分。
因為我不使用 Core Data,我手頭有一個簡單的,保守的解決方案。我把時間軸(消息和人物對象)通過NSCoding保存到一個 plist 文件中。啟動的時候它讀取這個文件,創(chuàng)建消息和人物對象,UI 一出現(xiàn)就顯示時間軸。
這明顯的減少了延遲。
把消息和人物對象作為NSManagedObject的實例對象,這是不可能的。(假設(shè)我已經(jīng)編碼并且存儲對象的 IDs,但是那意味著讀取 plist 文件,之后再涉及數(shù)據(jù)庫。這種方式我完全避免了數(shù)據(jù)庫)。
(在更新更快的機器出來后, 我去掉了那些代碼。回顧過去,我希望我可以把它留下來。)
我怎么考慮這個問題
當(dāng)考慮是否使用 Core Data,我考慮下面這些事情:
會有難以置信數(shù)量的數(shù)據(jù)嗎?
對于一個 RSS 閱讀器或者 Twitter app,答案顯而易見:是的。有些人關(guān)注上百個人。一個人可能訂閱了上千個 feed。
即使你的應(yīng)用不從網(wǎng)絡(luò)獲取數(shù)據(jù),用戶仍然有可能自動添加數(shù)據(jù)。如果你用一個支持 AppleScript 的 Mac,有人會寫腳本去加載非常多的數(shù)據(jù)。如果通過 web API 去添加數(shù)據(jù)也是一樣的。
會有一個 Web API 包含類似于數(shù)據(jù)庫的結(jié)果嗎(對比于類似對象的結(jié)果)?
一個 RSS 同步 API 能夠返回一個已讀文章的 uniqueID 列表。一個筆記的應(yīng)用的一個同步 API 可能返回已存檔的和已刪除的筆記的 uniqueID 列表。
用戶可能通過操作處理大量對象嗎?
在底層,需要考慮和之前一樣的問題。當(dāng)有人刪除所有已經(jīng)下載的 5,000 個面食食譜,你的食譜 app 性能如何?(在 iPhone 上?)
如果我決定使用 Core Data(我已經(jīng)發(fā)布過使用 Core Data 的應(yīng)用),我會特別注意我如何使用它。結(jié)果為了得到好的性能,我發(fā)現(xiàn)我把它當(dāng)做了一個奇怪接口的 SQL 數(shù)據(jù)庫在使用,然后我就知道了,我應(yīng)該舍棄 Core Data,而去直接使用 SQLite。
我如何使用 SQLite
我通過FMDB Wrapper來使用 SQLite,F(xiàn)MDB 來自 Flying Meat Software,由 Gus Mueller 開發(fā)。
基本操作
在使用 iPhone 和 Core Data 之前,我就使用過 SQLite。這里有關(guān)于它如何工作的要點:
所有數(shù)據(jù)庫訪問 - 讀和寫 - 發(fā)生在一個后臺線程的連續(xù)的隊列里。在主線程中觸及數(shù)據(jù)庫是從來不被允許的。使用一個連續(xù)隊列來保證每一件事是按順序發(fā)生的。
我大量使用 blocks 使得異步編程容易些。
模型對象只存在在主線程(但有兩個重要的例外),改變會觸發(fā)一個后臺保存。
模型對象列出來它們在數(shù)據(jù)庫中存儲的屬性。這可能在代碼里或者在 plist 文件里。
有些模型對象是唯一的,有些不是。取決于 app 的需要(大部分情況是唯一的)。
對關(guān)系型數(shù)據(jù),我盡可能避免創(chuàng)建查詢表。
一些對象類型在啟動的時候就完全讀入內(nèi)存,另一些對象類型我可能創(chuàng)建和維護的只有它們 uniqueID 的一個 NSMutableSet,所以我可以在不去碰數(shù)據(jù)庫的情況下就知道什么存在、什么不存在。
Web API 的調(diào)用發(fā)生在后臺線程,它們使用“分離“的模型對象。
我會使用我目前的 app的代碼來描述。
數(shù)據(jù)庫更新
在我最近的 app 中,有一個單一的數(shù)據(jù)庫控制器 -VSDatabaseController,它通過 FMDB 來與 SQLite 對話。
FMDB 區(qū)分更新和查詢。更新數(shù)據(jù)庫,app 調(diào)用:

VSDatabaseUpdateBlock很簡單:

runDatabaseBlockInTransaction也很簡單:

(注意我用的自己的連續(xù) dispatch 隊列。Gus 建議看一下FMDatabaseQueue,這也是一個連續(xù)調(diào)度隊列。因為它比 FMDB 剩下的其他東西都要新,所以我自己還沒有去看過。)
beginTransaction和endTransaction的調(diào)用是可嵌套的(在我的數(shù)據(jù)庫控制器里)。在合適的時候他們會調(diào)用-[FMDatabase beginTransaction]和-[FMDatabase commit]。(使用 transactions 是讓 SQLite 變快的一大關(guān)鍵。)提示:我在-[NSThread threadDictionary]中存儲當(dāng)前的 transaction 的計數(shù)。這對于針對每個線程的數(shù)據(jù)來說是很方便的,我也幾乎從不用它做其他的事情。
這兒有個調(diào)用更新數(shù)據(jù)庫的簡單例子:

這說明了不少事情。首先, SQL 并不可怕。即使你從沒見過它,你也知道這行代碼做了什么。
像VSDatabaseController的所有其他公共接口一樣,emptyTagsLookupTableForNote也應(yīng)該在主線程中被調(diào)用。模型對象只能在主線程中被引用,所以在 block 中使用uniqueID,而不是 VSNote 對象。
注意在這種情況下,我更新了一個查詢表。Notes 和 tags 有一個多對多關(guān)系,一種表現(xiàn)方式是用一個數(shù)據(jù)庫表映射 note uniqueIDs 和 tag uniqueIDs。這些表不會很難維護,但是如果可能,我盡量避免使用它們。
注意在更新字符串中的?。-[FMDatabase executeUpdate:]是一個可變參數(shù)函數(shù)。SQLite 支持使用占位符 - ? 字符 - 所以你不需要把實際的值放入字符串中去。這是一個安全上的考量:它可以守護程序避免 SQL 注入。它也可以幫助你減少必須 escape 值這樣的不必要的麻煩。
最后,注意在 tagsNotesLookup 表中,有一個 noteUniquelID 的索引(索引是 SQLite 性能的又一個關(guān)鍵)。這行代碼在每次啟動時都調(diào)用:

數(shù)據(jù)庫獲取
要獲取對象,app 調(diào)用:

這兩行代碼做了大部分工作:

用 FMDB 查找數(shù)據(jù)庫返回一個FMResultSet. 通過 resultSet 你可以逐句循環(huán),創(chuàng)建模型對象。
我建議寫通用的代碼去將數(shù)據(jù)庫中的行轉(zhuǎn)換為對象。一種我已經(jīng)使用的方法是在 app 中用一個 plist 文件,將列的名字映射到模型對象的屬性上去。它也包含類型,所以你知道是調(diào)用-[FMResultSet dateForColumn:]還是-[FMResultSet stringForColumn:]或是其他方法。
在我的最新 app 里我做的事情更簡單。數(shù)據(jù)庫行剛好對應(yīng)模型對象屬性的名字。除了那些名字以 “Date” 結(jié)尾的屬性以外,所有屬性都是字符串。簡單,但是你可以看到所需要明顯清晰的對應(yīng)關(guān)系。
唯一對象
創(chuàng)建模型對象的操作和從數(shù)據(jù)庫獲取數(shù)據(jù)操作在同樣的后臺線程進行。一但獲取到,app 會把它們轉(zhuǎn)到主線程。
通常我會使用唯一對象。數(shù)據(jù)庫里的同一行,始終對應(yīng)著同樣的一個對象。
為了做到唯一,我使用 NSMapTable 創(chuàng)建了一個對象緩存,在 init 函數(shù)里:_objectCache = [NSMapTable weakToWeakObjectsMapTable]。我來解釋一下:
例如,當(dāng)你進行一個數(shù)據(jù)庫獲取操作并且把對象轉(zhuǎn)交給一個視圖控制器時,你希望在這個視圖控制器使用完這些對象后,或者在一個不一樣的視圖控制器被顯示后,這些對象可以消失。
如果你的對象緩存是一個NSMutableDictionary,那你將需要做一些額外的工作來清空緩存中的對象。保證它只引用了那些其他地方有引用的對象是一件非常讓人蛋疼的事情。而使用配合弱引用的NSMapTable,這個問題就被自動處理掉了。
所以:我們在主線程中讓對象唯一。如果一個對象已經(jīng)在對象緩存中存在,我們就用那個存在的對象。(因為主線程中對象可能有改變,因此在沖突時我們使用主線程的對象。)如果對象緩存中沒有,它會被加上。
保持對象在內(nèi)存中
有很多次,把整個對象類型保留在內(nèi)存中是有道理的。我最新的 app 有一個 VSTag 對象。雖然可能有成百上千篇筆記,但 tags 的數(shù)量很小,基本少于十個。一個 tag 只有 6 個屬性:三個 BOOL,兩個很小的 NSstring,還有一個 NSDate。
啟動的時候,app 獲取所有 tags 并且把它們保存在兩個字典里,其中一個的鍵是 tag 的 uniqueID,另一個的鍵是 tag 名字的小寫。
這簡化了很多事,比如 tag 自動補全系統(tǒng),就可以完全在內(nèi)存中操作,而不需要從數(shù)據(jù)庫獲取了。
但是很多次,把所有數(shù)據(jù)保留在內(nèi)存中是不實際的。比如我們不會在內(nèi)存中保留所有筆記。
但是也有很多次,把所有對象保存在內(nèi)存中是不可行的。當(dāng)不能在內(nèi)存中保留一個對象類型時,你可能會希望在內(nèi)存中保留所有 uniqueID,你可以進行這樣一個獲取操作:

resultSet 只包含了 uniqueIDs, 你可以存儲到一個 NSMutableSet 里。
我發(fā)現(xiàn)有時這個對 web APIs 很有用。想象一個 API 返回從某個確定的時間以后所創(chuàng)建筆記的 uniqueIDs 列表。如果我本地已經(jīng)有了一個包含所有筆記 uniqueIDs 的 NSMutableSet,我可以 (通過-[NSMutableSet minusSet]) 快速檢查是否有漏掉的筆記,然后去調(diào)用另一個 API 下載那些漏掉的筆記。這些完全不需要觸及數(shù)據(jù)庫。
但是,像這樣的事情應(yīng)該小心處理。app 可以提供足夠的內(nèi)存嗎?它真的簡化編程并且提高性能了嗎?
使用 SQLite 和 FMDB 來代替 Core Data,會給你帶來大量的靈活性和使用更聰明的辦法來解決問題的空間。記住有的時候聰明是好的,也有的時候聰明是一個大錯誤。
Web APIs
我的 API 調(diào)用都跑在后臺進程里(通常是用一個NSOperationQueue,這樣我可以取消操作)。模型對象只在主線程,然后將模型對象傳遞給我的 API 調(diào)用。
具體這么做:一個數(shù)據(jù)庫對象有一個detachedCopy方法,可以復(fù)制數(shù)據(jù)庫對象。這個復(fù)制的對象不會被我用來做唯一化的對象緩存所引用。唯一引用這個對象的地方是 API 調(diào)用,當(dāng) API 調(diào)用結(jié)束時,這個復(fù)制的對象也就消失了。
這是一個好的系統(tǒng),因為它意味著我可以在 API 調(diào)用里使用模型對象。方法看起來像這樣:

VSNoteAPICall 從分離出來的VSNote中獲取值,并且創(chuàng)建 HTTP 請求,而不是將 note 包裝成一個字典或其他表現(xiàn)形式。
處理 Web API 的返回值
我對 web 的返回值做了一些類似的處理。我會對返回的 JSON 或者 XML 創(chuàng)建一個模型對象,這個模型對象也是分離的。它沒有存儲在唯一化模型緩存里。
這里有些事情是不確定的。有時我們需要用那個模型對象在在內(nèi)存緩存以及數(shù)據(jù)庫兩個地方做本地修改。
數(shù)據(jù)庫通常是容易的部分。比如:我的 app 已經(jīng)有一個方法來保存筆記對象。它使用 SQL 的insert or replace命令。我只需用從 web API 返回值所生成的筆記對象來進行調(diào)用,數(shù)據(jù)庫就會更新。
但是可能同樣的對象在內(nèi)存中還有一個版本,幸運的是我們很容易找到它:

如果 cachedNote 存在,我會讓它從 downloadedNote中 獲取值(這部分可以共享detachedCopy方法的代碼。),而不是直接替換它(這樣可能違反唯一性)。
一旦 cachedNote 更新了,觀察者會通過 KVO 察覺到變化,或者我會發(fā)送一個NSNotification,或者兩者都做。
Web API 調(diào)用也會返回一些其他值。我提到過 RSS 閱讀器可能獲得一個已讀條目的大列表。這種情況下,我選擇通過那個列表創(chuàng)建一個NSSet,在內(nèi)存的緩存中更新每一個緩存文章的read屬性,然后調(diào)用-[FMDatabase executeUpdate:]。
完成這個工作的關(guān)鍵是NSMapTable的查找是快速的。如果你找的對象在一個 NSArray 里,我們就得重新考慮考慮了。
數(shù)據(jù)庫遷移
當(dāng)正常工作的時候,Core Data 的數(shù)據(jù)庫遷移功能還是蠻酷的。
但是不可避免的,它在代碼和數(shù)據(jù)庫中加入了一層。如果你更直接一點,去使用 SQLite,那么更新數(shù)據(jù)庫也就變得越直接。
你可以安全容易的做到這點。
比如加一個表:

或添加一個索引

或添加一列:

app 應(yīng)該用類似上面這樣的代碼來首先對數(shù)據(jù)庫進行設(shè)置。以后的改變就是添加對 executeUpdate 的調(diào)用 — 我讓他們按順序執(zhí)行。因為我的數(shù)據(jù)庫是我設(shè)計的,所以這不會有什么問題(我從沒碰到性能問題,它很快)。
當(dāng)然大的改變需要更多代碼。如果你的數(shù)據(jù)通過 web 獲取,有時你可以從一個新數(shù)據(jù)庫模型開始,重新下載你需要的數(shù)據(jù)。
性能技巧
SQLite 可以非常非???,但是也可以非常慢。完全取決于你怎么使用它。
事務(wù)
把更新包裝在事務(wù)里。在更新前調(diào)用-[FMDatabase beginTransaction],更新后調(diào)用-[FMDatabase commit]。
如果你不得不反規(guī)范化( Denormalize)
反規(guī)范化讓人很不爽。這個方法是,為了加速檢索而添加冗余數(shù)據(jù),但是它意味著你需要維護冗余數(shù)據(jù)。
我總是盡力避免它,直到這樣能有嚴重的性能差異。然后我會盡可能少得這么做。
使用索引
我的 app 中 tags 表的創(chuàng)建語句像這樣:

uniqueID 列是自動索引的,因為它定義為 unique。但是如果我想用 name 來查詢表,我可能會在name上創(chuàng)建一個索引,像這樣:

你可以一次性在多列上創(chuàng)建索引,像這樣:

但是注意太多索引會降低你的插入速度。你只需要足夠數(shù)量并且是正確的那些。
使用命令行應(yīng)用
當(dāng)我的 app 在模擬器里運行時,我會用NSLog輸出數(shù)據(jù)庫的路徑。我可以通過 sqlite3 的命令行來打開數(shù)據(jù)庫。(通過 man sqlite3 命令來了解這個應(yīng)用的更多信息)。
打開數(shù)據(jù)庫的命令:sqlite3 path/to/database。
打開以后,你可以輸入.schema來查看 schema。
你可以更新和查詢,這是在你的 app 使用 SQL 之前就將它們正確地準備妥當(dāng)?shù)暮芎玫姆绞健?/p>
這里面最酷的一部分是,SQLite Explain Query Plan 命令,你會希望確保你的語句執(zhí)行的盡可能快。
真實的例子
我的 app 顯示所有沒有歸檔筆記的標(biāo)簽列表。每當(dāng)筆記或者標(biāo)簽有變化,這個查詢就會重新執(zhí)行一次,所以它需要很快。
我可以用SQL join來查詢,但是這會很慢(join 都很慢)。
所以我放棄 sqlite3 并開始嘗試別的方法。我又檢查了一次我的 schema,意識到我可以反規(guī)范化。一個筆記的歸檔狀態(tài)可以存儲在 notes 表里,它也可以存儲在 tagsNotesLookup 表。
然后我可以執(zhí)行一個查詢:

我已經(jīng)有了一個在 tagUniqueID 上的索引。所以我用 explain query plan 來告訴我當(dāng)我執(zhí)行這個查詢的時候會發(fā)生什么。

它用了一個索引,這很不錯,但是 SCAN TABLE 聽起來不太好,最好是一個 SEARCH TABLE 加上覆蓋索引的方式。
我在 tagUniqueID 和 archive 上建了索引:

再次執(zhí)行 explain query plan:

現(xiàn)在好多了。
更多性能提示
FMDB 的某處加了緩存 statements 的能力,所以當(dāng)創(chuàng)建或打開一個數(shù)據(jù)庫的時候,我總是調(diào)用[self.database setShouldCacheStatements:YES]。這意味著對每個調(diào)用你不需要再次編譯每個 statement。
我從來沒有找到關(guān)于使用vacuum的好的指引。如果數(shù)據(jù)庫沒有定期壓縮,它會變得越來越慢。我的 app 會每周跑一次 vacuum。(在 NSUserDefaults 里存儲上次 vacuum 的時間,然后在開始的時候檢查是否過了一周)。
使用auto_vacuum可能會更好,可以參看pragma statements supported by SQLite列表。
其他酷的東西
Gus Mueller 讓我講講自定義 SQLite 方法的內(nèi)容。我并沒有真的使用過這些東西,不過既然他指出了,我可以放心的說我能找到它的用處。因為它很酷。
在 Gus 的這個 gist 里,有一個查詢是這樣的:

SQLite 完全不知道 UTTypes 的事情。但是你可以通過代碼塊來添加核心方法,感興趣的話,可以看看-[FMDatabase makeFunctionNamed:maximumArguments:withBlock:]方法。
你可以執(zhí)行一個大的查詢來替代,然后評估每個對象 - 但是那需要更多工作。最好在 SQL 級就過濾,而不是在將表格行轉(zhuǎn)為對象以后再做這件事情。
最后
你真的應(yīng)該使用 Core Data,我不是在開玩笑。
我用 SQLite 和 FMDB 一段時間了,我對多得到的好處感到很興奮,也得到非同一般的性能。
但是記住設(shè)備在不斷變快。也請記住,其他看你代碼的人期望看到 Core Data,這是他們已經(jīng)了解的 - 他們不打算看你的數(shù)據(jù)庫代碼如何工作。
所以請把這整篇文章看做一個瘋子的叫喊,關(guān)于他為自己建立了充滿細節(jié)又瘋狂的世界 - 并把自己鎖在了里面。
有點難過的搖頭,并且請享受這個話題下那些超贊的 Core Data 的文章吧。
而對我來說,接下來在研究完 Gus 指出的自定義 SQLite 方法特性后,我會研究 SQLite 的全文搜索擴展。 總有更多的內(nèi)容需要不斷去學(xué)習(xí)。