Build TikTok: Create a smooth scrolling video feed in Swift on iOS
With these magic tricks you can build a world-class iOS App
When users open your app, they immediately expect infinite scrolling and content to be immediately available. The bar is set high by existing feed products like Instagram and TikTok, which manage to barely show any loading indication.
However, the technical challenges are immense, even with modern hardware and the latest iOS.
Most apps target 60 frames per second to keep the scroll feeling smooth, which is equivalent to only 16.67ms per frame!
Playing audiovisual content within this time constraint is extremely challenging. A simple solution using just AVKit
and UICollectionView
won’t cut it, as there isn’t enough time to download the video by the time the user has scrolled to it. A naïve solution will also often hijack the main thread to do data operations, leading to even frozen UI.
Suggested Libraries
Luckily, you won’t need to write all this performant code from scratch. There are three key libraries that I use to simplify the task of prefetching and intelligent cell reuse.
IGListKit
Most likely, your app has a UICollectionView
to power the feed in your app, and indeed this is a great place to start. But as the datatypes become more complex and take a longer time to load, it’s time to upgrade. In comes IGListKit
. At the core, IGListKit is CollectionViews with a smart diffing engine built on top, keeping track of the cells that have updates without the need to manually reload the data. It also eliminates most of the data management logic that causes view controller bloat, drastically simplifying the code and reducing bugs. As with most things iOS, there’s a great tutorial on Ray Wenderlich that help explain the value of integrating IGListKit and also how to refactor existing CollectionViews into the framework.
IGListKit also has many delegate hooks for more advanced functionality, including the working range delegate that we’ll use to power prefetching. I recommend you follow the tutorial to get familiar with Adaptors
and Section Controllers
to build your feed, and then come back here to find out how to make it scroll smoothly.
Alternatively, if you really don’t want to refactor your existing app, there’s the option of using the Apple provided UICollectionViewDataSourcePrefetching
protocol. You can read more about it in the docs — the general idea is the same as IGListKit, in that there is a delegate that will provide you the necessary hooks to begin prefetching data.
Kingfisher
Kingfisher is the best Swift image loading and processing library. It hooks right off of any existing UIImageView
, making integration a breeze. Looking at the most simple example in the documentation shows its ease-of-use:
import Kingfisher
let url = URL(string: "https://example.com/image.png")
imageView.kf.setImage(with: url)
Under the hood, these images are cached in both an in-memory and disk cache, both of which can be tweaked for additional performance. Kingfisher neatly simplifies the task of downloading and caching images, and makes sure it all happens in a thread-safe way. Use it to download any remote image.
I encourage you to read through the Kingfisher Cheat Sheet, which gives code snippets for all the functionalities of Kingfisher. Using them will dramatically increase the readability of your image code, and also give you image processing super-powers (look into the ImageProcessor
!)
CachingPlayerItem
The most difficult part of prefetching is the downloading and storing of video data. AVFoundation
really only allows two modes: playing from an asset you already have on disk, or streaming the asset from the server. The challenge starts when you want to do both: How do you start downloading the asset, and when the user scrolls to it, start streaming from the data you already have?
In comes a nifty little extension called CachingPlayerItem, which does the hard work of converting to data the video file from streaming:
CachingPlayerItem is a subclass of AVPlayerItem. It allows you to play and cache audio and video files without having to download them twice. You can start playing a remote file immediately, without waiting it to be downloaded completely. Once it is downloaded, you will be given an opportunity to store it for future use.
Sure, it’s pretty small and you can write it yourself, but I have found it to be battletested and already avoiding most of the opaque error codes you’ll bump into if you attempt to build a hybrid loader yourself. I recommend forking the library and embedding it directly into your project.
Implementing Smooth Scrolling
Now that you are familiar with the building block common libraries for smooth scrolling, let’s combine them all and get some data loaded in!
Using Working Ranges to Prefetch
The simple idea is to make sure by the time the user has scrolled to a cell, all the content has already been downloaded. To do so, we’ll hook into the working range delegate that IGListKit surfaces. The working range is an area near the viewport of content that is about to appear on the screen. By defining the “working range”, a certain number of cells ahead of the current view port will get callbacks that they are about to show up on screen.
As the user scrolls, a sliding window of cells transition into the working range. As they do, that’s the point that we’ll start getting the data ready. Depending on your data type, you’ll want a different working range size. The working range is defined in the number of cells ahead to notify, default is 2. A larger working range gives more time for the data to download, which is good for longer videos, but can cause memory contention.
The first step is to hook into the IGListWorkingRangeDelegate provided by IGListKit:
// MARK: - ListWorkingRangeDelegate func listAdapter(_ listAdapter: ListAdapter, sectionControllerWillEnterWorkingRange sectionController: ListSectionController) { // Prefetch HERE} func listAdapter(_ listAdapter: ListAdapter, sectionControllerDidExitWorkingRange sectionController: ListSectionController) { // Clean up here }
Prefetch Images
Whether your feed has a mix of photos and videos or is just videos, you’ll always need image prefetching for video thumbnails. I usually kickoff video thumbnail prefetching at the same time as video prefetching.
Kingfisher makes prefetching images super easy: it has a built-in ImagePrefetcher
class. The prefetcher takes in a url and then downloads it to the Kingfisher cache, so that any other UIImageView
in your app can easily load from it.
func prefetch(urlToPrefetch: URL) {
let prefetcher = ImagePrefetcher(urls: [urlToPrefetch]) {
skippedResources, failedResources, completedResources in }
prefetcher.start()
}
It’s also a good idea to hold a variable of your cells ImagePrefetcher, so that you can cancel work when the cell is going to be reused.
Prefetch Videos
Prefetching videos, and then saving them to a cache, is more challenging because AVAsset
has no way to save directly to disk. A video app usually starts by creating a AVURLAsset
and then playing it inside of an AVPlayerLayer
. This works well to play a single video, but runs into issues because it can’t be stored. This will necessitate loading before playing, which is what we want to avoid.
There are two steps here: first, find a way to download the player item so that it can be saved to memory, and second, build a video cache.
My first thought was to take the AVURLAsset
and plug it into a AVAssetExportSession
which is the normal way to save an asset to disk. However, this will fail with some very non-obvious messaging:
Error Domain=AVFoundationErrorDomain Code=-11838 "Operation Stopped" UserInfo={NSLocalizedFailureReason=The operation is not supported for this media.
Avoid using AVAssetExportSession! It won’t work for remote assets and is a notable deadend.
Instead, in comes the CachingPlayerItem
that I mentioned earlier, as it is built specifically for this purpose.
let playerItem = CachingPlayerItem(url: videoURL)
player = AVPlayer(playerItem: playerItem)
player?.automaticallyWaitsToMinimizeStalling = false
At this point, all the videos in your app will start to stream. This will look pretty good already on a fast wifi connection — but turn off wifi and browse on data (or turn on the network link conditioner to test poor connections) and you’ll start to see slow performance. One more step to go — the prefetching of the videos, and then saving them to a cache.
Building a Video Cache
We’ll be storing the data that our CachingPlayerItem
has prefetched. For this, a simple in-memory singleton cache wrapping NSCache
should suffice. For longer term storage you can write through to disk, but I find that the latency increase isn’t worth it.
public class YourAppMediaCache: NSObject {
static let sharedInstance = YourAppMediaCache()
let memCache = NSCache<NSString, NSData>() public func cacheItem(_ mediaItem: Data, forKey key: String) {
memCache.setObject(mediaItem as NSData, forKey: key as NSString)
}
public func getItem(forKey key: String) -> Data? {
return memCache.object(forKey: key as NSString) as Data?
}
}
That’s it! I use the Singleton design pattern here to ensure that there is only ever one media cache, and that we can access it from anywhere in the app, simplifying the flow data. Additionally, I want to write a wrapper and break this cache out into its own class to separate concerns, write clear code, and not conflate responsibilities in my view layer.
Next, we hook up checking the cache into our configuration of the view. If we have the downloaded data, then let’s use it, otherwise we can setup the playerItem for streaming.
if let videoData = YourAppMediaCache.sharedInstance.getItem(forKey: key) {
playerItem = CachingPlayerItem(data: videoData as Data, id: key, mimeType: "video/mp4", fileExtension: "mp4")
} else {
playerItem = CachingPlayerItem(url: URL(string: key)!, id: key)
}
Alright, we’ve built the cache, and checked the cache when configuring our video view. Lastly, we obviously need to put out data into the cache when it’s prefetched!
There’s two options to do this: either conform to the delegate of the player item and use the didFinishDownloadingData
method, or the recommended, add a line to the CachingPlayerItem
inside the finished downloading callback to use our cacheItem
function from above.
Let’s finish the project by finally putting it all together: add a call to download the player item inside of the prefetch function you setup in the earlier section:
func prefetch(urlToPrefetch: URL) {
// Previous image thumbnail caching logic here
let key = urlToPrefetch.absoluteString if YourAppMediaCache.sharedInstance.getItem(forKey: key) == nil {
let playerItem = CachingPlayerItem(url: urlToPrefetch)
playerItem.download()
}
When the download is finished, it’ll save the data in the cache, and then as our next view configures itself, it’ll check the cache, get the video data, and skip the network call. Done!
Conclusion
Every good iOS app has a good foundation of data management. If you take the time now to improve the scrolling performance of your app using libraries like IGListKit
, you’ll find that the app is able to scale and stay performant as you grow. Good luck!