App啟動時間優(yōu)化

背景

一個項(xiàng)目做的時間長了阎姥,啟動流程往往容易雜亂,庫也用的越來越多鸽捻,APP 的啟動時間也會慢慢變長呼巴。本次將針對 iOS APP 的啟動時間優(yōu)化一波氨淌。

通常針對一個技術(shù)點(diǎn)做優(yōu)化的時候,都要先了解清楚這個技術(shù)點(diǎn)有哪些流程伊磺,優(yōu)化的方向往往是減少流程的數(shù)量盛正,以及減少每個流程的消耗。

本次優(yōu)化從結(jié)果上來看屑埋,main 階段的優(yōu)化效果最顯著豪筝,尤其是啟動時的一些 IO 操作處理,對啟動時間的減少有很大作用摘能。多線程啟動的設(shè)計和驗(yàn)證最有意思续崖,但是在實(shí)踐上由于我們業(yè)務(wù)本身的原因,只開了額外一個子線程來并行啟動团搞,且僅在子線程做了少量的獨(dú)立操作严望,主要還是我們的業(yè)務(wù)之間耦合性太強(qiáng)了,不太適合拆分到子線程逻恐。

一般說來像吻,pre-main階段的定義為 APP 開始啟動到系統(tǒng)調(diào)用 main 函數(shù)這一段時間;main 階段則代表從main函數(shù)入口到主 UI 框架的 viewDidAppear 函數(shù)調(diào)用的這一段時間复隆。(本文后續(xù) main 階段的時間統(tǒng)計都用 viewDidAppear 作為基準(zhǔn)而非的applicationWillFinishLaunching)

本文前半部分講原理(內(nèi)容基本是從網(wǎng)上借鑒/摘錄)拨匆,后半部分講實(shí)踐,pre-main階段的原理比較難理解挽拂,不過實(shí)踐倒是根據(jù)結(jié)論直接做就好了惭每。

App啟動過程

解析Info.plist;加載相關(guān)信息亏栈,例如閃屏台腥;沙箱建立、權(quán)限檢查

Mach-O加載绒北;如果是胖二進(jìn)制文件黎侈,尋找合適當(dāng)前CPU架構(gòu)的部分;加載所有依賴的Mach-O文件(遞歸調(diào)用Mach-O加載的方法)

定位內(nèi)部镇饮、外部指針引用蜓竹,例如字符串、函數(shù)等储藐;執(zhí)行聲明為attribute((constructor))的C函數(shù)俱济;加載類擴(kuò)展(Category)中的方法;C++靜態(tài)對象加載钙勃、調(diào)用ObjC的 +load 函數(shù)

程序執(zhí)行:調(diào)用main()蛛碌;調(diào)用UIApplicationMain();調(diào)用applicationWillFinishLaunching

換成另一個說法就是:

App 開始啟動后辖源,系統(tǒng)首先加載可執(zhí)行文件(自身 App 的所有.o文件的集合)蔚携,然后加載動態(tài)鏈接器dyld希太,dyld 是一個專門用來加載動態(tài)鏈接庫的庫。 執(zhí)行從 dyld 開始酝蜒,dyld 從可執(zhí)行文件的依賴開始誊辉,遞歸加載所有的依賴動態(tài)鏈接庫。

動態(tài)鏈接庫包括:iOS 中用到的所有系統(tǒng) framework亡脑,加載 OC runtime 方法的libobjc堕澄,系統(tǒng)級別的libSystem,例如libdispatch(GCD)和libsystem_blocks (Block)霉咨。

可執(zhí)行文件的內(nèi)核流程

如圖蛙紫,當(dāng)啟動一個應(yīng)用程序時,系統(tǒng)最后會根據(jù)你的行為調(diào)用兩個函數(shù)途戒,fork和execve坑傅。fork 功能創(chuàng)建一個進(jìn)程;execve 功能加載和運(yùn)行程序喷斋。這里有多個不同的功能唁毒,比如execl、execv和exect继准,每個功能提供了不同傳參和環(huán)境變量的方法到程序中枉证。在 OSX 中,每個這些其他的exec路徑最終調(diào)用了內(nèi)核路徑execve移必。

執(zhí)行exec系統(tǒng)調(diào)用,一般都是這樣毡鉴,用fork()函數(shù)新建立一個進(jìn)程崔泵,然后讓進(jìn)程去執(zhí)行exec調(diào)用。我們知道猪瞬,在fork()建立新進(jìn)程之后憎瘸,父進(jìn)程與子進(jìn)程共享代碼段(TEXT),但數(shù)據(jù)空間(DATA)是分開的陈瘦,但父進(jìn)程會把自己數(shù)據(jù)空間的內(nèi)容copy到子進(jìn)程中去幌甘,還有上下文也會copy到子進(jìn)程中去。

為了提高效率痊项,采用一種寫時copy的策略锅风,即創(chuàng)建子進(jìn)程的時候,并不copy父進(jìn)程的地址空間鞍泉,父子進(jìn)程擁有共同的地址空間皱埠,只有當(dāng)子進(jìn)程需要寫入數(shù)據(jù)時(如向緩沖區(qū)寫入數(shù)據(jù)),這時候會復(fù)制地址空間咖驮,復(fù)制緩沖區(qū)到子進(jìn)程中去边器。從而父子進(jìn)程擁有獨(dú)立的地址空間训枢。而對于fork()之后執(zhí)行exec之前,這種策略能夠很好的提高效率忘巧,如果一開始就copy恒界,那么exec之后,子進(jìn)程(此處可以說是父進(jìn)程也可以說是子進(jìn)程砚嘴,因?yàn)閭z進(jìn)程的數(shù)據(jù)此時是一樣的)的數(shù)據(jù)會被放棄仗处,被新的進(jìn)程所代替。見下圖:

啟動時間的分布枣宫,pre-main和main階段原理淺析

rebase修復(fù)的是指向當(dāng)前鏡像內(nèi)部的資源指針婆誓; 而bind指向的是鏡像外部的資源指針。

rebase步驟先進(jìn)行也颤,需要把鏡像讀入內(nèi)存洋幻,并以page為單位進(jìn)行加密驗(yàn)證,保證不會被篡改翅娶,所以這一步的瓶頸在IO文留。bind在其后進(jìn)行,由于要查詢符號表竭沫,來指向跨鏡像的資源燥翅,加上在rebase階段,鏡像已被讀入和加密驗(yàn)證蜕提,所以這一步的瓶頸在于CPU計算森书。這兩個步驟在下面會詳細(xì)闡述。

pre-main過程

main過程

一些概念

什么是dyld?

動態(tài)鏈接庫的加載過程主要由dyld來完成谎势,dyld是蘋果的動態(tài)鏈接器凛膏。

系統(tǒng)先讀取App的可執(zhí)行文件(Mach-O文件),從里面獲得dyld的路徑脏榆,然后加載dyld猖毫,dyld去初始化運(yùn)行環(huán)境,開啟緩存策略须喂,加載程序相關(guān)依賴庫(其中也包含我們的可執(zhí)行文件)吁断,并對這些庫進(jìn)行鏈接,最后調(diào)用每個依賴庫的初始化方法坞生,在這一步仔役,runtime被初始化。當(dāng)所有依賴庫的初始化后恨胚,輪到最后一位(程序可執(zhí)行文件)進(jìn)行初始化骂因,在這時runtime會對項(xiàng)目中所有類進(jìn)行類結(jié)構(gòu)初始化,然后調(diào)用所有的load方法赃泡。最后dyld返回main函數(shù)地址寒波,main函數(shù)被調(diào)用乘盼,我們便來到了熟悉的程序入口。

當(dāng)加載一個 Mach-O 文件 (一個可執(zhí)行文件或者一個庫) 時俄烁,動態(tài)鏈接器首先會檢查共享緩存看看是否存在其中绸栅,如果存在,那么就直接從共享緩存中拿出來使用页屠。每一個進(jìn)程都把這個共享緩存映射到了自己的地址空間中粹胯。這個方法大大優(yōu)化了 OS X 和 iOS 上程序的啟動時間。

問題:測試發(fā)現(xiàn)辰企,由于手機(jī)從開機(jī)后风纠,連續(xù)兩次啟動同一個APP的pre-main實(shí)際時間的差值比較大,這一步可以在真機(jī)上復(fù)現(xiàn)牢贸,那么這兩次啟動pre-main的時間差值竹观,是跟系統(tǒng)的framework關(guān)系比較大,還是跟APP自身依賴的第三方framework關(guān)系比較大呢潜索?

Mach-O 鏡像文件

Mach-O被劃分成一些segement臭增,每個 segement 又被劃分成一些section。segment 的名字都是大寫的竹习,且空間大小為頁的整數(shù)誊抛。頁的大小跟硬件有關(guān),在arm64架構(gòu)一頁是16KB整陌,其余為4KB拗窃。

section 雖然沒有整數(shù)倍頁大小的限制,但是 section 之間不會有重疊蔓榄。幾乎所有 Mach-O 都包含這三個段(segment):__TEXT并炮,__DATA和__LINKEDIT。

__TEXT包含Mach header甥郑,被執(zhí)行的代碼和只讀常量(如C 字符串)。只讀可執(zhí)行(r-x)荤西。

__DATA包含全局變量澜搅,靜態(tài)變量等⌒靶浚可讀寫(rw-)勉躺。

__LINKEDIT包含了加載程序的『元數(shù)據(jù)』,比如函數(shù)的名稱和地址觅丰。只讀(r–)饵溅。

ASLR(Address Space Layout Randomization):地址空間布局隨機(jī)化,鏡像會在隨機(jī)的地址上加載妇萄。

傳統(tǒng)方式下蜕企,進(jìn)程每次啟動采用的都是固定可預(yù)見的方式咬荷,這意味著一個給定的程序在給定的架構(gòu)上的進(jìn)程初始虛擬內(nèi)存都是基本一致的,而且在進(jìn)程正常運(yùn)行的生命周期中轻掩,內(nèi)存中的地址分布具有非常強(qiáng)的可預(yù)測性幸乒,這給了黑客很大的施展空間(代碼注入,重寫內(nèi)存)唇牧;

如果采用ASLR罕扎,進(jìn)程每次啟動,地址空間都會被簡單地隨機(jī)化丐重,但是只是偏移腔召,不是攪亂。大體布局——程序文本扮惦、數(shù)據(jù)和庫是一樣的臀蛛,但是具體的地址都不同了,可以阻擋黑客對地址的猜測 径缅。

代碼簽名:可能我們認(rèn)為 Xcode 會把整個文件都做加密 hash 并用做數(shù)字簽名掺栅。其實(shí)為了在運(yùn)行時驗(yàn)證 Mach-O 文件的簽名,并不是每次重復(fù)讀入整個文件纳猪,而是把每頁內(nèi)容都生成一個單獨(dú)的加密散列值氧卧,并存儲在 __LINKEDIT 中。這使得文件每頁的內(nèi)容都能及時被校驗(yàn)確并保不被篡改氏堤。

關(guān)于虛擬內(nèi)存

我們開發(fā)者開發(fā)過程中所接觸到的內(nèi)存均為虛擬內(nèi)存沙绝,虛擬內(nèi)存使App認(rèn)為它擁有連續(xù)的可用的內(nèi)存(一個連續(xù)完整的地址空間),這是系統(tǒng)給我們的饋贈鼠锈,而實(shí)際上闪檬,它通常是分布在多個物理內(nèi)存碎片,系統(tǒng)的虛擬內(nèi)存空間映射vm_map負(fù)責(zé)虛擬內(nèi)存和物理內(nèi)存的映射關(guān)系购笆。

ARM處理器64bit的架構(gòu)情況下械哟,也就是0x000000000-0xFFFFFFFFF菠赚,每個16進(jìn)制數(shù)是4位,即2的36次冪,就是64GB拯爽,即App最大的虛擬內(nèi)存空間為64GB纠修。

共享動態(tài)庫其實(shí)就是共享的物理內(nèi)存中的那份動態(tài)庫淘讥,App虛擬內(nèi)存中的共享動態(tài)庫并未真實(shí)分配物理內(nèi)存士袄,使用時虛擬內(nèi)存會訪問同一份物理內(nèi)存達(dá)到共享動態(tài)庫的目的,iPhone7 PLUS(之前的產(chǎn)品最大為2GB)的物理內(nèi)存RAM也只有3GB襟锐,那么超過3GB的物理內(nèi)存如何處理呢撤逢,系統(tǒng)會使用一部分硬盤空間ROM來充當(dāng)內(nèi)存使用,在需要時進(jìn)行數(shù)據(jù)交換,當(dāng)然磁盤的數(shù)據(jù)交換是遠(yuǎn)遠(yuǎn)慢于物理內(nèi)存的蚊荣,這也是我們內(nèi)存過載時初狰,App卡頓的原因之一。

系統(tǒng)使用動態(tài)鏈接有幾點(diǎn)好處:

代碼共用:很多程序都動態(tài)鏈接了這些 lib妇押,但它們在內(nèi)存和磁盤中中只有一份跷究。

易于維護(hù):由于被依賴的 lib 是程序執(zhí)行時才鏈接的,所以這些 lib 很容易做更新敲霍,比如libSystem.dylib是libSystem.B.dylib的替身俊马,哪天想升級直接換成libSystem.C.dylib然后再替換替身就行了。

減少可執(zhí)行文件體積:相比靜態(tài)鏈接肩杈,動態(tài)鏈接在編譯時不需要打進(jìn)去柴我,所以可執(zhí)行文件的體積要小很多。

上圖中扩然,TEXT段兩個進(jìn)程共用艘儒,DATA段每個進(jìn)程各一份。

下面開始詳細(xì)分析pre-main的各個階段

加載 Dylib

從主執(zhí)行文件的 header 獲取到需要加載的所依賴動態(tài)庫列表夫偶,而 header 早就被內(nèi)核映射過界睁。然后它需要找到每個dylib,然后打開文件讀取文件起始位置兵拢,確保它是Mach-O文件翻斟。接著會找到代碼簽名并將其注冊到內(nèi)核。然后在 dylib 文件的每個 segment 上調(diào)用 mmap()说铃。應(yīng)用所依賴的 dylib 文件可能會再依賴其他 dylib访惜,所以 dyld 所需要加載的是動態(tài)庫列表一個遞歸依賴的集合。一般應(yīng)用會加載100到400個 dylib 文件腻扇,但大部分都是系統(tǒng) dylib债热,它們會被預(yù)先計算和緩存起來,加載速度很快幼苛。

加載系統(tǒng)的 dylib 很快窒篱,因?yàn)橛袃?yōu)化(因?yàn)椴僮飨到y(tǒng)自己要用部分framework所以在操作系統(tǒng)開機(jī)后就已經(jīng)緩存了?)舶沿。但加載內(nèi)嵌(embedded)的 dylib 文件很占時間舌剂,所以盡可能把多個內(nèi)嵌 dylib 合并成一個來加載,或者使用static archive暑椰。使用dlopen()來在運(yùn)行時懶加載是不建議的,這么做可能會帶來一些問題荐绝,并且總的開銷更大一汽。

在每個動態(tài)庫的加載過程中, dyld需要:

分析所依賴的動態(tài)庫

找到動態(tài)庫的mach-o文件

打開文件

驗(yàn)證文件

在系統(tǒng)核心注冊文件簽名

對動態(tài)庫的每一個segment調(diào)用mmap()

針對這一步驟的優(yōu)化有:

減少非系統(tǒng)庫的依賴;

使用靜態(tài)庫而不是動態(tài)庫召夹;

合并非系統(tǒng)動態(tài)庫為一個動態(tài)庫岩喷;

Rebase && Binding

Fix-ups

在加載所有的動態(tài)鏈接庫之后,它們只是處在相互獨(dú)立的狀態(tài)监憎,需要將它們綁定起來纱意,這就是Fix-ups。代碼簽名使得我們不能修改指令鲸阔,那樣就不能讓一個 dylib 的調(diào)用另一個 dylib偷霉。這時需要加很多間接層。

現(xiàn)代code-gen被叫做動態(tài)PIC(Position Independent Code)褐筛,意味著代碼可以被加載到間接的地址上类少。當(dāng)調(diào)用發(fā)生時,code-gen 實(shí)際上會在 __DATA 段中創(chuàng)建一個指向被調(diào)用者的指針渔扎,然后加載指針并跳轉(zhuǎn)過去硫狞。

所以 dyld 做的事情就是修正(fix-up)指針和數(shù)據(jù)。Fix-up 有兩種類型晃痴,rebasing和binding残吩。

Rebase

Rebasing:在鏡像內(nèi)部調(diào)整指針的指向,針對mach-o在加載到內(nèi)存中不是固定的首地址(ASLR)這一現(xiàn)象做數(shù)據(jù)修正的過程倘核;

由于ASLR(address space layout randomization)的存在泣侮,可執(zhí)行文件和動態(tài)鏈接庫在虛擬內(nèi)存中的加載地址每次啟動都不固定,所以需要這2步來修復(fù)鏡像中的資源指針笤虫,來指向正確的地址旁瘫。 rebase修復(fù)的是指向當(dāng)前鏡像內(nèi)部的資源指針; 而bind指向的是鏡像外部的資源指針琼蚯。

在iOS4.3前會把dylib加載到指定地址酬凳,所有指針和數(shù)據(jù)對于代碼來說都是固定的,dyld 就無需做rebase/binding了遭庶。

iOS4.3后引入了 ASLR 宁仔,dylib會被加載到隨機(jī)地址,這個隨機(jī)的地址跟代碼和數(shù)據(jù)指向的舊地址會有偏差峦睡,dyld 需要修正這個偏差翎苫,做法就是將 dylib 內(nèi)部的指針地址都加上這個偏移量,偏移量的計算方法如下:

Slide=?actual_address?-?preferred_address

然后就是重復(fù)不斷地對 __DATA 段中需要 rebase 的指針加上這個偏移量榨了。這就又涉及到page fault和COW煎谍。這可能會產(chǎn)生 I/O 瓶頸,但因?yàn)?rebase 的順序是按地址排列的龙屉,所以從內(nèi)核的角度來看這是個有次序的任務(wù)呐粘,它會預(yù)先讀入數(shù)據(jù)满俗,減少 I/O 消耗。

在 Rebasing 和 Binding 前會判斷是否已經(jīng) Prebinding作岖。如果已經(jīng)進(jìn)行過預(yù)綁定(Prebinding)唆垃,那就不需要 Rebasing 和 Binding 這些 Fix-up 流程了,因?yàn)橐呀?jīng)在預(yù)先綁定的地址加載好了痘儡。

rebase步驟先進(jìn)行辕万,需要把鏡像讀入內(nèi)存,并以page為單位進(jìn)行加密驗(yàn)證沉删,保證不會被篡改渐尿,所以這一步的瓶頸在IO。bind在其后進(jìn)行丑念,由于要查詢符號表涡戳,來指向跨鏡像的資源,加上在rebase階段脯倚,鏡像已被讀入和加密驗(yàn)證渔彰,所以這一步的瓶頸在于CPU計算。

Binding

Binding:將指針指向鏡像外部的內(nèi)容推正,binding就是將這個二進(jìn)制調(diào)用的外部符號進(jìn)行綁定的過程恍涂。比如我們objc代碼中需要使用到NSObject, 即符號_OBJC_CLASS_$_NSObject,但是這個符號又不在我們的二進(jìn)制中植榕,在系統(tǒng)庫Foundation.framework中再沧,因此就需要binding這個操作將對應(yīng)關(guān)系綁定到一起;

lazyBinding就是在加載動態(tài)庫的時候不會立即binding, 當(dāng)時當(dāng)?shù)谝淮握{(diào)用這個方法的時候再實(shí)施binding尊残。 做到的方法也很簡單: 通過dyld_stub_binder這個符號來做炒瘸。lazyBinding的方法第一次會調(diào)用到dyld_stub_binder, 然后dyld_stub_binder負(fù)責(zé)找到真實(shí)的方法,并且將地址bind到樁上寝衫,下一次就不用再bind了顷扩。

Binding 是處理那些指向 dylib 外部的指針,它們實(shí)際上被符號(symbol)名稱綁定慰毅,也就是個字符串隘截。__LINKEDIT段中也存儲了需要 bind 的指針,以及指針需要指向的符號汹胃。dyld 需要找到 symbol 對應(yīng)的實(shí)現(xiàn)婶芭,這需要很多計算,去符號表里查找着饥。找到后會將內(nèi)容存儲到 __DATA 段中的那個指針中犀农。Binding 看起來計算量比 Rebasing 更大,但其實(shí)需要的 I/O 操作很少宰掉,Binding的時間主要是耗費(fèi)在計算上井赌,因?yàn)镮O操作之前 Rebasing 已經(jīng)替 Binding 做過了谤逼,所以這兩個步驟的耗時是混在一起的。

可以從查看 __DATA 段中需要修正(fix-up)的指針仇穗,所以減少指針數(shù)量才會減少這部分工作的耗時。對于 ObjC 來說就是減少 Class,selector 和 category 這些元數(shù)據(jù)的數(shù)量戚绕。從編碼原則和設(shè)計模式之類的理論都會鼓勵大家多寫精致短小的類和方法纹坐,并將每部分方法獨(dú)立出一個類別,其實(shí)這會增加啟動時間舞丛。對于 C++ 來說需要減少虛方法耘子,因?yàn)樘摲椒〞?chuàng)建 vtable,這也會在 __DATA 段中創(chuàng)建結(jié)構(gòu)球切。雖然 C++ 虛方法對啟動耗時的增加要比 ObjC 元數(shù)據(jù)要少谷誓,但依然不可忽視。最后推薦使用 Swift 結(jié)構(gòu)體吨凑,它需要 fix-up 的內(nèi)容較少捍歪。

Objective-C 中有很多數(shù)據(jù)結(jié)構(gòu)都是靠 Rebasing 和 Binding 來修正(fix-up)的,比如 Class 中指向父類的指針和指向方法的指針鸵钝。

Rebase&&Binding該階段的優(yōu)化關(guān)鍵在于減少__DATA segment中的指針數(shù)量糙臼。我們可以優(yōu)化的點(diǎn)有:

減少Objc類數(shù)量, 減少selector數(shù)量恩商,把未使用的類和函數(shù)都可以刪掉

減少C++虛函數(shù)數(shù)量

轉(zhuǎn)而使用swift stuct(其實(shí)本質(zhì)上就是為了減少符號的數(shù)量变逃,使用swift語言來開發(fā)?)

未使用類的掃描,可以利用linkmap文件和otool工機(jī)具反編譯APP的可進(jìn)行二進(jìn)制文件得出一個大概的結(jié)果怠堪,但是不算非常精確揽乱,掃描出來后需要手動一個個確認(rèn)。掃描原理大致是classlist和classref兩者的差值粟矿,所有的類和使用了的類的差值就是未使用的類啦凰棉。因?yàn)槲词褂玫念愔饕獌?yōu)化的是pre-main的時間,根據(jù)測試我們的工程pre-main時間并不長嚷炉,所以本次并沒有針對這一塊做優(yōu)化渊啰。(TODO:寫腳本來驗(yàn)證這一點(diǎn))。

ObjC SetUp

主要做以下幾件事來完成Objc Setup:

讀取二進(jìn)制文件的 DATA 段內(nèi)容申屹,找到與 objc 相關(guān)的信息

注冊 Objc 類绘证,ObjC Runtime 需要維護(hù)一張映射類名與類的全局表。當(dāng)加載一個 dylib 時哗讥,其定義的所有的類都需要被注冊到這個全局表中嚷那;

讀取 protocol 以及 category 的信息,把category的定義插入方法列表 (category registration)杆煞,

確保 selector 的唯一性

ObjC 是個動態(tài)語言魏宽,可以用類的名字來實(shí)例化一個類的對象腐泻。這意味著 ObjC Runtime 需要維護(hù)一張映射類名與類的全局表。當(dāng)加載一個 dylib 時队询,其定義的所有的類都需要被注冊到這個全局表中派桩。

C++ 中有個問題叫做易碎的基類(fragile base class)。ObjC 就沒有這個問題蚌斩,因?yàn)闀诩虞d時通過 fix-up 動態(tài)類中改變實(shí)例變量的偏移量铆惑。

在 ObjC 中可以通過定義類別(Category)的方式改變一個類的方法。有時你想要添加方法的類在另一個 dylib 中送膳,而不在你的鏡像中(也就是對系統(tǒng)或別人的類動刀)员魏,這時也需要做些 fix-up。

ObjC 中的 selector 必須是唯一的撕阎。

由于之前2步驟的優(yōu)化,這一步實(shí)際上沒有什么可做的碌补。幾乎都靠 Rebasing 和 Binding 步驟中減少所需 fix-up 內(nèi)容魄眉。因?yàn)榍懊娴墓ぷ饕矔沟眠@步耗時減少囊骤。

Initializers

以上三步屬于靜態(tài)調(diào)整也物,都是在修改__DATA segment中的內(nèi)容宫屠,而這里則開始動態(tài)調(diào)整,開始在堆和棧中寫入內(nèi)容滑蚯。 工作主要有:

Objc的+load()函數(shù)

C++的構(gòu)造函數(shù)屬性函數(shù) 形如attribute((constructor)) void DoSomeInitializationWork()

非基本類型的C++靜態(tài)全局變量的創(chuàng)建(通常是類或結(jié)構(gòu)體)(non-trivial initializer) 比如一個全局靜態(tài)結(jié)構(gòu)體的構(gòu)建浪蹂,如果在構(gòu)造函數(shù)中有繁重的工作,那么會拖慢啟動速度

Objc的load函數(shù)和C++的靜態(tài)構(gòu)造函數(shù)采用由底向上的方式執(zhí)行告材,來保證每個執(zhí)行的方法坤次,都可以找到所依賴的動態(tài)庫

dyld開始將程序二進(jìn)制文件初始化

交由ImageLoader讀取image,其中包含了我們的類斥赋、方法等各種符號

由于runtime向dyld綁定了回調(diào)缰猴,當(dāng)image加載到內(nèi)存后,dyld會通知runtime進(jìn)行處理

runtime接手后調(diào)用mapimages做解析和處理疤剑,接下來loadimages中調(diào)用 callloadmethods方法滑绒,遍歷所有加載進(jìn)來的Class闷堡,按繼承層級依次調(diào)用Class的+load方法和其 Category的+load方法

整個事件由dyld主導(dǎo),完成運(yùn)行環(huán)境的初始化后疑故,配合ImageLoader 將二進(jìn)制文件按格式加載到內(nèi)存杠览,動態(tài)鏈接依賴庫,并由runtime負(fù)責(zé)加載成objc 定義的結(jié)構(gòu)焰扳,所有初始化工作結(jié)束后倦零,dyld調(diào)用真正的main函數(shù)

C++ 會為靜態(tài)創(chuàng)建的對象生成初始化器。而在 ObjC 中有個叫 +load 的方法吨悍,然而它被廢棄了,現(xiàn)在建議使用 +initialize蹋嵌。對比詳見StackOverflow的一個連接;

這一步可以做的優(yōu)化有:

使用 +initialize 來替代 +load

不要使用 atribute((constructor)) 將方法顯式標(biāo)記為初始化器育瓜,而是讓初始化方法調(diào)用時才執(zhí)行。

比如使用 `dispatch_once()`,`pthread_once()` 或 `std::once()`栽烂。也就是在第一次使用時才初始化躏仇,推遲了一部分工作耗時。

也盡量不要用到C++的靜態(tài)對象腺办。

從效率上來說焰手,在 +load 和+initialize里執(zhí)行同樣的代碼,效率是一樣的怀喉,即使有差距书妻,也不會差距太大。 但所有的+load 方法都在啟動的時候調(diào)用躬拢,方法多了就會嚴(yán)重影響啟動速度了躲履。 就說我們項(xiàng)目中,有200個左右+load方法聊闯,一共耗時大概1s 左右工猜,這塊就會嚴(yán)重影響到用戶感知了。 而+initialize方法是在對應(yīng) Class 第一次使用的時候調(diào)用菱蔬,這是一個懶加載的方法篷帅,理想情況下,這200個+load方法都使用+initialize來代替拴泌,將耗時分?jǐn)偟接脩羰褂眠^程中魏身,每個方法平均耗時只有5ms,用戶完全可以無感知弛针。 因?yàn)閘oad是在啟動的時候調(diào)用叠骑,而initialize是在類首次被使用的時候調(diào)用,不過當(dāng)你把load中的邏輯移到initialize中時候削茁,一定要注意initialize的重復(fù)調(diào)用問題宙枷,能用dispatch_once()來完成的掉房,就盡量不要用到load方法。

如果程序剛剛被運(yùn)行過慰丛,那么程序的代碼會被dyld緩存卓囚,因此即使殺掉進(jìn)程再次重啟加載時間也會相對快一點(diǎn),如果長時間沒有啟動或者當(dāng)前dyld的緩存已經(jīng)被其他應(yīng)用占據(jù)诅病,那么這次啟動所花費(fèi)的時間就要長一點(diǎn)哪亿,這就分別是熱啟動和冷啟動的概念。下文中的啟動時間統(tǒng)計贤笆,均統(tǒng)計的是第二次啟動后的數(shù)據(jù)蝇棉。(具體dyld緩存的是動態(tài)庫還是APP的可執(zhí)行代碼,緩存多長時間芥永,需要再研究篡殷,有懂的大神可以告知一下?)

見下圖埋涧,出處是這里 (http://lingyuncxb.com/2018/01/30/iOS%E5%90%AF%E5%8A%A8%E4%BC%98%E5%8C%96/):

其實(shí)在我們APP的實(shí)踐過程中也會遇到類似的事情板辽,只不過我只統(tǒng)計了第二次啟動后的時間,也就是定義中的熱啟動時間棘催。

注:通過在工程的scheme中添加環(huán)境變量DYLD_PRINT_STATISTICS劲弦,設(shè)置值為1,App啟動加載時Xcode的控制臺就會有pre-main各個階段的詳細(xì)耗時輸出醇坝。但是DYLD_PRINT_STATISTICS 變量打印時間是iOS10以后才支持的功能邑跪,所以需要用iOS10系統(tǒng)及以上的機(jī)器來做測試。

pre-main階段耗時的影響因素:

動態(tài)庫加載越多纲仍,啟動越慢呀袱。

ObjC類越多,函數(shù)越多郑叠,啟動越慢夜赵。

可執(zhí)行文件越大啟動越慢。

C的constructor函數(shù)越多乡革,啟動越慢寇僧。

C++靜態(tài)對象越多,啟動越慢沸版。

ObjC的+load越多嘁傀,啟動越慢。

整體上pre-main階段的優(yōu)化有:

減少依賴不必要的庫视粮,不管是動態(tài)庫還是靜態(tài)庫细办;如果可以的話,把動態(tài)庫改造成靜態(tài)庫;如果必須依賴動態(tài)庫笑撞,則把多個非系統(tǒng)的動態(tài)庫合并成一個動態(tài)庫岛啸;

檢查下 framework應(yīng)當(dāng)設(shè)為optional和required,如果該framework在當(dāng)前App支持的所有iOS系統(tǒng)版本都存在茴肥,那么就設(shè)為required坚踩,否則就設(shè)為optional,因?yàn)閛ptional會有些額外的檢查瓤狐;

合并或者刪減一些OC類和函數(shù)瞬铸;關(guān)于清理項(xiàng)目中沒用到的類,使用工具AppCode代碼檢查功能础锐,查到當(dāng)前項(xiàng)目中沒有用到的類(也可以用根據(jù)linkmap文件來分析嗓节,但是準(zhǔn)確度不算很高);有一個叫做FUI的開源項(xiàng)目能很好的分析出不再使用的類皆警,準(zhǔn)確率非常高赦政,唯一的問題是它處理不了動態(tài)庫和靜態(tài)庫里提供的類,也處理不了C++的類模板耀怜。

刪減一些無用的靜態(tài)變量,

刪減沒有被調(diào)用到或者已經(jīng)廢棄的方法桐愉,方法見http://stackoverflow.com/questions/35233564/how-to-find-unused-code-in-xcode-7和https://developer.Apple.com/library/ios/documentation/ToolsLanguages/Conceptual/Xcode_Overview/CheckingCodeCoverage.html财破。

將不必須在+load方法中做的事情延遲到+initialize中,盡量不要用C++虛函數(shù)(創(chuàng)建虛函數(shù)表有開銷)

類和方法名不要太長:iOS每個類和方法名都在__cstring段里都存了相應(yīng)的字符串值从诲,所以類和方法名的長短也是對可執(zhí)行文件大小是有影響的左痢;因還是object-c的動態(tài)特性,因?yàn)樾枰ㄟ^類/方法名反射找到這個類/方法進(jìn)行調(diào)用系洛,object-c對象模型會把類/方法名字符串都保存下來俊性;

用dispatch_once()代替所有的 attribute((constructor)) 函數(shù)、C++靜態(tài)對象初始化描扯、ObjC的+load函數(shù)定页;

在設(shè)計師可接受的范圍內(nèi)壓縮圖片的大小,會有意外收獲绽诚。壓縮圖片為什么能加快啟動速度呢典徊?因?yàn)閱拥臅r候大大小小的圖片加載個十來二十個是很正常的,圖片小了恩够,IO操作量就小了卒落,啟動當(dāng)然就會快了,比較靠譜的壓縮算法是TinyPNG蜂桶。

我們的實(shí)踐

統(tǒng)計了各個庫所占的size(使用之前做安裝包size優(yōu)化的一個腳本)儡毕,基本上一個公共庫越大,類越多扑媚,啟動時在pre-main階段所需要的時間也越多腰湾。

所以去掉了Realm雷恃,DiffMatchPatch源碼庫,以及AlicloudHttpDNS檐盟,BCConnectorBundl褂萧,F(xiàn)eedBack,SGMain和SecurityGuardSDK幾個庫葵萎;

結(jié)果如下:

靜態(tài)庫导犹,少了7M左右:

第三方framework(其實(shí)也是靜態(tài)庫,只是腳本會分開統(tǒng)計而已)羡忘,少了1M左右:

我們使用cocoapodbs并沒有設(shè)置user_frameworks谎痢,所以pod管理的有源碼的第三方庫都是靜態(tài)庫的形式,而framework形式的靜態(tài)庫基本都是第三方公司提供的服務(wù)卷雕,上圖可以看到节猿,size占比最大的還是阿里和騰訊兩家的SDK,比如阿里的推送和騰訊的直播和IM漫雕。

上圖在統(tǒng)計中滨嘱,AliCloudHttpDNS的可執(zhí)行文件在Mac的Finder下的大小大概是10M,AlicloudUtils是3.4M浸间,UTMini是16M太雨,而UTDID只有1.6M。依賴關(guān)系上魁蒜,AliCloudHttpDNS依賴AlicloudUtils囊扳,而AlicloudUtils依賴UTMini和UTDID,UTMini依賴UTDID兜看。

上圖中在統(tǒng)計上锥咸,應(yīng)該是有所AlicloudUtils在左右兩個圖中size差值過大,應(yīng)該是依賴關(guān)系中UTMini導(dǎo)致的統(tǒng)計偏差细移。兩邊幾個庫加起來的差值大概是200kb搏予,這也應(yīng)該是AlicloudHttpDNS這個庫所占的size大小。

main階段

總體原則無非就是減少啟動的時候的步驟葫哗,以及每一步驟的時間消耗缔刹。

main階段的優(yōu)化大致有如下幾個點(diǎn):

減少啟動初始化的流程,能懶加載的就懶加載劣针,能放后臺初始化的就放后臺校镐,能夠延時初始化的就延時,不要卡主線程的啟動時間捺典,已經(jīng)下線的業(yè)務(wù)直接刪掉鸟廓;

優(yōu)化代碼邏輯,去除一些非必要的邏輯和代碼,減少每個流程所消耗的時間引谜;

啟動階段使用多線程來進(jìn)行初始化牍陌,把CPU的性能盡量發(fā)揮出來;

使用純代碼而不是xib或者storyboard來進(jìn)行UI框架的搭建员咽,尤其是主UI框架比如TabBarController這種毒涧,盡量避免使用xib和storyboard,因?yàn)閤ib和storyboard也還是要解析成代碼來渲染頁面贝室,多了一些步驟契讲;

這里重點(diǎn)講一下多線程啟動設(shè)計的原理

首先,iPhone有雙核滑频,除了維持操作系統(tǒng)運(yùn)轉(zhuǎn)和后臺進(jìn)程(包括電話短信等守護(hù)進(jìn)程)捡偏,在打開APP時,猜想雙核中的另一個核應(yīng)該有余力來幫忙分擔(dān)啟動的任務(wù)【待驗(yàn)證】峡迷;

其次银伟,iPhone7開始使用A10,iPhone8開始使用A11處理器绘搞,根據(jù)維基百科的定義彤避,A10 CPU包括兩枚高性能核心及兩枚低功耗核心,A11則包括兩個高性能核心和四個高能效核心夯辖,而且相比A10忠藤,A11兩個性能核心的速度提升了25%,四個能效核心的速度提升了70%楼雹。而且蘋果還準(zhǔn)備了第二代性能控制器,因此可以同時發(fā)揮六個核心的全部威力尖阔,性能提升最高可達(dá)70%贮缅,多線程能力可見一斑。

多線程測試結(jié)果如下圖:

結(jié)論如下:

純算法的CPU運(yùn)算介却,指定計算的總數(shù)量谴供,對于iPhone6和iPhone X來說,把計算量平均分配到多線程比全部放在主線程執(zhí)行要快;

iPhone6三個線程跟兩個線程的總體耗時總體一致齿坷,甚至要多一點(diǎn)點(diǎn)桂肌,所以對于iPhone6來說用兩個線程來做啟動設(shè)計足夠了;

iPhone X三個線程的耗時要比兩個線程短一些永淌,但是差值已經(jīng)不算太大了崎场;四個線程跟三個線程的總體一致,偶爾能比三個線程快一點(diǎn)點(diǎn)遂蛀;

綜上谭跨,利用多個線程來加速啟動時間的設(shè)計,是合理的。

但是多線程啟動的設(shè)計有幾個需要注意的點(diǎn):

黑屏問題螃宙;

用狀態(tài)機(jī)來設(shè)計蛮瞄,每個狀態(tài)機(jī)有2或3個線程在跑不同的任務(wù),所有線程任務(wù)都完成后谆扎,進(jìn)入到下一個狀態(tài)挂捅,方便擴(kuò)展;

線程碧煤活問題闲先,以及用完后要銷毀;

資源競爭或線程同步造成卡死的問題苗缩。

針對第一點(diǎn)饵蒂,多線程跑初始化任務(wù)的時候,可能主線程會有空閑等待子線程的階段酱讶,而主線程一旦空閑退盯,iOS系統(tǒng)的啟動畫面就會消失,如果此時APP尚未初始化完全泻肯,則可能會黑屏渊迁。為了避免黑屏,我們需要一個假的啟動畫面灶挟,在剛開始跑初始化任務(wù)時琉朽,就生成這個啟動畫面,啟動過程完全結(jié)束后再去掉稚铣∠淙或者當(dāng)一個狀態(tài)機(jī)里的主線程跑完時檢查下是否所有線程都執(zhí)行完任務(wù)了,如果沒有則去生成這個假的初始化頁面惕医,避免黑屏(我們采用后一種方式)耕漱。

第二點(diǎn)用狀態(tài)機(jī)來設(shè)計啟動,每個狀態(tài)跑兩個或者多個線程抬伺,單個狀態(tài)里每個線程的耗時是不一樣的螟够,跑完一個狀態(tài)再繼續(xù)下一個狀態(tài),可以多次測試去動態(tài)調(diào)整分派到各個線程里的任務(wù)峡钓。

第三點(diǎn)線程奔梭希活則跟runloop有關(guān),每個線程啟動后能岩,跑完一個狀態(tài)寞宫,不能立馬就回收,不然下一個狀態(tài)的子線程就永遠(yuǎn)不會執(zhí)行了拉鹃;另外就是淆九,APP初始化完成后统锤,線程要注意回收。

第四點(diǎn)跟具體的業(yè)務(wù)有關(guān)炭庙,只要不是一個線程去做初始化饲窿,就有可能遇到線程間死鎖的問題,比如下面采坑記錄里就有提到一個例子焕蹄。

我們在實(shí)踐中大概做了以下的幾點(diǎn):

把啟動時RN包的刪除和拷貝操作逾雄,僅在APP安裝后第一次啟動時才做,之后的啟動不再做這操作腻脏,而是等到網(wǎng)絡(luò)請求RN數(shù)據(jù)回來鸦泳,根據(jù)是否需要更新RN包的判斷,再去做這些IO操作從而避免啟動的耗時永品。iPhone5C能節(jié)省1.4s做鹰;

OSS token的獲取不是一個需要在啟動的時候必須要做的操作,放到子線程去處理鼎姐,大部分時候是節(jié)省10-15ms钾麸,偶爾能去到50ms;

去掉啟動狀態(tài)機(jī)里的原有定位服務(wù)炕桨,原來SSZLocationManager的定位服務(wù)因?yàn)閮?nèi)部依賴高德的SDK饭尝,需要初始化SDK,iPhone5C大概耗時100ms献宫。同時SSZLocationManager這個類代碼保留钥平,但是APP的工程去除對其的依賴;

打點(diǎn)統(tǒng)計模塊里的定位服務(wù)權(quán)限請求改成異步姊途,大概有50ms涉瘾;

阿里百川的Feedback,在網(wǎng)校并沒有使用捷兰,直接去掉其初始化流程睡汹,大概5ms左右;

友盟的分享服務(wù)寂殉,沒有必要在啟動的時候去初始化,初始化任務(wù)丟到后臺線程解決原在,大概600-800ms;

狀態(tài)機(jī)跑完后的啟動內(nèi)存統(tǒng)計,放到后臺去做蜀撑,大概50ms法瑟;

UserAgentManager里對于webview是否為UIWebview的判斷,以前是新創(chuàng)建一個對象使用對象方法來判斷浮庐,修改為直接使用類方法甚负,避免創(chuàng)建對象的消耗柬焕,節(jié)省約200ms;

阿里云的HTTPDNS已經(jīng)沒有再使用了梭域,所以這里也可以直接去掉斑举。大概20-40ms;

SSZAppConfig里把網(wǎng)絡(luò)請求放到后臺線程病涨,這樣子就可以配合啟動狀態(tài)機(jī)把該任務(wù)放到子線程進(jìn)行初始化了富玷,否則子線程消耗的時間太長了;

采用兩個線程來進(jìn)行啟動流程的初始化既穆,用狀態(tài)機(jī)來控制狀態(tài)的變化赎懦。但是要針對業(yè)務(wù)區(qū)分開,并不是把一部分業(yè)務(wù)拆分到子線程幻工,就可以讓整體的啟動速度更快励两;因?yàn)槿绻泳€程有一些操作是要在主線程做的,有可能會出現(xiàn)等待主線程空閑再繼續(xù)的情況囊颅;或者當(dāng)兩個線程的耗時操作都是IO時当悔,拆開到兩個線程,并不一定比單個線程去做IO操作要快迁酸。

主UI框架tabBarController的viewDidLoad函數(shù)里先鱼,去掉一些不必要的函數(shù)調(diào)用。

NSUserDefaults的synchronize函數(shù)盡量不要在啟動流程中去調(diào)用奸鬓,統(tǒng)一在APP進(jìn)入后臺焙畔,willTerminate和完全進(jìn)入前臺后把數(shù)據(jù)落地;

因?yàn)槲覀兊捻?xiàng)目用到了React Native技術(shù)(簡稱RN)串远,所以會有RN包的拷貝和更新這一操作宏多,之前的邏輯是每次啟動都從bundle里拷貝一次到Document的指定目錄,本次優(yōu)化修正為除了安裝后第一次啟動拷貝澡罚,其他時候不在做這個拷貝操作伸但,不過RN包熱更新的覆蓋操作,還是每次都要做檢查的留搔,如果有需要則執(zhí)行更新操作

其中遇到幾個坑:

并不是什么任務(wù)都適合放子線程更胖,有些任務(wù)在主線程大概10ms,放到子線程需要幾百ms隔显,因?yàn)槟承┤蝿?wù)內(nèi)部可能會用到UIKit的api却妨,又或者某些操作是需要提交到主線程去執(zhí)行的,關(guān)鍵是要搞清楚這個任務(wù)里邊究竟做了啥括眠,有些SDK并不見得適合放到子線程去初始化彪标,需要具體情況具體去測試和分析。

AccountManager初始化導(dǎo)致的主線程卡死掷豺,子線程的任務(wù)依賴AccountManager捞烟,主線程也依賴薄声,當(dāng)子線程比主線程先調(diào)用時,造成了主線程卡死题画,其原因是子線程會提交一個同步阻塞的操作到主線程默辨,而此時主線程被dipatch_one的鎖鎖住了,所以造成子線程不能返回婴程,主線程也無法繼續(xù)執(zhí)行廓奕。調(diào)試的時候還會用到符號斷點(diǎn)和LLDB去打印函數(shù)入?yún)ⅲㄒ话闶莚0-r3之間的寄存器)的值。

RN包的拷貝檢查除了是否第一次打開APP之外档叔,還要注意RN版本如果升級時桌粉,需要用新的包強(qiáng)制覆蓋掉舊的包,否則js代碼會一直得不到更新衙四。

實(shí)際優(yōu)化效果對比

由于只是去掉了幾個靜態(tài)庫铃肯,而且本來pre-main階段的耗時就不長,基本在200ms-500ms左右传蹈,所以pre-main階段優(yōu)化前后效果并不明顯押逼,有時候還沒有前后測試的誤差大。惦界。挑格。

main的階段的優(yōu)化效果還是很明顯的:

iPhone5C iOS10.3.3系統(tǒng)優(yōu)化前main階段的時間消耗為4秒左右,優(yōu)化后基本在1.8秒內(nèi)沾歪;

iPhone7 iOS10.3.3系統(tǒng)優(yōu)化前main階段的時間消耗為1.1秒左右漂彤,優(yōu)化后基本在600ms內(nèi);

iPhoneX iOS11.3.1系統(tǒng)優(yōu)化前main階段的時間消耗基本在1.5秒以上灾搏,優(yōu)化后在1秒內(nèi)挫望;

可以看到,同樣arm64架構(gòu)的機(jī)器狂窑,main階段是iPhone7比iPhoneX更快媳板,說明就操作系統(tǒng)來說,iOS11.3要比iOS10.3慢不少泉哈;

詳細(xì)測試數(shù)據(jù)見下圖

上圖中iPhone5C為啥前后測試pre-main時間差值那么大蛉幸?而且是優(yōu)化后的值比優(yōu)化前還要大?我也不知道丛晦,大概機(jī)器自己才知道吧奕纫。。采呐。

注意:

關(guān)于冷啟動和熱啟動,業(yè)界對冷啟動的定義沒有問題搁骑,普遍認(rèn)為是手機(jī)開機(jī)后第一次啟動某個APP斧吐,但是對熱啟動有不同的看法又固,有些人認(rèn)為是按下home鍵把APP掛到后臺,之后點(diǎn)擊APP的icon再拉回來到前臺算是熱啟動煤率,也有些人認(rèn)為是手機(jī)開機(jī)后在短時間內(nèi)第二次啟動APP(殺掉進(jìn)程重啟)算是熱啟動(此時dyld會對部分APP的數(shù)據(jù)和庫進(jìn)行緩存仰冠,所以比第一次啟動要快)。筆者認(rèn)為APP從后臺拉起到前臺的時間沒啥研究的意義蝶糯,而即使是短時間內(nèi)第二次啟動APP洋只,啟動時間也是很重要的,所以在統(tǒng)計啟動時間時昼捍,筆者會傾向于后一種說法识虚,不過具體怎么定義還是看個人吧,知道其中的區(qū)別就好妒茬。

關(guān)于如何區(qū)分framework是靜態(tài)庫還是動態(tài)庫見這里担锤。原理就是在終端使用指令file,輸出如果是ar archive就是靜態(tài)庫乍钻,如果是動態(tài)庫則會輸出dynamically linked相關(guān)信息肛循。

特別鳴謝

在做啟動優(yōu)化的過程中,得到了很多朋友們的幫助和支持银择。借鑒了淮哥狀態(tài)機(jī)的設(shè)計思路多糠,同時也感謝singro大神的指點(diǎn),感謝劉金哥叫我玩LLDB浩考,感謝長元兄對于動態(tài)庫和靜態(tài)庫的指教夹孔,感謝森哥的鞭策和精神鼓舞,以及展少爺在整個過程中的技術(shù)支持怀挠,引導(dǎo)和不耐其煩的解釋析蝴,再次謝謝大家,愛你們喲??绿淋!

參考鏈接:

優(yōu)化 App 的啟動時間 http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time/

iOS啟動優(yōu)化 http://lingyuncxb.com/2018/01/30/iOS%E5%90%AF%E5%8A%A8%E4%BC%98%E5%8C%96/

iOSApp啟動性能優(yōu)化 https://mp.weixin.qq.com/s/Kf3EbDIUuf0aWVT-UCEmbA

今日頭條iOS客戶端啟動速度優(yōu)化 https://techblog.toutiao.com/2017/01/17/iosspeed/

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末闷畸,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子吞滞,更是在濱河造成了極大的恐慌佑菩,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,185評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件裁赠,死亡現(xiàn)場離奇詭異殿漠,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)佩捞,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,652評論 3 393
  • 文/潘曉璐 我一進(jìn)店門绞幌,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人一忱,你說我怎么就攤上這事莲蜘√啡罚” “怎么了?”我有些...
    開封第一講書人閱讀 163,524評論 0 353
  • 文/不壞的土叔 我叫張陵票渠,是天一觀的道長逐哈。 經(jīng)常有香客問我,道長问顷,這世上最難降的妖魔是什么昂秃? 我笑而不...
    開封第一講書人閱讀 58,339評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮杜窄,結(jié)果婚禮上肠骆,老公的妹妹穿的比我還像新娘。我一直安慰自己羞芍,他們只是感情好哗戈,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,387評論 6 391
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著荷科,像睡著了一般唯咬。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上畏浆,一...
    開封第一講書人閱讀 51,287評論 1 301
  • 那天胆胰,我揣著相機(jī)與錄音,去河邊找鬼刻获。 笑死蜀涨,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的蝎毡。 我是一名探鬼主播厚柳,決...
    沈念sama閱讀 40,130評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼沐兵!你這毒婦竟也來了别垮?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,985評論 0 275
  • 序言:老撾萬榮一對情侶失蹤扎谎,失蹤者是張志新(化名)和其女友劉穎碳想,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體毁靶,經(jīng)...
    沈念sama閱讀 45,420評論 1 313
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡胧奔,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,617評論 3 334
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了预吆。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片龙填。...
    茶點(diǎn)故事閱讀 39,779評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出岩遗,到底是詐尸還是另有隱情胶背,我是刑警寧澤,帶...
    沈念sama閱讀 35,477評論 5 345
  • 正文 年R本政府宣布喘先,位于F島的核電站,受9級特大地震影響廷粒,放射性物質(zhì)發(fā)生泄漏窘拯。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,088評論 3 328
  • 文/蒙蒙 一坝茎、第九天 我趴在偏房一處隱蔽的房頂上張望涤姊。 院中可真熱鬧,春花似錦嗤放、人聲如沸思喊。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,716評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽恨课。三九已至,卻和暖如春岳服,著一層夾襖步出監(jiān)牢的瞬間剂公,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,857評論 1 269
  • 我被黑心中介騙來泰國打工吊宋, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留纲辽,地道東北人。 一個月前我還...
    沈念sama閱讀 47,876評論 2 370
  • 正文 我出身青樓璃搜,卻偏偏與公主長得像拖吼,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子这吻,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,700評論 2 354

推薦閱讀更多精彩內(nèi)容