Trang chủ

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.