Key-Value Observing

Key-Value Observing에 대해서 알아보려고 한다. 애플의 Key-Value Observing Programming Guide를 토대로 정리하였다.

Key-Value Observing이란?

Key-Value Observing, 줄여서 KVO 패턴은 프로퍼티의 변화를 감지하는 기법이다. 애플의 기본적인 디자인 패턴인 MVC를 보면 Model은 독립적으로 따로 떨어져 있는데 Model에 어떠한 변화가 일어날 경우 Controller에서 이를 감지하고 적절한 처리를 거쳐 View를 갱신해야한다. 이러한 상황에서 유용하게 쓰이는 것이 Key-Value Observing이다.

먼저 KVO 패턴의 기반이 되는 Key-Value Coding에 대해 알아보자.

Key-Value Coding

Key-Value Coding은 NSKeyValueCoding 프로토콜을 따르는 객체에 간접적으로 접근할 수 있게 하는 기법이다. 이러한 객체는 문자열을 파라미터로 손쉽게 어디서든 접근이 가능한 특징을 보인다. 일반적으로 NSObject를 직접적, 간접적으로 상속하는 객체들은 자동적으로 NSKeyValueCoding 프로토콜을 따르게 된다.

Swift에서 KVC 패턴은 Objective-C보다 많이 쓰이지는 않는다. NSKeyValueCoding의 많은 메서드들이 Swift와 맞지 않는 경우가 있고 또한 Swift에서 더 효과적인 기법들이 존재하기 때문이다. 하지만 AVFoudation 같은 프레임워크를 다룰 때에 효과적인 기법이다.

Key-Value Coding이 기반이 되어 파생된 기술들은 다음과 같다.

  • Key-Value observing
  • Cocoa bindings
  • Core Data
  • AppleScript

NSKeyValueCoding

NSKeyValueCoding 프로토콜을 따르게 하려면 대표적으로 두 메서드를 구현해주어야 한다.

  • value(forKey:): 키 값에 해당하는 값을 가져오는 getter 메서드
  • setValue(_:forKey:): 키 값에 해당하는 값을 변경하는 setter 메서드

이 두 메서드로 인해 문자열 타입의 키 값을 알면 손쉽게 해당하는 객체에 접근을 하고 쓰기가 가능하다.

예를 들어, BankAccount라는 객체가 있다고 가정하자.

1
2
3
4
5
6
7
@interface BankAccount : NSObject

@property (nonatomic) NSNumber* currentBalance;
@property (nonatomic) Person* owner;
@property (nonatomic) NSArray< Transaction* >* transactions;

@end

그러면 다음과 같이 myAccount 안의 프로퍼티인 currentBalance를 받고 읽을 수 있다.

1
2
NSNumber *myCurrentBalance = [_myAccount valueForKey:@"currentBalance"];
[_myAccount setValue:@(100.0) forKey:@"currentBalance"];

또 다음과 같이 Swift에서 iOS 시스템의 설정을 바로 바꾸고 싶을 때 쓰이기도 한다.

1
2
let orientationValue = UIDeviceOrientation.landscapeRight.rawValue
UIDevice.current.setValue(orientationValue, forKey: "orientation")

Key-Value Observing의 과정

원하는 객체를 KVO를 준수하는 프로퍼티로 만들어 감지하기 위해 다음과 같은 절차를 거쳐야 한다.

1. Resgistering as an Observer

addObserver(_:forKeyPath:options:context:) 메서드로 감지하려는 객체에 옵저버를 등록하며 전달받는 인자는 다음과 같은 역할을 한다.

  • observer: KVO 이벤트를 감지하는 객체를 의미하며 이 객체는 observeValue(forKeyPath:of:change:context:)를 구현해 이벤트를 받는다.
  • keyPath: 감지하려는 프로퍼티의 keyPath를 String 타입으로 전달한다.
  • options: NSKeyValueObservingOptionsenum 값의 배열이며 new, old, initial, prior의 네 가지로 이루어져 있다. 이 값들을 조합해 언제 이벤트 알림을 보낼지 결정한다.
  • context: 외부 변수의 포인터 값을 전달하며 알림이 온 정확한 위치를 알려주는 역할을 한다. nil 값을 전달하면 오로지 keyPath로 알림이 온 위치를 식별한다. 그러나 상위 클래스와 하위 클래스가 같은 프로퍼티를 감지할 경우 문제가 발생할 수 있다.

addObserver(_:forKeyPath:options:context:) 메서드는 옵저빙 객체, 옵저빙 당하는 객체, 그리고 컨텍스트의 레퍼런스를 Strong으로 잡아두지 않는다. 이것은 개발자가 직접 잡아두어야 한다.

예제 코드

1
2
3
4
5
6
private var playerItemContext = 0
...
playerItem.addObserver(self,
forKeyPath: #keyPath(AVPlayerItem.status),
options: [.old, .new],
context: &playerItemContext)

2. Receiving Notification of a Change

observeValueForKeyPath:ofObject:change:context: 메서드로 객체의 변화가 일어났을 때 옵저버의 메시지를 받는다. 각 인자는 다음과 같은 역할을 한다.

  • keyPath: 바뀐 프로퍼티의 위치를 나타내며 여러 프로퍼티를 감지할 경우에 이 인자 값으로 구분할 수 있다.
  • object: keyPath가 어떤 객체에 있는지 알려준다.
  • change: [NSKeyValueChangeKey : Any]? 타입의 Dictionary로 전달되며 옵저버 등록 시 options에 넣은 값들이 키값으로 하여 그것에 해당하는 값을 알려준다.
  • context: 위에서와 마찬가지로 keyPath와 함께 사용시 좀 더 안전한 감지와 처리가 가능하다.

예제 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard context == &playerItemContext else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)

return
}

if keyPath == #keyPath(AVPlayerItem.status) {
if let statusNumber = change?[.newKey] as? NSNumber {
guard let status = AVPlayerItemStatus(rawValue: statusNumber.intValue) else { return }
playerItemStatus = status
} else {
playerItemStatus = .unknown
}

switch playerItemStatus {
case .readyToPlay:
guard isFirstPlaying else { return }

delegate?.isPlayedFirst()
isFirstPlaying = false
case .failed:
assertionFailure("PlayerItem failed.")
case .unknown:
assertionFailure("PlayerItem is not yet ready.")
}
}
}

3. Removing an Object as an Observer

removeObserver:forKeyPath:context: 메서드를 통해 프로퍼티에 달아둔 옵저버를 제거한다. 옵저버를 해제하는 것에 대해 몇 가지 유의해야 할 점이 있다.

  • 아직 등록되지 않은 옵저버를 해제하는 것은 NSRangeException을 야기한다. 하나의 addObserver(_:forKeyPath:options:context:)에 하나의 removeObserver:forKeyPath:context:를 호출해야 하며 앱 내에서 실행이 가능하지 않은 경우 try-catch 구문 안에서 해제해야 한다.
  • 옵저버는 자동적으로 메모리에서 해제되지 않는다. 감지당하는 객체는 계속해서 감지하는 객체의 상태를 신경쓰지 않고 메시지를 보내는데 만약 감지하는 객체가 메모리에서 해제되었을 경우 메모리 접근 에러가 일어날 것이다. 그러므로 메모리에서 해제해주는 것이 중요하다.
  • 프로토콜은 어떤 객체가 감지하는지 또는 감지당하는지에 대해 물어보지 않는다. 일반적으로 감지하는 객체의 초기화 시(init / viewDidLoad) 옵저버를 등록하고 메모리 해제 시(dealloc) 같이 해제를 한다.

Reference