swiftcore-dataswiftui

Use .fetchBatchSize with @FetchRequest in SwifUI


I've been trying to understand and figure it out for a few days now, but still couldn't come up with solution.

For example, there is a core data entity with 1 mil rows, and I want to display all the record in a view with a "load as you scroll" in order increase the perfomance, as loading 1 mil rows on load would be slow and wasteful.

From the documentaion basically .fetchBatchSize will load 'batches' of data only when it's needed, which is perfect solution for a problem. A user scroll the list, and SwiftUI load data by batches when required.

Here is what I have (simplified):

ContextView.swift

struct ContentView: View {
    @FetchRequest private var items: FetchedResults<Item>
    
    init() {
        _items = FetchRequest<Item>(fetchRequest: request())
    }
    
    var body: some View {
        List(items) {
            Text($0.name)
        }
    }
    
    func request() -> NSFetchRequest<Item> {
        let request = Item.fetchRequest()
        request.sortDescriptors = []
        request.fetchBatchSize = 5
        
        return request
    }
}

The problem: @FetchRequest loads all the records at the same time without batching and/or it does work but retrieves all the batches at the same time and defeating the whole purpose of batch retrieving.

I tried actually loading 1 mil rows and takes a lot of time to show the view (as it is retrieving and preparing all 1 mil rows of data). If '.fetchBatchSize' worked it will load only the first 5 and will load slowly as the list scrolls.

*Note: I know there is .fetchLimit & .fetchOffset, but it would require to implement a separate logic *


Solution

  • You can't use fetchBatchSize because the List needs all of the objectIDs that SwiftUI diffs to detect inserts, removes & moves. That's why it loads all the batches if you set a batch size which is much slower than no batching.

    You can however disable includesPropertyValues to prevent Core Data loading all of the data into its row cache.

    With it disabled the SQL query becomes:

    SELECT 0, t0.Z_PK FROM ZITEM t0
    

    Instead of the default of enabled where it fetches all fields:

    SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZNAME, t0.ZTIMESTAMP FROM ZITEM t0
    

    To see the queries use launch argument -com.apple.CoreData.SQLDebug 4 (higher the number the more log output).

    You can also take advantage of List's lazy behaviour by moving the $0.name into a body of a child View so that the Core Data only hits the database to read the data and fire the fault of the object when it scrolls on to the screen, e.g. like RowView below, then you'll see this in the log:

    CoreData: details: SQLite: EXPLAIN QUERY PLAN SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZTIMESTAMP, t0.ZTITLE FROM ZITEM t0 WHERE  t0.Z_PK = ? 
         2 0 0 SEARCH t0 USING INTEGER PRIMARY KEY (rowid=?)
    CoreData: annotation: fault fulfilled from database for : 0xa17723cef738c1a0 <x-coredata://FA6F121D-805F-4558-AAAC-F5F60A117256/Item/p9778> with row values: <NSSQLRow: 0x600001745b60>{Item 1-9778-1 timestamp=2022-02-24 13:58:39 +0000 title=NULL and to-manys=0x0}
    

    These improvements may give a large enough increase in performance and would look like this:

    extension Item {
        static func myFetchRequest() -> NSFetchRequest<Item> {
            let fr = Self.fetchRequest()
            fr.includesPropertyValues = false
            fr.sortDescriptors = [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)]
            return fr
        }
    }
    
    struct RowView: View {
        @ObservedObject var item: Item
        
        var body: some View {
            Text(item.timestamp!, formatter: itemFormatter)
        }
    }
    
    struct ContentView: View {
        @Environment(\.managedObjectContext) private var viewContext
    
        @FetchRequest(
            fetchRequest: Item.myFetchRequest(),
            animation: .default)
        private var items: FetchedResults<Item>
    
        var body: some View {
            NavigationView {
                List {
                    ForEach(items) { item in
                        RowView(item: item)
    ...
    

    If you are using @SectionedFetchRequest then you will need to fetch the fields used in sectioning and that can be done by leaving includesPropertyValues its default of true and instead setting propertiesToFetch to the array of property names that are used for sectioning.

    E.g.

    extension Item {
        static func myFetchRequest() -> NSFetchRequest<Item> {
            let fr = Self.fetchRequest()
            fr.propertiesToFetch = ["title"]
            fr.sortDescriptors = [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)]
            return fr
        }
    }
    

    Results in an initial query of:

    CoreData: sql: SELECT 1, t0.Z_PK, t0.ZTITLE FROM ZITEM t0 ORDER BY t0.ZTIMESTAMP
    

    Which as you can see does not select timestamp. As the List scrolls and the object is accessed other queries are done to retrieve all fields.