IOS開發:Cocos2d觸摸分發原理分析
觸摸是iOS程式的精髓所在,良好的觸摸體驗能讓iOS程式得到非常好的效果,例如Clear。鑒於同學們只會用cocos2d的 CCTouchDispatcher 的 api 但並不知道工作原理,但瞭解觸摸分發的過程是極為重要的。畢竟涉及到許可權、兩套協議等的各種分發。
本文以cocos2d-iphone原始碼為講解。cocos2d-x 於此類似,就不過多贅述了。
零、cocoaTouch的觸摸
在講解cocos2d觸摸協議之前,我覺得我有必要提一下CocoaTouch那四個方法。畢竟cocos2d的Touch Delegate 也是通過這裡接入的。
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
1、一個UITouch的生命週期
一個觸摸點會被封裝在一個UITouch中,在TouchesBegan的時候建立,在Cancelled或者Ended的時候被銷毀。也就是說,一個觸摸點在這四個方法中記憶體位址是相同的,是同一個對象。
2、UIEvent
這是一個經常被大伙兒忽視的東西,基本上沒見過有誰用過,不過這個東西的確不常用。可以理解為UIEvent是UITouch的一個容器。
你可以通過UIEvent的allTouches方法來獲得當前所有觸摸事件。那麼和傳入的那個NSSet有什麼區別呢?
那麼來設想一個情況,在開啟多點支援的情況下,我有一個手指按在螢幕上,既不移動也不離開。然後,又有一隻手指按下去。
這時TouchBegan會被觸發,它接到的NSSet的Count為1,僅有一個觸摸點。
但是UIEvent的alltouches 卻是2,也就是說那個按在螢幕上的手指的觸摸資訊,是可以通過此方法擷取到的,而且他的狀態是UITouchPhaseStationary
3、關於Cancelled的誤區
有很多人認為,手指移出螢幕、或移出那個View的Frame 會觸發touchCancelled,這是個很大的誤區。移出螢幕觸發的是touchEned,移出view的Frame不會導致觸摸終止,依然是Moved狀態。
那麼Cancelled是幹什麼用的?
官方解釋:This method is invoked when the Cocoa Touch framework receives a system interruption requiring cancellation of the touch event; for this, it generates a UITouch object with a phase of UITouchPhaseCancel. The interruption is something that might cause the application to be no longer active or the view to be removed from the window
當Cocoa Touch framework 接到系統中斷通知需要取消觸摸事件的時候會調用此方法。同時會將導致一個UITouch對象的phase改為UITouchPhaseCancel。這個中斷往往是因為app長時間沒有響應或者當前view從window上移除了。
據我統計,有這麼幾種情況會導致觸發Cancelled:
1、官方所說長時間無響應,view被移除
2、觸摸的時候來電話,彈出UIAlert View(低電量 簡訊 推送 之類),按了home鍵。也就是說程式進入後台。
3、螢幕關閉,觸摸的時候,某種原因導致距離感應器工作,例如臉靠近。
4、手勢的許可權蓋掉了Touch, UIGestureRecognizer 有一個屬性:
@property(nonatomic) BOOL cancelsTouchesInView;
// default is YES. causes touchesCancelled:withEvent: to be sent to the view for all touches recognized as part of this gesture immediately before the action method is called
關於CocoaTouch就說到這裡,CocoaTouch的Touch和Gesture混用 我會在將來的教程中寫明。
一、TouchDelegate的接入。
眾所周知CCTouchDelegate是通過CocoaTouch的API接入的,那麼是從哪裡接入的呢?我們是知道cocos2d是跑在一個view上的,這個view 就是 EAGLView 可在cocos2d的Platforms的iOS檔案夾中找到。
在它的最下方可以看到,他將上述四個api傳入了一個delegate。這個delegate是誰呢?
沒錯就是CCTouchDispatcher
但縱覽整個EAGLView的.m檔案,你是找不到任何和CCTouchDispatcher有關的東西的。
那麼也就是說在初始化的時候載入的咯?
EAGLView的初始化在appDelegate中,但依然沒看到有關CCTouchDispatcher 有關的東西,但可以留意一句話:
[director setOpenGLView:glView];
點開後可以發現
CCTouchDispatcher *touchDispatcher = [CCTouchDispatcher sharedDispatcher];
[openGLView_ setTouchDelegate: touchDispatcher];
[touchDispatcher setDispatchEvents: YES];
呵呵~ CCTouchDispatcher 被發現了!
二、兩套協議
CCTouchDispatcher 提供了兩套協議。
@protocol CCTargetedTouchDelegate
- (BOOL)ccTouchBegan:(UITouch *)touch withEvent:(UIEvent *)event;
@optional
- (void)ccTouchMoved:(UITouch *)touch withEvent:(UIEvent *)event;
- (void)ccTouchEnded:(UITouch *)touch withEvent:(UIEvent *)event;
- (void)ccTouchCancelled:(UITouch *)touch withEvent:(UIEvent *)event;
@end
@protocol CCStandardTouchDelegate
@optional
- (void)ccTouchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)ccTouchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)ccTouchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)ccTouchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
@end
與之對應的還有兩個在CCTouchDispatcher 中的添加操作
-(void) addStandardDelegate:(id) delegate priority:(int)priority;
-(void) addTargetedDelegate:(id) delegate priority:(int)priority swallowsTouches:(BOOL)swallowsTouches;
其中StandardTouchDelegate 單獨使用的時候用法和 cocoaTouch 相同。
我們這裡重點說一下CCTargetedTouchDelegate
在標頭檔的注釋中可以看到:
使用它的好處:
1、不用去處理NSSet, 分發器會將它拆開,每次調用你都能精確的拿到一個UITouch
2、你可以在touchbegan的時候retun yes,這樣之後touch update 的時候 再獲得到的touch 肯定是它自己的。這樣減輕了你對多點觸控時的判斷。
除此之外還有
3、TargetedTouchDelegate支援SwallowTouch 顧名思義,如果這個開關開啟的話,比他許可權低的handler 是收不到 觸摸響應的,順帶一提,CCMenu 就是開了Swallow 並且許可權為-128(許可權是越小越好)
4、 CCTargetedTouchDelegate 的層級比 CCStandardDelegate 高,高在哪裡了呢? 在後文講分發原理的時候 我會說具體說明。
三、CCTouchHandler
在說分發之前,還要介紹下這個類的作用。
簡而言之呢,這個類就是用於儲存你的向分發器註冊協議時的參數們。
類指標,類所擁有的那幾個函數們,以及觸摸許可權。
只不過在 CCTargetedTouchHandler 中還有這麼一個東西
@property(nonatomic, readonly) NSMutableSet *claimedTouches;
這個東西就是記錄當前這個delegate中 拿到了多少 Touches 罷了。
只是想在這裡說一點:
UITouch只要手指按在螢幕上 無論是滑動 也好 開始began 也好 finished 也好
對於一次touch操作,從開始到結束 touch的指標是不變的.
四、觸摸分發
前面鋪墊這麼多,終於講到重點了。
這裡我就結合這他的代碼說好了。
首先先說dispatcher定義的資料成員
NSMutableArray*targetedHandlers;
NSMutableArray*standardHandlers;
BOOLlocked;
BOOLtoAdd;
BOOLtoRemove;
NSMutableArray*handlersToAdd;
NSMutableArray*handlersToRemove;
BOOLtoQuit;
BOOLdispatchEvents;
// 4, 1 for each type of event
struct ccTouchHandlerHelperData handlerHelperData[kCCTouchMax];
開始那兩個 數組 顧名思義是存handlers的 不用多說
之後下面那一段的東西是用於線程間資料修改時的標記。
提一下那個lock為真的時候 代表當前進行中觸摸分發
然後是總開關
最後就是個helper 。。
然後說之前提到過的那兩個插入方法
-(void) addStandardDelegate:(id) delegate priority:(int)priority;
-(void) addTargetedDelegate:(id) delegate priority:(int)priority swallowsTouches:(BOOL)swallowsTouches;
就是按照priority插入對應的數組中。
但要注意一點:當前若進行中事件分發,是不進行插入的。取而代之的是放到一個緩衝數組中。等觸摸分髮結束後才加入其中。
在講分發前,再提一個函數
-(void) setPriority:(int) priority forDelegate:(id) delegate
調整許可權,講它的目的是為了講它中間包含的兩個方法一個c函數,
-(CCTouchHandler*) findHandler:(id)delegate; -(void) rearrangeHandlers:(NSMutableArray*)array; NSComparisonResultsortByPriority(id first, id second, void *context);
調整許可權的過程就是,先找到那個handler的指標,修改它的數值,然後對兩個數組重新排序。 這裡有幾個細節: 1、findHandler 是先找 targeted 再找standard 且找到了就 return。也就是說 如果 一個類既註冊了targeted又註冊了standard,這裡會出現衝突。 2、排序的比較子函數 只比較許可權,其他一律不考慮。 在dispatcher.m的檔案中末,可以看到EAGLTouchDelegate 全都指向了
-(void) touches:(NSSet*)touches withEvent:(UIEvent*)event withTouchType:(unsigned int)idx
這個方法。
他就是整個 dispatcher的核心。
下面我們來分段講解下。
最開始
id mutableTouches;
locked = YES;
// optimization to prevent a mutable copy when it is not necessary
unsigned int targetedHandlersCount = [targetedHandlers count];
unsigned int standardHandlersCount = [standardHandlers count];
BOOL needsMutableSet = (targetedHandlersCount && standardHandlersCount);
mutableTouches = (needsMutableSet ? [touches mutableCopy] : touches);
struct ccTouchHandlerHelperData helper = handlerHelperData[idx];
首先開啟了鎖,之後是一個小最佳化。
就是說 如果 target 和 standard 這兩個數組中 有一個為空白的話 就不用 將傳入的 set copy 一遍了。
下面開始正題
targeted delegate 分發!
if( targetedHandlersCount > 0 ) {
for( UITouch *touch in touches ) {
for(CCTargetedTouchHandler *handler in targetedHandlers) {
BOOL claimed = NO;
if( idx == kCCTouchBegan ) {
claimed = [handler.delegate ccTouchBegan:touch withEvent:event];
if( claimed )
[handler.claimedTouches addObject:touch];
}
// else (moved, ended, cancelled)
else if( [handler.claimedTouches containsObject:touch] ) {
claimed = YES;
if( handler.enabledSelectors & helper.type )
[handler.delegate performSelector:helper.touchSel withObject:touch withObject:event];
if( helper.type & (kCCTouchSelectorCancelledBit | kCCTouchSelectorEndedBit) )
[handler.claimedTouches removeObject:touch];
}
if( claimed && handler.swallowsTouches ) {
if( needsMutableSet )
[mutableTouches removeObject:touch];
break;
}
}
}
}
其實分發很簡單,先枚舉每個觸摸點,然後枚舉targeted數組中的handler
若當前觸摸是 began 的話 那麼就 運行 touchbegan函數 如果 touch began return Yes了 那麼證明這個觸摸被claim了。加入handler的那個集合中。
若當前觸摸不是began 那麼判斷 handler那個集合中有沒有這個 UItouch 如果有 證明 之前的touch began return 了Yes 可以繼續update touch。 若操作是結束或者取消,就從set中把touch刪掉。
最後這點很重要 當前handler是claim且設定為吞掉觸摸的話,會刪除standardtouchdelegate中對應的觸摸點,並且終止迴圈。
targeted所有觸摸事件分發完後開始進行standard 觸摸事件分發。
按這個次序我們可以發現…
1、再次提起swallow,一旦targeted設定為swallow 比它許可權低的 以及 standard 無論是多高的許可權 全都收不到觸摸分發。
2、standard的觸摸許可權 設定為 負無窮(最高) 也沒有 targeted的正無窮(最低)許可權高。
3、觸摸分發,只和許可權有關,和層的高度(zOrder)完全沒關係,哪怕是同樣的許可權,也有可能低下一層先收到觸摸,上面那層才接到。許可權相同時數組裡是亂序的,非插入順序。
最後,關閉鎖
開始判斷在資料分發的時候有沒有發生 添加 刪除 清空handler的情況。
結束分發
注意,事件分發後的非同步處理資訊會出現幾個有意思的副作用
1、刪除的時候 retainCnt +1因為要把handler暫時加入緩衝數組中。
雖說是暫時的,但是會混淆你的調試。
例如:
- (BOOL) ccTouchBegan:(UITouch *)touch withEvent:(UIEvent *)event
{
NSLog(@"button retainCnt = ", button.retainCount);
[[CCTouchDispatcher sharedDispatcher] removeDelegate:button];
NSLog(@"button retainCnt = ", button.retainCount);
}
如果你記憶體管理做得好的話,應該是 輸出 2 和 3
2 是在 addchild 和 dispatcher中添加了。
3 是在 cache 中又被添加一次。
2、有些操作會失去你想要表達的效果。
例如一個你寫了個ScrollView 上面有一大塊menu。你想在手指拖拽view的時候 屏蔽掉 那個menu的響應。
也許你會這麼做:
1)讓scrollview的許可權比menu還要高,並設為不吞掉觸摸。
2)滑動的時候,scrollview肯定會先收到觸摸,這時取消掉menu的響應。
3)觸摸結束還,還原menu響應
但實際上第二步的時候 menu 還是會收到響應的,會把menu的item變成selected狀態。並且需要手動還原
範例代碼如下:
-(id) init
{
// always call "super" init
// Apple recommends to re-assign "self" with the "super" return value
if( (self=[super init])) {
CCSprite* sprite = [CCSprite spriteWithFile:@"Icon.png"];
CCSprite* sprite1 = [CCSprite spriteWithFile:@"Icon.png"];
sprite1.color = ccRED;
CCMenuItem* item = [CCMenuItemSprite itemFromNormalSprite:sprite
selectedSprite:sprite1
block:^(id sender) {
AudioServicesPlayAlertSound(1000);
}];
item.position = ccp(100, 100);
CCMenu* menu = [CCMenu menuWithItems:item, nil];
menu.position = ccp(0, 0);
menu.tag = 1025;
[self addChild:menu];
[[CCTouchDispatcher sharedDispatcher] addTargetedDelegate:self priority:-129 swallowsTouches:NO];
}
return self;
}
- (BOOL) ccTouchBegan:(UITouch *)touch withEvent:(UIEvent *)event
{
return YES;
}
- (void) ccTouchMoved:(UITouch *)touch withEvent:(UIEvent *)event
{
CCMenu*menu = (CCMenu*) [self getChildByTag:1025];
menu.isTouchEnabled = NO;
}
- (void) ccTouchEnded:(UITouch *)touch withEvent:(UIEvent *)event
{
CCMenu*menu = (CCMenu*) [self getChildByTag:1025];
menu.isTouchEnabled = YES;
}
3、需要注意的一點是,TouchTargetedDelegate 並沒有屏蔽掉多點觸摸,而是將多點離散成了單點,同時傳遞過來了。
也就是說,每一個觸摸點都會走UITouch LifeCircle ,只是因為在正常情況下NSSet提取出來的資訊順序相同,使得你每次操作看起來只是最後一個觸摸點生效了。
但是如果使用者“手賤”,多指觸摸,並不同時抬起全部手指,你將收到諸如start(-move)-end-(move)-end 之類的情況。
若開啟了多點觸控支援,一定要考慮好這點!否則可能會被使用者玩出來一些奇怪的bug…