為初學者用 Cocoa 程式設計 Mac OS X/Wikidraw 的檢視類
我們已經有了基本的形狀類和一個用來儲存它們的文件結構。拼圖的最後一塊是檢視,它將所有內容整合在一起,允許使用者互動式地建立繪圖。讓我們考慮一下它的設計。
一個通用的 NSView 提供了兩個主要的功能 - 在視窗中繪製圖形的能力,我們已經討論過,以及處理使用者滑鼠的能力,這是一個新的東西。我們已經討論過,這分為三個部分 - 滑鼠按下事件、一系列滑鼠拖動事件和滑鼠抬起事件。我們的形狀類已經準備好處理這些事件,當它們從我們的檢視中傳遞過來時。
然而,檢視需要做的不僅僅是傳遞這些事件,因為它還需要處理使用者互動的其他方面,例如繪圖中會有很多不同的形狀,以及需要在使用者點選時管理對它們的選中。
此外,形狀的建立將取決於當前在工具調色盤中選擇的工具(我們還沒有設計)。由於形狀的建立也是透過使用滑鼠和在檢視中繪製來完成的,因此檢視需要提供一種方法來區分不同的工具。
在繪製方面,檢視需要渲染整個繪圖,並確定是否選中了物件。雖然形狀本身處理它自己的實際渲染,但檢視必須確保呼叫每個物件的 drawWithSelection: 方法,並將選中狀態傳遞過去。
檢視從文件中獲取資訊在哪裡?我們將設定以便檢視能夠呼叫文件作為委託。我們透過文件的 objects 和 selection 方法建立了用於獲取物件和選中的非正式協議。只要我們設定了有效的委託,檢視就可以使用這個非正式協議呼叫它,並獲取它需要的資訊。NSView 預設沒有委託,因此我們需要自己新增。在 WKDDrawView 類中新增一個 _delegate 資料成員,型別為 'id'。然後新增設定和獲取它的方法 - setDelegate 和 delegate。注意,在這種情況下,檢視不需要保留它的委託 - 這是一個對建立對物件的新引用的通常規則的例外。在這種情況下沒問題,因為我們知道文件和檢視將始終一起建立和銷燬。
- (void) setDelegate:(id) aDelegate
{
_delegate = aDelegate;
}
- (id) delegate
{
return _delegate;
}
現在我們需要確保在建立檢視時將委託設定為文件。為此,我們將建立一個 Interface Builder 中的連結,以便文件可以找到檢視,然後呼叫它的 setDelegate 方法,並將自身傳遞過去。這似乎有點繞彎,但它可以防止檢視和文件之間過於相互依賴。
在 MyDocument.h 中,新增一個數據成員,如下所示
IBOutlet id _mainView;
然後儲存檔案。我們在這裡做的是建立一個名為 _mainView 的物件的引用。我們還將其標記為 IBOutlet。之前我們看到 IBAction 的返回值可以用來向 Interface Builder 提供資訊。這類似。它告訴 IB 這個特定資料成員可以是一個 _出口_,即與另一個物件的連線的一端,可以使用控制拖動連線方法訪問。在 Xcode 中,IBOutlet 被定義為空,因此它不會以任何方式影響程式碼。
將 MyDocument.h 拖到在 Interface Builder 中開啟的 MyDocument.nib 視窗。它將重新解析檔案,並獲取新的出口。現在我們需要將這個出口連線到視窗的檢視(WKDDrawView)。
你可能想知道我們如何做到這一點,因為 MyDocument 類似乎沒有在 Interface Builder 中被表示。事實上,這個類是在我們開啟檔案或選擇新建時由 Cocoa 建立的,那麼我們如何解決這個問題呢?這就是神秘的“檔案所有者”圖示發揮作用的地方。擁有 'MyDocument.nib' 的物件實際上是 MyDocument 類。因此,檔案所有者代表它。將 MyDocument.h 拖進去後,你會注意到檔案所有者圖示上有一個小的感嘆號。這意味著該物件中存在未連線的出口,這是真的,因為我們剛剛添加了出口 _mainView,但還沒有連線它。
從檔案所有者控制拖動到視窗內的檢視。確保突出顯示的是 WKDDrawView,而不是包含它的捲軸。當你鬆開時,檢查器將顯示連線,並切換到出口。選擇 '_mainView' 並點選連線。儲存檔案並返回 Xcode。
現在,當例項化 MyDocument 時,資料成員 _mainView 將被自動設定為指向視窗中的 WKDDrawView。因此,我們現在只需要使用這個引用來設定檢視的委託。當例項化 MyDocument 時,在初始化過程中呼叫的最後幾個方法之一是 'windowControllerDidLoadNib:'。此時,在 .nib 檔案中宣告的所有連線(如我們剛剛建立的連線)都保證到位,因此我們只需在這裡新增程式碼
- (void) windowControllerDidLoadNib:(NSWindowController *) aController
{
[super windowControllerDidLoadNib:aController];
[_mainView setDelegate:self];
}
從現在開始,無論何時檢視需要文件中的任何內容,它都會呼叫它的委託方法來獲取文件,以及使用非正式協議來獲取它想要的內容。檢視不會再知道關於文件的任何資訊,反之亦然 - 已經避免了過度耦合。注意,檢視可以在另一個使用完全不同的物件但實現相同非正式協議以完成工作的應用程式中使用。這是你可以設計類以便在各種專案中重複使用的一種方法。
我們的檢視要做的第一件事是渲染文件的內容。以下程式碼
- (void) drawRect:(NSRect) rect
{
[[NSColor whiteColor] set];
NSRectFill( rect );
NSArray* drawList = [[self delegate] objects];
NSArray* selection = [[self delegate] selection];
NSEnumerator* iter = [drawList objectEnumerator];
WKDShape* shape;
while( shape = [iter nextObject])
{
if ( NSIntersectsRect( rect, [shape drawBounds]))
[shape drawWithSelection:[selection containsObject:shape]];
}
}
和以前一樣,我們首先擦除要重繪的背景部分。接下來,我們使用之前設計的非正式協議向委託請求物件和選中。然後,它只需要遍歷物件並繪製每個物件。為了效率,我們還檢查重新整理矩形是否與物件的邊界相交,如果否,我們就不費事 - 這只是避免繪製任何不可見或不受重新整理影響的內容,從而加快繪製速度。選中狀態是透過檢視物件是否在選中陣列中來確定的。
眼尖的讀者會注意到,在形狀物件上呼叫了一個名為 drawBounds 的方法。我們還沒有討論過這個,但我們需要討論。如果我們使用形狀的邊界,我們會發現它繪製的東西的小部分不會總是被擦除,因為有些東西實際上是在 bounds 定義的區域之外繪製的。例如,選中控制代碼集中在邊界上,因此至少一半的控制代碼在邊界之外。同樣,當我們描邊形狀時,bounds 定義了描邊線的 _中心_。我們需要考慮到這些,以便我們可以正確地重新整理繪製的整個區域。drawBounds 方法返回一個基於 bounds 的矩形,但略微擴充套件了它
- (NSRect) drawBounds
{
return NSInsetRect([self bounds], -kHandleSize - [self strokeWidth], -kHandleSize - [self strokeWidth]);
}
它考慮了控制代碼的大小和描邊寬度,以便保證所有內容都落在這個區域內。任何簡單地重新繪製或重新整理物件的繪製都將使用 drawBounds 而不是 bounds,但必須記住,bounds 是形狀的嚴格數學邊界。
這就是繪製的全部內容。處理滑鼠的情況更加複雜。讓我們現在看看它。
- (void) mouseDown:(NSEvent*) evt
{
NSPoint pt = [self convertPoint:[evt locationInWindow] fromView:nil];
_dragShape = [[self delegate] objectUnderMousePoint:pt];
if (_dragShape == nil)
{
_dragShape = [self shapeForCurrentTool];
[[self delegate] addObject:_dragShape];
[_dragShape setLocation:pt];
}
if (([evt modifierFlags] & NSShiftKeyMask) == 0)
{
[[self delegate] deselectAll];
[[self delegate] selectObject:_dragShape];
}
else
{
if ([[[self delegate] selection] containsObject:_dragShape])
{
[[self delegate] deselectObject:_dragShape];
_dragShape = nil;
}
else
[[self delegate] selectObject:_dragShape];
}
[self setNeedsDisplay:YES];
[_dragShape mouseDown:pt];
}
- (void) mouseDragged:(NSEvent*) evt
{
NSPoint pt = [self convertPoint:[evt locationInWindow] fromView:nil];
NSRect update = [_dragShape drawBounds];
[_dragShape mouseDragged:pt];
[self setNeedsDisplayInRect:NSUnionRect([_dragShape drawBounds], update)];
}
- (void) mouseUp:(NSEvent*) evt
{
NSPoint pt = [self convertPoint:[evt locationInWindow] fromView:nil];
[_dragShape mouseUp:pt];
[self setNeedsDisplayInRect:[_dragShape drawBounds]];
_dragShape = nil;
}
涉及三種方法——mouseDown、mouseDragged 和 mouseUp。每個方法都會傳遞一個 NSEvent 物件,表示觸發該呼叫的滑鼠事件。首先我們需要將滑鼠座標從視窗調整到我們的本地檢視。NSEvent 始終儲存相對於視窗的滑鼠座標,可以使用 locationInWindow 方法提取此資訊。NSView 的 convertPoint:fromView: 將轉換座標,fromView: 為 nil 表示從視窗本身轉換。
在 mouseDown: 中,一旦我們處於本地座標系中,就可以使用之前編寫的 方法查詢文件(委託)以獲取滑鼠下的物件。這將返回最頂層的命中物件,或返回 nil 表示未命中任何物件。我們將結果儲存在一個名為 _dragShape 的資料成員中。這將跟蹤我們稍後呼叫 mouseDragged 和 mouseUp 方法時命中的形狀。
如果我們命中空白區域,我們將(暫時)使用它來表示根據當前工具建立新物件。稍後我們將對其進行改進,以實現透過拖動選框(選擇框)來選擇物件的功能。因此,讓我們看看當我們建立一個新的形狀物件時會發生什麼。首先,我們呼叫另一個方法 shapeForCurrentTool,以建立適當的形狀。這是一個工廠方法,因此返回的物件將被自動釋放。我們立即將新物件新增到文件中,並將它的位置設定為我們的滑鼠點所在位置。如果我們命中了現有物件,則會跳過建立物件並將其新增到文件中的步驟。
接下來,我們測試 Shift 鍵是否按下。如果按下,我們希望使用 Shift 選擇該專案,這意味著 _不_ 先取消選擇所有專案。然後我們選擇我們命中的物件。同樣,我們使用 Shift 鍵新增另一個改進——如果 Shift 鍵按下,我們切換物件的選中狀態,否則我們只選中它並取消選擇所有其他物件。最後,我們將點選傳遞到物件的 mouseDown 方法,我們之前已經討論過。由於我們可能更改了任何數量的位於繪圖中任何位置的物件的選中狀態,因此我們標記需要重新整理整個繪圖,以確保所有這些更改立即在螢幕上生效。請注意,這種方法很簡單,但並非一定是最優的。一旦繪圖變得複雜,每次重新整理整個繪圖都會變得非常昂貴且緩慢。相反,我們可以跟蹤實際更改的物件,並且只重新整理這些區域。但是對於這個簡單的教程,我們不會嘗試透過這樣做來使事情複雜化。
mouseDragged: 與之前一樣轉換座標,並將拖動傳遞到由 _dragShape 指定的當前物件,我們在 mouseDown: 中設定了 _dragShape。由於拖動可以移動和調整物件的大小,因此我們需要重新整理由物件的新位置及其舊位置影響的區域。因此,我們記錄了其邊界在拖動之前和之後的狀態,將它們組合在一起並重新整理該區域。如果我們沒有這樣做,我們會發現物件在拖動時會在其後面留下難看的痕跡。
mouseDragged: 目前沒有嘗試處理多個物件可以拖動的情況。在這種型別的大多數應用程式中,如果選擇了兩個物件並且拖動了一個,則兩個物件將一起拖動。作為練習,您可能希望考慮如何修改 mouseDragged: 以實現此功能。
最後,mouseUp: 透過將呼叫傳遞到拖動的物件、重新整理該位置的螢幕,然後將 _dragShape 設定為 nil 來清理。
處理工具
[edit | edit source]正如我們所見,要新增到繪圖中的形狀取決於 shapeForCurrentTool。它只需要檢視我們選擇了哪個工具,並建立正確型別的物件。但是,我們還沒有選擇工具的介面,因此讓我們建立一個介面,以便我們可以實現它。由於工具面板在應用程式中的所有文件中都是通用的,因此我們將其新增到 MainMenu.nib 中。
Cocoa 的關鍵設計正規化之一是它遵守面向物件設計的模型-檢視-控制器 (MVC) 方法。到目前為止,我們還沒有直接遇到它,但現在必須對此說些什麼。MVC 是一個很好的方法,可以將功能分離到 _模型_(管理應用程式處理的實際有意義的資料)和 _檢視_(該資料的螢幕上的視覺化表示,以及控制它操縱它的控制元件)之間。位於它們之間的是 _控制器_,它負責將檢視對映到資料,反之亦然。到目前為止,我們一直將 MyDocument 用作組合的模型和控制器,但對於工具面板,我們將使用更嚴格劃分的 MVC 方法。
工具面板本身將是檢視。我們需要一個控制器來處理面板中工具按鈕的選擇,並將此資訊以所選工具的形式傳遞出去。模型將只是一個單一變數,儲存當前的工具選擇。
檢視可以在 Interface Builder 中完全建立。控制器可以在 Interface Builder 中部分設定,並輔以我們稍後需要編寫的少量程式碼。首先讓我們看一下我們的“模型”。由於我們的 WKDDrawView 將承擔根據所選工具建立物件的責任,因此顯而易見的方法是讓檢視在需要知道時簡單地詢問工具控制器當前的工具是什麼。但是,有一個問題。對於檢視(在眾多可能的檢視中只有一個,因為我們可以開啟多個文件),沒有簡單的方法來定位工具控制器,而無需對它有明確的引用。一種解決方案是使用一個全域性變數來引用工具控制器,這是一個可行的方案。但是,在面向物件的應用程式中不鼓勵使用全域性變數,此外,僅設定這個全域性變數會以我們希望在本教程中避免的方式使事情複雜化。相反,我們可以在檢視類中宣告一個靜態變數,它將儲存所選工具的 ID。作為一個靜態變數,任何檢視都可以訪問它(靜態變數就像僅對宣告它們的相同檔案中的程式碼可見的全域性變數)。因此,此變數包含工具控制器擁有的工具 ID 的副本。通常我們會避免這種情況,因為相同資料的副本需要小心管理以確保它們不會不同步,但在這種情況下,它相當簡單。
那麼,當用戶點選面板按鈕時,我們如何同步工具 ID 呢?答案是使用通知。通知是 Cocoa 以不具有明確物件引用的方式傳遞資訊的方式。控制器將在每次工具更改時釋出通知。任何對它感興趣的人都可以訂閱該訊息並以適當的方式響應。在這種情況下,每個檢視都會訂閱該訊息,但在響應中設定靜態變數。這裡有些過分——如果有兩個檢視,每個檢視都會收到該訊息,並且該變數將被設定為相同的值兩次。在這種情況下,我們可以忍受這種行為,因為我們只是在談論一個簡單的整數。
那麼,當建立一個新檢視時,但工具設定為某個奇數值時會發生什麼?檢視仍然會知道該工具,因為它是一個靜態變數,在所有檢視例項之間共享。因此,我們不必擔心新檢視在可以使用正確的工具之前從未收到通知的情況,就像變數是每個檢視的本地變數那樣。
好的,讓我們開始行動。
在 IB 中開啟“MainMenu.nib”。在小部件面板(從左數第四個按鈕)中選擇 Windows 面板,並將一個面板拖到主視窗中。雙擊新圖示以打開面板。在檢查器中,刪除視窗的標題。使用“大小”面板將最小寬度設定為 60,最小高度設定為 100。將面板調整為窄而高,並將其放置在螢幕的左上角。使用“屬性”檢查器以選中“實用程式視窗(僅限面板)”和“非啟用面板(僅限面板)”。確保“最小化”和“縮放”框未選中。
切換到“控制元件”面板,並將一個大方形按鈕拖到視窗中。按 Option 鍵並向下拖動底部的選擇手柄,直到您擁有四個垂直排列的按鈕。使用 Option 鍵拖動按鈕會建立所謂的按鈕 _矩陣_,而不是單個按鈕。選擇矩陣後,使用屬性檢查器將行為設定為“單選”。
在新的介面構建器版本中,您透過使用 Option 鍵拖動按鈕來建立按鈕列表。然後選擇所有按鈕,並使用佈局 -> 將物件嵌入到 -> 矩陣中。這將建立一個包含所有按鈕的矩陣。
依次雙擊每個按鈕,並將每個按鈕的行為設定為開/關。請注意,當您瀏覽按鈕時,“標記”欄位會為每個按鈕提供不同的值。這個標記值將成為我們的工具 ID。完成後,我們將擁有一個粗略但功能齊全的工具面板介面。您可以在 IB 中透過選擇檔案 -> 測試介面來測試它的操作。驗證工具面板是否作為一組單選按鈕執行,並且一次只能選擇一個,並且您是否可以從突出顯示中判斷出選擇的是哪個。要返回 IB,請選擇“退出”。
現在我們需要一個工具面板的控制器。IB 透過實際為我們建立此物件來幫助我們,並建立了一些我們可以稍後新增程式碼的骨架 .h 和 .m 檔案。
在主視窗的“類”選項卡中,找到 NSWindowController 類(它是 NSResponder 的子類)。突出顯示 NSWindowController 並選擇類 -> 子類 NSWindowController。將新增一個新名稱;將其命名為 ToolsController。現在我們可以要求 IB 實際建立此物件,從而為我們提供一個可以連線到檢視的實際例項。為此,請突出顯示新類,並選擇類 -> 例項化 ToolsController。將一個新物件新增到主視窗中,它看起來像一個方框,並命名為 ToolsController。返回到“類”檢視(一種快捷方式是雙擊新物件)。在檢查器中,使用“操作”面板新增一個新操作:點選新增,然後鍵入操作名稱 selectionDidChange:(記住冒號!)。這將建立兩個操作,應該已經存在一個 showWindow: 操作。
返回到例項。從 ToolsController 物件 _拖動控制_ 到工具視窗(您可以拖動到實際視窗的標題欄,或者直接拖動到主視窗中的圖示)。將它連線到“視窗”出口。接下來,從 NSMatrix(四個按鈕 - 確保您將四個按鈕作為一個集合選中,而不是隻選中其中一個 - 最簡單的方法是從其中一個按鈕的邊緣的空白區域開始拖動)_拖動控制_ 到 ToolsController。將其連線到您剛剛新增的操作“selectionDidChange:”。
最後,我們需要一種在需要時顯示面板的方法。將一個新選單項拖動到“視窗”選單中,將其放置在“縮放”專案下方。將其命名為“顯示工具”。從選單項_拖動控制_ 到 ToolsController。將其連線到操作“showWindow:”。
儲存檔案。選擇 ToolsController 後,選擇類 -> 為 ToolsController 建立檔案。將 .h 和 .m 檔案新增到 Wikidraw。我們在 IB 中完成了操作,因此返回到 Xcode。
編碼控制器
[edit | edit source]向我們的 ToolsController 類新增一個 int 資料成員,名為 _curTool。新增一個方法來返回它,名為 currentTool。您的類定義應如下所示
@interface ToolsController : NSWindowController
{
int _curTool;
}
- (IBAction) selectionDidChange:(id) sender;
- (int) currentTool;
@end
extern NSString* notifyToolSelectionDidChange;
我們還聲明瞭一個外部字串變數,它將包含我們通知的名稱。
現在讓我們轉向實現部分。這裡我們不需要 'init' 方法,因為我們繼承自 NSWindowController 的 'init' 方法已經足夠了,但我們需要在從 .nib 檔案建立後進行一些設定。我們可以透過實現 'awakeFromNib' 方法來做到這一點。它只是一個方法(沒有引數),當我們從 .nib 檔案中被建立時呼叫。以下是我們需要做的事情。
- (void) awakeFromNib
{
[(NSPanel*)[self window] setFloatingPanel:YES];
[(NSPanel*)[self window] setBecomesKeyOnlyIfNeeded:YES];
_curTool = 0;
}
我們需要告訴我們的視窗成為一個浮動面板,並且除非絕對必要,否則不要成為關鍵視窗。這是為了防止工具調色盤中的點選將焦點從當前文件中移開,這將是一種非常奇怪的行為。我們也趁機將 _curTool 初始化為 0。
我們的控制器的主要方法是 action selectionDidChange;。以下是它的程式碼。
- (IBAction) selectionDidChange:(id) sender
{
_curTool = [[sender selectedCell] tag];
[[NSNotificationCenter defaultCenter] postNotificationName:notifyToolSelectionDidChange object:self];
}
您會記得,動作的傳送者是一個 NSMatrix。矩陣會跟蹤當前選定的專案,並在我們呼叫 selectedCell 時返回它。然後我們需要獲取與該專案相關的標籤值,所以我們呼叫 'tag'。然後它就被賦值給 _curTool。
接下來是有趣的部分。我們釋出了一個通知,告訴任何感興趣的物件,工具選擇剛剛被使用者更改。我們使用一種叫做“預設通知中心”的東西來實際傳送這個訊息。這是一個全域性的 Cocoa 物件,存在於此目的,用於將訊息轉發給任何感興趣的物件。因為它對所有類可見(以我們的 ToolsController 不具備的方式),所以它是將資訊傳遞給我們不瞭解的物件的理想方式。postNotificationName:object: 方法完成了這項工作。通知名稱只是一個字串,我們定義它,它唯一地指示正在發生的事情,而物件就是控制器本身。這個名稱被設定為一個字串
NSString* notifyToolSelectionDidChange = @"toolSelectionChanged";
字串本身並不重要,雖然給它一個描述性的名稱很有用,因為你可能希望在除錯期間將你收到的通知記錄到某個地方,如果名稱是有意義的,你就能很容易地知道它是從哪裡來的。
這就是我們的控制器的全部內容——它所做的只是接收按鈕的更改並將更改作為通知轉發。
接收通知
[edit | edit source]回到 WKDDrawView.h 中,我們需要宣告一個方法來處理通知的接收。我們稱它為 toolChange:,它看起來像這樣
- (void) toolChange:(NSNotification*) note;
在 .m 檔案的頂部,在實現之外,新增
static int sCurrentTool = 0;
這將是當前工具 ID 的本地副本。我們將它宣告為靜態的,以便它對我們檢視類的任何例項可見。接下來我們需要註冊通知。我們將在我們的 init 方法中完成此操作
- (id) initWithFrame:(NSRect)frameRect
{
if ((self = [super initWithFrame:frameRect]) != nil)
{
_delegate = nil;
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(toolChange:)
name:notifyToolSelectionDidChange
object:nil];
}
return self;
}
- (void) dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
[super dealloc];
}
我們使用 addObserver:selector:name:object: 來註冊對該通知的興趣。當物件被釋放時,我們必須取消註冊,以避免在釋放後傳送任何通知時出現異常。當傳送與名稱匹配的通知時,我們傳遞的選擇器所命名的 方法會被呼叫
- (void) toolChange:(NSNotification*) note
{
sCurrentTool = [[note object] currentTool];
}
我們所做的只是詢問通知哪個物件傳送了訊息([note object]),由於我們知道它是一個 ToolsController,我們可以簡單地詢問它工具 ID,然後將其儲存在 sCurrentTool 中。因此,sCurrentTool 應該始終與實際選定工具的當前值匹配。如果檢視需要在任何時間瞭解哪個工具被選中,它可以簡單地檢視 sCurrentTool,而不是試圖找出 ToolsController 在哪裡並詢問它。
當然,為了讓它編譯,我們需要將 ToolsController.h 檔案匯入到 WKFDDrawView.m 中
#import "ToolsController.h"
在我們繼續之前,讓我們構建並檢查一下,看看我們是否已準備好開始。您應該能夠從視窗選單中選擇“顯示工具”以使調色盤可見,並且單擊按鈕應該選擇它們。我們還不能確定它是否正在執行任何有用的操作,因為我們還沒有編寫根據所選工具的值進行操作的程式碼。但是,我們可以記錄對通知的接收。在開發和/或除錯應用程式時,記錄此類資訊總是很方便的。
要記錄任何東西,請使用 NSLog() 函式。它接受一個 NSString 常量,該常量將被寫入日誌。日誌本身在應用程式執行時在 Xcode 的主視窗中可見。傳遞給 NSLog 的字串接受類似 printf 的格式化指令,所以讓我們新增
NSLog(@"tool changed to %d", sCurrentTool);
到 toolChange: 方法的末尾。再次構建並執行,並驗證單擊工具按鈕是否會將工具 ID 寫入日誌。