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
- id có ổn định không? Rẻ nhất để kiểm tra, hậu quả kịch tính nhất khi sai.
- Có công việc thật nào trong
bodykhông? Định dạng, sắp xếp, lọc, tính toán ngày tháng. - Có
GeometryReadernào trong dòng không? Giờ gần như luôn thay thế được. - Ả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.