-
Notifications
You must be signed in to change notification settings - Fork 365
Performance Pro
Ready to take your game to the next level? Read on.
In the Performance Primer article we learned:
- Database connections aren't free.
- You should consider connections to be relatively heavy weight objects.
- You get a lot of bang for your buck if you recycle your connections.
However, all the wiki articles continually demonstrate one database connection per viewController... This may be fine if there are only a small number of viewControllers in the app. But what about larger apps? What about when the number of viewControllers gets bigger?
The articles are this way for the sake of simplicity. (Learning is a process — take one step at a time.) But now that you've made it to the Performance Pro article, you're ready to contemplate an alternative technique.
There are a couple downfalls to the one-databaseConnection-per-viewController technique. Recall that every databaseConnection maintains its own cache. The cache is a wonderful performance boost. Not only does it cut down on disk IO, it also allows you to skip the overhead of deserializing your objects. Now imagine that you bring up a tableViewController which is backed by data from the database. The tableViewController uses its own separate databaseConnection. The first time you bring it up you'll fetch a bunch of rows from the database, and the databaseConnection will handily cache them for you. Then you hit the back button in the navigation bar, and the tableViewController and databaseConnection are deallocated. Then you bring up the same tableViewController again... (this is a common pattern in many apps) The tableViewController will create a new databaseConnection, and will have to fetch all those items from disk, and deserialize them again. The database may be so fast that you don't even notice. But it would certainly be a lot more efficient if the same databaseConnection was still around, and we could take advantage of the existing cache.
In addition to this, the "flow" from viewController to viewController within an app is usually related. For example, the user is scrolling around in a tableView, and taps a cell. Whatever that cell was displaying is likely related to the viewController that is about to be displayed. And thus there is a high likelihood that something the new viewController needs is already in the cache.
Thus you can get a nice little performance boost by sharing the same databaseConnection between your viewControllers. In fact, this is recommended if your app has a significant number of concurrently running viewControllers.
It's fairly easy to switch to a shared uiDatabaseConnection setup. Start by creating the connection in some shared location:
extension Notification.Name {
static let UIDBConnectionWillUpdate = Notification.Name("UIDBConnectionWillUpdate")
static let UIDBConnectionDidUpdate = Notification.Name("UIDBConnectionDidUpdate")
}
class DBManager {
private var _uiDatabaseConnection: YapDatabaseConnection?
public func uiDatabaseConnection() -> YapDatabaseConnection? {
assert(Thread.isMainThread, "Can't use the uiDatabaseConnection outside the main thread")
return _uiDatabaseConnection
}
private func setupUIDatabaseConnection(_ db: YapDatabase) {
_uiDatabaseConnection = db.newConnection()
// Configure it however you want
_uiDatabaseConnection?.objectCacheLimit = 1000;
_uiDatabaseConnection?.metadataCacheLimit = 1000;
_uiDatabaseConnection?.name = "uiDatabaseConnection"
#if DEBUG
// Our intention is:
// - we will only use this connection on the main thread
// - we will only use this connection for read-only transactions
// - we will never perform an asyncRead transaction either
//
// YapDatabase can help us enforce this policy when in DEBUG mode !
//
_uiDatabaseConnection?.permittedTransactions = [.YDB_MainThreadOnly, .YDB_SyncReadTransaction]
#endif
_uiDatabaseConnection?.enableExceptionsForImplicitlyEndingLongLivedReadTransaction()
_uiDatabaseConnection?.beginLongLivedReadTransaction()
let nc = NotificationCenter.default
nc.addObserver( self,
selector: #selector(self.databaseModified(notification:)),
name: Notification.Name.YapDatabaseModified,
object: db)
}
@objc func databaseModified(notification: Notification) {
guard let uiDatabaseConnection = _uiDatabaseConnection else {
return
}
let nc = NotificationCenter.default
// Notify observers we're about to update the database connection
nc.post(name: Notification.Name.UIDBConnectionWillUpdate, object: self)
// Move uiDatabaseConnection to the latest commit.
// Function returns all the notifications for each commit we jump.
let notifications = uiDatabaseConnection.beginLongLivedReadTransaction()
// Notify observers that the uiDatabaseConnection was updated
let userInfo = ["notifications": notifications]
nc.post(name: Notification.Name.UIDBConnectionDidUpdate,
object: self,
userInfo: userInfo)
}
}
Here's the idea:
- Each viewController will use this uiDatabaseConnection (rather than creating its own)
- Instead of listening for
YapDatabaseModified
, the viewController will listen forUIDBConnectionDidUpdate
- It can also listen for
UIDBConnectionWillUpdate
if it needs to do anything special before the uiDatabaseConnection jumps forward to the latest commit.
Example code for a viewController:
class MyViewController {
var uiConnection: YapDatabaseConnection!
}
override func viewDidLoad() {
uiConnection = DBManager.sharedInstance.uiDatabaseConnection!
let nc = NotificationCenter.default
nc.addObserver( self,
selector: #selector(self.uiConnectionDidUpdate(notification:)),
name: .UIDBConnectionDidUpdate,
object: nil)
// ...
}
@objc func uiConnectionDidUpdate(notification: Notification) {
let notifications: [Notification] = notification.userInfo?["notifications"] as? [Notification] ?? []
// update UI as needed
}
Say you have 10,000 items you want to insert into the database. Further, you have a YapDatabaseView that is going to be sorting these items. Understanding how the View interacts with the Cache can have a significant impact upon the total insertion time.
Let us imagine 2 scenarios:
- Inserting 10,000 items that are pre-sorted. (e.g. the order we insert them matches their order in the YapDatabaseView.)
- Inserting 10,000 items that are un-sorted. (e.g. the order we insert them does NOT match the order in the YapDatabaseView.)
For scenario #1 you won't encounter any performance issues thanks to an optimization within YapDatabaseView. It's very common to always insert objects at either the "beginning" or "end" of a YapDatabaseView. (This is especially common when sorting items by date.) So YapDatabaseView remembers if the last insert ended up at the "beginning" or "end" of the view. And, if so, then it will quickly perform a comparison with either the first or last item in the view.
Thus, for scenario #1, the inserts will occur with very little overhead. The YapDatabaseView will only need to perform a single comparison. And further, the object to compare with should be available in the cache.
So, to optimize performance, perform batch inserts "in order" (according to your YapDatabaseView) whenever possible. Often times this isn't something that most people have to think about. That is, if they have a big batch of data to insert into the database, it's highly likely the batch is already sorted. For example, it's something they fetched from a server, and it's already sorted by date.
For scenario #2, it's a bit more work for the View:
- on every insert, the View needs to calculate the correct location for the inserted item
- to do this, it will use the sortingBlock you created for it
- it will use a binary search algorithm, and can find the correct location in O(log n)
- but that still means there are potentially log n faults per-insert (fault == database needs to fetch item from disk)
- which means we have the potential for N * log N total faults
To improve performance, we can take advantage YapDatabase's fine grained control over the cache size. You can change the cache size on-the-fly, within a transaction. So if you're going to do a big batch insert of randomly ordered items, while using a view, it's easy to temporarily increase the cache size. This will give YapDatabaseView a performance boost for its sorting task. Here's how:
dbConnection.asyncReadWrite {(transaction) in
// Our 'people' array is randomly sorted, and bigger than our cache size.
// So, to avoid lots of 'faulting' during the sorting of each person,
// we temporarily increase the cache size.
let originalCacheLimit = dbConnection.objectCacheLimit
dbConnection.objectCacheLimit = people.count
for person in people {
transaction.setObject(person, forKey: person.id, inCollection: "people")
}
dbConnection.objectCacheLimit = originalCacheLimit
}
Keep in mind that there is a trade-off here concerning speed vs memory consumption.
Hungry for more performance tips? Check out the Object Policy article to learn how to reduce disk IO and memory consumption.