天天看點

用 SQLite 和 FMDB 替代 Core Data

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

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

為什麼我不使用Core Data

Mike Ash 寫到:

就個人而言,我不是個狂熱粉絲。我發現 (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 調用:

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 剩下的其他東西都要新,是以我自己還沒有去看過。)

beginTransaction

 和 

endTransaction

 的調用是可嵌套的(在我的資料庫控制器裡)。在合适的時候他們會調用 

-[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 表中,有一個 noteUniquelID 的索引(索引是 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上建立一個索引,像這樣:

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

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

使用指令行應用

當我的 app 在模拟器裡運作時,我會用 

NSLog

 輸出資料庫的路徑。我可以通過 sqlite3 的指令行來打開資料庫。(通過 man sqlite3 指令來了解這個應用的更多資訊)。

打開資料庫的指令:

sqlite3 path/to/database

打開以後,你可以輸入 

.schema

 來檢視 schema。

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

這裡面最酷的一部分是,SQLite Explain Query Plan 指令,你會希望確定你的語句執行的盡可能快。

真實的例子

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

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

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

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

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

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

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

我在 tagUniqueID 和 archive 上建了索引:

再次執行 explain query plan:

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

現在好多了。

更多性能提示

FMDB 的某處加了緩存 statements 的能力,是以當建立或打開一個資料庫的時候,我總是調用 

[self.database setShouldCacheStatements:YES]

。這意味着對每個調用你不需要再次編譯每個 statement。

我從來沒有找到關于使用 

vacuum

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

使用 

auto_vacuum

 可能會更好,可以參看 pragma statements supported by SQLite 清單。

其他酷的東西

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

在 Gus 的這個 gist 裡,有一個查詢是這樣的:

SQLite 完全不知道 UTTypes 的事情。但是你可以通過代碼塊來添加核心方法,感興趣的話,可以看看 

-[FMDatabase makeFunctionNamed:maximumArguments:withBlock:]

 方法。

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

最後

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

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

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

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

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

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

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

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

為什麼我不使用Core Data

Mike Ash 寫到:

就個人而言,我不是個狂熱粉絲。我發現 (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 調用:

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 剩下的其他東西都要新,是以我自己還沒有去看過。)

beginTransaction

 和 

endTransaction

 的調用是可嵌套的(在我的資料庫控制器裡)。在合适的時候他們會調用 

-[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 表中,有一個 noteUniquelID 的索引(索引是 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上建立一個索引,像這樣:

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

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

使用指令行應用

當我的 app 在模拟器裡運作時,我會用 

NSLog

 輸出資料庫的路徑。我可以通過 sqlite3 的指令行來打開資料庫。(通過 man sqlite3 指令來了解這個應用的更多資訊)。

打開資料庫的指令:

sqlite3 path/to/database

打開以後,你可以輸入 

.schema

 來檢視 schema。

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

這裡面最酷的一部分是,SQLite Explain Query Plan 指令,你會希望確定你的語句執行的盡可能快。

真實的例子

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

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

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

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

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

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

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

我在 tagUniqueID 和 archive 上建了索引:

再次執行 explain query plan:

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

現在好多了。

更多性能提示

FMDB 的某處加了緩存 statements 的能力,是以當建立或打開一個資料庫的時候,我總是調用 

[self.database setShouldCacheStatements:YES]

。這意味着對每個調用你不需要再次編譯每個 statement。

我從來沒有找到關于使用 

vacuum

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

使用 

auto_vacuum

 可能會更好,可以參看 pragma statements supported by SQLite 清單。

其他酷的東西

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

在 Gus 的這個 gist 裡,有一個查詢是這樣的:

SQLite 完全不知道 UTTypes 的事情。但是你可以通過代碼塊來添加核心方法,感興趣的話,可以看看 

-[FMDatabase makeFunctionNamed:maximumArguments:withBlock:]

 方法。

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

最後

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

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

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

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

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

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