iOS的CoreData技術詳解

為什么寫這篇文章

最近因為新項目想用到數據持久化,本來這是很簡單的事情,復雜數據一般直接SQLite就可以解決了。

但是一直以來使用SQLite確實存在要自己設計數據庫,處理邏輯編碼,還有調試方面的種種繁瑣問題。所以考慮使用iOS的Core Data方案。

上網查了一堆資料后,發現很多代碼都已經是陳舊的了。甚至蘋果官方文檔提供的代碼樣例都未必是最新的Swift版本。于是萌生了自己寫一篇文章來整理一遍思路的想法。盡可能讓新人快速的上手,不但要知道其然,還要知道其設計的所以然,這樣用起來才更得心應手。

什么是Core Data

我們寫app肯定要用到數據持久化,說白了,就是把數據保存起來,app不刪除的話可以繼續讀寫。

iOS提供數據持久化的方案有很多,各自有其特定用途。

如果你正在面試,或者正準備跳槽,不妨看看我精心總結的面試資料:https://gitee.com/Mcci7/i-oser 來獲取一份詳細的大廠面試資料 為你的跳槽加薪多一份保障

比如很多人熟知的UserDefaults,大部分時候是用來保存簡單的應用配置信息;而NSKeyedArchiver可以把代碼中的對象保存為文件,方便后來重新讀取。

另外還有個常用的保存方式就是自己創建文件,直接在磁盤文件中進行讀寫。

而對于稍微復雜的業務數據,比如收藏夾,用戶填寫的多項表格等,SQLite就是更合適的方案了。關于數據庫的知識,我這里就不贅述了,稍微有點技術基礎的童鞋都懂。

Core DataSQLite做了更進一步的封裝,SQLite提供了數據的存儲模型,并提供了一系列API,你可以通過API讀寫數據庫,去處理想要處理的數據。但是SQLite存儲的數據和你編寫代碼中的數據(比如一個類的對象)并沒有內置的聯系,必須你自己編寫代碼去一一對應。

Core Data卻可以解決一個數據在持久化層和代碼層的一一對應關系。也就是說,你處理一個對象的數據后,通過保存接口,它可以自動同步到持久化層里,而不需要你去實現額外的代碼。

這種 對象→持久化 方案叫 對象→關系映射(英文簡稱ORM)。

除了這個最重要的特性,Core Data還提供了很多有用的特性,比如回滾機制,數據校驗等。

數據模型文件 - Data Model

當我們用Core Data時,我們需要一個用來存放數據模型的地方,數據模型文件就是我們要創建的文件類型。它的后綴是.xcdatamodeld。只要在項目中選 新建文件→Data Model 即可創建。

默認系統提供的命名為 Model.xcdatamodeld 。下面我依然以 Model.xcdatamodeld 作為舉例的文件名。

這個文件就相當于數據庫中的“庫”。通過編輯這個文件,就可以去添加定義自己想要處理的數據類型。

數據模型中的“表格” - Entity

當在xcode中點擊Model.xcdatamodeld時,會看到蘋果提供的編輯視圖,其中有個醒目的按鈕Add Entity。

什么是Entity呢?中文翻譯叫“實體”,但是我這里就不打算用各種翻譯名詞來提高理解難度了。

如果把數據模型文件比作數據庫中的“庫”,那么Entity就相當于庫里的“表格”。這么理解就簡單了。Entity就是讓你定義數據表格類型的名詞。

假設我這個數據模型是用來存放圖書館信息的,那么很自然的,我會想建立一個叫BookEntity。

“屬性” - Attributes

當建立一個名為BookEntity時,會看到視圖中有欄寫著Attributes,我們知道,當我們定義一本書時,自然要定義書名,書的編碼等信息。這部分信息叫Attributes,即書的屬性。

Book的Entity

屬性名 類型
name String
isbm String
page Integer32

其中,類型部分大部分是大家熟知的元數據類型,可以自行查閱。

同理,也可以再添加一個讀者:Reader的Entity描述。

Reader的Entity

屬性名 類型
name String
idCard String

“關系” - Relationship

在我們使用Entity編輯時,除了看到了Attributes一欄,還看到下面有Relationships一欄,這欄是做什么的?

回到例子中來,當定義圖書館信息時,剛書籍和讀者的信息,但這兩個信息彼此是孤立的,而事實上他們存在著聯系。

比如一本書,它被某個讀者借走了,這樣的數據該怎么存儲?

直觀的做法是再定義一張表格來處理這類關系。但是Core Data提供了更有效的辦法 - Relationship。

Relationship的思路來思考,當一本書A被某個讀者B借走,我們可以理解為這本書A當前的“借閱者”是該讀者B,而讀者B的“持有書”是A。

從以上描述可以看出,Relationship所描述的關系是雙向的,即A和B互相以某種方式形成了聯系,而這個方式是我們來定義的。

ReaderRelationship下點擊+號鍵。然后在Relationship欄的名字上填borrow,表示讀者和書的關系是“借閱”,在Destination欄選擇Book,這樣,讀者和書籍的關系就確立了。

對于第三欄,Inverse,卻沒有東西可以填,這是為什么?

因為我們現在定義了讀者和書的關系,卻沒有定義書和讀者的關系。記住,關系是雙向的。

就好比你定義了A是B的父親,那也要同時去定義B是A的兒子一個道理。計算機不會幫我們打理另一邊的聯系。

理解了這點,我們開始選擇Book的一欄,在Relationship下添加新的borrowBy,DestinationReader,這時候點擊Inverse一欄,會發現彈出了borrow,直接點上。

這是因為我們在定義BookRelationship之前,我們已經定義了ReaderRelationship了,所以電腦已經知道了讀者和書籍的關系,可以直接選上。而一旦選好了,那么在ReaderRelationship中,我們會發現Inverse一欄會自動補齊為borrowBy。因為電腦這時候已經完全理解了雙方的關系,自動做了補齊。

“一對一”和“一對多” - to one和to many

我們建立ReaderBook之間的聯系的時候,發現他們的聯系邏輯之間還漏了一個環節。

假設一本書被一個讀者借走了,它就不能被另一個讀者借走,而當一個讀者借書時,卻可以借很多本書。

也就是說,一本書只能對應一個讀者,而一個讀者卻可以對應多本書。

這就是 一對一→to one 和 一對多→to many 。

Core Data允許我們配置這種聯系,具體做法就是在RelationShip欄點擊對應的關系欄,它將會出現在右側的欄目中。(欄目如果沒出現可以在xcode右上角的按鈕調出,如果點擊后欄目沒出現Relationship配置項,可以多點擊幾下,這是xcode的小bug)。

Relationship的配置項里,有一項項名為Type,點擊后有兩個選項,一個是To One(默認值),另一個就是To Many了。

Core Data框架的主倉庫 - NSPersistentContainer

當我們配置完Core Data的數據類型信息后,我們并沒有產生任何數據,就好比圖書館已經制定了圖書的規范 - 一本書應該有名字、isbm、頁數等信息,規范雖然制定了,卻沒有真的引進書進來。

那么怎么才能產生和處理數據呢,這就需要通過代碼真刀真槍的和Core Data打交道了。

由于Core Data的功能較為強大,必須分成多個類來處理各種邏輯,一次性學習多個類是不容易的,還容易混淆,所以后續我會分別一一列出。

要和這些各司其職的類打交道,我們不得不提第一個要介紹的類,叫NSPersistentContainer,因為它就是存放這多個類成員的“倉庫類”。

這個NSPersistentContainer,就是我們通過代碼和Core Data打交道的第一個目標。它存放著幾種讓我們和Core Data進行業務處理的工具,當我們拿到這些工具之后,就可以自由的訪問數據了。所以它的名字 - Container 蘊含著的意思,就是 倉庫、容器、集裝箱。

進入正式的代碼編寫的第一步,我們先要在使用Core Data框架的swift文件開頭引入這個框架:

import CoreData

早期,在iOS 10之前,還沒有NSPersistentContainer這個類,所以Core Data提供的幾種各司其職的工具,我們都要寫代碼一一獲得,寫出來的代碼較為繁瑣,所以NSPersistentContainer并不是一開始就有的,而是蘋果框架設計者逐步優化出來的較優設計。

image

NSPersistentContainer的初始化

在新建的UIKIT項目中,找到我們的AppDelegate類,寫一個成員函數(即方法,后面我直接用函數這個術語替代):

private func createPersistentContainer() {
    let container = NSPersistentContainer(name: "Model")
}

這樣,NSPersistentContainer類的建立就完成了,其中"Model"字符串就是我們建立的Model.xcdatamodeld文件。但是輸入參數的時候,我們不需要(也不應該)輸入.xcdatamodeld后綴。

當我們創建了NSPersistentContainer對象時,僅僅完成了基礎的初始化,而對于一些性能開銷較大的初始化,比如本地持久化資源的加載等,都還沒有完成,我們必須調用NSPersistentContainer的成員函數loadPersistentStores來完成它。

private func createPersistentContainer() {
    let container = NSPersistentContainer(name: "Model")
    container.loadPersistentStores { (description, error) in
        if let error = error {
            fatalError("Error: \(error)")
        }
        print("Load stores success")
    }
}

從代碼設計的角度看,為什么NSPersistentContainer不直接在構造函數里完成數據庫的加載?這就涉及到一個面向對象的開發原則,即構造函數的初始化應該是(原則上)傾向于原子級別,即簡單的、低開銷內存操作,而對于性能開銷大的,內存之外的存儲空間處理(比如磁盤,網絡),應盡量單獨提供成員函數來完成。這樣做是為了避免在構造函數中出錯時錯誤難以捕捉的問題。

表格屬性信息的提供者 - NSManagedObjectModel

現在我們已經持有并成功初始化了Core Data的倉庫管理者NSPersistentContainer了,接下去我們可以使用向這個管理者索取信息了,我們已經在模型文件里存放了讀者和書籍這兩個Entity了,如何獲取這兩個Entity的信息?

這就需要用到NSPersistentContainer的成員,即managedObjectModel,該成員就是標題所說的NSManagedObjectModel類型。

為了講解NSManagedObjectModel能提供什么,我通過以下函數來提供說明:

private func parseEntities(container: NSPersistentContainer) {
    let entities = container.managedObjectModel.entities
    print("Entity count = \(entities.count)\n")
    for entity in entities {
        print("Entity: \(entity.name!)")
        for property in entity.properties {
            print("Property: \(property.name)")
        }
        print("")
    }
}

為了執行上面這個函數,需要修改createPersistentContainer,在里面調用parseEntities

private func createPersistentContainer() {
    let container = NSPersistentContainer(name: "Model")
    container.loadPersistentStores { (description, error) in
        if let error = error {
            fatalError("Error: \(error)")
        }

        self.parseEntities(container: container)
    }
}

在這個函數里,我們通過NSPersistentContainer獲得了NSManagedObjectModel類型的成員managedObjectModel,并通過它獲得了文件Model.xcdatamodeld中我們配置好的Entity信息,即圖書和讀者。

由于我們配置了兩個Entity信息,所以運行正確的話,打印出來的第一行應該是Entity count = 2。

container的成員managedObjectModel有一個成員叫entities,它是一個數組,這個數組成員的類型叫NSEntityDescription,這個類名一看就知道是專門用來處理Entity相關操作的,這里就沒必要多贅述了。

示例代碼里,獲得了entity數組后,打印entity的數量,然后遍歷數組,逐個獲得entity實例,接著遍歷entity實例的properties數組,該數組成員是由類型NSPropertyDescription的對象組成。

關于名詞Property,不得不單獨說明下,學習一門技術最煩人的事情之一就是理解各種名詞,畢竟不同技術之間名詞往往不一定統一,所以要單獨理解一下。

Core Data的術語環境下,一個Entity由若干信息部分組成,之前已經提過的EntityRelationship就是了。而這些信息用術語統稱為propertyNSPropertyDescription看名字就能知道,就是處理property用的。

只要將這一些知識點梳理清楚了,接下去打印的內容就不難懂了:

Entity count = 2

Entity: Book
Property: isbm
Property: name
Property: page
Property: borrowedBy

Entity: Reader
Property: idCard
Property: name
Property: borrow

我們看到,打印出來我們配置的圖書有4個property,最后一個是borrowedBy,明顯這是個Relationship,而前面三個都是Attribute,這和我剛剛對property的說明是一致的。

Entity對應的類

開篇我們就講過,Core Data是一個 對象-關系映射 持久化方案,現在我們在Model.xcdatamodeld已經建立了兩個Entity,那么如果在代碼里要操作他們,是不是會有對應的類?

答案是確實如此,而且你還不需要自己去定義這個類。

如果你點擊Model.xcdatamodeld編輯窗口中的Book這個Entity,打開右側的屬性面板,屬性面板會給出允許你編輯的關于這個Entity的信息,其中Entity部分的Name就是我們起的名字Book,而下方還有一個Class欄,這一欄就是跟Entity綁定的類信息,欄目中的Name就是我們要定義的類名,默認它和Entity的名字相同,也就是說,類名也是Book。所以改與不改,看個人思路以及團隊的規范。

所有Entity對應的類,都繼承自NSManagedObject

為了檢驗這一點,我們可以在代碼中編寫這一行作為測試:

var book: Book! // 純測驗代碼,無業務價值

如果寫下這一行編譯通過了,那說明開發環境已經給我們生成了Book這個類,不然它就不可能編譯通過。

測試結果,完美編譯通過。說明不需要我們自己編寫,就可以直接使用這個類了。

關于類名,官方教程里一般會把類名更改為Entity名 + MO,比如我們這個Entity名為Book,那么如果是按照官方教程的做法,可以在面板中編輯Class的名字為BookMO,這里MO大概就是Model Object的簡稱吧。

但是我這里為簡潔起見,就不做任何更改了,Entity名為Book,那么類名也一樣為Book。

另外,你也可以自己去定義Entity對應的類,這樣有個好處是可以給類添加一些額外的功能支持,這部分Core Data提供了編寫的規范,但是大部分時候這個做法反而會增加代碼量,不屬于常規操作。

數據業務的操作員 - NSManagedObjectContext

接下來我們要隆重介紹NSPersistentContainer麾下的一名工作任務最繁重的大將,成員viewContext,接下去我們和實際數據打交道,處理增刪查改這四大操作,都要通過這個成員才能進行。

viewContext成員的類型是NSManagedObjectContext

NSManagedObjectContext,顧名思義,它的任務就是管理對象的上下文。從創建數據,對修改后數據的保存,刪除數據,修改,五一不是以它為入口。

從介紹這個成員開始,我們就正式從 定義數據 的階段,正式進入到 產生和操作數據 的階段。

數據的插入 - NSEntityDescription.insertNewObject

梳理完前面的知識,就可以正式踏入數據創建的學習了。

這里,我們先嘗試創建一本圖書,用一個createBook函數來進行。示例代碼如下:

private func createBook(container: NSPersistentContainer,
                        name: String, isbm: String, pageCount: Int) {
    let context = container.viewContext
    let book = NSEntityDescription.insertNewObject(forEntityName: "Book",
                                                    into: context) as! Book
    book.name = name
    book.isbm = isbm
    book.page = Int32(pageCount)
    if context.hasChanges {
        do {
            try context.save()
            print("Insert new book(\(name)) successful.")
        } catch {
            print("\(error)")
        }
    }
}

在這個代碼里,最值得關注的部分就是NSEntityDescription的靜態成員函數insertNewObject了,我們就是通過這個函數來進行所要插入數據的創建工作。

insertNewObject對應的參數forEntityName就是我們要輸入的Entity名,這個名字當然必須是我們之前創建好的Entity有的名字才行,否則就出錯了。因為我們要創建的是書,所以輸入的名字就是Book

into參數就是我們的處理增刪查改的大將NSManagedObjectContext類型。

insertNewObject返回的類型是NSManagedObject,如前所述,這是所有Entity對應類的父類。因為我們要創建的EntityBook,我們已經知道對應的類名是Book了,所以我們可以放心大膽的把它轉換為Book類型。

接下來我們就可以對Book實例進行成員賦值,我們可以驚喜的發現Book類的成員都是我們在Entity表格中編輯好的,真是方便極了。

那么問題來了,當我們把Book編輯完成后,是不是這個數據就完成了持久化了,其實不是的。

這里要提一下Core Data的設計理念:懶原則。Core Data框架之下,任何原則操作都是內存級的操作,不會自動同步到磁盤或者其他媒介里,只有開發者主動發出存儲命令,才會做出存儲操作。這么做自然不是因為真的很懶,而是出于性能考慮。

為了真的把數據保存起來,首先我們通過context(即NSManagedObjectContext成員)的hasChanges成員詢問是否數據有改動,如果有改動,就執行contextsave函數。(該函數是個會拋異常的函數,所以用do→catch包裹起來)。

至此,添加書本的操作代碼就寫完了。接下來我們把它放到合適的地方運行。

我們對createPersistentContainer稍作修改:

private func createPersistentContainer() {
    let container = NSPersistentContainer(name: "Model")
    container.loadPersistentStores { (description, error) in
        if let error = error {
            fatalError("Error: \(error)")
        }

        //self.parseEntities(container: container)
        self.createBook(container: container,
                        name: "算法(第4版)",
                        isbm: "9787115293800",
                        pageCount: 636)
    }
}

運行項目,會看到如下打印輸出:

Insert new book(算法(第4版)) successful.

至此,書本的插入工作順利完成!

因為這個示例沒有去重判定,如果程序運行兩次,那么將會插入兩條書名都為"算法(第4版)"的book記錄。

數據的獲取

有了前面基礎知識的鋪墊,接下去的例子只要 記函數 就成了,讀取的示例代碼:

private func readBooks(container: NSPersistentContainer) {
    let context = container.viewContext
    let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
    do {
        let books = try context.fetch(fetchBooks)
        print("Books count = \(books.count)")
        for book in books {
            print("Book name = \(book.name!)")
        }
    } catch {

    }
}

處理數據處理依然是我們的數據操作主力context,而處理讀取請求配置細節則是交給一個專門的類,NSFetchRequest來完成,因為我們處理讀取數據有各種各樣的類型,所以Core Data設計了一個泛型模式,你只要對NSFetchRequest傳入對應的類型,比如Book,它就知道應該傳回什么類型的對應數組,其結果是,我們可以通過Entity名為Book的請求直接拿到Book類型的數組,真是很方便。

打印結果:

Books count = 1
Book name = 算法(第4版)

數據獲取的條件篩選 - NSPredicate

通過NSFetchRequest我們可以獲取所有的數據,但是我們很多時候需要的是獲得我們想要的特定的數據,通過條件篩選功能,可以實現獲取出我們想要的數據,這時候需要用到NSFetchRequest的成員predicate來完成篩選,如下所示,我們要找書名叫 算法(第4版) 的書。

在新的代碼示例里,我們在之前實現的readBooks函數代碼里略作修改:

private func readBooks(container: NSPersistentContainer) {
    let context = container.viewContext
    let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
    fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")
    do {
        let books = try context.fetch(fetchBooks)
        print("Books count = \(books.count)")
        for book in books {
            print("Book name = \(book.name!)")
        }
    } catch {
        print("\(error)")
    }
}

通過代碼:

fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")

我們從書籍中篩選出書名為 算法(第4版) 的書,因為我們之前已經保存過這本書,所以可以正確篩選出來。

篩選方案還支持大小對比,如

fetchBooks.predicate = NSPredicate(format: "page > 100")

這樣將篩選出page數量大于100的書籍。

數據的修改

當我們要修改數據時,比如說我們要把 isbm = "9787115293800" 這本書書名修改為 算法(第5版) ,可以按照如下代碼示例:

let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
    let books = try context.fetch(fetchBooks)
    if !books.isEmpty {
        books[0].name = "算法(第5版)"
        if context.hasChanges {
            try context.save()
            print("Update success.")
        }
    }
} catch {
    print("\(error)")
}

在這個例子里,我們遵循了 讀取→修改→保存 的思路,先拿到篩選的書本,然后修改書本的名字,當名字被修改后,context將會知道數據被修改了,這時候判斷數據是否被修改(實際上不需要判斷我們也知道被修改了,只是出于編碼規范加入了這個判斷),如果被修改,就保存數據,通過這個方式,成功更改了書名。

數據的刪除

數據的刪除依然遵循 讀取→修改→保存 的思路,找到我們想要的思路,并且刪除它。刪除的方法是通過contextdelete函數。

以下例子中,我們刪除了所有 isbm="9787115293800" 的書籍:

let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
    let books = try context.fetch(fetchBooks)
    for book in books {
        context.delete(books[0])
    }
    if context.hasChanges {
        try context.save()
    }
} catch {
    print("\(error)")
}

擴展和進階主題的介紹

如果跟我一步步走到這里,那么關于Core Data的基礎知識可以說已經掌握的差不多了。
當然了,這部分基礎對于日常開發已經基本夠用了。

關于Core Data開發的進階部分,我在這里簡單列舉一下:

  1. Relationship部分的開發,事實上通過之前的知識可以獨立完成。
  2. 回滾操作,相關類:UndoManager。
  3. EntityFetched Property屬性。
  4. 多個context一起操作數據的沖突問題。
  5. 持久化層的管理,包括遷移文件地址,設置多個存儲源等。

以上諸個主題都可以自己進一步探索,不在這篇文章的講解范圍。不過后續不排除會單獨出文探索。

結語

Core Data在圈內是比較出了名的“不好用”的框架,主要是因為其抽象的功能和機制較為不容易理解。本文已經以最大限度的努力試圖從設計的角度去闡述該框架,希望對你有所幫助。

引用:https://developer.apple.com/documentation/coredata

作者:風海銅鑼君
鏈接:http://www.lxweimin.com/p/132c2e2713c7

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容