ios專案優化
前言
在專案業務趨於穩定的時候,開發完迭代需求後,我們可能會無所適從,進入一段空白期,但是對於攻城獅來說閒暇不是件好事,所以我們可能總想學點什麼,卻又沒有頭緒。這個時候我們就可以考慮完善和優化我們的專案了。從中可以運用到一些底層RunLoop或者Runtime的知識,熟能生巧總是沒錯的��
- 結構與架構
1.1 結構
這裡說的結構大概有兩點:1.檔案目錄分類 2.第三方庫管理
1.1.1 檔案目錄分類
為了方便管理,最好將Xcode中的專案展示目錄與實際的儲存目錄保持一致
此外,一般按業務模組分類,一級目錄可以按照MVC格式,也可以按照業務模組劃分
用最普遍的Model View Controller架構舉例
- 以一個基礎的電商專案來解釋,4個tabbarItem對應著四大模組,首頁、分類、購物車、個人中心,往下每個還可以細分為MVC+Session層
- 按專案架構來分 <br/>
最外層為Model、View、Controller、Session層,內部才是業務模組
- 這一塊無需多言,兩者配合使用即可
1.1.2 第三方庫
個人建議:時間允許的話自己多造造輪子,風險可控,好維護
如非必要,儘量不要直接使用已經編譯好的三方庫(framework/.a),最好自己去編譯三方庫(安全要求)
管理方面有三種方式:
- 手動管理
手動維護各種第三方庫,適合於已經趨於穩定、極少Bug的三方庫 CocoaPods
Carthage
這裡更推薦使用Carthage,因為它對專案的侵入性最小,而且是去中心化管理,不需要等待漫長的pod update / install過程.不過各有各的好處,使用CocoaPods簡單粗暴,基本不需要額外設定什麼,看自己需求吧
1.2 專案架構
專案邏輯基本都圍繞了一條主線時,我們採用MVC已經可以很好的滿足我們的需求,但是當業務邏輯日漸複雜的時候,我們單純的採用Model View Controller這種程式設計模式已經不能很好的將業務邏輯與程式碼分離開,也就是解耦Decouple.
為了更好的將ViewController解耦,產生了Model View ViewModel這種程式設計模式,ViewModel層其實做了一層Model與ViewController中間的橋接,有利有弊,該模式會產生很多膠水程式碼,但是配合響應式程式設計框架(如 ReactiveCocoa或者RxSwift),可以做到最大程度的解耦。,適合與自己實際專案業務複雜程度的模式才是好的程式設計模式。
引申 : <關於元件化程式設計>
如果專案業務很複雜、很多業務元件都通用,可以採用元件化程式設計,常用的一種就是採用CocoaPods將專案業務模組分拆成各種pod庫,使用什麼模組直接整合就好,再配合MVVM和響應式程式設計框架(如 ReactiveCocoa或者RxSwift),可以做到最大程度的解耦。
- 崩潰&效能調優
當專案已經完成業務模組上線後,我們就可以開始考慮關於如何提高App的使用者體驗,舉例一下幾個問題:
- 程式碼規範,定期code review了嗎
- 複雜列表的滾動時FPS可以保持在60幀左右嗎?
- 頁面載入渲染的耗時能不能進一步減小?
- 網路快取有做嗎,UIWebView / WKWebView的常用靜態資源做快取了嗎
- App的啟動時間可以在保持最小業務邏輯的同時再減小一點嗎?
2.1 UITest & UnitTest
當開發完新需求的時候,在提測之前我們最好編寫下UITest和UnitTest,覆蓋主業務流程即可,可以提高我們的提測質量,減小一些可見的Bug,再加上冒煙用例,最大程度上提高我們提測的質量(成為KPI之王 - ��),而且上線之後這些單元測試和UITest元件的指令碼可以配合自動化測試定期進行迴歸測試,提高App的質量,減少崩潰率
2.2 NullSafe
絕大多數情況下,我們向NSNull物件傳送訊息,都會產生崩潰,NSNull物件常見於後臺返回資料中可能會有null欄位,很多JSON庫都會轉成NSNull物件,如下情況就會產生崩潰:
id obj = [NSNull null];
NSLog(@”%@”, [obj stringValue]);
但是向nil物件傳送訊息則不會產生崩潰,這些可以參考NullSafe中的處理方法,重寫
- (NSMethodSignature )methodSignatureForSelector:(SEL)aSelector和- (void)forwardInvocation:(NSInvocation )anInvocation這兩個方法將沒能力處理訊息的方法簽名轉發給nil物件則不會產生崩潰
此外,常見的崩潰比如,NSArray取值越界,NSDictionary傳了nil物件,這些問題產生的崩潰可以使用Runtime中的Method Swizzle,將原生的方法hook掉,如下:
@implementation NSMutableDictionary (NullSafe)
(void)swizzleMethod:(SEL)origSelector withMethod:(SEL)newSelector
{
Class class = [self class];Method originalMethod = class_getInstanceMethod(class, origSelector);
Method swizzledMethod = class_getInstanceMethod(class, newSelector);BOOL didAddMethod = class_addMethod(class,
origSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {
class_replaceMethod(class,
newSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}(void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
id obj = [[self alloc] init];
[obj swizzleMethod:@selector(setObject:forKey:) withMethod:@selector(safe_setObject:forKey:)];});
}
- (void)safe_setObject:(id)value forKey:(NSString *)key {
if (value) {
[self safe_setObject:value forKey:key];
}else {
NullSafeLogFormatter(@”[NSMutableDictionary setObject: forKey:], Object cannot be nil”)
}
}
@end
這種解決方法可以避免諸如陣列取值越界、字典傳空值、removeObjectAtIndex等錯誤,如下的崩潰就可以避免:
id obj = nil;
NSMutableDictionary *m_dict = [NSMutableDictionary dictionary];
[dict setObject:obj forKey:@”666”];
2.2 監控系統
目前大多數App都集成了第三方統計庫,常見的比如騰訊的Bugly、友盟的U-App等等,在這介紹下如何自建效能監控庫
可以使用PLCrashReporter或者KSCrash庫解析崩潰日誌並符號化,再上傳至後臺,自己做收集加統計,順帶提一下,我們使用了PLCrashReporter,後端使用了Laravel,很方便的開發了一套簡單的崩潰及各種效能引數收集的系統,所以如果要自建,可以考慮這個組合
CPU、記憶體、FPS記錄及儲存
CPU
、FPS
、Memory佔用
網上都有現成的方法獲取到這三個引數,這三個屬於效能監控,可以定時記錄,比如10S記錄一次到本地檔案中,每次開啟App上傳昨天的日誌。這就要自己制定日誌上傳的策略了卡頓日誌收集
使用者能感受到的卡頓一般都是因為在主執行緒做了耗時操作,舉幾個會發生卡頓的例子:- viewDidLoad中 for迴圈中初始化10000個UILabel例項
- cellForRow代理方法中手動休眠usleep(100*1000)
如何監聽這些事件呢?檢視下原始碼,核心方法CFRunLoopRun簡化後的邏輯如下:
int32_t __CFRunLoopRun()
{
//通知即將進入runloop
__CFRunLoopDoObservers(KCFRunLoopEntry);do
{
// 通知將要處理timer和source
__CFRunLoopDoObservers(kCFRunLoopBeforeTimers);
__CFRunLoopDoObservers(kCFRunLoopBeforeSources);__CFRunLoopDoBlocks(); //處理非延遲的主執行緒呼叫 __CFRunLoopDoSource0(); //處理UIEvent事件 //GCD dispatch main queue CheckIfExistMessagesInMainDispatchQueue(); // 即將進入休眠 __CFRunLoopDoObservers(kCFRunLoopBeforeWaiting); // 等待核心mach_msg事件 mach_port_t wakeUpPort = SleepAndWaitForWakingUpPorts(); // Zzz... // 從等待中醒來 __CFRunLoopDoObservers(kCFRunLoopAfterWaiting); // 處理因timer的喚醒 if (wakeUpPort == timerPort) __CFRunLoopDoTimers(); // 處理非同步方法喚醒,如dispatch_async else if (wakeUpPort == mainDispatchQueuePort) __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__() // UI重新整理,動畫顯示 else __CFRunLoopDoSource1(); // 再次確保是否有同步的方法需要呼叫 __CFRunLoopDoBlocks();
} while (!stop && !timeout);
//通知即將退出runloop
__CFRunLoopDoObservers(CFRunLoopExit);
}
我們可以看到在kCFRunLoopBeforeSources和kCFRunLoopBeforeWaiting等待時間過長即可判定為卡頓,具體怎麼算作卡頓,我們都知道FPS為一秒60幀左右最好,FPS即為Frames Per Second,嚴格意義上一秒60幀算流暢,也就是一幀需要1s/60 = 16.6ms,考慮會有其他的一些事件影響,可以用連續幾次50ms或者單次耗時過長判定為卡頓。判定為卡頓之後,我們可以使用PLCrashReporter或者KSCrash生成日誌記錄,可以儲存到本地
我們可以使用CFRunLoopObserverRef來實時獲取NSRunLoop狀態值的變化,一下為一個樣例:
@interface LagCollectionTool ()
{
int timeoutCount;
CFRunLoopObserverRef observer;
BOOL observeLag;
@public
dispatch_semaphore_t semaphore;
CFRunLoopActivity activity;
}
@end
@implementation LagCollectionTool
(instancetype)shareInstance {
static dispatch_once_t onceToken;
static LagCollectionTool *tool = nil;
dispatch_once(&onceToken, ^{
tool = [[LagCollectionTool alloc] init];
});
return tool;
}(void)lanuch {
if (observer)
return;// 訊號
semaphore = dispatch_semaphore_create(0);// 註冊RunLoop狀態觀察
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);// 在子執行緒監控時長
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES)
{
long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
if (st != 0)
{
if (!observer)
{
timeoutCount = 0;
semaphore = 0;
activity = 0;
return;
}if (activity==kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting) { timeoutCount++; // NSLog(@"%d", timeoutCount); if (timeoutCount < 5) continue; NSLog(@"----------------卡爆了!----------------"); PLCrashReporterConfig *config = [[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll]; PLCrashReporter *crashReporter = [[PLCrashReporter alloc] initWithConfiguration:config]; NSData *data = [crashReporter generateLiveReport]; PLCrashReport *reporter = [[PLCrashReport alloc] initWithData:data error:NULL]; NSString *report = [PLCrashReportTextFormatter stringValueForCrashReport:reporter withTextFormat:PLCrashReportTextFormatiOS]; //上傳卡頓日誌檔案 } } timeoutCount = 0; }
});
}
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
LagCollectionTool tool = (__bridge LagCollectionTool )info;
tool->activity = activity;
dispatch_semaphore_t semaphore = tool->semaphore;
dispatch_semaphore_signal(semaphore);
}
- 從容崩潰,上傳崩潰日誌
通過使用NSSetUncaughtExceptionHandler註冊自己的異常處理回撥,發生崩潰時讓程式顯示的從容一點,不會直接閃退,可以彈出自己的崩潰異常介面,可以參考Bilibili的介面,比如說前方遇到高能反應之類,程式需要重啟之類的,不會讓使用者感覺到很突兀得閃退了,也可以在收到崩潰日誌後手動維護Runloop,下面是一個樣例:
// 1. 註冊ExceptionHandler
(void)installUncaughtExceptionHandler {
NSSetUncaughtExceptionHandler(&HandleException);signal(SIGHUP, SignalHandler);
signal(SIGINT, SignalHandler);
signal(SIGQUIT, SignalHandler);signal(SIGABRT, SignalHandler);
signal(SIGILL, SignalHandler);
signal(SIGSEGV, SignalHandler);
signal(SIGFPE, SignalHandler);
signal(SIGBUS, SignalHandler);
signal(SIGPIPE, SignalHandler);
}
// 2. 處理崩潰資訊
void SignalHandler(int signal) {
// 1. 獲取呼叫棧
// 2. 處理異常
// 3. App保活
BOOL isContiune = TRUE; // 是否要保活
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop);
while (isContiune) {
for (NSString *mode in (__bridge NSArray *)allModes) {
CFRunLoopRunInMode((CFStringRef)mode, 0.001, true);
}
}
CFRelease(allModes);
signal(SIGABRT, SIG_DFL);
signal(SIGILL, SIG_DFL);
signal(SIGSEGV, SIG_DFL);
signal(SIGFPE, SIG_DFL);
signal(SIGBUS, SIG_DFL);
signal(SIGPIPE, SIG_DFL);
}
延伸:
監控系統不光侷限於效能、崩潰率,也可以將統計策略延伸到網路請求連通率或者一些業務層面,更好的把控App的質量
2.3 效能調優&App體驗優化
前面我們介紹瞭如何有效地減少崩潰及優雅地處理崩潰,下面來看看解決效能問題需要注意幾點。
2.3.1 懶載入的利與弊
懶載入適用於一些可能不會載入的頁面,比如彈框、空資料頁面之類的,使用得當可以避免記憶體暴漲,使用不好,比如在必定會彈出的頁面中使用懶載入可能會在增加頁面響應時間,所以使用懶載入一定要注意使用場景,避免產生副作用
2.3.2 避免使用重繪
重寫 drawRect 或者 drawReact:inContext方法會預設建立一個圖層上下文,圖形上下文所需要的記憶體為圖層寬 * 圖層高 * 4位元組,圖層每次進行重繪時都需要抹掉記憶體重新分配,會產生巨大的效能開銷
UIView類實際上是對CALayer的封裝,關於UI層面的效能優化有很多東西,可以看看iOS CoreAnimation 核心動畫高階程式設計中關於圖層效能的一章
2.3.3 App體驗優化
談起App體驗優化,其實這是個玄學,你需要在效能與體驗上找到一個平衡點,常見的糟糕的體驗包括:
- UITableViewCell 使用不當造成滑動卡頓
- 大量cornerRadius和maskToBounds一起使用造成的離屏渲染造成的效能問題
- 網路請求操作沒有任何狀態展示,比如載入框、按鈕置灰等
- 網路請求沒有進行快取
這些問題只是App的細節,但是從細節入手才能更顯的專業~
我們重點談談網路請求優化:
2.3.3.1 手動維護DNS解析
用www.manoboo.com來舉例,通過域名訪問首先會尋找DNS解析伺服器,然後才會對映到自己的伺服器IP上。我們直接使用IP請求介面訪問網路資源,可以避免很多問題,但是有利有弊,需要自己維護DNS對映,在直接比如:
- 運營商DNS流量劫持,具體表現在你的H5網頁莫名其妙的被加了廣告(關於這個問題,也可以做域名白名單,非本域名資源禁止請求,或者H5方面做處理),也有
- DNS服務商(如萬網)解析出現故障造成的大批量使用者無法正常使用App,按天計算。。
- DNS解析延遲過高造成的載入超時導致使用者體驗差
此時我們可以考慮自己手動做DNS解析,簡單點可以在網路請求時將URL中的域名替換掉,或者在Objective-C中實現NSURLProtocol(Swift中為URLProtocol)的子類對應的方法,做全域性替換URL
不過也有些弊端:
- 需要手動維護DNS解析表,解析出錯後需要一套容錯方案,保證介面的暢通
- HTTP請求可以通過設定header中的host欄位進行網路請求,HTTPS請求還需額外配置,受限於篇幅原因,詳細的弊端和解決方法可以閱讀下這篇文章HTTPDNS在iOS中的實踐
2.3.3.2 網路請求快取優化
適用場景:一些更新頻率較低的場景:比如個人中心
關於網路請求快取,App端的網路請求對面到後端更多的是增刪改查,這個方面需要和後端配合,是否資源改變即後端是否需要重新檢索或修改資料,這個時候我們就需要一個value比如時間戳Last-Modified或者標識ETag來告知伺服器自己當前的資源標記,目前常用的策略為:
以時間戳Last-Modified為例
- App端第一次請求介面,服務端返回成功,HTTP Status為200,並且在返回的Header中用Last-Modified表明伺服器中該資源最後被修改的時間
- App端第二次請求該介面,Header中傳遞本地快取的Header中的Last-Modified,如果伺服器端的資源並未發生變化,則會返回HTTP Status為304,我們直接可以使用本地的快取,傳輸流量更少,相對而言,使用者的等待時間會更短
注:
量化而非猜測,這是我們開發過程中的一個原則,當遇到效能問題時,我們可以使用instruments來測量實際執行過程中的各個引數,找到問題所在(建議真機除錯而不是模擬器,真機才能更高的還原效能問題)
instruments工具常見功能
點選右上方加號還有更多工具+
instruments中工具都有各自的用處,比如可以使用Leask檢視App執行過程中的記憶體洩露,使用TimeProfiler檢視App啟動耗時或者方法耗時,或者偷懶一點可以使用CACurrentMediaTime()兩次的差值計算方法耗時
結語
受限於篇幅原因,有些點也是一概而過,iOS中如何優化一個專案,這是一門很深的學問,知識點範圍很廣,我也只是涉及到了一部分,學無止境嘛,完成工作的同時我們也可以做一個酷酷的程式設計師,學學Haskell去體驗下函數語言程式設計思維的樂趣,或者搞搞LLDB更好得做個Debugger
最後,非常感謝您閱讀這篇文章,如果我的文章產生了幫助,可以給一個小小的紅心☺️,歡迎去我的小站www.manoboo.com拍磚啦,我會努力創作更好的文章
文中引用到的文章如下:
- CocoaChina - iOS 實時卡頓監控
- iOS Core Animation: Advanced Techniques 中文譯本
文中所涉及到的開源庫如下:
- PLCrashReporter
- KSCrash
- MBNullSafe ManoBoo編寫的NullSafe庫,會進一步拓展功能