作者介紹:李運鋒,美團點評iOS工程師,5年iOS開發經驗,現在是美團點評點餐團隊的一員。
前言
本文較長(5000字左右),建議閱讀時間: 20min+
一個iOS App的穩定性,主要決定於整體的系統架構設計,同時也不可忽略程式設計的細節,正所謂“千里之堤,潰於蟻穴”,一旦考慮不周,看似無關緊要的程式碼片段可能會帶來整體軟體系統的崩潰。尤其因為蘋果限制了熱更新機制,App本身的穩定性及容錯性就顯的更加重要,之前可以通過釋出熱補丁的方式解決線上程式碼問題,現在就需要在提交之前對App開發週期內的各個指標進行實時監測,儘量讓問題暴漏在開發階段,然後及時修復,減少線上出問題的機率。針對一個App的開發週期,它的穩定性指標主要有以下幾個環節構成,用一個腦圖表示如下:
1 開發過程
開發過程中,主要是通過監控記憶體使用及洩露,CPU使用率,FPS,啟動時間等指標,以及常見的UI的主執行緒監測,NSAssert斷言等,最好能在Debug模式下,實時顯示在介面上,針對出現的問題及早解決。
記憶體問題
記憶體問題主要包括兩個部分,一個是iOS中常見迴圈引用導致的記憶體洩露 ,另外就是大量資料載入及使用導致的記憶體警告。
mmap
雖然蘋果並沒有明確每個App在執行期間可以使用的記憶體最大值,但是有開發者進行了實驗和統計,一般在佔用系統記憶體超過20%的時候會有記憶體警告,而超過50%的時候,就很容易Crash了,所以記憶體使用率還是儘量要少,對於資料量比較大的應用,可以採用分步載入資料的方式,或者採用mmap方式。mmap 是使用邏輯記憶體對磁碟檔案進行對映,中間只是進行對映沒有任何拷貝操作,避免了寫檔案的資料拷貝。 操作記憶體就相當於在操作檔案,避免了核心空間和使用者空間的頻繁切換。之前在開發輸入法的時候 ,詞庫的載入也是使用mmap方式,可以有效降低App的記憶體佔用率,具體使用可以參考連結第一篇文章。
迴圈引用
迴圈引用是iOS開發中經常遇到的問題,尤其對於新手來說是個頭疼的問題。迴圈引用對App有潛在的危害,會使記憶體消耗過高,效能變差和Crash等,iOS常見的記憶體主要以下三種情況:
Delegate
代理協議是一個最典型的場景,需要你使用弱引用來避免迴圈引用。ARC時代,需要將代理宣告為weak是一個即好又安全的做法:
@property (nonatomic, weak) id <MyCustomDelegate> delegate;
複製程式碼
NSTimer
NSTimer我們開發中會用到很多,比如下面一段程式碼
- (void)viewDidLoad {
[super viewDidLoad];
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self
selector:@selector(doSomeThing)
userInfo:nil
repeats:YES];
}
- (void)doSomeThing {
}
- (void)dealloc {
[self.timer invalidate];
self.timer = nil;
}
複製程式碼
這是典型的迴圈引用,因為timer會強引用self,而self又持有了timer,所有就造成了迴圈引用。那有人可能會說,我使用一個weak指標,比如
__weak typeof(self) weakSelf = self;
self.mytimer = [NSTimer scheduledTimerWithTimeInterval:1 target:weakSelf selector:@selector(doSomeThing) userInfo:nil repeats:YES];
複製程式碼
但是其實並沒有用,因為不管是weakSelf還是strongSelf,最終在NSTimer內部都會重新生成一個新的指標指向self,這是一個強引用的指標,結果就會導致迴圈引用。那怎麼解決呢?主要有如下三種方式:
- 使用類方法
- 使用weakProxy
- 使用GCD timer
具體如何使用,我就不做具體的介紹,網上有很多可以參考。
Block
Block的迴圈引用,主要是發生在ViewController中持有了block,比如:
@property (nonatomic, copy) LFCallbackBlock callbackBlock;
複製程式碼
同時在對callbackBlock進行賦值的時候又呼叫了ViewController的方法,比如:
self.callbackBlock = ^{
[self doSomething];
}];
複製程式碼
就會發生迴圈引用,因為:ViewController->強引用了callback->強引用了ViewController,解決方法也很簡單:
__weak __typeof(self) weakSelf = self;
self.callbackBlock = ^{
[weakSelf doSomething];
}];
複製程式碼
原因是使用MRC管理記憶體時,Block的記憶體管理需要區分是Global(全域性)、Stack(棧)還是Heap(堆),而在使用了ARC之後,蘋果自動會將所有原本應該放在棧中的Block全部放到堆中。全域性的Block比較簡單,凡是沒有引用到Block作用域外面的引數的Block都會放到全域性記憶體塊中,在全域性記憶體塊的Block不用考慮記憶體管理問題。(放在全域性記憶體塊是為了在之後再次呼叫該Block時能快速反應,當然沒有呼叫外部引數的Block根本不會出現記憶體管理問題)。
所以Block的記憶體管理出現問題的,絕大部分都是在堆記憶體中的Block出現了問題。預設情況下,Block初始化都是在棧上的,但可能隨時被收回,通過將Block型別宣告為copy型別,這樣對Block賦值的時候,會進行copy操作,copy到堆上,如果裡面有對self的引用,則會有一個強引用的指標指向self,就會發生迴圈引用,如果採用weakSelf,內部不會有強型別的指標,所以可以解決迴圈引用問題。
那是不是所有的block都會發生迴圈引用呢?其實不然,比如UIView的類方法Block動畫,NSArray等的類的遍歷方法,也都不會發生迴圈引用,因為當前控制器一般不會強引用一個類。
其他記憶體問題
1 NSNotification addObserver之後,記得在dealloc裡面新增remove;
2 動畫的repeat count無限大,而且也不主動停止動畫,基本就等於無限迴圈了;
3 forwardingTargetForSelector返回了self。
記憶體解決思路:
1 通過Instruments來檢視leaks
2 整合Facebook開源的FBRetainCycleDetector
3 整合MLeaksFinder
具體原理及使用,可以參考連結。
CPU使用率
CPU的使用也可以通過兩種方式來檢視,一種是在除錯的時候Xcode會有展示,具體詳細資訊可以進入Instruments內檢視,通過檢視Instruments的time profile來定位並解決問題。另一種常見的方法是通過程式碼讀取CPU使用率,然後顯示在App的除錯皮膚上,可以在Debug環境下顯示資訊,具體程式碼如下:
int result;
mib[0] = CTL_HW;
mib[1] = HW_CPU_FREQ;
length = sizeof(result);
if (sysctl(mib, 2, &result, &length, NULL, 0) < 0)
{
perror("getting cpu frequency");
}
printf("CPU Frequency = %u hz\n", result);
複製程式碼
FPS監控
目前主要使用CADisplayLink來監控FPS,CADisplayLink是一個能讓我們以和螢幕重新整理率相同的頻率將內容畫到螢幕上的定時器。我們在應用中建立一個新的 CADisplayLink 物件,把它新增到一個runloop中,並給它提供一個 target 和selector 在螢幕重新整理的時候呼叫,需要注意的是新增到runloop的common mode裡面,程式碼如下:
- (void)setupDisplayLink {
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkTicks:)];
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}
- (void)linkTicks:(CADisplayLink *)link
{
//執行次數
_scheduleTimes ++;
//當前時間戳
if(_timestamp == 0){
_timestamp = link.timestamp;
}
CFTimeInterval timePassed = link.timestamp - _timestamp;
if(timePassed >= 1.f)
//fps
CGFloat fps = _scheduleTimes/timePassed;
printf("fps:%.1f, timePassed:%f\n", fps, timePassed);
}
}
複製程式碼
啟動時間
點評App裡面本身就包含了很多複雜的業務,比如外賣、團購、到綜和酒店等,同時還引入了很多第三方SDK比如微信、QQ、微博等,在App初始化的時候,很多SDK及業務也開始初始化,這就會拖慢應用的啟動時間。 如下主要參考了今日頭條iOS客戶端啟動速度優化
App的啟動時間t(App總啟動時間) = t1(main()之前的載入時間) + t2(main()之後的載入時間)。
t1 = 系統dylib(動態連結庫)和自身App可執行檔案的載入;
t2 = main方法執行之後到AppDelegate類中的- (BOOL)Application:(UIApplication *)Application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法執行結束前這段時間,主要是構建第一個介面,並完成渲染展示。
複製程式碼
針對t1的優化,優化主要有如下:
- 減少不必要的framework,因為動態連結比較耗時;
- 檢查framework應當設為optional和required,如果該framework在當前App支援的所有iOS系統版本都存在,那麼就設為required,否則就設為optional,因為optional會有些額外的檢查;
- 合併或者刪減一些OC類,這些我會在後續的靜態檢查中進行詳解;
針對t2的時間優化,可以採用:
- 非同步初始化部分操作,比如網路,資料讀取;
- 採用延遲載入或者懶載入某些檢視,圖片等的初始化操作;
- 對與圖片展示類的App,可以將解碼的圖片儲存到本地,下次啟動時直接載入解碼後的圖片;
- 對實現了+load()方法的類進行分析,儘量將load裡的程式碼延後呼叫。
UI的主執行緒監測
我們都知道iOS的UI的操作一定是在主執行緒進行,該監測可以通過hook UIView的如下三個方法
-setNeedsLayout,
-setNeedsDisplay,
-setNeedsDisplayInRect
複製程式碼
確保它們都是在主執行緒執行。子執行緒操作UI可能會引起什麼問題,蘋果說得並不清楚,但是在實際開發中,我們經常會遇到整個App的動畫丟失,很大原因就是UI操作不是在主執行緒導致。
2 靜態分析過程
靜態分析在這裡,我主要介紹兩方面,一個是正常的code review機制,另外一個就是程式碼靜態檢查工具
code review
組內的code review機制,可以參考團隊之前的OpenDoc - 前端團隊CodeReview制度,iOS客戶端開發,會在此基礎上進行一些常見手誤及Crash情況的重點標記,比如:
1 我們開發中首先都是在測試環境開發,開發時可以將測試環境的url寫死到程式碼中,但是在提交程式碼的時候一定要將他改為線上環境的url,這個就可以通過gitlab中的重點比較部分字串,給提交者一個強力的提示;
2 其他常見Crash的重點檢查,比如NSMutableString/NSMutableArray/NSMutableDictionary/NSMutableSet 等類下標越界判斷保護,或者 append/insert/add nil物件的保護;
3 ARC下的release操作,UITableViewCell返回nil,以及前面介紹的常見的迴圈引用等。
code review機制,一方面是依賴寫程式碼者的程式碼習慣及質量,另一名依賴審查者的經驗和細心程度,即使讓多人revew,也可能會漏過一些錯誤,所以我們又新增了程式碼的靜態檢查。
程式碼靜態檢查
程式碼靜態分析(Static Program Analysis)是指在不執行程式的條件下,由程式碼靜態分析工具自動對程式進行分析的方法. iOS常見的靜態掃描工具有Clang Static Analyzer、OCLint、Infer,這些主要是用來檢查可能存在的問題,還有Deploymate用來檢查api的相容性。
Clang Static Analyzer
Clang Static Analyzer是一款靜態程式碼掃描工具,專門用於針對C,C++和Objective-C的程式進行分析。已經被Xcode整合,可以直接使用Xcode進行靜態程式碼掃描分析,Clang預設的配置主要是空指標檢測,型別轉換檢測,空判斷檢測,記憶體洩漏檢測這種等問題。如果需要更多的配置,可以使用開源的Clang專案,然後整合到自己的CI上。
OCLint
OCLint是一個強大的靜態程式碼分析工具,可以用來提高程式碼質量,查詢潛在的bug,主要針對 C、C++和Objective-C的靜態分析。功能非常強大,而且是出自國人之手。OCLint基於 Clang 輸出的抽象語法樹對程式碼進行靜態分析,支援與現有的CI整合,部署之後基本不需要維護,簡單方便。
OCLint可以發現這些問題
- 可能的bug - 空的 if / else / try / catch / finally 語句
- 未使用的程式碼 - 未使用的區域性變數和引數
- 複雜的程式碼 - 高圈複雜度, NPath複雜, 高NCSS
- 冗餘程式碼 - 多餘的if語句和無用的括號
- 壞味道的程式碼 - 過長的方法和過長的引數列表
- 不好的使用 - 倒邏輯和入參重新賦值
對於OCLint的與原理和部署方法,這裡不做細講解,主要是每次提交程式碼後,可以在打包的過程中進行程式碼檢查,及早發現有問題的程式碼。當然也可以在合併程式碼之前執行對應的檢查,如果檢查不通過,不能合併程式碼,這樣檢查的力度更大。
Infer
Infer facebook開源的靜態分析工具,Infer可以分析 Objective-C, Java 或者 C 程式碼,報告潛在的問題。Infer效率高,規模大,幾分鐘能掃描數千行程式碼; C/OC中捕捉的bug型別主要有:
1:Resource leak
2:Memory leak
3:Null dereference
4:Premature nil termination argument
複製程式碼
只在 OC中捕捉的bug型別
1:Retain cycle
2:Parameter not null checked
3:Ivar not null checked
複製程式碼
結論
Clang Static Analyzer和Xcode整合度更高、更好用,支援命令列形式,並且能夠用於持續整合。OCLint有更多的檢查規則和定製,和很多工具整合,也同樣可用於持續整合。Infer效率高,規模大,幾分鐘能掃描數千行程式碼;支援增量及非增量分析;分解分析,整合輸出結果。infer能將程式碼分解,小範圍分析後再將結果整合在一起,兼顧分析的深度和速度,所以根據自己的專案特點,選擇合適的檢查工具對程式碼進行檢查,減少人力review成本,保證程式碼質量,最大限度的避免執行錯誤。
3 測試過程
前面介紹了很多指標的監測,程式碼靜態檢查,這些都是效能相關的,真正決定一個App功能穩定是否的是測試環節。測試是釋出之前的最後一道卡,如果bug不能在測試中發現,那麼最終就會觸達使用者,所以一個App的穩定性,很大程度決定它的測試過程。iOS App的測試包括以下幾個層次:單元測試,UI測試,功能測試,異常測試。
單元測試
XCTest是蘋果官方提供的單元測試框架,與Xcode整合在一起,由此蘋果提供了很詳細的文件XCTest。
Xcode單元測試包含在一個XCTestCase的子類中。依據約束,每一個 XCTestCase 子類封裝一個特殊的有關聯的集合,例如一個功能、用例或者一個程式流。同時還提供了XCTestExpectation來處理非同步任務的測試,以及效能測試measureBlock(),還包括很多第三方測試框架比如:KiWi,Quick,Specta等,以及常用的mock框架OCMock。
單元測試的目的是將程式中所有的原始碼,隔離成最小的可測試單元,以確保每個單元的正確性,如果每個單元都能保證正確,就能保證應用程式整體相當程度的正確性。但是在實際的操作過程中,很多公司都很難徹底執行單元測試,主要就是單元測試程式碼量甚至大於功能開發,比較難於維護。
對於測試用例覆蓋度多少合適這個話題,也是仁者見仁智者見智,其實一個軟體覆蓋度在50%以上就可以稱為一個健壯的軟體了,要達到70,80這些已經是非常難了,不過我們常見的一些第三方開源框架的測試用例覆蓋率還是非常高的,讓人咋舌。例如,AFNNetWorking的覆蓋率高達87%,SDWebImage的覆蓋率高達77%。
UI測試
Xcode7中新增了UI Test測試,UI測試是模擬使用者操作,進而從業務處層面測試,常用第三方庫有KIF,appium。關於XCTest的UI測試,建議看看WWDC 2015的視訊UI Testing in Xcode。 UI測試還有一個核心功能是UI Recording。選中一個UI測試用例,然後點選圖中的小紅點既可以開始UI Recoding。你會發現:隨著點選模擬器,自動合成了測試程式碼。(通常自動合成程式碼後,還需要手動的去調整)
功能測試
功能測試跟上述的UT和UI測試有一些相通的地方,首先針對各個模組設計的功能,測試是否達到產品的目的,通常功能測試主要是測試及產品人員,然後還需要進行專項測試,比如我們公司的雲測平臺,會對整個App的效能,穩定性,UI等都進行整體評測,看是否達到標準,對於大規模的活動,還需要進行服務端的壓力測試,確保整個功能無異常。測試通過後,可以進行estFlight測試,到最後正式釋出。
功能測試還包括如下場景:系統相容性測試,螢幕解析度相容性測試,覆蓋安裝測試,UI是否符合設計,訊息推送等,以及前面開發過程中需要監控的記憶體、cpu、電量、網路流量、冷啟動時間、熱啟動時間、儲存、安裝包的大小等測試。
異常測試
異常測試主要是針對一些不常規的操作
- 使用過程中的來電時及結束後,介面顯示是否正常;
- 狀態列為兩倍高度時,介面是否顯示正常;
- 意外斷電後,資料是否儲存,資料是否有損害等;
- 裝置充電時,不同電量時的App響應速度及操作流暢度等;
- 其他App的相互切換,前後臺轉換時,是否正常;
- 網路變化時的提示,弱網環境下的網路請求成功率等;
- 各種monkey的隨機點選,多點觸控測試等是否正常;
- 更改系統時間,字型大小,語言等顯示是否正常;
- 裝置儲存不夠時,是否能正常操作;
- ...
異常測試有很多,App針對自身的特點,可以選擇性的進行邊界和異常測試,也是保證App穩定行的一個重要方面。
4 釋出及監控
因為移動App的特點,即使我們通過了各種測試,產品最終釋出後,還是會遇到很多問題,比如Crash,網路失敗,資料損壞,賬號異常等等。針對已經發布的App,主要有一下方式保證穩定性:
熱修復
目前比較流行的熱修復方案都是基於JSPatch、React Native、Weex、lua+wax。
JSPatch能做到通過js呼叫和改寫OC方法。最根本的原因是 Objective-C 是動態語言,OC上所有方法的呼叫/類的生成都通過 objective-c Runtime 在執行時進行,我們可以通過類名和方法名反射得到相應的類和方法,也可以替換某個類的方法為新的實現,還可以新註冊一個類,為類新增方法。JSPatch 的原理就是:JS傳遞字串給OC,OC通過 Runtime 介面呼叫和替換OC方法。
React Native 是從 Web 前端開發框架 React 延伸出來的解決方案,主要解決的問題是 Web 頁面在移動端效能低的問題,React Native 讓開發者可以像開發 Web 頁面那樣用 React 的方式開發功能,同時框架會通過 JavaScript 與 Objective-C 的通訊讓介面使用原生元件渲染,讓開發出來的功能擁有原生App的效能和體驗。
Weex阿里開源的,基於Vue+Native的開發模式,跟RN的主要區別就在React和Vue的區別,同時在RN的基礎上進行了部分效能優化,總體開發思路跟RN是比較像的。
但是在今年上半年,蘋果以安全為理由,開始拒絕有熱修復功能的應用,但其實蘋果拒的不是熱更新,拒的是從網路下載程式碼並修改應用行為,蘋果禁止的是“基於反射的熱更新“,而不是 “基於沙盒介面的熱更新”。而大部分框架(如 React Native、weex)和遊戲引擎(比如 Unity、Cocos2d-x等)都屬於後者,所以不在被警告範圍內。而JSPatch因為在國內大部分應用來做熱更新修復bug的行為,所以才回被蘋果禁止。
降級
使用者使用App一段時間後,可能會遇到這樣的情況:每次開啟App時閃退,或者正常操作到某個介面時閃退,無法正常使用App。這樣的使用者體驗十分糟糕,如果沒有一個好的解決方案,很容易被使用者刪除App,導致使用者量的流失。因為熱更新基本不能使用,那就只能是App自身修復能力。目前常用的修復能力有:
- 啟動Crash的監控及修復
1 在應用起來的時候,記錄flag並儲存本地,啟動一個定時器,比如5秒鐘內,如果沒有發生Crash,則認為使用者操作正常,清空本地flag。
2 下次啟動,發現有flag,則表明上次啟動Crash,如果flag陣列越大,則說明Crash的次數越多,這樣就需要對整個App進行降級處理,比如登出賬號,清空Documents/Library/Caches目錄下的檔案。
- 具體業務下的Crash及修復
針對某些具體業務Crash場景,如果是上線的前端頁面引起的,可以先對前端功能進行回滾,或者隱藏入口,等修復完畢後再上線,如果是客戶端的某些異常,比如資料庫升遷問題,主要是進行業務資料庫修復,快取檔案的刪除,賬號退出等操作,儘量只修復此業務的相關的資料。
- 網路降級
比如點評App,本身有CIP(公司內部自己研發的)長連線,接入騰訊雲的WNS長連線,UDP連線,HTTP短連線,如果CIP伺服器發生問題,可以及時切換到WNS連線,或者降級到Http連線,保證網路連線的成功率。
線上監控
Crash監控
Crash是對使用者來說是最糟糕的體驗,Crash日誌能夠記錄使用者閃退的崩潰日誌及堆疊,程式執行緒資訊,版本號,系統版本號,系統機型等有用資訊,收集的資訊越詳細,越能夠幫助解決崩潰,所以各大App都有自己崩潰日誌收集系統,或者也可以使用開源或者付費的第三方Crash收集平臺。
端到端成功率監控
端到端監控是從客戶端App發出請求時計時,到App收到資料資料的成功率,統計物件是:網路介面請求(包括H5頁面載入)的成敗和端到端延時情況。端到端監控SDK提供了監控上傳介面,呼叫SDK提供的監控API可以將資料上報到監控伺服器中。
整個端到端監控的可以在多個維度上做查詢端到端成功率、響應時間、訪問量的查詢,維度包括:返回碼、網路、版本、平臺、地區、運營商等。
使用者行為日誌
使用者行為日誌,主要記錄使用者在使用App過程中,點選元素的時間點,瀏覽時長,跳轉流程等,然後基於此進行使用者行為分析,大部分應用的推薦演算法都是基於使用者行為日誌來統計的。某些情況下,Crash分析需要查詢使用者的行為日誌,獲取使用者使用App的流程,幫助解決Crash等其他問題。
程式碼級日誌
程式碼級別的日誌,主要用來記錄一個App的效能相關的資料,比如頁面開啟速度,記憶體使用率,CPU佔用率,頁面的幀率,網路流量,請求錯誤統計等,通過收集相關的上下文資訊,優化App效能。
總結
雖然現在市面上第三方平臺已經很成熟,但是各大互聯公司都會自己開發線上監控系統,這樣保證資料安全,同時更加靈活。因為移動使用者的特點,在開發測試過程中,很難完全覆蓋所有使用者的全部場景,有些問題也只會在特定環境下才發生,所以通過線上監控平臺,通過日誌回撈等機制,及時獲取特定場景的上下文環境,結合資料分析,能夠及時發現問題,並後續修復,提高App的穩定性。
全文總結
本文主要從開發測試釋出等流程來介紹了一個App穩定性指標及監測方法,開發階段主要針對一些比較具體的指標,靜態檢查主要是掃描程式碼潛在問題,然後通過測試保證App功能的穩定性,線上降級主要是在儘量不發版的情況下,進行自修復,配合線上監控,資訊收集,使用者行為記錄,方便後續問題修復及優化。本文觀點是作者從事iOS開發的一些經驗,希望能對你有所幫助,觀點不同歡迎討論。
參考:
微信mars 的高效能日誌模組 xlog 基於 CADisplayLink 的 FPS 指示器詳解 今日頭條iOS客戶端啟動速度優化 微信讀書 iOS 效能優化總結 移動端監控體系之技術原理剖析 美團點評行動網路優化實踐 iOS 啟動連續閃退保護方案 微信 SQLite 資料庫修復實踐