Danh tính và vòng đời
SwiftUI quyết định hai view là cùng một view như thế nào
Struct của bạn được tạo lại liên tục còn bộ nhớ của SwiftUI thì không. Giữa hai thứ đó là đúng một câu hỏi, được hỏi ở mỗi lần cập nhật: đây có phải view tôi đã thấy lần trước không, hay là một view khác?
Câu trả lời quyết định mọi thứ có vẻ ma thuật hoặc có vẻ hỏng. Cùng một view: @State sống sót,
thay đổi chạy animation, ô nhập chữ giữ nguyên con trỏ. View khác: trạng thái bị vứt đi rồi dựng lại
từ giá trị ban đầu, view cũ chuyển cảnh đi ra và view mới đi vào, con trỏ biến mất.
SwiftUI có hai cách trả lời câu hỏi đó.
Danh tính theo cấu trúc
Mặc định, danh tính là vị trí trong cây view. Không phải kiểu, không phải nội dung — mà là chỗ đứng.
VStack {
Text("Tiêu đề")
ProfileCard(user: user)
}
ProfileCard là “đứa con thứ hai của cái VStack kia”. Lần cập nhật sau, thứ gì xuất hiện ở ô đó
đều được coi là cùng một view, và bộ nhớ của nó được dùng lại. Việc user đổi không tạo ra danh tính
mới — vẫn là cái thẻ ấy đang hiện dữ liệu khác, đúng như bạn muốn.
Cái bẫy là if tạo ra hai ô khác nhau:
if isEditing {
NameField(name: $name)
} else {
NameField(name: $name)
}
Hai cái đó trông giống hệt nhau và không phải là một. Builder đã tạo ra _ConditionalContent, nhánh
đúng và nhánh sai là hai vị trí riêng biệt, và lật isEditing sẽ hủy một NameField rồi tạo ra cái
khác. Mọi @State bên trong nó bị đặt lại, và bàn phím đóng xuống.
Cách chữa là có một view với đầu vào thay đổi, thay vì hai view:
NameField(name: $name)
.disabled(!isEditing)
Cảnh báo
Đây là nguyên nhân phổ biến nhất của “@State của tôi cứ bị đặt lại” và “bàn phím đóng xuống
khi tôi đang gõ”. Trước khi với tới thứ gì cao siêu, hãy tìm một cái if có hai nhánh vẽ ra cùng
một thứ ở hai cấu hình khác nhau.
Danh tính tường minh
Cách còn lại là nói thẳng ra, bằng id. Điều này là bắt buộc ở đâu SwiftUI không tự suy ra vị trí
được — chủ yếu là trong các tập hợp:
List(orders) { order in
OrderRow(order: order)
}
List cần Order tuân thủ Identifiable vì các dòng bị chèn vào, xóa đi và đảo thứ tự, mà vị trí
thì vô nghĩa dưới những thao tác đó. Dòng của đơn hàng số 17 phải vẫn là dòng của đơn hàng số 17 kể
cả khi ba dòng phía trên nó biến mất.
Đặt id sai thì triệu chứng rất đáng nhớ. Dùng chỉ số mảng rồi xóa dòng đầu tiên thì mọi dòng bên dưới
trông như đổi nội dung — kèm một animation chữ của từng dòng mờ chồng lên nhau, vì SwiftUI thật sự
tin rằng dòng 0 đã trở thành một đơn hàng khác. Dùng một UUID() sinh ra ngay trong body thì mọi
dòng đều là mới ở mọi lần cập nhật, nên danh sách dựng lại liên tục và cuộn thì giật.
struct Order: Identifiable {
let id: UUID // lưu sẵn, ổn định, đến từ mô hình
let title: String
}
Quy tắc: một id phải đến từ dữ liệu và sống đúng bằng thứ nó gọi tên.
.id() như một cú đặt lại có chủ đích
.id() đặt danh tính bằng tay, và công dụng chính của nó không phải danh sách — mà là ép một view
bị thay thế:
ArticleView(article: article)
.id(article.id)
Không có .id, đi từ bài này sang bài khác sẽ dùng lại đúng bộ nhớ của ArticleView đó, nên @State
của nó — vị trí cuộn, công tắc “hiện bản dịch” — mang theo từ bài trước sang. Có nó, mỗi bài được
một view mới tinh, và mọi thứ đặt lại.
Đó là một công cụ chính đáng, và cũng là một công cụ thô. Nó vứt hết bộ nhớ dưới view đó rồi dựng lại cả cây con, nên dùng trên thứ gì lớn hoặc áp vào mọi lần cập nhật thì chính nó thành vấn đề hiệu năng. Hãy với tới nó khi đặt lại đúng là điều bạn muốn nói.
Vòng đời, và vì sao onAppear không phải viewDidAppear
Vòng đời của một view là vòng đời của danh tính nó, không phải của cái struct. Struct được tạo ra rồi hủy đi liên tục và không có phần nào trong đó quan sát được.
onAppear chạy khi một view mang danh tính đó gia nhập cây; onDisappear chạy khi nó rời đi. Cả hai
đều không gắn với chuyện nhìn thấy được theo cái nghĩa mà tên gọi bên UIKit gợi ra — một dòng cuộn
ra khỏi màn hình trong List có thể biến mất mà cũng có thể không, và một view bên trong TabView
có thể xuất hiện từ rất lâu trước khi tab của nó được chọn.
Với công việc nên đi theo đời của view, .task gần như luôn là công cụ tốt hơn: nó khởi động cùng
view và Task của nó bị hủy tự động khi danh tính mất đi.
.task(id: article.id) {
await viewModel.load(article.id)
}
Cái id: ở đó khởi động lại tác vụ khi bài viết đổi, đúng hành vi bạn muốn và là thứ viết tay cho
đúng thì rất phiền.