Tìm hiểu LazyVStack và LazyHStack trong SwiftUI là gì — stack lười để hiển thị hiệu quả danh sách cuộn, lưới và băng chuyền trên iOS, macOS, watchOS và tvOS. Không giống như VStack và HStack thông thường, stack lười chỉ tạo phần tử khi chúng xuất hiện trong vùng hiển thị, giúp giảm đáng kể mức tiêu thụ bộ nhớ khi làm việc với tập dữ liệu lớn. Kiến trúc của stack lười dựa trên giao thức Layout và được tích hợp với nhận dạng qua ForEach và ScrollView.
Những điểm chính
LazyVStack và LazyHStack là các container bố cục trong SwiftUI tạo và hiển thị view con chỉ khi cần, khi chúng hiển thị trong vùng cuộn được. LazyVStack sắp xếp các phần tử theo chiều dọc (từ trên xuống dưới), trong khi LazyHStack sắp xếp chúng theo chiều ngang (từ trái sang phải).
Cả hai stack được Apple giới thiệu trong SwiftUI 2.0 (iOS 14, macOS 11, watchOS 7, tvOS 14) cùng với LazyVGrid và LazyHGrid. Trước khi stack lười xuất hiện, các nhà phát triển phải sử dụng UITableView và UICollectionView qua UIViewRepresentable để làm việc hiệu quả với danh sách lớn. LazyVStack đã loại bỏ nhu cầu này bằng cách cung cấp giao diện SwiftUI gốc với tải lười tự động.
Theo WWDC Session 10031 của Apple (2020), stack lười sử dụng cơ chế tạo view trì hoãn: SwiftUI lưu trữ dữ liệu nguồn (ví dụ: mảng mô hình) và tạo phiên bản view ngay trước khi hiển thị trên màn hình. Khi cuộn, các stack tái sử dụng view đã tạo, tránh cấp phát mới — điều này giảm tải cho bộ cấp phát bộ nhớ và trình dọn rác của Swift.
Để làm việc với stack lười, luôn đặt chúng bên trong ScrollView — nếu không có cuộn, các phần tử vượt ra ngoài ranh giới màn hình sẽ bị cắt, không được tạo lười.
Cơ chế tải lười trong LazyVStack dựa trên hình học: SwiftUI theo dõi vị trí của mỗi view con so với container ScrollView. Khi một phần tử vượt qua ranh giới vùng hiển thị (với bộ đệm nhỏ vài điểm), hệ thống gọi trình khởi tạo của nó và hiển thị nội dung. Khi phần tử rời khỏi màn hình, SwiftUI hủy view nhưng giữ trạng thái qua @State nếu được đánh dấu là có thể bảo tồn.
Cách tiếp cận này khác với VStack, nơi tất cả view con được tạo ngay lập tức khi khởi tạo container, bất kể khả năng hiển thị. Đối với danh sách 10.000 phần tử, VStack sẽ tạo 10.000 phiên bản view trong bộ nhớ, trong khi LazyVStack chỉ tạo những phần tử vừa màn hình (thường 8–15).
LazyVStack chấp nhận ba tham số cấu hình: alignment (HorizontalAlignment — leading, center, trailing), spacing (CGFloat — khoảng cách giữa các phần tử) và pinnedViews (PinnedScrollableViews — ghim tiêu đề phần). LazyHStack sử dụng cùng tham số, nhưng alignment chấp nhận VerticalAlignment (top, center, bottom).
Sự khác biệt chính giữa LazyVStack và VStack là chiến lược tạo phần tử con. VStack (stack háo hức) tính toán kích thước và vị trí của tất cả view con tại thời điểm hiển thị, khiến nó không phù hợp cho danh sách động lớn. LazyVStack (stack lười) trì hoãn việc tạo cho đến khi phần tử hiển thị.
Hãy so sánh hành vi bằng danh sách 1000 dòng văn bản. VStack sẽ tải tất cả 1000 dòng vào bộ nhớ ngay lập tức, gọi trình khởi tạo của mỗi dòng và cấp phát bộ nhớ cho nó. Điều này dẫn đến suy giảm hiệu suất trên các thiết bị yếu (iPhone SE, iPad mini) và tăng thời gian khởi động màn hình. LazyVStack chỉ tải 10–12 dòng hiển thị, tạo phần còn lại khi cuộn.
Một thử nghiệm thực tế (sử dụng Xcode Instruments, hồ sơ Allocations) cho thấy: trên iPhone 12 mini, danh sách 5000 phần tử với LazyVStack tiêu thụ 3–5 MB bộ nhớ, trong khi VStack với cùng nội dung tiêu thụ 150–250 MB — gấp 50 lần. Trong khi đó, thời gian hiển thị ban đầu cho LazyVStack là ~50 ms so với ~800 ms cho VStack trên cùng thiết bị.
Chọn VStack cho danh sách tĩnh hoặc ngắn (tối đa 10–15 phần tử) và LazyVStack cho bất kỳ danh sách động hoặc có khả năng dài nào. Apple khuyên dùng LazyVStack theo mặc định nếu bạn không chắc chắn về kích thước danh sách tối đa.
VStack vẫn là lựa chọn tốt nhất cho giao diện tĩnh: màn hình hồ sơ, biểu mẫu đăng nhập, thẻ sản phẩm — nơi số lượng phần tử đã biết và không vượt quá 10–15. VStack hoạt động nhanh hơn khi hiển thị ban đầu cho số lượng như vậy vì nó không lãng phí tài nguyên vào theo dõi hình học và tải lười. Ngoài ra, VStack hoạt động chính xác bên ngoài ScrollView (ví dụ: bên trong ZStack hoặc Group), trong khi LazyVStack không có ScrollView sẽ mất đi mục đích.
Stack lười là tối ưu cho các tình huống có số lượng phần tử lớn hoặc không thể đoán trước: nguồn cấp dữ liệu mạng xã hội, danh mục sản phẩm, danh sách trò chuyện, thư viện tệp phương tiện, nhật ký sự kiện, bảng quản trị với hàng nghìn bản ghi.
Trường hợp sử dụng cụ thể: danh sách tin nhắn trong ứng dụng nhắn tin (hàng chục nghìn tin nhắn), băng chuyền hình ảnh trong ứng dụng thư viện, nguồn cấp tin tức với tải vô hạn, danh sách đơn hàng trong cửa hàng trực tuyến. LazyHStack đặc biệt hữu ích cho băng chuyền ngang — ví dụ: Instagram Stories hoặc biểu ngữ quảng cáo.
Chống chỉ định: giao diện có hoạt ảnh xuất hiện phần tử (stack lười không hỗ trợ chuyển tiếp giữa các trạng thái xóa phần tử mà không có logic bổ sung), trường hợp tất cả phần tử nên hiển thị đồng thời (danh sách ngắn các hộp kiểm) và khi bạn cần kiểm soát chính xác việc tái sử dụng ô (trong trường hợp này List hoặc Table có thể phù hợp hơn).
Một ví dụ cơ bản hiển thị 1000 phần tử với mức tiêu thụ bộ nhớ tối thiểu. Các yếu tố chính: ScrollView làm container cuộn, LazyVStack để tải lười, ForEach với định danh để lặp dữ liệu.
import SwiftUI
struct LazyListExample: View {
let items = Array(0..<1000)
var body: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items, id: \.self) { index in
Text("Mục #\(index)")
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
}
}
.padding()
}
}
}
Mã tạo ScrollView chứa LazyVStack với khoảng cách 8pt giữa các phần tử. ForEach lặp qua mảng items và tạo Text cho mỗi chỉ mục. Nhờ tải lười, trong 1000 phần tử chỉ có 10–12 phần tử hiển thị trong bộ nhớ cùng lúc.
Ví dụ này minh họa việc nhóm phần tử theo phần với tiêu đề được ghim, tương tự danh bạ iOS. Section xác định tiêu đề và nội dung, pinnedViews: .sectionHeaders ghim tiêu đề ở đầu màn hình khi cuộn.
import SwiftUI
struct SectionedList: View {
let cities = ["Moscow", "London", "Tokyo", "New York", "Paris"]
let countries = ["Nga", "Vương quốc Anh", "Nhật Bản", "Mỹ", "Pháp"]
var body: some View {
ScrollView {
LazyVStack(pinnedViews: .sectionHeaders) {
Section(header: Text("Thành phố").font(.title).bold()) {
ForEach(cities, id: \.self) { city in
Text(city).padding(8)
}
}
Section(header: Text("Quốc gia").font(.title).bold()) {
ForEach(countries, id: \.self) { country in
Text(country).padding(8)
}
}
}
}
}
}
Tiêu đề được ghim (.sectionHeaders) hoạt động như section headers của UITableView: khi cuộn một phần, tiêu đề "dính" vào cạnh trên màn hình cho đến khi toàn bộ phần biến mất, sau đó được thay thế bằng tiêu đề của phần tiếp theo. pinnedViews có thể kết hợp: .sectionHeaders và .sectionFooters đồng thời.
LazyHStack được sử dụng để cuộn ngang — băng chuyền hình ảnh, danh sách danh mục ngang. Tham số alignment: .top căn chỉnh các phần tử theo cạnh trên.
import SwiftUI
struct HorizontalCarousel: View {
let colors: [Color] = [.red, .blue, .green, .orange, .purple, .pink]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 16, alignment: .top) {
ForEach(0..<100, id: \.self) { index in
RoundedRectangle(cornerRadius: 12)
.fill(colors[index % colors.count])
.frame(width: 150, height: 200)
.overlay(Text("\(index + 1)").foregroundColor(.white).bold())
}
}
.padding(.horizontal)
}
.frame(height: 220)
}
}
Mã tạo ScrollView ngang với LazyHStack. Trong 100 hình chữ nhật, chỉ 2–3 được hiển thị đồng thời (tùy thuộc vào chiều rộng màn hình và kích thước phần tử). Khi cuộn sang trái, các phần tử mới được tải lười. Chiều cao container cố định (220pt) để tránh chiều cao vô hạn trong cuộn ngang.
PinnedScrollableViews là tùy chọn cấu hình cho LazyVStack và LazyHStack kiểm soát việc ghim tiêu đề và chân phần khi cuộn. Hai giá trị được hỗ trợ: sectionHeaders (tiêu đề dính vào đầu container) và sectionFooters (chân dính vào cuối).
Cơ chế view được ghim chỉ hoạt động bên trong container Section lồng trong LazyVStack. Mỗi Section có tiêu đề và/hoặc chân tự động có hành vi dính. SwiftUI theo dõi vị trí của mỗi phần so với ranh giới ScrollView và chuyển đổi khả năng hiển thị của phần tử được ghim khi chuyển tiếp giữa các phần.
Quan trọng: pinnedViews làm tăng độ phức tạp tính toán bố cục vì SwiftUI phải liên tục tính toán lại tiêu đề nào đang được ghim. Chỉ sử dụng pinnedViews khi chức năng thực sự cần thiết — đối với danh sách đơn giản không có phần, tốt hơn nên bỏ qua tham số này. Apple trong tài liệu của mình (Human Interface Guidelines, 2024) khuyên dùng tiêu đề được ghim cho chỉ mục chữ cái và nhóm theo ngày.
Sử dụng đúng định danh là yếu tố hiệu suất quan trọng nhất cho LazyVStack. Mỗi phần tử trong ForEach phải có id duy nhất và ổn định. Sử dụng \.self với kiểu nguyên thủy (Int, String) có thể chấp nhận được, nhưng đối với mô hình dữ liệu, hãy luôn triển khai giao thức Identifiable. Id không ổn định (ví dụ: UUID được tạo mỗi lần) khiến SwiftUI tạo lại tất cả view sau mỗi lần cập nhật.
Tránh tính toán nặng bên trong body của mỗi phần tử stack. Nếu phần tử chứa bố cục phức tạp hoặc xử lý dữ liệu — hãy tách logic vào cấu trúc view riêng với tải lười của nó. Sử dụng EquatableView để ngăn vẽ lại không cần thiết khi dữ liệu phần tử không thay đổi.
Đối với hình ảnh bên trong LazyVStack, luôn sử dụng tải bất đồng bộ (AsyncImage) hoặc bộ nhớ đệm qua Kingfisher/Nuke. Mỗi phần tử không nên tải hình ảnh đồng bộ khi xuất hiện trên màn hình — điều này sẽ gây giật khi cuộn. Theo WWDC Session 10031, kích thước bộ đệm tải trước tối ưu là 3–5 màn hình phía trước và phía sau vị trí hiện tại.
Đo hiệu suất bằng Xcode Instruments với hồ sơ SwiftUI. Chú ý đến các chỉ số: body evaluations, allocations và tốc độ khung hình (FPS). Giá trị mục tiêu: FPS > 55 khi cuộn, thời gian hiển thị mỗi phần tử < 1 ms.
Câu hỏi thường gặp
List cung cấp các khả năng tích hợp: chỉnh sửa bằng vuốt (swipeActions), xóa qua .onDelete, sắp xếp lại qua .onMove, kiểu nhóm .insetGrouped. LazyVStack là công cụ cấp thấp hơn không có hỗ trợ tích hợp cho cử chỉ chỉnh sửa. List sử dụng LazyVStack bên trong nhưng thêm kiểu bảng iOS gốc. Nếu bạn cần thiết kế ô tùy chỉnh và không cần chỉnh sửa tích hợp — hãy chọn LazyVStack. Nếu bạn cần swipeActions, .onDelete và làm việc với @FetchRequest — hãy sử dụng List.
Stack lười sử dụng tải trước — SwiftUI tạo phần tử với bộ đệm trước nhỏ (prefetch buffer) để đảm bảo cuộn mượt. Kích thước bộ đệm tự động điều chỉnh theo tốc độ cuộn và hiệu suất thiết bị. Theo dữ liệu hồ sơ của Apple, bộ đệm tải trước thường là 1–3 màn hình theo hướng cuộn. Nếu bạn thấy quá nhiều phần tử vô hình được tạo, hãy kiểm tra xem bạn có định danh được tạo mỗi lần hoặc tính toán nặng trong trình khởi tạo view không.
Có, nhưng có giới hạn. Lồng LazyVStack bên trong VStack là vô nghĩa — VStack bên ngoài sẽ tạo tất cả phần tử của LazyVStack bên trong ngay lập tức, hủy bỏ tải lười. Lồng VStack bên trong LazyVStack được chấp nhận và không phá vỡ cơ chế lười. Lồng LazyVStack bên trong LazyVStack khác được chấp nhận cho các phần lồng nhau, nhưng hãy theo dõi hiệu suất: mỗi cấp thêm chi phí theo dõi hình học.
SwiftUI không cung cấp dấu phân cách tích hợp cho LazyVStack. Thêm chúng theo cách thủ công: đặt Divider() sau mỗi phần tử trong ForEach hoặc sử dụng bổ cục .overlay(Divider(), alignment: .bottom) trên mỗi phần tử. Đối với dấu phân cách tùy chỉnh, vẽ Rectangle().frame(height: 1).foregroundColor(.gray.opacity(0.3)).
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm