Reputation: 4287
(Update: In Edit 4 below, I definitely found the cause of my problem!)
I'm using a tableView
with a NSFetchedResultsController
. That's how I fetch the data (I call this in viewDidLoad()
) :
let fetchRequest: NSFetchRequest<Entry> = Entry.fetchRequest()
let sortSections = NSSortDescriptor(key: #keyPath(Entry.section), ascending: false)
let sortDate = NSSortDescriptor(key: #keyPath(Entry.date), ascending: true)
fetchRequest.sortDescriptors = [sortSections, sortDate]
fetchRequest.fetchBatchSize = 15 // this seems to have no impact
fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: managedObject, sectionNameKeyPath: #keyPath(Entry.section), cacheName: "EntriesCache")
This, somehow, is very slow (I notice this when I segue to the view controller
that contains this table view
).
On my device, I tried it with about 200 Entry
objects in my database. It takes slightly more than 1 seconds for the view controller
to appear. But I also tried it with about 10 objects, it's not that much faster. (Strangely, on the simulator it's incredibly fast)
I tried to analyse it by using the Time Profiler
. During this 1 second, the CPU is at 100%. Is that normal?
Before I noticed this slow performance, I didn't have this line
fetchRequest.fetchBatchSize = 15
I added it but nothing changed. It's not even a tiny bit faster. Also I printed the count of the fetched objects after they have loaded:
print(fetchedResultsController.fetchedObjects?.count)
It says that all objects are loaded, not just 15 of them (as you can't see more than that at once in the table view). Why is that?
That's my Entry
Entity I use for the table view
I don't know what code/information you need in order to be able to help me (I'm not an expert in terms of performance issues). Please tell me, if you need anything more than that.
Thanks you guys!
Edit:
How I access the managedObjectContext:
lazy var managedObject: NSManagedObjectContext = {
let managedObject = self.appDelegate.persistentContainer.viewContext
return managedObject
}()
Edit 2 (maybe I found the cause?):
Okay, so I edited my scheme so that it shows me all SQL queries. First, it loads several times 15 rows (when 15 is the fetchBatchSize
). But after that it gets interesting:
I didn't exactly count it, but I'm pretty sure that it does the following query(/queries) for every object there is in the database. I tried it with 600 objects or so and it took quite a while for these SQL queries to run through:
CoreData: sql: SELECT t0.Z_ENT, t0.Z_PK, Z_FOK_ENTRY FROM ZENTRYTEXT t0 WHERE t0.ZENTRY = ?
CoreData: annotation: sql connection fetch time: 0.0001s
CoreData: annotation: total fetch execution time: 0.0002s for 1 rows.
CoreData: annotation: to-many relationship fault "entryTexts" for objectID 0xd000000006480000 <x-coredata://C53DABDD-5D31-4ADE-B6E7-3ED69454B572/Entry/p402> fulfilled from database. Got 1 rows
CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZTEXT, t0.ZENTRY, t0.Z_FOK_ENTRY FROM ZENTRYTEXT t0 WHERE t0.Z_PK = ?
CoreData: annotation: sql connection fetch time: 0.0001s
CoreData: annotation: total fetch execution time: 0.0002s for 1 rows.
CoreData: annotation: fault fulfilled from database for : 0xd000000007940002 <x-coredata://C53DABDD-5D31-4ADE-B6E7-3ED69454B572/EntryText/p485>
I don't know what exactly that is, but I think it's causing the delay. After these queries ran through, the view controller is being displayed.
Here are my table view
datasource methods:
func numberOfSections(in tableView: UITableView) -> Int {
guard let sections = fetchedResultsController.sections else {
return 0
}
return sections.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard let sectionInfo = fetchedResultsController.sections?[section] else {
return 0
}
return sectionInfo.numberOfObjects
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "bitCell") as! BitCell
let entry = fetchedResultsController.object(at: indexPath)
cell.configure(entry: entry)
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let entry = fetchedResultsController.object(at: indexPath)
extendBitPopup.fadeIn(withEntry: entry, completion: nil)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.contentOffset.y >= 400 {
UIView.animate(withDuration: 0.5, animations: {
self.arrowUpButton.alpha = 1.0
self.arrowUpButton.isEnabled = true
self.arrowUpButton.isUserInteractionEnabled = true
})
} else {
UIView.animate(withDuration: 0.5, animations: {
self.arrowUpButton.alpha = 0.0
self.arrowUpButton.isEnabled = false
self.arrowUpButton.isUserInteractionEnabled = false
})
}
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
let entry = fetchedResultsController.object(at: indexPath)
guard !entry.isFault else {
return 0
}
// this estimates the height the cell needs when the text is inserted
return BitCell.suggestedHeight(forEntry: entry)
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
if let sectionInfo = fetchedResultsController.sections?[section] {
let dateFormatter = DateFormatter()
// Entry.section has this format: "yyyyMMdd" I chose this to make a section for each day.
dateFormatter.dateFormat = "yyyyMMdd"
let date = dateFormatter.date(from: sectionInfo.name)!
dateFormatter.dateStyle = .full
dateFormatter.timeStyle = .none
return dateFormatter.string(from: date)
}
return ""
}
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
return 25
}
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
let view = UIView()
return view
}
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let moment = UITableViewRowAction(style: .normal, title: "Moment") { (action, indexPath) in
let entry = self.fetchedResultsController.object(at: indexPath)
entry.isMoment = !entry.isMoment
self.appDelegate.saveContext()
tableView.setEditing(false, animated: true)
}
moment.backgroundColor = AppTheme.baseGray
let delete = UITableViewRowAction(style: .destructive, title: "Delete") { (action, index) in
let entry = self.fetchedResultsController.object(at: indexPath)
self.managedObject.delete(entry)
self.appDelegate.saveContext()
tableView.setEditing(false, animated: true)
}
delete.backgroundColor = AppTheme.errorColor
return [delete, moment]
}
The problem is this function:
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
let entry = fetchedResultsController.object(at: indexPath)
guard !entry.isFault else {
return 0
}
return BitCell.suggestedHeight(forEntry: entry)
}
I played around with this and now I'm almost certain that this line is the troublemaker:
let entry = fetchedResultsController.object(at: indexPath)
If I return a static CGFloat right before this line, the view controller loads almost instantly (I tested it with 700 objects). Also, it then fetches only the first 50 items (that's the fetchBatchSize
) and it only loads more if you scroll down.
If I return after this line, it fetches all of the data (according to the many SQL queries), it gets extremely slow and this whole delay problem appears.
So, I think the problem occurs if this line from above tries to get an object that is faulted
(maybe it then tries to refetch from the database or something)
Now's the question: How to solve this? I need the Entry
object in order to estimate the cell height, but I only want to call this line if I know that the object isn't faulted (if that's the problem). How can I do that?
Upvotes: 4
Views: 1134
Reputation: 119242
Use the estimated height delegate method, and return a fixed size. The table view should then only query for the actual height of a row when it needs to display that row, so it can properly use the faulting and batching features of the fetch results controller.
If a table has, say 400 rows, and you've implemented heightForRow, then it will call the delegate method for every single row in the table so that it can calculate the content size of the table view. Asking the results controller for the object at a certain index will convert it from a fault automatically, and in any case returning zero size will completely mess up the content size of your table.
If you supply an estimated size instead, either by using the delegate method or setting it as a property on the table, then the table view will only call the specific height method for rows that are, or are about to be, displayed. It will use the estimated height to make a guess at the table view's content size. This means the content size fluctuates slightly as you scroll, but this is not really noticeable.
Upvotes: 3