Ask
89

swiftui list drops to 41 fps with 2,000 rows and asyncimage on an iphone 12 performance

Feed screen, List of about 2,000 rows. Each row has a 60x60 remote thumbnail via AsyncImage, a title, a subtitle and a timestamp.

On recent hardware it is fine. On an iPhone 12 it hitches noticeably on fast scrolls and Instruments shows frame rate dipping to around 41 during flings, with the drops clustered where new rows come in.

Things I have already tried without much change: removing the timestamp formatting from the row body, giving rows a fixed height, and switching from LazyVStack in a ScrollView to List (this helped a bit).

The images are the obvious suspect but they are only 60x60 on screen so I assumed they were cheap.

10 answers Share
Report

Answering anonymously — a moderator will review it first.

  • @rueben_alsop · 2mo ago · 2 replies

    Once images are handled, the next tier of things that cost frames in a List row:

    • AnyView anywhere in the row. It erases the type, so the diffing machinery cannot tell that the structure is unchanged and re-evaluates more than it needs to.
    • Formatters constructed in body. A DateFormatter created per row per redraw is genuinely expensive - hoist it to a static.
    • Deeply chained modifiers, particularly stacked .frame, .padding and .background combinations that each add a layout pass.
    • Any computed property on your model that does real work and is read in body.

    Measure with the SwiftUI template in Instruments rather than guessing. The "View Body" track shows you which view's body is slow and how often it runs, and "Long View Body Updates" points at the specific offender. It is a much better use of an hour than shotgunning modifiers.

    49
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @probe_to_ground · 2mo ago

      The formatter one is embarrassingly common and has an easy fix now - the .formatted() style APIs are cheaper than constructing a formatter, and if you need a custom one, a static let costs nothing after the first use.

      21
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @petra_lindqvist · 2mo ago · 3 replies

    60x60 on screen is not 60x60 in memory. That is the whole problem.

    AsyncImage does no caching beyond the URL loading system's default behaviour, which means as rows recycle it re-requests, re-decodes and re-downsamples the same images repeatedly. A 1200x1200 JPEG decoded to a bitmap is somewhere around 5.7MB of memory regardless of the frame you draw it in, and the decode itself is the expensive part - it happens on a scroll frame, and it blows your budget.

    What to do:

    • Downsample at decode time, not with .resizable(). Use ImageIO with kCGImageSourceThumbnailMaxPixelSize set to your display size in pixels, and kCGImageSourceCreateThumbnailFromImageAlways. You get a small bitmap out and never materialise the full one.
    • Cache the decoded result, keyed by url plus target size, in an NSCache. The second appearance of a row should be a dictionary lookup.
    • Do it off the main thread and hand the finished image back.

    Either adopt a library that does all three or write about 60 lines. Both are fine. What is not fine is AsyncImage in a long scrolling list - it is a convenience for a detail screen, not a feed component.

    2,000 rows is not your problem. 2,000 full-size decodes is.

    74
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @barre_chord_blues · 2mo ago

      Downsampling at decode plus a cache took the flings to a solid 60 on the same device. I had genuinely assumed the frame size was doing the work.

      24
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
    • @rueben_alsop · 2mo ago

      The other half of that win is asking your backend for a thumbnail URL. Downsampling client-side is the right fix for images you cannot control, but if you own the server, shipping a 1200px image to draw at 60pt is wasting the user's data as well as their CPU.

      20
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @probe_to_ground · 2mo ago

    Good instinct moving to List. Worth knowing why it helped, because it tells you when to use which.

    List is backed by a real reusing collection view. Cells are recycled and views for offscreen rows are torn down. LazyVStack in a ScrollView is lazy about creation but does not recycle - everything you have scrolled past is still there. On a 2,000 row feed that difference is memory that keeps growing and a scroll that gets worse the longer you use it.

    Rule of thumb: long or unbounded, use List. Short and heterogeneous where you want full control of the layout, LazyVStack is fine.

    32
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @rueben_alsop · 2mo ago · 2 replies

    Long lists with images are the case where SwiftUI still is not there. Wrap a UICollectionView with UIViewRepresentable and you get proper prefetching and cell reuse. Fighting the framework on this costs more time than the bridge does.

    9
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @petra_lindqvist · 2mo ago

      List already is a reusing collection view underneath. The OP's problem was image decoding, which a UIKit rewrite does not solve either - you would be writing the same downsampling code, just with more boilerplate around it. Bridging is a real tool for genuinely custom layouts, not a general answer to "this scrolls badly".

      7
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @corwin_ashby · 2mo ago

    Also check whether your row is doing any work in onAppear. It is easy to put a "mark as seen" network call or an analytics event there and then fire 2,000 of them during a fling. Debounce anything in onAppear on a list row, or move it to a visibility threshold.

    17
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @tenon_tuesday · 2mo ago

    Every performance thread eventually arrives at "you are decoding a photo of a mountain to draw a thumbnail the size of a postage stamp".

    4
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report