Key-Value Observing (KVO) là một cơ chế quan trọng trong Objective-C, cung cấp khả năng giám sát sự thay đổi giá trị của các thuộc tính đối tượng. Đây là một triển khai của mô hình Observer (Quan sát viên), cho phép các đối tượng đăng ký để nhận thông báo khi một thuộc tính cụ thể của đối tượng khác thay đổi. Khi bạn đăng ký một đối tượng làm trình quan sát cho một thuộc tính, hệ thống sẽ thực hiện một số thao tác "ma thuật" ở runtime: nó tạo ra một lớp con trung gian, ví dụ như NSKVONotifying_ClassName, và thay đổi con trỏ isa của đối tượng được quan sát để trỏ đến lớp con mới này. Lớp con động này sẽ ghi đè (override) phương thức setter của thuộc tính đang được giám sát để chèn logic thông báo.
Để minh họa, hãy xem xét một đối tượng MyItem có thuộc tính count. Khi chúng ta thêm một trình quan sát KVO cho thuộc tính count của một thể hiện firstItem, mọi thay đổi trên firstItem.count sẽ kích hoạt thông báo KVO. Ngược lại, một thể hiện secondItem không được quan sát sẽ hoạt động bình thường mà không có các thông báo này.
// Giả định có một lớp MyItem với thuộc tính `count`
@interface MyItem : NSObject
@property (nonatomic, assign) int count;
@end
@implementation MyItem
// Phương thức setter cơ bản, có thể ghi đè hoặc không.
- (void)setCount:(int)value {
_count = value;
NSLog(@"MyItem - setCount: được gọi, giá trị mới: %d", value);
}
@end
// Trong một ViewController hoặc lớp bất kỳ cần quan sát
- (void)viewDidLoad {
[super viewDidLoad];
MyItem *firstItem = [[MyItem alloc] init];
firstItem.count = 10;
MyItem *secondItem = [[MyItem alloc] init];
secondItem.count = 20;
// Đăng ký quan sát thuộc tính 'count' của firstItem
NSKeyValueObservingOptions opts = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[firstItem addObserver:self forKeyPath:@"count" options:opts context:nil];
NSLog(@"\n--- Thực hiện thay đổi giá trị ---");
NSLog(@"Thay đổi giá trị firstItem.count:");
firstItem.count = 15; // Sẽ kích hoạt KVO
NSLog(@"Thay đổi giá trị secondItem.count:");
secondItem.count = 25; // Sẽ không kích hoạt KVO
// Luôn loại bỏ observer khi không còn cần thiết, ví dụ trong dealloc
// [firstItem removeObserver:self forKeyPath:@"count"];
}
// Phương thức quan sát bắt buộc để nhận thông báo KVO
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"count"] && [object isKindOfClass:[MyItem class]]) {
int oldValue = [change[NSKeyValueChangeOldKey] intValue];
int newValue = [change[NSKeyValueChangeNewKey] intValue];
NSLog(@"KVO Thông báo - Giá trị của '%@' thay đổi từ %d sang %d cho đối tượng: %@", keyPath, oldValue, newValue, object);
} else {
// Đảm bảo gọi super nếu bạn có thể nhận thông báo cho các đối tượng khác
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
Từ ví dụ trên, có thể thấy rằng khi firstItem.count thay đổi, phương thức observeValueForKeyPath của trình quan sát được gọi, trong khi với secondItem thì không. Điều này cho thấy firstItem đã trải qua một sự thay đổi ngầm nào đó ở runtime.
Kiểm tra sự thay đổi con trỏ ISA
Để xác minh sự thay đổi về lớp của đối tượng được quan sát, chúng ta có thể sử dụng hàm object_getClass từ thư viện runtime của Objective-C để kiểm tra lớp thực tế mà con trỏ isa của đối tượng đang trỏ tới, trước và sau khi thêm KVO.
#import <objc/runtime.h> // Cần import để sử dụng object_getClass
// ... (Trong viewDidLoad của ViewController)
NSLog(@"\n--- Kiểm tra lớp của đối tượng trước khi thêm KVO ---");
NSLog(@" firstItem thực tế là: %@, secondItem thực tế là: %@", object_getClass(firstItem), object_getClass(secondItem));
// Thêm KVO
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[firstItem addObserver:self forKeyPath:@"count" options:options context:nil];
NSLog(@"\n--- Kiểm tra lớp của đối tượng sau khi thêm KVO ---");
NSLog(@" firstItem thực tế là: %@, secondItem thực tế là: %@", object_getClass(firstItem), object_getClass(secondItem));
[firstItem removeObserver:self forKeyPath:@"count"]; // Dọn dẹp
Kết quả đầu ra sẽ tương tự như sau:
--- Kiểm tra lớp của đối tượng trước khi thêm KVO ---
firstItem thực tế là: MyItem, secondItem thực tế là: MyItem
--- Kiểm tra lớp của đối tượng sau khi thêm KVO ---
firstItem thực tế là: NSKVONotifying_MyItem, secondItem thực tế là: MyItem
Qua thí nghiệm này, chúng ta thấy rõ rằng sau khi thêm KVO, con trỏ isa của firstItem không còn trỏ trực tiếp đến lớp MyItem ban đầu nữa, mà chuyển sang trỏ đến một lớp mới được tạo ra động: NSKVONotifying_MyItem. Đối tượng secondItem không được quan sát thì con trỏ isa vẫn giữ nguyên. Sự thay đổi con trỏ isa này là chìa khóa cho cơ chế KVO.
Cơ chế gửi thông báo và chuỗi kế thừa
Khi con trỏ isa của firstItem được chuyển hướng sang NSKVONotifying_MyItem, quá trình gửi thông điệp (method dispatch) cho firstItem cũng thay đổi. Trước khi thêm KVO, khi gọi firstItem.count = 15;, hệ thống sẽ đi theo con trỏ isa của firstItem để tìm lớp MyItem và thực thi phương thức setCount: tại đó.
Sau khi thêm KVO, con trỏ isa của firstItem trỏ đến NSKVONotifying_MyItem. Lớp này là một lớp con của MyItem. Khi firstItem.count = 15; được gọi, hệ thống sẽ tìm và thực thi phương thức setCount: trong NSKVONotifying_MyItem. Phương thức setCount: được ghi đè trong lớp con này chứa logic để kích hoạt KVO.
Thực tế, bên trong phương thức setCount: được ghi đè của NSKVONotifying_MyItem, Apple đã chèn một chuỗi các lệnh gọi. Chuỗi này thường bao gồm:
- Gọi
willChangeValueForKey:(thông báo rằng giá trị sắp thay đổi). - Gọi phương thức setter gốc của lớp cha (
MyItem) để thực sự thay đổi giá trị thuộc tính. - Gọi
didChangeValueForKey:(thông báo rằng giá trị đã thay đổi).
Chính phương thức didChangeValueForKey: là nơi hệ thống kích hoạt phương thức observeValueForKeyPath:ofObject:change:context: của tất cả các trình quan sát đã đăng ký.
Cấu trúc nội bộ của NSKVONotifying_ClassName
Là một lớp con, NSKVONotifying_MyItem có con trỏ superclass trỏ đến MyItem. Ngoài phương thức setter được ghi đè (setCount:), lớp này còn có một số phương thức khác được thêm vào bởi runtime. Chúng ta có thể dùng runtime để liệt kê các phương thức này:
#import <objc/runtime.h>
// ... (Trong viewDidLoad của ViewController, sau khi đã thêm KVO cho firstItem)
NSLog(@"\n--- Liệt kê các phương thức của lớp ---");
[self logClassMethods:object_getClass(firstItem)]; // NSKVONotifying_MyItem
[self logClassMethods:object_getClass(secondItem)]; // MyItem
[firstItem removeObserver:self forKeyPath:@"count"]; // Dọn dẹp
}
// Hàm trợ giúp để liệt kê các phương thức của một lớp
- (void)logClassMethods:(Class)targetClass {
unsigned int methodCount;
Method *methodList = class_copyMethodList(targetClass, &methodCount);
NSMutableString *discoveredMethods = [NSMutableString stringWithFormat:@"Các phương thức của lớp %@: ", targetClass];
for (unsigned int i = 0; i < methodCount; i++) {
SEL selector = method_getName(methodList[i]);
NSString *methodName = NSStringFromSelector(selector);
[discoveredMethods appendFormat:@"%@ ", methodName];
}
NSLog(@"%@", discoveredMethods);
free(methodList); // Giải phóng bộ nhớ đã cấp phát
}
Kết quả đầu ra sẽ tương tự như sau:
--- Liệt kê các phương thức của lớp ---
Các phương thức của lớp NSKVONotifying_MyItem: setCount: class dealloc _isKVOA
Các phương thức của lớp MyItem: setCount: count
Từ đây, chúng ta có thể thấy NSKVONotifying_MyItem không chỉ ghi đè setCount: mà còn thêm các phương thức như class, dealloc, và _isKVOA. Cấu trúc tổng thể có thể hình dung như sau:
Ghi đè phương thức class
Một điểm đáng chú ý là NSKVONotifying_MyItem ghi đè phương thức class. Điều này giải thích tại sao khi bạn gọi [firstItem class], nó vẫn trả về MyItem thay vì NSKVONotifying_MyItem. Apple đã làm điều này để che giấu chi tiết triển khai KVO khỏi nhà phát triển, giữ cho hành vi của đối tượng nhất quán từ góc độ API.
// Với firstItem đã được KVO
NSLog(@"\n--- Kiểm tra phương thức `class` trên đối tượng được KVO ---");
NSLog(@" [firstItem class]: %@, object_getClass(firstItem): %@", [firstItem class], object_getClass(firstItem));
Kết quả:
--- Kiểm tra phương thức `class` trên đối tượng được KVO ---
[firstItem class]: MyItem, object_getClass(firstItem): NSKVONotifying_MyItem
Điều này cho thấy [firstItem class] trả về lớp gốc MyItem, trong khi object_getClass(firstItem) trả về lớp runtime thực tế NSKVONotifying_MyItem, xác nhận rằng phương thức class đã được ghi đè trong lớp con động.
Tổng kết về KVO
Sau khi tìm hiểu sâu, chúng ta có thể trả lời các câu hỏi chính về KVO:
- Bản chất của KVO là gì?
Khi một trình quan sát được thêm vào một đối tượng cho một thuộc tính cụ thể, Objective-C runtime sẽ tạo ra một lớp con động (ví dụ:NSKVONotifying_MyClass) của lớp đối tượng ban đầu. Con trỏisacủa đối tượng được quan sát sau đó được thay đổi để trỏ đến lớp con mới này. Lớp con này ghi đè các phương thức setter của các thuộc tính được quan sát. Trong phương thức setter được ghi đè, nó sẽ tuần tự gọiwillChangeValueForKey:, sau đó gọi setter gốc của lớp cha, và cuối cùng làdidChangeValueForKey:. Phương thứcdidChangeValueForKey:chính là nơi hệ thống gửi thông báo đến các trình quan sát thông qua phương thứcobserveValueForKeyPath:ofObject:change:context:. - Làm thế nào để kích hoạt KVO một cách thủ công?
Để kích hoạt KVO thủ công, bạn cần tự gọi các cặp phương thứcwillChangeValueForKey:vàdidChangeValueForKey:xung quanh đoạn mã thay đổi giá trị thuộc tính. - Việc thay đổi trực tiếp biến thành viên (ivar) có kích hoạt KVO không?
Không. KVO được kích hoạt thông qua việc gọi phương thức setter của thuộc tính. Khi bạn thay đổi trực tiếp biến thành viên (thường bằng cách truy cập_propertyName), bạn đã bỏ qua phương thức setter, và do đó, chuỗi các lệnh gọiwillChangeValueForKey:vàdidChangeValueForKey:sẽ không được thực hiện, dẫn đến không có thông báo KVO nào được gửi đi.