Android 內存優化知識點梳理總結

前言:

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 內存優化 內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: