8.
Measuring & Boosting Performance
Written by Matthew Morey
In many ways, it’s a no-brainer: You should strive to optimize the performance of any app you develop. An app with poor performance will, at best, receive bad reviews and, at worst, become unresponsive and crash.
This is no less true of apps that use Core Data. Luckily, most implementations of Core Data are fast and light already, due to Core Data’s built-in optimizations, such as faulting.
However, the flexibility that makes Core Data a great tool means you can use it in ways that negatively impact performance. From poor choices in setting up the data model to inefficient fetching and searching, there are many opportunities for Core Data to slow down your app.
You’ll begin the chapter with an app that’s a slow-working memory hog. By the end of the chapter, you’ll have an app that’s light and fast, and you’ll know exactly where to look and what to do if you find yourself with your own heavy, sluggish app — and how to avoid that situation in the first place!
Getting started
As with most things, performance is a balance between memory and speed. Your app’s Core Data model objects can exist in two places: in random access memory (RAM) or on disk.
Accessing data in RAM is much faster than accessing data on disk, but devices have much less RAM than disk space.
iOS devices, in particular, have less available RAM, which prevents you from loading tons of data into memory. With fewer model objects in RAM, your app’s operations will be slower due to frequent slow disk access. As you load more model objects into RAM, your app will probably feel more responsive, but you can’t starve out other apps or the OS will terminate your app!
The starter project
The starter project, EmployeeDirectory, is a tab bar-based app full of employee information. It’s like the Contacts app, but for a single fictional company.
Open the EmployeeDirectory starter project for this chapter in Xcode and build and run it.
The app will take a long time to launch and once it does launch, it will feel sluggish and may even crash as you use it. Rest assured, this is by design!
Note: It’s possible the starter project may not even launch on your system. The app was architected to be as sluggish as possible while still able to run on most systems, so the performance improvements you’ll make will be easily noticeable. If the app refuses to work on your system, continue to follow along. The first set of changes you make should enable the app to work on even the slowest devices.
As you can see in the following screenshots, the first tab includes a table view and a custom cell with basic information, such as name and department, for all employees.
Tap a cell to reveal more details for the selected employee, such as start date and remaining vacation days.
Tap the profile picture of the employee makes the picture full-screen; tap anywhere on the full-screen picture to dismiss it.
The startup time of the app is quite long, and the scrolling performance of the initial employee list could use some work. The app also uses a lot of memory, which you’ll measure yourself in the next section.
Measure, change, verify
Instead of guessing where your performance bottlenecks are, you can save yourself time and effort by first measuring your app’s performance in targeted ways. Xcode provides tools just for this purpose.
Ideally, you should measure performance, make targeted changes and then measure again to validate your changes had the intended impact.
You should repeat this measure–change–verify process as many times as needed until your app meets all of your performance requirements.
In this chapter, you’ll do just that:
- You’ll measure performance issues in the provided starter project using Gauges, Instruments and the XCTest framework.
- Next, you’ll make changes to the code that will improve the performance of the app.
- Finally, you’ll verify the changes had the intended results by measuring again.
You’ll then repeat this cycle until EmployeeDirectory performs like a Core Data champ!
Measuring the problem
Build, run, and wait for the app to launch. Once it does, use the Memory Report to view how much RAM the app is using.
To launch the Memory Report, first verify that the app is running and then perform the following steps:
- Click on the Debug navigator in the left navigator pane.
- To get more information, expand the running process — in this case, EmployeeDirectory — by tapping on the arrow.
Now, click on the Memory row and look at the top half of the memory gauge:
The top half includes a Memory Use gauge showing the amount and percentage of memory your app is using. For EmployeeDirectory, you’ll see somewhere between 100 MB and 400 MB of memory is in use, or about 10% of the available RAM on an iPhone 6S, which is the least-performant device that still runs iOS 14.
The Usage Comparison pie chart depicts this chunk of memory as a fraction of the total available memory. It also shows the amount of RAM in use by other processes, as well as the amount of available free RAM.
Now, look at the bottom half of the Memory Report:
The bottom half consists of a chart showing RAM usage over time. For EmployeeDirectory, you’ll see two distinct areas.
-
Upon first launch, EmployeeDirectory performs an import operation before loading the primary employee list. Ignore these spikes in memory for now.
-
The next chunk of memory usage takes place after the import operation, when the employee list is visible. Once the app has fully loaded the list, you can see the memory usage is fairly stable.
Note: If you use a device besides an iPhone 6S or iPhone SE, including the iOS Simulator, your memory gauge may not look exactly like these screenshots. The utilization percentages will be based off of the amount of available RAM on your test device, which may not match the RAM available on an iPhone 6S.
The RAM usage is quite high, considering there’s only 50 employee records in the app. The data model itself could be at fault here, so that’s where you’ll start your investigation.
Exploring the data source
In Xcode, open the project navigator and click on EmployeeDirectory.xcdatamodeld to view the data model. The model for the starter project consists of an Employee entity with 11 attributes and a Sale entity with two attributes.
On the Employee entity, the about, address, department, email, guid, name and phone attributes are string types; active is a Boolean; picture is binary data; startDate is a date and vacationDays is an integer. Employee has a to-many relationship with Sale, which contains an amount integer attribute and a date Date attribute.
On first launch, the app will import sample data from the bundled JSON file seed.json. Here’s an excerpt of the JSON:
{
"guid": "769adb89-82ad-4b39-be41-d02b89de7b94",
"active": true,
"picture": "face10.jpg",
"name": "Kasey Mcfarland",
"vacationDays": 2,
"department": "Marketing",
"startDate": "1979-09-05",
"email": "kaseymcfarland@liquicom.com",
"phone": "+1 (909) 561-2981",
"address": "201 Lancaster Avenue, West Virginia, 2583",
"about": "Dolore reprehenderit ... voluptate consectetur.\r\n"
},
Note: You can vary the amount and type of data the app imports from the seed.json file by modifying the
amountToImportandaddSalesRecordsconstants located at the top of AppDelegate.swift. For now, leave these constants set to their default values.
In terms of performance, the text showing the employee names, departments, email addresses and phone numbers is inconsequential compared to the size of the profile pictures, which are large enough to potentially impact the performance of the list.
Now that you’ve measured the problem and have a baseline for future comparisons, you’ll make changes to the data model to reduce the amount of RAM in use.
Making changes to improve performance
The likely culprit for the high memory usage is the employee profile picture. Since the picture is stored as a binary data attribute, Core Data will allocate memory and load the entire picture when you access an employee record — even if you only need to access the employee’s name or email address!
The solution here is to split out the picture into a separate, related record. In theory, you’ll be able to access the employee record efficiently, and then take the hit for loading the picture only when you really need it.
To start, open the visual model editor by clicking on EmployeeDirectory.xcdatamodeld. Start by creating an object, or entity, in your model. In the bottom toolbar, click the Add Entity plus (+) button to add a new entity.
Name the entity EmployeePicture. Then click the entity and make sure the fourth tab is selected in the Utilities section. Change the class to EmployeePicture, Module to Current Product Module and Codegen to Manual/None.
Make sure the EmployeePicture entity is selected by clicking on either the entity name in the left panel or the diagram for the entity in the diagram view.
Next, click and hold on the plus (+) button in the lower-right (next to the Editor Style segmented control) and then click Add Attribute from the popup. Name the new attribute picture.
Finally, in the data model inspector, change the Attribute Type to Binary Data and check the Allows External Storage option.
Your editor should look similar to the following:
As previously mentioned, binary data attributes are usually stored right in the database. If you check the Allows External Storage option, Core Data automatically decides if it’s better to save the data to disk as a separate file or leave it in the SQLite database.
Select the Employee entity and rename the picture attribute to pictureThumbnail. To do so, select the picture attribute in the diagram view and then edit the name in the data model inspector.
You’ve updated the model to store the original picture in a separate entity and a thumbnail version on the main Employee entity. The smaller thumbnail pictures won’t require as much RAM when the app fetches Employee entities from Core Data. Once you’ve finished modifying the rest of the project, you’ll get a chance to test this out and verify the app is using less RAM than before.
You can link the two entities together with a relationship. That way, when the app needs the higher-quality, larger picture, it can still retrieve it via a relationship.
Select the Employee entity and click and hold the plus (+) button in the lower right. This time, select Add Relationship. Name the relationship picture, set the destination as EmployeePicture and finally, set the Delete Rule to Cascade.
Core Data relationships should always go both ways, so now add a corresponding relationship. Select the EmployeePicture entity and add a new relationship. Name the new relationship employee, set the Destination to Employee and finally, set the Inverse to picture.
Your model should now look like this:
Now that you’ve finished making changes to the model, you need to create an NSManagedObject subclass for the new EmployeePicture entity. This subclass will let you access the new entity from code.
Right-click on the EmployeeDirectory group folder and select New File. Select the Cocoa Touch Class template and click Next. Name the class EmployeePicture and make it a subclass of NSManagedObject. Make sure Swift is selected for the Language, click Next and finally click Create.
Select EmployeePicture.swift from the project navigator and replace the automatically generated code with the following code:
import Foundation
import CoreData
public class EmployeePicture: NSManagedObject {
}
extension EmployeePicture {
@nonobjc
public class func fetchRequest() ->
NSFetchRequest<EmployeePicture> {
return NSFetchRequest<EmployeePicture>(
entityName: "EmployeePicture")
}
@NSManaged public var picture: Data?
@NSManaged public var employee: Employee?
}
This is a very simple class with just two properties. The first, picture, matches the single attribute on the EmployeePicture entity you just created in the visual data model editor. The second property, employee, matches the relationship you created on the EmployeePicture entity.
Note: You could also have Xcode create the EmployeePicture class automatically. To add a new class this way, open EmployeeDirectory.xcdatamodeld, go to Editor ▸ Create NSManagedObject Subclass…, select the data model and then select the EmployeePicture entity in the next two dialog boxes. Select Swift as the language option in the final box. If you’re asked, say No to creating an Objective-C bridging header. Click Create to save the file.
Next, select the Employee.swift file from the project navigator and update the code to make use of the new pictureThumbnail attribute and picture relationship. Rename the picture variable to pictureThumbnail and add a new variable named picture of type EmployeePicture. Your variables will now look like this:
@NSManaged public var about: String?
@NSManaged public var active: NSNumber?
@NSManaged public var address: String?
@NSManaged public var department: String?
@NSManaged public var email: String?
@NSManaged public var guid: String?
@NSManaged public var name: String?
@NSManaged public var phone: String?
@NSManaged public var pictureThumbnail: Data?
@NSManaged public var picture: EmployeePicture?
@NSManaged public var startDate: Date?
@NSManaged public var vacationDays: NSNumber?
@NSManaged public var sales: NSSet?
Next, you need to update the rest of the app to make use of the new entities and attributes.
Open EmployeeListViewController.swift and find the following lines of code in tableView(_:cellForRowAt:).
It should be easy to find, as it will have an error marker on the next line!
if let picture = employee.picture {
This code gets the picture data from the employee object, ready to make the image. Now the full picture is held in a separate entity, you should use the newly added pictureThumbnail attribute. Update the file to match the following code:
if let picture = employee.pictureThumbnail {
Next, open EmployeeDetailViewController.swift and find the following code within configureView(). Again, it should be next to an error:
if let picture = employee.picture {
You’ll need to update the picture that’s set, just as you did in EmployeeListViewController.swift. Like the cell picture, the employee detail view will only have a small picture and therefore only needs the thumbnail version. Update the code to look like the following:
if let picture = employee.pictureThumbnail {
Next, open EmployeePictureViewController.swift and find the following code in configureView():
guard let employeePicture = employee?.picture else {
return
}
This time, you want to use the high-quality version of the picture, since the image will be shown full-screen. Update the file to use the picture relationship you created on the Employee entity to access the high-quality version of the picture:
guard let employeePicture = employee?.picture?.picture else {
return
}
There’s one more thing to do before you build and run. Open AppDelegate.swift and find the following line of code in importJSONSeedData(_:):
employee.picture = pictureData
Now that you have a separate entity for storing the high-quality picture, you need to update this line of code to set the pictureThumbnail attribute and the picture relationship.
Replace the line above with the following:
employee.pictureThumbnail =
imageDataScaledToHeight(pictureData, height: 120)
let pictureObject =
EmployeePicture(context: coreDataStack.mainContext)
pictureObject.picture = pictureData
employee.picture = pictureObject
First, you use imageDataScaledToHeight to set the pictureThumbnail to a smaller version of the original picture. Next, you create a new EmployeePicture entity.
You set the picture attribute on the new EmployeePicture entity to the pictureData constant. Finally, you set the picture relationship on the employee entity to the newly-created picture entity.
Note:
imageDataScaledToHeighttakes in image data, resizes it to the passed-in height and sets the quality to 80% before returning the new image data.
If you have an app that needs pictures and retrieves data via a network call, you should make sure the API doesn’t already include smaller thumbnail versions of the pictures. There’s a small performance cost associated with converting images on the fly like this.
Since you changed the model, delete the app from your testing device. Build and run the app. Give it a go! You should see exactly what you saw before:
The app should work as before, and you might even notice a small performance difference because of the thumbnails. But the main reason for this change was to improve memory usage.
Verify the changes
Now that you’ve made all the necessary changes to the project, it’s time to see if you actually improved the app.
While the app is running, use the Memory Report to view how much RAM the app is using. This time it’s consumed only about 20 MB to 60 MB of RAM, or about 2% of the total available RAM of the iPhone 6S.
Now look at the bottom half of the report. Like last time, the initial spike is from the import operation and you can ignore it. The flat area is much lower this time.
Congratulations, you’ve reduced this app’s RAM usage simply by making adjustments to its data model!
First, you measured the app’s performance using the Memory Report tool. Next, you made changes to the way Core Data stores and accesses the app’s data. Finally, you verified the changes improved the app’s performance.
Fetching and performance
Core Data is the keeper of your app’s data. Anytime you want to access the data, you have to retrieve it with a fetch request.
For example, when the app loads the employee list, it needs to perform a fetch. But each trip to the persistent store incurs overhead. You don’t want to fetch more data than you need — just enough so you aren’t constantly going back to disk. Remember, disk access is much slower than RAM access.
For maximum performance, you need to strike a balance between the number of objects you fetch at any given time and the usefulness of having many records taking up valuable space in RAM.
The startup time of the app is a little slow, suggesting something is going on with the initial fetch.
Fetch batch size
Core Data fetch requests include the fetchBatchSize property, which makes it easy to fetch just enough data, but not too much.
If you don’t set a batch size, Core Data uses the default value of 0, which disables batching.
Setting a non-zero positive batch size lets you limit the amount of data returned to the batch size. As the app needs more data, Core Data automatically performs more batch operations. If you searched the source code of the EmployeeDirectory app, you wouldn’t see any calls to fetchBatchSize. This indicates another potential area for improvement!
Let’s see if there’s any places you could use a batch size to improve the app’s performance.
Measuring the problem
You’ll use the Instruments tool to analyze where the fetch operations are in your app.
First, select one of the iPhone simulator targets and then from Xcode’s menu bar, select Product and then Profile (or press ⌘ + I). This will build the app and launch Instruments.
Note: You can only use the Instruments Core Data template with the Simulator, as the template requires the DTrace tool which is not available on real iOS devices. You may also need to select a development team for the Target to enable Instruments to run.
You’ll be greeted by the following selection window:
Select the Core Data template and click Choose. This will launch the Instruments window. If this is the first time you’ve launched Instruments, you might be asked for your password to authorize Instruments to analyze running processes — don’t worry, it’s safe to enter your password in this dialog.
Once Instruments has launched, click on the Record button in the top-left of the window.
Once EmployeeDirectory has launched, scroll up and down the employee list for about 20 seconds and then click on the Stop button that’s appeared in place of the Record button.
Click on the Fetches tool. The Instruments window should look similar to the following:
The default Core Data template includes the following tools to help you tune and monitor performance:
- Faults Instrument: Captures information about fault events that result in cache misses. This can help diagnose performance in low-memory situations.
- Fetches Instrument: Captures fetch count and duration of fetch operations. This will help you balance the number of fetch requests versus the size of each request.
- Saves Instrument: Captures information on managed object context save events. Writing data out to disk can be a performance and battery hit, so this instrument can help you determine whether you should batch things into one big save rather than many small ones.
Since you clicked on the Fetches tool, the details section at the bottom of the Instruments window shows more information about each fetch that occurred.
Each of the three rows corresponds to the same line of code in the app. The first two rows are private Core Data, so you can ignore them.
Pay attention to the last row, though. This row includes the fetch entity, fetch count and fetch duration in microseconds. To the right is the caller tree.
EmployeeDirectory imports 50 employees. The fetch count shows 50, which means the app is fetching all employees from Core Data at the same time. That’s not very efficient!
The Fetches tool corroborates your experience, the fetch is slow and it’s easily noticeable, as you can see it takes about 5,000 microseconds. The app has to complete this fetch before it makes the table view visible and ready for user interaction.
Note: Depending on your Mac, the numbers onscreen (and the thickness of the bars) might not match those shown in these screenshots. Faster Macs will have quicker fetches. Don’t worry — what’s important is the change in time you’ll see after you modify the project.
Changes to improve performance
Open EmployeeListViewController.swift and find the following line of code in employeeFetchRequest(_:):
let fetchRequest: NSFetchRequest<Employee> =
Employee.fetchRequest()
This code creates a fetch request using the Employee entity. You haven’t set a batch size, so it defaults to 0, which means no batching. Set the batch size on the fetch request to 10, replace the above with the following:
let fetchRequest: NSFetchRequest<Employee> =
Employee.fetchRequest()
fetchRequest.fetchBatchSize = 10
How do you come up with an optimal batch size? A good rule of thumb is to set the batch size to about double the number of items that appear onscreen at any given time. The employee list shows three to five employees onscreen at once, so 10 is a reasonable batch size.
Verify the changes
Now that you’ve made the necessary change to the project, it’s once again time to see if you’ve actually improved the app.
To test this fix, first build and run the app and make sure it still works.
Next launch Instruments again: from Xcode, select Product and then Profile, or press ⌘ + I) and repeat the steps you followed previously. Remember to scroll up and down the employee list for about 20 seconds before clicking the Stop button in Instruments.
Note: To use the latest code, make sure you launch the app from Xcode, which triggers a build, rather than just hitting the red button in Instruments.
This time, the Core Data Instrument should look like this:
Now there are multiple fetches, and the initial fetch is faster!
The first fetch looks similar to the original fetch, as it is fetching all 50 employees. This time, however, it’s only fetching the count of the objects, instead of the full objects, which makes the fetch duration much shorter. Core Data does this automatically, now that you’ve set a batch size on the request.
After the first fetch, you can see numerous fetches in batches of 10. As you scroll through the employee list, new entities are fetched only when needed.
You’ve cut the time of the initial fetch down to almost a third of the original, and the subsequent fetches are much smaller and faster. Congratulations, you have increased the speed of your app again!
Advanced fetching
Fetch requests use predicates to limit the amount of data returned. As mentioned above, for optimal performance, you should limit the amount of data you fetch to the minimum needed: the more data you fetch, the longer the fetch will take.
Fetch Predicate Performance: You can limit your fetch requests by using predicates. If your fetch request requires a compound predicate, you can make it more efficient by putting the more restrictive predicate first. This is especially true if your predicate contains string comparisons. For example, a predicate with a format of
"(active == YES) AND (name CONTAINS[cd] %@)"would likely be more efficient than"(name CONTAINS[cd] %@) AND (active == YES)".For more predicate performance optimizations please consult Apple’s Predicate Programming Guide: apple.co/2a1Rq2n.
Build and run EmployeeDirectory, and select the second tab labeled Departments. This tab shows a listing of departments and the number of employees in each department.
Tap a department cell to see a list of the employees in the selected department.
Tap the detail disclosure, also known as the information icon, in each department cell to show the total employees, active employees and a breakdown of employees’ available vacations days.
The first screen simply lists the departments and the number of employees per department. There’s not too much data here, but there could still be performance issues lurking here. Let’s find out.
Measure the problem
Instead of Instruments, you’ll use the XCTest framework to measure the performance of the department list screen. XCTest is usually used for unit tests, but it also contains useful tools for testing performance.
Note: For more information on unit tests and Core Data, check out Chapter 7, “Unit Testing”.
First, familiarize yourself with how the app creates the department list screen. Open DepartmentListViewController.swift and find the following code in totalEmployeesPerDepartment().
//1
let fetchRequest: NSFetchRequest<Employee> =
Employee.fetchRequest()
let fetchResults: [Employee]
do {
fetchResults =
try coreDataStack.mainContext.fetch(fetchRequest)
} catch let error as NSError {
print("ERROR: \(error.localizedDescription)")
return [[String: String]]()
}
//2
var uniqueDepartments: [String: Int] = [:]
for department in fetchResults.compactMap({ $0.department }) {
uniqueDepartments[department, default: 0] += 1
}
//3
return uniqueDepartments.map { department, headCount in
[
"department": department,
"headCount": String(headCount)
]
}
This code does the following:
- It creates a fetch request with the Employee entity and then fetches all employees.
- It iterates though the employees departments and builds a dictionary, where the key is the department name and the value is the number of employees in that department.
- It builds an array of dictionaries with the required information for the department list screen.
Now to measure the performance of this code.
Open DepartmentListViewControllerTests.swift (notice the Tests suffix in the filename) and add the following method:
func testTotalEmployeesPerDepartment() {
measureMetrics(
[.wallClockTime],
automaticallyStartMeasuring: false
) {
let departmentList = DepartmentListViewController()
departmentList.coreDataStack =
CoreDataStack(modelName: "EmployeeDirectory")
startMeasuring()
_ = departmentList.totalEmployeesPerDepartment()
stopMeasuring()
}
}
This function uses measureMetrics to see how long code takes to execute.
You have to set up a new Core Data stack each time so your results don’t get skewed by Core Data’s excellent caching abilities, which would make subsequent test runs really fast!
Inside the block, you first create a DepartmentListViewController and give it a CoreDataStack. Then, you call totalEmployeesPerDepartment to retrieve the number of employees per department.
Now you need to run this test. From Xcode’s menu bar, select Product and then Test, or press ⌘ + U. This will build the app and run the tests.
Once the tests have finished running, Xcode will look like this:
Notice two new things:
- There’s a green checkmark next to
testTotalEmployeesPerDepartment. That means the test ran and passed. - There’s a message on the right side with the amount of time the test took.
On the low-spec test device you might see times as long as 0.252 seconds to perform the totalEmployeesPerDepartment operation. These results might seem good, but there is still room for improvement.
Note: You might get somewhat different test results, depending on your test device. Don’t worry — what’s important is the change in time you’ll see after you modify the project.
Changes to improve performance
The current implementation of totalEmployeesPerDepartment uses a fetch request to iterate through all employee records. Remember the very first optimization in this chapter, where you split out the full-size photo into a separate entity? There’s a similar issue here: Core Data loads the entire employee record, but all you really need is a count of employees by department.
It would be more efficient to somehow group the records by department and count them. You don’t need details like employee names and photo thumbnails!
Open DepartmentListViewController.swift and add the following method below totalEmployeesPerDepartment():
func totalEmployeesPerDepartmentFast() -> [[String: String]] {
//1
let expressionDescription = NSExpressionDescription()
expressionDescription.name = "headCount"
//2
let arguments = [NSExpression(forKeyPath: "department")]
expressionDescription.expression = NSExpression(
forFunction: "count:",
arguments: arguments
)
//3
let fetchRequest: NSFetchRequest<NSDictionary> =
NSFetchRequest(entityName: "Employee")
fetchRequest.propertiesToFetch =
["department", expressionDescription]
fetchRequest.propertiesToGroupBy = ["department"]
fetchRequest.resultType = .dictionaryResultType
//4
var fetchResults: [NSDictionary] = []
do {
fetchResults =
try coreDataStack.mainContext.fetch(fetchRequest)
} catch let error as NSError {
print("ERROR: \(error.localizedDescription)")
return [[String: String]]()
}
return fetchResults as? [[String: String]] ?? []
}
This code still uses a fetch request to populate the department list screen, but it takes advantage of an NSExpression.
Here’s how it works:
- First, you create a
NSExpressionDescriptionand name itheadCount. - Next, you create a
NSExpressionwith thecount:function for thedepartmentattribute. - Next, you create a fetch request with the
Employeeentity. This time, the fetch request should only fetch the minimum required properties by usingpropertiesToFetch; you only need the department attribute and the calculated property the expression created earlier. The fetch request also groups the results by thedepartmentattribute. You’re not interested in the managed object, so the fetch request return type isDictionaryResultType. This will return an array of dictionaries, each containing a department name and an employee count — just what you need! - Finally, you execute the fetch request.
Find the following line of code in viewDidLoad():
items = totalEmployeesPerDepartment()
This line of code uses the old and slow function to populate the department list screen. Replace it by calling the function you just created:
items = totalEmployeesPerDepartmentFast()
Now the app populates the table view data source for the department list screen with the faster, NSExpression-backed fetch request.
Note: NSExpression is a powerful API, yet it is seldom used, at least directly. When you create predicates with comparison operations, you may not know it, but you’re actually using expressions. There are many pre-built statistical and arithmetical expressions available in NSExpression, including
average,sum,count,min,max,median,modeandstddev.Consult the NSExpression documentation for a comprehensive overview.
Verify the changes
Now that you’ve made all the necessary changes to the project, it’s once again time to see if you’ve improved the app’s performance.
Open DepartmentListViewControllerTests.swift and add a new function to test the totalEmployeesPerDepartmentFast function you just created.
func testTotalEmployeesPerDepartmentFast() {
measureMetrics(
[.wallClockTime],
automaticallyStartMeasuring: false
) {
let departmentList = DepartmentListViewController()
departmentList.coreDataStack =
CoreDataStack(modelName: "EmployeeDirectory")
startMeasuring()
_ = departmentList.totalEmployeesPerDepartmentFast()
stopMeasuring()
}
}
As before, this test uses measureMetrics to see how long a particular function is taking; in this case, totalEmployeesPerDepartmentFast.
Now you need to run this test. From Xcode’s menu bar, select Product and then Test, or press ⌘ + U. This will build the app and run the tests. Once the tests have finished running, Xcode will look similar to the following:
This time, you’ll see two messages with total execution time, one next to each test function.
Note: If you don’t see the time messages, you can view the details of each individual test run in the logs generated during the test. From Xcode’s menu bar, select View, Debug Area, and then Show Debug Area.
Depending on your test device, the new function, totalEmployeesPerDepartmentFast, will take approximately 0.002 seconds to complete. That’s much faster than the 0.1 to 0.3 seconds used by the original function, totalEmployeesPerDepartment. You’ve increased the speed by over 100%!
Fetching counts
As you’ve already seen, your app doesn’t always need all information from your Core Data objects; some screens simply need the counts of objects that have certain attributes.
For example, the employee detail screen shows the total number of sales an employee has made since they’ve been with the company.
For the purposes of this app, you don’t care about the content of each individual sale — for example, the date of the sale or the name of the purchaser — only how many sales there are in total.
Measure the problem
You’ll use XCTest again to measure the performance of the employee detail screen.
Open EmployeeDetailViewController.swift and find salesCountForEmployee(_:).
func salesCountForEmployee(_ employee: Employee) -> String {
let fetchRequest: NSFetchRequest<Sale> = Sale.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "%K = %@",
argumentArray: [#keyPath(Sale.employee), employee]
)
let context = employee.managedObjectContext
do {
let results = try context?.fetch(fetchRequest)
return "\(results?.count ?? 0)"
} catch let error as NSError {
print("Error: \(error.localizedDescription)")
return "0"
}
}
This code fetches all sales for a given employee and then returns the count of the returned array.
Fetching the full sale object just to see how many sales exist for a given employee is probably wasteful. This might be another opportunity to boost performance!
Let’s measure the problem before attempting to fix it.
Open EmployeeDetailViewControllerTests.swift and find testCountSales().
func testCountSales() {
measureMetrics(
[.wallClockTime],
automaticallyStartMeasuring: false
) {
let employee = getEmployee()
let employeeDetails = EmployeeDetailViewController()
startMeasuring()
_ = employeeDetails.salesCountForEmployee(employee)
stopMeasuring()
}
}
Like the previous example, this function is using measureMetrics to see how long a single function takes to run. The test gets an employee from a convenience method, creates an EmployeeDetailViewController, begins measuring and then calls the method in question.
From Xcode’s menu bar, select Product and then Test, or press ⌘ + U. This will build the app and run the test.
Once the test has finished running, you’ll see a time next to this test method, as before.
The performance is not too bad — but there is still some room for improvement.
Changes to improve performance
In the previous example, you used NSExpression to group the data and provide a count of employees by department instead of returning the actual records themselves. You’ll do the same thing here.
Open EmployeeDetailViewController.swift and add the following code to the class below the salesCountForEmployee(_:) method.
func salesCountForEmployeeFast(_ employee: Employee) -> String {
let fetchRequest: NSFetchRequest<Sale> = Sale.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "%K = %@",
argumentArray: [#keyPath(Sale.employee), employee]
)
let context = employee.managedObjectContext
do {
let results = try context?.count(for: fetchRequest)
return "\(results ?? 0)"
} catch let error as NSError {
print("Error: \(error.localizedDescription)")
return "0"
}
}
This code is very similar to the function you reviewed in the last section. The primary difference is that instead of calling execute(_:), you now call count(for:). Find the following line of code in configureView():
salesCountLabel.text = salesCountForEmployee(employee)
This line of code uses the old sales count function to populate the label on the department details screen. Replace it by calling the function you just created:
salesCountLabel.text = salesCountForEmployeeFast(employee)
Verify the changes
Now that you’ve made the necessary changes to the project, it’s once again time to see if you’ve improved the app. Open EmployeeDetailViewControllerTests.swift and add a new function to test the salesCountForEmployeeFast function you just created.
func testCountSalesFast() {
measureMetrics(
[.wallClockTime],
automaticallyStartMeasuring: false
) {
let employee = getEmployee()
let employeeDetails = EmployeeDetailViewController()
startMeasuring()
_ = employeeDetails.salesCountForEmployeeFast(employee)
stopMeasuring()
}
}
This test is identical to the previous one, except it uses the new and, with any luck, faster function.
From Xcode’s menu bar, select Product and then Test, or press ⌘U. This will build the app and run the test.
Looks great — another performance improvement under your belt!
Using relationships
The code above is fast, but the faster method still seems like a lot of work. You have to create a fetch request, create a predicate, get a reference to the context, execute the fetch request and get the results out.
The Employee entity has a sales property, which holds a Set containing objects of type Sale.
Open EmployeeDetailViewController.swift and add the following new method below the salesCountForEmployeeFast(_:) method:
func salesCountForEmployeeSimple(
_ employee: Employee
) -> String {
"\(employee.sales?.count ?? 0)"
}
That looks better. By using the sales relationship on the Employee entity, the code is much simpler — and easier to comprehend.
Update the view controller and tests to use this method instead, following the same pattern as above.
Check out the change in performance now.
Challenge
Using the techniques you just learned, try to improve the performance of the DepartmentDetailsViewController class. Don’t forget to write tests to measure the before and after execution times. As a hint, there are many methods that provide counts, rather than the full records; these can probably be optimized somehow to avoid loading the contents of the records.
Key points
- Most implementations of Core Data are fast and light already, due to Core Data’s built-in optimizations, such as faulting.
- When making improvements to Core Data performance you should measure, make targeted changes and then measure again to validate your changes had the intended impact.
- Small changes to the data model, such as moving large binary blobs to other entities, can improve performance.
- For optimal performance, you should limit the amount of data you fetch to the minimum needed: the more data you fetch, the longer the fetch will take.
- Performance is a balance between memory and speed. When using Core Data in your apps, always keep this balance in mind.