Trang chủ

Vì sao List của tôi cuộn tệ, và bốn thứ đã chữa được nó

Một danh sách khoảng 300 dòng cuộn mượt trên máy tôi và giật thấy rõ trên một chiếc iPhone SE. Tôi đã cho rằng câu trả lời là “máy cũ rồi” lâu hơn mức đáng lẽ.

Hóa ra là bốn vấn đề riêng biệt, tất cả đều do tôi, và không cái nào là cái tôi đã đoán.

1. Công việc nằm trong body mà lẽ ra không nên ở đó

Mỗi dòng đang định dạng một ngày và một giá trị tiền tệ, cho từng dòng, ở mọi lần tính:

struct OrderRow: View {
    let order: Order

    var body: some View {
        HStack {
            Text(order.title)
            Spacer()
            Text(order.total, format: .currency(code: order.currencyCode))
            Text(DateFormatter.medium.string(from: order.date))
        }
    }
}

body chạy nhiều lần trong một khung hình lúc cuộn — hệ thống bố cục tra hỏi các con bằng vài lời đề nghị trước khi quyết định bất cứ điều gì. Việc định dạng không miễn phí, và làm nó ba trăm lần một giây thì đo được.

Cách chữa là định dạng một lần, ở chỗ dữ liệu thay đổi, rồi đưa cho view một giá trị vốn đã là chuỗi:

struct OrderRowModel: Identifiable {
    let id: Order.ID
    let title: String
    let total: String        // định dạng một lần, ở phía trên
    let date: String
}

Đây là quy tắc “đừng tính toán trong body”, và là quy tắc tôi phá nhiều nhất vì tính toán trong body đọc lên tự nhiên quá.

2. Một id không ổn định

List(orders.indices, id: \.self) { index in
    OrderRow(order: orders[index])
}

Danh tính dựa trên chỉ số nghĩa là chèn một dòng lên đầu sẽ đổi danh tính của mọi dòng bên dưới. SwiftUI kết luận rằng ba trăm dòng đều đã thành những dòng khác, vứt bỏ bộ nhớ của chúng, và dựng lại tất cả — kèm một animation mờ chồng trên từng dòng, và đó là lý do cú chèn vừa trông sai vừa chậm.

List(orders) { order in           // Order: Identifiable, id đến từ mô hình
    OrderRow(order: order)
}

Cảnh báo

id: \.self trên một tập hợp các giá trị là một cái bẫy họ hàng: nó băm cả phần tử, nên một dòng có nội dung thay đổi sẽ nhận danh tính mới và bị dựng lại chứ không được cập nhật. Hãy dùng một id ổn định đến từ dữ liệu, đừng dùng chính dữ liệu.

3. Một GeometryReader nằm trong dòng

Có một cái, dùng để lấy kích thước cho một thanh tiến độ theo tỷ lệ bề rộng của dòng. GeometryReader tham gia vào lượt bố cục và chạy lại mỗi khi bất cứ điều gì về bố cục thay đổi, mà trong lúc cuộn thì là liên tục.

// trước
GeometryReader { proxy in
    Rectangle().frame(width: proxy.size.width * order.progress)
}

// sau
Rectangle().containerRelativeFrame(.horizontal) { width, _ in width * order.progress }

Kết quả nhìn giống hệt, không tham gia vào lượt tính kích thước. Đây là cải thiện lớn nhất trong bốn cái.

4. Ảnh được giải mã ở độ phân giải đầy đủ

Mỗi dòng có một ảnh thu nhỏ 40×40, nạp từ một ảnh gốc 2000×2000. Image giữ bitmap đã giải mã, không phải cái file — nên một ảnh 2000×2000 chiếm khoảng 16MB trong bộ nhớ bất kể nó được vẽ ra nhỏ đến đâu.

Ba mươi dòng nhìn thấy được nghĩa là hàng trăm megabyte bitmap đã giải mã, áp lực bộ nhớ liên tục, và công việc giải mã diễn ra ngay trong lúc cuộn.

func thumbnail(from data: Data, size: CGFloat) -> UIImage? {
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceThumbnailMaxPixelSize: size * UIScreen.main.scale,
        kCGImageSourceShouldCacheImmediately: true,
    ]
    guard let source = CGImageSourceCreateWithData(data as CFData, nil),
          let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
    else { return nil }
    return UIImage(cgImage: image)
}

CGImageSourceCreateThumbnailAtIndex giải mã thẳng ở kích thước đích thay vì giải mã cả ảnh rồi thu nhỏ lại. kCGImageSourceShouldCacheImmediately chuyển việc giải mã về đúng chỗ bạn gọi nó — ngoài luồng chính — thay vì để nó lười xảy ra lúc vẽ.

Tôi đã kiểm tra gì để tìm ra chúng

Self._printChanges() đặt trong dòng, rồi cuộn, rồi nhìn console. Các dòng in ra @identity trong một cú cuộn bình thường chính là dấu hiệu cho vấn đề số 2.

Mẫu Animation Hitches của Instruments cho phần còn lại. Nó hiện thời gian commit từng khung hình so với hạn chót và đánh dấu những khung bị lỡ. Bung một cú giật ra sẽ thấy thứ gì đang nằm trên luồng chính lúc đó, và đó là cách GeometryReader cùng việc giải mã ảnh lộ ra.

Thứ tự tôi sẽ kiểm tra lần sau

  1. id có ổn định không? Rẻ nhất để kiểm tra, hậu quả kịch tính nhất khi sai.
  2. Có công việc thật nào trong body không? Định dạng, sắp xếp, lọc, tính toán ngày tháng.
  3. Có GeometryReader nào trong dòng không? Giờ gần như luôn thay thế được.
  4. Ảnh có đang được giải mã lớn hơn kích thước được vẽ không? Gần như phổ biến, và hậu quả về bộ nhớ còn tệ hơn hậu quả về CPU.

Thứ tôi rốt cuộc không cần đến: phân trang, một diffable data source, tụt xuống UIKit, hay bất cứ thứ gì khác tôi đã xếp hàng sẵn trước khi đo. Cái danh sách vốn ổn — nó chỉ đang làm bốn việc không cần thiết cho mỗi dòng, ba trăm lần một giây.