1. 程式人生 > 實用技巧 >iOS優化篇之App啟動時間優化

iOS優化篇之App啟動時間優化

前言

最近由於體驗感覺我們的app啟動時間過長,因此做了APP的啟動優化。本次優化主要從三個方面來做了啟動時間的優化,main之後的耗時方法優化premain的+load方法優化二進位制重排優化premain時間

通常我們對於啟動時間的定義為從使用者點選app到看到首屏的時間。因此對於啟動時間優化就是遵循一個原則:儘早讓使用者看到首頁內容。

app啟動過程

iOS應用的啟動可分為pre-main階段和main()階段,pre-main階段為main函式執行之前所做的操作,main階段為main函式到首頁展示階段。其中系統做的事情為:

premain

  • 載入所有依賴的Mach-O檔案(遞迴呼叫Mach-O載入的方法)
  • 載入動態連結庫載入器dyld(dynamic loader)
  • 定位內部、外部指標引用,例如字串、函式等
  • 載入類擴充套件(Category)中的方法
  • C++靜態物件載入、呼叫ObjC的 +load 函式
  • 執行宣告為attribute((constructor))的C函式

main

  • 呼叫main()
  • 呼叫UIApplicationMain()
  • 呼叫applicationWillFinishLaunching

通常的premain階段優化即為刪減無用的類方法、減少+load操作、減少attribute((constructor))的C函式、減少啟動載入的動態庫。而main階段的優化為將啟動時非必要的操作延遲到首頁顯示之後載入、統計並優化耗時的方法、對於一些可以放在子執行緒的操作可以儘量不佔用主執行緒。

作為一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流群:413038000,不管你是大牛還是小白都歡迎入駐 ,分享BAT,阿里面試題、面試經驗,討論技術, 大家一起交流學習成長!

推薦閱讀

iOS開發——最新 BAT面試題合集(持續更新中)

一、耗時方法優化

1.統計啟動時的耗時方法

我們可以通過Instruments的TimeProfile來統計啟動時的主要方法耗時,Call Tree->Hide System Libraries過濾掉系統庫可以檢視主執行緒下方法的耗時。

也可以通過列印時間的方式來統計各個函式的耗時。

double launchTime = CFAbsoluteTimeGetCurrent();
[SDWebImageManager sharedManager];
NSLog(@"launchTime = %f秒", CFAbsoluteTimeGetCurrent() - launchTime);
複製程式碼

這一階段就是需要對啟動過程的業務邏輯進行梳理,確認哪些是可以延遲載入的,哪些可以放在子執行緒載入,以及哪些是可以懶載入處理的。同時對耗時比較嚴重的方法進行review並提出優化策略進行優化。

二、+load方法優化以及刪減不用的類

2.1 +load方法統計

同樣的我們可以通過Instruments來統計啟動時所有的+load方法,以及+load方法所用耗時

我們可以對不必要的+load方法進行優化,比如放在+initialize裡。不必要的+load進行刪減。

2.2 使用__attribute優化+load方法

由於在我們的工程中存在很多的+load方法,而其中一大部分為cell模板註冊的+load方法(我們的每一個cell對應一個模板,然後該模板對應一個字串,在啟動時所有的模板方法都在+load中註冊對應的字串即在字典中儲存字串和對應的cell模板,然後動態下發展示對應的cell)。

即存在這種場景,在啟動時需要大量的在+load中註冊key-value。

此時可以使用__attribute((used, section("__DATA,"#sectname" ")))的方式在編譯時寫入"TempSection"的DATA段一個字串。此字串為key:value格式的字典轉json。對應著key和value。

#ifndef ZYStoreListTemplateSectionName
#define ZYStoreListTemplateSectionName "ZYTempSection"
#endif

#define ZYStoreListTemplateDATA(sectname) __attribute((used, section("__DATA,"#sectname" ")))

#define ZYStoreListTemplateRegister(templatename,templateclass) \
class NSObject; char * k##templatename##_register ZYStoreListTemplateDATA(ZYTempSection) = "{ \""#templatename"\" : \""#templateclass"\"}";
/**
通過ZYStoreListTemplateRegister(key,classname)註冊處理模板的類名(類必須是ZYStoreListBaseTemplate子類)
【注意事項】
該方式通過__attribute屬性在編譯期間繫結註冊資訊,執行時讀取速度快,註冊資訊在首次觸發呼叫時讀取,不影響pre-main時間
該方式註冊時‘key’欄位中不支援除下劃線'_'以外的符號
【使用示例】
註冊處理模板的類名:@ZYStoreListTemplateRegister(baseTemp,ZYStoreListBaseTemplate)
**/
複製程式碼

在使用時@ZYStoreListTemplateRegister(baseTemp,ZYStoreListBaseTemplate)即為在編譯期間繫結註冊資訊。

讀取使用__attribute在編譯期間寫入的key-value字串。 關於__attribute詳情可以參考__attribute黑魔法

#pragma mark - 第一次使用時讀取ZYStoreListTemplateSectionName的__DATA所有資料
+ (void)readTemplateDataFromMachO {
    //1.根據符號找到所在的mach-o檔案資訊
    Dl_info info;
    dladdr((__bridge void *)[self class], &info);

    //2.讀取__DATA中自定義的ZYStoreListTemplateSectionName資料
    #ifndef __LP64__
        const struct mach_header *mhp = (struct mach_header*)info.dli_fbase;
        unsigned long templateSize = 0;
        uint32_t *templateMemory = (uint32_t*)getsectiondata(mhp, "__DATA", ZYStoreListTemplateSectionName, &templateSize);
    #else /* defined(__LP64__) */
        const struct mach_header_64 *mhp = (struct mach_header_64*)info.dli_fbase;
        unsigned long templateSize = 0;
        uint64_t *templateMemory = (uint64_t*)getsectiondata(mhp, "__DATA", ZYStoreListTemplateSectionName, &templateSize);

    #endif /* defined(__LP64__) */

    //3.遍歷ZYStoreListTemplateSectionName中的協議資料
    unsigned long counter = templateSize/sizeof(void*);
    for(int idx = 0; idx < counter; ++idx){
        char *string = (char*)templateMemory[idx];
        NSString *str = [NSString stringWithUTF8String:string];
        if(!str)continue;

        //NSLog(@"config = %@", str);
        NSData *jsonData = [str dataUsingEncoding:NSUTF8StringEncoding];
        NSError *error = nil;
        id json = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
        if (!error) {
            if ([json isKindOfClass:[NSDictionary class]] && [json allKeys].count) {
                NSString *templatesName = [json allKeys][0];
                NSString *templatesClass  = [json allValues][0];
                if (templatesName && templatesClass) {
                    [self registerTemplateName:templatesName templateClass:NSClassFromString(templatesClass)];
                }
            }
        }
    }
}
複製程式碼

這樣我們就可以優化大量的重複+load方法。而且使用__attribute屬性為編譯期間繫結註冊資訊,執行時讀取速度快,註冊資訊在首次觸發呼叫時讀取,不影響pre-main時間。

三、二進位制重排

自從抖音團隊分享了這篇 抖音研發實踐:基於二進位制檔案重排的解決方案 APP啟動速度提升超15% 啟動優化文章後 , 二進位制重排優化 pre-main 階段的啟動時間自此被大家廣為流傳。

當程序訪問一個虛擬記憶體Page而對應的實體記憶體卻不存在時,會觸發一次 缺頁中斷(Page Fault)。

二進位制重排,主要是優化我們啟動時需要的函式非常分散在各個頁,啟動時就會多次Page Fault造成時間的損耗。

3.1 獲取Order File

本次主要是通過Clang靜態插樁的方式,獲取到所有的啟動時呼叫的函式符號,匯出為OrderFile。

Target -> Build Setting -> Custom Complier Flags -> Other C Flags新增 -fsanitize-coverage=func,trace-pc-guard引數

然後實現hook程式碼獲取所有啟動的函式符號。啟動後在首頁顯示之後,可以通過觸發下邊-getAllSymbols方法獲取所有符號。

#import "dlfcn.h"
#import <libkern/OSAtomic.h>
複製程式碼
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                         uint32_t *stop) {
    static uint64_t N;  // Counter for the guards.
    if (start == stop || *start) return;  // Initialize only once.
    printf("INIT: %p %p\n", start, stop);
    for (uint32_t *x = start; x < stop; x++)
        *x = ++N;  // Guards should start from 1.
}

//原子佇列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
static BOOL isEnd = NO;
//定義符號結構體
typedef struct{
    void * pc;
    void * next;
}SymbolNode;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    //if (!*guard) return;  // Duplicate the guard check.
    if (isEnd) {
        return;
    }
    void *PC = __builtin_return_address(0);

    SymbolNode * node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC,NULL};

    //入隊
    // offsetof 用在這裡是為了入隊新增下一個節點找到 前一個節點next指標的位置
    OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}

- (void)getAllSymbols {
    isEnd = YES;
    NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
    while (true) {
        //offsetof 就是針對某個結構體找到某個屬性相對這個結構體的偏移量
        SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
        if (node == NULL) break;
        Dl_info info;
        dladdr(node->pc, &info);

        NSString * name = @(info.dli_sname);

        // 新增 _
        BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
        NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];

        //去重
        if (![symbolNames containsObject:symbolName]) {
            [symbolNames addObject:symbolName];
        }
    }

    //取反
    NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
    NSLog(@"%@",symbolAry);

    //將結果寫入到檔案
    NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"linkSymbols.order"];
    NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
    BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    if (result) {
        NSLog(@"linkSymbol result %@",filePath);
    }else{
        NSLog(@"linkSymbol result檔案寫入出錯");
    }
}
複製程式碼

由於我們的工程為pod工程,如果只在主工程裡新增other c flags只能獲取到主工程層下的所有啟動函式,如果要獲取所有的包含依賴pod中啟動函式符號則需要在每一個pod target設定other c flags引數。

我們可以通過新增pod指令碼來對每一個target新增other c flags引數。

在podfile最後新增指令碼來為每一個target新增編譯引數。注意可以過濾掉Debug環境才載入的庫。

post_install do |installer|
    pods_project = installer.pods_project
    build_settings = Hash[
    'OTHER_CFLAGS' => '-fsanitize-coverage=func,trace-pc-guard'
#    ,'OTHER_SWIFT_FLAGS' => '-sanitize=undefined -sanitize-coverage=func'
    ]

    pods_project.targets.each do |target|
#      if !target.name.include?('Pods-')
      if !target.name.include?('Pods-') and target.name != 'LookinServer' and target.name != 'DoraemonKit' and target.name != 'DoraemonKit-DoraemonKit'
        # 修改build_settings
        target.build_configurations.each do |config|
            build_settings.each do |pair|
                key = pair[0]
                value = pair[1]
                if config.build_settings[key].nil?
                    config.build_settings[key] = ['']
                end
                if !config.build_settings[key].include?(value)
                    config.build_settings[key] << value
                end
            end
        end

        puts '[Other C Flags]: ' + target.name + ' success.'
      end
    end
end
複製程式碼

重新install之後所有的pod target都會新增上other c flags引數。然後就可以獲取到所有的函式符號(注意如果是二進位制庫則還是會獲取不到)。

3.1 設定Order File

通過objc的原始碼可以看到objc也是通過設定order file設定編譯順序的。

我們可以在主工程的Target -> Build Setting -> Linking -> Order File新增上述步驟匯出的函式符號列表linkSymbols.order。

$(SRCROOT)/linkSymbols.order 這裡可以根據根目錄路徑然後尋找,不必把orderfile新增到工程bundle裡。如果新增到工程裡則會被打包到ipa裡。我們可以只是放在工程資料夾下,只在編譯的時候根據路徑引用就可以了。

設定完orderfile之後我們可以通過設定write link map file屬性為YES來找到編譯時生成的符號($Project)-LinkMap-normal-arm64.txt。 修改完畢後 clean 一下 , 執行工程 , Products - show in finder, 找到 macho 的上上層目錄。 找到結尾為arm64.txt的檔案並開啟。

Intermediates -> project_ios.build -> Debug-iphoneos -> project_ios.build -> project_ios-LinkMap-normal-arm64.txt

($Project)-LinkMap-normal-arm64.txt檔案裡在#Symbols之後為函式符號連結的順序,可以驗證一下重排是否成功。

最後可以看一下我們重排之後的效果,Instruments下System Trace下Page Fault的次數和耗時:

總結

最後在看一下本次優化的效果。圖中為iPhone6s Plus重啟後第一次啟動的優化前後截圖。

參考文章:

iOS 優化篇 - 啟動優化之Clang插樁實現二進位制重排

iOS App啟動優化

作為一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流群:413038000,不管你是大牛還是小白都歡迎入駐 ,分享BAT,阿里面試題、面試經驗,討論技術, 大家一起交流學習成長!

推薦閱讀

iOS開發——最新 BAT面試題合集(持續更新中)

作者:橘子不酸丶
連結:https://juejin.im/post/6861917375382929415
來源:掘金