1. 程式人生 > >ios專案優化

ios專案優化

前言

在專案業務趨於穩定的時候,開發完迭代需求後,我們可能會無所適從,進入一段空白期,但是對於攻城獅來說閒暇不是件好事,所以我們可能總想學點什麼,卻又沒有頭緒。這個時候我們就可以考慮完善和優化我們的專案了。從中可以運用到一些底層RunLoop或者Runtime的知識,熟能生巧總是沒錯的��

  1. 結構與架構

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),可以做到最大程度的解耦。

  1. 崩潰&效能調優

當專案已經完成業務模組上線後,我們就可以開始考慮關於如何提高App的使用者體驗,舉例一下幾個問題:

  1. 程式碼規範,定期code review了嗎
  2. 複雜列表的滾動時FPS可以保持在60幀左右嗎?
  3. 頁面載入渲染的耗時能不能進一步減小?
  4. 網路快取有做嗎,UIWebView / WKWebView的常用靜態資源做快取了嗎
  5. 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記錄及儲存
    CPUFPSMemory佔用網上都有現成的方法獲取到這三個引數,這三個屬於效能監控,可以定時記錄,比如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庫,會進一步拓展功能