Comprehensive practices for building 60-240 FPS UI with Native iOS UIKit
Table of Contents
- Frame Budget & Rendering Pipeline
- UITableView Optimization
- UICollectionView Optimization
- Prefetching & Data Loading
- Cell Optimization
- Auto Layout Performance
- Core Animation & CALayer
- Image Loading & Caching
- Offscreen Rendering
- Animations
- Threading & GCD
- Memory Management
- Profiling & Debugging
- High Refresh Rate (120Hz+) Specifics
- Core Animation Pipeline & Hitches
- Auto Layout Cost & Alternative Engines
- GPU vs CPU Rendering
- Off-Main-Thread Work & Texture
- Advanced 240fps Techniques
- Metal Integration
- Quick Reference
1. Frame Budget & Rendering Pipeline
Frame Timing
| Display | Refresh Rate | Frame Budget |
|---|---|---|
| Standard | 60 Hz | 16.67ms |
| ProMotion | 120 Hz | 8.33ms |
| Always-On | 1-120 Hz | Variable |
| External/Future* | 240 Hz | 4.17ms |
*Note: No current iOS devices support 240Hz natively. This budget is included for external high-refresh displays (via USB-C/HDMI) and future device compatibility.
iOS Rendering Pipeline
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ LAYOUT │ → │ DISPLAY │ → │ PREPARE │ → │ COMMIT │
│ (Constraints)│ │ (drawRect) │ │ (Decode) │ │ (GPU) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
↓ ↓ ↓ ↓
Measure & Core Graphics Image decode CALayer tree
Position CPU drawing decompression sent to GPU
Run Loop & Rendering
// Core Animation commits at end of run loop
// All layout/display changes batched together
// Force immediate layout if needed
view.setNeedsLayout()
view.layoutIfNeeded() // Forces synchronous layout
// Prefer asynchronous layout
view.setNeedsLayout() // Batched with next run loop
CADisplayLink for Frame Sync
class FrameSyncController {
private var displayLink: CADisplayLink?
func start() {
displayLink = CADisplayLink(target: self, selector: #selector(handleFrame))
displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: 60,
maximum: 120,
preferred: 120
)
displayLink?.add(to: .main, forMode: .common)
}
@objc private func handleFrame(_ displayLink: CADisplayLink) {
let frameDuration = displayLink.targetTimestamp - displayLink.timestamp
// Update animations synchronized with display
}
func stop() {
displayLink?.invalidate()
displayLink = nil
}
}
2. UITableView Optimization
Essential Setup
class OptimizedTableViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
// ✅ Register cells for reuse
tableView.register(CustomCell.self, forCellReuseIdentifier: "cell")
// ✅ Estimated heights for faster initial layout
tableView.estimatedRowHeight = 80
tableView.rowHeight = UITableView.automaticDimension
// ✅ Prefetching
tableView.prefetchDataSource = self
// ✅ Disable unnecessary features
tableView.separatorStyle = .none // If custom separators
}
}
Cell Reuse (Critical)
override func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// ✅ Always dequeue - NEVER create new cells
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! CustomCell
let item = items[indexPath.row]
// ✅ Configure with data
cell.configure(with: item)
return cell
}
Diffable Data Source (iOS 13+)
class ModernTableViewController: UIViewController {
enum Section { case main }
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var tableView: UITableView!
func setupDataSource() {
dataSource = UITableViewDiffableDataSource(tableView: tableView) {
tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
withIdentifier: "cell",
for: indexPath
) as! CustomCell
cell.configure(with: item)
return cell
}
}
func updateData(_ items: [Item], animated: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
// ✅ iOS 15+: Apply without animation uses diff, not reloadData
dataSource.apply(snapshot, animatingDifferences: animated)
}
// ✅ iOS 15+: Reconfigure without full cell reload
func updateItem(_ item: Item) {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([item]) // Only updates content, not layout
dataSource.apply(snapshot)
}
}
Height Calculation
// ❌ BAD: Calculating height in heightForRowAt
override func tableView(_ tableView: UITableView,
heightForRowAt indexPath: IndexPath) -> CGFloat {
let item = items[indexPath.row]
return calculateHeight(for: item) // Called frequently during scroll!
}
// ✅ GOOD: Cache heights
private var heightCache: [IndexPath: CGFloat] = [:]
override func tableView(_ tableView: UITableView,
heightForRowAt indexPath: IndexPath) -> CGFloat {
if let cached = heightCache[indexPath] {
return cached
}
let height = calculateHeight(for: items[indexPath.row])
heightCache[indexPath] = height
return height
}
// Clear cache when data changes
func updateData(_ newItems: [Item]) {
heightCache.removeAll()
items = newItems
tableView.reloadData()
}
Self-Sizing Cells
// In cell class
override func systemLayoutSizeFitting(
_ targetSize: CGSize,
withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority,
verticalFittingPriority: UILayoutPriority
) -> CGSize {
// ✅ Cache calculated size
let size = super.systemLayoutSizeFitting(
targetSize,
withHorizontalFittingPriority: horizontalFittingPriority,
verticalFittingPriority: verticalFittingPriority
)
return size
}
3. UICollectionView Optimization
Modern Compositional Layout
func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(100)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(100)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 8
section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)
return UICollectionViewCompositionalLayout(section: section)
}
Cell Registration (iOS 14+)
// ✅ Modern cell registration
let cellRegistration = UICollectionView.CellRegistration<CustomCell, Item> { cell, indexPath, item in
cell.configure(with: item)
}
// In data source
dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) {
collectionView, indexPath, item in
return collectionView.dequeueConfiguredReusableCell(
using: cellRegistration,
for: indexPath,
item: item
)
}
Orthogonal Scrolling Sections
// Horizontal section within vertical collection view
section.orthogonalScrollingBehavior = .continuous
// With paging
section.orthogonalScrollingBehavior = .groupPaging
// With visible items changed callback
section.visibleItemsInvalidationHandler = { items, offset, environment in
// Handle visible items for parallax effects, etc.
}
4. Prefetching & Data Loading
UITableViewDataSourcePrefetching
extension TableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
// ✅ Start async loading for upcoming cells
for indexPath in indexPaths {
let item = items[indexPath.row]
// Start image prefetch
if let url = item.imageURL {
imageLoader.prefetch(url: url)
}
// Start data prefetch
if !item.detailsLoaded {
dataLoader.prefetchDetails(for: item.id)
}
}
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
// ✅ Cancel prefetch for cells no longer needed
for indexPath in indexPaths {
let item = items[indexPath.row]
if let url = item.imageURL {
imageLoader.cancelPrefetch(url: url)
}
}
}
}
UICollectionViewDataSourcePrefetching
extension CollectionViewController: UICollectionViewDataSourcePrefetching {
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
// ✅ Batch prefetch requests
let urls = indexPaths.compactMap { items[$0.item].imageURL }
imageLoader.prefetchBatch(urls: urls)
}
func collectionView(_ collectionView: UICollectionView,
cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
let urls = indexPaths.compactMap { items[$0.item].imageURL }
imageLoader.cancelBatch(urls: urls)
}
}
iOS 15+ adds automatic cell prefetching: build with the iOS 15 SDK and cells are prepared during idle time between frames, granting up to 2x preparation time without hitches. This is separate from the data-source prefetching above, which you still implement for network and image work.
Adaptive Prefetching
class SmartPrefetchController {
private var operationQueue: OperationQueue = {
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 4 // Limit concurrent fetches
queue.qualityOfService = .userInitiated
return queue
}()
private var prefetchOperations: [IndexPath: Operation] = [:]
func prefetch(at indexPaths: [IndexPath], using loader: DataLoader) {
for indexPath in indexPaths {
guard prefetchOperations[indexPath] == nil else { continue }
let operation = loader.loadOperation(for: indexPath)
prefetchOperations[indexPath] = operation
operationQueue.addOperation(operation)
}
}
func cancelPrefetch(at indexPaths: [IndexPath]) {
for indexPath in indexPaths {
prefetchOperations[indexPath]?.cancel()
prefetchOperations[indexPath] = nil
}
}
}
5. Cell Optimization
Lightweight Cell Setup
class OptimizedCell: UITableViewCell {
// ✅ Lazy subview creation
private lazy var customImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 16, weight: .medium)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
// ✅ One-time setup
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupViews()
setupConstraints()
}
private func setupViews() {
contentView.addSubview(customImageView)
contentView.addSubview(titleLabel)
}
private func setupConstraints() {
NSLayoutConstraint.activate([
customImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
customImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
customImageView.widthAnchor.constraint(equalToConstant: 48),
customImageView.heightAnchor.constraint(equalToConstant: 48),
titleLabel.leadingAnchor.constraint(equalTo: customImageView.trailingAnchor, constant: 12),
titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
])
}
// ✅ Fast configuration
func configure(with item: Item) {
titleLabel.text = item.title
// Async image loading
customImageView.image = nil // Clear previous
if let url = item.imageURL {
ImageLoader.shared.load(url: url) { [weak self] image in
self?.customImageView.image = image
}
}
}
// ✅ Cancel ongoing work on reuse
override func prepareForReuse() {
super.prepareForReuse()
customImageView.image = nil
ImageLoader.shared.cancel(for: customImageView)
}
}
Avoid in cellForRow
// ❌ BAD: Heavy operations in cellForRow
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
// ❌ Synchronous image loading
if let data = try? Data(contentsOf: imageURL) {
cell.imageView?.image = UIImage(data: data)
}
// ❌ Heavy text calculation
let attributedText = createComplexAttributedString(item.description)
cell.textLabel?.attributedText = attributedText
// ❌ Date formatting
let formatter = DateFormatter()
formatter.dateStyle = .medium
cell.detailTextLabel?.text = formatter.string(from: item.date)
return cell
}
// ✅ GOOD: Pre-calculate and cache
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! CustomCell
let viewModel = viewModels[indexPath.row] // Pre-calculated
cell.configure(with: viewModel)
return cell
}
// Pre-calculate view models
struct ItemViewModel {
let title: String
let formattedDate: String // Pre-formatted
let attributedDescription: NSAttributedString // Pre-calculated
let imageURL: URL?
}
Specifically, never do JSON parsing, data transformation, image decoding or resizing, network requests, complex constraint mutations, or heavy object allocation inside
cellForRowAt/cellForItemAt. Each of these runs on the main thread directly in the scroll path.
6. Auto Layout Performance
Constraint Priorities
// ✅ Use priorities to reduce constraint conflicts
let heightConstraint = view.heightAnchor.constraint(equalToConstant: 100)
heightConstraint.priority = .defaultHigh // Allows flexibility
heightConstraint.isActive = true
// ✅ Required constraints should be minimal
let requiredConstraint = view.widthAnchor.constraint(greaterThanOrEqualToConstant: 44)
requiredConstraint.priority = .required
requiredConstraint.isActive = true
Batch Constraint Updates
// ❌ BAD: Individual constraint updates
constraint1.constant = 10
constraint2.constant = 20
constraint3.constant = 30
// Triggers 3 layout passes!
// ✅ GOOD: Batch updates
NSLayoutConstraint.deactivate([constraint1, constraint2, constraint3])
constraint1.constant = 10
constraint2.constant = 20
constraint3.constant = 30
NSLayoutConstraint.activate([constraint1, constraint2, constraint3])
// Single layout pass
Intrinsic Content Size
class OptimizedLabel: UILabel {
// ✅ Cache intrinsic content size
private var cachedIntrinsicSize: CGSize?
override var intrinsicContentSize: CGSize {
if let cached = cachedIntrinsicSize {
return cached
}
let size = super.intrinsicContentSize
cachedIntrinsicSize = size
return size
}
override var text: String? {
didSet {
cachedIntrinsicSize = nil // Invalidate cache
}
}
override var font: UIFont! {
didSet {
cachedIntrinsicSize = nil
}
}
}
Stack Views vs Manual Layout
// Stack views: Convenient but add overhead
// For performance-critical cells, consider manual layout
// ✅ Manual layout for maximum performance
class ManualLayoutCell: UITableViewCell {
override func layoutSubviews() {
super.layoutSubviews()
let padding: CGFloat = 16
let imageSize: CGFloat = 48
imageView?.frame = CGRect(
x: padding,
y: (contentView.bounds.height - imageSize) / 2,
width: imageSize,
height: imageSize
)
let textX = padding + imageSize + 12
textLabel?.frame = CGRect(
x: textX,
y: padding,
width: contentView.bounds.width - textX - padding,
height: contentView.bounds.height - 2 * padding
)
}
}
7. Core Animation & CALayer
GPU-Accelerated Properties
// ✅ GPU-accelerated (fast)
view.layer.transform = CATransform3DMakeScale(1.5, 1.5, 1.0)
view.layer.opacity = 0.5
view.layer.position = CGPoint(x: 100, y: 100)
// ❌ Triggers layout (slow)
view.frame.size = CGSize(width: 200, height: 200)
view.bounds = CGRect(x: 0, y: 0, width: 200, height: 200)
Shadow Optimization
// ❌ BAD: Dynamic shadow calculation
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOffset = CGSize(width: 0, height: 2)
view.layer.shadowOpacity = 0.3
view.layer.shadowRadius = 4
// Shadow shape calculated every frame!
// ✅ GOOD: Pre-defined shadow path
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOffset = CGSize(width: 0, height: 2)
view.layer.shadowOpacity = 0.3
view.layer.shadowRadius = 4
view.layer.shadowPath = UIBezierPath(
roundedRect: view.bounds,
cornerRadius: view.layer.cornerRadius
).cgPath // Cached path
Rasterization
// ✅ Rasterize complex layer hierarchies
complexView.layer.shouldRasterize = true
complexView.layer.rasterizationScale = UIScreen.main.scale
// Use for:
// - Static complex views
// - Views with many sublayers
// - Views with effects (shadows, gradients)
// Don't use for:
// - Frequently changing content
// - Animated views
// - Views larger than screen
Corner Radius Performance
// ✅ cornerRadius is GPU-accelerated
view.layer.cornerRadius = 8
// ✅ Use cornerCurve for continuous corners (iOS 13+)
view.layer.cornerCurve = .continuous
// ❌ Avoid masksToBounds with shadows
view.layer.cornerRadius = 8
view.layer.masksToBounds = true // Triggers offscreen render
view.layer.shadowOpacity = 0.5 // Won't be visible!
// ✅ Use separate shadow layer
let shadowLayer = CALayer()
shadowLayer.shadowPath = ...
shadowLayer.shadowOpacity = 0.5
view.layer.insertSublayer(shadowLayer, at: 0)
view.layer.cornerRadius = 8
view.layer.masksToBounds = true // Only clips content
Layer Opacity
// ✅ Set opaque when possible
view.layer.isOpaque = true // No alpha blending needed
view.backgroundColor = .white // Not .clear!
// ✅ Reduce blending
imageView.layer.isOpaque = true
imageView.backgroundColor = .white // Match parent
8. Image Loading & Caching
Efficient Image Loader
class ImageLoader {
static let shared = ImageLoader()
private let cache = NSCache<NSURL, UIImage>()
private let session: URLSession
private var tasks: [URL: URLSessionDataTask] = [:]
private let queue = DispatchQueue(label: "imageLoader", attributes: .concurrent)
init() {
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
let config = URLSessionConfiguration.default
config.urlCache = URLCache(
memoryCapacity: 20 * 1024 * 1024,
diskCapacity: 100 * 1024 * 1024
)
session = URLSession(configuration: config)
}
func load(url: URL, completion: @escaping (UIImage?) -> Void) {
// Check memory cache
if let cached = cache.object(forKey: url as NSURL) {
DispatchQueue.main.async { completion(cached) }
return
}
// Fetch from network
let task = session.dataTask(with: url) { [weak self] data, _, error in
guard let data = data, error == nil else {
DispatchQueue.main.async { completion(nil) }
return
}
// Decode on background thread
self?.queue.async {
guard let image = UIImage(data: data) else {
DispatchQueue.main.async { completion(nil) }
return
}
// Cache
self?.cache.setObject(image, forKey: url as NSURL, cost: data.count)
DispatchQueue.main.async { completion(image) }
}
}
queue.async(flags: .barrier) {
self.tasks[url] = task
}
task.resume()
}
func cancel(url: URL) {
queue.async(flags: .barrier) {
self.tasks[url]?.cancel()
self.tasks[url] = nil
}
}
func prefetch(url: URL) {
guard cache.object(forKey: url as NSURL) == nil else { return }
let task = session.dataTask(with: url) { [weak self] data, _, _ in
guard let data = data, let image = UIImage(data: data) else { return }
self?.cache.setObject(image, forKey: url as NSURL, cost: data.count)
}
queue.async(flags: .barrier) {
self.tasks[url] = task
}
task.resume()
}
}
Image Downsampling
extension UIImage {
// ✅ Downsample large images to target size
static func downsample(url: URL, to pointSize: CGSize, scale: CGFloat = UIScreen.main.scale) -> UIImage? {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else {
return nil
}
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
] as CFDictionary
guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else {
return nil
}
return UIImage(cgImage: downsampledImage)
}
}
// Usage
DispatchQueue.global(qos: .userInitiated).async {
let image = UIImage.downsample(url: imageURL, to: CGSize(width: 100, height: 100))
DispatchQueue.main.async {
imageView.image = image
}
}
Background Decoding (iOS 15+)
// ✅ prepareForDisplay decodes off the main thread
let fullImage = UIImage(contentsOfFile: path)!
fullImage.prepareForDisplay { prepared in
DispatchQueue.main.async { imageView.image = prepared }
}
// ✅ Or prepare a thumbnail at the display size
fullImage.prepareThumbnail(of: targetSize) { thumbnail in
DispatchQueue.main.async { imageView.image = thumbnail }
}
Prepared images hold raw, decompressed pixel data — far larger than the compressed original. Cache them sparingly to avoid memory warnings.
SDWebImage / Kingfisher Integration
// SDWebImage
imageView.sd_setImage(
with: url,
placeholderImage: placeholder,
options: [.scaleDownLargeImages, .avoidAutoSetImage],
context: [.imageThumbnailPixelSize: CGSize(width: 200, height: 200)],
progress: nil
) { [weak self] image, error, cacheType, url in
self?.imageView.image = image
}
// Kingfisher
imageView.kf.setImage(
with: url,
placeholder: placeholder,
options: [
.processor(DownsamplingImageProcessor(size: CGSize(width: 200, height: 200))),
.scaleFactor(UIScreen.main.scale),
.cacheOriginalImage
]
)
9. Offscreen Rendering
What Triggers Offscreen Rendering
// ❌ Triggers offscreen rendering
// 1. Masks
view.layer.mask = maskLayer
// 2. masksToBounds with corner radius (on certain views)
view.layer.cornerRadius = 8
view.layer.masksToBounds = true
// 3. Shadow without shadowPath
view.layer.shadowOpacity = 0.5
// No shadowPath set
// 4. Group opacity
view.layer.allowsGroupOpacity = true
view.alpha = 0.5
// 5. Rasterization (creates offscreen buffer)
view.layer.shouldRasterize = true
Detecting Offscreen Rendering
// Debug: Color Offscreen-Rendered Yellow in Simulator
// Or use Instruments → Core Animation
// Check if view triggers offscreen render
func checkOffscreenRendering(_ view: UIView) {
let layer = view.layer
if layer.mask != nil {
print("⚠️ Mask triggers offscreen render")
}
if layer.shadowOpacity > 0 && layer.shadowPath == nil {
print("⚠️ Shadow without path triggers offscreen render")
}
if layer.cornerRadius > 0 && layer.masksToBounds && !layer.contents.isNil {
print("⚠️ Clipping with content may trigger offscreen render")
}
}
Avoiding Offscreen Rendering
// ✅ Pre-render shadows using shadowPath
let path = UIBezierPath(roundedRect: view.bounds, cornerRadius: 8)
view.layer.shadowPath = path.cgPath
// ✅ Use pre-rendered images for complex shapes
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { context in
// Draw complex shape once
}
imageView.image = image
// ✅ Separate shadow and clipping layers
class CardView: UIView {
private let shadowLayer = CALayer()
private let contentLayer = CALayer()
override init(frame: CGRect) {
super.init(frame: frame)
// Shadow layer (no clipping)
shadowLayer.shadowColor = UIColor.black.cgColor
shadowLayer.shadowOffset = CGSize(width: 0, height: 2)
shadowLayer.shadowRadius = 4
shadowLayer.shadowOpacity = 0.2
layer.insertSublayer(shadowLayer, at: 0)
// Content layer (with clipping)
contentLayer.masksToBounds = true
contentLayer.cornerRadius = 8
layer.addSublayer(contentLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
shadowLayer.frame = bounds
shadowLayer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 8).cgPath
contentLayer.frame = bounds
}
}
10. Animations
UIView Animations
// ✅ Spring animations for natural feel
UIView.animate(
withDuration: 0.3,
delay: 0,
usingSpringWithDamping: 0.7,
initialSpringVelocity: 0.5,
options: [.allowUserInteraction],
animations: {
view.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
}
)
// ✅ Use .allowUserInteraction for responsive UI
UIView.animate(
withDuration: 0.3,
delay: 0,
options: [.allowUserInteraction, .beginFromCurrentState],
animations: {
view.alpha = 1.0
}
)
Core Animation
// ✅ CABasicAnimation for performance
let animation = CABasicAnimation(keyPath: "transform.scale")
animation.fromValue = 1.0
animation.toValue = 1.2
animation.duration = 0.3
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
view.layer.add(animation, forKey: "scale")
// ✅ CASpringAnimation for physics-based
let spring = CASpringAnimation(keyPath: "transform.scale")
spring.fromValue = 1.0
spring.toValue = 1.2
spring.damping = 10
spring.stiffness = 100
spring.mass = 1
spring.duration = spring.settlingDuration
view.layer.add(spring, forKey: "scale")
UIViewPropertyAnimator (iOS 10+)
// ✅ Interruptible, reversible animations
let animator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 0.7) {
view.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
}
animator.addCompletion { position in
if position == .end {
// Animation completed
}
}
animator.startAnimation()
// Pause, reverse, scrub
animator.pauseAnimation()
animator.isReversed = true
animator.fractionComplete = 0.5
animator.continueAnimation(withTimingParameters: nil, durationFactor: 1)
Animating GPU Properties Only
// ✅ GPU-accelerated properties (fast)
UIView.animate(withDuration: 0.3) {
view.transform = CGAffineTransform(translationX: 100, y: 0)
view.alpha = 0.5
}
// ❌ Layout-triggering properties (slower)
UIView.animate(withDuration: 0.3) {
view.frame.origin.x += 100 // Triggers layout
view.bounds.size = newSize // Triggers layout
}
11. Threading & GCD
Main Thread Safety
// ✅ Always update UI on main thread
DispatchQueue.global(qos: .userInitiated).async {
let result = heavyComputation()
DispatchQueue.main.async {
self.updateUI(with: result)
}
}
// ✅ Check if already on main
func updateUI(with data: Data) {
if Thread.isMainThread {
performUpdate(data)
} else {
DispatchQueue.main.async {
self.performUpdate(data)
}
}
}
Quality of Service
// QoS levels (highest to lowest priority)
DispatchQueue.global(qos: .userInteractive) // UI animations
DispatchQueue.global(qos: .userInitiated) // User-triggered tasks
DispatchQueue.global(qos: .default) // Normal
DispatchQueue.global(qos: .utility) // Long-running tasks
DispatchQueue.global(qos: .background) // Prefetching, backup
// ✅ Use appropriate QoS
func loadImage() {
DispatchQueue.global(qos: .userInitiated).async {
let image = self.processImage()
DispatchQueue.main.async {
self.imageView.image = image
}
}
}
Operation Queues for Complex Tasks
class DataLoader {
private let operationQueue: OperationQueue = {
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 4
queue.qualityOfService = .userInitiated
return queue
}()
func loadItems(_ items: [Item], completion: @escaping ([Result]) -> Void) {
let operations = items.map { item in
LoadOperation(item: item)
}
let completionOperation = BlockOperation {
let results = operations.map { $0.result }
DispatchQueue.main.async {
completion(results)
}
}
operations.forEach { completionOperation.addDependency($0) }
operationQueue.addOperations(operations, waitUntilFinished: false)
operationQueue.addOperation(completionOperation)
}
}
12. Memory Management
Weak References in Closures
// ✅ Avoid retain cycles
imageLoader.load(url: url) { [weak self] image in
guard let self = self else { return }
self.imageView.image = image
}
// ✅ With multiple captures
networkService.fetch { [weak self, weak delegate] result in
self?.process(result)
delegate?.didComplete()
}
Cell Reuse Cleanup
class CustomCell: UITableViewCell {
private var imageTask: URLSessionTask?
func configure(with item: Item) {
// Cancel previous task
imageTask?.cancel()
imageTask = ImageLoader.shared.load(url: item.imageURL) { [weak self] image in
self?.imageView.image = image
}
}
override func prepareForReuse() {
super.prepareForReuse()
// ✅ Clean up
imageTask?.cancel()
imageTask = nil
imageView.image = nil
}
}
Autoreleasepool for Loops
// ✅ Reduce memory spikes in loops
func processLargeDataset(_ items: [Data]) -> [UIImage] {
var images: [UIImage] = []
for item in items {
autoreleasepool {
if let image = UIImage(data: item) {
images.append(image)
}
}
}
return images
}
13. Profiling & Debugging
Instruments Templates
| Template | Use Case |
|---|---|
| Time Profiler | CPU usage, method timing |
| Core Animation | FPS, GPU usage |
| Allocations | Memory usage |
| Leaks | Memory leaks |
| System Trace | System-wide analysis |
Core Animation Debugging
// Simulator Debug Options:
// - Color Blended Layers (red = blending)
// - Color Offscreen-Rendered Yellow
// - Color Hits Green and Misses Red (rasterization cache)
// - Flash Updated Regions
FPS Counter
class FPSCounter {
private var displayLink: CADisplayLink?
private var lastTimestamp: CFTimeInterval = 0
private var frameCount: Int = 0
var fpsLabel: UILabel?
func start() {
displayLink = CADisplayLink(target: self, selector: #selector(handleFrame))
displayLink?.add(to: .main, forMode: .common)
}
@objc private func handleFrame(_ link: CADisplayLink) {
if lastTimestamp == 0 {
lastTimestamp = link.timestamp
return
}
frameCount += 1
let elapsed = link.timestamp - lastTimestamp
if elapsed >= 1.0 {
let fps = Double(frameCount) / elapsed
fpsLabel?.text = String(format: "%.1f FPS", fps)
frameCount = 0
lastTimestamp = link.timestamp
}
}
func stop() {
displayLink?.invalidate()
displayLink = nil
}
}
14. High Refresh Rate (120Hz+) Specifics
Hardware reality check: No current iOS device runs above 120Hz. ProMotion (120Hz) ships on iPad Pro (since 2017) and iPhone 13 Pro and later Pro/Pro Max. A 240Hz panel remains hypothetical. Your real high-refresh target is 120fps (8.33ms/frame); at a theoretical 240Hz you’d have only ~2-3ms of usable work per frame, which makes every technique in sections 14-20 effectively mandatory.
Enabling 120Hz on iPhone (Required Opt-In)
iPhone apps are capped at 60Hz by default. Opt into ProMotion by adding to Info.plist:
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
This is not needed on iPad Pro, where ProMotion is active without opt-in.
Per-Animation CAFrameRateRange (iOS 15+)
CAFrameRateRange replaces the deprecated preferredFramesPerSecond and can be set per display link and per animation:
let anim = CABasicAnimation(keyPath: "position")
anim.preferredFrameRateRange = CAFrameRateRange(
minimum: 80,
maximum: Float(UIScreen.main.maximumFramesPerSecond),
preferred: Float(UIScreen.main.maximumFramesPerSecond)
)
The “Dummy DisplayLink” Trick
ProMotion infers desired frame rates from content. A subtle fade or small movement may be served at only 30Hz. Force a higher rate for the duration of an animation with an otherwise-empty display link:
class FrameRateRequest {
private var displayLink: CADisplayLink?
func start(preferredRate: Float, duration: TimeInterval) {
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: 30,
maximum: Float(UIScreen.main.maximumFramesPerSecond),
preferred: preferredRate
)
displayLink?.add(to: .main, forMode: .common)
DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in
self?.displayLink?.invalidate()
self?.displayLink = nil
}
}
@objc private func tick() { }
}
Caveats
- Low Power Mode caps the display at 60Hz regardless of opt-in or frame-rate requests.
- A
CADisplayLink’sdurationreflects one frame’s duration, not the callback interval. - Some devices (iPhone 15 Pro) have been observed capping at 90Hz — a known bug.
15. Core Animation Pipeline & Hitches
The Five-Phase Pipeline
Beyond the four in-process phases (Layout → Display → Prepare → Commit), the full path through the render server is:
- Event — the app handles touch events
- Commit (app process) — Layout → Display → Prepare → Commit the layer tree to the render server
- Render Prepare (render server) — decodes the layer tree
- Render Execute (GPU) — draws via Metal
- Display — the frame is presented
The system is double-buffered, falling back to triple buffering under load.
Commit Hitch vs Render Hitch
- Commit hitch: your app’s commit phase exceeds the VSYNC deadline (too much main-thread work).
- Render hitch: the render server’s execute phase exceeds VSYNC — caused by overly complex layer trees, not your code’s CPU time.
Distinguishing the two tells you whether to optimize main-thread work or to flatten/simplify the layer hierarchy.
drawsAsynchronously
layer.drawsAsynchronously = true
Defers CGContext drawing to a background thread. The drawing code must be thread-safe. Useful when a layer’s draw work is non-trivial but already correct on a background queue.
Disabling Implicit Animations
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.position = newPosition
layer.opacity = 1.0
CATransaction.commit()
Standalone CALayers apply implicit 0.25s animations on property changes. Disable them during scrolling or rapid programmatic updates.
allowsGroupOpacity
layer.allowsGroupOpacity = false
Prevents compositing sublayers against the parent’s opacity, reducing offscreen rendering when the parent is partially transparent.
16. Auto Layout Cost & Alternative Engines
The Reality: 8-12x Slower Than Manual
| Framework | Speed |
|---|---|
| PinLayout, FlexLayout, LayoutKit | Equal or faster than manual |
| Manual frame layout | Baseline |
| Auto Layout, UIStackView | 8-12x slower |
iOS 12+ rewrote the engine to scale linearly with constraint count, but the constant factor is still high. In a hot scroll path, this is the difference between a smooth and a hitching list.
Avoiding Layout Thrashing
// ❌ BAD: two immediate solves
view.addConstraint(c1)
view.layoutIfNeeded()
view.addConstraint(c2)
view.layoutIfNeeded()
// ✅ GOOD: batch, single deferred solve
view.addConstraint(c1)
view.addConstraint(c2)
view.setNeedsLayout()
Alternative Layout Engines
- PinLayout — CSS absolute-positioning style, frame-based, ~8-12x faster than Auto Layout.
- FlexLayout — Yoga/flexbox wrapper, supports multithreaded layout.
- Texture’s
ASLayoutSpec— fully off-main-thread layout (see section 18).
For scrolling cells, prefer frame-based layoutSubviews (section 6) or one of these engines over Auto Layout.
17. GPU vs CPU Rendering
Stays on GPU (Fast)
- Standard
CALayerproperty changes (position, bounds, transform, opacity) CAShapeLayerpath renderingCAGradientLayer,CATextLayer- Image display via
layer.contents CAAnimation— runs in the render server with zero main-thread cost
Falls to CPU (Slow)
- Any
draw(_ rect:)override — even an empty one allocates a backing store - Core Graphics drawing
shouldRasterizerasterization passes- Dynamic shadow calculation (without
shadowPath) masksToBounds+cornerRadiusNSAttributedStringtext sizing and rendering
CAShapeLayer vs drawRect
// ✅ GPU (preferred)
let shape = CAShapeLayer()
shape.path = UIBezierPath(roundedRect: bounds, cornerRadius: 8).cgPath
shape.fillColor = UIColor.blue.cgColor
layer.addSublayer(shape)
// ❌ CPU (avoid)
override func draw(_ rect: CGRect) {
UIBezierPath(roundedRect: bounds, cornerRadius: 8).fill()
}
Benchmark: GPU-based draw(layer:ctx:) holds a steady 120 FPS at 10-11% CPU, whereas an equivalent CPU drawRect path drops below 20 FPS.
Pixel-Aligned Frames
// ❌ BAD: subpixel anti-aliasing forces GPU interpolation
view.frame = CGRect(x: 10.3, y: 20.7, width: 100.5, height: 50.2)
// ✅ GOOD: snap to pixel boundaries
let s = UIScreen.main.scale
view.frame = CGRect(
x: round(10.3 * s) / s,
y: round(20.7 * s) / s,
width: round(100.5 * s) / s,
height: round(50.2 * s) / s
)
Debug with Color Misaligned Images — magenta marks subpixel placement, yellow marks stretching.
18. Off-Main-Thread Work & Texture
Background Text Sizing
NSAttributedString sizing is one of the most expensive main-thread operations in a scroll. Compute it off-thread and cache the result:
DispatchQueue.global(qos: .userInitiated).async {
let rect = attributedString.boundingRect(
with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil
)
let height = ceil(rect.height)
DispatchQueue.main.async {
self.cachedHeights[indexPath] = height
}
}
Pre-Rendering Text as Images
DispatchQueue.global(qos: .userInitiated).async {
let renderer = UIGraphicsImageRenderer(size: targetSize)
let textImage = renderer.image { _ in
attributedString.draw(in: CGRect(origin: .zero, size: targetSize))
}
DispatchQueue.main.async {
textLayer.contents = textImage.cgImage
}
}
This is the core technique behind Texture/AsyncDisplayKit.
Texture (AsyncDisplayKit)
UIKit runs layout, drawing, and display on the main thread. Texture distributes that work across cores:
UIKit: Layout → Drawing → Display [all in 8.33ms on one thread]
Texture: Background Thread 1: Layout computation
Background Thread 2: Text rendering
Background Thread 3: Image decoding
Main Thread: Only final display (minimal work)
- Replaces
UIViewwithASDisplayNode(which wrapsUIView/CALayer) - Layout runs off the main thread via
layoutSpecThatFits: - No cell reuse — nodes are pre-built and ready
- Replaces Auto Layout entirely with composable
ASLayoutSpec
Trade-offs: no Auto Layout or Interface Builder support; ASDisplayNode.init runs off-main, so no UIKit access there; project maintenance has slowed since Pinterest reduced investment.
19. Advanced 240fps Techniques
RunLoop Observers for Idle-Time Work
let observer = CFRunLoopObserverCreateWithHandler(
kCFAllocatorDefault,
CFRunLoopActivity.beforeWaiting.rawValue,
true, 0
) { _, _ in
self.processIdleQueue() // Pre-render cells, warm caches, pre-compute layouts
}
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .defaultMode)
Core Animation itself uses this mechanism to commit implicit transactions.
UITrackingRunLoopMode
// Fire during scrolling too
displayLink.add(to: .main, forMode: .common)
// Only fire when NOT scrolling
timer.add(to: .main, forMode: .default)
During scrolling the run loop switches to tracking mode; .common fires in both modes.
Pre-Rendering Entire Cells
func preRender(model: CellModel, size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
avatarImage.draw(in: avatarRect)
titleString.draw(in: titleRect)
UIBezierPath(roundedRect: cardRect, cornerRadius: 8).fill()
}
}
// Then: cell.contentImageView.image = preRenderedImage
Object Pooling
class Pool<T> {
private var available: [T] = []
private let factory: () -> T
private let lock = NSLock()
init(factory: @escaping () -> T) { self.factory = factory }
func acquire() -> T {
lock.lock(); defer { lock.unlock() }
return available.isEmpty ? factory() : available.removeLast()
}
func release(_ object: T) {
lock.lock(); defer { lock.unlock() }
available.append(object)
}
}
// Use for DateFormatters, NSParagraphStyle, and other expensive-to-create objects
View Hierarchy Flattening
| Approach | Pros | Cons |
|---|---|---|
Many UIViews | Modular, accessible | More compositing, blending |
Flattened drawRect | Fewer layers, less compositing | Loses interactivity |
| Layer-only | Middle ground | Manual hit-testing |
Flatten selectively — only the expensive read-only parts:
class FlatContentView: UIView {
var model: ContentModel? { didSet { setNeedsDisplay() } }
override func draw(_ rect: CGRect) {
// Single pass: avatar + text + decorations replaces 5-10 subviews
}
}
class ComplexCell: UICollectionViewCell {
let flatContent = FlatContentView() // Flattened
let button = UIButton() // Kept: needs tap handling
}
20. Metal Integration
CAMetalLayer for Custom Rendering
class MetalView: UIView {
override class var layerClass: AnyClass { CAMetalLayer.self }
var metalLayer: CAMetalLayer { layer as! CAMetalLayer }
override func didMoveToWindow() {
super.didMoveToWindow()
metalLayer.device = MTLCreateSystemDefaultDevice()
metalLayer.pixelFormat = .bgra8Unorm
metalLayer.framebufferOnly = true
metalLayer.contentsScale = window?.screen.scale ?? 2.0
}
}
When mixing Metal content with UIKit overlays, set metalLayer.presentsWithTransaction = true to synchronize Metal frames with the UIKit layer tree and avoid one-frame desync.
21. Quick Reference
60-120fps Baseline Checklist
- Cell reuse implemented correctly
- Prefetching enabled for table/collection views
- Diffable data source used (iOS 13+);
reconfigureItemsoverreloadItems - Shadow paths set for all shadows
- Images downsampled before display
- Async image loading with cancellation
- No offscreen rendering (check with Instruments)
- Background thread for heavy operations
- Memory properly managed (weak references)
- Profiled with Instruments
High Refresh Rate / 240fps Additional Checklist
-
CADisableMinimumFrameDurationOnPhoneadded toInfo.plist - Per-animation
CAFrameRateRangeset; dummy DisplayLink used to force rate where ProMotion under-delivers - Aware that Low Power Mode caps at 60Hz
- Commit hitch vs render hitch identified before optimizing
-
drawsAsynchronously/ off-main text sizing for expensive drawing - Frame-based layout (or PinLayout/FlexLayout) in scrolling cells — not Auto Layout
- Rendering kept on GPU (CAShapeLayer over drawRect; no
drawoverride unless required) - All frames pixel-aligned
- Texture/AsyncDisplayKit for the most demanding scroll scenarios
- RunLoop observer pre-renders cells / warms caches during idle time
- Object pooling for expensive-to-create objects
- View hierarchy flattened where read-only
- Metal (
CAMetalLayer,presentsWithTransaction) for intensive custom graphics - Hitch Time Ratio < 5ms/s in the Animation Hitches instrument
Priority Order (Highest Impact First)
- Add
CADisableMinimumFrameDurationOnPhonetoInfo.plist - Move image decoding off the main thread (
prepareThumbnail/prepareForDisplay) - Eliminate offscreen rendering (shadowPath; no
masksToBounds+cornerRadius) - Set non-transparent views opaque with solid backgrounds
- Frame-based layout in scrolling cells (or PinLayout/FlexLayout)
- Cache cell heights and pre-compute text layouts
- Downsample images to display size before assigning
- Pixel-align all frames
- Disable implicit animations during batch updates
- Use Texture/AsyncDisplayKit for the most demanding scroll scenarios
- Profile with the Animation Hitches instrument — target < 5ms/s hitch ratio
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| No cell reuse | Memory spike, jank | dequeueReusableCell |
| Sync image load | Frozen scroll | Async loading |
| Shadow without path | Dropped frames | Set shadowPath |
| Deep Auto Layout | Slow layout | Flatten or manual |
| Main thread I/O | Hitches | GCD background |
| Missing prepareForReuse | Wrong content | Cancel + clear |
Frame Time Budgets
| Display | FPS | Budget | Usable Time |
|---|---|---|---|
| Standard | 60 | 16.67ms | ~12ms |
| ProMotion | 120 | 8.33ms | ~5-6ms |
| Hypothetical 240Hz | 240 | 4.17ms | ~2-3ms |
At 240Hz, off-main-thread rendering (Texture-style), manual frame-based layout, and pre-rendering of all complex content become mandatory, not optional.
Advanced Instruments
| Instrument | Signal | Threshold |
|---|---|---|
| Animation Hitches — Hitch Time Ratio | Total Hitch Time / Interaction Duration | < 5ms/s good, 5-10 warning, > 10 critical |
| GPU Driver — Renderer Utilization | GPU-bound when high | > 95% = GPU-bound |
| GPU Driver — Tiler Utilization | Too many layers / overdraw | > 95% = too many layers |
func testScrollingPerformance() {
let app = XCUIApplication()
app.launch()
measure(metrics: [
XCTOSSignpostMetric.scrollDecelerationMetric,
XCTOSSignpostMetric.scrollDraggingMetric
]) {
app.scrollViews.firstMatch.swipeUp(velocity: .fast)
}
}
Instruments Shortcuts
| Shortcut | Action |
|---|---|
| ⌘R | Run with Instruments |
| ⌘I | Profile |
| ⌘⇧R | Record |
| Space | Pause/Resume |