首頁 > 軟體

Android 記憶體優化知識點梳理總結

2022-06-16 10:00:05

前言:

Android 作業系統給每個程序都會分配指定額度的記憶體空間,App 使用記憶體來進行快速的檔案存取互動。例如展示網路圖片時,就是通過把網路圖片下載到記憶體中展示,如果需要儲存到本地,再從記憶體中儲存到磁碟空間中。

RAM 和 ROM

手機一般有兩種儲存媒介,一個是 RAM ,我們常說的記憶體,也稱之為執行記憶體;另一個是 ROM ,即磁碟空間。 RAM 的存取速度一般會比 ROM 快,它是隨插即用,斷電會抹除所有資料,RAM 越大,可同時操作的資料就越多;ROM 是外部儲存空間,相當於電腦的硬碟,主要是用來儲存本地資料的。

App 執行時,會被載入到 RAM 中,又因為 App 所在程序會分配指定額度的空間,所以 App 的記憶體空間是有限的,記憶體的大小對 App 效能及正常執行都會有很大的影響。 當 App 所分配的記憶體空間不足時,會丟擲 OOM 。所以對執行中的 App 的記憶體的優化就顯得尤為重要。

常見記憶體問題

常見的記憶體問題包括:

  • 記憶體漏失:因為 Java 物件無法被正常回收,如果長期執行程式,就會造成大量的無用物件佔用記憶體空間,最終導致 OOM。
  • 記憶體抖動:頻繁的建立物件,當物件資料到達一定程度會造成 GC ,如果短時間內頻繁的 GC 就會造成 App 卡頓的現象,這個就叫記憶體抖動。
  • 記憶體溢位:當 App 申請記憶體空間時,沒有足夠的記憶體空間供其使用,就會導致記憶體溢位,即 Out Of Memory。

記憶體溢位

記憶體溢位(Out Of Memory,簡稱OOM)是指應用系統中存在無法回收的記憶體或使用的記憶體過多,最終使得程式執行要用到的記憶體大於能提供的最大記憶體。此時 App 就執行不了,系統會提示記憶體溢位,丟擲異常。

所以避免 OOM 的辦法就是解決記憶體漏失問題,或儘量在程式碼中節約使用記憶體兩種思路。

記憶體漏失

記憶體漏失在 Android 中就是在當前App 的生命週期內不再使用的物件被GC Roots參照,導致不能回收,使實際可使用記憶體變小。 需要注意的是,記憶體漏失問題的出現,是和生命週期有關係的,從生命週期的角度考慮,就是生命週期短的物件被生命週期長的 GC Roots 物件持有參照,從而導致生命週期短的物件在該被回收的時候,無法被正確回收,該物件長期存活,但又毫無用處,白白地佔用了記憶體空間。當這種物件過多時,就會造成 OOM 。

常見記憶體漏失場景

無法回收無用物件的場景,可以統一理解為發生了記憶體漏失,常見的 case 有:

  • 資原始檔未關閉/回收
  • 註冊物件未登出
  • 靜態變數持有資料物件
  • 單例造成記憶體漏失
  • 非靜態內部類的範例持有外部類參照
  • Handler
  • 集合物件中的物件未釋放
  • WebView 記憶體漏失
  • View 的生命週期大於容器的生命週期

常見的諸如資原始檔未關閉/為回收、註冊物件未登出,導致觀察者一致持有註冊物件的參照,從而無法正常回收註冊的物件。這裡對其他幾種場景進行詳細的說明。

靜態變數或單例持有物件

在 JVM 規範中,靜態變數屬於 GC Root 其中的一種,一般情況下它的生命週期都會比較長,所以如果一個物件的某個屬性被靜態變數持有了參照,就會導致該屬性範例無法正常被回收。

以簡單的範例程式碼說明:

class TestC {
    companion object {
        var leak: Any? = null
    }
}
class LeakCanaryActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent { LeakCanaryPage(actions()) }
    staticOOM()
	}
	private fun staticOOM() {
    Toast.makeText(this, "static own Context", Toast.LENGTH_SHORT).show()
    TestC.leak = this
	}
}

當我們開啟這個LeakCanaryActivity後,返回上一個 Activity,此時檢視 Profiler 排查記憶體漏失的內容:

同樣的道理,單例模式一般也是全域性的生命週期且唯一的物件,如果被單例持有也會導致一樣的問題。

object TestB {
    var leak: Any? = null
}
// 修改 LeakCanaryActivity 的 staticOOM 方法
	private fun staticOOM() {
    Toast.makeText(this, "static own Context", Toast.LENGTH_SHORT).show()
    TestB.leak = this
	}

非靜態內部類的範例生命週期比外部類更長導致的記憶體漏失

非靜態內部類一般持有對外部類範例的參照,這個可以通過檢視 class 檔案發現,內部類的構造方法一般需要一個外部類型別的引數。所以如果一個內部類物件,生命週期更久的話就會造成記憶體漏失。 這裡一個比較明顯的例子是多執行緒操作內部類物件時,外部類的生命週期已經結束時,因為內部類範例持有外部類的參照,導致外部類範例無法被正常回收:

class LeakCanaryActivity : ComponentActivity() {
	// ... 
	// 執行這個方法
	private fun innerClassOOM() {
    Toast.makeText(this, "inner leak", Toast.LENGTH_SHORT).show()
    val inner = InnerLeak()
    Thread(inner).start()
	  finish()
	}
	// 內部類
	inner class InnerLeak: Runnable {
    override fun run() {
        Thread.sleep(15000)
    }
	}
}

當我們開啟一個 Activity 後,立刻建立一個新的執行緒執行內部類,然後立刻關閉自身,此時因為 InnerLeak 仍在子執行緒中,子執行緒在 sleep ,導致,外部類生命週期已經結束(呼叫了 finish),內部類物件 inner 仍持有外部類LeakCanaryActivity的參照。 除了這種內部類的形式,也可以用匿名內部類的形式來寫,都會導致記憶體漏失。 另一方面,不光是多執行緒的場景,如果內部類物件被靜態變數持有參照也是一樣的效果,因為他們都持有了內部類的參照,導致內部類的生命週期比外部類的生命週期更長。

Handler 導致的記憶體漏失

通過 Handler 傳送訊息時,訊息物件 Message 本身會持有 Handler 物件:

// Handler#sendMessage(Message) 會執行到 enqueueMessage 方法
private boolean enqueueMessage(@NonNull MessageQueue queue, @NonNull Message msg,
        long uptimeMillis) {
	  // 這裡把 handler 自身儲存到了 Message 的 target 屬性中了
    msg.target = this;
    msg.workSourceUid = ThreadLocalWorkSource.getUid();

    if (mAsynchronous) {
        msg.setAsynchronous(true);
    }
    return queue.enqueueMessage(msg, uptimeMillis);
}

sendMessage 方法內部呼叫到enqueueMessage(MessageQueue, Message, long)時,會把 Handler 物件自身賦值到 Message 的 target 上,這樣 message 就知道去找哪個 Handler 執行handleMessage(msg: Message)方法。也是因為這個持有,導致瞭如果訊息沒有立刻被執行,就會一直持有 Handler 物件,此時如果關閉 Activity ,就會導致記憶體漏失。

原因是 Handler 以匿名內部類或內部類的形式宣告並建立的,會持有外部 Activity 的參照。從而導致持有關係是:

Message -> Handler -> Activity

實現 Handler 記憶體漏失的程式碼:

// in LeakCanaryActivity
private fun handlerOOM() {
    val handler = object : Handler(Looper.getMainLooper()) {
        override fun handleMessage(msg: Message) {
            if (msg.what == 12)
            Toast.makeText(this@LeakCanaryActivity, "handler executed", Toast.LENGTH_SHORT).show()
        }
    }
    Thread {
        handler.sendMessageDelayed(Message().apply { what = 12 }, 10000)
    }.start()
}

操作邏輯是,在 LeakCanaryActivity 中呼叫這個方法後,立刻 finish LeakCanaryActivity ,然後檢視記憶體漏失情況:

postDelayed 導致的記憶體漏失

postDelayed 實際上是把 Runnable 封裝成了一個 Message 物件,傳入的 Runnable 引數被賦值給了 Message 的 callback :

public final boolean postDelayed(@NonNull Runnable r, long delayMillis) {
    return sendMessageDelayed(getPostMessage(r), delayMillis);
}
private static Message getPostMessage(Runnable r) {
    Message m = Message.obtain();
    m.callback = r;
    return m;
}

而最終執行邏輯的方法都是 sendMessageDelayed(Message, long),所以和 sendMessage 一樣都會導致記憶體漏失。與之不同的是,postDelayed的洩漏會多一個Message#callback因為 在呼叫postDelayed時,第一個是個匿名內部類物件,多了一個參照。

handler.postDelayed(object : Runnable {
    override fun run() {
        Log.d(TAG, "postdelay done")
    }
}, 10000)

View 的生命週期大於 Activity 時導致的記憶體漏失

一個極其簡單的記憶體漏失場景是,當我在一個 Activity 內多次彈出 Toast 時,立刻關閉當前 Activity ,就會導致記憶體漏失的情況出現:

// in LeakCanaryActivity
private fun toastOOM() {
    Toast.makeText(this, "toast leak", Toast.LENGTH_SHORT).show()
}

操作步驟:將上面的方法設定在某個點選事件中,快速連續點選幾次,然後立刻關閉當前 Activity ,檢視 Profiler:

集合中的物件未釋放導致記憶體漏失

最常見的場景是觀察者模式,觀察者模式中註冊一些觀察者物件,一般是儲存到一個全域性的集合中,如果觀察者物件在釋放時不及時登出,就會造成記憶體漏失:

object LeakCollection {
	val list = ArrayList<Any>()
}

class LeakCanaryActivity : ComponentActivity() {
	// ... 
	private fun collectionOOM() {
    LeakCollection.list.add(this)
	}
}

操作步驟:在 LeakCanaryActivity 內呼叫collectionOOM() ,然後立刻 finish 。

最常見的解決辦法就是在 Activity 的 destroy 時,從 list 清除自身的參照。

WebView 導致的記憶體漏失

網上都說 WebView 會導致記憶體漏失。通過 Profiler 直接檢視並沒有明顯的一個 Leaks 提示。那麼如何排查這個記憶體洩露呢?

一個思路是參照對比實驗:

  • 對照組 A :NoLeakActivity,一個空的 Activity,裡面沒有任何內容。
  • 對照組 B :LeakWebViewActivity, 一個包含 WebView 的 Activity 。

在同一個 Root Activity 中分別開啟 A 和 B ,通過對比記憶體變化,來證明 WebView 是否真的造成了記憶體漏失。

首先是開啟了 NoLeakActivity, 並沒有明顯的記憶體變化。

然後返回到 LeakCannaryActivity ,記憶體還是沒有變化。接著開啟 LeakWebViewActivity ,發現記憶體明顯上升,主要上升在 Native 、Others 和 Graphics 。 Graphics 可以理解,因為 loadUrl 失敗了會顯示一個失敗頁面,其中有個 icon 圖片,所以主要分析的點是 Native 和 Others 。

然後返回到 LeakCanaryActivity, 記憶體基本沒有變化。

為了證明,不是因為 NoLeakActivity 先開啟,LeakWebViewActivity 後開啟,所以記憶體中會有多餘的 NoLeakActivity 相關的記憶體佔用,我們再次開啟 NoLeakActivity ,再返回,記憶體仍無明顯變化。

所以,基本上可以證明,WebView 沒有隨著 Activity 的銷燬而被回收。

但是如何解決這種情況呢?這個問題值得後續仔細研究一下。但目前網上的各種奇怪的解決方案(例如開啟一個單獨的程序)並不是合理的辦法。 一個說法是,在 xml 裡面是有 WebView 會出現記憶體漏失,但是如果通過 addView 的形式去使用不會造成,以下是通過 addView 的形式新增 一個 WebView 物件的記憶體變化。

而這是通過 XML 的形式使用 WebView 的記憶體變化。

兩種方法好像並沒有什麼區別,但有用的一點是,這裡的記憶體變化,主要體現在 Native 上,證明 WebView 元件,會在 Native 層面生成一些內容。

這個部分的分析,後續可以再深入研究。從應用層面來看,WebView 並沒有直接觸發再 Java heap 上的記憶體漏失。而是更底層的 Native heap 中。

另外需要注意的一點是,通過 LeakCanary 並不能精準的檢測到記憶體漏失,還是得用 Profiler。

記憶體抖動

短時間內頻繁建立物件,導致虛擬機器器頻繁觸發GC操作,頻繁的 GC 會導致畫面卡頓。

解決方案

  • 儘量避免在迴圈體內建立物件,應該把物件建立移到迴圈體外。
  • 注意自定義 View 的 onDraw() 方法會被頻繁呼叫,所以在這裡面不應該頻繁的建立物件。
  • 當需要大量使用 Bitmap 的時候,試著把它們快取在陣列中實現複用。
  • 對於能夠複用的物件,同理可以使用物件池將它們快取起來。

其他優化點

基本上減少記憶體優化的其他思路就是複用和壓縮資源。

  • 圖片資源過大,進行縮放處理。
  • 減少不必要的記憶體開銷:一些基本資料型別的包裝類,例如 Integer 佔用 16 個位元組,而 int 佔用 4 個位元組,所以儘量避免使用自動裝箱的類。
  • 物件和資源進行復用。
  • 選擇更合適的資料結構,避免資料結構分配過大導致的記憶體浪費。
  • 使用int 列舉或 String 列舉代替列舉型別 ,但列舉型別也會有比前者更好的特性,需要酌情使用。
  • 使用 LruCache 等快取策略。
  • App 記憶體過低時主動清理。

App 記憶體過低時主動清理

實現 Application 中的 onTrimMemory/onLowMemory 方法去釋放掉圖片快取、靜態快取來自保。

class BaseApplication: Application() {
    override fun onLowMemory() {
        super.onLowMemory()
    }
    override fun onTrimMemory(level: Int) {
        super.onTrimMemory(level)
    }
}

到此這篇關於Android 記憶體優化知識點梳理總結的文章就介紹到這了,更多相關Android 記憶體優化 內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com