Tìm hiểu sâu về cơ chế hoạt động của Key-Value Observing (KVO) trong Objective-C

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 đó.

KVO监听age属性变化

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.

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:

  1. Gọi willChangeValueForKey: (thông báo rằng giá trị sắp thay đổi).
  2. 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.
  3. 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ý.

KVO调用流程

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:

NSKVONotifyin_Person的内存结构

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:

  1. 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ỏ isa củ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ọi willChangeValueForKey:, sau đó gọi setter gốc của lớp cha, và cuối cùng là didChangeValueForKey:. Phương thức didChangeValueForKey: 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ức observeValueForKeyPath:ofObject:change:context:.
  2. 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ức willChangeValueForKey: và didChangeValueForKey: xung quanh đoạn mã thay đổi giá trị thuộc tính.
  3. 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ọi willChangeValueForKey: 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.

Thẻ: KVO objective-c runtime Key-Value Observing ISA Swizzling

Đăng vào ngày 25 tháng 10 lúc 07:16