Swift 程式語言

FMDB與SQLite 數據庫應用示範:打做一隻簡單的電影資料庫 App

FMDB與SQLite 數據庫應用示範:打做一隻簡單的電影資料庫 App
FMDB與SQLite 數據庫應用示範:打做一隻簡單的電影資料庫 App
In: Swift 程式語言

通常在 iOS Apps 中使用數據庫並處理數據都會是一個重要和嚴肅的話題。在幾個月前我寫了一篇關於如何利用 SwiftyDB 來管理 SQLite 數據庫的文章。今天,我又提起數據庫這個話題,只不過這次我會介紹另一個庫。你也許聽說過了,它就是FMDB

這兩個庫的功能都是一樣的,都是用來與 SQLite 數據庫打交道並允許你高效地管理你的 App 數據。但是,它們在使用上是截然不同的。SwiftyDB 提供了一個高級 API 來隱藏所有 SQL 細節和其它底層操作,而 FMDB 提供了一個更精細的粒度來處理數據,它是一個位於更底層的 API。它仍然「隱藏」了與 SQLite數據連接和通訊的細節這些非常繁瑣的工作,大部份開發者想要的功能無非是想自定義查詢和操作數據。總的來說,兩個庫在不同的情況下各有所長,這取決於 App的特性和目的。因此,它們都是非常棒的工具,你可以根據自己的需要選擇最合適的一個。

現在,讓我們來看看 FMDB,它實際是一個對 SQLite 的高級別的 封裝, 這樣我們可以不必關心如何連接數據庫,以及如何去實際讀取和寫入數據到數據庫中。對於那些想充分利用自己的 SQL 技能並自己編寫 SQL 語句的開發者來說,FMDB 是最好的選擇,他們完全可以不用自己編寫 SQLite 管理器。它可以在 Objective-C 和 Swift 下使用,將它整合到你的專案中非常容易,一點也不費事。

我們將通過一個小的 Demo App 中的幾個例子來學習 FMDB。一開始我們會用程式碼創建一個新的數據庫,然後進行常見的數據庫操作:插入、修改、刪除和查詢。更多的內容,我建議你看一下原來的 Github 頁。當然,由於這是一個數據庫相關的主題,我會假設你懂得基本的 SQL語句,否則你可能需要先了解一下這些知識才能繼續學習。

如果你與我一樣,是一個數據庫愛好者,那麼請跟我來吧,我們會學習一些非常有趣的事情!

Demo App 介紹

在本文中,我們的 Demo App 將顯示一個影片列表,並通過一個新的 View Controller 展現(我經常使用電影來作為演示數據,而且在 IMDB上有一個非常好的數據源)。當顯示電影詳情時,我們能夠將電影標記為 已觀看打分 (從 0-3)。

fmdb-demo-app

電影數據保存在 SQLite 數據庫中,這個數據庫我們會使用 FMDB 進行管理。初始數據將從一個已有的 tab 符分隔檔案 (.tsv) 導入。 我們將主要精力放在數據庫相關的內容,因此這裡提供了一個 開始專案,你可以用它來開始學習。在開始專案中,已經完成了一些工作,包括原始的 .tsv 檔案,我們將通過這個檔案來獲取最初始的電影數據。

再多透露一點關於 Demo App 的事情。首先,它是一個 navigation-based的App,有兩個 View Controller:一個叫做 MoviesViewController ,它包含了一個 Tableview ,我們用來顯示電影的標題和封面 (總共有 20 部電影)。 在表記錄中,電影封面圖片沒有保存在本地;而是當列表被顯示時從網絡上異步抓取的。點擊每一行電影的 cell,會顯示第二個 View Controller,叫做 MovieDetailsViewController。每部電影的數據將包括:

  • Image
  • Title – 這裡將放置一個按鈕,當點擊按鈕時將會用 Safrai 來打開這部電影在 IMDB上的 web 頁
  • Category
  • Year

此外,我們還有一個 switch 控件,用於表示這部電影你是否已經看過,以及一個 stepper 控件用於你給這部電影打分,通過加號和減號按鈕來調整你對這部電影的喜好程度。影片詳情修改后應當保存到數據庫。

除此之外,在 MoviesViewController.swift 檔案中,你會發現一個結構體,叫做 MovieInfo。它的屬性和數據庫中的欄位段相匹配,一個 MovieInfo 結構將在代碼中用於代表一個影片的信息。在這裡我就不討論關於這個數據庫以及我們要如何使用它了,我們會在具體過程中去了解它。我再說一次接下來我們將要干的事情:創建數據庫(通過編寫程式碼)、插入數據、修改數據、刪除數據和查找數據。我們會力求簡化這些過程,但我們也可以在大數據量的應用中套用同樣的技術。

下載完開始專案並瀏覽了專案內容之後,我們繼續下一步的內容。我們一開始要將 FMDB這個庫添加到開始專案中,然後才能學習如何對數據庫進行操作。同時,我們將學習到一些讓你的程序員生涯更加舒適的良好實踐。

整合 FMDB 到 Swift 專案中

整合 FMDB 到專案中的最常見的方式是使用 CocoaPods ,你可以參考 這裡。但是,對於 Swift專案,最好的辦法是下載源代碼的 zip 包,然後將某些檔案拖到你的專案中。Xcode 可能會問你是否要添加 bridging header 檔案,因為 FMDB 庫是用 Objective-C 些的,爲了讓 Swift 和 Objective-C 這兩種語言能夠共存,你必須使用 bridging 檔案。

讓我們具體演示一下。在瀏覽器中打開我前面提到的超鏈接。在頁面右上角有一個綠色的 “Clone or download” 按鈕。點擊它,你會看到另一個 “Download ZIP” 的按鈕,點擊這個按鈕,源代碼就會以 zip 壓縮檔案的形式下載到你的電腦。

t56_3_download_button

打開下載的 zip 檔案,解壓縮,找到 fmdb-master/src/fmdb 目錄 (使用 Finder)。這個目錄下的檔案就是你需要添加到開始專案中的檔案。最好先在專案導航窗口中新創建一個 Group 用於存放這些檔案,這樣這些檔案會單獨組織在一起以和專案中的其它檔案分開。選中這個目錄下的所有檔案(其中 .plist 檔案除外,你不需要它),然後拖到 Xcode 的專案導航窗口。

t56_4_drag_drop_files

添加完這些檔案后,Xcode 會問你是否需要創建一個 bridging header 檔案。

t56_6_create_bridging_header

如果你不想手動創建這個檔案的話,最好點擊確認。這會添加另一個檔案到專案中,叫做 FMDBTut-Bridging-Header.h。打開這個檔案並編寫下面的代碼:

#import "FMDB.h"

現在,FMDB 的類就可以在 Swift 中使用了,接下來我們就準備使用它們。

創建數據庫

和數據庫打交道總是這幾個步驟:和數據庫建立連接、加載或修改數據庫中的數據、關閉連接。我們可以在專案中的每一個類中重複這些步驟,因為 FMDB 的類可以在你想用時候就用。但是,我覺得這並不是一個好主意,將來你修改或調試代碼時這會帶來一些不方便,因為這些和數據庫有關的代碼在整個專案中擴散了。我更願意創建一個類來干這些事情:

  1. 用 FMDB API 和數據庫進行通訊 – 我們沒有必要將檢查數據庫檔案是否存在或者數據庫是否已經打開的代碼重複多遍。
  2. 實現數據庫相關的方法 – 我們會創建一些自定義的方法來操作數據,然後在其它類中呼叫這些方法來處理數據。

你應該明白了,我們會基於 FMDB 創建一個更高級的數據庫 API,只不過它們只會用在這個 App 中。爲了讓這個類獲得更大的靈活性,我們將這個類創建成一個單實例 singleton ,這樣使用它時不用創建類的實例。關於單實例 singletons, 可以參考 這裡 ,或者去 web 上搜索。

現在,讓我們理論聯繫實際。打開開始專案,創建一個類用於數據庫管理(使用 Xcode 的 File > New > File… -> Cocoa Touch Class)。當 Xcode 詢問類名時,使用 DBManager 作為類名,並伸延 NSObject 類。創建好新的類檔案之後,請繼續。

打開 DBManager 類,添加以下代碼以實現 singleton:

static let shared: DBManager = DBManager()

我強烈建議你讀一下關於 Swift 的 singleton,理解爲什麽這行代碼會實現 singleton。無論如何,以後我們只需要用這樣的代碼 DBManager.shared.Do_Something() 就可以了。不需要宣告新的實例(當然,你非要那樣做也可以)。

然後,我們需要宣告 3 個重要屬性:

  1. 數據庫檔案名 – 這個屬性並不是非要不可,但爲了便於重用,我們還是定義這樣一個屬性吧。
  2. 數據庫檔案路徑。
  3. 一個 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() 方法不是空方法。這裡,我們將數據庫路徑指定為 documents 目錄 + 數據庫檔案名。

我們創建一個方法,叫做 createDatabase() (或者別的什麽名字),用於創建數據庫。這個方法的返回值為 Bool ,用於表示數據庫是否創建成功。你會在後面理解這個返回值的作用,現在不知道也沒有關係。我會先解釋一下,我們會向數據庫中插入一些原始數據,但前提是我們要先判斷數據庫是否真的創建成功。數據庫創建和數據導入只會執行一次,就是 App 第一次運行的時候。

現在,來看一看數據庫檔案要如何創建:

func createDatabase() -> Bool {
    var created = false

    if !FileManager.default.fileExists(atPath: pathToDatabase) {
        database = FMDatabase(path: pathToDatabase!)

    }

    return created
}

我們做了兩件事情:

  1. 我們只會在數據庫檔案不存在的情況下創建數據庫。這非常重要,因為再次創建數據庫檔案會導致原來的數據庫被覆蓋。
  2. 這一句: 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() 非常關鍵,因為只有在打開數據庫之後我們才能訪問數據庫。後面我們會以類似的方法關閉(我們已經連接的)數據庫。

現在,我們來創建一張數據庫。爲了簡單起見,我們只創建這一張表。欄位(表名我們會使用 movies) 和 MovieInfo 結構中的屬性完全相同,如果你打開 MoviesViewController.swift 檔案,你會看到這些屬性。為了簡便,我給出了正確的 SQL 語句(從中你也可以看到每個欄位的名字和型態):

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)"

接下來真正執行上面的 SQL 語句,這會在我們的數據庫中創建一張數據表:

database.executeUpdate(createMoviesTableQuery, values: nil)

executeUpdate(...) 方法用於執行任何會對數據庫進行修改(也就是非 select 語句)的 SQL 語句。第二個參數是一個陣列,我們可以將任何需要傳遞給 SQL 語句的參數放到陣列中,但這裡我們並不需要用到。後面我們會看到這個參數的使用。

上面的語句會導致 Xcode 報錯。因為這個方法會 拋出 一個異常。因此我們需要將上面的語句修改為:

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)
                }

                // At the end close the database.
                database.close()
            }
            else {
                print("Could not open the database.")
            }
        }
    }

    return created
}

一些建議

在繼續後面的內容之前,我來演示一些最佳實踐,也許會讓我們的工作更加輕鬆,避免潛在的問題。之所以要這樣,是因為我們的 App 太簡單了,而且操作的數據非常少。如果你在做一個大專案,那你真的需要注意這些,因為它們會節省你的時間,防止你出現相同的代碼和相同的錯誤。

因此,從現在開始,就讓我們的日子更好過而且在大專案中節省我們的時間吧。當我們建立數據庫連接查找數據或者進行修改操作時(插入、修改和刪除),我們必須重複這幾步:判斷數據庫物件(database)是否宣告,如果未宣告,就呼叫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
}

這個方法首先檢查 database物件是否宣告,如果未宣告它的值應該是 nil。然後嘗試打開數據庫,這個方法的返回值是 Bool 。如果打開成功,返回 true,否則要麼是數據庫檔案不存在,要麼是別的什麽錯誤導致數據庫無法打開。通常,如果這個方法返回 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)"

這個 SQL 語句並沒有問題,但有一點,在我們下次書寫新的 SQL 語句時會導致某種風險。這個問題出在欄位名上,每當我們創建查詢時都不得不拼寫這些欄位名。這樣做的次數一多,我們就會拼錯一個甚至是多個的欄位,這就會導致錯誤。例如,如果我們粗心大意,很容易將 movieID 拼寫成 movieId,或者將 movieURL 拼成 movieurl。我們總會和許多 SQL 語句打交道的,這種情況基本上都會發生,這跟表的多寡無關。好了,這不是什麽大問題,因為要發現這種問題其實也不花多少時間。但爲什麽要把時間浪費在這種問題上?消滅風險是一種好的做法,將欄位名(每個表中,這裡我們只是一張表)定義成constant 屬性吧!在本例中,我們可以這樣做:

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 會自動建議你名字中包含有 field 的屬性,這樣你就容易找到你想要的欄位名了。名字的第二部份實際上是每個欄位的簡短描述。你甚至可以更進一步,將每個屬性中帶上表名:

let field_Movies_MovieID = "movieID"

當然這裡並不需要,因為我們只有一張表。如果你有多張表就不一樣了,你可以使用上面的命名規約。

通過用常量來保存欄位名,我們不再需要手動拼寫欄位名,我們可以在所有需要的地方用常量來代替欄位名,以避免輸入錯誤。如果我們修改我們的 SQL 語句,則會是這個樣子:

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)"

上面兩個建議在你的專案中並不是必須的,我這裡只是建議和推薦,但你用不用它們則隨你的便。你可以繼續使用傳統的做法,也可以找出另一種更好的解決方案。但在我們的 Demo App中,我仍然會採用這兩個建議。言歸正傳,讓我們繼續後面的內容。

插入記錄

在這一節,我們會將原始數據導入到數據庫中,數據來自於 movies.tsv 檔案,這個檔案已經包含在開始專案中了 (可以在專案導航窗口中找到它)。這個檔案包含了 20 部影片的數據,每條影片的記錄是以 “\r\n” (不包含雙引號)分割的。每條記錄中的欄位用 tab 字符 (“\t”) 分割。這種格式的轉換都不是什麽大問題。數據格式如下所示:

  • 影片名字
  • 種類
  • 年份
  • 影片 URL
  • 影片封面 URL (一張影片海報的 URL,通常是影片的正面海報)

對於表中還沒有數據的其它欄位,我們暫時用默認值替代。

DBManager 類中,我們創建一個新方法,為我們處理所有事情。我們會使用前面創建的方法來打開數據庫,因此方法的第一行是這個:

func insertMovieData() {
    // Open the database.
    if openDatabase() {

    }
}

接下來的流程是這個樣子:

  1. 首先需要找到 “movies.tsv” 檔案在哪裡。然後加載它到一個 String 物件中。
  2. 然後根據 /r/n 將字符串分割成子字符串,構成一個 String 怎咧([String])。陣列中的每個 String 代表一個影片的數據。
  3. 接著,用一個迴圈,遍歷每部影片的 String,根據 tab 符號(“\t”)將它們以前面一樣的方式進行分割,構成新的 String 陣列,陣列中每個 String 表示一部影片中不同的信息。有了這些信息就可以直接使用了,構建我們需要的 SQL 插入敘述就可以了。

首先是獲得 “movies.tsv” 檔案的位置並加載它到一個 String 物件中:

if let pathToMoviesFile = Bundle.main.path(forResource: "movies", ofType: "tsv") {
    do {
        let moviesFileContents = try String(contentsOfFile: pathToMoviesFile)

    }
    catch {
        print(error.localizedDescription)
    }
}

從檔案中加載 String 會拋出異常,所以要用 do-catch 敘述。然後是第二步,將 String 根據 "\r\n” 字符分割成陣列:

let moviesData = moviesFileContents.components(separatedBy: "\r\n")

然後是第三步。使用 for 迴圈將每部影片的數據再次分割成陣列。注意在迴圈之前我們要指定一個新的 String變數 (叫 query),用於構造後面要使用到的插入 SQL 語句。

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 語句內部,我們來構建插入 SQL 語句。後面的程式碼中你會看到,每個 SQL 語句都會用一個 分號 (;) 結尾,這是因為:我們想一次執行多條 SQL 語句,SQLite是用;作為語句分隔符的。注意兩點:一,欄位名使用先前定義的常量;二,注意在 SQL 語句中使用單引號 “” 包裹字符串值。如果你在該使用 的地方忘記使用 ,就可能出現問題。

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);"

最後兩個欄位,我們使用默認值。後面我們會用 SQL 語句中的 update 語句來修改它們。

for 迴圈結束后,變數 query 會包含所有即將執行的 insert SQL 語句(總共 20 條 SQL 語句)。在 FMDB 中一次性執行多條 SQL 語句非常簡單,我們只需要呼叫 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” 檔案的數據,並以一種易於復用的方式轉換這些數據,但仍然有一個和我們的主題不太相關的地方也值得關注: 如何創建多個 SQL 語句 (記住,語句之間用 ; 分隔),以及如何批量執行這些語句。這是 FMDB 的特性之一,我們在這一節中學會了它。

在我們結束本節之前,我們還需要做一件事情;我們必須呼叫我們的新方法去創建數據庫和插入初始數據到數據庫中。打開 AppDelegate.swift 檔案,找到 applicationDidBecomeActive(_:) 委託方法。加入以下兩行:

func applicationDidBecomeActive(_ application: UIApplication) {
    if DBManager.shared.createDatabase() {
        DBManager.shared.insertMovieData()
    }
}

加載數據

MoviesViewController 類中,有一個 TableView,基本功能已經完成,只差一步就可以顯示我們從數據庫中加載的數據了。TableView 的數據源是一個陣列,叫做 movies, 它是一個 MovieInfo 物件的集合。 MovieInfo 結構在 MoviesViewController.swift 檔案中定義,它是數據庫中的 movies 表的程式化表現,它承載了一部影片的相關數據。記住,我們需要從數據庫中加載已有的影片,並設置 MovieInfo 物件的屬性中,我們將用 MovieInfo 物件來渲染 TableView 中的數據。

回到 DBManager ,最關鍵的任務是如何用 FMDB 來執行 SELECT SQL 語句, 我們會在一個新方法中加載影片數據:

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"

接下來執行 SQL 語句:

do {
    let results = try database.executeQuery(query, values: nil)
}
catch {
    print(error.localizedDescription)
}

FMDatabaseexecuteQuery(...) 方法有兩個參數:SQL 語句,以及一個陣列物件,同 SQL 語句一起傳遞。如果這個陣列沒有什麽值需要傳遞,設置為 nil 即可。這個方法返回一個 FMResultSet (是一個 FMDB 類) 物件,用於包含查詢到的數據,我們隨後看到如何訪問它所返回的數據。

在上面的 SQL 語句中,我們讓 FMDB 返回所有的影片,並以發佈年份升序排序。這僅僅是一個簡單的例子,但你可以根據自己的需要創建更複雜的 SQL 語句。讓我們來看一個更複雜的例子,我們想根據指定的種類加載影片數據,同樣以年份排序,但是以降序排序:

let query = "select * from movies where \(field_MovieCategory)=? order by \(field_MovieYear) desc"

注意在 SQL 語句的 where 字句中並沒有指定 category 的具體值。對應地,我們在 SQL 中用一個符號替代,我們會在下面提供一個真實的值(我們告訴 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"

執行上面的 SQL 語句需要提供兩個值:

let results = try database.executeQuery(query, values: ["Crime", 1990])

明白了吧,創建和執行查詢語句沒有什麽特別的地方,再次說明,你可以按照自己的需求創建你自己的 SQL 語句。

接下來我們繼續下一步,即使用返回的數據。下面的程式碼中,我們用一個 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 ...),上面的初始化方法就不適用了,App 會崩潰。這是因為 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 的 reloadData 方法:

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
}

每部影片的圖片都是異步下載的,只有當數據下載完后才會在 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()

現在,你可以第一次運行 App 了。第一次運行 App,數據庫會被創建並插入原始數據。然後數據被加載,TableView 中顯示出影片數據,如下圖所示:

t56_1_movies_list

修改

當我們點擊列表中的一行 cell 時,我們希望顯示這部影片的詳情,也就是說我們需要跳到 MovieDetailsViewController 並顯示所選影片的信息。雖然最簡單的做法是將所選的 MovieInfo 物件傳遞給 MovieDetailsViewController,但我們決定換一種做法。我們會傳遞影片的 ID,然後從數據庫中讀取影片資料。我稍後會解釋這樣做的目的。

我們先從修改 prepare(for segue:) 方法開始,這個方法用於呈現 MovieDetailsViewController,因此回到 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
        }
    }
}

在下面的 tableView 方法中,selectedMovieIndex 會被賦值,這是開始專案已經寫好的:

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    selectedMovieIndex = indexPath.row
    performSegue(withIdentifier: "idSegueMovieDetails", sender: nil)
}

MovieDetailsViewController 類中有一個 movieID 屬性,所以上面的代碼是沒有問題的。

現在,我們將用戶選擇的影片 ID 傳遞給下一個 View Controller,我們需要寫一個新方法,以便通過影片 ID 加載影片的資料。在前面的方法中,我們還沒有看到任何跟數據庫有關的程式碼。另外還有一點特別的地方:過去你可能會猜測這個方法肯定會返回一個 MovieInfo 物件。但是你錯了!我們不打算返回一個值,而是用一個 completion 閉包 將讀取到的數據返回給 MovieDetailsViewController,我的目的是,向你展示當從數據庫中讀取數據時,如何用 completion 閉包來代替返回值。

回到 DBManager.swift 檔案,首先看一下這個方法的第一行:

func loadMovie(withID ID: Int, completionHandler: (_ movieInfo: MovieInfo?) -> Void) {

}

看到了嗎?有兩個參數:第一個是我們需要讀取的影片的 ID,第二個是 completion 閉包,這個閉包中會提供一個參數,即成功讀取到的 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 作為參數呼叫了 completion 閉包,無論這個物件是否用影片數據宣告過,如果有錯誤發生,這個物件就會是空。

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()
                }
            }
        })
    }
}

注意兩點:一,在 completion 閉包中,將 movie 物件賦給我們事先宣告的 movieInfo 屬性,這樣在整個類中隨時可以訪問所讀取到的數據。二,我們使用了主執行緒 (DispatchQueue.main),因為 setValuesToViews() 方法會修改 UI,因此必須在主執行緒中進行。如果上面的語句執行成功,我們正確地讀取到一部影片,它的資料會被顯示在正確的視圖上。你可以運行 App 並選擇一部影片:

t56_2_movie_details

當然這還沒完。我們希望能夠修改數據庫和指定影片的數據,並記錄影片的觀影狀態(即是否看過這部影片),並根據我們的喜好程度對它進行打分。這其實很容易,我們只需要在 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()
    }
}

這個方法接受 3 個參數:要修改的影片的 ID,一個用於表示是否看過的 Bool 值,以及一個用於表示我們對這部影片的喜好程度的數字。創建 SQL 語句很容易,和我們之前討論過的一樣。有趣的地方是 executeUpdate(...) 方法,我們在創建數據庫時見過這個方法了。這個方法在種對數據庫進行修改時必須用到,或者執行非 select 語句時必須用到。這個方法的第二個參數仍然是一個 Any 物件,和 SQL語句一起提供。

另外,你可以返回一個 Bool 值用於表示 update 語句是否執行成功,但在這裡我們其實不關心成功與否。當然,如果我們在處理重要數據時,這一點就顯得比較重要了。

現在回到 MovieDetailsViewController.swift 檔案,是時候來呼叫這個方法了。找到 saveChanges(_:) IBAction 方法,加入:

@IBAction func saveChanges(_ sender: AnyObject) {
    DBManager.shared.updateMovie(withID: movieInfo.movieID, watched: movieInfo.watched, likes: movieInfo.likes)

    _ = self.navigationController?.popViewController(animated: true)
}

通過上面的程式碼,當我們點擊 Save 按鈕時,影片的觀影狀態和喜好程度會被修改,同時會返回到 MoviesViewController

刪除記錄

之前我們曾經討論了如何手動創建一個數據庫,如何執行批量 SQL 語句,如何讀取數據以及如何修改。還剩一件事情,就是如何刪除已有的記錄了。爲了簡單起見,我們會運行通過手勢來刪除一部影片,當我們在 cell 上從右到左橫劃時,刪除按鈕就會出現。

在此之前,先讓我們最後一次拜訪我們的 DBManager 類。我們的任務是寫一個新方法,以刪除選定的影片記錄。你會再次看到 FMDatabaseexecuteUpdate(...) 方法,我們會呼叫它來執行我們的 SQL 語句。廢話少說,讓我們看一眼這個方法的實現:

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 的數據源(the movies 陣列)和 Table View。

現在,回到 MoviesViewController 實現這個 Table View 方法:

func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
    if editingStyle == .delete {

    }
}

這一句會使紅色的刪除按鈕可用,並在我們從左至右滑動時顯示這個刪除按鈕。我們會通過呼叫 deleteMovie(_:) 方法來完成這個 if 語句, 如果刪除成功,我們會從 movies陣列中刪掉對應的 MovieInfo 物件。

最後,我們要刷新 Table View,這樣已經刪除掉的影片所在的 cell 就會消失:

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()
        }
    }
}

你現在可以運行 App,並刪除一部影片。數據庫中會將這部影片的記錄刪除,這部影片從現在開始就不再存在了。

t56_7_delete_movie

總結

如果你熟悉 SQL 語句,並喜歡用和本文中相同的方式處理數據庫,那麼 FMDB 非常適合你。將它加到你的專案中很容易,同時易於使用,你不需要和太多的方法和類打交道,最難得的是,你不再需要為建立數據庫連接並與之“通話”而煩惱了。需要記住的規則很少,其中最重要的一條就是在執行操作之前和之後必須打開、關閉數據庫。

儘管在我們的例子中,數據庫中只有一張表,但將我們學到的知識用到多張表其實也不難。我還想說一點,在 Demo App 中,一開始我們用編程的方式創建了數據庫,但並不是我們只能這樣做。你可以用 SQLite Manager 等圖形化的方式創建數據庫並定義表和它的欄位,然後將數據庫檔案拷貝到 bundle中。當然,你必須拷貝到 documents 目錄下,如果你準備通過 App 將修改保存進數據庫的話。這裡我只是簡單那地說一下創建數據庫的可能方式,到底此採用哪一種隨你的便。

關於 FMDB 庫,還有一些更高級的話題這裡沒有涉及。但是,那些話題已經超出了本文最初的範疇,所以就放到以後再說了。現在,你必須要做的事情是,先去嘗試一下 FMDB 並檢驗它是否適合你。我希望這篇文章對你有所幫助。最後,感謝 FMDB 的作者 Gus Mueller,別忘了去拜讀一下 FMDB 的GitHub 頁。你會發現有很多值得一讀的東西,或許會在你遇到問題時有所幫助。祝開心!

你可以參考 Demo App 的 完整專案

譯者簡介:楊宏焱,CSDN 博客專家(個人博客 http://blog.csdn.net/kmyhy)。2009 年開始學習蘋果 iOS 開發,精通 O-C/Swift 和 Cocoa Touch 框架,開發有多個商店應用和企業 App。熱愛寫作,著有和翻譯有多本技術專著,包括:《企業級 iOS 應用實戰》、《iPhone & iPad 企業移動應用開發秘笈》、《iOS8 Swift 編程指南》,《寫給大忙人看的 Swift》、《iOS Swift game Development cookbook》等

原文Working with SQLite Databases in iOS with FMDB Library

作者
Gabriel Theodoropoulos
資深軟體開發員,從事相關工作超過二十年,專門在不同的平台和各種程式語言去解決軟體開發問題。自2010年中,Gabriel專注在iOS程式的開發,利用教程與世界上每個角落的人分享知識。可以在Google+或推特關注 Gabriel。
評論
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。