在 iOS 的 SQLite 數據庫中應用 FMDB 庫
在一款應用中,操作數據庫和處理數據通常都是一個重要而且關鍵的部分。幾個月前我所寫的一篇文章曾經談到過這個話題,那篇文章講解了如何使用 SwiftyDB 來管理 SQLite 數據庫。我今天再來探討數據庫的話題,不過這次我要介紹另一個你可能已經知道的庫: FMDB 。
這兩個庫的目標是相同的:處理 SQLite 數據庫,并有效地管理你的應用中的數據。然而,二者在使用方式上存在差異。SwifytDB 提供了一個高級的編程 API,隱藏了所有的 SQL 細節以及幕后的其他高級操作,而 FMDB 通過更低級的 API 為用戶提供了細粒度的數據處理方式。它同樣“隱藏”了程序在幕后與 SQLite 數據庫連接和通信的細節,畢竟這些東西很無聊;大多數程序員想要的是編寫自定義的查詢以及對數據執行操作。但是一般來說,評價一個東西優于另一個東西是分情況的,并且總是取決于應用的類型和目的。所以,它們都是偉大的工具,并且可以完美滿足我們的需要。
現在把注意力全部集中到 FMDB 上來,它實際上是一個 SQLite 的包裝器,這意味著可以讓我們在更高的抽象層訪問 SQLite 特性,我們不必處理鏈接方面的東西以及實際的數據庫讀寫操作。對開發人員來說,使用自己的 SQL 知識并編寫自己的 SQL 查詢,而不必編寫自己的 SQLite 管理器,這是最好的選擇。它有 Objective-C 和 Swift 版本,并且由于將它集成到項目中非常快,并不會影響開發進度。
接下來,我們將實現一個小型的示例應用,通過其中的一些簡單示例展示 FMDB 庫的用法。我們將通過編程方式創建一個新的數據庫,這會接觸到所有的常規數據操作:插入、更新、刪除和選擇。更多的有關信息,我鼓勵你查看原始的 Github 頁面。當然,因為我將要討論的是一個數據庫相關的主題,假設你有一定的 SQL 語言基礎,不然需要先去熟悉它,然后再繼續。
無論如何,如果你是一個和我一樣的數據庫愛好者,那只需跟上我的腳步,下面將看到一些有趣的東西!
示例應用預覽
在這個教程中,我們的示例應用將顯示一個電影列表,電影的詳細信息可以在一個新的視圖控制器中展示(是的,我知道,之前使用過電影作樣本數據,但是 IMDB 的數據資源的質量實在是太高了)。除了詳細信息,我們還可以將電影標記為 已觀看 ,并標注 喜歡 的程度(范圍從 0 到 3)。
電影的數據將存儲在 SQLite 數據庫中,當然了我們會使用 FMDB 庫管理它們。電影的初始數據將通過已經準備好的制表符分隔文件(.tsv)插入到數據庫中。 我們關注的是重點數據庫,因此我已經準備好了一個 初始工程 ,在繼續之前你可以前去獲取。在這個初始項目中,你會發現項目中已經做好了默認的應用程序實現,以及原始的 .tsv 文件,我們將使用它來獲取電影的初始數據。
有關示例應用的更多細節,我首先要說的是,它是一個基于導航的應用程序,有兩個視圖控制器:第一個名為 MoviesViewController ,包含一個 tableview,我們將在其中展示每個電影的標題和一張海報圖片(共有二十部電影)。為了演示記錄,電影圖像不做本地存儲; 在運行時,當需要顯示列表時,異步獲取這些圖片。我們稍后會看到。點擊一個電影的 Cell,會展示名為 MovieDetailsViewController 的第二個視圖控制器。每部電影需要展示的詳細信息如下:
- 圖片
- 標題——它會被設計為一個按鈕,點擊后會在 Safari 中打開 IMDB 中該電影的網頁
- 分類
- 年代
除此之外,我們還有一個開關,用于指示是否觀看過了某部電影,以及一個步進器,用以增加或減少對某部電影的喜歡數。更新后的電影詳情將顯式存儲到數據庫中。
另外,在 MoviesViewController.swift 文件中,你還將找到一個名為 MovieInfo 的結構體。它的屬性與我們將在數據庫中維護的表的字段相匹配,程序中 MovieInfo 類型的對象將用來表示電影。我不會在這里討論數據庫和我們的工作,因為很快就會接觸到所有細節。再次提醒,我們會見到所有可完成的操作:數據庫的創建(以編程的方式),數據插入、更新、刪除和選擇。我們將保持形式上的簡單,但是我們所展示的所有情況都可以在更大的規模上應用。
因此,一旦你下載了初始項目并且已經開始獨自摸索了,那么請繼續閱讀。我們首先將 FMDB 庫加到初始工程中,稍后我們將看到每個數據庫操作是如何實現和工作的。此外,我們將看到一些可以使你的開發者生活變輕松的最佳實踐。
在你的 Swift 工程中集成 FMDB
在項目中集成 FMDB 庫最常用的方法是用 CocoaPods 進行安裝。然而,尤其是對 Swift 項目來說,下載庫的 zip 文件,然后把特定的文件添加到項目中的方式更加快捷。你會被要求添加一個橋接頭文件,因為 FMDB 庫是用 Objective-C 編寫的,橋接文件是兩種語言一起工作所必需的。
讓我們來看一些細節。首先在瀏覽器中打開我上面給你提供的鏈接。在靠近右上角的地方有一個標題為 “ Clone or download ” 的綠色按鈕。點擊它,你會看到另一個寫著 “ Download ZIP ” 的按鈕。點擊它,庫的 zip 文件就會被下載到你的電腦中。
當你打開了 zip 文件并將其解壓后,在 Find 中打開 fmdb-master / src / fmdb 目錄。找到的文件就是你需要添加到初始工程中的文件。這里體現了一個分組的思想,首先在項目導航目錄中為這些文件創建一個新的分組,這樣你就可以把它們與項目的其它文件分開。選擇它們(目錄中還有一個.plist文件,但真的不需要它),然后拖拽到 Xcode 的項目導航目錄中。
當把文件添加到工程中之后,Xcode 會詢問是否創建一個橋接頭文件。
如果不想手動創建頭文件就選擇接受。 一個新的文件將被添加到項目,名為 FMDBTut-Bridging-Header.h。 打開它并寫下面這行:
#import "FMDB.h"
現在,在整個 Swift 項目中都可以使用 FMDB 中的類了,讓我們準備開始吧。
創建一個數據庫
使用數據庫幾乎總是涉及相同的通用操作:建立數據庫的連接,加載或修改存儲的數據,最后關閉連接。在項目中的任何類中都可以做到,如我們所了解的,在需要的地方,FMDB 中的類總是可用的。但是在我看來,這不是一個好策略,如果數據庫相關的代碼遍布整個項目,可能會對未來的更新或調試帶來麻煩。 我一直都喜歡的方法是創建一個類,并執行以下操作:
- 通過 FMDB 的 API 處理程序與數據庫的通信——這樣不必編寫多個代碼來檢查實際的數據庫文件是否真的存在,或者數據庫是否開啟了。
- 實現數據庫的相關方法——將根據自身的需要創建特定的自定義方法來操作數據,其他類如果想要操作數據就需要調用這些方法。
正如你所理解的,我們將創建一種基于 FMDB 的更高級別的數據庫 API,但是它完全是按照我們的應用的目的設計的。為了提高這個類運作的靈活性,我們把它設計成一個 單例 ,當需要使用它時,將能夠直接使用,而不用創建它的新實例(新對象)。
現在讓我們把理論付諸實踐,回到初始項目中。首先為數據庫管理器(database manager)創建一個新類(在 Xcode 中,打開 File 目錄 > New > File… > Cocoa Touch Class )。當 Xcode 要求你提供一個名稱時,請設置為 DBManager,并確保你將其設置為 NSObject 的子類。創建完新文件之后請繼續閱讀。
現在打開 DBManager 類,增加下面這行代碼構造一個單例:
static let shared: DBManager = DBManager()
我強烈建議你去閱讀 Swift 中單例的相關資料,并了解上面的代碼如何滿足我們的需求。從現在開始,任何情況下,我們只需要寫諸如 DBManager.shared.Do_Something() 這樣的代碼,單例就會起作用了。不需要初始化類的新實例(但是如果你有強烈的愿望,你仍然可以這樣做)。
除了上述的步驟,我們還需要為應用聲明三個更重要的屬性:
- 數據庫文件名——不是必須將其作為屬性,但從可復用的角度考量建議將其作為屬性。
- 數據庫文件的路徑
- 一個將在真實數據庫上執行訪問和操作的 FMDatabase 對象(來自 FMDB 庫)。
開始了:
let databaseFileName = "database.sqlite"
var pathToDatabase: String!
var database: FMDatabase!
嘿,等一下!我們缺少了一個類中必需的 init() 方法:
override init() {
super.init()
let documentsDirectory = (NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] as NSString) as String
pathToDatabase = documentsDirectory.appending("/\(databaseFileName)")
}
顯然, init() 方法不為空;構造器最適合承擔這樣的工作:指定應用程序的文檔目錄的路徑,并組成數據庫文件的路徑。
讓我們在一個新的自定義方法中創建數據庫,將調用 createDatabase() 這個方法(還有什么?)。該方法將返回一個 Bool 值,指示數據庫是否已成功創建。雖然現在來看作用不是很明顯,后面你會更好地理解返回值的目的,我提前透露一下,我們會將一些初始數據插入數據庫,但只有確認數據庫已經真正創建了才會執行這個操作。數據庫創建和初始數據插入是兩個動作,在應用程序第一次啟動的時候這兩個動作各自執行一次。
現在來看看如何創建實際的數據庫文件:
func createDatabase() -> Bool {
var created = false
if !FileManager.default.fileExists(atPath: pathToDatabase) {
database = FMDatabase(path: pathToDatabase!)
}
return created
}
這里有兩個地方值得注意:
- 無論下一步是什么,只有數據庫不存在的時候才會創建它。這很重要,因為我們不想重復創建數據庫文件并銷毀原始數據庫。
- 這行代碼 : database = FMDatabase(path: pathToDatabase!) 正在創建由構造器的參數指定的數據庫文件,并且只在文件沒有找到的情況下創建(實際上是我們刻意為之)。但是這個過程中沒有建立連接,我們只知道這行代碼執行完之后,可以使用 database 屬性來訪問我們的數據庫。
不要介意 created 標志位,我們會在正確的時機設置它的值。回到我們的新方法中,確保數據庫已創建后我們繼續,打開它:
func createDatabase() -> Bool {
var created = false
if !FileManager.default.fileExists(atPath: pathToDatabase) {
database = FMDatabase(path: pathToDatabase!)
if database != nil {
// 打開數據庫
if database.open() {
}
else {
print("Could not open the database.")
}
}
}
return created
}
database.open() 是上面的關鍵代碼行,因為只有在打開數據庫后才能對數據庫數據進行操作。后面,我們將以類似的方式關閉數據庫(與數據庫的真實連接)。
在數據庫中創建一張 表 。為簡單起見先不創建其他表。該表(我們命名為電影)的屬性與 MovieInfo 結構體的屬性相同,你只需在 Xcode 中打開 MoviesViewController.swift 文件就能看到該結構體。為了方便起見,我接下來只給出查詢(query)語句(你還可以在其中查看字段及其數據類型):
let createMoviesTableQuery = "create table movies (movieID integer primary key autoincrement not null, title text not null, category text not null, year integer not null, movieURL text, coverURL text not null, watched bool not null default 0, likes integer not null)"
下面這行代碼將執行上面的查詢操作,它將在數據庫上創建新表:
database.executeUpdate(createMoviesTableQuery, values: nil)
executeUpdate(...) 方法用于可以修改數據庫的所有查詢(換句話說,非選擇(Select)查詢)。方法的第二個參數接受一組值,封裝我們可能想要與查詢一起傳遞的信息,但現在不需要使用它。 稍后會看到。上面的代碼會觸發 Xcode 的錯誤提醒,因為如果發生錯誤該方法可能會拋出(throw)異常。所以需要調整最后一行代碼:
do {
try database.executeUpdate(createMoviesTableQuery, values: nil)
created = true
}
catch {
print("Could not create table.")
print(error.localizedDescription)
}
可以看到,在 do 語句的方法體中,只有在表成功創建之后, created 標志位才變為 true 。接下來我會給你一個 createDatabase() 方法。注意不管前面出了什么問題,我們都會在 catch 語句的后面關閉數據庫:
func createDatabase() -> Bool {
var created = false
if !FileManager.default.fileExists(atPath: pathToDatabase) {
database = FMDatabase(path: pathToDatabase!)
if database != nil {
// Open the database.
if database.open() {
let createMoviesTableQuery = "create table movies (movieID integer primary key autoincrement not null, title text not null, category text not null, year integer not null, movieURL text, coverURL text not null, watched bool not null default 0, likes integer not null)"
do {
try database.executeUpdate(createMoviesTableQuery, values: nil)
created = true
}
catch {
print("Could not create table.")
print(error.localizedDescription)
}
// 最后關閉數據庫
database.close()
}
else {
print("Could not open the database.")
}
}
}
return created
}
一些最佳實踐
在繼續之前,我想介紹幾個最佳實踐,它們將使我們的生活更輕松,并讓我們遠離將來可能出現的潛在麻煩。因為示例工程是一個小型的工程,對數據庫的操作會非常有限,所以我在這里展示的操作對示例工程來說可能不是必要的。但是,如果你正在處理大項目,那么接下來的內容真的值得你堅持下去,因為它會幫你節省足夠多的時間,它會阻止你重復寫相同的代碼,還能防止你犯排版的錯誤。
所以,讓我們開始做一些讓生活更輕松的事情,這將節省我們開發大型項目花費的時間。每當想要建立數據庫的連接以便檢索數據或執行任意種類的更新操作(插入,更新,刪除)時,我們必須遵循特定的步驟:確保數據庫對象已經初始化,如果沒有就去初始化它,然后使用 open() 方法打開數據庫,如果一切都正常再開始進行真正的工作。每當需要對數據庫做一些操作時,都必須重復這些步驟,現在想想無論何時只要打開數據庫都得做這些檢查以及執行可選的操作,這是多么的無聊、抗生產和耗時啊。想變得聰明一點,為什么不創建一個方法執行以上所有的操作,所以當我們需要它的時候只需要調用這方法(只是一行代碼),而不是執行上面的所有操作?
我們在 DBManager 類中創建一個這樣的方法,如下:
func openDatabase() -> Bool {
if database == nil {
if FileManager.default.fileExists(atPath: pathToDatabase) {
database = FMDatabase(path: pathToDatabase)
}
}
if database != nil {
if database.open() {
return true
}
}
return false
}
該方法首先會檢查數據庫對象是否已經被初始化,然后會再次檢查它是否仍然是 nil。然后,它嘗試打開數據庫。 該方法的返回值是 Bool 類型的。當它為真時,表示數據庫已成功打開,否則表示數據庫文件不存在,或者發生了其他的錯誤導致數據庫無法打開。一般來說,如果方法返回 true,那么代表我們已經準備使用的數據庫( database 對象)設置了一個處理程序,最重要的是,通過實現該方法,當需要打開數據庫的時候不必每次都重復上面的代碼。你可以隨意擴展上面的實現,如果你需要的話添加更多的條件、檢查或者錯誤消息。
在上一部分中,我們組成了一個 SQL 查詢,創建 movies 表:
let createMoviesTableQuery = "create table movies (movieID integer primary key autoincrement not null, title text not null, category text not null, year integer not null, movieURL text, coverURL text not null, watched bool not null default 0, likes integer not null)"
這個查詢沒問題,但接下來寫的每個后續查詢都有潛在的風險。危險在于字段的名稱,我們必須在每個將要創建的查詢中寫入的名稱文字。如果繼續這樣做,那么可能會輸入一個或多個字段的名稱,這會引起錯誤。 例如,如果我們不夠仔細,很容易錯誤地輸入 “movieId” 而不是 “movieID” 或者輸入了 “movieurl” 而不是 “movieURL”。如果有多張表的多個查詢的話,按照統計學理論這種錯誤是一定會發生的。好吧,沒有什么大不了,因為遲早你會發現這個(些)問題,但為什么要浪費時間呢?有一個很好的方法來避免這種風險,那就是用常量屬性分配字段名(所有的表,在我們的例子中只有一個表)。 來看看這種情形中該怎么做:
在 DBManager 類的頭部增加下列代碼:
let field_MovieID = "movieID"
let field_MovieTitle = "title"
let field_MovieCategory = "category"
let field_MovieYear = "year"
let field_MovieURL = "movieURL"
let field_MovieCoverURL = "coverURL"
let field_MovieWatched = "watched"
let field_MovieLikes = "likes"
我添加了“field”前綴,以便在 Xcode 中輸入時很容易找到所需的字段。如果你開始輸入“field”,Xcode 將自動把所有包含該術語的屬性提示給你,你很容易找到感興趣的字段名稱。每個名稱的第二部分實際上是關于每個字段的簡短描述。你甚至可以提升它的語義,把每個屬性的表名稱加進去:
let field_Movies_MovieID = "movieID"
這不是必需的,因為我們只有一個表,但是如果有多個表,遵循上面的命名約定會有很大的作用。
通過將字段名分配給常量的方式,我們不需要再手動輸入任何字段名,因為在全局使用了常量,可以確保不會存在拼寫錯誤。如果更新查詢,新的樣式如下:
let createMoviesTableQuery = "create table movies (\(field_MovieID) integer primary key autoincrement not null, \(field_MovieTitle) text not null, \(field_MovieCategory) text not null, \(field_MovieYear) integer not null, \(field_MovieURL) text, \(field_MovieCoverURL) text not null, \(field_MovieWatched) bool not null default 0, \(field_MovieLikes) integer not null)"
不是一定要在你的項目中使用上述的兩種做法。我只是建議和推薦它們,但是否要使用完全取決于你,如果你堅持用傳統的方式寫東西,或者你甚至找到另一個更好的方式提升它們,那就用你自己的方式。但是在示例工程中我會使用這種寫法。既然提到了,那就開搞吧!
插入記錄
在這部分中,我們將一些初始數據插入到數據庫中,數據源是已經存在于初始項目中的 movies.tsv 文件(只需要放在項目導航目錄中)。這個文件中包含二十部電影的數據,其中的電影記錄由字符“\ r \ n”(不帶引號)分隔。標簽字符(“\ t”)分隔單個電影內部的數據,該格式將使我們的解析工作變很容易。 數據的順序如下:
- 電影標題
- 分類
- 年代
- 電影 URL
- 電影封面的 URL(電影圖片的地址,通常是封面的地址)
對于表中存在的其余字段,這里沒有數據,我們只插入一些默認值。
在 DBManager 類中,我們將實現一個新方法,為完成所有的工作。首先使用上一部分中實現的方法,所以只需要一行代碼就可以打開數據庫:
func insertMovieData() {
// 打開數據庫
if openDatabase() {
}
}
我們要遵循的邏輯如下:
- 首先,找到“movies.tsv”文件,把它的內容加載到一個 String 類型的對象中。
- 然后將根據 / r / n 子串的位置來截斷原字符串從而分離出電影數據,會得到一個字符串數組( [String] )。 數組的每個位置保存單個影片數據的字符串。
- 接下來,使用一個循環,遍歷所有的電影,并逐個獲取它們,我們使用類似上面的方式截斷每個電影字符串,但這一次基于 tab 字符(“\ t”)。這會生成一個新的數組,數組的每個位置上保存每部電影的不同數據項。這些數據會在后面用來組成我們想要的插入查詢。
從第一步開始,讓我們得到“movies.tsv”文件的路徑,然后將它的內容加載到一個字符串對象中:
if let pathToMoviesFile = Bundle.main.path(forResource: "movies", ofType: "tsv") {
do {
let moviesFileContents = try String(contentsOfFile: pathToMoviesFile)
}
catch {
print(error.localizedDescription)
}
}
通過一個文件的內容創建的字符串可能會拋出異常,所以必須使用 do-catch 語句。 現在讓我們繼續第二步,將字符串的內容拆分成基于“\ r \ n”字符的字符串數組:
let moviesData = moviesFileContents.components(separatedBy: "\r\n")
第三步,寫一個 for 循環,把每個電影的數據拆分成數組。注意,在循環之前,首先要初始化另一個字符串(取名為 Query ),一會會使用它來編寫插入命令。
var query = ""
for movie in moviesData {
let movieParts = movie.components(separatedBy: "\t")
if movieParts.count == 5 {
let movieTitle = movieParts[0]
let movieCategory = movieParts[1]
let movieYear = movieParts[2]
let movieURL = movieParts[3]
let movieCoverURL = movieParts[4]
}
}
在上面的 if 語句的中,我們將構造插入查詢。正如你將在下面的代碼片段中看到的那樣,每個查詢以一個分號(;)結束,原因很簡單:我們想立即執行多個查詢,SQLite 將基于 ; 符號區分每個查詢。注意另外兩件事:首先,我使用之前創建的常量值表示字段名稱。其次,注意查詢字符串中的單引號“’”。 如果省略任意必須的 ‘ 符號,可能會引發問題。
query += "insert into movies (\(field_MovieID), \(field_MovieTitle), \(field_MovieCategory), \(field_MovieYear), \(field_MovieURL), \(field_MovieCoverURL), \(field_MovieWatched), \(field_MovieLikes)) values (null, '\(movieTitle)', '\(movieCategory)', \(movieYear), '\(movieURL)', '\(movieCoverURL)', 0, 0);"
現在我們給最后的兩個字段指定了一些默認值。 稍后將通過執行更新查詢更改它們。到 for 循環結束時, query 字符串將包含所有要執行的插入查詢(這里總共20個查詢)。使用 FMDB 一次執行多個語句很容易,因為我們所要做的就是通過 database 對象調用 executeStatements(_:) 方法:
if !database.executeStatements(query) {
print("Failed to insert initial data into the database.")
print(database.lastError(), database.lastErrorMessage())
}
上面所示的 lastError() 和 lastErrorMessage() 只有在插入失敗時才會真正起作用。這兩個方法報告遇到的問題以及大多數問題的出處,以便你可以輕松地解決問題。當然,這個代碼段需要寫在循環之后。
有一件事情可能聽起來不重要,但是不要忘記(我重復一遍,不要忘記)關閉與數據庫的連接,為此我們添加一個 database.close() 命令以完成這段代碼。下面是完整的 insertMovieData() 方法:
func insertMovieData() {
if openDatabase() {
if let pathToMoviesFile = Bundle.main.path(forResource: "movies", ofType: "tsv") {
do {
let moviesFileContents = try String(contentsOfFile: pathToMoviesFile)
let moviesData = moviesFileContents.components(separatedBy: "\r\n")
var query = ""
for movie in moviesData {
let movieParts = movie.components(separatedBy: "\t")
if movieParts.count == 5 {
let movieTitle = movieParts[0]
let movieCategory = movieParts[1]
let movieYear = movieParts[2]
let movieURL = movieParts[3]
let movieCoverURL = movieParts[4]
query += "insert into movies (\(field_MovieID), \(field_MovieTitle), \(field_MovieCategory), \(field_MovieYear), \(field_MovieURL), \(field_MovieCoverURL), \(field_MovieWatched), \(field_MovieLikes)) values (null, '\(movieTitle)', '\(movieCategory)', \(movieYear), '\(movieURL)', '\(movieCoverURL)', 0, 0);"
}
}
if !database.executeStatements(query) {
print("Failed to insert initial data into the database.")
print(database.lastError(), database.lastErrorMessage())
}
}
catch {
print(error.localizedDescription)
}
}
database.close()
}
}
即使我對“movies.tsv”文件中數據的處理方式予以足夠的重視,并以一種可以在代碼中輕松使用的方式對其進行轉換,但是我們主題的其他方面才是重點:如何創建多個查詢(記住使用 ; 符號分隔它們),以及如何批量執行它們。那才是 FMDB 的功能,也是這部分的主題。
在結束本節的工作之前,還有最后一件事; 必須調用我們的新方法創建數據庫并將初始數據插入數據庫。打開 AppDelegate.swift 文件,找到 applicationDidBecomeActive(_:) 委托(delegate)方法。增加下面兩行代碼:
func applicationDidBecomeActive(_ application: UIApplication) {
if DBManager.shared.createDatabase() {
DBManager.shared.insertMovieData()
}
}
加載數據
在 MoviesViewController 類中,有一個已經完成了基本實現的 tableview,它在“等待”我們完成全部的實現,所以可以在它上面展示從數據庫加載的電影信息。這個 tableview 的數據源是一個名為 movies 的數組,它是 MovieInfo 對象的集合。你可以在 MoviesViewController.swift 文件中找到結構體 MovieInfo 的定義,其包含了數據庫中 movies 表的程序化表示,每個對象描述一部電影。了解了這些之后,我們在這個章節的目的是從數據庫加載現有的電影,并指定 MovieInfo 對象中的細節,然后使用這些對象來填充 tableview 中的數據。
再次回到 DBManager 類中,這次最重要的目標是了解如何在 FMDB 中執行 SELECT 查詢,我們將自定義一個新的方法,在方法體中加載電影數據:
func loadMovies() -> [MovieInfo]! {
}
返回值是一個 MovieInfo 對象的集合,我們需要在 MoviesViewController 中使用這個集合。下面來實現該方法,聲明一個本地數組來存儲從數據庫加載的結果,并打開數據庫:
func loadMovies() -> [MovieInfo]! {
var movies: [MovieInfo]!
if openDatabase() {
}
return movies
}
下一步是創建 SQL 查詢,告訴數據庫要加載哪些數據:
let query = "select * from movies order by \(field_MovieYear) asc"
查詢的執行如下:
do {
let results = try database.executeQuery(query, values: nil)
}
catch {
print(error.localizedDescription)
}
FMDatabase 對象的 executeQuery(...) 方法有兩個參數:查詢字符串,以及需要與查詢一起傳遞的值的數組。如果沒有值,傳入 nil 就行了。這個方法返回一個 FMResultSet 對象(FMDB 中的類),包含了檢索到的數據,我們將在稍后看到訪問返回數據的方法。
使用上面的查詢,要求 FMDB 基于發布年份按照升序的方式獲取所有的電影。這里只演示了一個簡單的查詢,你可以根據需要創建更高級的查詢。看看另一個稍微復雜些的查詢,加載特定類別的電影,依舊按照年份排列,但是這次按照降序排列:
let query = "select * from movies where \(field_MovieCategory)=? order by \(field_MovieYear) desc"
相信你已經看到了,查詢本身并未指定 where 子句的類別名稱。相反,我們在查詢中設置一個占位符,采用如下所示的方式提供實際值(我們要求 FMDB 僅加載屬于“犯罪”類別的電影):
let results = try database.executeQuery(query, values: ["Crime"])
另一個示例,加載發布年份大于指定年份的特定類別的所有電影數據,按照 ID 值按降序排序:
let query = "select * from movies where \(field_MovieCategory)=? and \(field_MovieYear)>? order by \(field_MovieID) desc"
上面的查詢語句期望獲得兩個參數:
let results = try database.executeQuery(query, values: ["Crime", 1990])
如上所示,創建或執行查詢一點都不難,你仍然可以隨意創建自己的查詢并且在其上做更多的實驗。
讓我們繼續,合理利用返回的數據。 在下面的代碼段中,使用一個 while 循環來遍歷所有返回的記錄。用每一次循環得到的值初始化一個新的 MovieInfo 對象,把對象加到 movies 數組中,并最終創建顯示到 tableview 的數據集合。
while results.next() {
let movie = MovieInfo(movieID: Int(results.int(forColumn: field_MovieID)),
title: results.string(forColumn: field_MovieTitle),
category: results.string(forColumn: field_MovieCategory),
year: Int(results.int(forColumn: field_MovieYear)),
movieURL: results.string(forColumn: field_MovieURL),
coverURL: results.string(forColumn: field_MovieCoverURL),
watched: results.bool(forColumn: field_MovieWatched),
likes: Int(results.int(forColumn: field_MovieLikes))
)
if movies == nil {
movies = [MovieInfo]()
}
movies.append(movie)
}
在上述代碼中有一個重要的并且強制性的要求,不管你期望獲取多個還是單個數據它總是適用的:每次必須調用 results.next() 方法。當有多個記錄時與 while 語句一起使用; 對于單條記錄結果,可以使用 if 語句:
if results.next() {
}
你應該記住的另一個細節是:每個 movie 對象使用 MovieInfo 結構體的默認構造器進行初始化。這是行得通的,因為我們要求查詢中檢索的每個記錄都返回所有字段( select * from movies ... )。然而,如果你決定要獲得所有字段的一個子集(例如, select (field_MovieTitle), (field_MovieCoverURL) from movies where ... ),上述初始化程序將無法工作,應用程序會崩潰。這是因為任何 results.XXX(forColumn:) 方法試圖獲取未加載字段的數據時找到的是 nil 而不是真實的值。所以,當你處理結果時,觀察并且牢記從數據庫中加載的字段,這會使你擺脫困擾。
現在讓我們看看在這部分中創建的方法:
func loadMovies() -> [MovieInfo]! {
var movies: [MovieInfo]!
if openDatabase() {
let query = "select * from movies order by \(field_MovieYear) asc"
do {
print(database)
let results = try database.executeQuery(query, values: nil)
while results.next() {
let movie = MovieInfo(movieID: Int(results.int(forColumn: field_MovieID)),
title: results.string(forColumn: field_MovieTitle),
category: results.string(forColumn: field_MovieCategory),
year: Int(results.int(forColumn: field_MovieYear)),
movieURL: results.string(forColumn: field_MovieURL),
coverURL: results.string(forColumn: field_MovieCoverURL),
watched: results.bool(forColumn: field_MovieWatched),
likes: Int(results.int(forColumn: field_MovieLikes))
)
if movies == nil {
movies = [MovieInfo]()
}
movies.append(movie)
}
}
catch {
print(error.localizedDescription)
}
database.close()
}
return movies
}
我們使用它來填充 tableview 上的電影數據。打開 MoviesViewController.swift 文件,實現 viewWillAppear(_:) 方法。在其中添加以下兩行代碼,使用上述方法加載電影數據,并觸發 tableview 上的重新加載:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
movies = DBManager.shared.loadMovies()
tblMovies.reloadData()
}
但是,我們必須在 tableView(_:, cellForRowAt indexPath:) 方法中指定每個單元格(Cell)的內容。由于這不是本主題的重要部分,所以我直接給出完整的實現:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
let currentMovie = movies[indexPath.row]
cell.textLabel?.text = currentMovie.title
cell.imageView?.contentMode = UIViewContentMode.scaleAspectFit
(URLSession(configuration: URLSessionConfiguration.default)).dataTask(with: URL(string: currentMovie.coverURL)!, completionHandler: { (imageData, response, error) in
if let data = imageData {
DispatchQueue.main.async {
cell.imageView?.image = UIImage(data: data)
cell.layoutSubviews()
}
}
}).resume()
return cell
}
每部電影的圖片以異步方式下載,并在其數據可用時顯示在單元格上。我希望 URLSession 塊不會讓你迷惑; 寫在多行它會是這樣的:
let sessionConfiguration = URLSessionConfiguration.default
let session = URLSession(configuration: URLSessionConfiguration.default)
let task = session.dataTask(with: URL(string: currentMovie.coverURL)!) { (imageData, response, error) in
if let data = imageData {
DispatchQueue.main.async {
cell.imageView?.image = UIImage(data: data)
cell.layoutSubviews()
}
}
}
task.resume()
無論如何,現在你第一次可以運行應用程序了。 首次啟動時將創建數據庫,并將初始數據插入其中。接下來,數據將被加載,電影將顯示在 tableview 上,就像下面的截圖所示:
更新
當我們點擊表格視圖中單元格的時候,需要應用程序來展示電影的細節,這意味著要展示 MovieDetailsViewController ,并用所選電影的細節填充這個頁面。最簡單的方法是將選中的 MovieInfo 對象傳遞給 MovieDetailsViewController ,但是我們會使用不同的方法。傳遞電影 ID,然后從數據庫中加載電影信息。 稍后我會解釋這樣做的目的。
我們首先更新即將展示 MovieDetailsViewController 的 Segue 的準備(prepare)方法,所以仍需打開 MoviesViewController.swift 文件。它有一個初始實現,所以只需做如下更新(在 if 語句中添加兩行):
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let identifier = segue.identifier {
if identifier == "idSegueMovieDetails" {
let movieDetailsViewController = segue.destination as! MovieDetailsViewController
movieDetailsViewController.movieID = movies[selectedMovieIndex].movieID
}
}
}
selectedMovieIndex 屬性的值在下面所示的 tableview 的方法中獲取,這個方法在初始工程中已經實現了。
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
selectedMovieIndex = indexPath.row
performSegue(withIdentifier: "idSegueMovieDetails", sender: nil)
}
另外, MovieDetailsViewController 中有一個名為 movieID 的屬性,所以上面的代碼可以順利運行。
現在我們將所選電影的 ID 傳遞給下一個視圖控制器,需要編寫一個新方法來加載該 ID 指定的電影數據。方法用到的數據庫相關的操作,之前都已經見過了。不過會有一個區別:通常你會期望這個方法返回一個 MovieInfo 對象。好吧,不要這樣做,我們會返回一個完成處理器(completion handler)將獲取到的數據傳遞回 MovieDetailsViewController 類,而不是返回一個值,這才是我的目的:向你展示從數據庫獲取數據時如何使用完成處理器而不是返回值。
打開 DBManager.swift 文件,看看新方法的標題行:
func loadMovie(withID ID: Int, completionHandler: (_ movieInfo: MovieInfo?) -> Void) {
}
如你所見,這里有兩個參數:第一個是我們要加載的電影的 ID。第二個參數是完成處理器,它又有一個參數,把加載到的電影作為一個 MovieInfo 類型的對象傳入。現在先忽略方法的實現,你馬上就能看到具體實現。你看到的東西之前都有討論:
func loadMovie(withID ID: Int, completionHandler: (_ movieInfo: MovieInfo?) -> Void) {
var movieInfo: MovieInfo!
if openDatabase() {
let query = "select * from movies where \(field_MovieID)=?"
do {
let results = try database.executeQuery(query, values: [ID])
if results.next() {
movieInfo = MovieInfo(movieID: Int(results.int(forColumn: field_MovieID)),
title: results.string(forColumn: field_MovieTitle),
category: results.string(forColumn: field_MovieCategory),
year: Int(results.int(forColumn: field_MovieYear)),
movieURL: results.string(forColumn: field_MovieURL),
coverURL: results.string(forColumn: field_MovieCoverURL),
watched: results.bool(forColumn: field_MovieWatched),
likes: Int(results.int(forColumn: field_MovieLikes))
)
}
else {
print(database.lastError())
}
}
catch {
print(error.localizedDescription)
}
database.close()
}
completionHandler(movieInfo)
}
在方法末尾,我們調用了完成處理器,傳入了 movieInfo 對象,無論該對象已經初始化成功還是因為某種錯誤而返回了 nil。
現在打開 MovieDetailsViewController.swift 文件,直接在 viewWillAppear(_:) 方法中調用上面的方法:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let id = movieID {
DBManager.shared.loadMovie(withID: id, completionHandler: { (movie) in
DispatchQueue.main.async {
if movie != nil {
self.movieInfo = movie
self.setValuesToViews()
}
}
})
}
}
這里要提到兩件事:首先,完成處理器中的 movie 對象被分配給(已聲明的) movieInfo 屬性,因此可以在整個類中使用獲取到的值。第二,使用主線程( DispatchQueue.main ),因為 setValuesToViews() 方法將更新 UI,更新 UI 應該總是在主線程上進行。如果上面的結果是成功的,并且正確地獲取電影信息,則電影的詳情將被填充到適當的視圖中。現在你可以嘗試這個功能,運行應用程序并選擇一部電影:
但是這還不夠。我們希望能夠更新數據庫以及特定電影的數據,并跟蹤觀看狀態(是否觀看了該電影),并根據我們的喜歡程度給電影評分。這很容易實現,因為只需要在 DBManager 類中編寫一個新方法執行更新。回到 DBManager.swift 文件中,添加下面的方法:
func updateMovie(withID ID: Int, watched: Bool, likes: Int) {
if openDatabase() {
let query = "update movies set \(field_MovieWatched)=?, \(field_MovieLikes)=? where \(field_MovieID)=?"
do {
try database.executeUpdate(query, values: [watched, likes, ID])
}
catch {
print(error.localizedDescription)
}
database.close()
}
}
方法接受三個參數:我們要更新的電影 ID,指示電影是否已被觀看的 Bool 值,以及給打電影的喜歡個數。參照前面討論的內容很容易創建該查詢。有趣的部分是 executeUpdate(...) 方法,在我們創建數據庫的時候已經見過這個方法了。對數據庫執行任何類型的更改時都必須使用這個方法,換句話說,你需要在執行 Select 之外的操作時使用它。該方法的第二個參數是一個 Any 類型的數組,它與將要執行的查詢語句一起傳遞。
或者,我們可以返回 Bool 值以指示更新是否成功,不過在本示例中并不關心。但如果我們處理更關鍵的數據,這將是一個重要的補充。
現在回到 MovieDetailsViewController.swift 文件中,使用上面的方法。找到 saveChanges(_:) 方法,添加下面的內容:
@IBAction func saveChanges(_ sender: AnyObject) {
DBManager.shared.updateMovie(withID: movieInfo.movieID, watched: movieInfo.watched, likes: movieInfo.likes)
_ = self.navigationController?.popViewController(animated: true)
}
添加了上面的代碼之后,每次我們點擊保存按鈕時應用程序都會更新電影的觀看狀態和喜歡數量,然后程序將返回到 MoviesViewController 中。
刪除記錄
到目前為止,我們已經了解了如何以編程的方式創建數據庫,如何執行批處理語句,如何加載數據以及如何更新。還有一件事仍在期望中,那就是如何刪除現有記錄。我們會簡單處理,允許通過向左側滑動單元格來刪除電影,滑動后會出現常見的紅色“刪除”按鈕。
在實現這個功能之前,最后一次來到 DBManager 類中。我們的任務是實現一個新的方法,刪除選擇的電影的記錄。你將再次看到 FMDatabase 類的 executeUpdate(...) 方法被用于執行將要創建的查詢。不浪費時間了,看看新方法的實現:
func deleteMovie(withID ID: Int) -> Bool {
var deleted = false
if openDatabase() {
let query = "delete from movies where \(field_MovieID)=?"
do {
try database.executeUpdate(query, values: [ID])
deleted = true
}
catch {
print(error.localizedDescription)
}
database.close()
}
return deleted
}
這個方法返回一個 Bool 值,以指示刪除是否成功,除此之外這里沒有什么值得討論的新東西。我們需要這個 Bool 值,因為必須更新 tableview 的數據源( movies 數組)以及 tableview,接下來會看到。
接下來,打開 MoviesViewController ,然后實現下列 tableview 方法:
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
}
}
以上代碼將啟用紅色刪除按鈕,當我們從右到左滑動單元格式時,按鈕會出現。通過調用 deleteMovie(_:) 方法完成 if 語句的功能,如果方法執行成功,將從 movies 數組中刪除匹配的 MovieInfo 對象。最后,重新加載 tableview,使對應的電影單元格消失:
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
if DBManager.shared.deleteMovie(withID: movies[indexPath.row].movieID) {
movies.remove(at: indexPath.row)
tblMovies.reloadData()
}
}
}
現在你可以再次運行應用程序,并嘗試刪除一部電影。 數據庫將通過刪除你選擇的電影進行更新,從現在開始不管你何時運行程序,這部電影都不會再出現。
結論
如果你熟悉 SQL 查詢,并且喜歡這篇文章中所示的數據庫處理方式,那么 FMDB 是一款適合你的工具。它很容易集成到你的項目中,并且它就像你平時所處理的類和方法一樣易用,最重要的是它可以使你擺脫建立數據庫連接然后再去“會話”的繁瑣工序。FMDB 中必須遵循的規則很少,最重要的一點是,你必須在任何操作前后打開和關閉數據庫。
雖然在我們的示例中數據庫中只有一個表,但是在多個表中應用所學的內容一樣很容易。除此之外,我還想提一點。在項目開始時以編程方式創建數據庫,但這不是唯一的方法。你可以使用 SQLite 管理器創建數據庫,并以簡單和圖形化的方式指定表及其字段,然后將數據庫文件放在應用程序包(bundle)中。但是,如果你計劃通過應用程序更改數據庫,則必須將該管理器復制到 documents 目錄中。 這完全取決于你如何創建數據庫,我只是不得不提到這個選項。
關于 FMDB 庫,還有更多高級的東西,這里沒有涉及。但是,談論這些事情將會超出這個話題的初衷,可能將來會討論這個問題。所以,現在你需要做的是嘗試一下 FMDB,看看它是否適合你。我真的希望你在這里讀到的內容能提供盡可能多的幫助。!
來自:http://swift.gg/2017/01/09/fmdb-sqlite-database/