如果你有其他的平台的經驗,來寫 Cocoa 應用程式,可能會發現 NSWindow 的行為跟你想得似乎不太一樣。
※ NSWindow 屬於 View
雖然現在的 GUI 應用程式的架構大都遵循 MVC 的設計典範,但是不同的 Framework 之間,那個部分屬於 View,那個部分又屬於 Contoller,規劃卻又不盡相同。在許多 Framework 的設計中,Window 被當成是 Controller 使用,但是在 Cocoa 的架構中,一個 NSWindow 物件,卻是單純扮演 View 的角色。
當你打開 Visual Studio,打算寫個 WinForm 程式,你大概會先用視覺化工具拉出一個 Window(這個 Window 的物件型別其實叫做 Form 就是了),在上面再拉一個按鈕,在這個按鈕上面點兩下,就可以開始撰寫使用者點到這個按鈕時會觸發的程式。-這裡,在 Window 裡頭產生的按鈕,被當成是這個 Window 物件的成員變數,點擊這個按鈕所觸發的行為,也是這個 Window 物件的 method。總之,在這個 Window 中發生的種種,都是由 Window 物件處理,Window 物件是其他放在 Window 裡頭其他 UI 元件的 Controller。
但是在 Cocoa 中卻不一樣。在 Cocoa 中產生了一個 Window 之後,這個 Window 並不扮演 Controller 的角色,而是另外有一個 Controller 物件,這個物件有一個成員變數(通常是個 IBOutlet)連結到這個 Window 上。你的 Window 物件與上面的其他 UI 元件的關係,就只是 NSWindow 有個 content view,content view 裡頭擺放這些元件,在 MVC 的界定上,Window 本身與其他 UI 元件,都是另外一個 Controller 的成員變數。
所以,如果你想要做的事情是:讓使用者在某個 Window 中點了某個按鈕後,跳出另外一個 Window,就很容易發現兩者之間的差別。在 WinForm 裡頭,要產生另外一個 Window,你會先另外製作一個繼承 System.Windows.Forms.Form 的型別,然後在原本的 Form 裡頭產生這個 Form 的 instance,最後呼叫 Show() 顯示出來。
在 Cocoa 裡頭,因為 NSWindow 是被當成 View,所以要產生一個有兩個以上 Window 的應用程式,就不是從某個主要 Window 去產生另外一個 Window,而是你可能會有一個繼承自 NSObject 的 Controller 物件,這個物件可以同時管理兩個 Window,只要設定兩個 NSWindow 成員變數,再去 Interface Builder 拉出兩個 Window,並且連結起來,就可以了。
※ NSWindowController
現在有些人可能是因為 iPhone 接觸 Objective-C 語言與蘋果的開發工具,再來接觸 Mac 上面的應用程式開發。-在 iPhone 應用程式中,我們會產生許多的 UIViewController 物件,然後把 UIViewController 放進 UINavigationController 或 UITabBarController 的瀏覽路徑中。既然 iPhone 的 UI 實作是以 UIViewController 為主,因為 iPhone 的 UI 的重點是 View,而在 Mac 上的 UI 的重點應該就是 Window,所以在寫 Mac 應用程式的時候,是不是可以比照 iPhone,產生許多的 NSWindowCongtroller 呢?
這樣想好像很直觀,不過,狀況並不是這樣。NSWindowController,主要是給 NSDocument 用的,而使用 NSDocument 的應用程式,叫做 document-based application,甚至在 NSWindowController 的文件中,都可以看到參閱文件是 Document-Based Applications Overview。
打開 Xcode,可以看到 project template 中,Cocoa Application 分類中有一個 checkbox,讓你選擇要不要產生 document-based application。什麼是 document-based application?
我們可以這樣簡單區分:有一種類型的應用程式,在程式中的 Window 數量是固定的,所有的工作都可以在這些有限的 Window 中完成,例如計算機-計算機程式只有一個 Window,上面是數字與計算用的按鈕,各種計算工作都只在這個 Window 中完成,拉下 File 選單只會看到 Close,而沒有像是 New 或 Save 這些指令;這是一般的 Cocoa Application。
在這種應用程式中,可能還有其他的 Window,例如在計算機中,我們可能想要多一個Window 幫我們做單位轉換,像攝氏轉華氏之類。在這種狀況下,其實只要像之前提到的,用一個物件產生兩個 IBOutlet,分別連到這兩個 Window 就好。
另外有一類應用程式,則是可以用 New 產生新的文件,分別會有一個 Window 代表這個文件,比方說文字編輯器-你可以產生一份新檔案,或是從 Finder 裡頭打開一份檔案,每開一個新檔案,就會多一個 Window,裡頭是檔案的文字內容,你可以繼續開啟更多的檔案,就會產生更多的 Window,所以應用程式中的 Window 數量會隨著開啟文件的數量而變動,沒辦法用有限的 Window 完成所有的工作。這種是 document-based application。
在這兩種類型的應用程式中,Window 會有不同的行為:
1. 關閉 Window 的行為。在一般的 Cocoa 應用程式中,使用者按下計算機的關閉按鈕,只是想要把計算機 Window 隱藏起來;但是在文書編輯器中,關閉 Window 就代表我已經不想要編輯這個檔案了,不只是隱藏起來而已,而是除非要求應用程式重新開啟檔案,這個 Window 就不應該存在,在關閉的同時,如果檔案被改過而沒有儲存,應用程式也應該要提醒使用者要不要存檔。
2. Window 是否要有一些代表檔案的元件。在一般 Cocoa 應用程式中,Window 的標題列就只是單純顯示一個標題,計算機的 Window 標題就是「計算機」三個字;而文書編輯軟體的標題呢,則會是目前正在編輯的檔案的檔名,同時顯示一個檔案圖示的小 icon,在檔名上面點選右鍵,會有一個選單告訴你這個檔案的完整路徑,拖拉這個 icon 到 Finder 裡頭,還可以搬移或是複製檔案。
一個一般的 Cocoa 應用程式,可以直接用 IBOutlet 建立 Controller 與 Window 之間的簡單關係;在 document-based application 中,Cocoa Framework 則是用 NSDocument、NSWindowController 與 NSWindow 實作。
※ NSDocument、NSWindowController 與 NSWindow
NSDocument 所處理的是與檔案之間的關係,最主要的是開啟、修改與儲存檔案。以文書編輯軟體來說,應用程式打開檔案的時候,就會有一個 NSDocument 物件負責讀入檔案,把文字內容放在某個成員變數中,同時記得這個檔案的路徑(不過,最近幾版的 Mac OS X,慢慢地都用 URL 取代本機路徑)。另外,NSDocument 也負責檔案列印與相關設定。
在應用程式中,會有一個 Singleton 地 NSDocumentController,產生了一個新的 NSDocument 時,我們就要把這個 NSDocument 物件加到 NSDocumentController 中,這樣,如果我們打開一個已經開過的檔案,就可以透過比對 NSDocumentController 中是否有路徑相同的檔案,確認是否開過,如果已經開過,就不要產生新的 NSDocument 物件,直接用現有的物件,頂多把隱藏起來的文件 Window 顯示出來。
NSWindow 就是在螢幕上面看到的 UI,而 NSWindowController 的功能,就是介於 NSDocument 與 NSWindow 之間,與兩者互動。
從選單按下 New,產生 NSDocument,到一個 Window 出現在螢幕上,如果你直接用 project template 產生一個 document-based application,會發現 Cocoa 已經幫你做好很多預設實作,而光讀程式碼似乎看不懂 Cocoa 到底做了什麼。官方文件裡頭 Message Flow in the Document Architecture 就在講這幾個 Class 是怎麼串起來的,流程大抵是-
1. NSDocument 首先免不了的要 alloc、init,設定檔案 URL(如果是新文件,就是 nil),把 NSDocument 加入 NSDocumentController 的管理中。
2. 接著,呼叫 NSDocument 的 makeWindowControllers。NSDocument 基本的實作是產生一個 NSWindowController,這個 NSWindowController 透過 NSDocument 的 windowNibName 決定要載入哪一個 nib。產生了 NSWindowController 後,NSDocument 會用 addWindowController:,把這個 NSWIndowController 物件加到自己的 windowControllers 中。每個 WindowController 負責管理一個 Window。
3. 對 NSWindowController 呼叫 showWindow:,把從 nib 載入的 Window 顯示出來。如果 NSWindowController 還沒有載入 window,就會自動用 loadWindow 載入。
NSWindowController 在 NSDocument 與 NSWindow 之間,最主要會用到的就是 setShouldCascadeWindows: 與 setShouldCloseDocument:,這部份大概都會在 makeWindowControllers 的時候設定。
雖然 makeWindowControllers 的時候預設的實作是產生一個單一的 NSWindowController,但是一個 NSDocument 可能會用到很多不同的 Window,每個 Window 都可能有不同的行為。如果我們會用到很多 Window,就可以在 makeWindowControllers 時產生對應的 NSWindowController,用 addWindowController: 加入。
以文字編輯器來說,一個檔案的內容可能出現在一個主 Window 中,但是有可能有許多工具 Window,比方說主 Window 旁邊有一個可以展開或收起來的 Drawer,裡頭有字數統計等相關資訊,在關閉主 Window 時,行為應該是關閉文件,這個 NSWindowController 就應該在 setShouldCloseDocument: 設成 YES,至於抽屜呢,關閉時就只是收起來而已,就反之,應該設成 NO。
甚至,在同一個應用程式中,關閉不同 Window 的行為也都不一樣。在 Safari 裡頭,關閉一個瀏覽器 Window,與關閉檔案下載列表的 Window,前者代表的是我不要看這些網頁了,但後者只是把檔案下載列表隱藏起來而已。
如果 setShouldCloseDocument: 設成 YES,在關閉 Window 的時候,Window 首先會用 delegate 的方式通知 NSWindowController,NSWindowController 再通知 NSDocument,確認檔案是不是已經被改過,是不是應該要先存檔才關閉;從 NSDocument 透過 NSWindowController 產生 Window 的過程中,NSDocument 也同時把自己的 Undo Manager 指派到 NSWindow 上,讓 Window 上的 UI 元件-像是 NSTextField 或 NSTextView 等做 Undo 的時候,讓 NSDocument 知道對檔案的修改已經被 Undo 了。
setShouldCascadeWindows: 則是決定我們要不要在新視窗產生的時候,讓視窗位置稍微出現在前一個產生出來的文件的視窗的附近,但是視窗位置的上緣與左側比前一個視窗多一點(打開「文字編輯」然後一直開新文件就看到這種效果)。同樣的,只有編輯檔案的主視窗需要這種行為,但 Drawer 則不用。
※ 幾個可以想到的問題
問:我們要在應用程式中開一個視窗,一定要用 NSDocument 嗎?假如我們要寫一個聊天軟體,開啟新的聊天視窗的時候,這個聊天視窗根本與檔案路徑無關,我們是不是可以直接產生一個新的 NSWindowController 物件,讓他來從 nib 載入 Window?
答:這麼做,在記憶體管理上,可能會有一些麻煩的地方。在關閉 Window 的時候,如果我們也要把管理 Window 的 NSWindowController 放掉,那麼可能的作法就會是讓 NSWindowController 成為 Window 的 delegate,在 Window 關閉時,NSWindowController 對自己呼叫 [self release],光想就覺得頂危險。而對 NSDocument 呼叫 close 的話,Cocoa 則已經有一套對 NSDocument 的記憶體管理機制。
這邊需要特別注意:NSDocument 的 close 代表的是要關閉文件,NSWindowController 如果沒有一個關連的 NSDocument 的時候,單純呼叫 close,只代表把 Window 隱藏起來而已,如果有關連的 NSDocument,就會去呼叫 NSDocument 的 close。
而且,就算是聊天軟體,我們大概也不會想點選相同的聯絡人,結果卻是開不同的 Window 聊天。所以,每個聊天 Window 要知道是屬於跟誰聊天的,大概也是透過一套 URL 管理,這樣還是用 NSDocument 比較好,可能某個 NSDocument 代表 msn://zonble 之類,只是這類的 URL,通常就需要改寫一下 Window 設定,讓 Window 的標題列不要出現與檔名相關的東西。
問:那麼,為什麼 NSDocument 與 NSWindow 之間,需要經過 NSWindowController 這一層?而不是 NSDocument 直接連結到 NSWindow 上?一定要用 makeWindowControllers 產生 Window 嗎?
而且,事實上,NSDocument 自己也有 window 這個 method,我們也經常把 NSDocument 的 IBOutlet 直接連到 NSWindowController 負責載入的 Window 上,對吧?
答:呃,說起來,如果你的 NSDocument 物件只有一個視窗的話,只要實作 windowNibName,回傳一個要載入的 nib 的檔名字串就好,其他事情預設實作都幫你做好了,就當做 NSWindowController 不存在就好。這個 API 也不是我規劃的,有些事情你還真不知道為什麼。
原來NSWindow和NSWindowController的關係是這麼回事,真的非常感謝你的說明。
可以这样理解吗?NSDocument 是不是顶级管理
类似于WIN下面的多视图管理呢?
恩,就是MDI.是有点类似于那个.
谢谢LZ的解答,茅塞顿开