iOS 效能優化備忘

小星星_ios發表於2020-03-26

影像成像原理

  • CPU:物件的建立與銷燬,物件屬性的調整、佈局計算、文字的計算與排版,圖片的格式轉換和解碼、影像的繪製
  • GPU:紋理的渲染

圖片成像原理

iOS採用了雙快取頁交換技術 + 垂直同步技術

  • 垂直同步:防止造成畫面撕裂、跳幀的現象(就是這次還沒有渲染完就顯示到螢幕上了)。也就是當一次VSync訊號(按照60FPS來算,就是16ms有一次VSync訊號)來的時候,從螢幕緩衝區還讀不到,那麼這一幀就會等到下一次VSync訊號來的時候再顯示,也就是掉幀。

  • 雙快取頁交換技術

雙快取頁交換技術

iOS的雙快取編碼(CGBitmapContextCreate)

  • 所謂“螢幕雙緩衝”是指在記憶體中建立一個“圖形裝置上下文的快取”,所有的繪圖操作都在這個“圖形上下文快取”上進行,在需要顯示這個“圖形上下文”的時候,再次把它更新到螢幕裝置上。

卡頓的解決思路

  • 儘可能減少CPU、GPU資源消耗
    • 儘量使用輕量級的物件,比如不需要事件處理的地方,可以用CALayer代替UIView
    • 不要頻繁的調整UIView的相關屬性,如frame、bounds、transform等屬性
    • 提前計算好佈局,在有需要時一次性調整對應的屬性
    • Autolayout會比直接訊息frame消耗更多的資源
    • 圖片的size最好和UIImageView的size一樣
    • 控制一下執行緒的最大併發數量
    • 將耗時操作放到子執行緒
      • 文字處理(尺寸計算、繪製)
      • 圖片處理(解碼、繪製)
    • 儘量避免短時間內大量圖片的顯示,儘可能將多張圖片合成一張進行展示
    • GPU能處理的最大紋理是4096*4096,如果超過這個尺寸,就會佔用CPU去處理
    • 儘量減少檢視的數量和層次
    • 減少透明的檢視,不透明就設定opaque = YES就可以了

離屏渲染

在OpenGL中,GPU有2種渲染方式 在屏渲染:在當前用於顯示的螢幕緩衝區進行渲染操作 離屏渲染:在當前螢幕快取區外新開闢一個緩衝區進行渲染操作

  • 為什麼消耗效能?
    • 需要建立新的緩衝區
    • 多次切換上下文環境,在屏緩衝區切換到離屏緩衝區,離屏渲染結束以後,將離屏緩衝區的渲染結果顯示到螢幕上,又切換回在屏緩衝區。
  • 哪些操作會觸發?
    • layer.shouldRasterize = true
    • layer.mask
    • layer.masksToBounds = true && layer.cornerRadius > 0(可以通過CG自己繪製裁剪圓角,或者叫美工提供圓角圖片)
    • layer.shadowXXX(但是如果設定了layer.shadowPath就不會產生離屏渲染了)

卡頓檢測

  • 利用Observer到主執行緒中的RunLoop中,通過監聽RunLoop狀態切換的耗時,以達到監測的目的

耗電的主要來源

  • CPU
  • 網路
  • 定位
  • 影像

耗電優化

  • 少用定時器

  • 優化I/O操作

    • 儘量不要頻繁寫入小資料,最好指量一次性寫入
    • 讀取大量重要資料時,考慮用dispatch_io,提供了基於GCD的非同步操作檔案I/O的API。系統會優化磁碟的訪問
    • 資料量比較大時,建議使用資料庫
  • 網路優化

    • 減少、壓縮網路資料
    • 如果多次請求的結果是相同的,儘量使用快取
    • 使用斷點續傳,否則網路不穩定時可能多次傳輸相同的內容
    • 批量傳輸
  • 定位優化

    • 如果只是需要快速確定使用者位置,最好用CLLocationManager的requestLocation方法。定位完成後,會自動讓定位硬體斷電
    • 不需要實時更新位置,定位完畢就關掉定位服務
    • 儘量降低定位的精度
    • 需要後臺定位時,儘量設定pausesLocationUpdatesAutomatically為yes,如果使用者不太可能移動的時候,系統就會自動暫停位置更新

APP的啟動

  • www.cocoachina.com/articles/24…
  • 冷啟動:從0啟動
  • 熱啟動:app已經在記憶體中,在後臺存活著,啟動
  • 通過新增環境變數可以列印出App的啟動時間分析
DYLD_PRINT_STATISTICS設定為1
或者更加詳細的
DYLD_PRINT_STATISTICS_DETAILS設定為1(一般在400ms以內就是不錯的了)
複製程式碼
  • 冷啟動分為2大階段

    • pre-main:App開始啟動到系統呼叫main函式的這一段時間
    • main:main函式到主UI框架的viewDidAppear函式呼叫的這一段時間
  • app啟動具體流程

    1. ①解析 info.plist
    2. 載入相關資訊,例如閃屏
    3. 沙箱建立、許可權檢查
    4. ②Mach-O載入
    5. 如果是胖二進位制檔案,尋找合適當前CPU架構的部分
    6. 載入所有依賴的Mach-O檔案(遞迴呼叫Mach-O載入的方法)
    7. 定位內部、外部指標引用
    8. 執行宣告__attribute__((constructor))的C函式
    9. 載入類擴充套件(Category)中的方法
    10. C++靜態物件載入,呼叫Objc的+load函式
    11. ③程式執行
    12. 呼叫main()
    13. 呼叫UIApplicationMain()
    14. 呼叫applicationWillFinishLaunching

dyld(蘋果的動態連結器)

系統先讀取App的可執行檔案,從裡面獲得dyld的路徑,然後載入dyld,dyld去初始化執行環境,開啟快取策略,載入程式相關依賴庫,並對這些庫進行連結,最後呼叫每個依賴庫的初始化方法,在這一步,runtime被初始化。

當所有依賴庫的初始化後,輪一最後一位進行初始化,在這時runtime會對專案中所有類進行類結構初始化,然後呼叫所有的load方法。最後dyld返回main函式地址,main函式被呼叫。

當載入一個Mach-O檔案時,動態連結器首先會檢查共享快取看看是否存在其中,如果存在,那麼就直接從共享快取中拿出來使用。每一程式都把這個共享快取對映到了自己的地址空間中。這個方法大大優化了iOS上程式的啟動時間。

Executable(程式的可執行檔案,Mach-O其中的一種格式)

  • Mach-O被劃分成一些segement,大小為頁的整數。arm64下一面是16KB,其餘為4KB。
  • 幾乎都包含__TEXT,__DATA和__LINKEDIT三個segment
    • __Text包含Mach header,被執行的程式碼和只讀常量。只讀可執行
    • __DATA包含全域性變數,靜態變數。可讀寫
    • __LINKEDIT包含了載入程式的後設資料。比如函式的名稱和地址。只讀

ASLR:地址空間佈局隨機化,映象會在隨機的地址上載入

程式碼簽名:為了在執行時驗證Mach-O檔案的簽名,並不是每次重複讀入整個檔案,而是把每頁內容都生成一個單獨的加密雜湊值,並儲存在__LINKEDIT中。這使得檔案每頁的內容都能及時被校驗確保不被纂改。

載入 Dylib

從主執行檔案的 header 獲取到需要載入的所依賴動態庫列表,而 header 早就被核心對映過。然後它需要找到每個 dylib,然後開啟檔案讀取檔案起始位置,確保它是 Mach-O 檔案。接著會找到程式碼簽名並將其註冊到核心。然後在 dylib 檔案的每個 segment 上呼叫 mmap()。應用所依賴的 dylib 檔案可能會再依賴其他 dylib,所以 dyld 所需要載入的是動態庫列表一個遞迴依賴的集合。一般應用會載入 100 到 400 個 dylib 檔案,但大部分都是系統 dylib,它們會被預先計算和快取起來,載入速度很快。

載入系統的 dylib 很快,因為有優化(因為作業系統自己要用部分framework所以在作業系統開機後就已經快取了?)。但載入內嵌(embedded)的 dylib 檔案很佔時間,所以儘可能把多個內嵌 dylib 合併成一個來載入,或者使用 static archive。使用 dlopen() 來在執行時懶載入是不建議的,這麼做可能會帶來一些問題,並且總的開銷更大。

在每個動態庫的載入過程中, dyld需要:

  1. 分析所依賴的動態庫
  2. 找到動態庫的mach-o檔案
  3. 開啟檔案
  4. 驗證檔案
  5. 驗證檔案
  6. 在系統核心註冊檔案簽名
  7. 對動態庫的每一個segment呼叫mmap()

針對這一步驟的優化有:

  1. 減少非系統庫的依賴;
  2. 使用靜態庫而不是動態庫;
  3. 合併非系統動態庫為一個動態庫;

Rebase && Binding

  • rebase: 在映象內部調整指標的指向。

Slide = actual_address - preferred_address

然後就是重複不斷地對 __DATA 段中需要 rebase 的指標加上這個偏移量。這就又涉及到 page fault 和 COW。這可能會產生 I/O 瓶頸,但因為 rebase 的順序是按地址排列的,所以從核心的角度來看這是個有次序的任務,它會預先讀入資料,減少 I/O 消耗。

  • binding:將指標指向映象外部的內容,binding就是將這個二進位制呼叫的外部符號進行繫結的過程。

lazyBinding就是在載入動態庫的時候不會立即binding, 當時當第一次呼叫這個方法的時候再實施binding。 做到的方法也很簡單: 通過dyld_stub_binder這個符號來做。lazyBinding的方法第一次會呼叫到dyld_stub_binder, 然後dyld_stub_binder負責找到真實的方法,並且將地址bind到樁上,下一次就不用再bind了。

針對這一步驟的優化有:

  1. 減少Objc類數量, 減少selector數量,把未使用的類和函式都可以刪掉

  2. 減少C++虛擬函式數量

  3. 轉而使用swift stuct

ObjC SetUp

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

  1. 讀取二進位制檔案的 DATA 段內容,找到與 objc 相關的資訊
  2. 註冊 Objc 類,ObjC Runtime 需要維護一張對映類名與類的全域性表。當載入一個 dylib 時,其定義的所有的類都需要被註冊到這個全域性表中;
  3. 讀取 protocol 以及 category 的資訊,把category的定義插入方法列表 (category registration),
  4. 確保 selector 的唯一性

ObjC 是個動態語言,可以用類的名字來例項化一個類的物件。這意味著 ObjC Runtime 需要維護一張對映類名與類的全域性表。當載入一個 dylib 時,其定義的所有的類都需要被註冊到這個全域性表中。

Objc的load函式和C++的靜態建構函式採用由底向上的方式執行,來保證每個執行的方法,都可以找到所依賴的動態庫

  1. dyld開始將程式二進位制檔案初始化

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

  3. 由於runtime向dyld繫結了回撥,當image載入到記憶體後,dyld會通知runtime進行處理

  4. runtime接手後呼叫mapimages做解析和處理,接下來loadimages中呼叫 callloadmethods方法,遍歷所有載入進來的Class,按繼承層級依次呼叫Class的+load方法和其 Category的+load方法

  5. 呼叫C++靜態初始化器和__attribute__((constructor))修飾的函式

到此為止,可執行檔案和動態庫中所有的符號(Class,Protocol,Selector,IM,...)都已經按照一定的格式被裝載進記憶體,被runtime管理

整個事件由dyld主導,完成執行環境的初始化後,配合ImageLoader 將二進位制檔案按格式載入到記憶體,動態連結依賴庫,並由runtime負責載入成objc 定義的結構,所有初始化工作結束後,dyld呼叫真正的main函式

這一步可以做的優化有:

  1. 使用 +initialize 來替代 +load

  2. 不要使用 attribute((constructor)) 將方法顯式標記為初始化器,而是讓初始化方法呼叫時才執行。比如使用 dispatch_once(),pthread_once() 或 std::once()。也就是在第一次使用時才初始化,推遲了一部分工作耗時。也儘量不要用到C++的靜態物件。

main階段

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

main階段的優化大致有如下幾個點:

  1. 減少啟動初始化的流程,能懶載入的就懶載入,能放後臺初始化的就放後臺,能夠延時初始化的就延時,不要卡主執行緒的啟動時間,已經下線的業務直接刪掉;

  2. 優化程式碼邏輯,去除一些非必要的邏輯和程式碼,減少每個流程所消耗的時間;

  3. 啟動階段使用多執行緒來進行初始化,把CPU的效能儘量發揮出來;

  4. 使用純程式碼而不是xib或者storyboard來進行UI框架的搭建,尤其是主UI框架比如TabBarController這種,儘量避免使用xib和storyboard,因為xib和storyboard也還是要解析成程式碼來渲染頁面,多了一些步驟;

相關文章