跪拜 Guibai
← Back to the summary

The iOS Render Loop, Frame by Frame

Preface

It's hard to imagine someone still writing iOS articles in 2026.

This article will start from how a screen forms an image, explain why pages experience stuttering, outline the process a screen goes through to present a single frame, then analyze which parts of this process might cause frame drops, and provide corresponding solutions.

The content roughly includes:

Additionally, based on the iOS rendering pipeline and optimizations possible at each stage, I've written a skill for iOS page fluency. This allows AI to directly use this ios-ui-performance-skill to analyze and adjust areas in a project that might affect fluency.

Now, let's start with screen imaging.

How Does a Screen Form an Image?

render_Screen Imaging Principle.gif

Physically, our screens are composed of individual pixels. The reason they can display different images is that each pixel can emit different light. These pixels are arranged row by row, column by column, forming a pixel matrix.

The screen scans via a driver circuit, proceeding row by row, from left to right and top to bottom. One complete scan is one frame. However, a frame doesn't appear only after the scan is complete; because scanning happens row by row, the pixels in a row change as soon as it's scanned, and we can perceive that change. A so-called 120Hz or 60Hz screen refers to the number of scans occurring in one second. A 120Hz screen is a high refresh rate screen, capable of displaying 120 frames per second; a 60Hz screen can only display 60 frames per second. This is why 120Hz looks smoother than 60Hz. Animation is simply a series of still images displayed in quick succession; the more images shown in the same amount of time, the smoother it appears.

After completing a frame scan, the display emits a vertical sync signal. Displays typically refresh at a fixed rate, and this refresh rate is the frequency at which the vertical sync signal is generated. When playing games, some allow you to toggle vertical sync on or off—this refers to that same vertical sync signal.

The vertical sync signal was originally used to solve the problem of screen tearing.

How Does Screen Tearing Occur?

render_Screen Tearing.png

First, understand that the display and GPU operate independently. The display's job is mechanical: it starts from the top row, refreshes row by row downwards, and when it reaches the bottom row, one frame is complete, then it returns to the top and repeats. Its speed is fixed—for example, 60 times per second—and nothing can change its rhythm.

When refreshing, it reads content from a block of memory called the frame buffer. The GPU's task is to write new frames into this memory.

This creates a problem: the display is reading while the GPU is writing, operating on the same memory, causing a conflict. So the system prepares two buffers: while the display reads A, the GPU draws a new frame into B. Once B is ready, the two swap; the display then reads B, and the GPU draws the next frame into A. This is double buffering.

Screen tearing occurs precisely at the point of when to swap. Imagine a scenario: the display is reading A, having just refreshed halfway down the screen. At that moment, the GPU says "I'm done" and swaps the buffer to B. The display, unaware of this, continues refreshing, but the lower half it reads is now the new frame. Thus, one screen shows the top half of the old frame and the bottom half of the new frame. If the image is moving—for instance, a game camera panning left or right—the two halves are misaligned, which is tearing.

Why is the tear always horizontal? Because the display refreshes row by row from top to bottom; the tear appears at the exact row where the swap occurred.

The Role of the Vertical Sync Signal

The vertical sync signal establishes a rule for "buffer swapping": no swapping at arbitrary times; it must wait until the display has completely finished refreshing the current frame. As mentioned, the display emits a vertical sync signal after finishing a frame. Even if the GPU finishes drawing early, it must hold the new frame and wait for this signal, swapping only after the display completes the current frame. This way, the display reads from a single, complete buffer during each refresh cycle, ensuring the image is always a full frame, thus preventing tearing.

Of course, the cost is that no matter how fast the GPU draws, it must wait for the signal, so the frame rate is locked to the refresh rate. If the GPU draws a bit too slowly and misses the signal, it must wait for the next one—this frame stays on screen for an extra cycle, and the user perceives a momentary pause, which is a dropped frame.

In iOS, vertical sync is mandatory, and its role extends beyond preventing screen tearing; the entire rendering system is driven by it. CADisplayLink is attached to this signal, and Core Animation's per-frame commit also marches to its beat.

How Does the Vertical Sync Signal Enter an App?

The essence of the vertical sync signal is an interrupt emitted by the display at the boundary of each refresh cycle. The operating system receives it and turns it into a message distributed to the objects that need it. Inside an app, its most direct incarnation is CADisplayLink: you can listen for CADisplayLink in the app; after registering, the system calls back once per beat—many frame rate monitoring tools work this way.

Additionally, when a page is continuously changing, such as a scrolling list, the main thread is actually awakened beat by beat by this signal: it wakes up, does a round of work, sleeps, and wakes up again on the next beat.

A detail needs clarification: the app is not awakened on every beat. When idle, the app is event-driven—if you don't touch it, it sleeps, the screen doesn't need a new frame, and the display keeps repeating the old frame. This, of course, refers to a screen with no changing content, a completely static image. But the moment you touch the page, tap something, or receive data that requires a list refresh, the app is put on the clock: it must prepare the new frame's content before the beat arrives.

How is a Single Frame Rendered in iOS?

As mentioned, once a page starts changing, the app marches to the VSync beat, preparing new frames one by one.

So, what exactly is being "prepared" for a frame? For instance, how does view.frame = newFrame ultimately become pixels on the screen?

Apple calls the entire process of a frame, from event to display, the Render Loop, which consists of five stages:

render_Rendering Pipeline.png

Below, starting from a chat list receiving a new message, I'll introduce how a frame travels step-by-step to the screen.

Stage 1: Event

The first stage is event handling.

Events here aren't just user taps on the screen; they also include:

After the app receives these events, it executes our business logic and then modifies the page.

For example, after a chat list receives a new message, we might create or reuse a Cell and then modify its content:

cell.contentLabel.text = message.content;
cell.avatarView.image = avatar;
cell.alpha = 1;

We usually operate on UIView, but UIView is not what's ultimately handed to the GPU.

In iOS, every UIView has a CALayer behind it. UIView is mainly responsible for events, layout, and the view hierarchy, while CALayer is responsible for recording how this view should be displayed:

All the CALayers behind UIViews, organized according to parent-child relationships, form a layer tree:

render_Layer Tree.png

So, when we modify a UIView, we are ultimately changing the CALayer behind it. But at this point, only the state recorded in the layer tree has been modified; the new pixels haven't actually been drawn yet.

If these changes affect layout, the system marks the corresponding view as "needs layout"; if they affect content, the system marks it as "needs display."

This is the meaning of Needs in setNeedsLayout and setNeedsDisplay: they don't execute layout or drawing immediately but rather record the need, to be processed together in the later Commit phase.

Stage 2: Commit

After event handling ends, if any UI changes occurred in this cycle, the system enters the Commit phase.

Core Animation doesn't submit to the Render Server immediately every time we modify a property. Because during a single event handling cycle, we might modify many states consecutively:

view.frame = newFrame;
view.alpha = 0.5;
view.backgroundColor = UIColor.redColor;
...

If it submitted after every modification, the previous intermediate state would be overwritten by the next before it could even be displayed, generating a lot of meaningless work.

So Core Animation uses CATransaction to collect the layer changes occurring in this round and processes and submits them all at once.

Even if we don't write animations, CATransaction still exists. The transaction's role is to merge layer tree changes; the transaction itself is not equivalent to animation.

A complete Commit Transaction is further divided into four clearly sequenced phases:

Layout -> Display -> Prepare -> Commit

1. Layout

The first step is layout.

The system finds all views marked as needing layout and calls their layoutSubviews. Layout proceeds from parent views down to child views according to the view hierarchy.

In our chat list example, this step needs to determine:

If Auto Layout is used, constraint solving also happens here. The system calculates the final position and size of views based on constraints and reflects the results onto the corresponding CALayer.

The work for determining text layout dimensions also happens here.

For example, to calculate the height of a body text Label, the system needs to arrange the text line by line based on font, font size, line width, and line-breaking rules to know the final height this text occupies.

So text processing needs to be split into two concepts:

The former participates in layout and size calculation; the latter happens in the subsequent Display phase.

2. Display

After layout is complete, the second step is Display, which is drawing.

The system finds all views and layers marked as needing redisplay and generates new layer content for them.

If a view overrides drawRect:, the system calls it here. The system prepares a Core Graphics drawing context backed by a texture and then lets the view draw its content into it.

For example:

- (void)drawRect:(CGRect)rect {
    CGContextRef context = UIGraphicsGetCurrentContext();

    CGContextSetFillColorWithColor(context, UIColor.redColor.CGColor);
    CGContextFillRect(context, rect);
}

This doesn't draw directly onto the physical screen but into the layer's own content storage, known as the backing store. After drawing finishes, this content is handed to the subsequent Render Server and GPU for use.

Text actually turning into pixels also happens in this phase.

The previous Layout phase already calculated where each character should be placed; the Display phase then draws these glyphs into the layer content. For Core Animation, after drawing is complete, it no longer cares that this is text; it just treats it as the content the layer needs to display.

However, not every CALayer needs to execute drawRect:.

Properties like background color, border, corner radius, position, opacity, and transform are attributes that CALayer can describe directly. When only these properties are modified, the system can directly update the layer tree without requiring the app to redraw a bitmap.

Similarly, if a layer's content hasn't changed, only its position, the original content can be reused without redrawing.

3. Prepare

After drawing is complete, the next step is Prepare, making final content preparations for submission.

Image decoding is handled in this phase.

There are three distinct flows for image rendering:

The reason JPEG and PNG files are small on disk is that they store compressed data. To display an image, the GPU needs decompressed pixel data; it cannot directly use the compressed bytes from a JPEG or PNG file as a regular texture.

So we need to decompress images.

For example, a standard RGBA image of 1000 * 1000 pixels might be only a few hundred KB as a JPEG file on disk, but after full decoding, it requires approximately 1000 * 1000 * 4 bytes = 4 MB.

This is because each pixel after decoding typically needs four channels: red, green, blue, and alpha.

Here's the most easily confused concept: Creating a UIImage object does not mean the image has been decoded.

UIImage *image = [UIImage imageWithData:data];
imageView.image = image;

The first line mainly creates an image object using compressed data. As long as the pixel data behind it isn't ready, this image is "not yet decoded."

During the Prepare phase of the Commit Transaction, the system discovers that this frame needs to use this image and decodes it from compressed formats like JPEG or PNG into a pixel format the GPU can use.

If the image's color format isn't one the GPU can handle directly, the system also completes color format conversion here. This conversion process requires reallocating memory and copying pixels, adding time and memory overhead.

So by default, when an undecoded image participates in display for the first time, the decoding cost falls on this frame's Prepare phase. If the image is large, this step lengthens the entire Commit Transaction, potentially causing the app to miss the submission deadline.

What's called "background pre-decoding" is essentially completing this Decode before the image enters the page.

Without pre-decoding:

Load
  ↓
Wait until Commit's Prepare phase
  ↓
Decode
  ↓
Submit

With pre-decoding:

Background Load
  ↓
Background Decode
  ↓
Get a prepared image
  ↓
Use directly in Commit's Prepare phase

4. Commit

After Layout, Display, and Prepare are all complete, the final step is the actual Commit.

Core Animation starts from the root node, recursively organizes the changed layer tree, packages the parent-child relationships, positions, sizes, opacity, transforms, and prepared layer content, and then submits it to the Render Server.

So what the app hands over is not a pre-composited screenshot of the page, nor does it hand UIView objects directly to the GPU. It hands over a prepared layer tree and the content needed for rendering each layer.

At this point, the app process's part is finished.

If any step among Layout, Display, Prepare, or Commit takes too long, the app cannot complete the submission on time. This type of stutter is called a Commit Hitch.

Stage 3: Render Prepare

The content handed over by the app goes to the Render Server.

The Render Server is a system process independent of the app. It receives the layer tree submitted by Core Animation and translates this UI description into rendering tasks that the GPU can execute.

This stage is called Render Prepare.

It's important to note that Render Prepare still involves CPU work, but this CPU work occurs in the Render Server process, not the app process.

The Render Server doesn't recognize UIView; it sees a layer tree:

The Render Server traverses the layer tree from the root node, organizing it into a rendering pipeline the GPU can execute, according to parent-child relationships and stacking order.

During this process, it also calculates:

For example, if we submit a Core Animation position animation, the app only needs to submit the start point, end point, duration, and timing curve. After the animation starts, the Render Server calculates the position for the current frame; the app doesn't need to re-modify the frame every frame.

After Render Prepare completes, the Render Server obtains a set of drawing commands that can be handed to the GPU for execution.

Stage 4: Render Execute

This stage is where the GPU actually performs the rendering.

At this point, the GPU doesn't need to understand UILabel, UIImageView, or UIButton. Text has already been turned into usable layer content, and images have been decoded into pixel data.

The GPU's job is to composite all layers into a final image, following the commands prepared by the Render Server.

The GPU processes layers from back to front.

Opaque layers can directly overwrite the content below them; semi-transparent layers need to read the pixels underneath and calculate the blending result based on opacity.

If certain effects cannot be completed in a single rendering pass—such as some combinations of masks, shadows, or clipping—the GPU needs to draw intermediate results to a temporary render target and then bring them back to participate in the final composition. This is off-screen rendering.

After all layers are composited, the GPU writes the final image into the Render Buffer.

If the Render Server's CPU or the GPU execution doesn't finish on time, this type of stutter is called a Render Hitch.

Stage 5: Display

After the GPU writes the final image into the Render Buffer, the display driver reads the prepared buffer when the target VSync arrives and then updates the pixels onto the screen.

In double-buffering mode:

If a new frame doesn't catch the target VSync, it cannot be displayed on time. The screen can only let the previous frame stay for an extra refresh cycle, and the user perceives a momentary pause.

This is a Parallel Pipeline

The five stages above have clear sequential dependencies, but they don't wait for one frame to be completely displayed before starting to process the next.

In a normal double-buffering pipeline, at the same time period, it might be:

App              Preparing frame N
Render Server    Rendering frame N - 1
Display System   Displaying frame N - 2

So, the 16.7 ms under 60Hz doesn't mean the app has to complete all the work from business code to the physical screen within 16.7 ms alone.

It means the entire pipeline must advance roughly every 16.7 ms. Both the app and the Render Server have their own deadlines. As long as each stage can hand off its work on time, the screen can continuously receive new frames.

Looking back now, the entire flow is:

Event
  App handles events, modifies UIView and CALayer
  ↓
Commit Transaction
  Layout → Display → Prepare → Commit
  ↓
Render Prepare
  Render Server uses CPU to prepare rendering commands
  ↓
Render Execute
  GPU composites layers, writes to Render Buffer
  ↓
Display
  Display driver sends the frame to the screen at the target VSync

render_Rendering Pipeline.png

If any stage misses its deadline, the new frame arrives late—this is stuttering.

Causes of Stuttering

Knowing the rendering pipeline, let's look at exactly where stuttering can occur.

Apple categorizes stuttering in the rendering process into two types:

This classification is based on where the hitch occurs, not simply dividing it into CPU lag and GPU lag. Because CPU work doesn't only happen in the app process; the Render Server also uses the CPU when preparing rendering commands.

Commit Hitch

If the app takes too long in the Event or Commit Transaction phases and doesn't submit the new layer tree to the Render Server on time, a Commit Hitch occurs.

In the Event phase, main thread time-consuming operations might come from:

If the main thread is doing these things, the subsequent layout and submission can only wait.

Entering the Commit Transaction, time consumption can come from different phases:

Besides single heavy tasks, overly frequent updates can also cause Commit Hitches.

For example, a chat list that does a full refresh for every message received triggers layout, text layout, and drawing anew each time. A single refresh might not time out, but if every frame is packed full, encountering a longer message or an undecoded image can exceed the frame's deadline.

Render Hitch

The app submitting the layer tree on time doesn't guarantee the frame will be displayed on time. The Render Server still needs to complete Render Prepare and Render Execute. Timeouts in either phase produce a Render Hitch.

Render Prepare uses the CPU, but the work happens in the Render Server process. It needs to traverse the layer tree, calculate animation states, and organize layer transforms, clipping, masks, and shadows into drawing commands the GPU can execute. If the layer tree is complex, or this frame needs to handle many animations and special effects, Render Prepare might exceed the deadline.

Render Execute is the phase where the GPU actually performs rendering. Common pressures mainly come from:

However, corner radius does not necessarily trigger off-screen rendering. Whether off-screen rendering is needed depends on whether it clips sublayers, uses a mask, and whether the system can directly achieve the effect.

Large images also need to be considered separately. If an image hasn't been decoded yet, the time cost occurs in the app's Prepare phase, belonging to a Commit Hitch; if the GPU needs to process many large textures in one frame, the problem appears in the Render phase.

When investigating stuttering, the first thing to determine is whether the app failed to submit this frame on time, or the Render Server failed to render it on time.

How to Solve Stuttering?

The first step in solving stuttering is not to immediately move any time-consuming operation to the background upon seeing it, nor to switch to pre-cut images upon encountering rounded corners or shadows. It's to first locate: Where exactly did this frame fall behind?

If the app didn't generate and submit the layer tree in time, it's a Commit Hitch; if the app submitted on time but the Render Server or GPU didn't complete rendering in time, it's a Render Hitch. The two occur in different places, and the solutions differ.

First, Determine Where the Lag Is

Before optimizing, you need to first determine exactly where the lag is. It's best to stably reproduce the stutter on a real device and record the operation path and performance metrics when the stutter occurs.

Our project built a performance floating window based on YYFPSLabel, supplementing GPU metrics on top of FPS, CPU, and memory. During daily development, you can directly observe this data and immediately sense any performance fluctuations.

(Later, I wrote a SwiftUI version, adding performance log export and charting features: FRPerfKit).

However, these metrics only provide clues; they cannot directly determine which phase the stutter occurred in. After noticing performance fluctuations, you still need to go to Xcode Instruments' Animation Hitches to pinpoint the problem.

render_Instruments.png

If significant delays appear in the Commits phase, the problem is usually on the app process side, and you can continue using Time Profiler to find time-consuming functions on the main thread.

If delays appear in the Renders or GPU phase, you should examine the layer architecture, off-screen rendering, blending, overdraw, and texture sizes.

Solving Commit Hitch

Commit Hitch indicates the app didn't complete Event, Layout, Display, Prepare, and Commit within this frame's time budget.

Shortening this critical path generally involves three methods:

Let's go through it phase by phase.

Event: Reduce Extra Work on the Main Thread

In the Event phase, the CPU isn't just handling touch events. After a message arrives, synchronously parsing large JSON, reading from disk, waiting for a lock held long-term by another thread, or creating and destroying many objects at once will all make subsequent layout and submission wait.

Prepare Message Display Data in the Background

Take our project's public chat as an example. Each message has a user level icon, user badge icon, user nickname, and body text; if it's a gift message, there's also a small gift icon at the end. So we assemble this content into an NSAttributedString and finally hand it to YYLabel for display.

The optimizations we made for this step can be summarized in four points:

  1. Process message parsing in the background, converting raw data from the Socket into RoomMessageModel.
  2. When parsing the message, assemble the rich text based on the level, badge, nickname, and body text in the model, and record the result in model.attribute.
  3. If it's a gift message, append the gift image to the end of the rich text after it's ready, then use YYText to calculate the final layout size and record it in model.extraLayout.
  4. Only Models with both rich text and layout prepared enter the data source queue for the public chat list. The main thread refreshes at a fixed cadence, and the Cell only needs to read attribute and extraLayout.

Ultimately, the Cell just needs to use the pre-calculated results in the Model:

self.contentLabel.attributedText = model.attribute;
CGSize size = model.extraLayout.size;
self.contentLabel.frame = CGRectMake(0, 0, size.width + 20, size.height + 10);

There's another point that can be optimized: we previously only cached the rich text and size; YYLabel internally might still regenerate the layout. If debugging reveals this step still takes a long time, you can directly record the YYTextLayout property in the Model, and the Cell can consume it directly.

If strictly differentiated by rendering phase, message parsing and model conversion reduce main thread work in the Event phase, while pre-measuring sizes reduces work in the subsequent Layout phase.

Merge and Throttle High-Frequency Tasks

For high-frequency messages, they can first enter a queue and then be merged and refreshed at a fixed cadence, avoiding triggering a complete layout and draw for every single message. During a message flood, if necessary, some low-priority messages can be dropped. For example, our public chat throttles to 10 chat messages per second, because when a chat room is flooded, the main pursuit is the atmosphere; the specific content isn't that important.

We refresh the list approximately every 400 ms.

It's important to note that what's being limited here is the UI submission frequency, not the message reception frequency. Messages are received normally and shouldn't be arbitrarily discarded.

Control Object Creation and Destruction

Object lifecycles also belong to this phase:

It should be noted that releasing objects in the background has very low priority. It's only worth considering when Instruments indeed observes that the dealloc process of a specific large pure data object is blocking the main thread, and the entire object is allowed to be destroyed in the background. Ordinary small objects, objects containing UIKit resources, and objects with unclear destructor thread requirements should not be handled this way, as the benefit is minimal.

If these conditions are indeed met, you can release it like this:

NSArray *tmp = self.hugeArray;   // Catch it with a local variable
self.hugeArray = nil;            // The main thread's reference is broken, but tmp still holds it, the object can't die

dispatch_async(bgQueue, ^{
    [tmp class];                 // Just call something arbitrarily
});
// After the block executes, tmp, the last reference, disappears on the background thread
// → dealloc, along with the release of tens of thousands of elements, all happen in the background

Only when this is confirmed to be the last strong reference will the final destruction begin from this point. As long as there are still caches, closures, or other objects holding it, the final release will still happen where that reference is broken.

The thread on which an object's last strong reference is broken is the thread on which dealloc executes.

Layout: Avoid Repeated Layout and Text Measurement

The focus of layout optimization isn't simply abandoning Auto Layout, but avoiding recalculating the same results within a single frame.

For example, as we did earlier, recording the rich text content into the Model, so the Cell only reads the result when about to be displayed, without needing to measure again.

Of course, the pre-laid-out results mentioned earlier are not necessarily immutable. When the message content, public chat width, or font mode changes, they need to be cleared and rebuilt.

Also, it should be noted that using frame is not inherently faster than Auto Layout. A more common first step is to reuse already established constraints, only update the necessary constants, and avoid repeatedly adding and removing entire constraint sets, recursively triggering parent view layouts, or calling layoutIfNeeded multiple times in one update. Only when Time Profiler identifies that the constraints are indeed too complex, and the layout is stable enough, is it worth considering frame. Auto Layout is still much better than frame in terms of code readability.

For the animation part, as mentioned earlier, Core Animation animations only require the app to submit start point, end point, and timing parameters; intermediate frames can be calculated by the Render Server. When visual effects allow, prioritize using transform and opacity animations that Core Animation can directly express for translation, scaling, and fading, rather than modifying constraints or frame frame-by-frame using CADisplayLink, which forces the app to re-layout and submit every frame.

// Submit start point, end point, and timing parameters; don't modify constraints frame-by-frame in CADisplayLink
self.cardView.transform = CGAffineTransformIdentity;
self.cardView.alpha = 1.0;

[UIView animateWithDuration:0.25 animations:^{
    self.cardView.transform = CGAffineTransformMakeTranslation(0, -12);
    self.cardView.alpha = 0.0;
}];

Display: Reduce Drawing Costs

Entering Display, the app needs to generate content for changed layers. Complex text, custom Core Graphics drawing, and large-area redraws can all occupy the main thread.

First, reduce invalid drawing:

If complex text or custom graphic drawing has become a clear bottleneck, then consider asynchronous drawing. You can refer to YYAsyncLayer and AsyncDisplayKit.

The implementation of YYAsyncLayer mainly involves the following four steps:

  1. The main thread reads the current state and generates an immutable, thread-safe drawing snapshot.
  2. A background thread generates a bitmap based on the snapshot and checks at appropriate drawing nodes whether the task has been cancelled.
  3. Before returning to the main thread, it verifies the content version or Cell identity again.
  4. Only if the result is still valid does it set the bitmap onto the corresponding Layer.

The hardest part of asynchronous drawing is preventing drawing onto the wrong object. Because from the time a task enters the background queue to when drawing is complete and returns to the main thread, there's a time gap. During this period, the Cell might have been reused, and the content might have changed.

YYAsyncLayer's approach uses a mechanism similar to a version number, designing an atomic counter that only increments. It compares version numbers at the start and end to verify and prevent drawing onto the wrong object.

YYAsyncLayer's handling:

// YYSentinel —— Version number
@implementation YYSentinel {
    int32_t _value;
}
- (int32_t)value { return _value; }
- (int32_t)increase { return OSAtomicIncrement32(&_value); }
@end

// YYAsyncLayer.m —— The sole entry point for "content changed": version number +1, all in-flight old tasks are invalidated
- (void)setNeedsDisplay {
    [self _cancelAsyncDisplay];
    [super setNeedsDisplay];
}
- (void)_cancelAsyncDisplay {
    [_sentinel increase];
}

// YYAsyncLayer.m —— Asynchronous branch
- (void)_displayAsync:(BOOL)async {
    __strong id<YYAsyncLayerDelegate> delegate = (id)self.delegate;
    YYAsyncLayerDisplayTask *task = [delegate newAsyncDisplayTask];   // Packaged on main thread

    // Before departure: grab the current latest version number
    YYSentinel *sentinel = _sentinel;
    int32_t value = sentinel.value;
    BOOL (^isCancelled)(void) = ^BOOL { return value != sentinel.value; };

    // Even size / opaque / scale are captured as local variables on the main thread
    CGSize size = self.bounds.size;
    BOOL opaque = self.opaque;
    CGFloat scale = self.contentsScale;

    // Enter background
    dispatch_async(YYAsyncLayerGetDisplayQueue(), ^{
        // Checkpoint 1: Before starting to draw
        if (isCancelled()) return;
        UIGraphicsBeginImageContextWithOptions(size, opaque, scale);  // Open canvas
        CGContextRef context = UIGraphicsGetCurrentContext();
        // Checkpoint 2: Inside display, passed into the drawing loop
        task.display(context, size, isCancelled);
        // Checkpoint 3: After drawing, before fetching the image
        if (isCancelled()) {
            UIGraphicsEndImageContext();
            return;
        }
        UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
        UIGraphicsEndImageContext();

        // Return to main thread
        dispatch_async(dispatch_get_main_queue(), ^{
            // Checkpoint 4: The final check before going on screen
            if (isCancelled()) {
                if (task.didDisplay) task.didDisplay(self, NO);
            } else {
                // Set the image
                self.contents = (__bridge id)(image.CGImage);
                if (task.didDisplay) task.didDisplay(self, YES);
            }
        });
    });
}

As you can see, many steps check the cancellation status, allowing expired tasks to be discovered and stopped as early as possible.

Another point to note: When a Block captures an object, it captures a strong reference, not a deep copy of the object. If the original object can still be modified, it should be copy'd into an immutable value beforehand, or other methods should be used to ensure no race conditions occur during background reads.

Additionally, asynchronous drawing doesn't reduce the workload; it actually adds scheduling and task cancellation, and must ensure result consistency. During redrawing, it might also hold both the old and new bitmaps simultaneously. You need to judge based on the actual scenario whether to use asynchronous drawing. Again, don't use it just for the sake of using it.

Prepare: Prepare Images in Advance

PNG, JPEG, and other image files store compressed data that cannot be used directly. Images need to be decoded into pixel data before their first on-screen appearance. If an original image of several thousand pixels is ultimately displayed as just a few dozen pixels for an avatar, decoding the full size at this point brings unnecessary memory spikes.

For images whose pixel dimensions are already close to the display size, you can use iOS 15's prepareForDisplay to decode in advance; for significantly oversized images, you can use prepareThumbnail or ImageIO to generate a thumbnail at the target display size. The key for the latter is downsampling in the decoding path, which can be colloquially understood as shrinking the dimensions first, then decoding. Pass imageView.bounds.size to prepareThumbnail, then use UIGraphicsImageRenderer to draw into a smaller canvas.

NSString *itemID = [item.identifier copy];
CGSize targetSize = item.layout.avatarSize; // Target display size (pt), from the already calculated layout
cell.representedID = itemID;
cell.avatarView.image = self.placeholderImage;
__weak ChatCell *weakCell = cell;

[rawImage prepareThumbnailOfSize:targetSize
               completionHandler:^(UIImage *thumbnail) {
    dispatch_async(dispatch_get_main_queue(), ^{
        ChatCell *cell = weakCell;
        if (!cell || ![cell.representedID isEqual:itemID]) return;
        cell.avatarView.image = thumbnail;
    });
}];

When using SDWebImage, you can pass SDWebImageContextImageThumbnailPixelSize to let the decoder generate a thumbnail at the target pixel dimensions. Note that the unit for this parameter is pixels, so you need to pass bounds.size * screen scale.

Additionally, YYWebImage and SDWebImage both force decoding by default, so the pre-decoding step can be handed off to these third-party libraries.

Another scenario: For lists with low real-time requirements and where delayed loading during fast scrolling is acceptable, you can load only placeholder content while the list is scrolling and start loading the actual content after the user stops scrolling.

Commit: Simplify the Layer Tree to be Submitted

After layout, drawing, and image preparation are complete, the app still needs to recursively organize the layer tree and submit this frame's changes to the Render Server. In iOS, every UIView has a CALayer behind it, and the View's parent-child relationships typically form a corresponding Layer hierarchy. Therefore, having many nodes in the layer tree with no actual responsibility, or a small change repeatedly triggering layout and drawing for the entire subtree, will increase the Commit workload.

So, during daily development, we should pay attention to whether there are intermediate layers in the view hierarchy that can be removed, and whether each update can be limited to only the area that actually changed.

Avoid Excessive Hierarchy Depth

First, don't add containers with no actual responsibility just for grouping. For example, if an intermediate View doesn't handle layout, clipping, background, interaction, or animation, and merely wraps another View, consider removing it directly. Simple background colors, borders, and corner radii can also be set directly on the existing View or its Layer, without overlaying another decorative View.

Second, keep the view tree stable. Subviews in a Cell or complex component should be created once during initialization, with subsequent updates only modifying content, position, and visibility. Don't delete and recreate them on every refresh. Use hidden for fixed content that is temporarily not displayed. For content with a variable number of tags, avatars, etc., reuse existing Views, only supplementing or recycling the changed parts.

- (void)setupSubviews {
    self.iconView = [[UIImageView alloc] init];
    self.titleLabel = [[UILabel alloc] init];
    self.badgeView = [[UIImageView alloc] init];

    [self addSubview:self.iconView];
    [self addSubview:self.titleLabel];
    [self addSubview:self.badgeView];
}

- (void)updateWithModel:(CardModel *)model {
    self.iconView.image = model.icon;
    self.titleLabel.text = model.title;
    self.badgeView.hidden = !model.showsBadge;
}

Third, content that is displayed together, scrolls together, and doesn't require separate interaction can be appropriately merged. For example, the level, badge, nickname, body text, and gift image in the public chat messages mentioned earlier can be combined into a single rich text and handed to one text control for display, rather than creating multiple separate UIImageViews and UILabels.

But for buttons, input fields, and content requiring independent clicks or independent animation, keeping them as separate controls is usually more appropriate.

It's important to note that replacing multiple Views with an equal number of CALayers does not flatten the layer tree, because these sublayers are still nodes that need to be submitted. True simplification means deleting unnecessary nodes or compositing multiple elements that only serve static display into a single drawing content.

Limit the Scope of Changes

Change Occurred Handling Method Update Scope
Modifying text, image, color, or opacity Directly update the corresponding View or Layer property The corresponding node
Subview positions or sizes need recalculation Call setNeedsLayout on the smallest container responsible for these subviews The subtree of that container
All content of a custom-drawn View changes Call setNeedsDisplay The entire View
Only a fixed area in a custom-drawn View changes Call setNeedsDisplayInRect: The specified rectangle

For example, if only the visibility of a badge on a custom card changes, and the badge position is fixed, you only need to update badgeView.hidden; there's no need to re-layout the entire page. If the badge change affects the title width, you can let the card re-layout its own subviews.

- (void)setShowsBadge:(BOOL)showsBadge {
    if (_showsBadge == showsBadge) return;

    _showsBadge = showsBadge;
    self.badgeView.hidden = !showsBadge;
    [self setNeedsLayout]; // Only request the current card to re-layout its own subviews
}

- (void)layoutSubviews {
    [super layoutSubviews];

    self.badgeView.frame = [self badgeFrame];
    self.titleLabel.frame = [self titleFrameForShowsBadge:self.showsBadge];
}

setNeedsLayout only records a layout request once; the system processes it uniformly in the next update cycle. Unless the code immediately needs to read the new frame or is executing an animation that depends on the final layout result, avoid frequently calling layoutIfNeeded to force immediate layout. When immediate layout is necessary, it should be called on the smallest common parent View that can cover the relevant constraints, rather than starting from the root View.

Custom-Drawn Views

Ordinary UILabels and UIImageViews each become nodes in the view tree. A custom-drawn View uses drawRect: to directly draw this content, without creating these sub-controls. It's not putting multiple controls into a container but replacing those controls with a single drawing operation.

Additionally, drawRect: is called by the system; business code should not call it directly. setNeedsDisplayInRect: adds the specified rectangle to the area pending redraw and processes it uniformly in the next drawing cycle, rather than executing immediately.

Custom drawing is suitable for large amounts of repetitive, primarily display-oriented content with simple interaction. It can reduce View and Layer nodes but also shifts some work to the Display phase. By default, drawRect: executes on the main thread. When controls need independent clicks, animations, or frequent individual changes, continuing to use UIKit-provided controls is more appropriate; only use custom drawing after confirming it's necessary.

Solving Render Hitch

If the app has submitted the layer tree on time, but the Renders or GPU phase is still late, the problem isn't whether the business code is faster, but whether the Render Server's command generation and the GPU's command execution are timely.

Render Prepare: Simplify Layer Effects and Structure

The Render Server needs to traverse the submitted layer tree, calculate animation states, transforms, clipping, masks, and shadows, and generate commands the GPU can execute. Complex effects and frequently changing layer structures increase preparation costs, and some effects also increase subsequent pixel processing.

For example, if a card shadow's shape is already determined, set an explicit shadowPath to avoid the system repeatedly inferring the shadow outline. For simple rounded corners, prioritize using cornerRadius and cornerCurve. Only enable corresponding clipping when child content truly needs to be clipped; use an additional Mask only for complex, irregular shapes. Rounded corners themselves do not necessarily trigger off-screen rendering; judgment should be combined with the actual layer content and tool diagnostic results.

When shadow and content clipping coexist, you can split responsibilities into inner and outer layers: the outer layer draws the shadow without clipping, and the inner layer handles rounded corners and clipping.

- (void)layoutSubviews {
    [super layoutSubviews];

    CGFloat radius = 12.0;
    self.contentContainer.frame = self.bounds;
    self.contentContainer.layer.cornerRadius = radius;
    self.contentContainer.layer.cornerCurve = kCACornerCurveContinuous; // iOS 13+
    self.contentContainer.layer.masksToBounds = YES;

    self.layer.masksToBounds = NO;
    self.layer.shadowPath =
        [UIBezierPath bezierPathWithRoundedRect:self.bounds
                                   cornerRadius:radius].CGPath;
}

shadowPath needs to be updated synchronously when bounds change, so it's placed in layoutSubviews. The inner layer only needs masksToBounds when content truly exceeds the rounded corner boundary.

Stable layer structures should also be reused as much as possible; don't repeatedly create Masks, Effect Views, or entire Layer subtrees just for a state change.

Key scenarios to check for off-screen rendering include:

Render Execute: Reduce GPU Pixel Work

At Render Execute, the GPU actually processes layers and pixels. The core of optimization can be summarized in one sentence: reduce the number of pixels that need to be processed and the number of processing passes in this frame.

First, reduce unnecessary alpha blending. If every pixel of a View is confirmed to be opaque, correctly set the opaque property and background so the GPU doesn't need to read the color behind it for blending. However, when containing transparent pixels, areas outside rounded corners being transparent, or the content itself being semi-transparent, you cannot incorrectly declare it as opaque for performance.

// Only set this when every pixel of this layer is confirmed to be opaque
self.backgroundView.opaque = YES;
self.backgroundView.backgroundColor = UIColor.whiteColor;

self.messageLabel.opaque = YES;
self.messageLabel.backgroundColor = UIColor.whiteColor;

Second, reduce overdraw. If a chat Cell simultaneously has an opaque Cell background, a full-screen container background, a transparent overlay, and the final content, the same area might be drawn multiple times. Completely invisible background layers, repeated gradients, and full-size transparent Views with no actual effect should be removed. Using Color Blended Layers can reveal these blending areas, but whether optimization is needed should be judged in conjunction with GPU time consumption.

Third, control off-screen rendering. Effects like dynamic shadows, complex masks, and blurs might generate intermediate layers first, then participate in the final composition, adding extra rendering passes and memory bandwidth. Using shadowPath, simpler layer effects, and avoiding unnecessary Masks can reduce these off-screen rendering behaviors. There's no great need to use background image cutting just because of rounded corners; the steps for background image cutting are also quite troublesome.

Fourth, control the pixel dimensions and quantity of textures. A few-dozen-pixel avatar doesn't need to long-term hold a decoded bitmap of several thousand pixels; this increases both the app's Prepare and memory pressure, and the cost of processing large textures in the rendering phase.

If a page needs to simultaneously draw many small fixed icons or sprite frames, and measurements have already shown that texture switching or resource preparation is the bottleneck, you can further consider texture atlasing. Multiple Layers can share the same image, displaying their respective regions via contentsRect:

layer.contents = (__bridge id)atlasImage.CGImage;
layer.contentsRect = CGRectMake(x, y, width, height); // Normalized coordinates 0~1

However, it should be noted that contentsRect only selects the texture region; it doesn't guarantee that ordinary Core Animation scenes are necessarily merged into a single draw call.

Atlasing brings costs like atlas rebuilding, edge sampling color bleeding, size limits, and large images residing in memory. Therefore, it's suitable for stable, small resources that often appear together, not for continuously changing network images like user avatars.

Summary

The process for iOS to draw a UI element is as follows:

render_Overall Process.png

Along this entire chain, each phase has its own optimization solutions, though not all are mandatory:

The overall optimization approach is: Do what can be done early, reuse what can be reused, only update what has actually changed, and re-measure and verify after optimization. Most importantly, judge whether optimization is necessary; excessive optimization is counterproductive.

Finally finished writing.

References

Skill Address

ios-ui-performance-skill

Comments

Top 2 from juejin.cn, machine-translated. The original thread is authoritative.

沛君37070

Very good, clear logic, easy to understand.

用户3353606041416

Well written.