1. 程式人生 > >用 SQLite 和 FMDB 替代 Core Data

用 SQLite 和 FMDB 替代 Core Data

憑良心講,我不能告訴你不去使用 Core Data。它不錯,而且也在變得更好,並且它被很多其他 Cocoa 開發者所理解,當有新人加入你的團隊或者需要別人接手你的 app 的時候,這點很重要。

更重要的是,不值得花時間和精力去寫自己的系統去代替它。使用 Core Data 吧。真的。

為什麼我不使用Core Data

就個人而言,我不是個狂熱粉絲。我發現 (Core Data 的) API 是笨拙的,並且框架本身對於超過一定數量級的資料的處理是極其緩慢的。

一個實際的例子:10,000 個條目

想象一個 RSS 閱讀器,一個使用者可以在一個 feed 上點選右鍵,並且選擇標記所有為已讀。

實際實現上,我們有一個帶有 read

屬性的 Article 實體。把所有條目標記為已讀,app 需要載入這個 feed 的所有文章 (可能通過一對多的關係),然後設定 read 屬性為 YES。

大部分時候這樣是沒問題的。但是設想那個 feed 有 200 篇文章,為了避免阻塞主執行緒,你可能考慮在後臺執行緒裡做這個工作 (尤其是如果這個 app 是一個 iPhone app)。一旦你開始使用 Core Data 多執行緒的時候,事情就開始變得不好處理了。

這可能還沒這麼糟糕,至少不值得拋棄使用 Core Data。

但是,再新增同步。

我用過兩個不同的 RSS 同步 API,它們返回已讀文章的 uniqueID 陣列。其中一個返回近 10,000 個 ID。

你不會打算在主執行緒中載入 10,000 篇文章,然後設定 read 為 NO。你大概也不會想在後臺執行緒里加載 10,000 篇文章,即使很小心地管理記憶體。這裡有太多的工作(如果你頻繁的這麼做,想一下對電池壽命的影響)。

概念上來說,你真正想要做的是,讓資料庫將 uniqueID 列表裡的每一篇文章的 read 設定為 YES。

SQLite 可以做到這個,只用一次呼叫。如果 uniqueID 上有索引,這會很快。而且你可以在後臺執行緒執行,這和在主執行緒執行一樣容易。

另一個例子:快速啟動

我的另一個 app,我想減少啟動時間 — 不只是 app 的啟動時間,還有資料顯示之前所需要的時間。

這是個類似 Twitter 的 app (雖然它不是):它顯示訊息的時間軸。顯示時間軸意味著獲取訊息,並載入相關使用者。它很快,但是在啟動的時候,會填充 UI,然後

填充資料。

關於 iPhone app(或者所有應用),我的理論是,啟動時間比其他大部分開發者想的都要重要。啟動時間很慢的 app 是不太可能被啟動的,因為人們潛意識裡會記住,並且在啟動那個應用這件事情上形成一種抵抗心理。減少啟動時間可以減少這種阻力,使用者也會更願意使用你的應用,並且把它推薦給其他人。這是你讓你的 app 成功的一部分。

因為我不使用 Core Data,我手頭有一個簡單的,保守的解決方案。我把時間軸(訊息和人物物件)通過 NSCoding 儲存到一個 plist 檔案中。啟動的時候它讀取這個檔案,建立訊息和人物物件,UI 一出現就顯示時間軸。

這明顯的減少了延遲。

把訊息和人物物件作為 NSManagedObject 的例項物件,這是不可能的。(假設我已經編碼並且儲存物件的 IDs,但是那意味著讀取 plist 檔案,之後再涉及資料庫。這種方式我完全避免了資料庫)。

(在更新更快的機器出來後, 我去掉了那些程式碼。回顧過去,我希望我可以把它留下來。)

我怎麼考慮這個問題

當考慮是否使用 Core Data,我考慮下面這些事情:

會有難以置信數量的資料嗎?

對於一個 RSS 閱讀器或者 Twitter app,答案顯而易見:是的。有些人關注上百個人。一個人可能訂閱了上千個 feed。

即使你的應用不從網路獲取資料,使用者仍然有可能自動新增資料。如果你用一個支援 AppleScript 的 Mac,有人會寫指令碼去載入非常多的資料。如果通過 web API 去新增資料也是一樣的。

會有一個 Web API 包含類似於資料庫的結果嗎(對比於類似物件的結果)?

一個 RSS 同步 API 能夠返回一個已讀文章的 uniqueID 列表。一個筆記的應用的一個同步 API 可能返回已存檔的和已刪除的筆記的 uniqueID 列表。

使用者可能通過操作處理大量物件嗎?

在底層,需要考慮和之前一樣的問題。當有人刪除所有已經下載的 5,000 個麵食食譜,你的食譜 app 效能如何?(在 iPhone 上?)

如果我決定使用 Core Data(我已經發布過使用 Core Data 的應用),我會特別注意我如何使用它。結果為了得到好的效能,我發現我把它當做了一個奇怪介面的 SQL 資料庫在使用,然後我就知道了,我應該捨棄 Core Data,而去直接使用 SQLite。

我如何使用 SQLite

我通過 FMDB Wrapper 來使用 SQLite,FMDB 來自 Flying Meat Software,由 Gus Mueller 開發。

基本操作

在使用 iPhone 和 Core Data 之前,我就使用過 SQLite。這裡有關於它如何工作的要點:

  • 所有資料庫訪問 - 讀和寫 - 發生在一個後臺執行緒的連續的佇列裡。在主執行緒中觸及資料庫是從來不被允許的。使用一個連續佇列來保證每一件事是按順序發生的。
  • 我大量使用 blocks 使得非同步程式設計容易些。
  • 模型物件只存在在主執行緒(但有兩個重要的例外),改變會觸發一個後臺儲存。
  • 模型物件列出來它們在資料庫中儲存的屬性。這可能在程式碼裡或者在 plist 檔案裡。
  • 有些模型物件是唯一的,有些不是。取決於 app 的需要(大部分情況是唯一的)。
  • 對關係型資料,我儘可能避免建立查詢表。
  • 一些物件型別在啟動的時候就完全讀入記憶體,另一些物件型別我可能建立和維護的只有它們 uniqueID 的一個 NSMutableSet,所以我可以在不去碰資料庫的情況下就知道什麼存在、什麼不存在。
  • Web API 的呼叫發生在後臺執行緒,它們使用“分離“的模型物件。

我會使用我目前的 app 的程式碼來描述。

資料庫更新

在我最近的 app 中,有一個單一的資料庫控制器 - VSDatabaseController,它通過 FMDB 來與 SQLite 對話。

FMDB 區分更新和查詢。更新資料庫,app 呼叫:

-[VSDatabaseController runDatabaseBlockInTransaction:(VSDatabaseUpdateBlock)databaseBlock]

VSDatabaseUpdateBlock很簡單:

typedef void (^VSDatabaseUpdateBlock)(FMDatabase *database);

runDatabaseBlockInTransaction也很簡單:

- (void)runDatabaseBlockInTransaction:(VSDatabaseUpdateBlock)databaseBlock {
    dispatch_async(self.serialDispatchQueue, ^{
        @autoreleasepool {
            [self beginTransaction];
            databaseBlock(self.database);
            [self endTransaction];
        }
    });
}

(注意我用的自己的連續 dispatch 佇列。Gus 建議看一下 FMDatabaseQueue,這也是一個連續排程佇列。因為它比 FMDB 剩下的其他東西都要新,所以我自己還沒有去看過。)

beginTransactionendTransaction 的呼叫是可巢狀的(在我的資料庫控制器裡)。在合適的時候他們會呼叫 -[FMDatabase beginTransaction]-[FMDatabase commit]。(使用 transactions 是讓 SQLite 變快的一大關鍵。)提示:我在 -[NSThread threadDictionary] 中儲存當前的 transaction 的計數。這對於針對每個執行緒的資料來說是很方便的,我也幾乎從不用它做其他的事情。

這兒有個呼叫更新資料庫的簡單例子:

- (void)emptyTagsLookupTableForNote:(VSNote *)note {
    NSString *uniqueID = note.uniqueID;
    [self runDatabaseBlockInTransaction:^(FMDatabase *database) {
        [database executeUpdate:
            @"delete from tagsNotesLookup where noteUniqueID = ?;", uniqueID];
    }];
}

這說明了不少事情。首先, SQL 並不可怕。即使你從沒見過它,你也知道這行程式碼做了什麼。

VSDatabaseController 的所有其他公共介面一樣,emptyTagsLookupTableForNote 也應該在主執行緒中被呼叫。模型物件只能在主執行緒中被引用,所以在 block 中使用 uniqueID ,而不是 VSNote 物件。

注意在這種情況下,我更新了一個查詢表。Notes 和 tags 有一個多對多關係,一種表現方式是用一個數據庫表對映 note uniqueIDs 和 tag uniqueIDs。這些表不會很難維護,但是如果可能,我儘量避免使用它們。

注意在更新字串中的 ?-[FMDatabase executeUpdate:] 是一個可變引數函式。SQLite 支援使用佔位符 - ? 字元 - 所以你不需要把實際的值放入字串中去。這是一個安全上的考量:它可以守護程式避免 SQL 注入。它也可以幫助你減少必須 escape 值這樣的不必要的麻煩。

最後,注意在 tagsNotesLookup 表中,有一個 noteUniqueID 的索引(索引是 SQLite 效能的又一個關鍵)。這行程式碼在每次啟動時都呼叫:

[self.database executeUpdate:
    @"CREATE INDEX if not exists noteUniqueIDIndex on tagsNotesLookup (noteUniqueID);"];

資料庫獲取

要獲取物件,app 呼叫:

-[VSDatabaseController runFetchForClass:(Class)databaseObjectClass 
                             fetchBlock:(VSDatabaseFetchBlock)fetchBlock 
                      fetchResultsBlock:(VSDatabaseFetchResultsBlock)fetchResultsBlock];

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

FMResultSet *resultSet = fetchBlock(self.database);
NSArray *fetchedObjects = [self databaseObjectsWithResultSet:resultSet 
                                                       class:databaseObjectClass];

用 FMDB 查詢資料庫返回一個 FMResultSet. 通過 resultSet 你可以逐句迴圈,建立模型物件。

我建議寫通用的程式碼去將資料庫中的行轉換為物件。一種我已經使用的方法是在 app 中用一個 plist 檔案,將列的名字對映到模型物件的屬性上去。它也包含型別,所以你知道是呼叫 -[FMResultSet dateForColumn:]還是 -[FMResultSet stringForColumn:]或是其他方法。

在我的最新 app 裡我做的事情更簡單。資料庫行剛好對應模型物件屬性的名字。除了那些名字以 “Date” 結尾的屬性以外,所有屬性都是字串。簡單,但是你可以看到所需要明顯清晰的對應關係。

唯一物件

建立模型物件的操作和從資料庫獲取資料操作在同樣的後臺執行緒進行。一但獲取到,app 會把它們轉到主執行緒。

通常我會使用唯一物件。資料庫裡的同一行,始終對應著同樣的一個物件。

為了做到唯一,我使用 NSMapTable 建立了一個物件快取,在 init 函式裡:_objectCache = [NSMapTable weakToWeakObjectsMapTable]。我來解釋一下:

例如,當你進行一個數據庫獲取操作並且把物件轉交給一個檢視控制器時,你希望在這個檢視控制器使用完這些物件後,或者在一個不一樣的檢視控制器被顯示後,這些物件可以消失。

如果你的物件快取是一個 NSMutableDictionary,那你將需要做一些額外的工作來清空快取中的物件。保證它只引用了那些其他地方有引用的物件是一件非常讓人蛋疼的事情。而使用配合弱引用的NSMapTable,這個問題就被自動處理掉了。

所以:我們在主執行緒中讓物件唯一。如果一個物件已經在物件快取中存在,我們就用那個存在的物件。(因為主執行緒中物件可能有改變,因此在衝突時我們使用主執行緒的物件。)如果物件快取中沒有,它會被加上。

保持物件在記憶體中

有很多次,把整個物件型別保留在記憶體中是有道理的。我最新的 app 有一個 VSTag 物件。雖然可能有成百上千篇筆記,但 tags 的數量很小,基本少於十個。一個 tag 只有 6 個屬性:三個 BOOL,兩個很小的 NSstring,還有一個 NSDate。

啟動的時候,app 獲取所有 tags 並且把它們儲存在兩個字典裡,其中一個的鍵是 tag 的 uniqueID,另一個的鍵是 tag 名字的小寫。

這簡化了很多事,比如 tag 自動補全系統,就可以完全在記憶體中操作,而不需要從資料庫獲取了。

但是很多次,把所有資料保留在記憶體中是不實際的。比如我們不會在記憶體中保留所有筆記。

但是也有很多次,把所有物件儲存在記憶體中是不可行的。當不能在記憶體中保留一個物件型別時,你可能會希望在記憶體中保留所有 uniqueID,你可以進行這樣一個獲取操作:

FMResultSet *resultSet = [self.database executeQuery:@"select uniqueID from some_table"];

resultSet 只包含了 uniqueIDs, 你可以儲存到一個 NSMutableSet 裡。

我發現有時這個對 web APIs 很有用。想象一個 API 返回從某個確定的時間以後所建立筆記的 uniqueIDs 列表。如果我本地已經有了一個包含所有筆記 uniqueIDs 的 NSMutableSet,我可以 (通過 -[NSMutableSet minusSet]) 快速檢查是否有漏掉的筆記,然後去呼叫另一個 API 下載那些漏掉的筆記。這些完全不需要觸及資料庫。

但是,像這樣的事情應該小心處理。app 可以提供足夠的記憶體嗎?它真的簡化程式設計並且提高效能了嗎?

使用 SQLite 和 FMDB 來代替 Core Data,會給你帶來大量的靈活性和使用更聰明的辦法來解決問題的空間。記住有的時候聰明是好的,也有的時候聰明是一個大錯誤。

Web APIs

我的 API 呼叫都跑在後臺程序裡(通常是用一個 NSOperationQueue,這樣我可以取消操作)。模型物件只在主執行緒,然後將模型物件傳遞給我的 API 呼叫。

具體這麼做:一個數據庫物件有一個 detachedCopy 方法,可以複製資料庫物件。這個複製的物件不會被我用來做唯一化的物件快取所引用。唯一引用這個物件的地方是 API 呼叫,當 API 呼叫結束時,這個複製的物件也就消失了。

這是一個好的系統,因為它意味著我可以在 API 呼叫裡使用模型物件。方法看起來像這樣:

- (void)uploadNote:(VSNote *)note {
    VSNoteAPICall *apiCall = [[VSNoteAPICall alloc] initWithNote:[note detachedCopy]];
    [self enqueueAPICall:apiCall];
}

VSNoteAPICall 從分離出來的 VSNote 中獲取值,並且建立 HTTP 請求,而不是將 note 包裝成一個字典或其他表現形式。

處理 Web API 的返回值

我對 web 的返回值做了一些類似的處理。我會對返回的 JSON 或者 XML 建立一個模型物件,這個模型物件也是分離的。它沒有儲存在唯一化模型快取裡。

這裡有些事情是不確定的。有時我們需要用那個模型物件在在記憶體快取以及資料庫兩個地方做本地修改。

資料庫通常是容易的部分。比如:我的 app 已經有一個方法來儲存筆記物件。它使用 SQL 的 insert or replace 命令。我只需用從 web API 返回值所生成的筆記物件來進行呼叫,資料庫就會更新。

但是可能同樣的物件在記憶體中還有一個版本,幸運的是我們很容易找到它:

VSNote *cachedNote = [self.mapTable objectForKey:downloadedNote.uniqueID];

如果 cachedNote 存在,我會讓它從 downloadedNote中 獲取值(這部分可以共享 detachedCopy 方法的程式碼。),而不是直接替換它(這樣可能違反唯一性)。

一旦 cachedNote 更新了,觀察者會通過 KVO 察覺到變化,或者我會發送一個 NSNotification,或者兩者都做。

Web API 呼叫也會返回一些其他值。我提到過 RSS 閱讀器可能獲得一個已讀條目的大列表。這種情況下,我選擇通過那個列表建立一個 NSSet,在記憶體的快取中更新每一個快取文章的 read 屬性,然後呼叫 -[FMDatabase executeUpdate:]

完成這個工作的關鍵是 NSMapTable 的查詢是快速的。如果你找的物件在一個 NSArray 裡,我們就得重新考慮考慮了。

資料庫遷移

當正常工作的時候,Core Data 的資料庫遷移功能還是蠻酷的。

但是不可避免的,它在程式碼和資料庫中加入了一層。如果你更直接一點,去使用 SQLite,那麼更新資料庫也就變得越直接。

你可以安全容易的做到這點。

比如加一個表:

[self.database executeUpdate:@"CREATE TABLE if not exists tags "
    "(uniqueID TEXT UNIQUE, name TEXT, deleted INTEGER, deletedModificationDate DATE);"];

或新增一個索引

[self.database executeUpdate:@"CREATE INDEX if not exists "
    "archivedSortDateIndex on notes (archived, sortDate);"];

或新增一列:

[self.database executeUpdate:@"ALTER TABLE tags ADD deletedDate DATE"];

app 應該用類似上面這樣的程式碼來首先對資料庫進行設定。以後的改變就是新增對 executeUpdate 的呼叫 — 我讓他們按順序執行。因為我的資料庫是我設計的,所以這不會有什麼問題(我從沒碰到效能問題,它很快)。

當然大的改變需要更多程式碼。如果你的資料通過 web 獲取,有時你可以從一個新資料庫模型開始,重新下載你需要的資料。

效能技巧

SQLite 可以非常非常快,但是也可以非常慢。完全取決於你怎麼使用它。

事務

把更新包裝在事務裡。在更新前呼叫 -[FMDatabase beginTransaction],更新後呼叫 -[FMDatabase commit]

如果你不得不反規範化( Denormalize)

反規範化讓人很不爽。這個方法是,為了加速檢索而新增冗餘資料,但是它意味著你需要維護冗餘資料。

我總是盡力避免它,直到這樣能有嚴重的效能差異。然後我會盡可能少得這麼做。

使用索引

我的 app 中 tags 表的建立語句像這樣:

CREATE TABLE if not exists tags 
  (uniqueID TEXT UNIQUE, name TEXT, deleted INTEGER, deletedModificationDate DATE);

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

CREATE INDEX if not exists tagNameIndex on tags (name);

你可以一次性在多列上建立索引,像這樣:

CREATE INDEX if not exists archivedSortDateIndex on notes (archived, sortDate);

但是注意太多索引會降低你的插入速度。你只需要足夠數量並且是正確的那些。

使用命令列應用

當我的 app 在模擬器裡執行時,我會用 NSLog 輸出資料庫的路徑。我可以通過 sqlite3 的命令列來開啟資料庫。(通過 man sqlite3 命令來了解這個應用的更多資訊)。

開啟資料庫的命令:sqlite3 path/to/database

開啟以後,你可以輸入 .schema 來檢視 schema。

你可以更新和查詢,這是在你的 app 使用 SQL 之前就將它們正確地準備妥當的很好的方式。

真實的例子

我的 app 顯示所有沒有歸檔筆記的標籤列表。每當筆記或者標籤有變化,這個查詢就會重新執行一次,所以它需要很快。

我可以用 SQL join 來查詢,但是這會很慢(join 都很慢)。

所以我放棄 sqlite3 並開始嘗試別的方法。我又檢查了一次我的 schema,意識到我可以反規範化。一個筆記的歸檔狀態可以儲存在 notes 表裡,它也可以儲存在 tagsNotesLookup 表。

然後我可以執行一個查詢:

select distinct tagUniqueID from tagsNotesLookup where archived=0;

我已經有了一個在 tagUniqueID 上的索引。所以我用 explain query plan 來告訴我當我執行這個查詢的時候會發生什麼。

sqlite> explain query plan select distinct tagUniqueID from tagsNotesLookup where archived=0;
0|0|0|SCAN TABLE tagsNotesLookup USING INDEX tagUniqueIDIndex (~100000 rows)

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

我在 tagUniqueID 和 archive 上建了索引:

CREATE INDEX archivedTagUniqueID on tagsNotesLookup(archived, tagUniqueID);

再次執行 explain query plan:

sqlite> explain query plan select distinct tagUniqueID from tagsNotesLookup where archived=0;
0|0|0|SEARCH TABLE tagsNotesLookup USING COVERING INDEX archivedTagUniqueID (archived=?) (~10 rows)

現在好多了。

更多效能提示

FMDB 的某處加了快取 statements 的能力,所以當建立或開啟一個數據庫的時候,我總是呼叫 [self.database setShouldCacheStatements:YES]。這意味著對每個呼叫你不需要再次編譯每個 statement。

我從來沒有找到關於使用 vacuum 的好的指引。如果資料庫沒有定期壓縮,它會變得越來越慢。我的 app 會每週跑一次 vacuum。(在 NSUserDefaults 裡儲存上次 vacuum 的時間,然後在開始的時候檢查是否過了一週)。

其他酷的東西

Gus Mueller 讓我講講自定義 SQLite 方法的內容。我並沒有真的使用過這些東西,不過既然他指出了,我可以放心的說我能找到它的用處。因為它很酷。

select displayName, key from items where UTTypeConformsTo(uti, ?) order by 2;

SQLite 完全不知道 UTTypes 的事情。但是你可以通過程式碼塊來新增核心方法,感興趣的話,可以看看 -[FMDatabase makeFunctionNamed:maximumArguments:withBlock:] 方法。

你可以執行一個大的查詢來替代,然後評估每個物件 - 但是那需要更多工作。最好在 SQL 級就過濾,而不是在將表格行轉為物件以後再做這件事情。

最後

你真的應該使用 Core Data,我不是在開玩笑。

我用 SQLite 和 FMDB 一段時間了,我對多得到的好處感到很興奮,也得到非同一般的效能。

但是記住裝置在不斷變快。也請記住,其他看你程式碼的人期望看到 Core Data,這是他們已經瞭解的 - 他們不打算看你的資料庫程式碼如何工作。

所以請把這整篇文章看做一個瘋子的叫喊,關於他為自己建立了充滿細節又瘋狂的世界 - 並把自己鎖在了裡面。

有點難過的搖頭,並且請享受這個話題下那些超讚的 Core Data 的文章吧。

而對我來說,接下來在研究完 Gus 指出的自定義 SQLite 方法特性後,我會研究 SQLite 的 全文搜尋擴充套件。 總有更多的內容需要不斷去學習。