Chuyển ObservableObject sang @Observable, và những lần vẽ lại biến mất
Tôi cứ tưởng @Observable chỉ là một cú dọn dẹp cú pháp. Xóa @Published, xóa phần tuân thủ
protocol, xong. Nó đúng là như vậy, và nó cũng là một cơ chế quan sát khác, và sự khác biệt ấy hiện
ra thành một thay đổi hiệu năng đo được trên một màn hình mà tôi đã bỏ cuộc từ lâu.
Phần máy móc
// trước
final class Library: ObservableObject {
@Published var books: [Book] = []
@Published var searchText = ""
@Published var isSyncing = false
}
// sau
@Observable
final class Library {
var books: [Book] = []
var searchText = ""
var isSyncing = false
}
Rồi đến các property wrapper ở mọi chỗ dùng:
| Trước | Sau |
|---|---|
@StateObject var model = Model() |
@State var model = Model() |
@ObservedObject var model: Model |
let model: Model |
@EnvironmentObject var model: Model |
@Environment(Model.self) var model |
.environmentObject(model) |
.environment(model) |
@ObservedObject + $model.name |
@Bindable var model: Model |
Dòng đáng dừng lại là dòng thứ hai. Một đối tượng @Observable mà một view chỉ đọc thì không cần
property wrapper nào cả — một let thuần là đủ. Điều đó khiến tôi thấy sai sai suốt một tuần và nó
đúng: việc theo dõi được thiết lập bởi hành động đọc thuộc tính bên trong body, không phải bởi cái
wrapper.
Phần không máy móc
@Observable theo dõi các lần đọc. ObservableObject thông báo các lần ghi. Khác biệt đó nghĩa là
vị trí các lần đọc của bạn giờ trở thành một quyết định hiệu năng, và không có gì trong cuộc chuyển
đổi đánh dấu điều đó cho bạn.
Đây là hình dạng đã cắn tôi:
struct LibraryScreen: View {
let library: Library
var body: some View {
VStack {
Text("\(library.books.count) cuốn sách") // một lần đọc, ở view cha
SearchField(library: library)
BookList(library: library)
}
}
}
LibraryScreen đọc books, nên nó được tính lại mỗi khi mảng đó đổi — và cả cây con của nó đi
theo. Dưới thời ObservableObject thì chuyện này vốn đã xảy ra, nên chẳng có gì tệ đi. Nhưng toàn bộ
lợi ích của @Observable chỉ nằm cách một lần tái cấu trúc:
struct BookCount: View {
let library: Library
var body: some View { Text("\(library.books.count) cuốn sách") }
}
Giờ chỉ BookCount được tính lại. LibraryScreen chẳng đọc gì và không bao giờ chạy lại.
Mẹo
Mục trong danh sách kiểm tra chuyển đổi mà chẳng ai viết ra: sau khi chuyển xong, hãy nhìn mọi
view nhận vào một mô hình rồi đọc thẳng một thuộc tính trong body ở tầng cha. Mỗi chỗ như vậy
là một cây con đang vẽ lại vì một thay đổi mà nó không quan tâm, và tách ra một view nhỏ là chữa
được.
Nó thật sự đem lại gì
Màn hình tôi quan tâm là một danh sách tài liệu với một ô tìm kiếm và một chỉ báo đồng bộ. Dưới thời
ObservableObject, gõ một ký tự vào ô tìm kiếm sẽ tính lại mọi dòng — vì searchText là
@Published, và objectWillChange không phân biệt gì cả.
Với @Observable và các lần đọc đã được đẩy xuống, gõ chữ chỉ tính lại ô tìm kiếm và không gì khác.
Dùng Self._printChanges() để đếm: 47 lần tính body trên mỗi phím gõ trước đó, 2 lần sau đó.
Đó không phải một tối ưu vi mô. Đó là khác biệt giữa một ô tìm kiếm cảm giác giật trên một chiếc điện thoại đời cũ và một ô không giật, và trước đó tôi đã “chữa” nó bằng cách thêm một debounce — thứ làm độ giật thưa đi chứ không mất đi.
Ba thứ đã bẫy tôi
@Environment(Model.self) sập nếu không có gì tiêm nó vào. @EnvironmentObject cũng vậy, nhưng
cái mới sập ngay tại view đọc nó với một thông điệp hơi khác. Mọi preview của mọi view nằm dưới điểm
tiêm đều cần đối tượng đó, và không có phép kiểm tra nào lúc biên dịch.
Thuộc tính tính toán dựa trên chỗ chứa không được quan sát thì vô hình. Nếu một thuộc tính tính
toán đọc một private var mà macro không viết lại, chẳng có gì báo cáo lần đọc đó và view không bao
giờ cập nhật.
Mảng các class cần phần tử cũng được quan sát. library.books theo dõi cái mảng — chèn, xóa,
đảo thứ tự. Nó không theo dõi một thuộc tính bên trong một Book. Nếu Book là một class, hãy đánh
dấu nó @Observable rồi để mỗi dòng tự đọc nó; đó cũng là điều bạn muốn, vì khi ấy chỉ đúng dòng đó
vẽ lại.
Thứ tự chuyển đổi đã hiệu quả
Không làm tất cả cùng lúc. Mỗi lần một file, từ lá đi vào:
- Các kiểu mô hình không có ai phụ thuộc, làm trước. Chuyển, sửa những chỗ gọi mà trình biên dịch chỉ ra, rồi chạy.
- Rồi đến các view dùng chúng, tách view con ra ở mọi chỗ mà view cha đang đọc một thuộc tính.
- Phần tiêm environment để sau cùng, vì đó là thay đổi không có lưới an toàn lúc biên dịch và nó dễ kiểm chứng hơn khi mọi thứ bên dưới đã chạy được.
Hai hệ thống sống chung tốt, nên một ứng dụng chuyển đổi dở dang vẫn build và chạy được. Điều đó quan trọng hơn tôi tưởng — nó nghĩa là công việc có thể đi vào qua một tuần các commit nhỏ thay vì một thay đổi khổng lồ không ai review nổi.
Có đáng làm không
Nếu ứng dụng chạy trên hệ điều hành đời mới: có, và sớm hơn bạn nghĩ. ObservableObject chưa bị khai
tử nhưng rõ ràng là thế hệ trước, và cuộc chuyển đổi thật sự máy móc, trừ đúng một chỗ cần phán đoán
ở trên.
Nếu bạn còn hỗ trợ hệ điều hành cũ, @Observable cần iOS 17, và hai thứ không trộn được trong cùng
một kiểu. Đó là một rào cản thật và không có bản vá nào đáng dùng — câu trả lời là chờ, và trong lúc
chờ thì tránh viết thêm các kiểu ObservableObject mới ở đâu có thể tránh.