Our chat is outdated: for several years of evolution, it has become a cumbersome View Controller with strange fixes that no one could figure out. It became difficult to add new types of messages, but new bugs appeared with ease. Therefore, we decided to rewrite the chat to Swift from scratch and put it in
open source .
We began work on the project, setting two goals:
- scalable architecture : we needed the ability to easily add new message types without sacrificing previously written code;
- good performance : we wanted to ensure smooth loading and scrolling of messages.
This article will describe in more detail how we achieved our goals, what methods were used and what we got in the end. On our
page on GitHub there is a fairly detailed description of the application architecture.

Which is better: UICollectionView or UITableView?
UITableView was used in our old chat. It is quite good, but UICollectionView offers a richer API with more options for customizations (
animation ,
UIDynamics , etc.) and optimization (UICollectionViewLayout and UICollectionViewLayoutInvalidationContext).
Moreover, we
studied several already existing chat applications and it turned out that they all use UICollectionView. Therefore, the decision in favor of choosing a UICollectionView was self-evident.
Text messaging
No chat can do without clouds with text. In truth, in terms of performance, it is the most difficult to implement exactly this type of message, since text rendering and scaling is slow. We wanted the chat to automatically detect links and perform regular actions, as iMessage does.
UITextView initially has support for all these requirements, so there is no need to write a single line of code for link processing. Therefore, we chose this class, but this solution has become a source of problems for us. Next we will tell why.
')
Auto Layout and Self-Sizing Cells
Layout and sizing always cause difficulties: it is very easy to write duplicate code, and it is more difficult to maintain and it leads to the appearance of bugs, so we tried to avoid it. Since we provided support for iOS 8 from the very beginning, it was decided to try auto layout and sizing cells.
Here is a thread with a general description of the implementation of such an approach. After trying, we faced two major problems:
- jumps while scrolling . Usually in chat rooms, scrolling occurs from bottom to top, so at the beginning the sizes of the lower cells are considered, and then, during scrolling, the sizes of the cells appearing from above are considered. At the same time, the exact size of the cells located above is not known in advance, and for calculating the contentSize and the position of the lower cells, the UICollectionView uses the indicated estimatedItemSize. To get the exact cell size, UICollectionViewFlowLayout calls the preferredLayoutAttributesFittingAttributes (_ :) method of UICollectionViewCell. Then, since this size does not correspond to the specified estimatedItemSize, the position of the cells created earlier is adjusted, which leads to their downward shift. We could bypass this bug by turning the UICollectionView and UICollectionViewCells 180º (the lower cells would actually be the topmost ones), but there was another problem, namely ...
- poor scrolling performance . We could not achieve scrolling at a speed of 60 frames per second, even with precisely calculated cell sizes. The bottleneck was Auto Layout and UITextView resizing. We were not very surprised, because we knew that Apple does not use Auto Layout inside cells in iMessage. It does not follow at all that the Auto Layout should not be used; in fact, Badoo uses it very widely. However, it does have performance issues, usually affecting UICollectionView and UITableView.
Manual layout
So, for the layout, instead of Auto Layout, we decided to use the traditional approach. We settled on the classic method, in which a cell-blank is used to calculate the dimensions, and as much as possible of the general code would be used for the layout and size calculation. This approach worked much faster, but it was still not enough for the iPhone 4s.
Profiling revealed too much work inside the layoutSubviews method.
In fact, we performed the same work twice: at the beginning we considered the dimensions in a blank, and then we did it again in a real cell inside layoutSubviews. To solve this problem, we could cache sizeThatFits (_ :) values for UITextView, which is very expensive to calculate, but we went even further and created a layout model, within which the cell size and frames of all subviews were recorded and cached. As a result, we managed not only to significantly increase the scrolling speed, but also with maximum efficiency to reuse the code between calls to sizeThatFits (_ :) and layoutSubviews.
In addition, our attention was drawn to the updateViews method. With a small size, it turned out to be one of the main methods responsible for updating the cell in accordance with a given style and type of displayed data. Having one main method for updating the UI simplified the logic and maintenance of the code in the future, but it was called for almost every action that changes the properties of the cells. To cope with this problem, we have come up with two ways to optimize.
- Two different contexts : .Normal and .Sizing. The .Sizing context we used for our dummy cell to skip some redundant calls to updateViews (for example, updating the image of a little cloud or disabling link detection in UITextView).
- Batch update : we implemented the function performBatchUpdates (_: animated: completion) for cells. This allowed us to update the properties of the cells as many times as necessary, but in doing so, call updateViews only once.
More speed
We have already achieved a good scroll speed, but loading more messages (in batches of 50 units) caused the main thread to block too long, and this, in turn, suspended scrolling for a split second. Of course, the UITextView.sizeThatFits (_ :) function was again a bottleneck. We managed to significantly speed it up by disabling the ability to detect links and text selection in a blank cell and enable non-contiguous layout:
textView.layoutManager.allowsNonContiguousLayout = true textView.dataDetectorTypes = .None textView.selectable = false
After that, the simultaneous display of 50 new messages ceased to be a problem - provided that there were not very many messages before that. But we decided that we could go further.
Considering the level of abstractions that we achieved by caching and reusing the layout model to perform the tasks of calculating dimensions and position, we now had everything we needed to try to perform calculations in the background thread. But ... not counting UIKit.
As you know, UIKit is not thread safe, and our initial strategy (which was to simply ignore this fact) led to a number of expected failures in the UITextView. We knew that the NSString.boundingRectWithSize (_: options: attributes: context) method could be used in the background, but the sizes returned by it did not match the sizes obtained from UITextView.sizeThatFits (_ :). We spent a lot of time, but still managed to find a solution:
textView.textContainerInset = UIEdgeInsetsZero textView.textContainer.lineFragmentPadding = 0
We also used rounding sizes derived from NSString.boundingRectWithSize (_: options: attributes: context) to screen pixels using
extension CGSize { func bma_round() -> CGSize { return CGSize(width: ceil(self.width * scale) * (1.0 / scale), height: ceil(self.height * scale) * (1.0 / scale) ) } }
Thus, we could prepare the cache in the background thread, and then very quickly get all the sizes in the main thread - provided that the layout did not have to deal with 5,000 messages.
In this case, during the call to the UICollectionViewLayout.prepareLayout () method, the iPhone 4s began to slow down. The main bottleneck was the creation of UICollectionViewLayoutAttributes objects and the sizing of 5000 messages from NSCache. How did we solve this problem? We did the same thing as with the cells: we created a model for UICollectionViewLayout, which was involved in creating UICollectionViewLayoutAttributes, and in the same way we transferred its creation to the background thread. Now in the main thread we simply replaced the old model with a new one. And everything began to work amazingly fast, but ...
Rotation and Split View
While the device was rotating or resizing the Split View, the available width changed to show the messages, so you had to read all the sizes and positions of the messages again. For us, this was not a particular problem, since our application does not support rotation, but we were already going to release Chatto in open source and decided that decent support for rotation and Split View would be a big plus for these purposes. By that time, we had already implemented the size calculation in the background thread with smooth scrolling and loading of new messages, but this didn’t help much when the application had to deal with 10,000 messages. To calculate the size for such a large number of messages in the background, the iPhone 4s took from 10 to 20 seconds, and, of course, it was impossible to keep users waiting for so long. We have seen two ways to solve the problem:
- calculate the dimensions twice: for the first time - for the current width, and the second - for the width that the message on the device would accept after turning it 90º.
- avoid having to deal with 10,000 messages.
The first option is rather a hack than the actual solution — it doesn’t really help in Split View mode and doesn’t scale. Therefore, we have chosen the second method.
Sliding data source
After several tests on the iPhone 4s, we concluded that supporting fast rotation meant processing no more than 500 messages, so we implemented a sliding data source with a configurable number of simultaneously displayed messages. In accordance with this, when opening a chat, 50 messages were to be loaded first, and then the next portion of 50 messages would load as the user scrolled through the chat to see earlier entries. When the user scrolled back a sufficiently large number of messages, the first ones were deleted from the memory. So the pagination worked in both directions. Implementing this method was a fairly simple task, but now we had a problem in another case — when the data source was already filled and a new message arrived.
If 500 messages had already been received and a new one arrived, then it was necessary to delete the topmost message, move all the rest up one position and insert the just-received message into the chat. There was no difficulty with solving this either, but this approach was not liked by the UICollectionView.performBatchUpdates method (_: completion :). There were two main problems (they can be reproduced
here ):
- slow scrolling and jumps when receiving a large number of new messages;
- broken animation when adding a message due to a change in contentOffset.
To eliminate these problems, we decided to relax the restriction providing for the maximum number of messages. Now we allowed the application to insert new messages, breaking the set limit and thus ensuring a smooth update of the UICollectionView. After performing the insert and in the absence of unprocessed changes in the update queue, we sent a warning to the data source that there were too many messages. After that, we made the necessary adjustments using reloadData, not performBatchUpdates. Since we couldn’t particularly control the moment when it would happen, and considering that the user could scroll the chat to any position, we needed to inform the data source where the user had scrolled through the chat in order not to delete the messages that were currently being viewed. :
public protocol ChatDataSourceProtocol: class { ... func adjustNumberOfMessages(preferredMaxCount preferredMaxCount: Int?, focusPosition: Double, completion:(didAdjust: Bool) -> Void) }
Khaki UITextView
So, we have so far considered only the problems with the performance of Auto Layout and the calculation of sizes, as well as the obstacles to solving the problem of calculating the sizes in the background thread using NSString.boundingRectWithSize (_: options: attributes: context).
To take advantage of the ability to find links and some other available actions, we had to activate the UITextView.selectable property. This led to some undesirable side effects for the clouds (for example, you can select text and a magnifying glass). In addition, to support these features, UITextView uses a gesture recognition system that interfered with actions such as highlighting clouds with text and handling long clicks inside them. We are not going to talk in detail about what hacks we managed to get around these problems with, but you can learn more about it yourself by following the links:
ChatMessageTextView and
BaseMessagePresenter .
Interactive keyboard
In addition to the above problems, the work of UITextView affected the keyboard as well. The idea is that nowadays, the implementation of interactive keyboard hiding should be a fairly simple task. Just override inputAccessoryView and canBecomeFirstResponder in your controller, as shown
here . However, this method did not work efficiently when displaying UIActionSheet from UITextView, when the user made a long press on any link.
The essence of the problem was that the menu appeared under the keyboard and was not visible at all. Here is another
branch in which you can play
around with this problem
yourself (
rdar: // 23753306 ).
We tried to make the input field a part of the view controller hierarchy, track notifications from the keyboard, and manually change the contentInsets of a UICollectionView. However, when the user interacted with the keyboard, no notifications were received, and the input field was shown in the center of the screen, leaving a gap between it and the keyboard when the user pulled the keyboard down. This problem is solved with the help of a special hack, which consists in using a dummy inputAccessoryView (located under the input field) and observing it with the help of KVO. Read more about this
here .
Summary
- We tried to use Auto Layout, but due to insufficiently high performance we had to switch to manual layout.
- We came to our own positioning model, which allowed us to reuse the code in layoutSubViews and sizeThatFits (_ :), and also implemented the calculation of the layout in the background. It turns out that the solutions we found in something coincided with some ideas from the AsyncDisplayKit project.
- We implemented the performBatchUpdates method (_: animated: completion) and two separate contexts for the cells to minimize the number of view updates.
- We implemented a sliding data source with a limited number of messages in the code, thereby achieving a fast scaling when the device rotates and switches to Split View.
- UITextView turned out to be really hard to use, and it still remains a bottleneck that degrades performance when scrolling on older devices (iPhone 4s) due to link detection. Nevertheless, we continued to use it, because we needed the ability to perform regular actions when interacting with links.
- Because of the UITextView, we had to manually implement the interactive hiding of the keyboard by observing with the help of KVO the fictitious inputAccessoryView.
Badoo development team for iOS