📜 ⬆️ ⬇️

History of Chatto

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:

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:


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.


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:

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 ):


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



Badoo development team for iOS

Source: https://habr.com/ru/post/278581/


All Articles