TIP-맥OS

macOS NSUndoManager 세련된 방법으로 실행취소(되돌리기) 구현

무한열정 2017. 5. 7. 14:40


RaiseMan_Undo.zip

macOS의 NSUndoManager는 매우 우아한 방법으로 실행취소(되돌리기 , Command+Z)를 구현하는 방법입니다.


* 맥OS 단축키

Undo : Command + Z (실행취소, 되돌리기)

Redo : Command + Shift + Z (재실행)


* 기본개념 알아보기

Cocoa는 Objective-C를 기반으로하며 그 패턴의 대부분은 Objective-C 메시지 전달을 이용합니다.

메시지를 보내면 메시지 전송 시스템은 해당 개체가 NSInvocation을 인수로 사용하는 forwardInvocation (_ :) 메서드를 구현했는지 확인합니다. 객체에 이 메서드가 있으면 보낸 메시지가 NSInvocation으로 묶여 처리 할 forwardInvocation (_ :)에 전달됩니다. 

곧 보게 되겠지만, NSUndoManager는 이것을 이용합니다.

즉, 메시지 처리로 실행취소를 구현하고 메시지는 NSInvocation 인스턴스를 사용한다고 하는데 실제로 육안으로 보이지는 않습니다.

그냥 그렇구나 이해만 하면 될듯 합니다.

윈도우즈 OS도 객체에 전달은 메시지 개념으로 된다고 알고 있습니다. 그리고 참고로 Java나 이런 현대적인 언어들은 모두 Reflection이란걸 사용하는데 이런것도 상식적으로 알아놓으면 좋을듯 합니다.


* 기본동작원리 이해하기

이번 UndoManager가 어려운건 사실이지만 생각보다는 복잡하지 않다는 느낌인데요.

최대한 간단하게 설명을 해보겠습니다.

1) [Add employee]버튼을 두번 누르면 Undo Stack에 2개의 액션이 쌓이는데요.

    이게 알고보면 삭제하는 메소드가 NSInvocation 인스턴스 안에 기록이 됩니다.

    아키텍쳐를 구현한 사람이 머리가 좋다고 생가되는게 Undo, Redo의 원리가 액션이 발생하면 그 액션에 반대되는 메소드를 Undo Stack에 쌓아놓는게 전부입니다.

    즉, 추가 액션이면 Undo에는 삭제 메소드를 추가하여 놓는 방식인거죠. 머리 좋네요. ㅋㅋ

2) 그러면 2개의 반대 액션이 Undo Stack에 쌓여 있는 상태인데

    이때 사용자가 되돌리기를 하려고 Command + Z를 실행하면 undoManager가 Undo Stack에서 삭제 메소를 꺼내어 실행을 시킵니다.

3) 근데 여기서 Redo가 될려면 당연히 자동으로는 않되고, Redo Stack에 개발자가 insert하는 메소드를 등록해야 합니다.

    여기서 되돌린다는 개념은 원래 값을 다시 추가해 놓는것이므로 insert하는 메소드를 구동하는 이유입니다.


* 액션이 일어나면 개발자는 항상 Undo,Redo를 준비해야 하는데

undo.prepare(withInvocationTarget:self)를 호출해서 필요한 메소드를 등록하면 됩니다.

Swift3가 되면서 AnyObject로 형변환 (Type Casting)을 해야 하니 주의가 필요하구요.

(undo.prepare(withInvocationTarget:self) as AnyObject).insertObject(employee, inEmployeesAtIndex: index)

이렇게 써야 하는데 형변환때문에 뭔가 무지 복잡하게 보이네요.


* 구동되는 주요한 흐름 알아보기

실제 코드상에서 어떻게 구동되고 코드가 흘러가는지 헷깔릴 수 있어 정리를 해보았습니다.

일단 Add Employee버튼을 누르는 경우 기존에는 arrayController에 직접 연결되어 있었는데

이번장에서는 addEmployee function을 추가하고 @IBAction을 통해 UI와 연결되어 있습니다.

NSUndoManager는 꽤나 어려운 장입니다.

원서에도 보면 책의 초기에 다루기에 꽤 무거운 주제라고 써있고, 실행 취소에 대해 생각할 때 머리가 허우적(?) 댄다고 표현(swim a bit ^^)하고 있습니다.

그래서 저도 이번장은 좀더 신경써서 정리할 필요가 있었습니다.


1) [Add Employee] 버튼을 누르면 addEmployee(_ sender: NSButton)를 호출하면서 시작이 됩니다.


2) arrayController에서 새로운 오브젝트(Employee)를 생성하고 add하는게 주요 기능입니다.

    // Create the object

    let employee = arrayController.newObject() as! Employee

    // Add it to the array controller's contentArray

    arrayController.addObject(employee)


3) 이 메소드는 NSArrayController가 Employee 객체를 삽입하거나 제거 할 때 자동으로 호출됩니다 (예 : Add Employee 및 Remove 단추가 add : 및 remove : 메시지를 보낼 때).이 시점에서 삭제 및 삽입을 실행 취소 할 수 있다고 되어 있고 셀의 수정모드일때는 실행 취소는 조금 까다롭다고 되어 있네요. ^^;;;

employees.append(employee) 구문에서 employees가 추가 되네요.

4) insertObject에서 employees가 추가 되면서 willSet , didSet이 차례대로 호출이 되게 됩니다.

    willSet에서는 stopObservingEmployee(employee: employee)를 수행하여 기존에 수행되던 옵저버를 중지시킵니다.


5) willSet수행후 갑이 설정이 되었으면 didSet이 수행됩니다.

 이때 startObservingEmployee(employee: employee)를 수행하여 변수에 대한 옵저버 설정을 새로 하게 됩니다.


6) 새로운 옵저버가 설정됩니다.

   변수에대한 옵저버는 변수가 변경되는지 자동으로 관찰하는 편리한 관찰자의 역할을 수행합니다.



* 오류 대응

1) print(">>>>> click addEmployee")를 사용하는경우 다음과 같은 오류발생

2017-05-07 15:37:24.007350+0900 RaiseMan[62714:1984122] [General] printOperationWithSettings:error: is a subclass responsibility but has not been overridden.

2017-05-07 15:37:24.013747+0900 RaiseMan[62714:1984122] [General] (

0   CoreFoundation                      0x00007fff9b7e562b __exceptionPreprocess + 171

1   libobjc.A.dylib                     0x00007fffb0b921da objc_exception_throw + 48

2   CoreFoundation                      0x00007fff9b862c55 +[NSException raise:format:] + 197

3   AppKit                              0x00007fff997901b3 -[NSDocument printOperationWithSettings:error:] + 131

4   AppKit                              0x00007fff9978f965 __93-[NSDocument printDocumentWithSettings:showPrintPanel:delegate:didPrintSelector:contextInfo:]_block_invoke_2 + 542

5   AppKit                              0x00007fff995bf4ca -[NSDocument _commitEditingThenContinue:] + 474

6   AppKit                              0x00007fff995bf2e7 -[NSDocument _commitEditingWithDelegate:didSomethingSelector:contextInfo:thenContinue:] + 92

7   AppKit                              0x00007fff9978f738 __93-[NSDocument printDocumentWithSettings:showPrintPanel:delegate:didPrintSelector:contextInfo:]_block_invoke + 342

8   AppKit                              0x00007fff9982e057 -[NSDocument(NSDocumentSerializationAPIs) _performActivity:] + 1858

9   AppKit                              0x00007fff9982f5d1 -[NSDocument(NSDocumentSerializationAPIs) performActivityWithSynchronousWaiting:usingBlock:cancellationHandler:] + 447

10  AppKit                              0x00007fff9978f5dc -[NSDocument printDocumentWithSettings:showPrintPanel:delegate:didPrintSelector:contextInfo:] + 104

11  AppKit                              0x00007fff9978f375 -[NSDocument printDocument:] + 73

12  RaiseMan                            0x000000010000638f _TFC8RaiseMan8Document11addEmployeefCSo8NSButtonT_ + 143

13  RaiseMan                            0x000000010000796a _TToFC8RaiseMan8Document11addEmployeefCSo8NSButtonT_ + 58

14  libsystem_trace.dylib               0x00007fffb16a53a7 _os_activity_initiate_impl + 53

15  AppKit                              0x00007fff999d5791 -[NSApplication(NSResponder) sendAction:to:from:] + 456

16  AppKit                              0x00007fff994ba000 -[NSControl sendAction:to:] + 86

17  AppKit                              0x00007fff994b9f28 __26-[NSCell _sendActionFrom:]_block_invoke + 136

18  libsystem_trace.dylib               0x00007fffb16a53a7 _os_activity_initiate_impl + 53

19  AppKit                              0x00007fff994b9e80 -[NSCell _sendActionFrom:] + 128

20  AppKit                              0x00007fff994fc875 -[NSButtonCell _sendActionFrom:] + 98

21  libsystem_trace.dylib               0x00007fffb16a53a7 _os_activity_initiate_impl + 53

22  AppKit                              0x00007fff994b8762 -[NSCell trackMouse:inRect:ofView:untilMouseUp:] + 2481

23  AppKit                              0x00007fff994fc5ae -[NSButtonCell trackMouse:inRect:ofView:untilMouseUp:] + 798

24  AppKit                              0x00007fff994b7117 -[NSControl mouseDown:] + 832

25  AppKit                              0x00007fff99b512bf -[NSWindow(NSEventRouting) _handleMouseDownEvent:isDelayedEvent:] + 6341

26  AppKit                              0x00007fff99b4dadc -[NSWindow(NSEventRouting) _reallySendEvent:isDelayedEvent:] + 1942

27  AppKit                              0x00007fff99b4cf7a -[NSWindow(NSEventRouting) sendEvent:] + 541

28  AppKit                              0x00007fff999d16f1 -[NSApplication(NSEvent) sendEvent:] + 1145

29  AppKit                              0x00007fff9924c7f7 -[NSApplication run] + 1002

30  AppKit                              0x00007fff992171de NSApplicationMain + 1237

31  RaiseMan                            0x0000000100002bed main + 13

32  libdyld.dylib                       0x00007fffb1473235 start + 1

)


상속받는 클래스인 NSDocument에 print function이 존재하므로  충돌이 납니다.

@warn_unqualified_access

@IBAction open func print(_ sender: Any?)


참고로 아래는 Swift의 print문이 정의형식 입니다.

둘다 _(언더바)로 시작하기 때문에 동일하게 판단하는거 같습니다. ㅜㅠ

public func print(_ items: Any..., separator: String = default, terminator: String = default)


=> 해결 방법은 NSLog 또는 debugPrint 또는 Swift.print를 사용하여 해결은 되나 print 사용 못하는 이유는 정확히 알지 못함. ^^;;;

* Xcode 7.3.1에서는 Swift 2를 지원하기때문에 그런지 정상적으로 print가 동작한다.


2) 컬럼값에 대해서도 되돌리기 기능이 예제에는 구현되어 있는데 오류가 발생한다.

Name컬럼에 New Employee를 "111"을 붙여 수정한다. 흠 여기까지는 잘됩니다.

그럼 Command + Z로 되돌리기를 하면~ 허걱 오류가 나네요.  ㅜㅠ


아래 메소드에서 오류가 나는데요.

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {

다음과 같이 selector를 찾지 못한다는 오류가 발생합니다.

2017-05-09 15:03:14.115236+0900 RaiseMan[64380:2164702] keyPath=Optional("name")

2017-05-09 15:03:17.463811+0900 RaiseMan[64380:2164702] -[_TtGCs26_SwiftDeferredNSDictionaryVSC19NSKeyValueChangeKeyP__ length]: unrecognized selector sent to instance 0x608000020540

2017-05-09 15:03:17.469233+0900 RaiseMan[64380:2164702] [General] -[_TtGCs26_SwiftDeferredNSDictionaryVSC19NSKeyValueChangeKeyP__ length]: unrecognized selector sent to instance 0x608000020540

2017-05-09 15:03:17.480863+0900 RaiseMan[64380:2164702] [General] (

0   CoreFoundation                      0x00007fff9b7e562b __exceptionPreprocess + 171

1   libobjc.A.dylib                     0x00007fffb0b921da objc_exception_throw + 48

2   CoreFoundation                      0x00007fff9b865f14 -[NSObject(NSObject) doesNotRecognizeSelector:] + 132

3   CoreFoundation                      0x00007fff9b758c73 ___forwarding___ + 1059

4   CoreFoundation                      0x00007fff9b7587c8 _CF_forwarding_prep_0 + 120

5   libswiftCore.dylib                  0x00000001002480c2 _TTSfq4g_d___TFSSCfT12_cocoaStringPs9AnyObject__SS + 290

6   libswiftCore.dylib                  0x00000001002055c3 _TFSSCfT12_cocoaStringPs9AnyObject__SS + 19

7   libswiftFoundation.dylib            0x00000001006ec31e _TZFE10FoundationSS36_unconditionallyBridgeFromObjectiveCfGSqCSo8NSString_SS + 14

8   RaiseMan                            0x000000010000159b _TToFC8RaiseMan8Employees4nameGSqSS_ + 75

9   Foundation                          0x00007fff9d2eb897 -[NSObject(NSKeyValueObservingPrivate) _changeValueForKeys:count:maybeOldValuesDict:usingBlock:] + 848

10  Foundation                          0x00007fff9d170c7d -[NSObject(NSKeyValueObservingPrivate) _changeValueForKey:key:key:usingBlock:] + 60

11  Foundation                          0x00007fff9d1d955b _NSSetObjectValueAndNotify + 261

12  Foundation                          0x00007fff9d1ba5c1 -[NSObject(NSKeyValueCoding) setValue:forKey:] + 329

13  CoreFoundation                      0x00007fff9b75a0ac __invoking___ + 140

14  CoreFoundation                      0x00007fff9b759f31 -[NSInvocation invoke] + 289

15  CoreFoundation                      0x00007fff9b773266 -[NSInvocation invokeWithTarget:] + 54

16  Foundation                          0x00007fff9d247875 -[_NSUndoStack popAndInvoke] + 235

17  Foundation                          0x00007fff9d24761c -[NSUndoManager undoNestedGroup] + 433

18  libsystem_trace.dylib               0x00007fffb16a53a7 _os_activity_initiate_impl + 53

19  AppKit                              0x00007fff999d5791 -[NSApplication(NSResponder) sendAction:to:from:] + 456

20  AppKit                              0x00007fff994a89a2 -[NSMenuItem _corePerformAction] + 324

21  AppKit                              0x00007fff994a870e -[NSCarbonMenuImpl performActionWithHighlightingForItemAtIndex:] + 114

22  libsystem_trace.dylib               0x00007fffb16a53a7 _os_activity_initiate_impl + 53

23  AppKit                              0x00007fff994a7521 -[NSMenu performKeyEquivalent:] + 367

24  AppKit                              0x00007fff999d438c routeKeyEquivalent + 1024

25  AppKit                              0x00007fff999d1fa9 -[NSApplication(NSEvent) sendEvent:] + 3377

26  AppKit                              0x00007fff9924c7f7 -[NSApplication run] + 1002

27  AppKit                              0x00007fff992171de NSApplicationMain + 1237

28  RaiseMan                            0x0000000100002cbd main + 13

29  libdyld.dylib                       0x00007fffb1473235 start + 1

)


이건 뭐지? 머리가 아파오네요. ㅜㅠ

다음과 같이 수정하면 오류가 해결됩니다.

if let object = object, let keyPath = keyPath {

   (undo.prepare(withInvocationTarget: object) as AnyObject).setValue(oldValue,

                                                  forKeyPath: keyPath)

}

if let 이라는것은 다음과 같은 2가지의 기능을 함께 하는 원+원의 기능을 가지고 있는데요.

(1) Optional을 unwraping(옷을 벗기다의 뜻 ^^; 야한가요? ㅋㅋ) 해서 새로운 변수에 담아주는 기능이고

(2) 그 결과에 따라 조건문을 수행하라. nil인경우 수행하지 않음


오류의 원인은 withInvocationTarget: forKeyPath:이 2개 파라미터가 모두 옵셔널이면 않되기 때문입니다.

prepare(withInvocationTarget target: Any)

setValue(_ value: Any?, forKeyPath keyPath: String)

위와 같이 정의되어 있습니다. ?가 않붙어 있는것을 확인할수 있죠? ^


* 궁금한 사항이 하나 생기는데요. 그럼 반대로 value: Any?(Optional값요구) 파라미터에다가 unwraping값을 넣으면 어떻게 될까요?

  결과는 상관없습니다. ㅋㅋ  


* 궁금한 사항

1) undo.groupingLevel이란것을 사용하고 있다. undoManager가 그룹으로 관리되는것 같은데 자세한것을 아직 모른다. ^^;

// Has an edit occurred already in this event?

if undo.groupingLevel > 0 {

    // Close the last group

    undo.endUndoGrouping()

    // Open a new group

    undo.beginUndoGrouping()

}


2) KVOContext를 비교하는데요. 어떤때 쓰일는 걸까요?

    결과적으로 context값이 서로 다르면 super에 있는 observeValue를 수행하네요.

if context != &KVOContext {

    // If the context does not match, this message

    //   must be intended for our superclass.

    super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)

    /*super.observeValue(forKeyPath: keyPath,

                       of: object,

                                 change: change as! [NSKeyValueChangeKey : Any],

                                 context: context)*/

    return

}


3) Undoing 하는 시점에 따라 undo.setActionName를 수행하지 못하는 경우도 있는거 같은데요.

    수행을 못하면 어떻게 되는 걸까요?

if !undo.isUndoing {

    undo.setActionName("Add Person")

}


* 확인 완료

MainMenu.xib를 보면 Edit > Undo / Redo가 있는데요.

setActionName에 지정한 메시지가 이부분에 표시가 됩니다. OS적으로 이렇게 지원하네요.

Undo / Redo History 목록을 관리하는 어플이나 기능을 구현하는데 도움이 될듯 합니다.

되돌리기를 하면 다음과 같이 메시지가 메뉴명 옆에 "Add Person"이라고 보여지네요. ^^ 해결 완료~



* UI설정 상세히 알아보기

[Add Employee] 버튼의 @IBAction설정은 addEmployee:와 연결된걸 알수 있죠.


Document.xib파일에 보면 File's Owner라는 항목이 있죠. 이건 해당 UI의 소유가 누구꺼냐 라고 이해하면 쉽습니다.

Document.swift 클래스가 주인이다라고 이해가 되죠. 



Connection's Inspector를 보면 전체적인 연결 설정을 확인하고 알수 있습니다.


Bindings Inspector를 보면 Array Controller가 어떻게 Class에 있는 employees와 연결되는지 알수 있습니다.

iOS에 없는 생소한 사항인데 Model Key Path에 employees를 지정하여 참조관계를 설정하고 있습니다.


Array Controller를 선택하고 Connections Inspector를 보면 좀더 자세한 연결 상태를 볼수 있습니다.


이제좀 대강 머리속에 전체적인 그림(?)이 들어오네요. ^^

에구 머리야~







RaiseMan_Undo.zip
0.07MB