淺談App響應時間最佳化

xuexiangjys發表於2023-04-21
響應時間,它是用來衡量系統執行效率的一個重要指標。評價一個應用的響應時間,可以從使用者感知和系統效能這兩個角度來考量。

響應時間的長短,可能影響使用者對某個功能、某個應用、乃至某個系統的使用。畢竟如果有選擇,沒有哪個人會願意去使用卡頓的應用,執行慢的手機。

作為一名開發者,雖然我們平時可能只關注於堆業務,根本就沒有時間或者機會去最佳化我們程式的響應時間,但是這些內容對我們個人的技術成長是至關重要的。大的不說,這部分也是面試中經常考察的內容,知道了也不至於吃虧。

那麼接下來我們就長話短說,趕緊來瞧瞧,到底如何來最佳化我們應用的響應時間。

1. 核心原則

在演演算法中,我們經常會從時間複雜度空間複雜度這兩個緯度來衡量演演算法的優劣。

很多時候,我們無法做到時間複雜度空間複雜度兩者都最佳,只能在"時間"和"空間"中,取折中的最優解。同樣的,如果我們追求最極致的"時間"最佳,就可能需要犧牲一部分的"空間",這就是拿"空間"換"時間"的解法。

響應時間最佳化的核心:空間 -> 時間 (用空間換時間)

那麼我們應該怎麼做呢?下面是我歸納總結出來的四項基本原則:

  • 1.快取優先:能讀快取讀快取。
  • 2.減少新建:能複用絕不新建。
  • 3.減少任務:能不做的儘量不做。
  • 4.具體問題具體分析:針對具體事務本身進行分析,必須做的能提前做就提前做,不必須做的延後做。

2. 最佳化措施

可能我上面說的這些核心和基本原則,對絕大多數人來說都非常好理解,但是知道了這些,並不代表你懂得如何進行最佳化。 這就好比你高中學數學,即便告訴了你一堆的公式,但真要讓你來一道相關的應用題,你還真不一定能解得出來,這個時候"例題"就很關鍵了。

同樣的,即便你知道了一些關於應用響應時間最佳化的核心和原則後,當你真正面臨具體的最佳化問題時,你可能也會手足無措。

所以,接下來我就從任務執行資源載入資料結構執行緒/IO頁面渲染這五個角度,來給出我的最佳化建議。

2.1 任務執行

  • 1.業務/任務梳理:對業務進行拆分,對任務進行整合。
  • 2.任務轉換:序列 -> 並行, 同步 -> 非同步。
  • 3.執行順序按優先順序調整。
  • 4.延遲執行、空閒執行,如:IdleHandler

2.1.1 業務/任務梳理

業務往往是由一個個任務流組合而成。合理的業務/任務粒度可以有效提高響應的速度。

對業務和任務的梳理,正確的方式是先進行業務的拆分,將業務拆分為一個個子任務,再根據需要對子任務進行整合。

(1)對不合理的業務流進行拆分。

  • 對業務進行拆分,拆分出主要(必要)業務和次要(非必要)業務。
  • 分別對主要業務和次要業務進行優先順序評估,業務執行按優先順序從高到底依次執行。

(2)對任務流進行整合。

  • 多個相關的序列任務,可以整合為統一的業務整體。
  • 多個不相關的序列任務,可以整合為一個並行的業務。

2.1.2 任務轉換

1.序列 -> 並行的適用範圍:

  • 多個不相關的序列任務。
  • 多個任務弱相關且耗時,但是耗時接近。例如某個頁面你需要呼叫多個模組的介面查詢資料進行展示。

2.同步 -> 非同步的適用範圍:

  • 非必要(重要性不高)且耗時的任務。
  • 耗時且關聯性不大的任務。
  • 耗時且存在一定相關性的任務。使用非同步執行緒 + 同步鎖的方式執行。

2.1.3 任務優先順序

類似執行緒中的優先順序Priority,當系統資源緊張的時候,優先執行優先順序高的執行緒。

首先我們要對應用內所有需要最佳化的業務以及其子任務的優先順序進行定義,然後按優先順序順序進行排列和執行。

那麼如何才能保證任務被按優先順序進行執行呢?

1.對於執行緒,我們可以直接設定其Priority值。(但是一般我們不能直接使用執行緒,所有這個可以忽略)
2.對於執行緒池,我們可以從程式碼層將任務按優先順序順序加入到執行緒池中。注意,這裡的執行緒池最好是阻塞式的,例如:使用PriorityBlockingQueue實現的優先順序執行緒池 PriorityThreadPoolExecutor
3.使用第三方的任務執行框架,這裡推薦我開源的 XTask 供大家參考。

2.1.4 延遲執行

延遲執行,是將一些不必要、重要性不高或者高耗時的任務暫停執行,等後面資源充足或者要使用時才執行。

常見的延遲執行有以下幾種:

  • 延遲某個特定的時間執行。例如:某應用啟動後,每隔2分鐘同步一下使用者狀態。
  • 待某個特定的任務執行完成之後再執行。例如:導航應用定位獲取成功後,再執行目的地推薦獲取的任務。
  • 直接不執行,等相關業務用到的時候再執行。
  • 空閒執行,等待頁面都完全渲染完畢之後再執行。例如:使用IdleHandler,具體使用如下:
Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
    @Override
    public boolean queueIdle() {
        // 執行你的任務
        return false;
    }
});

當然,如果你想在空閒的時候執行多個任務,你也可以這樣寫:

public class DelayTaskQueue {

  private final Queue<Runnable> mDelayTasks = new LinkedList<>();

  private final MessageQueue.IdleHandler mIdleHandler = () -> {
    if (mDelayTasks.size() > 0) {
      Runnable task = mDelayTasks.poll();
      if (task != null) {
        task.run();
      }
    }
    // mDelayTasks非空時返回ture表示下次繼續執行,為空時返回false系統會移除該IdleHandler不再執行
    return !mDelayTasks.isEmpty();
  };

  public DelayTaskQueue addTask(Runnable task) {
    mDelayTasks.add(task);
    return this;
  }

  public void start() {
    Looper.myQueue().addIdleHandler(mIdleHandler);
  }
}

2.2 資源載入

  • 1.懶載入
  • 2.分段載入(部分載入)
  • 3.預載入(資料、佈局頁面等)

2.2.1 懶載入

對於一些不常用或者不重要的資料、圖片、控制元件以及其他一些資源,我們可以在用到時再進行載入。

1.資料懶載入

  • kotlin中的lazy標籤:修飾val變數,程式第一次使用到這個變數(或者物件)時再初始化。
  • Map、List和SharedPreferences等大資料的延遲初始化。

    private Map getSystemSettings() {
      if (mSettingMap == null) {
          mSettingMap = initSystemSettings();
      }
      return mSettingMap;
    }

2.圖片資源懶載入

  • 對於不常用的圖片,可以使用雲端圖片的資源url來替代。
  • 對於非程式預置的圖片(本地圖片檔案或者雲端圖片),用到時再載入。

3.控制元件懶載入

  • 使用ViewStub進行佈局的延遲載入。
  • 使用ViewPager2+Fragment進行Fragment的懶載入。
  • 使用RecyclerView替代ListView。

2.2.2 分段載入

分段載入常見應用於大資料的載入,這裡包括大圖和長影片等多媒體資源的載入。做到用到哪,載入到哪,完全不必要等全部載入完才給使用者使用。

1.大圖的分段載入:對於大圖,我們可以將其按一定尺寸進行切分,分割成一塊一塊的小瓦片,然後設定一個預覽預載入範圍,使用者預覽到哪裡我們就載入到哪裡。(就類似地圖的載入)

2.長影片的分段載入:對於長影片,我們可以將其按時間片進行拆分,並設定一個載入快取池。這樣使用者瀏覽一個長影片時,就可以快速開啟載入。

3.大檔案或者長WebView的分段載入:對於一些閱讀類的app,經常會遇到大檔案和長WebView的載入,這裡我們也可以同理對其進行拆分處理。

2.2.3 預載入

分段載入常和預載入一起組合使用。對於一些載入非常耗時的內容,我們可以將載入時機提前,從而減小使用者感知的載入時間。

預載入的本質是提前載入,這樣這個提前載入的時機就非常的關鍵和重要。因為預載入時機如果太晚,幾乎看不出效果;但是如果預載入的時機過早,有可能搶佔其他模組資源,造成資源緊張。

那麼我們何時可以觸發預載入,預載入的時機是什麼呢?下面我舉幾個簡單的例子。

1.使用者操作時。如果使用者點選了第2章,我們就開始預載入下一章和上一章;使用者上滑到了第3頁,我們預載入第4頁,使用者下滑到第5頁,我們預載入第4頁.

2.應用空閒時。例如之前說的IdleHandler。或者在onUserInteraction中監聽使用者的操作,一段時間沒有操作即視為空閒。

3.耗時等待時。對於一些常見的耗時操作,我們可以在其開始時,並行進行一些預載入操作,從而提高時間的利用率。例如Activity的建立比較耗時,我們可以在startActivity前就開始預載入資料,這樣Activity建立完之後有可能資料就已經載入好了,直接可以拿來渲染。例如一些有開屏廣告的app,可以在廣告開始時,同步進行一些資料資源的預載入。

2.3 資料結構

  • 1.資料結構最佳化(空間大小、讀取速度、複用性、擴充套件性)。
  • 2.資料快取(記憶體快取、磁碟快取、網路快取),分段快取。這裡可以參考glide.
  • 3.鎖最佳化(減少過度鎖,避免死鎖),悲觀鎖/樂觀鎖。
  • 4.記憶體最佳化,避免記憶體抖動,頻繁GC(尤其關注bitmap)

2.3.1 資料結構最佳化

不同的資料結構有不同的使用場景,選擇適合的資料結構能夠事半功倍。

1.ArrayList和LinkedList:

  • ArrayList:底層資料結構是陣列,查詢快、增刪慢。
  • LinkedList:底層資料結構是連結串列,查詢慢、增刪快。

2.HashMap和SparseArray:

  • HashMap:底層資料結構是陣列和連結串列(或紅黑樹)的組合,結合了ArrayList和LinkedList的優點,查詢快、增刪也快。但是擴容很耗效能,且空間利用率不高(75%),浪費記憶體。
  • SparseArray:底層資料結構是雙陣列,一個陣列存key,一個陣列存value。使用二分法查詢進行最佳化,在資料量小(一百條以下)的情況下,速度和HashMap相當,但是空間利用率大大提升。
  • ArrayMap:底層資料結構是雙陣列,一個陣列存key的hash值,一個陣列存value。設計與SparseArray類似,在資料量小的情況下,可完全替代HashMap。

3.Set: 保證每個元素都必須是唯一的。

4.TreeSet和TreeMap:有序的集合,保證存放的元素是排過序的,速度慢於HashSet和HashMap。

可以看到,在不考慮空間利用率的情況下,HashMap的效能是不錯的。

但是由於存在初始化大小和擴充套件因子對其效能有所影響,我們在使用時,儘量根據實際需要設定合理的初始化大小:避免設定小了擴容帶來效能消耗,設定大了造成空間浪費。

因為HashMap的預設擴容因子是0.75,如果你實際使用的數量是8,那你初始化大小就設定16;如果你實際使用的數量是60,那你初始化大小就設定128。

2.3.2 資料快取

對於一些變化不是很頻繁的資料資源,我們可以將其快取下來。這樣我們下次需要使用它們的時候,就可以直接讀取快取,這樣極大地減少了載入和渲染所需要的時間。

一般意義上的快取,按讀取的時間由快到慢,我們可分為記憶體快取、磁碟快取、網路快取。

  • 記憶體快取,就是儲存在記憶體中,我們可以直接讀取使用。而如果從介面渲染的角度,我們又可以將記憶體快取分為Active(活躍/正在顯示)快取和InActive(非活躍/不可顯示)快取。
  • 磁碟快取,就是儲存在磁碟檔案中,每次讀取都需要將磁碟檔案內容讀取到記憶體中,方可使用。
  • 網路快取,就是儲存在遠端伺服器中,每次讀取需要我們進行一次網路請求。一般來說,我們也可以將一次網路快取請求到的資料快取到磁碟中,將網路快取轉化為磁碟快取,透過減少網路請求,來提升讀取速度。

某種意義上來說,記憶體快取、磁碟快取和網路快取,它們又是可以相互轉化的,一般來說,我們會將網路快取->磁碟快取->記憶體快取,進行使用,從而提升讀取速度。

具體我們可以參考glide框架和RecyclerView的實現原理。

2.3.3 鎖最佳化

鎖是我們解決併發的重要手段,但是如果濫用鎖的話,很可能造成執行效率下降,更嚴重的可能造成死鎖等無法挽回的場景。

當我們需要處理高併發的場景時,同步呼叫尤其需要考量鎖的效能損耗:

  • 能用無鎖資料結構,就不要用鎖。
  • 縮小鎖的範圍。能鎖區塊,就不要鎖住方法體;能用物件鎖,就不要用類鎖。

那麼我們具體應該怎麼做呢?下面我簡單講幾個例子。

1.使用樂觀鎖代替悲觀鎖,輕量級鎖代替重量級鎖。

利用CAS機制, 全稱是Compare And Swap,即先比較,然後再替換。就是每次執行或者修改某個變數時,我們都會將新舊值進行比較,如果發生偏移了就更新。這就好比在一些無鎖的資料庫中,每次的資料庫操作都會攜帶一個唯一的版本號,每次進行資料庫修改的時候都會對比一下資料庫記錄和操作請求的版本號,如果版本號是最新的版本號,則進行修改,否則丟棄。

需要注意的是,CAS必須藉助volatile才能讀取到共享變數的最新值來實現【比較並交換】的效果,因為volatile會保證變數的可見性。

在Java中,JDK給我們預設提供了一些CAS機制實現的原子類,如AtomicIntegerAtomicReference等。

2.縮小同步範圍,避免直接使用synchronized,即使使用也要儘量使用同步塊而不是同步方法。多使用JDK提供給我們的同步工具:CountDownLatch,CyclicBarrier,ConcurrentHashMap。

3.針對不同使用場景,使用不同型別的鎖。

  • 針對併發讀多,寫少的,我們可以使用讀寫鎖(多個讀鎖不互斥,讀鎖與寫鎖互斥):ReentrantReadWriteLock,CopyOnWriteArrayList,CopyOnWriteArraySet。
  • 針對某一個併發操作通常由某一特定執行緒執行時,可嘗試使用偏向鎖(偏向於第一個獲得它的執行緒)。
  • 針對存在大量併發資源競爭的場景,推薦使用重量級鎖synchronized。

2.3.4 記憶體最佳化

記憶體最佳化的核心是避免記憶體抖動。不合理的記憶體分配、記憶體洩漏、物件的頻繁建立和銷燬,都會導致記憶體發生抖動,最終導致系統的頻繁GC。

頻繁的GC,必定會導致系統執行效率的下降,嚴重的可能會導致頁面卡頓,造成不好的使用者體驗。那麼我們應該著手從哪些地方進行最佳化呢?

  • 解決應用的記憶體洩漏問題。這裡我們可以使用LeakCanary 或者 Android Profile 等工具來檢查我們查詢可能存在的記憶體洩漏。
  • 平時編碼應當注意避免記憶體洩漏。如避免全域性靜態變數和常量、單例持有資源物件(Activity,Fragment,View等),資源使用完立即釋放或者recycle(回收)等。
  • 避免建立大記憶體物件,頻繁建立和釋放物件(尤其是在迴圈體內),頻繁建立的物件需要考慮複用或者使用快取。
  • 載入圖片可以適當降低圖片質量,小圖示儘量使用SVG,大圖/複雜的圖片考慮使用webp。儘量使用圖片載入框架,如glide,這些框架都會幫我們進行載入最佳化。
  • 避免大量bitmap的繪製。
  • 避免在自定義View的onMeasureonLayoutonDraw中建立物件。
  • 使用SpareArray、ArrayMap替代HashMap。
  • 避免進行大量的字串操作,特別是序列化和反序列化。不要使用+(加號)進行字串拼接。
  • 使用執行緒池(可設定適當的最大執行緒池數)執行執行緒任務,避免大量Thread的建立及洩漏。

2.4 執行緒/IO

  • 1.執行緒最佳化(統一、優先順序排程、任務特性)
  • 2.IO最佳化(網路IO和磁碟IO),核心是減少IO次數

    • 網路:請求合併,請求鏈路最佳化,請求體最佳化,系列化和反序列化最佳化,請求複用等。
    • 磁碟:檔案隨機讀寫、SharePreference讀寫等(例如對於讀多寫少的,可使用記憶體快取)
  • 3.log最佳化(迴圈中的log列印,不必要的log列印,log等級)

2.4.1 執行緒最佳化

當我們建立一個執行緒時,需要向系統申請資源,分配記憶體空間,這是一筆不小的開銷,所以我們平時開發的過程中都不會直接操作執行緒,而是選擇使用執行緒池來執行任務。所以執行緒最佳化的本質是對執行緒池的最佳化。

執行緒池使用的最大問題就在於如果執行緒池設定不對的話,很容易被人濫用,引發記憶體溢位的問題。而且通常一個應用會有多個執行緒池,不同功能、不同模組乃至是不同三方庫都會有自己的執行緒池,這樣大家各用各的,就很難做到資源的協調統一,勁不往一處使。

那麼我們應該如何進行執行緒池最佳化呢?

1.建立主執行緒池+副執行緒池的組合執行緒池,由執行緒池管理者統一協調管理。主執行緒池負責優先順序較高的任務,副執行緒池負責優先順序不高以及被主執行緒池拒絕降級下來的任務。

這裡執行的任務都需要設定優先順序,任務優先順序的排程透過PriorityBlockingQueue佇列實現,以下是主副執行緒池的設定,僅供參考:

  • 主執行緒池:核心執行緒數和最大執行緒數:2n(n為CPU核心數),60s keepTime,PriorityBlockingQueue(128)。
  • 副執行緒池:核心執行緒數和最大執行緒數:n(n為CPU核心數),60s keepTime,PriorityBlockingQueue(64)。

2.使用Hook的方式,收集應用內所以使用newThread方法的地方,改為由執行緒池管理者統一協調管理。

3.將所有提供了設定執行緒池介面的第三方庫,透過其開放的介面,設定為執行緒池管理者管理。沒有提供設定介面的,考慮替換庫或者插樁的方式,替換執行緒池的使用。

2.4.2 IO最佳化

IO最佳化的核心是減少IO次數。

1.網路請求最佳化。

  • 避免不必要的網路請求。對於那些非必要執行的網路請求,可以延時請求或者使用快取。
  • 對於需要進行多次序列網路請求的介面進行最佳化整合,控制好請求介面的粒度。比如後臺有獲取使用者資訊的介面、獲取使用者推薦資訊的介面、獲取使用者賬戶資訊的介面。這三個介面都是必要的介面,且存在先後關係。如果依次進行三次請求,那麼時間基本上都花在網路傳輸上,尤其是在網路不穩定的情況下耗時尤為明顯。但如果將這三個介面整合為獲取使用者的啟動(初始化)資訊,這樣資料在網路中傳輸的時間就會大大節省,同時也能提高介面的穩定性。

2.磁碟IO最佳化

  • 避免不必要的磁碟IO操作。這裡的磁碟IO包括:檔案讀寫、資料庫(sqlite)讀寫和SharePreference等。
  • 對於資料載入,選擇合適的資料結構。可以選擇支援隨機讀寫、延時解析的資料儲存結構以替代SharePreference。
  • 避免程式執行出現大量的序列化和反序列化(會造成大量的物件建立)。

2.5 頁面渲染

下面是我簡單列舉的幾點加快頁面渲染的方法,相信大家或多或少都用過,這裡我就不詳細闡述了:

  • 1.降低佈局層級、減少巢狀、避免過度渲染(背景)(merge,ConstraintLayout)
  • 2.頁面複用(include)
  • 3.頁面懶載入
  • 4.佈局延遲載入(ViewStub)
  • 5.inflate最佳化(佈局預載入+非同步載入,動態new控制元件/X2C)
  • 6.動畫最佳化(注意動畫的執行耗時和記憶體佔用,不可見時暫停動畫,可見時再恢復動畫)
  • 7.自定義view最佳化(減少onDraw、onLayout、onMeasure的物件建立和執行耗時)
  • 8.bitmap和canvas最佳化(bitmap大小、質量、壓縮、複用;canvas複用:clipRect,translate)
  • 9.RecycleView最佳化(減少重新整理次數,快取複用)

3. 推薦工具

最後

還是那句話,百聞不如一見,百見不如一試。寫了這麼多,我還是希望大家在平時開發的過程中,多重視一些應用響應時間最佳化的相關技巧,讓我們開發出流暢順滑的應用吧。(儘管很多時候,我們所謂的最佳化會被產品或者設計diss)

我是xuexiangjys,一枚熱愛學習,愛好程式設計,勤于思考,致力於Android架構研究以及開源專案經驗分享的技術up主。獲取更多資訊,歡迎微信搜尋公眾號:【我的Android開源之旅】

相關文章