【iOS】Cocoa(iOS,OSX)安保系统设计实现
前言
這里主要以iOS和OSX講講crash閃退怎么防御。
其中最新的OSX應用本身就有一定閃退防御,但有點類似@try @catch在最外層包了一下普通的越界調用空方法都會中斷在操作位置不向下執行,如果沒有進一步復雜邏輯不會閃退,只是影響后續的操作。
而iOS則沒這么好說話了,二話不說直接閃退給你看沒有上面的那種機制。
所以才有了設計一個安保系統的意義,來保證最大程度的健壯性,理想的狀態就是不crash且能繼續正常運行后面的邏輯。
參考了眾多網上的資料有了下面的小成果分享出來,這其實只是安保系統最后的一個環節的防御
安保系統設計
這里我所認為的安保系統應該從代碼和規范兩個層面看,畢竟想抓到所有的crash情況是一定不可能的,現實中即使處處try catch都沒法保證抓到所有crash!
代碼
-
swizzing切面
-
方法防御選型
-
防御成功上報
程序內需要的是代碼,這個模塊是要沒有任何侵入性的,所以切面是必須的,其次就是盡量的細化切面顆粒度保證意外情況最小化!
另一點就是切面以后我們對原方法應該采取怎樣的防御,這里即可以try catch的形式也可以進行邏輯判斷形式。
而我的代碼里用邏輯判斷,更多的考量是針對的函數都偏下層且容易使用時外部恰巧又有各種循環邏輯,那樣相較之下try catch在不間斷的調用性能會有一定影響,所以暫時沒用try catch作為防御的手段。
從另一角度看其實try catch的使用場景有些方法還是比較合適的,首先我們在防御時方法顆粒度已經很細所以抓住異常都會做對應處理不會有內存泄漏或邏輯遺漏,另外無論try還是catch內的方法也不會太多,滿足了`try catch的最佳場景,只是個別方法循環利用略過高可能性能沒法到達極致僅此而已。
防御完了crash就是上報,我們保護了程序的同時也就意味著有地方寫的有問題,由于沒crash所以沒crash log,這時候就需要在安保模塊里加入上報機制,這時候我的做法則是放出一個協議等人去實現,安保模塊就專心處理防御的事情,上報到服務端的事情交給專門處理這事的模塊,我們只需要在防御成功時告知協議有這么個事情即可。剩下的就是個人看情況如需詳細情況直接[NSThread callStackSymbols]把棧信息輸出一下!
| 1 2 3 4 5 6 7 8 9 10 | //安保模塊上報協議 @protocol?SafeObjectReportProtocol @required /** ? 上報防御的crash?log ? ? ? @param?log?log無法抓到Notification的遺漏注銷情況 ? */ -( void )reportDefendCrashLog:(NSString*)log; @end |
而實現這個協議的只需要對SafeObjectProxy做個Category實現一下即可。
還有就是防御的分類開啟,這時候枚舉就要用位運算的形式,這樣才能兼容多種模式并存如下只開啟Array和String的防御
| 1 | [SafeObjectProxy?startSafeObjectProxyWithType:?SafeObjectProxyType_Array|?SafeObjectProxyType_String] |
規范
另一個安保模塊的組成則應該是對代碼規范的制定與校驗,這就需要clang來做了,不是這里主要講的,相當于多了一種Build Options的Compiler for C/C++/Objective-C屬性的選擇,用我們開發的Xcode校驗插件,檢查代碼語法上的問題直接報錯,這樣從源頭來規范化編碼。
Crash分類及防御實現
-
Unrecognized Selector(找不到方法)
-
UI Refresh Not In Main Thread(UI刷新不在主線程)
-
Input Parm Abnormal(入參異常)
-
Dangling Pointer(野指針)
-
Abnormal Matching(異常配對)
-
Thread Conflict(線程沖突)
想要防御crash,首先要做的就是了解都有哪些情況會產生crash,上邊就是筆者總結的幾種最常見的情況,不全的話希望有人留言補足,畢竟crash的防御真正有發言權開發這種模塊的估計只有大公司開發app的,不然用戶量不夠沒樣本采集,沒法了解坑爹的情況!
而上面列的6種常見crash,真正能廣域控制得了的恐怕也只有一半不到!下面就一一講解一下,Hook切面就是主要的手段!
Unrecognized Selector(找不到方法)
這個找不到方法算是比較好辦的。。。也算是比較常見的好查的,另外處理ok了null對象調用的問題也會隨之解決
可選的方法有兩種
Hook這兩個方法
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
- (void)forwardInvocation:(NSInvocation *)anInvocation
或Hook這一個方法
-(id)forwardingTargetForSelector:(SEL)aSelector
核心思想就是在找不到方法之前創建方法確保繼續執行不掛,為了盡量不多余的創建方法,集中的把創建打到統一的地方。
前者需要在methodSignatureForSelector執行前在新的target里創建沒有的方法,然后用它調用methodSignatureForSelector返回,而這里的target當然要單例弄出來省的以后來回創建。然后在forwardInvocation里用他來調用invokeWithTarget指到我們新的target上。
后者也就是我用的方法,之所以用它主要是一個方法 就ok!而我們還要兼顧靜態方法和實例方法去分別hook才能防住這兩種,而前者也要hook的方法更多。。。。
而這里只需要切forwardingTargetForSelector方法,靜態方法返回class,動態方法返回target,當然返回之前我們要添加上不存在的方法,值得注意的是OSX上一個神奇的問題,我在判斷是否系統有這個方法的時候第一次居然respondsToSelector返回false而methodSignatureForSelector有數據,第二次校驗是methodSignatureForSelector才為空,而iOS上則沒這問題第一次校驗就是對的!
UI Refresh Not In Main Thread(UI刷新不在主線程)
刷新UI不在主線程的情況這里只是針對UIView和NSView的3個方法做切面線程判斷。分別是setNeedsLayout,setNeedsDisplay,setNeedsDisplayInRect,執行之前看是不是在主線程,不在的話就切到主線程執行,但很明顯這3個方法肯定覆蓋不全,而且就算覆蓋全了每次都判斷一下也是性能浪費,所以這里各自斟酌處理吧,這類情況暫時沒想到其他好的處理方式!但好在算是有這么個可控方案!
Input Parm Abnormal(入參異常)
入參異常這是一大類,防御的方法也相對比較通俗易懂,也是最容易查最容易出現的。
常用類型入參異常
常見類包括String,Array,Dictionary,URL,FileManager等這些類空值初始化,越界取值,空賦值等,基本看crash log統計依次切面對應方法在執行前判斷一下就ok。如objectAtIndex,objectAtIndexedSubscript,removeObjectAtIndex,fileURLWithPath,initWithAttributedString,substringFromIndex,substringToIndex等等。唯一需要注意的就是這些要切面的類名可是五花八門而且更iOS版本有很大關系,所以這個就是靠crash log積累了解有哪些坑。當然代碼寫的好就用不到了!__NSSingleObjectArrayI這個就是最近在iOS11上新發現的報錯數組類,當然也可能是最近我司有人寫出了這個相關的bug......
常見的需要注意的hook的類有以下
objc_getClass("__NSPlaceholderArray")
objc_getClass("__NSSingleObjectArrayI")
objc_getClass("__NSArrayI")
objc_getClass("__NSArrayM")
objc_getClass("__NSPlaceholderDictionary")
objc_getClass("__NSDictionaryI")
objc_getClass("__NSDictionaryM")
objc_getClass("NSConcreteAttributedString")
objc_getClass("NSConcreteMutableAttributedString")
objc_getClass("__NSCFConstantString")
objc_getClass("NSTaggedPointerString")
objc_getClass("__NSCFString")
objc_getClass("NSPlaceholderMutableString")
具體有哪些方法需要切面還是看源碼吧,這部分是沒什么難點的。
另外我的防御里面沒對NSCache做,可能以后會隨便加點,因為緩存相關的模塊我的建議是自己封裝緩存模塊或用第三方,那樣對于上層使用者來說已經是安全的了!各種異常處理在緩存模塊里就應該有封裝。
KVC Crash
KVC歸根結底也算這類入參異常,一共切面3個地方就夠防御了!
-(void)setValue:(id)value forKey:(NSString *)key,
-(void)setValue:(id)value forKeyPath:(NSString *)keyPath
空值防御上面2個方法
-(void)setValue:(id)value forUndefinedKey:(NSString *)key
上面這個就是沒有的屬性做賦值操作時走的回調,如果用到我的SafeObjectProxy要自定義各個類不同的處理是可以不開啟UndefinedKey防御的!
Dangling Pointer(野指針)
這個種Crash堪稱經典!就是那個最難排查的,而這里我們能做的防御事情也十分有限!
具體定位看看騰訊這幾篇很有幫助!
如何定位Obj-C野指針隨機Crash(一)
如何定位Obj-C野指針隨機Crash(二)
如何定位Obj-C野指針隨機Crash(三)
我們只能去對已知的出現野指針的類進行防御,找到crash的野指針開啟Zombie Objects,加上Zombies工具,然后想辦法不斷提高復現率還是可以的定位到的。
我們的防御則是hook系統dealloc,判斷需要做處理的類不走系統delloc而是走objc_desctructInstance釋放實例內部所持有屬性的引用和關聯對象,保證對象最小化。緊接著就需要來波isa swizzling了,因為通常野指針伴隨著的還有就是調用沒有的方法,或者由于調用的這個時機是不正常的,各種數據的安全性都沒了保證,所以dealloc后解除所有持有,再把原來的isa指向一個其他的類,而這個類能把所有的調用方法指向一個空方法這樣就起到了防御的作用。
能干這事的也只有NSProxy了,利用協議實現methodSignatureForSelector,forwardInvocation方法,統一打到之前處理找不到方法自動創建的類中,也就是在NSProxy內實現上面Unrecognized Selector的防御,這樣所有對于野指針的調用就都是空了!
正因為上面的原因一旦開啟了這個防御,真正釋放的時機就還是有的,如果在野指針出現前觸發了真正釋放的邏輯,crash就還是會有的!
我在SafeObjectProxy里只是用野指針個數控制做真正釋放,回頭可能會封裝個block方便復雜情況的判斷。
Abnormal Matching(異常配對)
這一類算是不建議做防御的!成對的方法處理異常像KVO,NSTimer,NSNotification都算,需要注冊和注銷。
這種情況我的建議是統一封裝獨立模塊調用統一的方法,讓人不需要關心注冊和注銷,主要寫邏輯處理。從功能實現上做嚴格限制,這樣讓人考慮的就是怎么樣把一個場景融入到封裝的方法中,而不是隨意的寫!
下面說下原因,由于注冊和注銷是分離寫的 ,所以使用場景,解決問題的方法都會有著非常靈活的操作,這其實很可怕,先用KVO做一個舉例順便說一下這類防御如果真要做一般的做法是怎么做。
KVO
KVO這種crash如果要防御其實只能防御下面3種情況:
1.觀察者或被觀察者已經不存在了
2.取消和添加的次數不匹配
3.沒寫監聽回調observeValueForKeyPath:ofObject:change:context:
而這3種情況我們來認真思考下開發的階段是不是貌似都會第一時間就被發現!而且如果是沒經驗的程序員寫KVO我們是不是都不敢用,會再三審查,而有經驗的又不會犯上面的錯。。。。
如果對上面的情況防御也很復雜,而且我嘗試并且用過很多第三方,都在我司稍微有點復雜的項目上掛了,不僅沒能防御crash還造了crash,這種成對邏輯的靈活性非常高,你沒法知道系統內部人家怎么用著玩的!
說一下防御上面的情況首先是吧,切面add、removeObserve是一定的,還要在所有的類里對再加一個對象,這個對象主要負責管理KVO下面就叫KVOController吧,讓所有的觀察者都成為了被觀察者的一個屬性,用map記錄原來的觀察者和keyPath等信息,這樣添加或移除觀察者就能判斷是不是成對出現的,另外KVOController在dealloc時也可以通過map依次移除監聽,而由于所有的監聽回調其實都是由KVOController的observeValueForKeyPath:ofObject:change:context:通過[originObserver observeValueForKeyPath:keyPath ofObject:object change:change context:context]傳遞出去的自然沒寫監聽回調的情況也可以判斷了,但也是能解決那3個情況!
真正KVO產生的恐怖的crash是移除時機不和觀察者或被觀察者銷毀有關系,而是跟我們的邏輯有關,一旦沒在合適時機移除導致的crash排查起來超級費勁!還有你在監聽回調里處理邏輯有沒有線程安全問題,這些才是我們在上線前容易漏,排查又不好排查的!
安保系統則是要保護上線后能正常運作,然而就像我這里說的KVO,如果不在編碼期間就做嚴格規范,上線后出的問題也是根本無從防御的!
然后再來說說怎么限制我們的自由發揮,KVOController剛才說到的這里需要的是把它變形,把回調用block放出來,另外就是讓它有單例模式和普通的實例模式,只有創建對象、關聯監聽和邏輯處理,一個KVOController可以是全局或屬于一個對象,相當于可視化了KVO的生效周期,一目了然,這里讓特殊邏輯適應我們的規范才是正確的安保思路。包括NSTimer在內也也是如此可以搞個TimerController不過封裝最好也別用NSTimer精度不高,反正要封裝不如直接gcd,與其要手動保持成對不如我們就把邏輯封裝好,讓使用者忘掉成對的概念!但在開放的今天完全可以GitHub搜一波找些封裝好的自己再簡單包裝下,然后讓團隊遵循規范開發即可。。。
KVO:KVOController比較推薦的一個KVO管理
NSTimer
NSTimer比較特殊,有些時候偏偏不該成對使用,它的成對的邏輯其實是跟自己的生命周期有關,畢竟生命周期結束時要去成對的停掉timer才能釋放,另一點就是NSTimer精確度并不高!但它封裝出來給人用的方法是ok的正是有單例模式和實例模式兩種使用。所以我的建議當然是自己把gcd的timer封裝一下,另外把target這個概念變為weak持有,這樣我們自己封裝的timer就可以dealloc的時候停掉timer釋放了,按照系統NSTimer封裝方法即可。這樣至少能保證timer指定的target釋放時timer能停掉不會因為跑了其他不安全的邏輯掛掉。其他可能掛掉的情況應該比較少。。。
Timer:MSWeakTimer比較推薦的一個計時器封裝方法就是我上面講的那種
NSNotification
這個雖然也是成對使用,單比上面的幾個要安全一些,因為使用它有[[NSNotificationCenter defaultCenter] removeObserver:self]多次調用或沒addObserver都不會掛,所以可以全局搞一下,我在SafeObjectProxy里面就只是對所有NSObject對象添加了個屬性做標識,然后hook一下NSNotificationCenter的-(void)addObserver:(id)observer selector:(SEL)aSelector name:(NSNotificationName)aName object:(id)anObject方法,只要observer是NSObject對象我就標識一下,然后切所有NSObject的dealloc只要標識了的統一執行[[NSNotificationCenter defaultCenter] removeObserver:self],反正多執行了也沒問題用的放心!
但只要是成對的,就有另一個問題,萬一真正需要注銷的地方是跟邏輯有關,那你對象銷毀時注銷早就晚了,就像上面KVO中提到的我們做的這層crash防御其實犯錯率并不高能及時發現,而及時發現不了的只能是通過編碼規范或者人員分級禁用來解決。
Thread Conflict(線程沖突)
基本無解的問題,出現以后瞬間懵逼,典型例子就是死鎖,異步調用同一對象導致不安全,基本沒有防御手段,排查也只能靠多加log不斷復現,然后猜。。。。
但一般只要代碼按照正常的規范寫也不會那么容易遇到這問題,但線程沖突理論上只要保證UI操作都在主線程,其他都gcd不在主線程上,然后部分需要線程安全的gcd信號量做鎖就可以,但不會有人這樣寫代碼,性能和效率那么搞是都要廢的,現在都恨不得你馬上出活那有空那樣,這類就可以完全不考慮防御的事了!
總結
以上是生活随笔為你收集整理的【iOS】Cocoa(iOS,OSX)安保系统设计实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 全球云观察|预见2021:云计算发展十大
- 下一篇: 关于setTimeout函数中的this